Проблема: Современные планировщики вроде 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
capacity
inbox’а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 помещается вhi
deque → вытесняет 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 вживую.