all_lessons/ai_compilers / lessons/12 · autodifflesson 13 / 20

Part V — The hard parts

Autodiff as a compiler pass

Lesson 11 taught the compiler to survive variable shapes and data-dependent control flow, so it can now capture, fuse, plan, and tune a real serving graph that changes size every request. But everything up to here has compiled the forward graph — inference. Training needs the backward graph too, and that backward must match a forward you have already fused, re-laid-out, and rewritten beyond recognition. Hand-writing backward kernels for an optimized graph is hopeless: the optimizer is allowed to delete, merge, and reorder ops you never see. The fix is the cleanest idea in the track — make differentiation itself a graph-to-graph pass, so the compiler builds the backward and then optimizes it with the exact same machinery it used on the forward.

The previous step left this broken
By lesson 11 the compiler owns the full inference pipeline: capture (02), typed SSA IR (03), rewrites (04), fusion (05), memory planning (06), layout (07), scheduling/codegen/tuning (08–10), guarded by dynamic shapes (11). None of it produced gradients. Training needs ∂loss/∂θ for every parameter, which means a backward graph — and that graph cannot be written by hand against the compiled forward, because by the time fusion has merged bias+gelu into a matmul epilogue and planning has reused buffers, the ops you would differentiate no longer exist as separate ops. Worse, framework code mutates tensors in place and aliases views, which silently breaks both differentiation and every rewrite from 03–06 (all of which assumed value semantics). You need the compiler to generate the backward itself, from clean value-semantics ops, and then hand the result to the same passes.
Linear position
Forced by: training needs a backward graph the optimizer never wrote, and the compiled forward is unrecognizable to a human writing backward kernels.
New idea: automatic differentiation as a graph-to-graph transformation — reverse-mode AD builds the backward from the forward by applying each op's vjp rule in reverse topological order, yielding a joint forward+backward graph that the same passes (fusion, planning, layout) then optimize. Two enabling passes make it sound and cheap: functionalization (remove in-place/aliasing so AD and rewrites are legal — paying back the SSA motif from lesson 03) and a min-cut partitioner that chooses, per activation, save-in-HBM vs recompute-in-backward.
Forces next: the joint graph is optimized for one device, but a frontier model's parameters, optimizer state, and activations do not fit or finish on a single GPU — the compiler must partition the graph across many (lesson 13).
The plan
Five moves. (1) Recap reverse-mode AD as a graph transform — a vjp rule per op, swept in reverse — and contrast it with eager autograd's runtime tape. (2) Walk the AOTAutograd pipeline: capture → functionalize → autodiff → min-cut partition → compile each half. (3) Functionalization: turn mutation and views into pure ops so the 03–06 passes apply. (4) The partitioner: save-vs-recompute as a min-cut, what stays in HBM vs what is regenerated. (5) Why the same middle-end now runs unchanged on the joint graph. Then drive the recompute partitioner until save-everything OOMs and recompute-everything crawls.

1 · Reverse-mode AD is a graph transform, not runtime magic

Automatic differentiation (AD) computes exact derivatives of a program by composing the known derivatives of its primitive ops via the chain rule — not symbolically (no expression blowup) and not numerically (no finite-difference error). For deep learning we want the gradient of one scalar loss with respect to millions of parameters, so we use reverse mode: one forward sweep to compute values, one backward sweep that propagates the gradient of the loss back through every op. Reverse mode costs ≈1 backward for all inputs at once, versus forward mode's one pass per input — for θ ∈ ℝ⁷ᴮ → loss ∈ ℝ, that is the difference between two sweeps and seven billion.

The atom of reverse mode is the vjp — the vector-Jacobian product. Every primitive op y = f(x) ships a rule that, given the incoming gradient ḡ = ∂loss/∂y, returns ∂loss/∂x = Jᶠᵀ · ḡ without ever forming the Jacobian J. The vjp is a small graph of ops, so differentiation is just substitution:

matmul · y = x·W
vjp: x̄ = ȳ·Wᵀ, W̄ = xᵀ·ȳ. Two matmuls — and they need x and W from the forward.
gelu · y = gelu(x)
vjp: x̄ = ȳ ⊙ gelu′(x). Needs x (or a cached intermediate) to evaluate the derivative.
add · y = a + b
vjp: ā = ȳ, b̄ = ȳ. Needs nothing from the forward — gradient just flows through.

