torch.compile for ML Performance Engineers

Concept-level, interview-deployable. Version anchor: PyTorch 2.10–2.12 era (2.10 shipped Jan 2026; two-month cadence since). Material changes in the last ~2 years are flagged inline: automatic dynamic shapes maturing (2.1→), compile caching becoming production-real (FXGraph/AOTAutograd/PGO caches + portable save_cache_artifacts, 2.6→), regional compilation, and vLLM-style piecewise compilation becoming the serving default. Everything deeper than the marked boundaries is compiler-team territory — knowing where that line is, is itself the staff signal.

1 · The Stack in One Diagram

Python functionartifact: CPython bytecode(the thing Dynamo hooks) Dynamobytecode symbolic exec +guards → artifact: FX graph AOTAutogradjoint fw/bw, functionalize,decompose → ATen graph GPU/CPU backend: Inductorschedule + fuse + autotuneartifact: Triton kernels / C++, cudagraph-wrapped TPU backend: openxla (torch_xla)export → StableHLO → XLA compilerartifact: HLO → fused TPU executable runtime: CUDA graphs replay, guard checks at entry,caches: FXGraph / AOTAutograd / Triton / PGO runtime: XLA executable cache keyed by shapes,PJRT execution; recompile on new shape signature

One sentence per stage. Dynamo intercepts CPython bytecode execution, symbolically evaluates it, and emits an FX graph of tensor ops plus guards — runtime predicates under which that graph is valid. AOTAutograd traces the joint forward+backward, removes mutation (functionalization), and lowers to a normalized ATen op set. The backend turns ATen into machine-executable code: Inductor schedules/fuses and emits Triton (GPU) or C++/OpenMP (CPU); the openxla backend exports to StableHLO and hands the whole problem to XLA, where fusion/layout decisions belong to XLA, not PyTorch.

Sayable — the stack in one sentence: "torch.compile is three stages: Dynamo captures Python into an FX graph with guards, AOTAutograd normalizes it into a functional ATen graph (joint fw/bw for training), and a backend — Inductor→Triton on GPU, openxla→StableHLO→XLA on TPU — does fusion and codegen; almost every production pathology is attributable to exactly one of those stages."

1.5 · What Each Stage Actually Looks Like

One function traced through every artifact — this is what you see on a real screen, and quoting any one of these from memory is worth a paragraph of description:

def ffn_out(x, w, b):          # [B,4096] @ [4096,4096] + bias, relu
    return torch.relu(x @ w + b)
cf = torch.compile(ffn_out, fullgraph=True)

Stage 0 — what Dynamo hooks (CPython bytecode, dis.dis(ffn_out)):

  2   LOAD_FAST     x        LOAD_FAST     w
      BINARY_OP     @ (MATMUL)
      LOAD_FAST     b        BINARY_OP     + (ADD)
      LOAD_GLOBAL   torch    LOAD_ATTR     relu
      CALL          1        RETURN_VALUE

Stage 1 — Dynamo's FX graph (TORCH_LOGS=graph_code):

def forward(self, L_x_: "bf16[8, 4096]", L_w_: "bf16[4096, 4096]", L_b_: "bf16[4096]"):
    matmul: "bf16[8, 4096]" = L_x_ @ L_w_
    add:    "bf16[8, 4096]" = matmul + L_b_;  matmul = None
    relu:   "bf16[8, 4096]" = torch.relu(add);  add = None
    return (relu,)

…and the guards that make it valid (TORCH_LOGS=guards, excerpt):

TENSOR_MATCH: check_tensor(L['x'], Tensor, DispatchKeySet(CUDA, BFloat16),
              torch.bfloat16, device=0, requires_grad=False,
              size=[8, 4096], stride=[4096, 1])     # ← batch dim 8 is SPECIALIZED (first call)
TENSOR_MATCH: check_tensor(L['w'], …, size=[4096, 4096], …)
ID_MATCH:     ___check_obj_id(G['torch'], 0x7f2…)   # torch itself hasn't been monkeypatched

