all_lessons/ai_compilers / lessons/17 · debugginglesson 18 / 20

Part VI — Shipping the compiled model

Debugging compiled models

Lesson 16 produced something that actually runs in production: a cache of compiled, quantized kernels, a memory plan, and a runtime that replays the whole launch sequence as one driver call. The model is fast and it fits. There is just one problem you've been quietly accumulating since lesson 04 — it no longer computes exactly what the eager model computed. Fusion reorders arithmetic. Reassociation (which lesson 04 planted as the one rewrite that isn't bit-exact) reorders reductions. Quantization (lesson 15) is deliberately approximate. A different fused kernel accumulates in a different order. And graph-replay can silently fall back to recompiling without telling you. The whole track kept promising "preserve semantics" — and then spent four lessons legitimately moving bits. This lesson is how you tell an acceptable float drift from a real bug, find which pass caused it, and decide when the answer is "ship it" versus "that's broken."

The previous step left this broken
You can now build a model that is fast (00–13), lowers every op (14), spends precision for bytes (15), and launches with near-zero overhead (16). But every one of those wins moved you off the eager reference. Run the compiled model and the logits differ in the 4th decimal place — is that the harmless rounding of a reordered sum, or did the int8 calibration (15) clip an outlier channel and corrupt a layer? Worse, the model that was 5× faster yesterday is 1.2× today and the GPU is mostly idle — a guard from lesson 02/11 is firing on every batch and you're recompiling in a hot loop, but nothing printed an error. A compiled model fails differently from eager: not with a stack trace, but with a number that's slightly wrong or a speed that quietly evaporated. You have no stack frame to set a breakpoint in — the Python is gone, replaced by a Triton kernel and a captured graph. Production needs a way to trust the compiler, which means a way to interrogate it.
Linear position
Forced by: fusion (05), float reassociation (04), quantization (15, deliberately approximate), different kernels & accumulation order, and graph-replay/recompile (16) all can change the output or silently re-pay compile cost — so "it ran" no longer means "it's correct" or "it's fast."
New idea: the debugging toolchain — accuracy as a first-class check (an rtol/atol tolerance against an fp32 reference, distinguishing float drift from a bug); bisection via the minifier (auto-shrink to the smallest graph that still reproduces, and tag the offending pass); reading the compiler logs (TORCH_LOGS=graph_breaks,recompiles,guards / tlparse) to diagnose graph-break and recompile storms; and the determinism story (TF32/fast-math, accumulation dtype, atomic/reduction-order nondeterminism, and the deterministic switch's perf cost).
Forces next: with build · ship · trust all in hand, you've seen every piece — time to assemble them as the real stacks (18) and trace one block end to end (19).
The plan
Five moves. (1) Why compiled ≠ bit-identical, and the rtol/atol + fp32-reference test that separates acceptable drift from a bug. (2) Bisection and the minifier — let the tool shrink the repro to the offending subgraph and pass. (3) Read the compiler: TORCH_LOGS / tlparse to diagnose graph-break storms (callback 02) and recompile storms (callback 11). (4) Determinism: where nondeterminism comes from and what the deterministic switch costs. (5) A debugging flowchart — slow? wrong? recompiling? — that routes any symptom to the right tool. Then drive the divergence detective until you can read a tolerance verdict cold.

1 · Why compiled ≠ bit-identical, and the test that tells drift from a bug

Start by accepting the thing nobody tells you: a correct compiler does not produce the same bits as eager. It produces a result that is mathematically equivalent on the reals but rounded differently. Four mechanisms, all of which you've already met:

reassociation (04)
Fusion and reduction-tree rewrites change the order of float adds. Floats are non-associative — (1e20 + −1e20) + 1 = 1 but 1e20 + (−1e20 + 1) = 0. Legal under fast-math; changes the last bits.
different kernel (05, 09)
The fused Triton kernel sums a row in a different tile order than eager's library kernel. Same math, different accumulation path, different rounding — a few ulps.
accumulation dtype
A bf16 matmul that accumulates in fp32 vs one that accumulates in bf16 differ by far more than a few ulps — the low-precision accumulator loses bits every add.
quantization (15)
The one pass that is deliberately approximate. int8/int4 quant preserves the math only on an accuracy budget; this drift is expected and can be large.

So "the numbers differ" is not a bug report — it's the normal state of affairs. The real question is how much they differ, and the only honest way to ask it is against a reference. The reference is the eager model run in fp32 (or fp64 if you're paranoid): the highest-precision computation of the same math, which is your best proxy for the true real-number answer. You then compare the compiled output to it with a two-part tolerance, exactly the test torch.allclose / numpy.allclose implements:

