← Модуль 8/Джоб-системы
EN
Модуль 8 · Технический deep-dive

Джоб-системы и параллелизм

Современный CPU — широкий, а не быстрый: 8–16 ядер, не одно шустрое. Игра в один поток тратит ~90% машины, и однопоточный геймплей — главное узкое место ААА. Джоб-система насыщает ядра графом задач — но параллелизм ограничен (закон Амдала) и опасен (гонки данных).
~17 мин🛠 параллелизм + 🔬 Амдал
Суть за 30 секунд
У CPU 2026 — 8–16 ядер; однопоточная игра занимает одно и оставляет ~90% простаивать (это главный перф-боттлнек ААА). Движки насыщают ядра джоб-системой: кадр дробится на мелкие задачи с явными зависимостями вход/выход → планировщик строит DAG → воркер-потоки тянут задачи из очередей, а простаивающие воруют у занятых (work-stealing). Два вкуса: job-based (Unity Jobs+Burst, Bevy) и fiber-based (Naughty Dog). Но ускорение упирается в закон Амдала: последовательная доля s ставит потолок 1/s — 10% серийного → максимум 10× при любом числе ядер. А цена — корректность: разделяемое изменяемое состояние → гонки данных (худшие баги). Поэтому проектируют под data-parallelism (независимая работа без общих записей — привет ECS/SoA) и безжалостно профилируют (Tracy: ищи кадры >16 мс, копай худшую зону). Бюджет кадра — 16.67 мс при 60 fps — жёсткий дедлайн, в который всё обязано влезть.

Механизм: граф задач под жёсткий дедлайн

Бюджет кадра и почему параллелить

60 fps = 16.67 мс на кадр (8.3 мс при 120): в них обязаны влезть ввод, геймплей, физика, анимация, culling, подача рендер-команд. Не влез — дропнутый кадр. А CPU 2026 — 8–16 логических ядер; игра в один поток использует одно и оставляет 90% компьюта на столе. Поэтому современные движки построены вокруг джоб-системы: обновление сцены, физика, AI, формирование рендер-команд — всё идёт джоб-графами по ядрам. Однопоточный геймплей-код в многопоточном движке — самый частый боттлнек ААА.

Джоб-система: DAG + work-stealing

Работа дробится на мелкие задачи (jobs), каждая объявляет что читает и что пишет. Планировщик строит граф зависимостей (задача B ждёт A, если читает её выход) и гонит независимые задачи параллельно по воркер-потокам. Балансировка — work-stealing: у каждого воркера своя очередь, простаивающий крадёт задачу из хвоста чужой. Два доминирующих подхода:

Закон Амдала — потолок ускорения

Добавить ядра не значит ускориться линейно. Если доля s работы принципиально последовательна (не параллелится — синк-точки, зависимости, главный поток), ускорение на N ядрах:

speedup(N)= 1 s+1−sN

Пример: s=0.1 (10% серийного), N=8 → 1/(0.1 + 0.9/8) ≈ 4.7×, не 8×. А при N→∞ потолок — 1/s=10×, сколько ядер ни дай. Вывод: цель — уменьшать последовательную долю (обычно это главный поток и синхронизация), а не просто накидывать ядра. 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 мс → провалился в худшую зону → оптимизировал → перемерил. Гадать, «где тормозит», — классическая ошибка: боттлнек почти всегда не там, где кажется.

🕹 Что включить — и что заметить

Параллелизм виден в загрузке ядер и в профайлере — и в том, где у игры «сим-боттлнек».

Factorio / Cities: Skylines симуляция · CPU/поток-bound

Симуляционно-тяжёлые игры упираются в CPU: наращиваешь число сущностей (заводы, агенты) — FPS/UPS падает, потому что симуляция ограничена потоком, а не GPU. Идеальный стенд «широкий CPU против последовательной симуляции».

🎮 Сделай: в Factorio/Cities раздуй базу/город и открой диспетчер задач: заметь, все ядра грузятся или одно? Многие симуляции долго были однопоточными (Амдал: серийная симуляция не параллелится) — увидишь одно ядро в потолке при простаивающих остальных. Это ровно «90% машины на столе».

Профайлер движка / Tracy таймлайн потоков

В Tracy/Unity Profiler/UE Insights видно главный поток и воркеры на одном таймлайне: где джобы параллелятся, где всё ждёт синк-точку (последовательный «перешеек» — доля s Амдала).

