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.
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.
| chunk \(c\) | \(t_{iter}\) | TPOT | TTFT (\(L\) = 4K) |
|---|---|---|---|
| 0 (decode only) | 93.1 ms | 93.1 ms | n/a |
| 128 | 93.1 ms | 93.1 ms | 2.98 s |
| 512 | 181.0 ms | 181.0 ms | 1.45 s |
| 2,048 | 615.4 ms | 615.4 ms | 1.23 s |
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.
Why does physical separation beat a well-tuned \(c\)? Because the two phases disagree about everything, not just the token budget:
| Prefill pool | Decode pool | |
|---|---|---|
| Bottleneck | tensor-core compute (\(I \gg\) ridge) | HBM bandwidth & capacity (\(I \ll\) ridge) |
| Batch policy | no batching needed; one long request saturates compute | batch as large as possible (intensity \(\propto B\), Lecture 1) |
| Parallel config | small TP; CP for long context (Lecture 3) | large EP/TP: bigger KV pool, more aggregate bandwidth (Lecture 6) |
| Hardware | compute-oriented GPU | bandwidth/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.
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/tok | 40 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×.
| \(L\) | \(kv_{max}\) | GQA-70B (320 KiB) | MLA (34.3 KiB) | GQA \(T\) | MLA \(T\) |
|---|---|---|---|---|---|
| 8K | 1.16 MiB/tok | ✓ | ✓ | 54 ms | 5.8 ms |
| 32K | 298 KiB/tok | ✗ | ✓ | 215 ms | 23 ms |
| 128K | 74.5 KiB/tok | ✗ | ✓ | 859 ms | 92 ms |
| 1M | 9.3 KiB/tok | ✗ | ✗ | 6.9 s | 737 ms |
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.
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}}. \]
| 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 µs | 43× | 859 ms | 10.7 ms |
| MLA DSv3 (\(P\) = 37B act., n = 61) | 149.5 µs vs 0.70 µs | 213× | 92 ms | 1.5 ms |
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:
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.
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}\).
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.
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.
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}}}. \]
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."
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:
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.
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.
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 prefill | PD disaggregation | |
|---|---|---|
| Mechanism | token-budget compromise within one pool | physical isolation into two pools |
| Objective | TTFT/ITL trade-off on one knob \(c\) | independent optimization per side |
| KV transfer | none, but prefill is stretched | transfer + layout rearrangement + cross-pool scheduling |
| Parallel config | both phases share one config | independent configs and hardware per side |
| Deployment complexity | low | high (transfer layer, global queue, ratio management) |
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.
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."
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.
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.
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.
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.)
Take Lab 5: PD Disaggregation from the course's hands-on pack. On two GPUs you will:
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":...}' behind the
vLLM disaggregated-prefill proxy.--ignore-eos), with an open-loop
rate sweep 0.25–4 req/s.nvidia-smi nvlink /
dcgmi whether you are actually on NVLink or PCIe (predicted: 4 GiB at 32K takes 4.8
ms NVLink, 67 ms PCIe, 86 ms IB). Measure, don't assume.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.