Джоб-системы и параллелизм
Механизм: граф задач под жёсткий дедлайн
Бюджет кадра и почему параллелить
60 fps = 16.67 мс на кадр (8.3 мс при 120): в них обязаны влезть ввод, геймплей, физика, анимация, culling, подача рендер-команд. Не влез — дропнутый кадр. А CPU 2026 — 8–16 логических ядер; игра в один поток использует одно и оставляет 90% компьюта на столе. Поэтому современные движки построены вокруг джоб-системы: обновление сцены, физика, AI, формирование рендер-команд — всё идёт джоб-графами по ядрам. Однопоточный геймплей-код в многопоточном движке — самый частый боттлнек ААА.
Джоб-система: DAG + work-stealing
Работа дробится на мелкие задачи (jobs), каждая объявляет что читает и что пишет. Планировщик строит граф зависимостей (задача B ждёт A, если читает её выход) и гонит независимые задачи параллельно по воркер-потокам. Балансировка — work-stealing: у каждого воркера своя очередь, простаивающий крадёт задачу из хвоста чужой. Два доминирующих подхода:
- Job-based (Unity Jobs + Burst, Bevy, EnTT): объявляешь задачи с зависимостями, Burst/Rust компилируют их в near-zero-overhead SIMD-код.
- Fiber-based (движки Naughty Dog — Uncharted/Last of Us): лёгкие кооперативные корутины поверх потоков, задача-фибра yield'ит, ожидая зависимость. Канон — доклад Кристиана Гирлинга «Parallelizing the Naughty Dog Engine Using Fibers» (GDC 2015).
Закон Амдала — потолок ускорения
Добавить ядра не значит ускориться линейно. Если доля работы принципиально последовательна (не параллелится — синк-точки, зависимости, главный поток), ускорение на ядрах:
Пример: (10% серийного), → 1/(0.1 + 0.9/8) ≈ 4.7×, не 8×. А при потолок — ×, сколько ядер ни дай. Вывод: цель — уменьшать последовательную долю (обычно это главный поток и синхронизация), а не просто накидывать ядра. Amdahl — жестокий: 5% серийного каппит на 20×.
Цена — корректность: гонки данных
Параллельные задачи, пишущие в общее изменяемое состояние, дают гонки данных (data races) — два потока пишут одно, результат неопределён; это худшие баги (недетерминированные, «плавающие», исчезающие под отладчиком). Синхронизация (мьютексы, атомики) чинит корректность, но добавляет contention — потоки ждут друг друга, а ожидание = сериализация = ухудшает Амдала. Чистое решение — data-parallelism без общих записей: партиционируй данные так, чтобы каждая задача владела своим срезом. Это и есть смысл ECS / data-oriented: системы читают/пишут непересекающиеся компоненты, планировщик видит независимость и параллелит безопасно. Тонкая ловушка — false sharing: два потока пишут разные данные на одной кэш-линии → она пинг-понгует между ядрами, убивая скорость без всякой логической гонки.
Профилирование: мерь, не гадай
Оптимизировать нельзя то, что не измерил. Индустриальный стандарт 2020-х — Tracy (FOSS, id/CDPR/Naughty Dog/сотни инди): размечаешь функции макросом ZoneScoped → Tracy рисует по-кадровую диаграмму Ганта CPU+GPU+локов на одном таймлайне. Цикл: разметил → запустил → нашёл кадры >16 мс → провалился в худшую зону → оптимизировал → перемерил. Гадать, «где тормозит», — классическая ошибка: боттлнек почти всегда не там, где кажется.
🕹 Что включить — и что заметить
Параллелизм виден в загрузке ядер и в профайлере — и в том, где у игры «сим-боттлнек».
Симуляционно-тяжёлые игры упираются в CPU: наращиваешь число сущностей (заводы, агенты) — FPS/UPS падает, потому что симуляция ограничена потоком, а не GPU. Идеальный стенд «широкий CPU против последовательной симуляции».
🎮 Сделай: в Factorio/Cities раздуй базу/город и открой диспетчер задач: заметь, все ядра грузятся или одно? Многие симуляции долго были однопоточными (Амдал: серийная симуляция не параллелится) — увидишь одно ядро в потолке при простаивающих остальных. Это ровно «90% машины на столе».
В Tracy/Unity Profiler/UE Insights видно главный поток и воркеры на одном таймлайне: где джобы параллелятся, где всё ждёт синк-точку (последовательный «перешеек» — доля Амдала).
🎮 Сделай: открой профайлер любой игры/движка и найди синк-точки — моменты, где все воркеры простаивают, ожидая один поток. Это и есть последовательная доля, каппящая ускорение. Прикинь: убери её — насколько вырастет параллелизм?
Одна и та же игра может тормозить по разным причинам: FPS падает в «людных» сценах (много сущностей/физики → CPU/джобы) или на высоком разрешении (→ GPU/fill). Диагноз меняет, что оптимизировать.
🎮 Сделай: в игре опусти разрешение вдвое. FPS вырос сильно → ты был GPU-bound. Почти не изменился → CPU-bound (джобы/геймплей/draw calls). Это 30-секундный тест, определяющий, где вообще искать проблему — в джоб-системе или в рендере.
Хардкор · теория: Амдал, Густафсон и последовательный перешеекможно пропустить
Почему серийная доля так больно каппит
Амдал: . Параллельная часть сжимается с ростом , а серийная — нет, поэтому в пределе она доминирует: при потолок 20×, при 0.2 — всего 5×. Отсюда инженерный приоритет: не «сколько ядер», а «какова серийная доля» — синк-точки, глобальные локи, зависимости «всё ждёт main thread». Убрать 1% серийного часто ценнее удвоения ядер.
Густафсон — обратная сторона
Амдал фиксирует размер задачи. Густафсон замечает: на практике с бóльшим железом мы решаем бóльшие задачи (больше сущностей, детальнее физика), и там параллельная часть растёт с , а серийная почти нет — ускорение по объёму лучше, чем предрекает Амдал. В играх это значит: больше ядер = не «та же игра быстрее», а «больше агентов/частиц за тот же бюджет». Оба закона верны: Амдал — про фикс-задачу, Густафсон — про растущую.
Work-stealing и балансировка
Наивное «раздать по 1/N задач каждому воркеру» ломается при неравной длине задач (один воркер закончил, другие ещё пашут). Work-stealing: свои задачи берёшь с головы своей очереди (кэш-локально), а простаивая — крадёшь с хвоста чужой (реже конфликтуешь с владельцем). Это даёт динамическую балансировку без центрального планировщика-горла и близко к оптимуму по загрузке — стандарт в Cilk, TBB, Rayon, Unity/Bevy.
Хардкор · инженерия: гонки, false sharing и job vs fiberможно пропустить
Почему гонки — худшие баги
Data race недетерминирована: зависит от точного тайминга потоков, поэтому воспроизводится 1 раз из 1000, исчезает под отладчиком (меняет тайминг — heisenbug) и на другой машине ведёт себя иначе. Санитайзеры (TSan) и модель «нет разделяемых записей» — единственная надёжная защита. Правило: разделяемое изменяемое состояние — источник всех гонок; убери изменяемость (иммутабельность) или разделяемость (партиционирование данных) — и гонок нет по построению. Это причина, по которой ECS и Rust (borrow checker) так дружат с параллелизмом.
False sharing
Кэш работает линиями (~64 байта). Если два потока пишут разные переменные, случайно легшие на одну линию, каждая запись инвалидирует линию у другого ядра → она пинг-понгует по шине, и «независимые» потоки тормозят друг друга без логической гонки. Лечится выравниванием/паддингом горячих на-поток данных по границе кэш-линии. Классическая ловушка «параллелил, а стало медленнее».
Job vs fiber
Job: задача — короткая функция без блокировок; зависимости выражены графом, планировщик гонит готовые. Просто, предсказуемо, дружит с data-oriented. Fiber: задача может yield посреди себя, ожидая зависимость, и возобновиться позже на любом потоке — удобно для сложных цепочек ожиданий (Naughty Dog), но сложнее (ручное управление стеками фибр, тонкости с thread-local). Выбор — простота/data-oriented (job) против гибкости сложных зависимостей (fiber).
ML / AI (твой домен): это дословно распределённый трейнинг/инференс. Закон Амдала каппит масштабирование обучения: серийная/коммуникационная доля (all-reduce-синк, страгглер — самый медленный воркер гейтит синк) не даёт линейного роста от добавления GPU — почему «в 2× больше GPU» ≠ «в 2× быстрее». Data-parallelism без общих записей = data-parallel training (каждый GPU — свой шард батча, синк градиентов) — тот же принцип непересекающихся данных, что в джоб-системе. Work-stealing/балансировка = динамическая балансировка и проблема страгглеров. Гонки/синхронизация = консистентность в async-SGD (staleness, lock-free). Бюджет кадра (жёсткий дедлайн) = латентный бюджет реал-тайм-инференса. А «мерь, не гадай» = перф-дисциплина ML: профилируй тренинг-луп — боттлнек часто не в матмуле, а в data loading / host→device (тот же «последовательный перешеек»). «Машина широкая, не быстрая» = вся суть ускорителей.
Бэкенд / распределённые: DAG-планировщики (Airflow, task graphs), пулы воркеров и work-stealing, гонки и локи, критическая секция как серийный перешеек; масштабируется не то, что параллельно, а то, у чего мала серийная доля.
Перформанс в целом: профилируй перед оптимизацией; ускоряй серийный путь (Амдал), а не только добавляй параллелизм; избегай разделяемого изменяемого состояния.
Принцип: добавить ядра/GPU помогает лишь настолько, насколько мала последовательная доля и координация. Минимизируй серийный путь, партиционируй данные, измеряй — тогда параллелизм окупается.
ZoneScoped в паре систем, запусти, найди кадр >16 мс и провались в худшую зону. Найди синк-точки (все воркеры простаивают) — это серийная доля. Прикинь по Амдалу: какое ускорение даст текущая на 8 ядрах, и что будет, если серийный кусок убрать.Почему однопоточный геймплей — «главный боттлнек», а не медленный шейдер или физика?
Что реально ограничивает ускорение от добавления ядер?
Почему гонки данных — «худшие» баги и как ECS их избегает?
Зачем work-stealing, если можно просто раздать по 1/N задач каждому?
CPU-bound или GPU-bound — почему это первый вопрос оптимизации?
- Christian Gyrling, «Parallelizing the Naughty Dog Engine Using Fibers» (GDC 2015) — канон fiber-based ААА.
- Bevy scheduler (Rust) — чистейший образовательный референс job-based с data-oriented.
- Tracy (github.com/wolfpld/tracy) — профайлер и его workflow; Optick/Superluminal/Nsight как альтернативы.
- Amdahl (1967) + Gustafson (1988) — оригиналы о пределах параллелизма.
- Модуль 8, «Job systems and task graphs» + «Tracy and modern profiling» (
08-technical-deep-dives.md).