Lab 4.2 — A Minimal Render Graph

Course 4 syllabus · Module 4 · Prev: « Lab 4.1 · Next: Lab 4.3 »

Goal

Stop placing barriers by hand. In Modules 2–3 every vkCmdPipelineBarrier and image-layout transition was written where it was needed, and by Lab 3.4 the post-processing chain had become a thicket of them — correct today, wrong after the next reordering. This lab builds a minimal render graph: passes declare what they read and write; the graph compiles an execution order, emits the barriers and layout transitions (Vulkan) automatically, and gives transient attachments aliased memory so the bloom pyramid stops paying rent for its whole lifetime. The Metal backend mostly delegates — automatic hazard tracking already solves the correctness half — and the lab measures what that asymmetry costs and buys: the same graph, explicit vs. tracked, head to head.

The acceptance test is the Module 3 pipeline re-expressed as passes: scene → HDR → bloom chain → tonemap, byte-identical output to Lab 3.4, with every barrier now machine-written.

Prerequisites

  • Lab 4.1: the backend seam, slot-based frame loop, deletion queues, and VMA — transient aliasing builds directly on the allocator.
  • Lab 3.4 working through the engine (as refactored in Lab 4.1) — its pass chain is the test article, and its hand-placed barriers are the “before” photograph.

Project & environment setup

  • Work in engine/render/ (target engine_render): the graph is backend-agnostic; per-backend emission lives behind the Lab 4.1 seam in engine_vulkan / engine_metal.
  • engine_viewer gains --graph on|off (off = the Lab 3.4 hand-rolled path, kept alive this lab only, as the comparison baseline) and a --dump-graph flag that prints the compiled order, lifetimes, and emitted barriers as text — that dump is a deliverable.
  • Sync-validation configuration from Lab 0.3 stays on for every run; it is the referee for the emission task.

Where results go:

Artifact Path
Notes, graph dumps, barrier tables, wrong-barrier postmortem labs/lab-4-2/notes.md
Tracy traces, GPU captures (both backends), screenshots labs/lab-4-2/captures/
Graph build-cost timings, aliasing before/after memory dumps labs/lab-4-2/benchmarks/

Background

The hazard taxonomy. Between two GPU operations touching the same resource there are three hazards: read-after-write (RAW — the consumer must see the producer’s writes: an execution and memory dependency, plus a layout transition if the image changes use), write-after-read (WAR — the writer must wait for readers to finish: execution-only, no cache work), and write-after-write (WAW — ordering plus visibility, the classic depth-prepass case). Vulkan makes you name each one as stages + access masks; Metal’s default hazard tracking derives them from resource usage at encoder granularity. Neither model changes what the hazards are — Course 3 Module 8’s memory-ordering discipline, at GPU scale.

Why hand-placed barriers rot. A hand barrier encodes a global fact — “everything that wrote this image has finished” — at a local place in the code. Reorder the passes, add one, make one conditional, and the fact silently stops being true; sync validation catches some of it, tearing catches the rest. The graph inverts this: locality of declaration (“this pass samples bloom[3]”), global derivation of barriers. Declarations are cheap to keep true; derivations are recomputed every time.

What the graph computes. From per-pass read/write sets: (1) a dependency DAG and a topological execution order; (2) per-resource lifetimes — first and last pass touching it — which drive transient aliasing: two transients whose lifetime intervals don’t overlap can share one VMA allocation (VMA_ALLOCATION_CREATE_CAN_ALIAS_BIT, bound at distinct offsets of one block); (3) per-edge barriers: for each resource whose consumer differs from its producer, the source/destination stage-access pair and, for images, the layout transition. Merged where adjacent, never speculative.

What production graphs add (named so this lab’s “minimal” is honest): async-compute queue assignment, multi-frame resource reuse, automatic split-barriers/events, pass culling from unreferenced outputs, and graph-driven memory budgets — Frostbite’s talk covers all five. This lab does none of them; Lab 5.4 and the capstone pick some up.

The Metal path. With tracked resources, the backend’s “emission” is nearly empty — encoders in compiled order, hazards resolved by the driver. That is not free: tracking costs CPU time in the driver and forbids some overlap. The lab keeps Metal on the delegation path (untracked + MTLFence is a Going-further), but measures the asymmetry so the choice is a number, not a vibe.

The Module 3 chain as this lab’s DAG:

flowchart LR
  S[scene pass<br/>HDR color + depth] --> B0[bloom down 0]
  B0 --> B1[bloom down 1] --> B2[bloom down 2] --> B3[bloom down 3] --> B4[bloom down 4]
  B4 --> U3[bloom up 3] --> U2[bloom up 2] --> U1[bloom up 1] --> U0[bloom up 0]
  B3 -.blend.-> U3
  B2 -.blend.-> U2
  B1 -.blend.-> U1
  B0 -.blend.-> U0
  S --> T[tonemap]
  U0 --> T
  T --> P[present / swapchain]

