LLM Inference | For You
Lecture 5 of 9

Prefill–Decode Disaggregation

The main question of this lecture: prefill and decode want different things. What if we simply never make them share a GPU? And what does the KV moving truck cost?

Lecture 4 ended on an uncomfortable note. You learned a beautiful trick, chunked prefill, which lets a long prompt share a step with many decodes by capping the chunk size \(c\). But the trick has a knob, and the knob is cruel: whatever you give to prefill, you take from decode, and vice versa. You tuned and tuned, and the best you could do was pick one point on a trade-off curve where both of your SLOs were a little bit unhappy.

This lecture is about the moment you stop tuning the knob and remove it. The idea is as physical as it gets: prefill is a compute-hungry GEMM machine, decode is a bandwidth-hungry memory machine. So give each its own pool of GPUs, tuned its own way, on its own hardware if you like. A request is born in the prefill pool; its KV cache is then shipped to the decode pool, where it lives out its days generating tokens. The price of admission is one moving truck: the KV transfer. Most of this lecture is about quantifying that truck, hiding it, and sizing the two pools so the whole arrangement is worth the fuel.

Main idea: Run prefill and decode as two independent pools, sized and scaled separately; pay one KV transfer per request in between. TTFT is then governed by the prefill pool alone, TPOT by the decode pool alone. The TTFT–ITL zero-sum of Lecture 4 collapses into two independent operating points.

Why One Pool Cannot Satisfy Two SLOs: The Zero-Sum, Formalized

Recall the continuous-batching step from Lecture 4: each iteration runs \(B\) decode tokens (one per active request) plus one prefill chunk of \(c\) tokens. For a dense model with \(P\) parameters in BF16 (weight bytes \(= 2P\)), one iteration costs \[ \text{FLOPs}_{iter} = 2P\,(c + B), \qquad \text{bytes}_{iter} = 2P + B\bar{L}\,kv + c\,kv, \] where \(\bar{L}\) is the mean resident context of the decode batch (every decode step re-reads the whole KV cache of every active request, Lecture 1) and \(c\,kv\) is the chunk's freshly written KV. The roofline then bounds the step time: \[ t_{iter}(c) \ge \max\!\left(\frac{2P\,(c+B)}{\pi_{eff}},\, \frac{2P + B\bar{L}\,kv + c\,kv}{\beta}\right). \]

Now here is the trap, in one line. A prompt of length \(L\) needs \(L/c\) such iterations to prefill, and each of those iterations is also a decode step for all \(B\) requests. Both SLOs are functions of the same knob: \[ \mathrm{TTFT}(c) \approx \frac{L}{c}\, t_{iter}(c), \qquad \mathrm{TPOT}(c) \approx t_{iter}(c). \] This is the zero-sum: raising \(c\) shortens TTFT but stretches every decode step; lowering it protects TPOT but stretches TTFT. One pool, two SLOs, one knob: you can only slide along the curve.

Worked example (the curve is steep; do it with me): Llama-3.3-70B, BF16 (\(2P = 140\) GB), one H100 (\(\pi_{eff} = 0.5 \times 990\) TFLOPS, \(\beta = 3.35\) TB/s; a capacity idealization: 140 GB does not fit in 80 GB, but per-GPU intensity is unchanged under TP, see Lecture 6). Decode batch \(B = 128\), each request holding \(\bar{L} = 4{,}096\) tokens of \(kv = 320\) KiB/token, so the batch reads \(128 \times 4{,}096 \times kv = 171.8\) GB of KV per step. Prompt \(L = 4{,}096\):
chunk \(c\)\(t_{iter}\)TPOTTTFT (\(L\) = 4K)
0 (decode only)93.1 ms93.1 msn/a
12893.1 ms93.1 ms2.98 s
512181.0 ms181.0 ms1.45 s
2,048615.4 ms615.4 ms1.23 s
Check \(c = 0\): bytes \(= 140 + 171.8 = 311.8\) GB \(\Rightarrow 311.8/3.35 = 93.1\) ms. That is bandwidth-bound (compute is only \(2PB/\pi_{eff} = 36\) ms). Check \(c = 512\): FLOPs \(= 140\times 10^9 \times (512+128) = 89.6\) TFLOP \(\Rightarrow 89.6/495 \times 10^3 = 181\) ms, now compute-bound (the byte side barely moves: \(+c\,kv\) adds 0.2 GB against 311.8 GB); TTFT \(= (4{,}096/512) \times 181\) ms \(= 1.45\) s. And look at the last row: TTFT is still no better than the \(L \cdot 2P/\pi_{eff} = 1.16\) s a dedicated prefill pass would achieve, while TPOT has degraded \(6.6\times\). Every point is a compromise. No setting of \(c\) dominates disaggregation; the curve lies entirely above the two private optima.

