all_lessons/ai_compilers / lessons/11 · dynamic shapeslesson 12 / 20

Part V — The hard parts

Dynamic shapes & control flow

Every pass in Parts III and IV bought its speedup by knowing the exact shapes. Fusion (lesson 05) grouped ops because it knew the tile counts; the memory planner (06) sized an arena to the byte; layout assignment (07) matched tensor-core fragments to concrete dimensions; the scheduler (08) and autotuner (10) searched tile sizes against one specific M, N, K and cached the winner under those numbers. That whole machine assumed the shapes are constants known at compile time. Production serving violates the assumption on the first request: batch size changes every microbatch, sequence length grows by one token every decode step, inputs arrive ragged, and a router sends a different number of tokens to each expert. If a fresh compile fires on every distinct shape you get a compile storm that buries the runtime. This lesson is how a compiler keeps the speedup while the shapes move.

The previous step left this broken
Autotuning (lesson 10) made the schedule space tractable by paying a one-time search and caching the winning config under a key built from the concrete shapes and dtypes. That cache is the whole economy: search once, replay a million times. But the model is only re-run a million times at the same shape. An autoregressive decoder runs the attention kernel at sequence length 1, then 2, then 3, … up to thousands — every step is a fresh cache key, a cache miss, and a re-trigger of the entire capture → fuse → plan → tune pipeline. A serving endpoint that sees batch sizes 1, 3, 7, 12, 31 in a minute compiles five times before it answers anyone. The static-shape assumption that made every earlier pass cheap is exactly what makes the compiled artifact unusable the moment shapes vary.
Linear position
Forced by: fusion, planning, layout, and autotuning all specialized to concrete static shapes, but real serving has variable batch size, sequence length that grows every decode step, ragged inputs, and data-dependent control flow — full specialization re-pays the entire compile cost on every distinct shape.
New idea: handle dynamism without recompiling each call — symbolic shapes (carry dimensions as variables s0, s0+1 with constraints, compile once for all sizes), guards + recompilation (specialize, guard the assumption, recompile only when a guard is violated), bucketing/padding (compile a handful of size buckets, pad each request up to the nearest — trade wasted FLOPs for cache hits), and control-flow ops (cond/while/scan captured as real graph nodes instead of unrolled or graph-broken).
Forces next: even fully handled, everything so far compiled the forward / inference graph. Training needs the backward graph — which you never wrote — and the compiler must build and optimize it itself. That is autodiff as a compiler pass (lesson 12).
The plan
Five moves. (1) Show why dynamism is everywhere — batching, autoregressive decode, ragged inputs, MoE routing. (2) Price the two extremes: full specialization (safe, but compile storm) vs symbolic shapes (one compile, slightly slower kernels). (3) The guard + recompile mechanism, and the 0/1 specialization gotcha that bites everyone. (4) Bucketing and padding in real serving stacks — the wasted-compute trade. (5) Data-dependent control flow as graph ops vs the graph break. Then drive the widget until specialize-each becomes a compile storm and symbolic flattens it.

1 · Why the shapes never hold still

Define the problem precisely. A static shape is a tensor dimension whose value is a compile-time constant — (batch=8, seq=512, d=4096). A dynamic shape (a.k.a. symbolic shape) is a dimension whose value is only known at run time and varies between calls. Earlier passes loved static shapes because every cost they minimized — bytes in a fused tile, slots in the arena, the autotune cache key — is a function of the exact numbers. Four forces make those numbers move in any real deployment:

variable batch size
A server batches whatever requests are in flight this 10 ms window — 1, then 5, then 23. Continuous batching (vLLM) changes the batch dimension on essentially every forward pass.
autoregressive decode
The killer. Each generated token grows the sequence by one, so the KV-cache length is 1, 2, 3, … N. Attention runs at a different sequence length on every single step.
ragged inputs
Prompts are 12 tokens or 4,000 tokens. Padding a batch to its longest member wastes compute on the pad; not padding means every row is a different length.
MoE routing
A top-k router sends a data-dependent, run-time-decided number of tokens to each expert. The per-expert GEMM has a shape that is literally not knowable until the routing argmax runs.