Second call with batch 16 → this guard fails → recompile with size=[s0, 4096]: that size going symbolic in the log IS automatic dynamic shapes happening.

Stage 2 — AOTAutograd's ATen graph (TORCH_LOGS=aot_graphs, inference path):

def forward(self, arg0_1: "bf16[8, 4096]", arg1_1: "bf16[4096, 4096]", arg2_1: "bf16[4096]"):
    mm:      "bf16[8, 4096]" = torch.ops.aten.mm.default(arg0_1, arg1_1)
    add:     "bf16[8, 4096]" = torch.ops.aten.add.Tensor(mm, arg2_1)
    relu:    "bf16[8, 4096]" = torch.ops.aten.relu.default(add)
    return (relu,)

Everything is now namespaced aten.*, functional, and (in training) would carry the backward: threshold_backward + two transposed mms in the same joint graph.

Stage 3a — GPU: Inductor's generated Triton (TORCH_COMPILE_DEBUG=1output_code.py, abbreviated):

@triton.jit
def triton_poi_fused_add_relu_0(in_ptr0, in_ptr1, out_ptr0, xnumel, XBLOCK: tl.constexpr):
    xoffset = tl.program_id(0) * XBLOCK
    xindex = xoffset + tl.arange(0, XBLOCK)[:]
    x0 = xindex % 4096
    tmp0 = tl.load(in_ptr0 + xindex)          # mm result
    tmp1 = tl.load(in_ptr1 + x0)              # bias (broadcast)
    tmp2 = tmp0 + tmp1
    tmp3 = triton_helpers.maximum(0, tmp2)    # relu — fused into the SAME kernel
    tl.store(out_ptr0 + xindex, tmp3)
# call site: extern_kernels.mm(arg0_1, arg1_1, out=buf0)   ← GEMM stays cuBLAS
#            triton_poi_fused_add_relu_0.run(buf0, arg2_1, buf1, 32768)

The reading that matters: kernel name says what fused (fused_add_relu — one kernel, one HBM round-trip for both ops) and the GEMM went to cuBLAS, not Triton. If you saw fused_add_0 and a separate fused_relu_1, fusion missed — that grep is the whole §5 inspection method.

Stage 3b — TPU: the StableHLO/XLA artifact (XLA_FLAGS=--xla_dump_to=…, post-optimization excerpt):

HloModule ffn_out, entry_computation_layout={(bf16[8,4096]{1,0}, bf16[4096,4096]{1,0}, bf16[4096]{0})->bf16[8,4096]{1,0}}

%fused_computation (p0: bf16[8,4096], p1: bf16[4096]) -> bf16[8,4096] {
  %broadcast = bf16[8,4096] broadcast(bf16[4096] %p1), dimensions={1}
  %add = bf16[8,4096] add(bf16[8,4096] %p0, %broadcast)
  ROOT %maximum = bf16[8,4096] maximum(%add, bf16[] constant(0))
}
ENTRY %main {
  %dot = bf16[8,4096] dot(%x, %w), lhs_contracting_dims={1}, rhs_contracting_dims={0}
  ROOT %fusion = bf16[8,4096] fusion(%dot, %b), kind=kLoop, calls=%fused_computation
}

Same story, XLA's dialect: dot stays a dot, add+maximum became a fusion(kind=kLoop). This HLO text is exactly the artifact class a TPU perf pipeline diffs between compiler versions — being able to read it is the TPU-side equivalent of reading output_code.py.

Sayable — the artifact tour: "I can walk the artifacts: Dynamo\u2019s FX graph and its guards — where you literally watch a size go from 8 to s0 when automatic dynamic kicks in; AOTAutograd\u2019s aten graph; then either Inductor\u2019s output_code.py, where the kernel name triton_poi_fused_add_relu tells you what fused, or the XLA HLO dump, where the same ops show up as a kind=kLoop fusion. When something\u2019s slow, I read those, in that order."

2 · Dynamo: Bytecode Capture, Guards, Graph Breaks

