Проблема: Современные планировщики вроде TBB оптимизированы для динамических workload'ов, но в реальности до 40% случаев (игровые движки, оффлайн-рендеринг) используют фиксированные DAG-графы, где важнее предсказуемость, чем гибкость.
В большинстве случаев, когда разработчику нужен параллелизм, он использует либо std::async, либо полноценный thread-пул (например, oneTBB). Эти инструменты отлично подходят для динамически растущих множеств задач, где граф неизвестен заранее и задачи могут порождать друг друга в ходе выполнения.
Но в реальных проектах довольно часто встречается совершенно другой сценарий:
граф исполнений известен полностью заранее,
выполняется один раз (или ограниченное число раз),
и при этом критичны предсказуемость, affinity и жёсткий контроль памяти.
Хороший пример — кадр игрового движка (один вызов → фиксированный DAG), оффлайн пайплайны, симуляции, инструменты, где граф описан статически.
Там throughput сам по себе не так важен - важнее гарантированное время выполнения, без всплесков и очередей.
Именно под такие задачи я сделал небольшой single-run DAG runtime, который не пытается быть ещё одним TBB, а целенаправленно решает фиксированный граф → один вызов → bounded execution (гарантированное время выполнения, без всплесков).
Почему обычный thread-pool здесь не подходит
|
Типичное поведение |
Проблема для single-run |
|---|---|
|
Задачи могут спавниться во время run() |
нельзя “запечатать” граф и вычислить static indegree |
|
Очереди не имеют жёсткого лимита |
backlog растёт до бесконечности |
|
Аффинити — best-effort |
отсутствует строгий контроль, на каком ядре выполняется stage |
|
Нет token-based ограничения |
одна нода может породить N исполнений без верхнего предела |
Если в середине пайплайна есть узкое место (например, heavy stage), producer просто заваливает очередь задачами, и pool начинает “раздуваться”. Ни одна обычная арена не блокирует верхний уровень пока не появятся свободные consumer’ы — она просто продолжает enqueue.
Что если допустить, что граф известен полностью?
Оказалось, что в этом случае runtime можно заметно упростить и сделать жёстко ограниченным по поведению:
-
Пользователь явно строит граф зависимостей, через
submit → then → when_all -
При вызове
run()граф seal’ится:-
проверяется наличие циклов,
-
вычисляются начальные
indegreeдля всех нод, -
создаются inbox’ы
-
-
Воркеры начинают исполнять tokens:
-
work-first (
push_bottom) -
при отсутствии работы → range stealing по Chase–Lev deque
-
-
У каждой ноды есть:
-
max_concurrency -
capacityinbox’а -
Overflow= {Block,Drop,Fail}
-
-
При заполнении inbox’а producer блокируется (или задача отбрасывается), т.е. backpressure является частью runtime, а не пользовательского кода
Что находится внутри(Архитектура runtime)

|
Компонент |
Зачем |
|---|---|
|
Chase–Lev deque + central MPMC |
work-first + дешёвое воровство диапазоном при отсутствии работы |
|
ScheduleOptions / NodeOptions |
affinity, priorities, capacity и политика Overflow |
|
Hazard Pointers + QSBR |
безопасное освобождение памяти во время работы |
|
small_function / small_vector |
избегание heap-alloc’ов для мелких токенов |
|
TaskGraph |
хранение static indegree и управляемое распространение токенов |
Это не “обёртка вокруг TBB” — pool является отдельным самодостаточным минимальным runtime (его можно использовать отдельно, как лёгкий аналог std::async, только без глобальных аренд и аллокаторов).
Как выглядит выполнение
-
если у ноды есть
capacity = 4, producer не создаст 5й token → блокируется -
если
affinity = 2, все её tokens будут исполняться на воркере #2 -
если
priority = High, token помещается вhideque → вытесняет low priority tasks
Когда это полезно
✔ Хорошо подходит для:
-
статических графов (game frame, simulation step, offline pipeline)
-
real-time сценариев, где нужно bounded latency
-
случаев, где важна hard affinity / priority, а не “примерно так”
✖ Не подходит для:
-
динамического spawn (рекурсивный fork)
-
long-living серверов / task арены
-
general-purpose scheduling
Как это выглядит в коде: знакомство с API
Философия "сначала опиши, потом запусти" напрямую отражена в API. Вместо того чтобы дёргать std::async по одному, мы сначала декларативно описываем весь граф задач внутри специального объекта TaskScope, а затем запускаем его на выполнение одной командой.
Давайте посмотрим на простой пример:
Есть задача A (например, загрузка модели).
Есть задача B, которая может начаться только после A (обработка модели).
Есть задача C, которая ждёт завершения и A, и B (например, отправка на рендер).
В коде это будет выглядеть так:
#include <api.hpp>
tp::Pool pool;
tp::TaskScope scope(pool);
auto a = scope.submit([] { /* … */ });
auto b = scope.then(a, [] { /* … */ });
auto c = scope.when_all({a, b}, [] { /* … */ });
scope.run_and_wait();
В TBB это был бы параллельный spawn + join, но его нельзя заранее запечатать — здесь мы явно описываем граф.
Benchmarks (Ryzen 7 6800H, MSVC 19.44)
|
Benchmark |
Problem size |
My runtime |
TBB |
Speedup (My / TBB) |
|---|---|---|---|---|
|
Dependent chain |
1000 tasks |
0.00579 s |
0.00288 s |
2.01× |
|
Independent tasks |
1000 tasks |
1.52830 s |
1.25051 s |
1.22× |
|
Independent batched |
1000 (batch=10) |
1.41718 s |
1.30121 s |
1.09× |
|
for_each_ws |
1 000 000 elems |
0.00957 s |
0.0268 s |
0.36× (≈2.8× faster) |
|
Workflow (w=10, d=5) |
~50 stage ops |
0.00161 s |
0.00029 s |
5.50× |
|
Noop tasks * |
1 000 000 tasks |
4.411 s |
0.2255 s |
20.7× |
-
20-кратное замедление на миллионе пустых задач — это не показатель производительности на реальной работе. Этот тест измеряет чистый оверхед на создание и передачу токена. Да, в моей системе он выше, потому что каждая задача проходит через более строгую систему контроля (проверка capacity, indegree и т.д.). Зато на реальных задачах, где само 'тело' функции занимает время, этот оверхед нивелируется, а выгоды от предсказуемости становятся ключевыми В сценариях с длинными цепочками зависимостей или сложной логикой ветвления TBB оказывается быстрее. Его алгоритмы work-stealing и общая архитектура лучше заточены под такие динамические графы. Мой рантайм выигрывает в другом — в предсказуемости и контроле, а не в чистой пропускной способности на сложных графах
Если вам интересен такой statically-scheduled подход — код полностью открыт, можно поэкспериментировать с графами, способами размещения и посмотреть поведение backpressure вживую.
Автор: cppshizoidS
