惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy International News Feed
云风的 BLOG
云风的 BLOG
E
Exploit-DB.com RSS Feed
GbyAI
GbyAI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
SegmentFault 最新的问题
B
Blog
Schneier on Security
Schneier on Security
Scott Helme
Scott Helme
美团技术团队
博客园 - Franky
S
Security @ Cisco Blogs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Google Online Security Blog
Google Online Security Blog
G
GRAHAM CLULEY
The GitHub Blog
The GitHub Blog
AI
AI
Recorded Future
Recorded Future
博客园 - 三生石上(FineUI控件)
The Cloudflare Blog
Y
Y Combinator Blog
N
Netflix TechBlog - Medium
博客园_首页
C
Check Point Blog
Hacker News: Ask HN
Hacker News: Ask HN
Jina AI
Jina AI
Cyberwarzone
Cyberwarzone
酷 壳 – CoolShell
酷 壳 – CoolShell
P
Palo Alto Networks Blog
T
Troy Hunt's Blog
AWS News Blog
AWS News Blog
L
LangChain Blog
Help Net Security
Help Net Security
I
Intezer
W
WeLiveSecurity
D
Docker
H
Hacker News: Front Page
P
Proofpoint News Feed
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
小众软件
小众软件
S
Schneier on Security
G
Google Developers Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
V
V2EX - 技术
N
News and Events Feed by Topic
C
CERT Recently Published Vulnerability Notes
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Blog — PlanetScale
Blog — PlanetScale

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Чтобы ваши тесты работали быстрее, нужен простой советский… xdist. Я измерил. Часть 2
Sergey Natalenko · 2026-06-15 · via Все публикации подряд на Хабре

Средний

9 мин

22

Дисклеймер. Продолжение части 1, тот же проект: Litestar + SQLAlchemy + Postgres + Redis, теперь 3477 тестов, почти все интеграционные, в настоящую БД. Замеры локальные (MacBook Pro M4 Max, 10 P-ядер + 4 E-ядра), медиана из трёх прогонов. Абсолютные секунды у вас будут другими — интересно, во сколько раз и где параллелизм врёт.

В части 1 я тремя правками инфраструктуры свёл сьют с получаса до полутора минут — и почти всё дала одна правка про scope фикстур. В конце я отмахнулся от самого очевидного вопроса одной строкой:

«Параллельный запуск, pytest-xdist. Самый очевидный кандидат — и он у нас в работе. Когда доведём до стабильного состояния и замерим, будет вторая часть».

В комментариях @dogekiller21 ровно это и попросил: «Жду исследования и результаты по работе с xdist в этом стэке». Вот оно.

Интуиция простая: 14 ядер простаивают, пока тесты ждут сокеты — кажется, -n auto поделит время на 10 и закроет вопрос. Я так и думал. На деле потолок оказался ×3,4, а не ×10, но главное даже не в этом. Главное: xdist дал свой выигрыш не вместо правок из части 1, а поверх них. Это второй множитель, а не замена первому — и весь остальной текст про то, где этот множитель упирается в потолок и почему «дать машине ресурсов» его не двигает.


Чего я ждал (чтобы потом сверить с замером)

Гипотезы я записал до прогонов — иначе постфактум подгонишь объяснение под любой график:

  1. Сьют I/O-bound (64 % времени — ожидание сокетов, замерено в части 1), значит масштабирование не упрётся в 10 P-ядер. Воркеры в основном ждут, а не считают, — выигрыш возможен и при числе воркеров больше числа ядер.

  2. «Налог на вход» вернётся. В части 1 я убрал пересоздание окружения на каждый тест, сделав фикстуры session-scope. Но под xdist каждая session-фикстура поднимается в каждом воркере: окружение встаёт N раз. На больших N это снова станет заметным.

  3. Первая стена — max_connections. Дефолт Postgres = 100, а пул SQLAlchemy × N воркеров его пробьёт.

  4. xdist не заменяет правки части 1, а складывается с ними.

Спойлер: №1 не подтвердилась, №2 и №3 подтвердились с цифрами, №4 — да. Но сначала пришлось вообще научить тесты ехать параллельно.


Прежде чем закидывать ядрами: изоляция