Why bytecode-level capture. torch.jit.trace records the ops one concrete execution ran — it silently bakes in the branch taken and the shapes seen; control flow is gone. torch.jit.script re-parses Python into TorchScript and dies on the Python it can't express. JAX traces pure functions of abstract values and simply requires you to write traceable code. Dynamo takes the fourth path: hook CPython's frame evaluation (PEP 523), symbolically execute the bytecode, extract the tensor-op subgraph, and compile as much as it can prove safe — falling back to real Python for the rest. That's the design contract: arbitrary Python runs correctly; the compiled fraction determines the speedup.

Guards, conceptually. A captured graph is a claim: "this graph is what your code does, provided these predicates hold" — tensor dtypes/devices/shape-classes, branch-relevant Python values, module identity. Guards are checked on every entry to the compiled function; pass → run compiled artifact, fail → recompile (new specialization) or fall back. Guards are the price of capturing an untyped dynamic language soundly: JAX doesn't need them because it forbids the things guards check for.

Graph breaks. When Dynamo hits something it can't soundly trace — data-dependent control flow on tensor values (if x.max() > 0:), calls into opaque code (NumPy on tensor data, arbitrary C extensions, print(tensor)), side effects it can't functionalize — it ends the current graph, runs the offending code in eager Python, and starts a new graph after. The cost is structural, not just overhead: each fragment is a separate compile unit, so fusion cannot cross the break, cudagraph capture is defeated (the eager region re-introduces per-launch CPU work), and guard checks multiply per fragment. One break in a decode inner loop can cost more than Inductor's fusion gained.

single graph (no breaks): one launch path, fusion across the whole step, cudagraph-able compiled graph #1 — whole decode step two graph breaks: three fragments + eager islands — no cross-fragment fusion, no whole-step cudagraph graph #1 eager graph #2 eager graph #3 each boundary: guard re-checks + Python dispatch + lost fusion; per-token loops amplify all three

A realistic break and its fix (the classic: a Python-value decision on tensor data in the hot path):

# BREAKS: .item() pulls a value to Python → data-dependent branch → graph break every step
max_len = (input_ids != pad_id).sum(-1).max().item()
if max_len < 512:
    logits = model(input_ids[:, :max_len])

# FIXED: keep the decision out of the compiled region — bucket outside, compile per bucket
bucket = select_bucket(seq_lens)          # plain Python, outside compiled fn
logits = compiled_fns[bucket](input_ids)  # branch-free tensor code inside

Finding breaks: torch._dynamo.explain(fn)(args) reports every break with its reason and source line; TORCH_LOGS=graph_breaks,recompiles streams them in production runs; torch.compile(fullgraph=True) turns any break into a hard error — the correct setting for serving code, where a silent break is a silent performance cliff.

Internals boundary: how Dynamo models Python frames/variables (VariableTracker), guard implementation and its dispatch cost accounting, and bytecode reconstruction are contributor territory. What a perf engineer needs: what guards assert, what breaks graphs, that fullgraph=True is the serving contract, and the three diagnosis commands above.
Sayable — the graph-break cliff: "A graph break isn't overhead, it's structural: fusion and cudagraphs can't cross it, so one .item() in a decode loop can cost more than all of Inductor's fusion gains; in serving we run fullgraph=True so breaks are build failures, not latency regressions."

3 · Dynamic Shapes — and the TPU Collision

The mechanism. First compile specializes on the exact shapes seen. When a dimension changes on a later call, Dynamo (since 2.1, "automatic dynamic") recompiles that dimension as symbolic (a SymInt) rather than concrete — the graph then serves a family of shapes, with guards reduced to range/divisibility constraints. Dimensions get re-specialized anyway when the code forces it: a value used in Python control flow, a reshape whose arithmetic needs a concrete value, or an op whose lowering wants divisibility (e.g., a kernel that requires s % 128 == 0 will guard on it). torch._dynamo.mark_dynamic(t, dim) declares intent up front and skips the specialize-first-then-generalize dance; the PGO cache (2.6+) persists these dynamism decisions across restarts so the dance isn't repeated per process.