Two of these are shape dynamism (the dimensions vary) and two shade into control-flow dynamism (which ops even run depends on the data — §5). Both break the static-shape contract. The compiler now has exactly three families of answer: specialize per shape and eat recompiles, reason symbolically and compile once, or normalize the shapes (bucket/pad) so only a few distinct ones ever reach the compiler.

2 · Full specialization vs symbolic shapes

Full specialization is the naive correct answer: treat every dimension as a constant, compile a fresh, fully-optimized kernel for each shape you see, and key it in the autotune cache (lesson 10). It is the fastest per call — the kernel knows its trip counts, can unroll loops, and was tuned at exactly this size. It is also correctness-trivial: nothing varies inside a compiled artifact. The catch is the compile bill. PyTorch compile and Inductor compile times run from roughly 1–60 s per graph depending on size and max-autotune; XLA HLO compiles are comparable. Pay that on the first appearance of every shape:

workloaddistinct shapes seenfull-specialization cost
fixed-shape training step1compile once, amortize over millions of steps — ideal
serving, batch ∈ {1…32}≈ 32up to 32 compiles before steady state — minutes of cold start
decode, seq 1 → 2,048≈ 2,048compile storm — recompile on literally every token; serving never warms up

The decode row is fatal: you would spend seconds compiling for each step that takes microseconds to run. So full specialization is right only when the shape set is tiny and stable — which training is and serving is not.

Symbolic shapes are the principled fix. Instead of baking a dimension as the constant 512, the compiler introduces a shape variable — call it s0 — and compiles a single kernel that is correct for all values of s0. Derived dimensions are tracked as symbolic expressions: if the KV cache is length s0 and you append one token it becomes s0+1; a reshape that splits s0 into heads carries s0/h as a constraint the compiler must prove divides evenly. This is exactly TorchDynamo's symbolic shapes machinery (it uses SymInt and a small symbolic solver, sympy, to discharge the constraints) and XLA's dynamic dimensions. The trade is real but small: a symbolic kernel can't hard-unroll a loop whose bound is a variable, and the autotuner must pick one config that is good across the size range rather than perfect at one size, so you give up maybe 5–20% of peak — in exchange for compiling once instead of a thousand times.

static  (recompile each call):   matmul[M=512, N=4096, K=4096]   key=(512,4096,4096) → MISS → compile
                                 matmul[M=513, N=4096, K=4096]   key=(513,4096,4096) → MISS → compile
                                 matmul[M=514, ...]              ... a new compile every step

symbolic (compile once):         matmul[M=s0,  N=4096, K=4096]   one kernel, valid for all s0 ≥ 1
                                 guard: s0 ≥ 2  (because s0=1 was specialized away — see §3)

In PyTorch you reach this with the dynamic flag on torch.compile: dynamic=True opts every dimension to symbolic from the first call; dynamic=False forces full specialization (one compile per shape); the default is adaptive — specialize on the first shape, then if a second distinct value of a dimension appears, promote that dimension to symbolic and recompile once. The deeper mechanics of this live in gpu_kernels · 23 torch.compile.

3 · Guards and the recompile trigger

How does a compiled artifact know it is still valid? A guard is a cheap boolean predicate, attached to a compiled graph, that asserts every assumption the compiler relied on: dtypes, devices, the rank of each tensor, which dimensions are static and what their values are, and the constraints on the symbolic ones (s0 ≥ 2, s0 % 8 == 0). Before every call the runtime evaluates the guard set in a few microseconds. Guard passes → cache hit → replay the compiled kernel. Guard fails → cache miss → recompile, specializing to the new fact (or relaxing a static dim to symbolic), and add the new artifact to the cache. Guards are the bookkeeping that makes "specialize but stay safe" possible: the optimizer is allowed to assume anything it likes, as long as it writes the assumption down as a guard so a violation is caught instead of producing a silently wrong result. This is the same preserve-semantics invariant from lessons 03–04, now enforced dynamically.