To differentiate a whole graph, walk it in reverse topological order: seed the output with ∂loss/∂loss = 1, then for each op apply its vjp to the gradient flowing back into its outputs, summing contributions where a value fanned out to multiple consumers. The result is a brand-new subgraph of vjp ops — the backward graph. Critically, this transformation happens at compile time on the IR: you give it a forward graph, it hands back a forward+backward graph. There is no Python running, no objects, no interpreter in the loop.

Contrast eager PyTorch. Eager autograd is a runtime tape: as each op executes, it pushes a backward closure (a Python/C++ object holding saved tensors) onto a graph, and loss.backward() later walks that tape calling each closure. That works, but the tape is built and torn down every iteration, it is opaque to any graph optimizer, and the saved tensors it pins are whatever the op author chose — there is no pass that can fuse a backward op into a forward one or trade memory for recompute, because there is no graph to rewrite. Turning AD into a compile-time graph transform is exactly what unlocks the rest of this lesson: once the backward is in the IR, fusion, planning, and layout treat it like any other ops.

eager autograd                          AD as a compiler pass
--------------                          ---------------------
run op  → push backward closure         forward graph  (IR)
run op  → push backward closure                 |  apply vjp per op, reverse sweep
   ...     (a runtime tape)                      v
loss.backward(): walk tape, call         forward + backward graph  (IR)
  each closure in reverse                        |  fuse / plan / layout / tune
                                                  v
opaque to the optimizer                  one compiled artifact, backward optimized too

2 · AOTAutograd: trace once, differentiate, partition, compile both halves

In torch.compile, the pass that does this is AOTAutograd (ahead-of-time autograd). After Dynamo (lesson 02) captures a forward FX graph, AOTAutograd takes over and runs a fixed pipeline that produces two compiled functions — one for the forward, one for the backward — wired together by a small set of saved tensors:

captureReceive the forward FX graph from Dynamo, with example inputs (real or symbolic, lesson 11) so every shape and dtype is known.
functionalizeRewrite all in-place ops and view aliasing into pure, value-semantics ops (§3). Now every value is defined once — the SSA precondition the 03–06 passes assume.
autodiffTrace the backward by applying each op's vjp in reverse, producing one joint graph that contains the forward and the backward together, sharing intermediate values.
partitionSplit the joint graph into a forward subgraph and a backward subgraph, choosing which forward activations to save (carry across the boundary in HBM) vs recompute in the backward — a min-cut (§4).
compile each halfHand both subgraphs to the backend (Inductor → Triton). Each is fused, memory-planned, laid out, and tuned independently — the backward gets the same treatment as the forward.

The payoff is that the backward is a first-class graph. When Inductor fuses bias+gelu into the matmul epilogue in the forward, it independently fuses the corresponding gelu′ ⊙ ȳ and Wᵀ-matmul cluster in the backward. The reason hand-writing backward kernels for an optimized forward is hopeless — the forward op boundaries are gone — is exactly why generating it works: the compiler differentiates the clean graph first, then optimizes forward and backward in lockstep. JAX takes the same stance with grad/vjp as program transformations over jaxprs, lowered through XLA.

3 · Functionalization: kill mutation and aliasing so the passes are legal

Reverse-mode AD and every rewrite from lessons 03–06 share one precondition: value semantics. A vjp rule for add assumes its inputs still hold their forward values when the backward runs; CSE assumes that two reads of the same value return the same thing; buffer reuse assumes a value is dead after its last read. In-place mutation and view aliasing violate all three. If x.add_(1) overwrites x, then a later op that wanted the old x for its vjp silently gets the wrong value. If y = x[2:] is a view and someone writes through y, x changes under the rewriter's feet.

Functionalization is the pass that removes this. It rewrites every mutating or aliasing op into a pure op that returns a new value, threading the new value forward to all subsequent uses:

mutating / aliasing formfunctionalized (pure) formwhy AD & passes now work
x.add_(1)  (overwrites x)x2 = add(x, 1); later uses read x2x is still available for any vjp that needs it; value defined once.
y = x.view(...); y.mul_(2)x2 = mul(x, 2) applied to the slice, recomposedno hidden alias; the rewriter sees a real dataflow edge, not a pointer.
buf[i] = v  (index_put_)buf2 = index_put(buf, i, v)liveness (06) can prove buf dead and reuse its storage safely.