|compiled − reference| ≤ atol + rtol · |reference|

atol (absolute tolerance) is the floor — it forgives small differences near zero, where relative error explodes (|0.0001 − 0.0| is "infinitely" wrong relatively but absolutely tiny). rtol (relative tolerance) scales with the magnitude of the value — it forgives a larger absolute gap on a large number, because large floats have larger ulps. A reasonable starting pair for bf16/fp16 work is rtol ≈ 1e-2, atol ≈ 1e-3; for fp32, something like rtol ≈ 1e-5. The verdict — within tolerance? — is your "drift vs bug" classifier:

observationdiagnosisaction
max rel error ≈ 1e-3, scattered uniformlybenign float drift (reassociation / different kernel)ship it; this is expected at bf16
max rel error ≈ 1e-2 on int8, concentrated in a few channelsquantization on its accuracy budget (15) — within designcheck end-task metric, not raw logits; ship if metric holds
error grows layer over layer, or a handful of NaN/Infreal bug — overflow, a wrong rewrite, a bad castdo not ship; bisect to the pass (§2)
error ~0 on most inputs, huge on a fewreal bug — a guard mismatch or an outlier the calibration misseddo not ship; minify the failing input (§2)

The trap is tolerance choice itself. Set the tolerance too loose and a genuine bug slips through as "close enough"; set it too tight and benign reassociation drift trips it on every run and you waste a week chasing a non-bug. The discipline is to anchor the tolerance to a downstream metric wherever you can — perplexity, top-1 accuracy, a generation-quality eval — because the last bits of a logit almost never matter, but a clipped channel that drops accuracy 3 points does. Raw-tensor allclose is the fast screen; the task metric is the verdict that ships.

2 · Bisection and the minifier: let the tool find the pass

Suppose the verdict is "real bug": the compiled model's error blows past any sane tolerance. The graph has thousands of ops and a dozen passes ran over it. Where is the fault? The brute-force answer is bisection — disable half the passes (or half the graph), re-test, and recurse into whichever half still reproduces. That's log₂ of the problem size in steps, and it's exactly what a minifier automates.

A minifier is a tool that takes a large failing program plus a check ("does the compiled output diverge from the reference?") and automatically shrinks it — deleting ops, narrowing the graph, simplifying inputs — to the smallest subgraph that still triggers the failure, while also identifying the pass after which the divergence first appears. In torch.compile you arm it with an environment variable:

# reproduce-after a given compiler stage, on an accuracy divergence
TORCHDYNAMO_REPRO_AFTER="aot"        # bisect after AOTAutograd (the joint fwd/bwd graph)
TORCHDYNAMO_REPRO_LEVEL=4            # 4 = accuracy minifier (vs crash minifier)
python train.py
# → writes repro.py: a ~20-line graph that still diverges,
#   plus which lowering reproduced it. Hand that to a bug report.

Two flavors matter. The crash minifier shrinks a graph that makes the backend error (a codegen assertion, an unsupported op) down to the offending node. The accuracy minifier (the one you want here, REPRO_LEVEL=4) shrinks a graph whose output diverges from the reference beyond tolerance, isolating the few ops whose lowering introduced the divergence. The output is a tiny, self-contained repro.py — the difference between "the 70B model is wrong somewhere" and "this 8-op subgraph, after fusion, produces a NaN on this one input shape." That artifact is what turns a vague divergence into a fixable, reportable bug. REPRO_AFTER picks the stage to bisect from — "dynamo" (the captured FX graph), "aot" (after autodiff + functionalization, lesson 12), or the Inductor lowering — so you can localize the fault to capture, the joint graph, or codegen.

The same bisection logic works by hand when you don't have a minifier: compare against the reference at each lowering stage. If the FX graph (capture) already diverges, the bug is in your model or capture; if it's exact after capture but wrong after Inductor, the bug is in fusion/codegen; if it only appears under reduce-overhead (CUDA graphs, lesson 16), it's a stale-buffer or stable-address problem, not a math bug. Each stage you can mark "matches reference" cuts the search space in half.

3 · Read the compiler: graph-break storms and recompile storms