compileCapture the graph for the current inputs; record every assumption (dtypes, ranks, which dims are static and their values, symbolic constraints) as a guard set.
guard checkOn each subsequent call, evaluate the guards (≈ µs). All true → cache hit. This is the fast path that runs for the vast majority of calls.
violationA guard is false — a new dtype, a static dim took a new value, a constraint broke. Cache miss. Fall through to recompile.
recompileRe-specialize (or widen a dim to symbolic) and cache the new artifact. Pay the compile cost once; a storm of these is the failure to avoid.

The trap everyone hits: the 0/1 specialization gotcha. Dimensions equal to 0 or 1 are special — a size-1 dimension broadcasts, and a great many rewrites are only valid when a dim is exactly 1 (a "squeeze," a broadcast, dropping a unit batch). So compilers always specialize on 0 and 1 rather than treat them as symbolic. The consequence: if your very first call has batch=1 (extremely common — you smoke-test with a single example, or decode generates one sequence), the guard becomes "batch == 1" baked in. The next call with batch=8 violates it and forces a recompile, and now you carry two artifacts and a guard s0 ≥ 2 on the symbolic one. The fix is to warm up with a representative shape that is not 0 or 1 (and not the longest, either) so the compiler generalizes from a typical case. Watch for it with TORCH_LOGS="recompiles,dynamic" — every recompile prints the guard that fired and the shape that broke it.

4 · Bucketing and padding in serving

Symbolic shapes are elegant, but two realities push serving toward a blunter tool. First, some kernels really are faster fully specialized — a fixed trip count unrolls and the autotuner nails one size. Second, CUDA-graph capture (the launch-overhead killer in reduce-overhead mode) needs stable shapes; a fully dynamic graph can't be captured. The workhorse compromise is bucketing with padding.