🎮 Сделай: открой профайлер любой игры/движка и найди синк-точки — моменты, где все воркеры простаивают, ожидая один поток. Это и есть последовательная доля, каппящая ускорение. Прикинь: убери её — насколько вырастет параллелизм?

CPU-bound vs GPU-bound диагностика бутылочного горла

Одна и та же игра может тормозить по разным причинам: FPS падает в «людных» сценах (много сущностей/физики → CPU/джобы) или на высоком разрешении (→ GPU/fill). Диагноз меняет, что оптимизировать.

🎮 Сделай: в игре опусти разрешение вдвое. FPS вырос сильно → ты был GPU-bound. Почти не изменился → CPU-bound (джобы/геймплей/draw calls). Это 30-секундный тест, определяющий, где вообще искать проблему — в джоб-системе или в рендере.

Хардкор · теория: Амдал, Густафсон и последовательный перешеекможно пропустить

Почему серийная доля так больно каппит

Амдал: speedup=1/(s+(1−s)/N). Параллельная часть сжимается с ростом N, а серийная — нет, поэтому в пределе она доминирует: при s=0.05 потолок 20×, при 0.2 — всего 5×. Отсюда инженерный приоритет: не «сколько ядер», а «какова серийная доля» — синк-точки, глобальные локи, зависимости «всё ждёт main thread». Убрать 1% серийного часто ценнее удвоения ядер.

Густафсон — обратная сторона

Амдал фиксирует размер задачи. Густафсон замечает: на практике с бóльшим железом мы решаем бóльшие задачи (больше сущностей, детальнее физика), и там параллельная часть растёт с N, а серийная почти нет — ускорение по объёму лучше, чем предрекает Амдал. В играх это значит: больше ядер = не «та же игра быстрее», а «больше агентов/частиц за тот же бюджет». Оба закона верны: Амдал — про фикс-задачу, Густафсон — про растущую.

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).

Аналогия
Распараллелить кадр — как готовить банкет с N поварами. Блюдо режешь на задачи (нарезать, обжарить, собрать) с зависимостями (нельзя собрать до обжарки). Освободившийся повар хватает следующую готовую задачу (work-stealing). Но часть шагов принципиально последовательна — «уваривать соус» занимает столько, сколько занимает, и вторым поваром это не ускорить; вот этот серийный узел и каппит, насколько N поваров вообще помогают (Амдал). А если два повара схватят одну сковороду (общее состояние) — хаос (гонка); поэтому каждому дают свою станцию (data-parallelism). Больше поваров помогает лишь до тех пор, пока не начинают доминировать последовательные шаги и толкотня-координация.
Почему это важно
Современные CPU широкие, а не быстрые: 8–16 ядер, не одно шустрое. Однопоточная игра тратит машину, и однопоточный геймплей — главный перф-боттлнек ААА. Джоб-системы насыщают ядра, но параллелизм ограничен (Амдал: серийная доля каппит ускорение) и опасен (гонки — худшие баги). Поэтому ремесло — минимизировать серийную долю, проектировать под data-parallelism без общих записей (ECS/SoA) и безжалостно профилировать (Tracy). Это ядро systems-инженерии, и оно один-в-один переносится на любую параллельную нагрузку — включая распределённый ML.
🔁 За пределами игр — куда это переносится
Урок — это насыщение параллельной машины под дедлайн: DAG задач, Амдал, гонки, балансировка.

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 помогает лишь настолько, насколько мала последовательная доля и координация. Минимизируй серийный путь, партиционируй данные, измеряй — тогда параллелизм окупается.