Disaggregation simply deletes the shared knob. Two pools, two instances of the same bound, no cross-terms: the prefill pool runs whole prompts (\(B = 0,\; c = L\)), the decode pool runs pure decode (\(c = 0\)): \[ \mathrm{TTFT} \approx \underbrace{\frac{2PL}{\pi_{eff}^{(P)}}}_{\text{prefill compute}} \;+\; t_{queue}^{(P)} \;+\; t_{transfer}, \qquad \mathrm{TPOT} \approx \underbrace{\frac{2P + B\bar{L}\,kv}{\beta_{agg}^{(D)}}}_{\text{decode step}}. \] Each SLO now depends only on its own pool's capacity and queue. The trade-off curve has collapsed into two independent points. That is the whole trick. Everything else in this lecture is the price tag.

One pool (chunked prefill): decode + prefill chunk c stretched step: ITL spike for everyone decode + next chunk another TTFT slice, another spike time: TTFT = (L/c) stretched steps · TPOT = stretched too Two pools (disaggregated): prefill pool → whole prompt, c = L next prompt decode pool → KV moves once, off the critical path ↓ decode steps never stretch: TPOT flat
How to: walk the top timeline left to right. Every green block is a step that all \(B\) decode requests must sit through. Below, the same work with the knob removed: the prefill pool chews whole prompts, the decode pool ticks evenly, and the dashed arrow is the only thing that ever crosses between them.

Two Phases, Two Optima, and Two Independent Autoscalers

Why does physical separation beat a well-tuned \(c\)? Because the two phases disagree about everything, not just the token budget:

Prefill poolDecode pool
Bottlenecktensor-core compute (\(I \gg\) ridge)HBM bandwidth & capacity (\(I \ll\) ridge)
Batch policyno batching needed; one long request saturates computebatch as large as possible (intensity \(\propto B\), Lecture 1)
Parallel configsmall TP; CP for long context (Lecture 3)large EP/TP: bigger KV pool, more aggregate bandwidth (Lecture 6)
Hardwarecompute-oriented GPUbandwidth/memory-oriented GPU, even a different generation

The decode intensity formula from Lecture 1, \(I \approx 2PB/(2P + B\bar{L}kv)\), makes the batching row precise: decode only nears the ridge when \(B\) is large, and large \(B\) needs a large KV pool, which is exactly what a bandwidth/capacity-oriented configuration gives. Prefill sits at \(I \approx L\) (hundreds to thousands) and is compute-bound on any realistic hardware; extra batching buys it almost nothing. In one pool, both phases share one parallel config and one hardware SKU; disaggregated, each takes its own optimum.

And because the SLOs decoupled, the scalability signals decouple too:

A single-pool deployment mixes the two pressures and scales the whole pool for either one. It pays for compute when it needs memory, and vice versa.

Engineering takeaway: autoscale the two pools on different signals: prefill replicas from queued uncached input tokens per second, decode replicas from concurrent sessions and KV-pool occupancy. Wiring both signals to one homogeneous pool is the classic way to pay twice for the same SLO.

The Moving Truck: KV Transfer and the Feasible Region

Disaggregation is not free: every request's KV must physically move from the prefill pool to the decode pool. Time to put numbers on the truck. From Lecture 1, one request carries \[ V_{KV} = L \cdot kv, \qquad kv = 2 \cdot n_{layers} \cdot n_{kv} \cdot d_h \cdot b_{dtype} \;\; \text{bytes/token}, \] and over a link of bandwidth \(BW_{link}\) the serialized transfer takes \[ T_{transfer} = \frac{L \cdot kv}{BW_{link}}. \]

Take the canonical inter-node link (400 Gbps InfiniBand ≈ 50 GB/s per GPU, about \(18\times\) below the 900 GB/s NVLink domain) and our two canonical models:

Model\(kv\)\(V_{KV}\) @ L = 128K\(T_{transfer}\) @ 50 GB/s
Llama-3.3-70B (GQA)\(2 \times 80 \times 8 \times 128 \times 2\) B = 320 KiB/tok40 GiB ≈ 43 GB≈ 0.86 s
DeepSeek-V3 (MLA)\(576 \times 61 \times 1\) B (FP8) ≈ 34.3 KiB/tok≈ 4.3 GiB≈ 92 ms

Look at the GQA row. Against a typical 2 s TTFT SLO, that truck alone would eat 40%+ of the budget. The same 40 GiB crosses an NVLink domain at 900 GB/s in ≈ 48 ms. So the conclusion: cross-node PD disaggregation of large dense GQA models is basically infeasible at long context. It needs in-NVLink-domain transfer or KV compression. This is a system-level benefit of everything Lecture 2 did to \(kv\): MLA and quantization enlarge the feasible region of PD disaggregation directly.

Let us make "feasible" precise. Declare a transfer budget \(t_{budget}\), the fraction of the TTFT SLO you are willing to spend on the wire. Transfer is feasible iff \[ \frac{L \cdot kv}{BW_{link}} \le t_{budget} \qquad\Longleftrightarrow\qquad kv \le kv_{max} = \frac{t_{budget} \cdot BW_{link}}{L}. \] Note the \(1/L\): every 4× of context growth shrinks the admissible bytes-per-token 4×.