This is the lesson 03 SSA motif paying back exactly when promised: lesson 03 argued value semantics make rewriting local and sound and flagged that mutation would have to be removed before AD. Functionalization is that removal. After it runs, the joint graph is pure SSA, so AD's vjp substitution is correct and fusion/CSE/DCE/planning all apply unchanged. The in-place ops are not gone forever — memory planning (06) reintroduces safe in-place updates later, but only where it can prove via liveness that no other consumer needs the old value. The discipline is: be pure for analysis, be in-place for execution, and let the compiler decide which is provably safe.

4 · The partitioner: save-vs-recompute as a min-cut

The backward needs forward intermediates — the matmul vjp needs x and W, gelu's vjp needs its input. The naive choice is to save every forward activation in HBM and read it back in the backward. That is the activation-memory wall: for a deep model the saved activations dwarf the parameters, and you OOM (the same ledger as cs336 · 05 accounting). The opposite extreme — recompute everything, saving only the inputs and regenerating each activation from scratch in the backward — costs almost a second full forward pass in FLOPs. Neither extreme is right; the answer is a per-activation choice, and the compiler makes it.

The partitioner formalizes it as a graph min-cut. Picture the joint graph with the forward on one side and the backward on the other; every forward value the backward consumes is an edge crossing the boundary. Each crossing edge can be paid for two ways: save it (cost = its bytes in HBM, carried across the cut) or recompute it (cost = the FLOPs of the ops that regenerate it from things already on the backward side). The partitioner picks the cut — the set of values to save — that minimizes total cost under the memory budget. Rematerialization is the general name for recompute-instead-of-store; activation checkpointing is the manual version (cs336 · 05), and the compiler partitioner is the automatic one.

cost(activation) = min( bytes_in_HBM , FLOPs_to_recompute )  →  cut minimizes Σ cost under peak-memory ≤ budget

The heuristics the real min_cut_rematerialization_partition uses are intuitive once you see the cost model. Cheap-and-big → recompute: elementwise ops (gelu, dropout mask, bias) are nearly free in FLOPs but their outputs are large, so regenerating them is far cheaper than the bytes. Expensive-and-shared → save: a matmul output is costly to recompute and often feeds several backward ops, so save it once. This is precisely FlashAttention's trade made by a compiler instead of by hand: cs336 · 07 kernels derives why attention recomputes the T×T scores in the backward rather than storing the O(T²) matrix — cheap FLOPs to save scarce HBM. The partitioner generalizes that single hand-tuned decision to every activation in the graph, automatically.

5 · The same middle-end runs on the joint graph

The quiet, important fact: once AD has produced the joint graph and the partitioner has split it, nothing downstream needs to know it is a backward. Fusion (05) sees a chain of memory-bound elementwise ops and fuses them whether they compute activations or gradients. Memory planning (06) computes liveness and reuses buffers across forward and backward alike — and the partitioner's save/recompute choice is literally input to that liveness analysis (a recomputed value is born late and dies fast; a saved value lives across the whole backward). Layout (07) propagates the same tensor-core-friendly formats. This is the reuse dividend of treating AD as a graph transform: you wrote the optimization passes once, for forward inference, and they pay off again, unchanged, for training. The compiler did not learn calculus; it learned to rewrite a graph, and gradients are just more graph.

6 · Drive it: the recompute partitioner

The widget is a small forward chain — x → matmul → gelu → dropout → matmul → norm → loss. Each box is an activation the backward will need; click it to toggle save (kept in HBM, green) vs recompute (regenerated in the backward, blue). The KPIs are the three numbers the partitioner trades: saved-activation memory, extra backward FLOPs from recompute, and peak memory (saved activations + the working set). The dashed budget is one device. Try the presets: save everything minimizes FLOPs but blows past the memory budget (OOM); recompute everything fits easily but pays nearly a second forward in FLOPs; the min-cut preset saves the expensive matmul outputs and recomputes the cheap-but-big elementwise ones — fitting the budget at a tiny FLOPs premium.

Recompute partitioner — save vs recompute per activation
Click a box to flip it between save (HBM) and recompute (regenerate in backward). The knob that breaks: save everything → OOM; recompute everything → wasted FLOPs. The min-cut saves expensive-and-shared outputs, recomputes cheap-and-big ones. Numbers are illustrative, order-of-magnitude.
saved-activation memory
extra backward FLOPs (recompute)
peak memory
fits 16 GB budget?

The shape of the trade is the lesson: peak memory and extra FLOPs pull in opposite directions, and the partitioner finds the cut that fits the budget for the fewest extra FLOPs. Save-everything is the eager-PyTorch default that OOMs at scale; recompute-everything is gradient checkpointing taken to its wasteful extreme; the min-cut is what the compiler picks for you — and it picks it again, automatically, every time shapes change.

