Дисклеймер. Продолжение части 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, а поверх них. Это второй множитель, а не замена первому — и весь остальной текст про то, где этот множитель упирается в потолок и почему «дать машине ресурсов» его не двигает.
Чего я ждал (чтобы потом сверить с замером)
Гипотезы я записал до прогонов — иначе постфактум подгонишь объяснение под любой график:
Сьют I/O-bound (64 % времени — ожидание сокетов, замерено в части 1), значит масштабирование не упрётся в 10 P-ядер. Воркеры в основном ждут, а не считают, — выигрыш возможен и при числе воркеров больше числа ядер.
«Налог на вход» вернётся. В части 1 я убрал пересоздание окружения на каждый тест, сделав фикстуры session-scope. Но под xdist каждая session-фикстура поднимается в каждом воркере: окружение встаёт N раз. На больших N это снова станет заметным.
Первая стена —
max_connections. Дефолт Postgres = 100, а пул SQLAlchemy × N воркеров его пробьёт.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 ( | 94 с | ×1,00 |
| 97 с | ×0,97 |
| 57 с | ×1,65 |
| 38 с | ×2,47 |
| 32 с | ×2,94 |
| 29 с | ×3,24 (колено) |
| 29 с | ×3,24 |
| 29 с | ×3,24 |
| 28 с | ×3,36 (лучшее) |
| 30 с | ×3,13 |
| 31 с | ×3,03 |

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

Память 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 % |
| 57 с | 63 с | −11 % |
| 38 с | 41 с | −8 % |
| 29 с | 30 с | −3 % |
| 28 с | 28 с | 0 % |
| 31 с | 31 с | 0 % |
Тюнинг не выиграл ни одного конфига, а на малом N проиграл 5–11 %: с лёгким DELETE durability-тюнингу нечего удешевлять, а tmpfs-контейнер только добавляет накладных. На плато всё тонет в дележе CPU.
Поймал я это лишь потому, что сверил серийную цифру с прошлой статьёй. Урок не про Postgres, а про замер: состояние кода — переменная не хуже числа воркеров. И заодно — часть 1 тут не исторический контекст, а рабочая база: именно её цифра поймала регрессию.
Ответ на комментарий: чем чистить базу между тестами
Раз речь зашла об очистке — закрою комментарий из части 1. @Tishka17 предложил третий вариант: не вычищать таблицы, а пересоздавать базу из шаблона — полная изоляция, ноль протечек.
«Как насчет drop database + create database … TEMPLATE? может быть быстрее транкейта».
«Быстрее транкейта» — проверяемое утверждение. Микробенчмарк на реальной схеме (53 таблицы, пустые — состояние «между тестами»), 300 циклов, медиана за цикл:
Стратегия очистки | Время/цикл | Разброс (max) | vs DELETE |
|---|---|---|---|
| 9.2 мс | 11.9 мс (ровно) | ×1,00 |
| 12.6 мс | 34.7 мс (скачет) | ×1,36 |
| 33 мс | 67 мс | ×3,6 |

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-кэпами: голодающий контейнер умирает сам, а не валит весь демон.Сон ноутбука. Без
caffeinatemacOS усыпил машину посреди матрицы; monotonic-часы pytest во сне стоят, wall — нет. Критерий валидности прогона:wall − pytest < 15 с, иначе строка — мусор.
Звучит как паранойя, но половина этих эффектов смещает цифры в одну сторону и коррелирует с порядком прогонов — то есть выглядит как настоящий тренд. Свежий контейнер на каждый прогон — единственный способ не перепутать дрейф инфраструктуры с эффектом, который ты измеряешь.
А что на раннерах? Там же не M4 Max
Справедливый вопрос: всё это замерено на ноутбуке с 14 ядрами. В CI раннер другой — слабее, общий. Что там?
Я выкачал длительность стадии test из GitLab — и сразу наступил на грабли усреднения. Брать «среднее за период» нельзя: за два месяца сьют вырос вдвое, в конце мая въехали правки части 1 (serial обвалился, потом пополз вверх вместе с числом тестов), да ещё до мая джобы ловил второй, более старый раннер (~13 минут), который потом перестал брать test-джобы. Среднее смешало бы три размера сьюта на двух раннерах. Сравнивать честно можно только like-for-like: один раннер, один сьют, соседние даты.
Беру окно перед самым xdist — тот же раннер, текущий (удвоившийся) сьют:
Стадия | Время |
|---|---|
до xdist (serial, 13 прогонов, 07–11.06) | медиана 449 с (~7.5 мин), разброс 410–480 |
после xdist ( | 166 с (~2.8 мин), ранний нестабильный — 233 с |

Внутри фиксированного сьюта и раннера серийный прогон вполне стабилен (410–480 с) — «дикий разброс» в CI оказался не лотереей, а наложением растущего сьюта, влитых оптимизаций и сменившегося раннера во времени.
Грубо ×2,7 — заметно, но меньше локального ×3,4. Причина ровно та же, что упёрла локальную кривую: -n auto берёт столько воркеров, сколько ядер у раннера, а их там далеко не 14. Зато масштабируется без ручного подбора -n: дадут раннер жирнее — -n auto сам его утилизирует, конфиг не трогать.
Итог
Что проверял | Ожидание | Замер |
|---|---|---|
Масштабирование по воркерам | ×10 на 10 ядрах | ×3,36 (n14), плато с n8 |
| «бесплатно» | −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» для типового интеграционного сьюта — мимо.
Что забрать в проект:
Сначала изоляция, потом
-n. База-на-воркера черезworker_id, свой индекс Redis, префиксы там, где логических БД нет (NATS). Без изоляции параллельный сьют «зелёный» ровно до первого флака.Посчитайте бюджет соединений.
(pool_size + max_overflow) × Nпротивmax_connections— иначе случайныеtoo many clients.Снимите кривую по
-n, а не одну точку. Колено и полка видны только на ней;-n autoобычно попадает в полку.Базовая линия —
-n 0, не-n 1(-n 1показывает оверхед самого xdist).Свежая инфраструктура на каждый прогон матрицы. Иначе дрейф состояния притворится трендом.
Скрипты (run_full_experiment.sh, xdist_arm.sh, cleanup_bench.py) и сырые CSV — в репозитории к статье: andy-takker/slow-tests-benchmark.



