Worked example (the feasibility table): \(BW_{link} = 50\) GB/s, TTFT SLO = 2 s, engineering budget \(t_{budget} = 10\% = 200\) ms, so \(kv_{max} = 0.2 \times 50\text{ GB} / L\):
\(L\)\(kv_{max}\)GQA-70B (320 KiB)MLA (34.3 KiB)GQA \(T\)MLA \(T\)
8K1.16 MiB/tok54 ms5.8 ms
32K298 KiB/tok215 ms23 ms
128K74.5 KiB/tok859 ms92 ms
1M9.3 KiB/tok6.9 s737 ms
Reading: GQA-70B crosses the boundary between 8K and 32K; MLA survives to 128K, but even MLA fails at 1M over 50 GB/s. There, a ≤ 200 ms transfer needs ≥ 184 GB/s, i.e. the NVLink domain (900 GB/s ⇒ 41 ms). Compressing \(kv\) by 9.3× (320 → 34.3 KiB) moves the cross-node boundary from ~32K to ~285K context. That is the quantitative content of "KV compression enlarges the feasible region".

TA says: here is the 2026 version of that same computation. Kimi K3's hybrid cache (69 fixed-size KDA states plus 24 Gated-MLA layers) totals \(27.0\) GiB of MLA + \(0.2\) GiB of KDA ≈ 27.2 GiB = 29.2 GB at the full 1M context. Serialized: 0.584 s over 400 Gbps IB, 32.5 ms inside an NVLink domain. Compare with GQA-70B above: 40 GiB at 128K, 0.86 s over the same IB link. K3 moves a 1M-token state more cheaply than GQA-70B moves a 128K one: 8× the context at 0.68× the bytes, because 74% of its layers carry no per-token cache at all. This has a very concrete production consequence: Moonshot recommends 64+ accelerator supernodes for K3 precisely so PD transfer at long context stays inside the NVLink domain. Architecture decisions (what to cache) and infrastructure decisions (where to disaggregate) are the same decision.

One more reading of feasibility, and this one no latency-hiding trick will ever fix. The fleet's aggregate transfer demand is \[ \lambda \cdot L \cdot kv \;\; \text{bytes/s}, \] and it must fit inside the deployed link capacity. At 128K context, one GQA-70B request per second already demands ≈ 43 GB/s: essentially a whole 400 Gbps link per request per second. Latency can be hidden; bandwidth cannot. Keep this sentence; we will need it again in a moment.

Hiding the Truck: Layer-Wise Pipelining and NIXL

Prefill produces KV layer by layer. So why wait for the whole model to finish before shipping? Once layer \(i\) is computed, its KV can start moving immediately, overlapping the computation of layer \(i+1\). The transfer then wraps up shortly after prefill ends. The only exposed remainder is the last layer's transfer.

The derivation is two lines. Layer \(i\) computes all \(L\) tokens in \(t_{comp} = 2PL/(n_{layers}\,\pi_{eff})\) and produces \(L \cdot kv/n_{layers}\) bytes, moving in \(t_{xfer} = L\,kv/(n_{layers}\,BW_{link})\). Full overlap of every layer but the last needs \(t_{comp} \ge t_{xfer}\): \[ \frac{2PL}{n_{layers}\,\pi_{eff}} \ge \frac{L\,kv}{n_{layers}\,BW_{link}} \quad\Longleftrightarrow\quad \frac{2P}{\pi_{eff}} \ge \frac{kv}{BW_{link}}, \] and both \(L\) and \(n_{layers}\) cancel. Overlap feasibility is a pure model/link property, independent of context length. When it holds, the exposed latency after a fully pipelined prefill is just \[ T_{exposed} \approx \frac{T_{transfer}}{n_{layers}} = \frac{L\,kv}{n_{layers}\,BW_{link}}. \]

Worked example (\(\pi_{eff} = 0.5\,\pi_{H100} = 495\) TFLOPS, IB 50 GB/s, L = 128K):
Model\(2P/\pi_{eff}\) vs \(kv/BW\)per-layer compute / transfer\(T_{transfer}\)exposed ≈ \(T/n_{layers}\)
GQA-70B (\(P\) = 70B, n = 80)283 µs vs 6.55 µs43×859 ms10.7 ms
MLA DSv3 (\(P\) = 37B act., n = 61)149.5 µs vs 0.70 µs213×92 ms1.5 ms
Per-layer compute dwarfs per-layer transfer in both cases, so pipelining hides >98% of even the GQA model's 0.86 s truck ride. The minimum link for full overlap is \(BW_{min} = kv\,\pi_{eff}/(2P)\): 1.2 GB/s (GQA-70B), 0.24 GB/s (MLA). Both are trivially met. The catch, repeating our sentence: pipelining hides per-request latency, not fleet-wide bandwidth contention; the aggregate \(\lambda L kv\) must still fit the link.
prefill compute (per layer, \(43\times\) longer) … layer n−1, layer n layer 1 layer 2 layer 3 KV transfer (one-sided RDMA) KV₁ KV₂ KV₃ exposed: last layer only ≈ T/n ≈ 1.5–10.7 ms without pipelining (serialized): whole prefill first … then the whole truck: +92 to 859 ms on TTFT
How to: read the two timelines together. Each tiny amber hop is one layer's KV moving while the next layer computes; when prefill ends, only the last hop is still in flight. The bottom strip is the naive alternative the pipelined design avoids.

