Cross the machine boundary: split prefill from decode, ship the KV cache over a network, and learn when that is cheaper than recomputing it.
1. What you build#
A two-role serving system:
┌──────────────┐ KV blocks ┌──────────────┐
client → router →│ PREFILL node │ ─────────────→ │ DECODE node │→ stream → client
└──────────────┘ (network) └──────────────┘A prefill worker processes the prompt and produces the KV cache; a transfer layer moves it; a decode worker (your Project 08 engine) admits the request with KV already populated and generates. A router picks a worker of each role. You then compare it against the co-located baseline and work out the break-even.
Diagram — Request flow, with the fallback#
sequenceDiagram
participant C as Client
participant R as Router
participant P as Prefill node
participant D as Decode node
C->>R: POST /v1/completions
R->>P: prompt + target decode node
P->>P: prefill, sample first token
alt transfer OK
P->>D: header + KV blocks + first token
D->>D: allocate blocks, admit to running set
else transfer failed or decode node full
R->>D: prompt only
D->>D: local prefill
end
loop until EOS
D-->>R: token
R-->>C: token
end2. Why it matters#
Prefill is compute-bound and bursty; decode is memory-bound and steady (V.03). Co-locating them means each interferes with the other and neither can be scaled or tuned separately. Disaggregation is how large deployments address this, and by 2026 it is a mainstream configuration in the major engines rather than a research idea. Its feasibility rests on one number: how fast KV can move versus how fast it can be recomputed. This project makes you measure that number.
3. Read first#
- IX.08 — Interconnects
- IX.09 — Communication cost math
- IX.10 — Multi-node
- IX.11 — Distributed KV cache
- XIII.06 — Disaggregation
- XIII.07 — KV offloading
4. Spec#
Router POST /v1/completions
1. choose prefill worker (least queued prompt tokens)
2. choose decode worker (least running sequences / most free KV blocks)
3. tell prefill worker where to send KV ; proxy the token stream from decode
Prefill node run prefill ; sample first token ; send {request meta, first token, KV blocks}
Transfer v1 TCP, length-prefixed frames, raw tensor bytes
v2 zero-copy send (memoryview / sendfile), pinned buffers, optional FP16→INT8
framing: header{request_id, n_layer, n_tokens, dtype, block_size} + payload
integrity: checksum per frame
Decode node allocate blocks (Project 09) ; write KV ; admit into running set ; decode
Failure prefill dies → router retries on another prefill node
transfer fails or decode node is full → fall back to local prefill on decode node5. Milestones#
- Do the arithmetic first. For your model: KV bytes per token × prompt length = payload. Divide by your link bandwidth → transfer time. Divide prompt length by your measured prefill tokens/s → recompute time. Predict which wins at 100, 1k, 8k tokens on 1 GbE, 10 GbE, 100 GbE.
- KV export/import in one process. Export after prefill, import into a fresh engine, continue decoding. Tokens identical to the uninterrupted run.
- Across processes over localhost TCP.
- Across machines (two VMs, or
tc netemto impose bandwidth and latency on loopback). - Router with role-aware selection and stream proxying.
- Baseline. Same hardware, both workers running co-located Project 08 engines.
- Interference experiment. Steady decode traffic + bursts of 8k-token prompts. Compare decode ITL p99 for co-located vs disaggregated.
- Failure handling and fallback.
6. Starter skeleton#
HDR = struct.Struct("!16sIIIH") # request_id, n_layer, n_tokens, payload_bytes, dtype_code
def send_kv(sock, rid, k, v): # k, v: [n_layer, n_kv_head, T, head_dim], contiguous
kb, vb = k.cpu().numpy(), v.cpu().numpy()
sock.sendall(HDR.pack(rid, k.shape[0], k.shape[2], kb.nbytes + vb.nbytes, DTYPES[k.dtype]))
sock.sendall(memoryview(kb)); sock.sendall(memoryview(vb)) # no pickle, no copies
def recv_kv(sock, cfg):
rid, L, T, nbytes, dt = HDR.unpack(recv_exact(sock, HDR.size))
buf = bytearray(nbytes); view = memoryview(buf); got = 0
while got < nbytes:
got += sock.recv_into(view[got:], nbytes - got)
half = nbytes // 2
shape = (L, cfg.n_kv_head, T, cfg.head_dim)
k = torch.frombuffer(view[:half], dtype=NP2T[dt]).view(shape)
v = torch.frombuffer(view[half:], dtype=NP2T[dt]).view(shape)
return rid, k, v
def should_transfer(n_tokens, kv_bytes_per_tok, link_bps, prefill_tps, overhead_s=0.002):
return n_tokens * kv_bytes_per_tok / link_bps + overhead_s < n_tokens / prefill_tps
7. What to measure#
| Measurement | Expectation to write down first |
|---|---|
| KV payload for a 1k and 8k prompt | tokens × bytes/token — hundreds of MB for real models |
| Transfer time vs prompt length, per link speed | Linear; slope = 1/bandwidth |
| Recompute time vs prompt length | Linear; slope = 1/prefill tok/s |
| Break-even link speed | Above it, transfer wins |
| Effective transfer GB/s vs raw link GB/s | Lower: serialization and GPU↔host copies |
| TTFT: co-located vs disaggregated | Disaggregated adds transfer; may still win under load |
| Decode ITL p99 during long-prompt bursts, both | The headline benefit |
| Throughput per GPU at a fixed SLO, both | The honest comparison |
| Best prefill:decode worker ratio for your workload | Depends on input/output token ratio |
8. Done when#
- Decode from transferred KV is token-identical to local.
- You have predicted and measured break-even curves on one chart.
- You showed the ITL-under-burst improvement, or showed that your link is too slow for it and explained which number would have to change.
- Killing the prefill worker mid-request does not fail the request.
- You can state in one sentence when disaggregation is the wrong choice.
9. Common pitfalls#
Pickling tensors. Slow and copies several times. Send raw bytes.
GPU → pageable host → socket → pageable host → GPU. Four copies. The data path, not the wire, becomes the bottleneck — which is why real systems use RDMA and GPU-direct transports.
Ignoring what a fast prefill node costs. Disaggregation only pays if each role is separately saturated.
Model/config mismatch between roles. Different dtype, block size, or quantization makes KV garbage with no error. Put a schema version and checksum in the header.
Not transferring the first sampled token and sampler state.
Assuming a datacenter network. On 1 GbE, recompute wins by a wide margin. That is a valid result — report it.
10. Stretch goals#
- Pipeline parallelism across the two nodes (layers 0-5 on A, 6-11 on B) and its bytes-per-hop
cost:
batch × d_model × bytesper token. Compare with P/D disaggregation. - Compress KV in transit (FP16 → INT8, or lossless) and re-plot the break-even.
- Shared KV store: both decode nodes pull prefixes from a Redis/disk tier instead of recomputing — the idea behind LMCache and peer-to-peer KV sharing.
- Run
nccl-testsacross two real nodes and compare against your TCP path. - Stream KV layer by layer so decode can start layer 0 before the last layer arrives.
11. Interview questions this project answers#
- Why separate prefill and decode onto different machines?
- What determines whether KV transfer beats recomputation? Do the arithmetic.
- How would you size the prefill-to-decode ratio?
- What goes wrong if the two roles run slightly different model builds?
- Why do disaggregated designs depend on RDMA-class networking?