The other failure mode isn't wrong — it's slow, and silently so. Two diseases, both diagnosable only by making the compiler talk. You make it talk with structured logs: torch.compile exposes TORCH_LOGS, a comma-separated list of channels, and tlparse, a tool that turns the raw log dump into a navigable HTML report of every compiled region, guard, and break.

TORCH_LOGS="graph_breaks,recompiles,guards" python serve.py
# graph_breaks → every place Dynamo fell back to eager and why
# recompiles    → every guard that fired and forced a recompile
# guards        → the exact assumptions each compiled graph is specialized on

A graph-break storm (callback to lesson 02) is when Dynamo can't trace some construct — a data-dependent .item(), a print, an unsupported op (lesson 14), a Python side effect — and graph-breaks back to eager at that point, splitting your one big fusable graph into many small islands. Fusion can't cross a break (lesson 05's seam problem), so the win evaporates: instead of one fused kernel you get the eager launch storm lesson 01 priced. The log tells you exactly where and why:

[graph_breaks] torch._dynamo: BREAK in forward() at model.py:88
    reason: Tensor.item() called — data-dependent value escapes the graph
    → graph split here; 2 subgraphs, fusion cannot cross the seam
[graph_breaks] torch._dynamo: BREAK at model.py:142
    reason: unsupported builtin print() with tensor arg

The fix is to remove the break (avoid .item() in the hot path, wrap unsupported ops as custom ops per lesson 14, or set fullgraph=True to make a break a hard error you must resolve rather than a silent fallback). A recompile storm (callback to lesson 11) is different: the graph compiled fine, but a guard — the assumption set the compiled graph is specialized on (shapes, dtypes, the value of a Python flag, an object id) — keeps failing, so the runtime recompiles on nearly every call. The classic cause is shape specialization: you didn't mark a dimension dynamic, so every new sequence length recompiles. The log makes it obvious:

[recompiles] torch._dynamo: recompiling forward() (cache size 7)
    guard failed: L['x'].size()[1] == 512   (now 537)
    → tip: mark batch/seq dynamic with torch._dynamo.mark_dynamic(x, 1)
[recompiles] ... (cache size 8)  guard failed: L['x'].size()[1] == 537 (now 489)

A climbing "cache size" with a shape guard in every entry is the signature: you are compiling, not running. The fix is the lesson-11 toolkit — symbolic/dynamic=True shapes so one graph covers a range, or bucketing so only a few sizes ever compile, plus the recompile_limit guardrail that converts an out-of-control storm into a loud error. tlparse stitches all of this into one report: how many distinct graphs you compiled, what each is guarded on, and which guard is thrashing — the whole-program view that a single exception never gives you. (For kernel-level profiling once the graph is stable, the deeper treatment is gpu_kernels · 20 the profiling toolkit and 21 reading a profile against the roofline.)

4 · Determinism: where the nondeterminism lives, and what the switch costs

Sometimes the bug is that the model gives a different answer each run with no code change — which makes everything above impossible, because you can't bisect a moving target. Determinism means: same inputs, same code, bit-identical outputs, every run. Compiled (and even eager GPU) code is nondeterministic by default for performance reasons, from three sources:

atomic / reduction order
Many GPU kernels (scatter-add, some backward reductions) use atomics — parallel threads add into one location in hardware-scheduled, run-varying order. Floats are non-associative (04), so the order changes the last bits run to run.
TF32 / fast-math
TF32 runs fp32 matmuls through tensor cores at reduced (19-bit) mantissa precision; fast-math lets the compiler reassociate/contract. Both trade precision for speed and can vary the result.
accumulation dtype
Whether a reduction accumulates in fp32 or in the (smaller) storage dtype. Reduced-precision accumulation both loses precision and makes the order matter more — set it explicitly, don't inherit a backend default.

The frameworks expose one switch that forces deterministic algorithms everywhere they exist and errors where they don't, so a hidden nondeterministic kernel can't sneak through:

torch.use_deterministic_algorithms(True)   # deterministic kernels, or raise
torch.backends.cudnn.deterministic = True
torch.backends.cuda.matmul.allow_tf32 = False   # full fp32, no TF32 shortcut
import os; os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"  # deterministic cuBLAS

This is a real trade, not a free correctness dial. Forcing deterministic algorithms can cost 10–30% wall-clock (sometimes worse): the deterministic scatter-add replaces fast atomics with a serialized or sorted reduction, and disabling TF32 drops matmul throughput by ~2–3× on tensor cores. So you turn determinism on while you debug — to get a stable target to bisect against and to make a divergence reproducible — and turn it off for production, where a few ulps of run-to-run noise are harmless and the speed is not. The same reproducibility-vs-speed tension as fast-math in lesson 04, now stated as an operational switch. The mistake is leaving it on in production (paying 20% for nothing) or expecting bit-identical results without it (chasing "nondeterminism bugs" that are just atomics).

5 · The debugging flowchart

Put it together as a triage. Every symptom of a compiled model routes to one of three questions, and each question has its tool. Don't reach for the minifier when the problem is a recompile storm, or for TORCH_LOGS when the output is wrong — match the tool to the symptom.

slow?
Is it slower than expected or slower than yesterday? → Turn on TORCH_LOGS=recompiles,graph_breaks + tlparse. Climbing cache size + a shape guard = recompile storm → mark dynamic / bucket (11). Many small kernels + breaks = graph-break storm → remove breaks / custom-op (02, 14). If the graph is stable and still slow, it's a kernel problem → profile (gpu_kernels 20/21), not a compiler problem.
wrong?
Is the output off? → Compare to the fp32 eager reference with rtol/atol. Within tolerance and uniform = benign drift, ship. Concentrated on quantized channels = check the task metric (15). Growing per layer or NaN/Inf = real bug → run the accuracy minifier (TORCHDYNAMO_REPRO_AFTER, REPRO_LEVEL=4) to get a tiny repro and the offending pass (§2).
flaky?
Different answer each run, or can't reproduce a failure? → Turn on use_deterministic_algorithms(True) and disable TF32 to get a stable target, then bisect. If it only diverges without determinism, it's atomics/reduction-order noise (harmless) — not a bug. If it diverges even with determinism on, it's a real bug; minify it (§2,§4).

6 · Drive it: the divergence detective

The widget runs a compiled block against its fp32 reference and reports the two numbers that decide everything: max absolute error and max relative error. Toggle fault sources — aggressive fusion-reassociation, int8 quantization, TF32 — and watch each add a characteristic amount of drift that sits comfortably under a sane tolerance. Then flip on a real bug (a wrong accumulation dtype that loses bits every add) and watch the error blow past any reasonable line. Move the rtol/atol slider to set your tolerance and read the within tolerance? verdict. The lesson is in the knob that breaks: tighten the tolerance far enough and the benign drift trips the verdict too — false-positive triage — which is exactly why you anchor tolerance to a task metric, not to "as tight as possible."

Divergence detective — drift vs bug, and tolerance triage
Each fault adds error against the fp32 reference. Float drift (fusion-reassoc, TF32) stays tiny; quant sits on its budget; the real bug blows past. The knob that breaks: drag the tolerance tighter and watch benign drift falsely trip the verdict — the false positive that wastes a debugging week.
max abs error
max rel error
tolerance bound
drift class
set faults and tolerance

The shape of the readout is the whole skill. With sane tolerances (rtol = 1e-2), every drift source — fusion, TF32, even int8 quant — reports "within tolerance," while the real bug always fails: that's the classifier working. Then drag rtol down toward 1e-5 and watch fusion drift flip to "FAIL" even though nothing is broken. The compiler didn't get worse; your tolerance got unrealistic. A correct debugging process is a correct tolerance plus a correct reference — get either wrong and the verdict lies.

Failure modes & checklist

Failure modes

  • Demanding bit-identical results from the compiler. Treating any difference from eager as a bug. Signal: you "found a numerical regression" that is uniform ~1e-3 drift in bf16 — that's reassociation/different-kernel rounding, not breakage.
  • Comparing against the wrong reference. Diffing the compiled bf16 model against the eager bf16 model — both are imprecise, so you can't tell which is right. Signal: the "error" disappears when you re-run because eager itself drifted; you needed an fp32 reference.
  • Tolerance too loose / too tight. Loose hides a real bug as "close enough"; tight trips on every benign run. Signal: a quant bug that drops eval accuracy 3 points passes allclose — anchor to the task metric, not raw tensors.
  • Chasing a recompile storm as a "slowness bug" in the kernels. Profiling the kernel when the real cost is recompilation. Signal: the GPU is idle and TORCH_LOGS=recompiles shows a climbing cache size with a shape guard — it's compiling, not running.
  • Leaving determinism on in production. Paying 10–30% for run-to-run reproducibility nobody needs at serve time. Signal: serving is mysteriously slow and matmuls aren't using TF32 — the deterministic switch is still set from a debug session.
  • Calling atomics noise a bug. Treating run-to-run last-bit variation as a defect. Signal: the "nondeterminism" vanishes under use_deterministic_algorithms(True) — it was scatter-add reduction order, harmless.

Checklist

  • Always diff against an fp32 reference. Eager-bf16-vs-compiled-bf16 can't tell you which is correct; fp32 is your proxy for the true answer.
  • Pick rtol/atol from the task, not from zero. Anchor to perplexity/accuracy; raw allclose is a screen, the metric is the verdict.
  • Let the minifier bisect. TORCHDYNAMO_REPRO_AFTER + accuracy minifier (REPRO_LEVEL=4) shrinks to the offending subgraph and pass — ship that repro, not the 70B.
  • Read the logs before guessing. TORCH_LOGS=graph_breaks,recompiles,guards + tlparse names the break/guard exactly; storms are diagnosed, not intuited.
  • Determinism on to debug, off to ship. Stable target for bisection; pay the perf back in production where ulps don't matter.
  • Set accumulation dtype explicitly. Reduced-precision accumulation is a real-bug source, not benign drift — don't inherit a backend default.

Checkpoint

Try it
Open the detective and turn on fusion-reassociation and TF32 together with rtol at 1e-2: read the max rel error and confirm the verdict is "within tolerance — benign drift." Now add int8 quantization and note the error jumps but still passes — that's the accuracy budget, the thing you'd verify with a task metric rather than allclose. Reset, turn on only the real bug, and confirm it fails at every tolerance you can set above the floor — a bug doesn't hide behind a reasonable tolerance. Finally, with just fusion drift on, drag rtol from 1e-2 down to 1e-5 and find the point where benign drift flips to "FAIL." That crossing is tolerance triage: predict, before you ship, whether your chosen rtol would have falsely failed a clean compiled model — and if so, loosen it to the task metric.

Where this points next

You can now do the thing the whole production half was missing: not just build a fast, fitting model, but trust it. You can tell an acceptable float drift (reassociation, TF32, a different kernel — uniform, tiny, ship it) from a real bug (growing error, NaN, a clipped channel — minify it); you can let the accuracy minifier hand you a 20-line repro tagged with the guilty pass; you can read TORCH_LOGS/tlparse to catch a graph-break or recompile storm before it eats your throughput; and you can force determinism to get a stable target, knowing what it costs. That closes the loop the track has been building since lesson 04 — every "preserve semantics" promise now has a way to verify the preservation and a defined boundary (fast-math, quant) where it's relaxed on purpose. With build (00–13), ship (14–16), and trust (17) all in hand, you've seen every part of the machine in isolation. The remaining question is how the real systems wire them together — and whether torch.compile, XLA, TVM, MLIR, and TensorRT are really the same chain under different names. That's lesson 18, The real stacks.

Takeaway
A correct compiler is not bit-identical to eager: fusion and reassociation (04) reorder float adds, different kernels accumulate in different orders, the accumulation dtype changes precision, and quantization (15) is deliberately approximate — so "the numbers differ" is the normal state, not a bug report. The classifier is an fp32 reference plus a two-part tolerance |compiled − ref| ≤ atol + rtol·|ref|: uniform ~1e-3 drift is benign and ships; error that grows per layer or produces NaN is a real bug. To localize it, bisect — the minifier (TORCHDYNAMO_REPRO_AFTER, accuracy minifier REPRO_LEVEL=4) auto-shrinks a huge failing model to the smallest subgraph and the offending pass. The other failure is silent slowness: TORCH_LOGS=graph_breaks,recompiles,guards + tlparse diagnose a graph-break storm (Dynamo fell back to eager, fusion can't cross the seam — callback 02) or a recompile storm (a shape guard thrashes the cache — callback 11). And determinism — nondeterministic from atomics/reduction order, TF32, and accumulation dtype (all 04's non-associativity) — is a 10–30% perf switch you keep on to debug (stable target) and off to ship. The triage: slow → read the logs; wrong → diff the fp32 reference then minify; flaky → force determinism then bisect. Trust is the last thing the production stack needed.

Interview prompts