← Blogs

Decode is memory-bound by construction

2026.08.30  ·  Notes  ·  inference performance

Prefill and decode run the same weights through the same kernels. They land on opposite sides of the roofline anyway — about 430× apart in arithmetic intensity on the configuration I worked through below.

That much is folklore. What I wanted was the closed form: not "decode is memory-bound," but how far from the ridge point, and what exactly you would have to change to move it. The answer turns out to be a two-line expression, and it says something fairly blunt — for multi-head attention at a short context, no batch size whatsoever gets decode to the ridge point. It is not a tuning problem.

The frame, compressed

A kernel doing FF FLOPs and moving BB bytes has two independent lower bounds on its runtime: F/peak FLOP/sF/\text{peak FLOP/s} and B/peak bytes/sB/\text{peak bytes/s}. On a modern GPU the two overlap — the next tile streams in while the current one multiplies — so the runtime is bounded by the larger, not the sum. Which one wins depends on one ratio, the arithmetic intensity AI=F/B\mathrm{AI} = F/B, compared against a property of the hardware alone:

RidgePoint=peak FLOP/speak bytes/s\mathrm{RidgePoint} = \frac{\text{peak FLOP/s}}{\text{peak bytes/s}}

H100 SXM: 989 TFLOP/s dense BF16 over 3.35 TB/s of HBM gives 295 FLOPs/Byte. Worth noticing that H200 keeps the same compute and adds bandwidth, which pulls the ridge point down to 206. For a memory-bound workload a lower ridge point is the improvement — that is the whole reason it is the better decode card.

The measurement

Reference model, chosen to be boring: d=4096d = 4096, 32 layers, 32 heads, head_dim 128, FFN 4d4d, MHA, FP16. That is 12d212d^2 parameters per layer, 6.44 B total, 12.88 GB of weights.

 Prefill, 512 tokDecode, 1 tokratio
FLOPs6.73 T13.2 G512×
bytes — weights12.9 GB12.9 GB1×
bytes — KV cache268 MB268 MB1×
bytes — activations2.42 GB4.7 MB512×
bytes — total15.6 GB13.2 GB1.2×
AI4331.00433×
vs. ridge 295compute-boundmemory-bound 

The compute fell by 512×. The memory traffic barely moved. Decode reads 13.2 GB out of HBM to produce one token, and the weight term — 12.88 GB of it — does not care how many tokens you are serving. Reading the weights is the cover charge for a forward pass.

Roofline for H100 SXM with prefill and decode marked
Same weights, same kernels, opposite sides of the ridge.

Two regimes, not one

Averaging decode into a single AI number hides the mechanism. Decode is really two workloads with different scaling behaviour, sharing a kernel launch. Separating them is where it gets interesting.

The GEMM half. Weights are shared across the batch — read once, used by all BB sequences. So

AIdecodeGEMM=2PB2P=B\mathrm{AI}^{\text{GEMM}}_{\text{decode}} = \frac{2PB}{2P} = B

Decode arithmetic intensity equals the batch size. At B=1B = 1 that is AI = 1, matching the table. To reach 295 you would need ~295 concurrent sequences. This is the entire justification for continuous batching, and it is a clean one.

The attention half. Per layer per sequence, decode attention costs 4Sd4Sd FLOPs and reads 4Sdkv4Sd_{kv} bytes of KV cache. So

AIdecodeattn=4Sd4Sdkv=nhnkv=g\mathrm{AI}^{\text{attn}}_{\text{decode}} = \frac{4Sd}{4Sd_{kv}} = \frac{n_h}{n_{kv}} = g

Everything cancels. The context length SS cancels. The batch size BB never appears at all — each sequence owns a private KV cache, so there is nothing to share across the batch.

For MHA, decode attention has an arithmetic intensity of 1. Not "1 at batch 1" — 1 at every batch size and every context length. No amount of batching moves it.

The only lever on that term is gg: give several query heads one shared KV head. Which is exactly what GQA and MQA do. I had always filed those under "saves memory"; the ratio above says the bandwidth story is the same story, seen from the other side.

The ceiling

Put the halves together and let the batch run away:

AIdecode(B,S)=2PB+4BLSd2P+4BLSdkv  →B→∞  2P+4LSd4LSdkv\mathrm{AI}_{\text{decode}}(B, S) = \frac{2PB + 4BLSd}{2P + 4BLSd_{kv}} \;\xrightarrow[B \to \infty]{}\; \frac{2P + 4LSd}{4LSd_{kv}}

The BB in the numerator cancels against the BB in the KV term, so this converges rather than growing. Substituting the standard shape P=12Ld2P = 12Ld^2 collapses it to something you can hold in your head:

AIdecodemax⁡=g(6dS+1)\mathrm{AI}^{\max}_{\text{decode}} = g\left(\frac{6d}{S} + 1\right)

