Lab 4.3 — Multithreaded Command Recording
← Course 4 syllabus · Module 4 · Prev: « Lab 4.2 · Next: Lab 4.4 »
Goal
Put the other cores to work. Everything so far records the frame on one thread; this lab builds a C++20 job system — a std::jthread worker pool, std::atomic counters with wait/notify, std::barrier for phase joins, work-stealing as an optional refinement — and points it at the frame’s most parallelizable job: command recording. On Vulkan that means secondary command buffers, one per worker, executed by a single primary; on Metal it means MTLParallelRenderCommandEncoder, which exists for exactly this purpose. The referee is Tracy’s zones and locks view: not “it uses threads” but visible parallelism — worker lanes actually overlapping, locks actually uncontended.
The intellectual spine is Amdahl’s law with real numbers in it: measure the serial fraction of your own frame, compute the speedup bound it permits, then run the 1→8 worker sweep and reconcile the curve against the bound. Threading an engine is a measurement discipline before it is a coding one.
Recommended reading
- C&S — the multi-threading chapter (title-level reference; confirm against the copy in hand): task-based parallelism and parallel command recording in an engine shaped like this one — read for architecture, then build your own smaller version.
- Course 3, Module 8 (syllabus) — your own atomics and memory-ordering notes: RMW costs, contention, and the cache-line mechanics behind false sharing. Everything there was C11 atomics; it transfers to
std::atomicalmost verbatim, now withwait/notify_oneadded in C++20. - Course 3, Module 7 (syllabus) — the layout discipline:
alignas, padding, and why “adjacent in the struct” means “shared in the cache” — the false-sharing trap below is this material with threads attached. - The Vulkan specification — the command-buffer lifecycle and threading-behavior material: what “externally synchronized” means for
VkCommandPool, and the secondary-buffer execution model (vkCmdExecuteCommands, inheritance info). - Apple’s Metal documentation on parallel command encoding —
MTLParallelRenderCommandEncoder: one render pass, many sub-encoders, order fixed by creation order regardless of encoding-thread timing. - vkguide.dev — the engine-architecture notes on multithreading Vulkan, for an honest account of what is and isn’t worth parallelizing at this scale.
Prerequisites
- Lab 4.1: the backend seam and slot-based frame loop — per-worker resources hang off the frame slot built there.
- Lab 4.2: the render graph — workers record within passes; the graph’s compiled order is what makes that safe to reason about.
- Lab 0.2: the benchmark discipline (Google Benchmark, medians, pinned repetitions) that the scaling sweep reuses.
Project & environment setup
- Job system lives in
engine/core/(targetengine_core) — it knows nothing about graphics; the parallel-recording layer lives behind the Lab 4.1 seam inengine/render/+ backends. engine_viewergains three flags:--workers N(0 = the Lab 4.2 single-threaded path, kept as baseline);--record-mode shared|per-threadfor the contention experiment;--chunk-size Nfor the partition-granularity knob.
- New benchmark target
job_bench(Google Benchmark) for job-system microbenchmarks, separate from the in-frame sweep. - Scene: the Lab 3.1 scene is too small to show scaling — duplicate its draws to a few thousand (a crude multiplier flag is fine; Lab 4.4 builds the real stress scene).
- Sweep runs from the shell, not by hand:
for n in 1 2 3 4 5 6 7 8; do
./engine_viewer --backend vulkan --workers $n --frames 500 \
--stats-csv labs/lab-4-3/benchmarks/sweep_vk.csv
doneWhere results go:
| Artifact | Path |
|---|---|
| Notes, Amdahl worksheet, contention postmortem, efficiency curve | labs/lab-4-3/notes.md |
| Tracy traces (baseline, contended, per-thread, sweep points), GPU captures | labs/lab-4-3/captures/ |
job_bench JSON, sweep CSV |
labs/lab-4-3/benchmarks/ |
Background
The C++20 job-system toolkit. The language finally ships the primitives a job system is made of; each earns a specific role:
| Primitive | Role in the job system |
|---|---|
std::jthread + std::stop_token |
RAII workers with cooperative shutdown — the pool’s destructor is the shutdown story |
std::atomic<T>::wait / notify_one |
futex-shaped parking for idle workers — blocking without condition-variable ceremony |
std::atomic counters (fetch_sub) |
fan-out/fan-in: each finished job decrements; waiters block on zero |
std::barrier |
phase joins: all recording done → primary assembles and submits |
std::hardware_destructive_interference_size |
the alignas value that keeps per-worker state off shared cache lines |
On top of these, two coordination shapes: a counter for cheap fan-out/fan-in, and a small future-like handle for jobs that produce values. Work-stealing deques are the classic refinement; a single mutex-guarded queue is the honest starting point, and Tracy will tell you when it stops being enough.
The false-sharing trap. Per-worker counters packed adjacently share a cache line; every increment then ping-pongs the line between cores — Course 3 Module 8’s RMW-contention measurement, resurfacing in C++. The fix is alignas(std::hardware_destructive_interference_size) on per-worker state — and note that on Apple Silicon the destructive-interference line is 128 bytes, which Course 3’s cache probes already established; verify what your standard library reports for the constant rather than assuming.
What parallelizes, what cannot. Before writing any worker code, classify the frame the engine already has:
| Frame phase (Tracy zone) | Serial or parallel? | Why |
|---|---|---|
wait_fence, flush_deletions |
serial | gates the slot; nothing to split |
acquire, present, submit |
serial | one swapchain, one queue submission |
graph_compile, graph_emit |
serial (this lab) | small; caching, not threading, is its fix (Lab 4.2) |
record |
parallel | per-draw work, independent given per-worker resources |
| culling, transform flatten | parallel — next lab’s client | flagged now, harvested in Lab 4.4 |
The serial rows are the \(1-p\) in everything below.
Vulkan’s threading rule. Command buffers are recorded on any thread, but a VkCommandPool is externally synchronized — two threads may not touch the same pool concurrently. The idiomatic consequence: one command pool per worker per frame slot, reset wholesale when the slot’s fence wait returns (the same lifecycle as Lab 4.1’s descriptor allocator — notice the rhyme). Workers record secondary command buffers with inheritance info naming the attachments (under dynamic rendering, VkCommandBufferInheritanceRenderingInfo carries the formats); the primary does begin-rendering, vkCmdExecuteCommands over the workers’ secondaries in draw order, end. Determinism comes from assembly order, not recording-completion order.
Metal’s version. MTLParallelRenderCommandEncoder is the same idea with the ordering built in: create sub-encoders up front (creation order = execution order), hand one to each worker, each worker encodes and calls endEncoding, parent ends after all children. No pools to manage — but the engine-side structure (partition, record, join) is identical, which is why the job system lives in engine/core/ and neither backend knows it exists.
Amdahl’s law, which this lab measures rather than recites. If a fraction \(p\) of the frame’s CPU time parallelizes perfectly across \(N\) workers and \(1-p\) stays serial,
\[S(N) \;=\; \frac{1}{(1-p) + p/N}, \qquad E(N) \;=\; \frac{S(N)}{N},\]
with the hard ceiling \(S(\infty) = 1/(1-p)\) no worker count escapes. The serial part here is real and visible in Tracy — the serial rows of the table above — plus the job system’s own overhead, which Amdahl charitably ignores and your measurements will not.
Tasks
- The job system. Build the worker pool in
engine/core/:jthreadworkers, atomic-wait parking, submission of jobs with a completion counter,std::barrierphase join, clean stop-token shutdown. Describe the API’s shape (nouns and verbs, ownership of job lifetimes) innotes.md; the implementation is yours. - Microbenchmark it.
job_benchmeasures three things before any rendering is involved: empty-job dispatch overhead (ns per job — the tax every use pays), fan-out of 10k trivial jobs across 1→8 workers, and the false-sharing variant — per-worker counters packed vs.alignas-separated, the Course 3 M8 experiment reprised in C++20 clothing. - Per-worker recording resources. Give each worker a command pool per frame slot (Vulkan), reset on slot reuse; thread Tracy’s zone context through the workers so their lanes appear in the trace. Validation stays on — it will catch pool misuse from the wrong thread, which is the point of doing this before the contention experiment.
- Partition and record. Split the frame’s draw list into contiguous chunks (
--chunk-size; chunk count a small multiple of worker count is the starting heuristic), record secondaries per chunk, assemble in the primary in stable draw order. Verify against the single-threaded baseline: identical image, identical draw count in the HUD. - The contention experiment. Run
--record-mode shared— all workers allocating from one mutex-guarded pool — versusper-thread. Capture both in Tracy and read the locks view: the shared run should show the mutex hot and worker lanes serialized into a staircase; the per-thread run should show clean overlap. Save both traces; write the one-paragraph postmortem. - Metal parallel encoding. Same partition, same job system, sub-encoders from
MTLParallelRenderCommandEncoder. Confirm image parity, then capture in Xcode and find the sub-encoders in the frame’s encoder list. - The scaling sweep.
--workers 1..8on the M-series, fixed scene, ≥ 500-frame medians per point: record total frame CPU time and the recording-phase time; compute \(S(N)\) and \(E(N)\); plot the efficiency curve (a script over the CSV is fine — plotting is not engine code). Measure the serial fraction from the 1-worker Tracy trace first and write the Amdahl bound innotes.mdbefore running the sweep — predictions filed after the fact don’t count.
Deliverable & expected results
engine_viewer --workers Nrendering identically to the baseline at every N, both backends; validation silent; the traces, CSV, and efficiency curve archived.notes.mdcarries the Amdahl worksheet: measured \(p\), the bound \(S(\infty) = 1/(1-p)\), the sweep table, the contention postmortem, and the reconciliation.
| Quantity | Predicted | Measured |
|---|---|---|
| Speedup bound from Amdahl at your measured serial fraction | compute \(S(N) = 1/((1-p) + p/N)\) for \(N = 2, 4, 8\) from the 1-worker trace’s \(p\) before the sweep; measured speedups must sit at or under the curve | … |
| Recording-phase speedup at 4 workers | approaches \(4\times\) on the recording zone alone if partitioning is even and pools are per-thread; whole-frame speedup much smaller — that gap is Amdahl | … |
| Shared pool vs. per-thread pools | qualitative: contention collapse — shared-mode worker lanes serialize on the mutex (Tracy locks view shows wait time ≫ hold time), per-thread restores overlap | … |
False-sharing benchmark, packed vs. alignas counters |
integer-factor gap, same direction and mechanism as Course 3 M8’s RMW experiment; 128-byte line arithmetic in the notes | … |
| Metal parallel-encoder sweep shape | qualitatively the same curve as Vulkan — same partition, same serial floor; absolute recording cost may differ (sub-encoder creation vs. secondary-buffer overhead) | … |
| Efficiency \(E(8)\) on M-series | below \(E(4)\) — serial floor plus heterogeneous cores: beyond the performance-core count, efficiency cores dilute the curve | … |
Profiling & performance
Tracy is the whole referee here: worker lanes named (worker/0 …), the recording phase bracketed by a frame-level zone, locks instrumented (TracyLockable on the shared-pool mutex) so the contention experiment is a picture, not an inference. Archive one trace per sweep point and one GPU capture per backend at --workers 4 to confirm the GPU-side frame is unchanged — this lab moves CPU time only, and the captures prove it. Keep powermetrics in view during the sweep: more cores at the same frame rate is a power cost worth one sentence in the notes.
Analysis & reconciliation
Reconcile the sweep against the Amdahl bound point by point. Where measured \(S(N)\) falls short of the bound, apportion the gap among three named causes: job-system overhead (priced by job_bench’s dispatch numbers), partition imbalance (visible as ragged lane ends in Tracy — re-run one point with a smaller --chunk-size to test), and the P-core/E-core split (QoS-hint or pin the pool and re-run one point). Explain the false-sharing factor against the 128-byte line — same arithmetic as Course 3, new language. Close by answering the design question the module has been building to: at this draw count, was parallel recording worth its complexity — and at what draw count (extrapolate from the measured per-draw recording cost) does it become unambiguous? Lab 4.4’s 10k-object scene will check the extrapolation.
Going further
- Implement work-stealing deques and re-run the sweep with deliberately unbalanced chunks (one chunk 10× the rest) — the case stealing exists for; measure against the fixed-partition pool.
- Parallelize a second phase with the same job system — Lab 4.4’s culling is the natural client — and watch the serial fraction \(1-p\) shrink between labs.
- Run the sweep on the Linux desktop (RTX 4090) and compare curve shapes — if its CPU’s cores are homogeneous (check before assuming; desktop parts vary), it’s a cleaner Amdahl specimen than the M-series’ P/E-core mix.
- Try
std::atomic::waitvs. a spin-then-park hybrid for worker parking and measure wake latency injob_bench— the latency/power tradeoff every job system tunes. - Read about device-generated commands and note in
notes.mdwhat Lab 5.4’s GPU-driven path will make of this lab’s CPU-side machinery — some of it is scaffolding for a thing the GPU will eventually do to itself.