Pipelining only works if transfers don't steal compute. The enabling mechanism is one-sided RDMA: the transfer occupies neither GPU's compute stream. Within a pool pair this is concretely NIXL (NVIDIA Inference Xfer Library), as used by Dynamo: KV is written directly from the prefill engine's VRAM into the decode engine's VRAM while forward passes at both ends keep serving other requests. Its operation, in the form relevant here:

  1. Registration. At init, every worker registers memory descriptors of all its KV blocks with a metadata service (etcd). Afterwards, cross-node addressing needs only a block id; no addresses ride the hot path.
  2. One-sided transfer. RDMA read/write: the prefill worker writes directly into (or the decode worker reads from) pre-allocated remote KV blocks, with no remote CPU or GPU involvement.
  3. Automatic backend selection. NVLink inside a domain; IB/RoCE (via UCX) across nodes; plus NVMe and object-storage backends for tiered storage (Lecture 2). One interface covers both PD transfer and KV offload; the transfer layer is shared infrastructure.

Layout rearrangement, the underestimated part. The two pools usually run different parallel configs (say TP=4 on the prefill side and a large EP+TP mesh on the decode side), so KV is sharded differently at the two ends: which head of which layer lives on which card changes across the transfer. The transfer must rearrange KV mid-flight, decomposing into many non-contiguous, multi-target scatter writes (one source block → several destination cards). This is exactly why NIXL emphasizes non-contiguous transfer semantics. Ignore it and it quietly eats the gains.

Engineering takeaway: budget the transfer system, not the transfer time: register KV blocks in a metadata service at startup, pre-allocate decode-side blocks before issuing remote prefill, and use one-sided RDMA so neither GPU's compute stream ever stalls. If your P and D pools use different TP/EP degrees, verify your transfer library's non-contiguous scatter throughput with your real shard map: headline GB/s numbers are measured on contiguous copies.

Sizing the Two Pools: The xPyD Ratio

Disaggregation hands you a brand-new design variable: \(x\) prefill instances paired with \(y\) decode instances. The ratio is determined by the workload, not by hardware, and we can build it straight from the Lecture-1 spine.

Prefill pool: compute demand. Requests arrive at rate \(\lambda\) with mean input length \(L_{in}\); a fraction \(h\) of the prefix is already cached (Lecture 2), so only \((1-h)L_{in}\) tokens per request need real compute. With FLOPs/token ≈ \(2P\) plus the attention correction \(1 + L/L^*\) approaching the crossover \(L^* = 2P/(4\,d_{model}\,n_{layers})\) (Lecture 3): \[ \text{prefill demand} = \lambda\,(1-h)\,L_{in} \;\text{tok/s}, \qquad \text{per-GPU supply} \approx \frac{\pi_{eff}}{2P\,(1 + L_{in}/L^*)} \;\text{tok/s}, \] \[ x = \left\lceil \frac{\lambda\,(1-h)\,L_{in}}{G_P \cdot \text{supply}_{GPU}} \right\rceil \;\; \text{instances of } G_P \text{ GPUs}. \] Note the hit-rate discount sitting in the numerator: the prefix hit rate directly shrinks the prefill pool. Caching is capacity, not just latency.

Decode pool: capacity and bandwidth. By Little's law, mean concurrency is \(C = \lambda\,T_{sess}\) with \(T_{sess} \approx \mathrm{TTFT} + L_{out}\cdot\mathrm{TPOT}\). A decode instance of \(G_D\) GPUs (aggregate bandwidth \(\beta_{agg} = G_D\,\beta\), weight footprint \(W\)) supports a batch bounded two ways: \[ B_{cap} = \min\Big( \underbrace{\frac{G_D \cdot \mathrm{HBM} - W}{kv\,(L_{in} + L_{out}/2)}}_{\text{KV capacity}},\; \underbrace{\frac{(\mathrm{TPOT}_{SLO} - W/\beta_{agg})\,\beta_{agg}}{kv\,(L_{in} + L_{out}/2)}}_{\text{TPOT bandwidth}} \Big), \qquad y = \lceil C / B_{cap} \rceil, \] the first bound from the KV-pool size divided by per-session KV, the second from the step-time budget \((W + B \cdot kv \cdot L_{ctx})/\beta_{agg} \le \mathrm{TPOT}_{SLO}\).

Worked example (sizing an agent workload, the full pipeline): DeepSeek-V3 (MLA; activated \(P = 37\)B, FP8 weights \(W = 671\) GB, \(kv = 34.3\) KiB/tok, 61 layers, \(d_{model} = 7168\)) on H100s. Workload: \(\lambda = 10\) req/s, \(L_{in} = 20\)K, \(L_{out} = 500\), \(h = 0.6\); SLOs TTFT ≤ 2 s, TPOT ≤ 50 ms. Assumptions: prefill MFU 40% (\(\pi_{eff} = 396\) TFLOPS); 16-H100 instances on both sides (the 671 GB footprint needs ≥ 9 H100s to hold); mean decode context \(L_{in} + L_{out}/2 = 20{,}250\); fluid (queue-free) session model.