Bucketing: pick a small set of canonical sizes — say sequence buckets {128, 256, 512, 1024, 2048} — and compile (and CUDA-graph-capture) one artifact per bucket. Padding: round each incoming request up to the nearest bucket and pad the extra positions (masked so they don't affect the result). A request of length 300 runs on the 512-bucket kernel with 212 padded positions. You now compile a handful of times instead of thousands, every call is a cache hit, and you keep CUDA graphs — at the cost of wasted FLOPs on the padding.

The waste is exactly quantifiable. With buckets and a uniform spread of true lengths, the average padded length is roughly halfway up to the next bucket, so coarse buckets waste more. Concretely, a length-300 request in a 512 bucket does 512/300 ≈ 1.7× the necessary attention work. The design knob is bucket granularity: more, tighter buckets → less padding waste but more compiles and more memory pinned by CUDA graphs; fewer, coarser buckets → cheap compile and capture but fat padding. Production stacks tune this empirically and pad attention with masks so the math stays exact. See how real engines wire variable-length batching and KV-cache growth — vLLM's paged KV and continuous batching, SGLang's RadixAttention and prefix-cache reuse — in vllm · lessons and sglang · lessons; both are, at heart, strategies to keep the compiled kernels' shapes under control while the workload churns.

strategycompilesper-call kernel speedwasted FLOPsuse when
specialize each shapeone per distinct shape — stormfastest (tuned, unrolled)noneshape set tiny & stable (training)
bucket + padone per bucket (≈ 4–8)fast (specialized bucket, CUDA-graphable)padding up to next bucket (≈ 10–40%)serving with bounded length range
pad to maxone (the max shape)fast but always max-sizedhuge for short requestsalmost never — only if lengths nearly equal
symbolic shapesone, for all sizesslightly slower (5–20%)nonewide/unpredictable shape range

5 · Data-dependent control flow

The last form of dynamism isn't a shape at all — it's which ops run. A if x.sum() > 0:, a while not converged: loop, a beam-search step, an MoE dispatch: the branch taken or the iteration count depends on a value computed at run time. The capture frontends from lesson 02 have two ways to deal with it, and only one keeps the graph whole.

The lazy way is a graph break (lesson 02): Dynamo can't statically resolve the branch, so it ends the current graph, runs the Python if eagerly, and starts a new graph after. Correct, but it severs the graph in two — fusion can't cross the break, and CUDA-graph capture stops there — so you lose the very optimizations Parts III–IV bought. A branch in the middle of a hot loop can erase most of the speedup.

The right way is a control-flow op: a real graph node whose operands are the predicate and the sub-graphs for each branch, so the whole thing stays inside one capturable, optimizable graph. PyTorch exposes torch.cond(pred, true_fn, false_fn, operands) for branches and torch.while_loop / associative scan for loops; JAX has lax.cond, lax.while_loop, and lax.scan (and lax.scan is how you express a decode loop as one rolled graph op instead of an unrolled chain of thousands of nodes). The constraint these ops impose is the price: both branches of a cond must produce the same output shapes and dtypes (the compiler picks a layout and arena slot once, for both), and a scan body's carry must be shape-stable across iterations. That constraint is exactly what lets the downstream passes — fusion, memory planning, layout — treat the branch as a single typed value instead of giving up.

# Graph break (severs the graph — fusion/CUDA-graph stop here):
if x.sum() > 0:          # data-dependent Python branch → Dynamo graph-breaks
    y = f(x)
else:
    y = g(x)

# Control-flow op (one graph node — stays fused & capturable):
y = torch.cond(x.sum() > 0, f, g, (x,))   # both f,g must return same shapes/dtypes

# JAX: a 2,048-step decode as ONE rolled op, not 2,048 unrolled nodes
final_carry, outs = jax.lax.scan(decode_step, init_carry, xs)

6 · Drive it: the shape-specialization cache

The widget streams a batch of inference requests with varying sequence lengths past four strategies. Watch the same request stream produce wildly different bills. Specialize-each compiles on every new length — a compile storm whose total time is dominated by compilation, not inference. Bucket-to-N compiles only the buckets, hits the cache after, and pays a modest padding tax (the red caps on each bar). Pad-to-max compiles once but every request runs at the maximum length — minimal compiles, maximal waste. Symbolic compiles exactly once and pads nothing. The KPIs make the §2–4 trade literal: recompiles, cache hit-rate, wasted padding FLOPs, total wall time.

Shape-specialization cache: four strategies, one request stream
Each bar is one request; height = its true sequence length, the red cap = padding the strategy adds, a glowing border = this request triggered a compile. The knob that breaks: set strategy to specialize-each and widen the length range — almost every bar glows (compile storm) and total time explodes. Switch to symbolic: exactly one compile, no red caps.
recompiles
cache hit-rate
wasted padding FLOPs
total time (compile + run)
useful work (true length) wasted padding triggered a compile

Re-roll the stream a few times. Specialize-each's bill tracks the number of distinct lengths, not requests — a stream of 28 requests with 25 distinct lengths is 25 compiles. Bucketing collapses that to the bucket count no matter how many distinct lengths appear, and the padding caps shrink as you add buckets. Symbolic stays at one compile forever. That is the recompile-versus-waste trade, made into a knob.

Failure modes & checklist

Failure modes

  • Compile storm in decode. Leaving full specialization on for an autoregressive loop recompiles on every token. Signal: first-token latency is fine but throughput is glued to the floor; TORCH_LOGS="recompiles" prints a recompile per step.
  • The 0/1 warmup. Warming up with batch=1 bakes in a batch==1 guard; the first real batch recompiles. Signal: an unexpected recompile right after deploy, guard message naming the batch dim.
  • Buckets too coarse. Two giant buckets minimize compiles but pad a 300-token request to 2,048. Signal: GPU utilization high but tokens/sec low — you're computing mostly mask.
  • Data-dependent branch left as Python. An if x.sum()>0 in the hot path graph-breaks, severing fusion and CUDA-graph capture. Signal: measured speedup ≈ 1.0×; TORCH_LOGS="graph_breaks" flags the branch.
  • cond branches with mismatched shapes. The two arms return different shapes/dtypes, so the op won't compile. Signal: a constraint error at trace time demanding equal output specs from both branches.

Checklist

  • Inventory the moving dims — batch, sequence/KV length, ragged padding, expert counts. Each is a recompile axis until handled.
  • Mark dynamic dims explicitly (dynamic=True or mark_dynamic) so the compiler reasons symbolically instead of specializing.
  • Warm up with a representative shape — not 0, not 1, not the maximum — so guards generalize.
  • Bucket the serving length axis and pad with masks; tune granularity against the padding-waste vs compile-count trade.
  • Express control flow as ops (torch.cond/scan, lax.cond/scan), not Python branches, to keep the graph whole.
  • Watch the logsrecompiles, dynamic, graph_breaks — every recompile and break costs you the optimizations of Parts III–IV.

Checkpoint

Try it
Open the widget, set strategy to specialize each shape, requests to 28 and max length to 2,048: note the recompile count (close to the number of distinct lengths) and the total time — almost all of it is compilation. Now switch to bucket to N with 4 buckets: recompiles drop to 4, hit-rate jumps, and a thin red padding cap appears on most bars. Slide buckets up to 8 and watch the caps shrink (less waste) while recompiles rise to 8 (more compile). Finally pick symbolic shapes: one recompile, zero padding, total time near pure run cost. Predict before you check: at 8 buckets over a length range of 256–2,048, what is the worst-case padding ratio for a request that lands just above a bucket boundary? (Roughly the bucket spacing over the request length — coarser buckets, fatter worst case.)

Where this points next

You can now keep the Part III–IV speedups while batch size, sequence length, and control flow all move at run time: symbolic shapes to compile once, guards to specialize safely, bucketing to bound the compile count, and control-flow ops to keep branches and loops inside the graph. But notice what every one of those handled — the forward graph. Everything from capture (02) through autotuning (10) and this lesson has compiled inference: the path from inputs to outputs. Training needs the backward graph too — the gradients — and you never wrote it. Hand-writing a backward for a fused, planned, laid-out, dynamically-shaped forward is hopeless; the compiler must build the backward graph from the forward and then run the same fusion, planning, and layout passes on it. That makes differentiation itself a compiler pass — lesson 12, Autodiff as a compiler pass.

Takeaway
Every pass in Parts III and IV bought its speedup by specializing to concrete static shapes and caching the result under those numbers — but production serving moves the shapes on every call: variable batch, KV length that grows each decode step, ragged prompts, MoE routing. Full specialization stays correct but triggers a compile storm — a recompile per distinct shape, fatal in decode where the cost dwarfs the microsecond kernels it protects. Four tools keep the speedup. Symbolic shapes carry dimensions as variables (s0, s0+1) with constraints and compile once for the whole size range, trading ≈5–20% peak for one compile. Guards are cheap predicates recording every assumption the optimizer made; they pass → cache hit, fail → recompile — the dynamic enforcement of the preserve-semantics invariant — and the 0/1 gotcha means warming up at batch 1 bakes in a guard that the first real batch violates. Bucketing + padding compiles a handful of canonical sizes and rounds each request up, keeping cache hits and CUDA graphs at the cost of wasted FLOPs on the pad (the granularity knob: tighter buckets = less waste, more compiles). And control-flow opstorch.cond/scan, lax.cond/scan — capture branches and loops as real graph nodes (with equal output specs across arms) instead of graph-breaking into eager, so fusion and capture survive. This is the defining hard problem of ML compilers — and it is the last one about the forward graph: training forces the compiler to build the backward graph itself.

Interview prompts