pytest-xdist поднимает N процессов-воркеров (gw0, gw1, …) и раздаёт им тесты. Но у меня все тесты ходят в одну базу: запусти их параллельно — воркеры затирают данные друг друга и дерутся за одни и те же строки (блокировки, ожидания, deadlock’и с откатом). Прогон выходит и недетерминированным, и медленным. Без изоляции параллелизм не ускоряет, а ломает.

Решение — база на воркера. xdist отдаёт каждому воркеру его worker_id, и фикстуры разводят ресурсы по нему:

@pytest.fixture(scope="session")
def db_config(worker_id: str) -> DatabaseConfig:
    # "master" (без xdist) -> базовая БД; gwN -> своя БД lms_gwN
    base = environ.get("APP_DATABASE_NAME", "lms")
    database = base if worker_id == "master" else f"{base}_{worker_id}"
    return DatabaseConfig(..., database=database, pool_size=5, max_overflow=5)

@pytest.fixture(scope="session")
def redis_config(worker_id: str) -> RedisConfig:
    # каждый воркер — свой логический номер БД Redis: master->0, gwN->N+1
    db_index = 0 if worker_id == "master" else int(worker_id.removeprefix("gw")) + 1
    return RedisConfig(redis_dsn=f"{base}/{db_index}")

У Redis 16 логических БД по умолчанию — на 20 воркеров не хватит, поднял --databases 64. У NATS аналога логических БД нет вовсе — там пришлось разводить префиксами субджектов (это грабля, отметил для себя).

Подвох — стена из гипотезы №3. Бюджет соединений: pool_size + max_overflow на воркер, умножить на N. У меня 5 + 5 = 10 на воркер. На 8 воркерах — 80, ещё терпит дефолтные 100. На 10 — уже 100 впритык, на 12+ — пробой: FATAL: sorry, too many clients already, и часть тестов падает не по своей вине. Лечится одним из двух: урезать пул на воркера или поднять max_connections. Я поднял до 300 (-c max_connections=300) и оговариваю это явно — это единственное отступление от «Postgres из коробки» во всём эксперименте. 20 воркеров × 10 = 200 < 300, с запасом.

Только теперь можно мерить.


Ось A: кривая «воркеры → время»

Базовая команда — pytest ./tests -p no:randomly, дальше добавляю -n и --dist load. Базовая линия — -n 0 (xdist загружен, но воркеров нет), не -p no:xdist: выгрузка плагина убирает фикстуру worker_id, от которой теперь зависит весь конфиг, и сьют просто не собирается.

Дефолтная Docker VM, дефолтный Postgres, очистка между тестами — DELETE (это важно, вернёмся):

Воркеры

Время (медиана)

Ускорение

serial (-n 0)

94 с

×1,00

-n 1

97 с

×0,97

-n 2

57 с

×1,65

-n 4

38 с

×2,47

-n 6

32 с

×2,94

-n 8

29 с

×3,24 (колено)

-n 10

29 с

×3,24

-n 12

29 с

×3,24

-n 14

28 с

×3,36 (лучшее)

-n 16

30 с

×3,13

-n 20

31 с

×3,03

Кривая «воркеры → время»: реальная кривая упирается в полку 28–29 с с восьми воркеров, тогда как идеальная (T/N) продолжала бы падать

Кривая «воркеры → время»: реальная кривая упирается в полку 28–29 с с восьми воркеров, тогда как идеальная (T/N) продолжала бы падать

Serial здесь — 94 с, а часть 1 закончилась на 109 с, хотя тестов стало больше (3316 → 3477). Скорее всего сработала гигиена замера: тут каждый прогон идёт на свежем контейнере с чистым каталогом Postgres, а в части 1 инстанс жил долго, и его каталог пух от тысяч CREATE/DROP DATABASE за время экспериментов. Похоже, чистка диска и пересоздание контейнера ускорили даже обычный последовательный прогон — несмотря на +5 % тестов. Но в лоб эти два числа сравнивать всё равно нельзя: разные замеры, разное окружение.

Что видно по кривой:

  • -n 1 медленнее serial (97 против 94 с) — это не «бесплатный xdist», а его честный оверхед: master плюс отдельный процесс-исполнитель и протокол execnet между ними. Окупаться начинает с двух воркеров.

  • Колено на -n 8, дальше полка до -n 14 (28–29 с): 10 P-ядер насыщаются, E-ядра добивают мелочь в окнах ожидания.

  • За полкой — деградация (-n 16/-n 20 → 30–31 с) — гипотеза №1 не подтвердилась, «выиграть за пределами числа ядер» не вышло. Считаем, кто делит 14 ядер: N процессов pytest плюс до 10 backend-процессов Postgres на воркера (Postgres форкает процесс на коннект) — на -n 20 это потенциально под две сотни соединений. Лишние воркеры уже не добавляют параллелизма, а отнимают CPU у тестов и у обслуживающих их backend’ов.