Prefill. Crossover \(L^* = 2 \times 37\times 10^9 / (4 \times 7168 \times 61) \approx 42.3\)K, so the attention correction at 20K is \(1 + 20/42.3 = 1.47\). Demand \(= 10 \times 0.4 \times 20{,}000 = 80{,}000\) uncached tok/s (vs 200,000 without prefix caching). Per-GPU supply \(= 396\times 10^{12}/(74\times 10^9 \times 1.47) \approx 3{,}634\) tok/s ⇒ per-instance 58,139 tok/s. So \(x = \lceil 80{,}000/58{,}139 \rceil = 2\) instances (32 H100s). Without \(h = 0.6\): \(\lceil 200{,}000/58{,}139 \rceil = 4\) instances. Prefix caching saves two whole instances (32 GPUs). TTFT check: compute \(= 20{,}000 \times 74\times 10^9 \times 1.47 / (16 \times 396\times 10^{12}) \approx 344\) ms + transfer \(20{,}000 \times 34.3\text{ KiB} / 50\text{ GB/s} \approx 14\) ms ≈ 358 ms ≪ 2 s ✓ (pipelining shaves the 14 ms to ≈ 0.2 ms exposed).

Decode. Per instance: \(\beta_{agg} = 16 \times 3.35 = 53.6\) TB/s; KV pool \(= 16 \times 80 - 671 = 609\) GB; per-session KV \(= 34.3\text{ KiB} \times 20{,}250 \approx 0.71\) GB. Capacity bound: \(609/0.712 \approx 856\) sessions. Step floor \(W/\beta_{agg} = 12.5\) ms, so the bandwidth bound is \((50 - 12.5)\text{ ms} \times 53.6\text{ TB/s} / 0.712\text{ GB} \approx 2{,}824\). So capacity binds: \(B_{cap} = 856\). Session time ≈ \(0.36 + 500 \times 0.05 = 25.4\) s ⇒ \(C = 254\). So \(y = \lceil 254/856 \rceil = 1\) instance (16 H100s). Output check: \(254/0.05 \approx 5{,}080\) tok/s ≥ \(\lambda L_{out} = 5{,}000\) ✓. Aggregate KV transfer \(= 10 \times 20{,}000 \times 34.3\text{ KiB} \approx 7\) GB/s, well within the link budget ✓.

Result: a 2P1D configuration. 32 prefill GPUs : 16 decode GPUs, a 67/33 split. Agent/long-document workloads with a high input share push the ratio toward P; chat-like workloads do the opposite (Research Card 3 reaches 5P1D, and short-input high-output mixes can even flip to P-minority splits).
arrivals λ req/s prefill pool: x instances P instance P instance demand: λ(1−h)L_in tok/s supply: π_eff / 2P(1+L/L*) sets TTFT KV λL kv B/s decode pool: y instances D instance concurrency: C = λ·T_sess B_cap = min(KV, TPOT) sets TPOT tokens agent mix ⇒ 2P1D (67/33 GPUs) · chat mix ⇒ 5P1D (83/17) · the ratio is the workload's, not yours
How to: trace the workload through. Arrivals enter on the left; the green pool's size comes from uncached input tokens per second, the blue pool's from concurrent sessions under the TPOT budget, and the amber arrow carries just enough bandwidth to keep the truck moving. The dashed strip shows two real ratios the same model produces under different mixes.

Every quantity in these formulas is a moving target: the input-vs-output mix and the hit rate drift over hours and days. A fixed \(x{:}y\) sized for the morning mix is wrong by the evening. Production systems therefore implement dynamic P/D role conversion: instances switch roles (and reload/re-shard accordingly) instead of the partition being static; Mooncake and Dynamo both do this, and the capacity model above is what tells the controller which direction to convert.

Engineering takeaway: treat \(x{:}y\) as a control output, not a design constant. Recompute the two ratios on rolling windows of \(\lambda, L_{in}, L_{out}, h\), and convert instances when the required ratio crosses a hysteresis band. And never size the prefill pool without the \((1-h)\) discount: at \(h = 0.6\) you over-provision prefill by \(2.5\times\).

TA says: the \((1-h)\) discount at 2026 scale is brutal. Moonshot reports >90% cache-hit rates in K3 coding workloads: a typical request is a 400K-token cached prefix (repository context) plus a 4K-token increment per turn, so the effective per-request hit rate is \(400/404 \approx 0.99\). Do the arithmetic at \(\lambda = 5\) req/s, \(L_{in} = 404\)K, against SGLang's measured deep-PP prefill rate of 4,550 tok/s/GPU: demand \(= 5 \times 0.1 \times 404\text{K} \approx 202\text{K tok/s} \Rightarrow ≈ 45\) prefill GPUs, versus 444 GPUs if sized without caching (a 9.9× over-provision), and only ≈ 5 GPUs if routing keeps each request's increment on the instance that already holds its 400K prefix. Now you know why Kimi's pricing charges \$0.30 per cache-hit MTok vs \$3.00 per cache-miss MTok: the hit price is exactly 1/10 of the miss price, mirroring the \((1-h)\) capacity term. For hybrid models it compounds further: a hit also skips re-running the KDA recurrence, which is otherwise the only way to rebuild a KDA state.