Recompilation storms. Symptom: throughput craters intermittently while the process "works"; every new (batch, seq-len) signature pays seconds-to-minutes of compile. Diagnosis: TORCH_LOGS=recompiles prints each recompile with the guard that failed — the single most useful log line in the stack; torch._dynamo.config.cache_size_limit (default 8 entries per code object) is the tripwire: exceeding it silently falls back to eager, converting a compile-time problem into a permanent 2× eager tax. Fix order: mark the honest dynamic dims → bucket the rest → raise the cache limit only when the shape set is finite and known.

naive: every new sequence length = a new compile (seconds each) — the storm s=137 s=201 s=93 s=412… → cache_size_limit hit → silent eager fallback production: pre-compiled bucket set — new lengths pad up to an existing artifact; zero runtime compiles bucket s≤512 bucket s≤1024 bucket s≤2048 bucket s≤4096 compiled at warm-up; request pads to next bucket (bounded waste ≤ ~2×, typically ~15–30%) the tradeoff is explicit: padded-token waste (bytes/FLOPs on pad) vs recompile latency and cache blowup — serving always buys the padding

The serving consequence. Production LLM serving does not let shapes float: engines enumerate a bucket set (batch × padded-length, plus cudagraph capture per bucket — §7), compile all of it at warm-up, and route requests by padding up. This is the same reasoning as the geometry discipline in kernel design: a bounded, known shape set converts an unbounded compile problem into a fixed warm-up cost.

TPU contrast: on GPU, a shape miss costs an Inductor/Triton compile — seconds, per kernel, amortizable. On TPU, XLA compiles whole programs ahead-of-time with static shapes baked into layout and fusion decisions; a shape miss recompiles the entire executable — tens of seconds to minutes for large models — and there is no symbolic-shape escape hatch: XLA's bounded-dynamism support is narrow, so bucketing isn't an optimization on TPU, it's the programming model. This is also why the pain ranking is: JAX-on-TPU (shapes static by culture, everyone buckets from day one) < PyTorch-on-GPU (symbolic shapes absorb most drift) < PyTorch-on-TPU (Dynamo's dynamism meets XLA's staticism — the collision zone where naive code produces per-shape multi-minute recompiles).
Sayable — bucket precompilation: "Serving engines pre-compile a bucket lattice of (batch, padded-length) shapes at warm-up and route by padding up — trading bounded pad waste, usually 15–30%, for zero runtime compiles; on TPU this isn't even a choice, because XLA recompiles the whole executable per shape signature."

4 · AOTAutograd in Two Paragraphs

Training can't be compiled from the forward graph alone: autograd normally builds the backward dynamically at runtime, which would leave the backward eager and uncompiled. AOTAutograd traces forward and backward ahead of time as one joint graph, then partitions it into compilable fw/bw halves — choosing what to save vs recompute at the partition boundary (this is where activation-memory-vs-recompute decisions physically live, e.g. min-cut partitioning).

Along the way it functionalizes — rewrites in-place mutation (add_, views) into pure ops — because both Inductor and XLA reason about SSA-style functional graphs, and it decomposes composite ops into a small normalized ATen/prims set so backends implement dozens of primitives, not thousands of ops. That's the whole concept load.

Internals boundary: partitioner heuristics, tangent handling, and the decomposition tables are contributor territory. The interview-relevant residue: "joint capture is why compiled training exists; functionalization is why mutation-heavy code compiles poorly; the fw/bw partition is where remat lives."

5 · Inductor at Concept Level

Inductor takes the ATen graph and (1) schedules — orders ops, picks loop structures and tilings; (2) fuses — merges producer/consumer elementwise/reduction chains into single kernels, and epilogues into matmuls; (3) codegens Triton for GPU (C++/OpenMP for CPU), calling vendor libs or generated templates for GEMMs; (4) autotunes — benchmarks candidate configs (block sizes, num_warps; and template-vs-cublas choices) at compile time, cached thereafter.