Failure modes & checklist

Failure modes

  • Differentiating a non-functionalized graph. Running AD before functionalization, so an in-place op clobbers a value a vjp still needs. Signal: gradients that are subtly wrong (off by a stale value), often only for ops downstream of an _ in-place call.
  • Saving everything by reflex. Carrying every activation to the backward "to be safe," reproducing eager's memory profile. Signal: training OOMs at a batch size inference handles fine; profiler shows activation memory ≫ parameter memory.
  • Recomputing the expensive ops. A partition that regenerates matmul/attention outputs to save a little memory. Signal: backward is far slower than ≈2× the forward; recompute FLOPs dominate the step.
  • Assuming the eager tape and the compiled backward match op-for-op. Looking for the forward's op boundaries inside the compiled backward. Signal: confusion when a profiler shows fused backward kernels that map to no single autograd node — they are fused vjp clusters.
  • Float non-associativity from recompute. Expecting bit-identical results between save and recompute. Signal: tiny numerical differences toggling checkpointing — expected; the math is equivalent, the rounding order is not.

Checklist

  • Functionalize before AD. Pure value semantics first; reintroduce in-place only where liveness proves it safe (06).
  • Think vjp, not Jacobian. Each op contributes Jᵀ·ḡ as a small graph; never materialize J.
  • Let the partitioner choose. Don't hand-checkpoint what the min-cut handles; reserve manual checkpointing for cases it can't model.
  • Recompute cheap-and-big, save expensive-and-shared. Elementwise/dropout → recompute; matmul/attention outputs → save.
  • Reuse the middle-end. The backward gets fusion/planning/layout for free — confirm in the profiler that backward kernels are fused.
  • Watch the two KPIs together. Peak memory and recompute FLOPs trade off; tune to the memory budget, not to zero recompute.

Checkpoint

Try it
Open the widget and hit save everything: note the peak memory (it exceeds the 16 GB budget → OOM) and that extra FLOPs are zero. Now hit recompute everything: peak memory drops well under budget, but extra backward FLOPs jump to nearly a full extra forward. Finally hit min-cut and read all three KPIs — it should fit the budget while adding only a small fraction of FLOPs, because it recomputes the two cheap-and-big elementwise activations (gelu, dropout) and saves the two expensive matmul outputs. Then by hand: which single box, flipped from recompute to save, would push the min-cut over budget? (Any one of the three recomputed activations — each is ≈4 GB, and saving one lifts peak from ≈13 GB to ≈17 GB, past the 16 GB budget; recomputing them is exactly what the min-cut is doing to stay under budget.) This is the FlashAttention decision from cs336 · 07, made per-op by the compiler.

Where this points next

You now have the whole training graph compiled: AD generated the backward as a graph transform, functionalization made it sound, the partitioner balanced memory against recompute, and the same middle-end fused and planned both halves. But every number in that pipeline assumed one device. The partitioner fought the activation-memory wall on a single GPU and won some headroom — yet a frontier model's parameters alone (tens to hundreds of GB), plus optimizer state, plus the activations the partitioner couldn't recompute away, do not fit on one 80 GB device, and even when they fit, one device can't finish the step in acceptable time. The min-cut bought slack; it did not buy a second order of magnitude. The only move left is to spread the graph — parameters, activations, and the batch — across many devices, which means partitioning and inserting communication becomes another compiler pass. That is lesson 13, Distributed compilation.

Takeaway
Hand-writing backward kernels for a fused, re-laid-out, rewritten forward is hopeless because the op boundaries you would differentiate no longer exist — so the compiler generates the backward instead. Reverse-mode AD is a graph-to-graph transform: apply each op's vjp (Jᵀ·ḡ, never the full Jacobian) in reverse topological order to build a joint forward+backward graph, not a runtime tape. In torch.compile this is AOTAutograd: capture → functionalize (remove in-place/aliasing so AD and the 03–06 rewrites are legal — the SSA motif paying back) → autodiff → min-cut partition (per activation, rematerialization: save its bytes in HBM vs recompute its FLOPs in the backward — cheap-and-big recompute, expensive-and-shared save, exactly FlashAttention's trade automated) → compile each half. The dividend: the same fusion, memory-planning, and layout passes run unchanged on the joint graph — the compiler never learned calculus, it learned to rewrite graphs, and gradients are just more graph. The min-cut buys memory headroom on one device but not a second order of magnitude — forcing the move to many devices.

Interview prompts