flowchart LR
  S[scene pass<br/>HDR color + depth] --> B0[bloom down 0]
  B0 --> B1[bloom down 1] --> B2[bloom down 2] --> B3[bloom down 3] --> B4[bloom down 4]
  B4 --> U3[bloom up 3] --> U2[bloom up 2] --> U1[bloom up 1] --> U0[bloom up 0]
  B3 -.blend.-> U3
  B2 -.blend.-> U2
  B1 -.blend.-> U1
  B0 -.blend.-> U0
  S --> T[tonemap]
  U0 --> T
  T --> P[present / swapchain]

Tasks

  1. Declaration API. Design the pass/resource declaration surface — a pass declares reads (sampled, attachment-input) and writes (color, depth, storage), transients are declared with format/extent and no explicit allocation. Describe the shape in notes.md (a table of the API’s nouns and verbs is enough); the implementation is yours. Re-express the Lab 3.4 chain in it — the declaration of the whole pipeline should fit on one screen.
  2. Compile: order + lifetimes. Topological sort with a deterministic tiebreak (stable declaration order — reproducible dumps matter); compute first-use/last-use lifetimes per resource. --dump-graph prints both. Cycles are a hard error with the offending path named.
  3. Vulkan barrier emission. For every producer→consumer edge, emit stages, access masks, and layout transitions; merge barriers that share an emission point. Referee: sync validation silent on the full chain, and the RenderDoc event browser showing your emitted barriers exactly where the dump says they are.
  4. Transient aliasing. Place non-overlapping transients into shared VMA memory; the HUD gains transient: aliased/unaliased bytes. The bloom pyramid is the showcase — most of it is dead by the time the upsample chain runs.
  5. Metal delegation. The Metal backend walks the same compiled order and lets tracked resources handle hazards. Verify parity with Lab 4.1’s screenshot rig, then capture both backends and compare where the GPU timeline serializes — tracked Metal vs. your explicit Vulkan barriers on the same DAG.
  6. Postmortem: the wrong barrier. In a scratch branch, hand-place one plausible-but-wrong barrier in the old path (e.g. transition the bloom source before the last downsample finishes — stage too early). Record what sync validation says, what the artifact looks like (if anything — that’s the lesson), and set it against the graph’s emitted barrier for the same edge in notes.md.

Deliverable & expected results

  • engine_viewer --graph on renders the Module 3 chain identically to Lab 3.4 (diff the screenshots), sync-validation silent, on both backends; --dump-graph output and the wrong-barrier postmortem in notes.md.
Quantity Predicted Measured
Transient memory saved by aliasing the bloom pyramid pyramid sums the geometric series \(\sum_{k\ge1} 4^{-k} \approx \tfrac{1}{3}\) of full-res HDR bytes; with lifetimes from the DAG, compute the exact aliased footprint by hand from Lab 3.4’s extents and predict the saving to the byte
Barriers emitted for the Module 3 chain hand-derivable: count producer→consumer edges in the DAG above whose resource changes use, + the two swapchain transitions — write the number down before --dump-graph
Graph build cost per frame (declare + compile + emit, Tracy zone) tens of µs at this pass count — and amortizable: nothing in the declaration changes frame-to-frame yet, so caching the compile is a one-flag experiment
Same DAG, Vulkan explicit vs. Metal tracked — GPU serialization points same count of true dependencies; Metal may serialize more coarsely (encoder granularity) — qualitative, from the two captures

Profiling & performance

Tracy: zone the graph phases separately (graph_declare, graph_compile, graph_emit) so build cost is never confused with recording cost; add a plot for aliased-transient bytes. Take one capture per backend of the full chain — the Vulkan capture cross-checked against RenderDoc’s barrier view on the Linux desktop (RTX 4090) (or MoltenVK best-effort on the Mac), the Metal capture against Xcode’s dependency viewer, which will draw its view of the same DAG — a genuinely satisfying diff to file in captures/.

Analysis & reconciliation

Reconcile your predicted barrier count against --dump-graph — every discrepancy is either a merge you didn’t predict or an edge you didn’t see; name each. Reconcile the aliasing saving to the byte against the VMA stats dump, explaining alignment slack. From the two GPU captures, write the asymmetry paragraph the lab exists for: what did explicit Vulkan sync buy over tracked Metal on this DAG (finer overlap? nothing yet?), what did it cost (emission code, a class of bugs), and at what pass count you’d expect the answer to change. File the pass-culling and async-compute gaps as questions the capstone answers.

Going further

  • Flip the bloom transients to hazardTrackingModeUntracked on Metal and place MTLFences from the graph’s edges — Metal as an explicit API; measure whether encoder overlap improves.
  • Add pass culling: a debug pass whose output nobody reads should vanish from the compiled order; verify by dump and by Tracy.
  • Cache the compiled graph keyed on a declaration hash and measure steady-state build cost dropping to the hash check — the amortization predicted above, made real.
  • Sketch (on paper, in notes.md) where async compute would split this DAG onto a second queue — which edges become cross-queue semaphores; Lab 5.4 cashes this in.