Part VII — Synthesis
Capstone — one block, source to kernel
Lesson 18 laid the stacks side by side and showed they are the same five stages — capture, represent, optimize, lower, codegen — wearing different product names. But a Rosetta stone is still a map, and a map is not the territory. The whole track has insisted on one claim: the passes are not a menu you pick from, they are one chain, each link forced by the wall the previous one hit. This last lesson proves it by doing the thing end to end. We take a single line — y = gelu(x @ w + b) with a dynamic batch dimension, plus its backward — and walk it from Python source to a tuned Triton kernel, naming the FILE# that owns each transform and tallying a scoreboard (kernels, HBM bytes, peak memory) the whole way. Then we point outward: the frontiers, the honest limits, and where to go next in the library.
New idea: walk one block — y = gelu(x @ w + b), dynamic batch, with backward — through every stage as a forced chain, watching the scoreboard (kernels, HBM bytes, peak memory, speedup-vs-eager) move at each link; then restate the three motifs and survey the frontiers and limits.
Forces next: nothing in this track — the chain is closed. The remaining walls (megakernels, distributed compilers, learned cost models, dynamic shapes, sparsity, the long tail of ops) point out of the compiler and into the rest of the library.
1 · The trace — one block, every stage
The block is the smallest interesting thing a transformer does: a linear layer with a bias and a GELU activation, on a batch whose size is not known at compile time.
import torch
def block(x, w, b): # x: (B, 1024) w: (1024, 4096) b: (4096,)
return torch.nn.functional.gelu(x @ w + b) # y: (B, 4096)
cblock = torch.compile(block, dynamic=True) # B is a symbolic dim
y = cblock(x, w, b); y.sum().backward() # forward + backward
Run it eagerly first, so we have a number to beat (01). Take B = 2,048, bf16 (2 bytes). The intermediate x@w and the bias-added tensor and the GELU output are each 2,048 · 4,096 · 2 ≈ 16.8 MB. Eager runs this as a chain of separate kernels: the matmul, then a bias-add, then the GELU (which itself is several elementwise ops — the tanh-approx GELU is roughly cube → scale → add → tanh → scale → multiply). Counting the matmul plus the elementwise ops conservatively, that is ≈5 kernels, each reading its ≈16.8 MB input from HBM and writing ≈16.8 MB back. The matmul reads x and w once, but the elementwise tail re-reads and re-writes the activation four-plus times — ≈10× the activation's bytes crossing HBM when one read and one write would do. That is the eager baseline: ≈5 kernels, ≈10× activation traffic, peak memory = sum of all the live intermediates. Now lower it.
dynamic=True, the batch dim is captured as a symbolic s0 and a guard is installed (dtype, rank, s0 ≥ 2) so the cached graph is reused for any batch — no graph break here. Scoreboard: unchanged; we just have a graph now.tanh-approx chain) — canonicalization at the op level — so it fuses; the matmul stays a single recognized op. Had the block contained a custom or exotic op, this is where it would graph-break back to eager, get a registered decomposition / torch.library custom op with a meta kernel for shape propagation, or fall back to a vendor library (cuBLAS/cuDNN). Scoreboard: no number moves if coverage holds — but one uncovered op mid-chain would shatter the fusion region below.cudaMalloc), dispatches on a stream, and amortizes launch overhead. CUDA-graph capture/replay records the whole launch sequence once and replays it with a single driver call — the real fix for lesson 01's launch-bound ceiling. For Python-free serving, torch.export → AOTInductor emits a .so. Scoreboard: launch overhead collapses from N driver calls to ≈1; wall-time drops most on small/launch-bound shapes.rtol/atol budget that distinguishes acceptable drift from a real bug. If it's wrong, the minifier bisects to the offending pass and TORCH_LOGS surfaces graph-break / recompile storms. Scoreboard: no KPI moves — this is the pass that lets you trust the other numbers.Read the chain top to bottom and the linear-thinking spine is visible in one frame: capture exists because optimization needs the whole program (01→02); the typed IR exists because rewriting must be safe (02→03); canonicalization exists to make fusion's patterns match (03→04→05); fusion creates and destroys buffers, which forces memory planning (05→06); planned buffers need a physical shape, which forces layout (06→07); the laid-out graph still only says what, which forces scheduling (07→08); a schedule is a plan, which forces codegen (08→09); codegen can emit anything, which forces search (09→10); every pass assumed static shapes, which forces dynamic-shape handling (10→11); we only compiled inference, which forces autodiff (→12); the joint graph outgrows one device, which forces distributed compilation (→13). And then shipping forces its own chain: a rewrite can only match ops the back end can generate, which forces op coverage (→14); fp16 still leaves bytes on the table, which forces quantization (→15); a compiled artifact is inert and launch-bound, which forces the runtime and CUDA-graph replay (→16); and every one of those passes can move bits, which forces a way to debug and trust the result (→17). Nothing here is optional. The scoreboard collapse — ≈5 kernels → 1–2, ≈10× bytes → ≈2×, peak memory from sum-of-intermediates toward one fused tile — is the whole track expressed as four numbers.
2 · The three motifs, one last time
3 · Frontiers — where the chain is still being built
The pipeline you just traced is real and shipping, but it is not finished. Each motif above has an open edge.
Be honest about the limits. Peak performance still needs humans — the fastest FlashAttention and GEMM kernels are hand-tuned; the compiler gets you 80–95% of the way, and the last few percent on a hot kernel is still a person with a profiler. Debugging compiled code is hard — a graph break, a silent recompile storm, or a fused kernel that's numerically off-by-a-float sends you reading generated Triton and guard logs, not your Python. And the deepest limit: the compiler cannot fix a bad algorithm. It will faithfully, beautifully lower an O(T²) attention into the best kernel that O(T²) can be — but it will not invent FlashAttention's online softmax for you. Algorithmic wins live above the compiler; it makes a good algorithm fast, it does not make a bad one good.
4 · Drive the whole pipeline
The widget below is the scoreboard from §1 made live. The block flows left→right through every stage; click a stage to toggle its pass on or off. The KPIs — #kernels, HBM bytes (× the activation), peak memory, est. speedup vs eager — recompute from the same back-of-envelope formulas the earlier lessons used. Start from the eager baseline (all off) and turn the passes on in order: watch fusion (05) do the heavy lifting on kernels and traffic, planning (06) drop peak memory, and layout/schedule/autotune (07/08/10) climb the speedup. Try turning fusion off while leaving codegen on, to see that emitting Triton without fusing buys you almost nothing — the order is the point.
The shape of the walk is the lesson: nothing happens for the first three stages (capture, IR, canonicalize just prepare), then fusion drops the floor out from under kernels and bytes, planning halves peak memory, and the back-end stages multiply the speedup. The eager-to-compiled gap is not one magic pass — it is a chain, and the chain only works in order.
Failure modes & checklist
Failure modes
- Expecting a speedup with nothing to fuse. A single big GEMM is already compute-bound;
torch.compilecan't beat cuBLAS on it. Signal: you compiled a one-matmul function and saw ≈1× — there was no memory-bound chain (05) to collapse. - Recompile storms from dynamic shapes. Forgetting
dynamic=True(11) so every new batch size triggers a fresh compile. Signal: first call after each shape change stalls for seconds; the guard log shows repeated specialization. - Graph breaks killing the win. A data-dependent
.item()or an unsupported op splits the region (02), so fusion (05) only sees fragments. Signal: the profiler still shows many small kernels;torch._dynamoreports breaks. - Trusting the compiler to fix the algorithm. Compiling an O(T²) attention and expecting FlashAttention. Signal: memory still scales as T² — the compiler lowered your algorithm faithfully; it didn't replace it.
- Compile-time cost ignored. Autotuning (10) and codegen (09) cost real seconds-to-minutes; pay it once and amortize, or it dominates short-lived jobs. Signal: a script that runs the model twice is slower compiled than eager.
Checklist
- Price eager first. Count kernels and HBM round trips (01) so every pass has a number to beat.
- Confirm the chain captured. Check for graph breaks (02) before blaming the optimizer — fusion can't span a break.
- Make fusion the goal. Everything before it (03, 04) just sets up the one pass (05) that moves the scoreboard.
- Read peak, not just time. Memory planning (06) and the partitioner (12) decide whether it fits at all.
- Declare dynamism. Use symbolic shapes (11) to compile once and reuse across batch/seq.
- Cache the tuned artifact. Pay search (10) once; key it by shape/dtype and reuse.
Checkpoint
Where this points next
This is the end of the chain. You can now look at any pass in torch.compile, XLA, TVM, or MLIR and name the inefficiency that forced it, sketch the five-stage stack from memory, and trace a line of PyTorch from source to a tuned kernel with a running cost in your head. The track is closed — the link from here points out, to the rest of the library where each frontier lives in depth. For the kernels themselves — how a fused Triton or CUDA kernel is actually written, the memory hierarchy, tensor cores — go to gpu_kernels and triton_kernels. For how compiled kernels get wired into a production serving engine with paged KV and continuous batching, vllm and sglang. For the full training stack the compiler serves — parallelism, scaling, the systems half — cs336. And for shrinking the model the compiler then lowers, distillation. Back to the start any time at the track index.
Interview prompts
- Walk y = gelu(x @ w + b) from Python to a kernel and name what each stage does. (§1 — Dynamo capture (02) → typed FX/SSA IR (03) → canonicalize/fold/CSE (04) → AOTAutograd autodiff+functionalize+partition (12) → epilogue-fuse bias+GELU into the matmul (05) → memory-plan (06) → layout for tensor cores (07) → schedule tiles (08) → codegen Triton (09) → autotune (10) → guard with symbolic batch (11) → optionally shard (13).)
- Which single pass moves the scoreboard most, and why is everything before it "just setup"? (§1–2 — fusion (05): for memory-bound chains wall-clock ≈ bytes/bandwidth, and fusing the bias+GELU epilogue into the matmul turns ≈5 kernels/≈10× traffic into ≈1 kernel/≈2×; capture/IR/canonicalize only exist to make that rewrite legal and matchable.)
- You compiled a function that is one big matmul and saw ≈1× speedup. Why? (§Failure modes — a single GEMM is already compute-bound; there's no memory-bound chain to fuse, so the compiler can't beat cuBLAS. The win comes from collapsing the elementwise tail, which this function doesn't have.)
- Where does autodiff fit in the pipeline, and what does the partitioner decide? (§1 link 12 — AOTAutograd functionalizes then builds the backward by reverse-mode AD; the partitioner min-cuts the joint graph to choose save-vs-recompute, trading backward FLOPs for activation memory. The same middle-end passes then optimize both halves.)
- How does the compiler avoid a recompile per batch size, and what does that cost? (§1 link 11 — symbolic shapes carry the batch as s0 with guards, so one compiled+tuned kernel serves all batches; the cost is some padding/bucketing waste and looser specialization than a per-shape compile.)
- Name three things a compiler still can't do well. (§3 — reach hand-tuned peak on hot kernels, give clean debugging across graph breaks/recompiles/float drift, and fix a bad algorithm (it will faithfully lower an O(T²) attention but won't invent online softmax). Sparsity and the long tail of ops are also largely open.)
- Why is "the same five stages with different bets" the right mental model for every stack? (§2, lesson 18 — torch.compile/XLA/TVM/MLIR/TensorRT all capture→represent→optimize→lower→codegen; they differ only in IR choices and where on the abstraction↔specialization ladder they commit, so the trace above maps onto any of them.)