At d=4096d = 4096, S=512S = 512, MHA: 49. Against a ridge point of 295. I checked this two ways — direct computation and the closed form agree to the decimal — because a result this convenient is usually a dropped factor of two.

Decode arithmetic intensity vs batch size for MHA, GQA and MQA
Batching lifts the GEMM half linearly and then runs into the KV cache. Only at GQA-4 does the ceiling clear H100's ridge point at all.

Two things fall out of the 1/S1/S dependence. At 2K context the ceiling is 13; at 8K it is 4.0; at 128K it is 1.2. And at 128K the KV cache for a single sequence is 68.7 GB against 12.9 GB of weights — the workload has stopped being about the model at all. It is a memory-streaming problem wearing a Transformer costume.

Decode arithmetic intensity ceiling versus context length
The ceiling falls like one over context length: the weights are fixed, the KV cache is not. Even MQA drops below the ridge point past ~2K context.

What continuous batching is actually buying

If AI≈B\mathrm{AI} \approx B, then scheduling is a performance feature, not a plumbing detail. Two things stop you from getting the BB you configured.

Slot occupancy. A static batch runs until its slowest member finishes, so it burns B⋅max⁡iLiB \cdot \max_i L_i slot-steps while only B⋅meaniLiB \cdot \text{mean}_i L_i are useful. For iid exponential output lengths E[max⁡]=mean⋅HB\mathbb{E}[\max] = \text{mean} \cdot H_B, so occupancy is exactly 1/HB1/H_B — decaying like 1/ln⁡B1/\ln B. At B=32B = 32 that is 24.6%: you configured 32 and you are running about 8. Continuous batching refills a slot the step after it frees, which is why it is worth the scheduler complexity.

Capacity. After weights, an 80 GB card has ~65 GB for KV cache. At 512 context that is 242 concurrent sequences; at 8K it is 15; at 32K it is 3. Note that capacity and intensity fail together — a longer context both shrinks the batch that fits and lowers the ceiling that batch could have reached. Two bad things, same cause.

Achieved decode arithmetic intensity, static versus continuous batching
The shaded gap is what a static scheduler leaves on the floor. Neither curve reaches the ridge point, because neither can.

What chunked prefill is actually buying

Different problem, same arithmetic. A prefill occupying the GPU stalls every decode step queued behind it, so users mid-generation watch their inter-token latency spike. At batch 32 an undisturbed decode step is ~8 ms. A 512-token prefill adds ~14 ms — survivable. An 8K prefill adds 285 ms, and a 32K prefill adds two seconds, because attention is quadratic and prefill is where that actually bites.

Split the prompt into chunks and interleave them with decode steps, and a queued decode waits for at most one chunk — a constant, independent of prompt length. At 32K that is 39 ms instead of 2000 ms.

Worst-case time per output token versus prompt length, chunked and unchunked
Unchunked, the stall grows super-linearly with the prompt. Chunked, it is a constant you choose. (50% MFU, 80% of peak bandwidth — assumptions, not measurements.)

So make the chunks small? No — and this is the part I like. A step processing NN tokens has AI≈N\mathrm{AI} \approx N, by the same algebra that gave decode AI≈B\mathrm{AI} \approx B. A chunk below the ridge point is memory-bound, wasting compute in precisely the way batch-1 decode does. The chunk size is squeezed from both ends: large enough to stay compute-bound, small enough that one chunk fits the TPOT budget. On H100 the lower end is ~295 tokens in theory and closer to 512 once activation traffic is counted.

The part I got wrong first

The tidy version of this is: tokens per step sets the AI, so continuous batching and chunked prefill are one mechanism seen from two ends — both exist to keep tokens-per-step above the ridge point.

That is a good sentence and it is not quite true. I built the table before I believed it, which is the only reason I noticed. A decode token drags its entire KV cache along; a prefill-chunk token does not. So the two kinds of token are not interchangeable:

decode Bchunk ctokensAIKV share of bytes 
10112%memory-bound
320322040%memory-bound
25602564184%memory-bound
05125125124%compute-bound
3251254433141%compute-bound
25651276812584%memory-bound

256 decode tokens and a 512-token chunk have comparable token counts and sit on opposite sides of the ridge. Tokens-per-step is the first-order knob; the KV column is the second-order term that decides whether the knob still works. The practical reading is narrower than the tidy sentence and more useful: mixing a prefill chunk into a decode batch is what lifts a step over the ridge — the decode tokens alone never will.

What I would want to measure

Written while working through the arithmetic rather than after; the derivations are mine and reproducible, the efficiency assumptions are guesses, and I would like to be corrected on the third bullet in particular.

Code Every number and figure here is produced by llm-prefill-decode-roofline — scripts/roofline.py for the intensities and closed forms, scripts/serving.py for the scheduling analysis. GPU specifications from NVIDIA datasheets (dense BF16, no structured sparsity).