По-простому: -n auto (=14 на этой машине) даёт 28 секунд — попадает в оптимум без подбора N, полка широкая.


Потолок ×3,36, а не ×10. Куда делись ядра

Идеальное масштабирование на 10 P-ядрах дало бы ~×10 (94/10 ≈ 9 с). Я получил ×3,36 (28 с). Где разница?

Это вернувшаяся гипотеза №2 — налог на вход, только теперь умноженный на воркеров. Каждый из 14 воркеров при старте поднимает своё окружение: создаёт свою БД, накатывает схему (create_all), поднимает пул к Postgres, пул Redis, инстанс приложения. В серийном прогоне это делается один раз. На 14 воркерах — 14 раз, и эти 14 setup’ов конкурируют за один диск и один Postgres. Плюс общий контеншн: 14 процессов делят пропускную способность одной БД.

То есть параллелизм не бесплатен ровно там, где часть 1 была так хороша: session-scope сделал setup однократным на процесс, но xdist возвращает «на процесс × N». Лечится это дешёвым per-worker setup — клонированием базы из заранее собранного шаблона (CREATE DATABASE ... TEMPLATE) вместо create_all в каждом воркере. Это отдельная история, и она упирается в ту же стену, что и ответ на один комментарий из части 1, — к нему сейчас и перейдём.

Но главный вывод уже здесь: «закидать ядрами» — не замена правкам части 1, а добавка к ним. Одна правка про scope дала ×8. Весь параллелизм поверх неё — ещё ×3,4. Перемножается, не заменяет.


Что ещё я проверил: память Docker и тюнинг Postgres

Раз сьют упёрся в потолок, следующая мысль — «дать машине больше ресурсов». Проверил два популярных рычага.

Три плеча (дефолт VM, VM 16 ГиБ, PG с fsync=off+tmpfs) почти совпадают; тюнингованное даже выше на малом числе воркеров

Три плеча (дефолт VM, VM 16 ГиБ, PG с fsync=off+tmpfs) почти совпадают; тюнингованное даже выше на малом числе воркеров

Память Docker VM. Прогнал кривую на дефолтной VM (~8 ГБ) и на 16 ГиБ / 14 CPU. Дельта везде ±2 %, без тенденции — ровно ноль. Суммарный RSS Postgres + Redis < 1 ГБ, остальное page cache, которого хватает и в 8 ГБ.

Тюнинг Postgres. Народный приём: fsync=off, synchronous_commit=off, full_page_writes=off, данные на tmpfs. Durability в тестах не нужна — звучит как чистый выигрыш.

Звучит-то звучит, но первый замер я провёл неправильно — и причина оказалась в части 1.


Как я выбросил первый прогон

Первый заход показал, что тюнинг помогает, особенно на serial (−20 %). Я почти записал это в статью, но серийная цифра царапала: она была выше 109 с из части 1, хотя оптимизации те же.

Причина нашлась в коде ветки. Ветка с xdist отщепилась от master раньше, чем туда въехала правка TRUNCATE → DELETE, и тащила старую очистку через TRUNCATE ... RESTART IDENTITY CASCADE. То есть она случайно вернула ровно ту проблему, которую первая часть уже закрыла: там я доказал замером, что TRUNCATE медленнее и нестабильнее DELETE, — а тут снова мерил на нём. А TRUNCATE ... CASCADE — DDL с тяжёлой блокировкой и записью в каталог на каждом тесте, как раз то, что durability-тюнинг и tmpfs удешевляют. Я мерил не «помогает ли тюнинг тестам», а «помогает ли он моему багу».

Вернул DELETE, перегнал всё. На корректной базе:

Воркеры

Дефолтный PG

Тюнингованный PG

Дельта

serial

94 с

99 с

−5 %

-n 2

57 с

63 с

−11 %

-n 4

38 с

41 с

−8 %

-n 8

29 с

30 с

−3 %

-n 14

28 с