The Short-Request Exception: Conditional Disaggregation

Hold on. The remote path carries fixed costs: extra queueing, two rounds of scheduling (decode-side allocation, then prefill execution), control traffic, the transfer itself. For a short prompt, local prefill is tens of milliseconds, and the remote path can be slower end-to-end. So... when is remote actually worth it? Let us derive the threshold instead of guessing.

Let \(O_{fixed}\) be the fixed remote overhead (queue + scheduling + control) and \(k_P\) the number of GPUs that would serve a local prefill. Local vs remote: \[ t_{local}(L) = \frac{2PL}{k_P\,\pi_{eff}}, \qquad t_{remote}(L) = O_{fixed} + \frac{L\,kv}{BW_{link}} + \underbrace{\frac{2PL}{x \cdot \text{supply}}}_{\approx\,0}, \] where the remote compute term vanishes because the prefill pool is provisioned so that queueing, not compute, dominates. Remote wins when \(t_{remote} < t_{local}\), i.e. beyond \[ L_{thresh} = \frac{O_{fixed}}{\dfrac{2P}{k_P\,\pi_{eff}} - \dfrac{kv}{BW_{link}}}. \]

Worked example (numbers, as always): MLA/DeepSeek-V3, \(k_P = 1\) GPU equivalent for the local comparison, \(\pi_{eff} = 396\) TFLOPS: per-token local compute \(= 2P/\pi_{eff} = 187\) µs, per-token transfer \(= 0.70\) µs. With \(O_{fixed} = 50\) ms: \(L_{thresh} = 50\text{ ms}/186.2\text{ µs} \approx 270\) tokens; with a leaner 20 ms overhead: ≈ 107 tokens. For GQA-70B (354 µs vs 6.6 µs per token): ≈ 144 tokens at 50 ms overhead. The textbook's rule of thumb, "a prefill of a few hundred tokens stays local", falls straight out of the formula.
Note: one subtlety worth seeing once. Push \(BW_{link}\) to 900 GB/s NVLink and the transfer term drops to 0.039 µs/token, yet \(L_{thresh}\) barely moves (≈ 161 tokens at \(O_{fixed} = 30\) ms either way). The threshold is set by fixed overhead vs local compute, not by the wire: link upgrades buy you feasible long context, not more disaggregated short prompts.

Dynamo implements exactly this: it dynamically decides local vs remote prefill based on input length (and current load), with a configurable length threshold. Production PD is therefore hybrid: "long prefill disaggregated, short prefill in place."

The Global Queue and the Mooncake Form

With multiple prefill instances, requests must be load-balanced across them. The naive flow (send a request to some prefill instance, then later negotiate the KV handoff) turns out to be backwards. The production pattern (Dynamo, built on NATS) inverts it:

  1. The decode-side worker allocates the KV blocks for the incoming request first.
  2. It pushes a remote-prefill request, carrying the destination block ids, into a global prefill queue.
  3. Prefill workers pull tasks from the queue, execute, and on completion write KV directly into the pre-allocated blocks via NIXL one-sided writes.

Pre-allocation is what makes the transfer fire-and-forget: the prefill worker never negotiates with the decode worker. Cache-aware routing (Lecture 2) then layers on top: prefer the prefill instance that already holds a matching prefix. The \((1-h)\) discount applies per routing decision, not just at capacity-planning time.

decode worker 1. allocate KV blocks for the request FIRST (later: fetch KV & decode) global prefill queue 2. task = request + destination block ids (no negotiation!) prefill workers worker worker 3. pull · prefill (maybe chunked) · write KV directly into allocated blocks (one-sided RDMA) KV lands in the decode worker's pre-allocated blocks: fire and forget
How to: follow the numbers 1 → 2 → 3, then the dashed amber return. Notice what is not in the picture: there is no arrow where the two workers talk to each other. The queue and the pre-allocated block ids are the entire contract.

Mooncake carries the idea to its endpoint. Put a distributed, tiered (HBM / DRAM / SSD) KV pool at the center of the architecture, and let both GPU pools flow through it: a prefill instance first fetches the hit prefix KV, computes only the missed incremental part, and writes new KV back; a decode instance fetches the full KV from the pool (or straight from prefill) and starts generating. In this form, Lecture 2's cache infrastructure and this lecture's PD transfer merge into a single data path, and the transfer layer (an RDMA transfer engine / NIXL) is the common foundation. PD disaggregation, prefix caching, and tiered KV storage stop being three features. They become one system organized around KV movement.

TA says: Mooncake is not a reference design; it is the serving platform behind Kimi, and it serves K3 disaggregated in production. Two 2026 specifics extend the picture to hybrid models. (1) Hybrid-aware transfer: K3's cache is two structures (per-token MLA pages + fixed KDA state blocks), and NIXL handles this with dual page views (each page registered as both an MLA view and a KDA view), so one transfer operation moves both caches; the KDA state rides the KV pool rather than getting its own transport ("When Prefix Cache Meets KDA", Aug 2026). vLLM's validated K3 recipe is exactly the flow above: TEP8 prefill → DEP16 decode, PD-disaggregated over NIXL. (2) Scale-out granularity: the 64+-accelerator supernode recommendation ties back to the feasibility table: at 1M context, K3's 27.2 GiB per-request cache crosses NVLink in ≈ 33 ms but needs ≈ 0.59 s over IB, so keeping P and D inside one domain is what makes 1M-context disaggregation feasible at all.

