executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
job-systemExpert parallel job systems for game engines — task scheduler, work stealing, fiber/job primitives, dependency graphs, parallel-for and GPU/CPU pipelining under the frame budget.
Independent GitHub snapshot · Automated classification · Original documentation governs
Modern CPUs give you 8–32 cores; a single-threaded Update() leaves them idle. The job system is the backbone that spreads simulation, animation, physics, and render-command generation across cores — transparently, deterministically, and within the frame budget.
| Workload | Threads that help | Where it breaks |
|---|---|---|
| Graphics (GPU) | Async compute helps | Raster work is GPU-bound |
| Simulation (physics, AI) | Many cores | Contention on shared state |
| Animation skel + pool | Many cores | Ordering (blend tree) |
| Render command gen | Medium parallel | Sorting at end |
Raw multicore won't help if every thread fights over one lock. Job systems give structured parallel workers: fixed worker threads, work queues, and dependencies.
CPU threads: [render thread] [audio thread] [gameplay thread] [worker × N]
Job pool: dependent jobs flushed to workers by the scheduler
Never create a thread per request (std::thread per job → oversubscription + thread-creation cost ~10k cycles). The scheduler owns N workers = hardware threads − 1 (one for the main/render thread).
struct Job {
void (*fn)(JobContext&, void* data); // context = arena, worker_id, frame_data
void* data;
JobHandle dependencies[MaxDeps]; // DAG edges
};
struct JobHandle {
uint32_t counter; // atomic completion counter
// wait(): busy-wait until counter == 0
};
A job is "done" when its counter drops to 0 (its own + all children). Join is the primitive: JobSystem::Wait(handle) blocks the caller until the group completes.
// Game frame: dispatch all systems, join at the end
JobHandle hPhysics = jq->dispatch(physicsJobs, kPhysicsJobs);
JobHandle hAnim = jq->dispatch(animJobs, animCount, &hPhysics); // depends
JobHandle hRender = jq->dispatch(renderCmds, kRenderCmds, &hAnim);
jq->wait(&hRender); // one join per frame, not per subsystem
The single join at frame end is what makes job systems cheap — workers run everything in parallel; the main thread only waits once.
One global queue of runnable jobs; workers pop from it (mutex-protected). Works up to ~8 threads before contention. Simple, deterministic-ish.
class WorkQueue {
std::mutex m; std::deque<std::function<void()>> jobs_;
public:
void push(job j) { lock; jobs_.push_back(j); }
job tryPop() { lock; if empty return {}; pop_front; }
};
Each worker owns a deque of jobs. When idle, it steals from a random peer's deque tail (LIFO → cache-locality; steal front → cache-cold).
Worker A deque: [j1, j2, j3] ← A pops j3 (LIFO, hot cache)
Worker B idle: steals j1 (front) ← cache-cold, steal is rare so OK
Benefits: near-linear scaling for embarrassingly-parallel passes, minimal lock contention. Determinism caveat: steal order is random → iteration order varies run to run.
Partition a job list into k chunks and dispatch k worker jobs; each handles count/k items. For-loop style:
// parallel_for over [0, n)
void ForEach(uint32_t n, void(*fn)(uint32_t i, void* ctx), void* ctx) {
... dispatch chunk jobs ...
}
parallel_for is the single most-used job primitive (systems, ECS queries, mesh pools).
| Style | Granularity | Pros | Cons |
|---|---|---|---|
| Coarse (system-level) | whole systems depend on whole systems | super simple, near-deterministic | bubbles (waits) |
| Fine (job-level) | per-job deps | max parallelism | build DAG complexity |
Production engines do both: coarse system phases with fine dependencies inside the phase.
AnimPose ──┬──> SkelUpdate ──> RenderNode
Player ─┴──> Camera ──────> ...
Careful: a job with two parents waits for both → a fold/join. Most job systems represent via the atomic counter: parent dependencies increment the counter; children decrement on completion.
A "dependency counter" per job lets a scheduler run a job only when all inputs are ready. Unreal uses a similar FGraphEvent; Unity's NativeArray + JobHandle.CombineDependencies. This is the same shape as a graph-task system (Intel TBB, Rayon's par_iter).
GPU-async workloads (async compute, DXR ray tracing, decompression) want a stack per job, but stacks cost memory (64 KB each). Fibers (cooperative threads) share one stack via SwitchToFiber — a job suspends, the scheduler switches to another, resuming later.
Fibers are complex (your whole call path must be fiber-safe; stack size finite). Modern engines (UE5 2024+, many) prefer job + async/await semantics without fibers, or GPU semaphore-based async, because fiber + lock-free + callbacks is a bug factory. Rule of thumb: introduce fibers last, or only in the narrow job-graph where blocking is genuinely long (I/O, decompression).
Determinism is the #1 pain of parallelism. Races in iteration = different results per boot = network desyncs, replay mismatch.
chunk(0)..chunk(k-1)), even if they run on different cores.float differs by order → use sorted/batch accumulation, or fix-point math.std::sort on equal keys is unstable — use stable sort or tiebreak on id.rand() shared across threads.// Physics reads positions (snapshot A), gameplay writes next (buffer B)
struct WorldState {
RigidBodies bodies[2]; // flip per tick
int read = 0, write = 1;
};
Different systems read different snapshots within a tick → genuinely parallel.
[t game thread] → simulation orchestrator (the frame driver)
[w0..w3] → physics/animation/AI workers
[w4..w6] → render-command generation
[audio thread] → mix + submit
[async I/O] → decompression, asset load
Priorities in the scheduler: render-command gen > animation > physics for latency of the critical path, but each frame has a global budget.
Too many joins = bubble. Pattern: gather all phase jobs → single join → next phase starts with the cold dependency-free heads.
// CPU submits frame N's commands while GPU executes frame N-1
// Double/triple buffered command buffers + fences.
gpu->BeginCommandList(frame);
workerJobs → RenderCmdGen (CPU) → [submit async]
gpu->StartFrame(); // CPU continues to build next frame's jobs
gpu->EndFrame(); // fence N
Job system + async compute = CPU never waits on the GPU except one fence at the end.
Key metrics:
references/scheduler-impl.md — central queue vs work stealing implementations, worker spin/wait, prioritiesreferences/dependency-graph.md — DAG counters, combine dependencies, join/fold, dynamic dispatchreferences/parallel-for.md — chunking, tile iteration, reductions, SIMD-friendly partitionreferences/determinism.md — ordering, double buffering, stable sorts, RNG, replay & network rulesreferences/fibers-and-async.md — fiber stacks, switch, async/await modelling, GPU async computereferences/job-profiling.md — utilization, bubble, steal, counters, Perfetto/Tracy integration