28 с

0 %

-n 20

31 с

31 с

0 %

Тюнинг не выиграл ни одного конфига, а на малом N проиграл 5–11 %: с лёгким DELETE durability-тюнингу нечего удешевлять, а tmpfs-контейнер только добавляет накладных. На плато всё тонет в дележе CPU.

Поймал я это лишь потому, что сверил серийную цифру с прошлой статьёй. Урок не про Postgres, а про замер: состояние кода — переменная не хуже числа воркеров. И заодно — часть 1 тут не исторический контекст, а рабочая база: именно её цифра поймала регрессию.


Ответ на комментарий: чем чистить базу между тестами

Раз речь зашла об очистке — закрою комментарий из части 1. @Tishka17 предложил третий вариант: не вычищать таблицы, а пересоздавать базу из шаблона — полная изоляция, ноль протечек.

«Как насчет drop database + create database … TEMPLATE? может быть быстрее транкейта».

«Быстрее транкейта» — проверяемое утверждение. Микробенчмарк на реальной схеме (53 таблицы, пустые — состояние «между тестами»), 300 циклов, медиана за цикл:

Стратегия очистки

Время/цикл

Разброс (max)

vs DELETE

DELETE FROM по таблицам, 1 транзакция

9.2 мс

11.9 мс (ровно)

×1,00

TRUNCATE ... RESTART IDENTITY CASCADE

12.6 мс

34.7 мс (скачет)

×1,36

DROP DATABASE + CREATE ... TEMPLATE (per-test)

33 мс

67 мс

×3,6

Очистка БД между тестами: DELETE 9.2 мс и с узким разбросом, TRUNCATE 12.6 мс с прыгающим хвостом, per-test TEMPLATE 33 мс

Очистка БД между тестами: DELETE 9.2 мс и с узким разбросом, TRUNCATE 12.6 мс с прыгающим хвостом, per-test TEMPLATE 33 мс

  • DELETE — дешевле и стабильнее всех (max 11.9 мс). Подтверждает вывод части 1 на изолированном замере.

  • TRUNCATE — +36 % к медиане и прыгающий хвост (max ×2,7 медианы): DDL-блокировка плюс работа с каталогом, цена зависит от его состояния.

  • TEMPLATE — ×3,6, то есть даже не быстрее транкейта. Прямой ответ Tishka17: нет. Клонирование из шаблона дешёвое, но CREATE DATABASE ... TEMPLATE требует ноль подключений к шаблону, а session-пул держит их постоянно — значит на каждый тест гасим пул и реконнектимся. Тот самый входной налог, который мы убирали всей частью 1.

На масштабе сьюта (3477 тестов, только очистка): DELETE ≈ 32 с против TEMPLATE ≈ 116 с. Отсюда выбор под xdist: база-на-воркера, созданная один раз (вот здесь TEMPLATE и уместен — клонируешь N баз на старте), а между тестами — DELETE. База-на-тест честнее, но платит реконнектом, и для типового сьюта это дорого.


Гигиена замеров под матрицу прогонов

Я гонял 99 прогонов подряд (3 конфигурации Postgres/VM × 11 точек по воркерам × 3 повтора). На такой матрице вылезает то, чего на одном прогоне не видно.

Контейнеры пересоздаются перед каждым прогоном. Не один раз на старте, а перед каждой точкой: docker rm -fv + свежий Postgres + Redis. Причина — дрейф состояния: сотни CREATE/DROP DATABASE, распухание каталога, WAL, autovacuum. Без пересоздания поздние прогоны мерятся на «уставшем» инстансе, и смещение коррелирует с порядком — то есть подмешивается прямо в результат.

Что я поймал, пока это настраивал:

  • Утечка томов. Образ postgres:18 объявляет VOLUME, и docker rm -f без -v оставляет анонимный том. 70 прогонов × ~2 ГБ — и 142 ГБ забили диск VM, плечо упало с «No space left». Первый заход на забивающемся диске дал красивое «ложное колено» с обвалом после -n 10 — чистый артефакт, которого на чистом диске нет.

  • OOM демона. Контейнеры без лимита памяти на ~17-м цикле пересоздания уронили сам демон Docker, после чего харнесс молча записывал мусор. Лечится --memory-кэпами: голодающий контейнер умирает сам, а не валит весь демон.

  • Сон ноутбука. Без caffeinate macOS усыпил машину посреди матрицы; monotonic-часы pytest во сне стоят, wall — нет. Критерий валидности прогона: wall − pytest < 15 с, иначе строка — мусор.