Engineering takeaway: if you already operate a cross-instance KV pool (prefix cache, tiered offload), incremental PD disaggregation is cheap, because the transfer substrate is the same. If you don't, build the KV transfer layer first; it pays off in both Lecture 2's and this lecture's features at once.

So When Is Disaggregation Actually Worth It?

Worth it:

Not worth it:

And how does this sit with Lecture 4's chunked prefill? The two are not mutually exclusive. Under disaggregation, the prefill pool can still chunk internally (to bound activation memory or mix priorities), and the occasional short local prefill inside the decode pool is mixed in as chunks:

Chunked prefillPD disaggregation
Mechanismtoken-budget compromise within one poolphysical isolation into two pools
ObjectiveTTFT/ITL trade-off on one knob \(c\)independent optimization per side
KV transfernone, but prefill is stretchedtransfer + layout rearrangement + cross-pool scheduling
Parallel configboth phases share one configindependent configs and hardware per side
Deployment complexitylowhigh (transfer layer, global queue, ratio management)
Read the fine print on published numbers: honest PD evaluations report on the goodput/SLO basis (max throughput subject to TTFT/TPOT SLOs), which is the right metric for an SLO-decoupling technique. But unbundle combined claims: Dynamo 1.0's advertised 7× throughput gain was measured with PD disaggregation combined with wide EP (Lecture 6) on GB200 NVL72, not from disaggregation alone. When reading any PD evaluation, confirm three things: (1) the workload's input/output length distribution, (2) the SLO settings, (3) whether the baseline has chunked prefill enabled and tuned. A disaggregation win over an untuned single-pool baseline may be a chunk-size win in disguise.
Common pitfalls (the museum of this lecture's failures):
  1. Attributing combined gains to disaggregation alone. The Dynamo 7× bundles PD with wide EP. Which fraction survives an equally-parallel baseline?
  2. Disaggregating offline workloads. With no SLO conflict you pay the transfer tax and gain nothing.
  3. Ignoring layout rearrangement. Mismatched TP/EP means mid-flight re-sharding via non-contiguous scatter; a contiguous-only transfer layer becomes a CPU-staging bottleneck that quietly eats the gains.
  4. Fixing x:y under a drifting mix. The ratio is a function of \(\lambda, L_{in}, L_{out}, h\), all of which drift; a static partition is mis-sized most of the day.
  5. Treating transfer as a pure latency problem. Pipelining hides per-request latency, but aggregate \(\lambda L kv\) must still fit the link. At 128K GQA, one request/second is a whole 400 Gbps link.
  6. Forgetting the \((1-h)\) discount on prefill sizing. You over-provision by \(1/(1-h)\): 2.5× at \(h = 0.6\).

Research Thinking

How to: read the starting point; read the question and think, for a minute, a day, a week... and only then open the answers. You are not supposed to reinvent several months of someone's research; it's the habit of thinking that counts.

? Card 1, "invent" conditional disaggregation. Starting point: two pools decouple the SLOs, but the remote path carries a fixed overhead \(O_{fixed}\) (queueing, two scheduling rounds, control traffic) that a local prefill never pays. Question: for which prompts should the engine not disaggregate? Derive the crossover before opening. What quantity does it depend on, and what, surprisingly, does it not depend on?

Possible answer

Balance the paths: remote wins once \(O_{fixed} + L\,kv/BW_{link} < 2PL/(k_P\pi_{eff})\), giving \(L_{thresh} = O_{fixed}/\left(\frac{2P}{k_P\pi_{eff}} - \frac{kv}{BW_{link}}\right)\). MLA/DeepSeek-V3: 187 µs/tok local vs 0.70 µs/tok transfer, so \(L_{thresh} \approx 270\) tokens at \(O_{fixed} = 50\) ms, ≈ 107 at 20 ms, ≈ 161 at 30 ms. GQA-70B: ≈ 144 tokens at 50 ms. The surprise: upgrading the link to 900 GB/s NVLink moves the threshold by <1 token. The wire term was never the binding one; fixed overhead vs local compute per token is. Existing solution: Dynamo implements exactly this hybrid policy, a configurable length threshold with load-aware dynamic decisions. Production PD is "long prefill disaggregated, short prefill in place."

? Card 2, re-size for a different mix. Starting point: our agent workload (\(\lambda = 10\) req/s, \(L_{in} = 20\)K, \(L_{out} = 500\), \(h = 0.6\)) sized to 2P1D. Same model, same hardware, same 16-GPU instances. Question: a high-rate chat workload: \(\lambda = 500\) req/s, \(L_{in} = 1\)K, \(L_{out} = 300\), \(h = 0.3\), TPOT ≤ 50 ms. Compute \(x\) and \(y\) before opening. Which pool does the huge arrival rate actually stress?

Possible answer (worked)

Prefill: the attention correction is \(1 + 1{,}024/42{,}300 = 1.024\); per-GPU supply \(= 396\times10^{12}/(74\times10^9 \times 1.024) \approx 5{,}225\) tok/s, per instance 83,598 tok/s. Demand \(= 500 \times 0.7 \times 1{,}024 = 358{,}400\) tok/s ⇒ \(x = \lceil 358{,}400/83{,}598 \rceil = 5\) instances. Decode: TTFT ≈ 12 ms; session ≈ \(0.012 + 300 \times 0.05 = 15.0\) s; concurrency \(C = 500 \times 15.0 = 7{,}506\). KV/session ≈ \(34.3\text{ KiB} \times (1{,}024 + 150) = 41.2\) MB; capacity bound \(= 609\text{ GB}/41.2 \text{ MB} \approx 14{,}764\); bandwidth bound \(= (50-12.5)\text{ ms} \times 53.6\text{ TB/s}/ 41.2\text{ MB} \approx 48{,}800\); so \(B_{cap} = 14{,}764\) and \(y = \lceil 7{,}506/14{,}764 \rceil = 1\). Result: 5P1D (83/17 GPUs) vs the agent mix's 2P1D (67/33). Short inputs make prefill cheap per request but λ still dominates; decode is tiny per session (41 MB KV) so one instance absorbs enormous concurrency. Had \(L_{out}\) been much larger, \(y\) would grow while \(x\) stays fixed; decode-dominated mixes can flip the split to a P-minority. This is why the ratio is a control output, not a design constant.

? Card 3, the truck that latency hiding can't hide. Starting point: layer-wise pipelining reduces exposed transfer to \(T_{transfer}/n_{layers}\): 1.5 ms for MLA at 128K. So is transfer "solved"? Question: at arrival rate \(\lambda\), context 128K, which link does a GQA-70B deployment need? And what does that tell you about the difference between latency problems and throughput problems?

Possible answer

Aggregate demand is \(\lambda \cdot L \cdot kv\) bytes/s. At 128K, one GQA-70B request carries ≈ 43 GB, so \(\lambda = 1\) req/s already needs ≈ 43 GB/s, essentially a whole 400 Gbps IB link per request per second. Pipelining changes when bytes move, not how many: it hides per-request latency perfectly while leaving fleet-wide contention untouched. Latency can be hidden; bandwidth cannot. This is the same pressure that pushes production deployments toward in-NVLink-domain PD (900 GB/s), KV compression (MLA's 9.3× divides demand by 9.3), and, in the Mooncake form, toward not moving KV twice between cache and transfer paths. When you plan links, size them from \(\lambda L kv\), and check the pipelining condition's fine print: it assumes the link is not saturated by concurrent transfers.

? Card 4, why does the global queue go backwards? Starting point: the Dynamo flow has the decode worker allocate KV blocks before prefill even starts, and prefill writes results into them without ever talking back. Question: what failure modes of the naive order (prefill first, negotiate handoff later) does this eliminate? And what does it buy you for prefix-cache routing?

Possible answer

The naive order has a hidden rendezvous: prefill finishes holding KV it cannot place without asking the decode side where to put it. That means a negotiation on the critical path, a decode-side OOM racing against prefill completion, and a worker blocked mid-handoff. Inverting it makes the transfer fire-and-forget: destination block ids ride in the queue message, one-sided RDMA writes need no remote participation, and O(1) admission control happens up front (decode refuses work that cannot fit). For routing, pre-allocation composes cleanly with cache awareness (Lecture 2): the router can prefer the prefill instance already holding the matching prefix, so the \((1-h)\) discount applies per routing decision. In the Mooncake form both flows unify: fetch-hit-prefix, compute-miss, write-back and PD-transfer use the same RDMA/NIXL substrate, which is also why vLLM's K3 recipe can ride one connector for TEP8-prefill → DEP16-decode.

Have Fun! The Moving Truck Calculator

Enough reading. Now you drive the truck. Pick a model, a context length, and a link, and see whether your KV fits inside a 200 ms transfer budget. Can you find a setting where pipelining does not rescue the GQA model? (And once you can't, try saying why in one sentence.)



Truck rules: \(T_{transfer} = L \cdot kv / BW_{link}\), budget 10% of a 2 s TTFT SLO; exposed latency assumes the overlap condition \(2P/\pi_{eff} \ge kv/BW_{link}\), which all three models satisfy easily on either link.

Seminar & Homework

Take Lab 5: PD Disaggregation from the course's hands-on pack. On two GPUs you will:

Analysis questions to answer in your report: Why does goodput under a dual SLO separate the two architectures more sharply than raw throughput? Where does the proxy/router itself appear in your TTFT decomposition? And at what \(\lambda\) does \(\lambda L\,kv\) saturate your link? Watch the pitfalls: attributing proxy/queue delay to transfer (decompose TTFT!), mismatched configs between baseline and pair, and single-point rate tests that miss the goodput knee.

Summary

← Lecture 4: Batching and Scheduling Lecture 6: Parallelism →