Why fusion is the money. Roofline logic: an unfused chain x.mul(s).add(b).relu() at bf16 reads and writes the full tensor per op — 3 reads + 3 writes = 6N·2 bytes of HBM for 3N FLOPs, AI ≈ 0.25 — hopelessly memory-bound; fused, it's 1 read + 1 write (2N·2 bytes) for the same FLOPs — 3× fewer HBM round-trips, ≈3× faster, mechanically, because time = bytes ÷ bandwidth in this regime. On decode-shaped workloads almost everything outside the GEMMs is in this regime, which is why compile's win on inference is mostly "fusion killed the elementwise traffic," not "faster matmuls."

Cudagraphs (mode="reduce-overhead"): Inductor wraps compiled regions in CUDA graph capture/replay, collapsing thousands of per-kernel launches (~5–10 µs of CPU each) into one graph replay. Mechanism: decode steps at small batch are launch-bound — GPU idle between tiny kernels waiting on the host — and replay removes the host from the loop. Constraints follow from capture: static shapes and stable memory addresses per bucket (hence per-bucket capture in serving), no eager islands inside (a graph break defeats it).

Inspecting output: TORCH_COMPILE_DEBUG=1 dumps per-graph directories (post-fusion IR, generated Triton in output_code.py); TORCH_LOGS=output_code streams the kernels; the generated Triton is readable and greppable — the fastest way to answer "did my ops fuse?" is to count kernels and look for your op names in one kernel vs three.

Internals boundary: scheduler node/IR details, template systems (persistent matmuls, TMA templates), and Halide/CUTLASS-style codegen decisions are contributor territory. The perf-engineer's interface is: what fuses (and what boundary blocks it — §8), how to read output_code, when cudagraphs apply, and that autotuning cost is a compile-time, cacheable cost.
Sayable — fusion mechanism: "Fusion's win is arithmetic: an unfused elementwise chain pays an HBM read+write per op at AI≈0.25 — pure bandwidth; fusing three ops cuts round-trips 3× and that's the speedup, byte for byte. So I check output_code for kernel count before I believe any 'compile made it faster' claim."

6 · The TPU Path: torch_xla, openxla, and the JAX Contrast

Two capture routes exist on TPU. Legacy lazy-tensor tracing: torch_xla tensors record ops into a lazy IR; at a barrier (xm.mark_step()) the accumulated graph compiles and runs. It re-traces every step (Python overhead per step) and turns any value-inspection (.item(), printing) into a synchronization + possible recompile. The current route — torch.compile(backend="openxla"): Dynamo captures once (guards, no per-step re-trace), AOTAutograd normalizes, the graph exports to StableHLO, and XLA owns everything after — fusion, layout assignment, memory planning. Inductor's entire role is played by XLA; there is no Triton in the picture.

What the engineer actually experiences differently: compile times are whole-program and large (minutes for big models vs seconds/kernel for Triton) but cached per shape signature; shape constraints are absolute (§3); and debugging swaps output_code.py for HLO artifacts — XLA_FLAGS=--xla_dump_to=/tmp/hlo, then read post-optimization HLO to see fusion decisions (the same artifact class a TPU perf-regression pipeline diffs). Op coverage gaps surface as CPU fallbacks that are catastrophic on TPU (device→host→device per op) rather than merely slow.

TPU contrast — why JAX is structurally smoother: jax.jit traces pure functions of abstract shapes: no bytecode interception, no guards, no graph breaks — code that isn't traceable simply doesn't run, so the whole guard/break machinery has nothing to do. Dynamo exists precisely because PyTorch promises to run arbitrary imperative Python; that promise is what TPU enablement keeps paying for — every Dynamo edge case, every mutation pattern, every dynamic shape must be bridged into a compiler stack (XLA) designed around JAX's constraints. Implication for enablement work: the gap isn't kernels, it's capture semantics — which is why PyTorch-on-TPU efforts (incl. the vLLM TPU backend) converge on "restrict to the traceable, statically-shaped subset and lower through StableHLO," i.e., meet XLA where JAX already lives.
Sayable — the JAX/Dynamo contrast: "JAX never needs guards or graph breaks because it refuses at trace time what Dynamo must handle at runtime — jit takes pure functions of abstract shapes, period. Dynamo's complexity is the cost of PyTorch's promise to run arbitrary Python; on TPU you end up paying that cost and then adopting JAX's discipline anyway."