Звучит как паранойя, но половина этих эффектов смещает цифры в одну сторону и коррелирует с порядком прогонов — то есть выглядит как настоящий тренд. Свежий контейнер на каждый прогон — единственный способ не перепутать дрейф инфраструктуры с эффектом, который ты измеряешь.


А что на раннерах? Там же не M4 Max

Справедливый вопрос: всё это замерено на ноутбуке с 14 ядрами. В CI раннер другой — слабее, общий. Что там?

Я выкачал длительность стадии test из GitLab — и сразу наступил на грабли усреднения. Брать «среднее за период» нельзя: за два месяца сьют вырос вдвое, в конце мая въехали правки части 1 (serial обвалился, потом пополз вверх вместе с числом тестов), да ещё до мая джобы ловил второй, более старый раннер (~13 минут), который потом перестал брать test-джобы. Среднее смешало бы три размера сьюта на двух раннерах. Сравнивать честно можно только like-for-like: один раннер, один сьют, соседние даты.

Беру окно перед самым xdist — тот же раннер, текущий (удвоившийся) сьют:

Стадия test в CI (один раннер, текущий сьют)

Время

до xdist (serial, 13 прогонов, 07–11.06)

медиана 449 с (~7.5 мин), разброс 410–480

после xdist (-n auto, первые прогоны 12.06)

166 с (~2.8 мин), ранний нестабильный — 233 с

Стадия test в CI на одном раннере и текущем сьюте: serial ~449 с против ~166 с с -n auto, ×2,7

Стадия test в CI на одном раннере и текущем сьюте: serial ~449 с против ~166 с с -n auto, ×2,7

Внутри фиксированного сьюта и раннера серийный прогон вполне стабилен (410–480 с) — «дикий разброс» в CI оказался не лотереей, а наложением растущего сьюта, влитых оптимизаций и сменившегося раннера во времени.

Грубо ×2,7 — заметно, но меньше локального ×3,4. Причина ровно та же, что упёрла локальную кривую: -n auto берёт столько воркеров, сколько ядер у раннера, а их там далеко не 14. Зато масштабируется без ручного подбора -n: дадут раннер жирнее — -n auto сам его утилизирует, конфиг не трогать.


Итог

Что проверял

Ожидание

Замер

Масштабирование по воркерам

×10 на 10 ядрах

×3,36 (n14), плато с n8

-n 1 vs serial

«бесплатно»

−3 % (честный оверхед execnet)

Oversubscription (n16–20)

выигрыш на I/O-bound

деградация

Память Docker VM (8→16 ГиБ)

быстрее

ноль

Тюнинг Postgres (fsync off + tmpfs)

быстрее

−5…−11 % на малом N, ноль на плато

xdist vs правки части 1

замена

добавка (×8 × ×3,4)

Главное в одной фразе: параллелизм — это второй множитель, а не первый. Сначала уберите налог на вход (scope фикстур, часть 1) — это даёт порядок величины и стоит нескольких правок в фикстурах. Потом xdist добавит ещё ×3–3.5, и потолок вам поставит не число ядер, а тот же налог на вход, вернувшийся как «setup × N воркеров» плюс контеншн на общей БД. А «дай машине ресурсов» и «затюнь Postgres» для типового интеграционного сьюта — мимо.

Что забрать в проект:

  1. Сначала изоляция, потом -n. База-на-воркера через worker_id, свой индекс Redis, префиксы там, где логических БД нет (NATS). Без изоляции параллельный сьют «зелёный» ровно до первого флака.

  2. Посчитайте бюджет соединений. (pool_size + max_overflow) × N против max_connections — иначе случайные too many clients.

  3. Снимите кривую по -n, а не одну точку. Колено и полка видны только на ней; -n auto обычно попадает в полку.

  4. Базовая линия — -n 0, не -n 1 (-n 1 показывает оверхед самого xdist).

  5. Свежая инфраструктура на каждый прогон матрицы. Иначе дрейф состояния притворится трендом.


Скрипты (run_full_experiment.sh, xdist_arm.sh, cleanup_bench.py) и сырые CSV — в репозитории к статье: andy-takker/slow-tests-benchmark.