🔧 Запусти и поковыряй — на домашнем компе
Во что играть — выше (🕹). Здесь — измерить параллелизм.
🔧 Поковырять (Tracy / профайлер) ~40 мин, Bevy или движок
Возьми Bevy-пример (или свой движок) с Tracy: размести ZoneScoped в паре систем, запусти, найди кадр >16 мс и провались в худшую зону. Найди синк-точки (все воркеры простаивают) — это серийная доля. Прикинь по Амдалу: какое ускорение даст текущая s на 8 ядрах, и что будет, если серийный кусок убрать.
🧪 Потестить (диагностика) ~15 мин
Для тормозящей сцены: CPU-bound или GPU-bound (тест «половина разрешения»)? Если CPU — это джобы, геймплей или draw calls? Затем найди в своём коде разделяемое изменяемое состояние между потоками — потенциальная гонка; как убрать разделяемость (партиционировать) или изменяемость (иммутабельность)?
Чеклист: разметил зоны в Tracy и нашёл худшую; нашёл синк-точку/серийную долю; посчитал Амдал для своей s и N; определил CPU/GPU-bound; нашёл разделяемое состояние и способ убрать гонку.
Связи
основа
Игровой цикл — бюджет кадра (16.67 мс) и фикс-шаг, в который вся параллельная работа обязана влезть.
основа
ECS и data-oriented — партиционирование данных по компонентам делает параллелизм безопасным (нет общих записей → нет гонок).
смежное
Рендер-конвейер — GPU-параллелизм (варпы/occupancy); здесь — CPU-параллелизм, который его кормит командами.
дальше
Аудио и DSP — аудио-микс тоже крутится на своём потоке с жёстким дедлайном буфера.
Вопросы пытливого ума
Почему однопоточный геймплей — «главный боттлнек», а не медленный шейдер или физика?
Потому что он оставляет большинство машины простаивать и становится последовательным перешейком (доля s Амдала) для всего кадра. Движок может параллелить рендер, физику, culling по 16 ядрам, но если геймплей-логика течёт в одном потоке и всё её ждёт на синк-точке, кадр упирается в неё — 15 ядер простаивают. Медленный шейдер бьёт по GPU-бюджету локально; однопоточный геймплей каппит весь кадр по Амдалу и не масштабируется с железом. Поэтому это системная, а не точечная проблема — и почему движки так давят на data-oriented геймплей, который параллелится.
Что реально ограничивает ускорение от добавления ядер?
Последовательная доля s (закон Амдала): часть работы принципиально не параллелится — синк-точки, зависимости, критические секции, единый главный поток. Параллельная часть сжимается с ростом N, серийная — нет, поэтому в пределе 1/s — потолок: 10% серийного → максимум 10× при любом числе ядер. Плюс накладные: синхронизация (потоки ждут = сериализация), contention на локах, false sharing, дисбаланс задач. Поэтому инженерный приоритет — сокращать серийную долю и координацию, а не наращивать ядра: убрать 1% серийного часто ценнее, чем удвоить ядра.
Почему гонки данных — «худшие» баги и как ECS их избегает?
Потому что они недетерминированы: зависят от точного тайминга потоков, поэтому воспроизводятся редко, исчезают под отладчиком (он меняет тайминг — heisenbug) и по-разному ведут себя на разных машинах. Обычная отладка бессильна. Корень — разделяемое изменяемое состояние: два потока пишут одно. ECS убирает разделяемость по построению: данные разложены по компонентам, а системы декларируют, какие компоненты читают/пишут, — планировщик видит, что две системы трогают непересекающиеся данные, и параллелит их безопасно; пересекающиеся — сериализует. Нет общих записей → нет гонок, причём это гарантия структуры, а не дисциплины программиста (та же идея у borrow checker в Rust).
Зачем work-stealing, если можно просто раздать по 1/N задач каждому?
Потому что статическое «по 1/N» ломается при неравной длине задач: обновление одного тяжёлого босса дольше, чем сотни простых частиц. Раздал поровну по счёту — и один воркер пашет, пока трое давно закончили и простаивают (дисбаланс). Work-stealing балансирует динамически: закончил свою очередь — украл задачу с хвоста чужой. Каждый берёт свои с головы (кэш-локально), крадёт с хвоста (реже конфликтует с владельцем), центрального планировщика-горла нет. Это близко к оптимальной загрузке без априорного знания длительностей — потому и стандарт (Cilk, TBB, Rayon, Unity/Bevy). Балансировка по факту, а не по плану.
CPU-bound или GPU-bound — почему это первый вопрос оптимизации?
Потому что он определяет, где вообще искать, и не даёт оптимизировать не то. Если ты GPU-bound (упёрся в fill/шейдеры/геометрию), сколько ни ускоряй джоб-систему — FPS не сдвинется, GPU всё равно горло. И наоборот. 30-секундный тест: опусти разрешение вдвое — сильно вырос FPS значит GPU-bound (меньше пикселей = меньше GPU-работы), почти не изменился — CPU-bound (пиксели ни при чём, узкое место на CPU: джобы, геймплей, draw calls). Только после этого выбираешь инструмент — профайлер CPU (Tracy) или GPU (RenderDoc/Nsight). Оптимизировать, не зная bound, — это чинить не тот конец.
Что почитать