7 · torch.compile in Production Inference

How vLLM-class engines actually use it. Not one monolithic compile: piecewise compilation — the model is compiled in regions with attention (paged, custom-kernel, dynamic by nature) deliberately left outside the compiled/captured regions, called as a registered custom op between them. Per shape bucket: compile the pieces, then capture cudagraphs per bucket over the compiled regions; decode dispatch becomes bucket-select + graph replay. Warm-up runs the full bucket lattice before serving traffic (compile + capture + allocator steady-state). Caching across restarts (mature since ~2.6): FXGraph/AOTAutograd/Triton caches keyed remotely, plus portable torch.compiler.save_cache_artifacts() bundles shipped with the container image — turning a 20-minute cold warm-up into tens of seconds; the PGO cache additionally persists dynamism decisions. Restart cost is a fleet-availability number, so cache hit rate on warm-up is a monitored SLI, not a nicety.

Quantization interaction. Two modes: (a) compiled quant ops — int8/fp8 matmuls as ATen ops or custom ops with registered meta functions; Inductor fuses the dequant/activation epilogues around them — compile helps, killing the elementwise traffic quantization otherwise adds; (b) hand-written kernel suites (FP8 attention, fused MoE dispatch, MXFP4 GEMMs) where the kernel already fuses everything internally — compile's job shrinks to gluing between them, and a naive torch.compile over the whole model can fight them: decompositions splitting a pattern the kernel wanted whole, or a custom op without proper fake-tensor registration causing graph breaks around every call. The fix is registration discipline (torch.library.custom_op + fake impl), not abandoning compile.

The compiler-first, custom-kernel-last procedure — the decision list to say out loud:

1 · profileop is top-k by time? 2 · roofline placementat its ceiling already? → stop 3 · inspect codegenoutput_code / HLO: did it fuse? 4 · coax the compilerfix breaks, register ops, flags, layout 5 · only then: hand-writeTriton/CuTe (GPU) · Pallas (TPU) — and now youown it across every future compiler/hw rev gate at each step: expected win ≥ measured gap × the roofline says it's achievable; else stop — most "slow op" investigations end at 2 (already at ceiling) or 4 (a break or missing fake-impl was the whole story)
Sayable — the procedure: "Profile → roofline-place the op → read the generated code to see if it fused → fix the capture problem if not → and only hand-write a kernel when the compiler provably can't reach a ceiling the roofline says is reachable — because a hand kernel is a permanent liability across every future compiler release and hardware generation."

8 · Failure-Mode Bestiary

PathologySymptomDiagnosisFix
Graph-break stormCompile "works," speedup ≈ 0; trace shows eager islands between fragmentstorch._dynamo.explain; TORCH_LOGS=graph_breaks; fragment count ≫ 1Remove .item()/prints/NumPy from hot path; register custom ops with fake impls; fullgraph=True to lock it
Recompilation storm (dynamic shapes)Intermittent multi-second stalls; throughput sawtoothTORCH_LOGS=recompiles shows failing shape guardsmark_dynamic honest dims; bucket the rest; PGO/caches to persist decisions
Silent eager fallbackStalls stop — and so does all speedup, permanentlycache_size_limit exceeded in logs; kernel names revert to aten::Shrink shape space (buckets); raise limit only for finite known sets; alert on fallback in serving
Guard overhead at dispatchSmall-model/small-batch: latency floor dominated by per-call checks (µs-scale, matters at ms budgets)Profiler shows time before first kernel; guard counts in TORCH_LOGS=guardsFewer specializations; cudagraph replay path (bypasses per-call Python); trim closure-captured Python state
Cudagraph incompatibilityreduce-overhead silently no-ops or errors: dynamic shapes, unstable addresses, in-capture syncsTORCH_LOGS=cudagraphs; capture errors name the opPer-bucket static shapes; stable KV/weight buffers; keep sync-y ops (sampling on CPU) outside captured region
Fusion miss at custom-op boundaryKernel count higher than expected; elementwise ops unfused around your quant/attention opCount kernels in output_code.py; op appears as opaque call splitting a chainProper torch.library registration incl. meta/fake; move epilogues inside the op or express as ATen so Inductor sees them
Compile-time blowupWarm-up minutes→hours; autotune dominates; CI times outCompile-phase logs/profile (TORCH_LOGS=+dynamo timing, tlparse)Cache artifacts across restarts (save_cache_artifacts); regional compilation (compile the repeated block once); trim autotune space
TPU shape-specialization explosionEach new signature = whole-program XLA recompile, minutes each; serving unusabletorch_xla metrics (CompileTime counters), XLA dump dir filling with executablesStrict bucket lattice; pad everything; persistent XLA compilation cache; treat any runtime compile in steady state as a paging bug
Sayable — MFU-gap opener: "When compiled-model MFU disappoints, I check in order: graph breaks (fragmented capture), recompiles (shape guards), silent eager fallback (cache limit), then fusion misses at custom-op boundaries — four log commands, ten minutes, and it's usually one of those before any kernel is to blame."

9 · Interview Deployment Sheet

#Sayable statementGenre
1"torch.compile is Dynamo (bytecode→FX graph + guards) → AOTAutograd (joint fw/bw, functionalized ATen) → backend: Inductor→Triton on GPU, openxla→StableHLO→XLA on TPU. Every pathology maps to exactly one stage."all three
2"Guards are the soundness contract of capturing an untyped language: predicates checked per call, recompile on miss. JAX needs none because jit only accepts pure functions of abstract shapes — Dynamo's machinery is the price of PyTorch's any-Python promise."model-on-TPU co-design
3"A graph break is structural, not overhead: fusion and cudagraph capture can't cross it. One .item() in the decode loop can outweigh every fusion win — serving runs fullgraph=True so breaks fail the build."MFU investigation
4"Recompilation storms diagnose in one line: TORCH_LOGS=recompiles names the failing guard. And the quiet killer is the cache limit — exceed it and you get permanent silent eager, which is why serving alerts on fallback."MFU investigation
5"Production serving pre-compiles a (batch × padded-length) bucket lattice at warm-up and routes by padding up — bounded pad waste, typically 15–30%, bought at the price of zero steady-state compiles."serving design
6"On TPU, bucketing isn't an optimization, it's the programming model: XLA compiles whole programs with static shapes, so a shape miss is a minutes-scale executable rebuild, not a seconds-scale kernel compile."serving / TPU co-design
7"Fusion's value is roofline arithmetic: unfused elementwise chains run at AI≈0.25 — each op an HBM round-trip; fusing N ops divides the bytes by N in the memory-bound regime. That's why compile's inference win is mostly killed elementwise traffic, not faster GEMMs."MFU investigation
8"Cudagraphs collapse thousands of ~5–10 µs launches into one replay — the fix for launch-bound small-batch decode — and their constraints (static shapes, stable addresses) are exactly why engines capture per bucket and keep paged attention outside as a custom op."serving design
9"vLLM-class engines use piecewise compilation: compile the regular regions, leave dynamic attention outside as a registered custom op, capture cudagraphs per bucket, and ship compile-cache artifacts in the image so restart warm-up is seconds, not twenty minutes."serving design
10"Compiler-first, custom-kernel-last: profile, roofline-place, read output_code/HLO for fusion, fix capture problems — and hand-write only when the compiler provably can't reach a reachable ceiling, because a hand kernel is a liability you re-own at every compiler and hardware rev."all three

Version note, honestly stated: behavior described is the 2.10–2.12 era; automatic dynamic shapes (2.1+), mature multi-level caching incl. PGO and portable artifacts (2.6+), and Python 3.14/free-threaded support (2.10) are the recent material shifts. Anything guard-implementation-level or scheduler-source-level was deliberately excluded — deeper than this document is compiler-team territory, and saying so in the room is part of the answer.