Skip to content
datarekha

Disaggregated serving (prefill/decode split)

Prefill is compute-bound, decode is memory-bound — yet a normal server runs both on the same GPU, where they fight. Disaggregated serving splits them onto separate pools, so a long prefill no longer stalls everyone's decode and each pool scales on its own.

Written and reviewed by Last reviewed

8 min read Advanced Generative AI Lesson 55 of 73

What you'll learn

  • Why co-locating prefill and decode on one GPU causes latency interference
  • How disaggregation splits prefill and decode into separate worker pools
  • The cost — transferring the KV cache between pools — and KV-locality routing
  • Independent scaling, smoother ITL, and where disaggregation is used

Before you start

Inference metrics left us with an awkward fact: the two phases of inference want opposite things from the hardware. Prefill is compute-bound — it wants raw math throughput. Decode is memory-bandwidth-bound — it wants fast memory and lots of it.

Yet the default server runs both on the same GPU, time-sharing it. That co-location causes two problems, and disaggregated serving is the fix: run prefill and decode on separate pools of GPUs.

The interference problem

When prefill and decode share a GPU, a freshly-arrived request’s prefill — a big, compute-heavy burst — has to be squeezed in between the decode steps of every request already streaming.

That burst stalls those decodes: their inter-token latency spikes for one step, and the user sees a visible hitch mid-answer. The more (or longer) the prompts arriving, the more the ongoing answers stutter.

base_itl, prefill_ms = 15, 80
# Co-located: a new request's prefill is injected at step 5, stalling that decode step
colocated = [base_itl] * 10
colocated[5] += prefill_ms
# Disaggregated: prefill runs on a separate pool; the decode stream is untouched
disagg = [base_itl] * 10

print("co-located ITL (ms):", colocated, "-> worst", max(colocated))
print("disaggregated  (ms):", disagg, "-> worst", max(disagg))
co-located ITL (ms): [15, 15, 15, 15, 15, 95, 15, 15, 15, 15] -> worst 95
disaggregated  (ms): [15, 15, 15, 15, 15, 15, 15, 15, 15, 15] -> worst 15

Co-located, the steady 15 ms stream jumps to 95 ms the moment a prefill lands — a p99 ITL six times the median, all from interference. Disaggregated, decode just keeps decoding.

The split

Disaggregation puts prefill and decode in two pools:

  1. A request hits a prefill worker, which processes the prompt and builds its KV cache.
  2. That KV cache is transferred to a decode worker.
  3. The decode worker streams the output tokens, attending to the transferred cache — uninterrupted, because prefills never run here.
Co-located · one GPUprefilldecode stalls → ITL spike (95 ms)Disaggregated · two poolsprefill poolcompute-boundKV transferdecode poolmemory-boundITL flat (15 ms) — no prefill interference
Splitting the phases lets the decode stream run undisturbed, and lets each pool be sized — even hardware-typed — for its own bottleneck.

What disaggregation buys

  • No interference → smooth ITL. Decode workers only ever decode, so the per-token latency stays flat and the p99 tail shrinks. That is the single biggest user-visible win.
  • Independent scaling. Prefill load (prompt tokens/sec) and decode load (active streams) rarely move together. Disaggregated, you scale each pool to its own demand — add prefill workers when prompts are long, decode workers when concurrency is high — instead of over-provisioning one GPU type for both.
  • Hardware specialization. You can even put the pools on different GPUs: compute-dense parts for prefill, memory-bandwidth-rich parts for decode — matching silicon to bottleneck.
  • Better goodput at SLO. Because TTFT (prefill) and ITL (decode) no longer contend, you can hit a tight TTFT and a tight ITL target at once — which a co-located server struggles to do.

The cost: moving the KV cache

Disaggregation is not free — between step 1 and step 3 you must ship the KV cache from the prefill worker to the decode worker, and that cache can be large. So the technique lives and dies on KV transfer cost:

  • Fast interconnect (NVLink, InfiniBand, RDMA) makes the transfer cheap enough to be worth it; over slow networking it can erase the gains.
  • KV-locality routing. Route a request to a decode worker that already holds a relevant prefix’s KV (or keep related requests on the same worker), so you transfer — or recompute — less. The same idea scales up to multi-region serving: keep a user’s KV near where they are.
  • KV offloading. Systems like LMCache tier the KV cache across GPU, CPU, and disk so a large prompt’s cache can be stored and re-served cheaply — pairing naturally with KV-cache offloading.

In one breath

  • Prefill (compute-bound) and decode (memory-bound) want opposite hardware, but a co-located server runs both on one GPU, where a new request’s prefill stalls ongoing decodes — an ITL spike (95 ms vs a 15 ms median in the demo).
  • Disaggregated serving splits them into a prefill pool (builds the KV cache) and a decode pool (streams tokens), with the KV cache transferred between.
  • Wins: smooth ITL (no interference, smaller p99), independent scaling of each pool, hardware specialization, and better goodput at a tight SLO.
  • The cost is moving the KV cache, so it depends on fast interconnect and KV-locality routing (and KV offloading/tiering) to keep transfer cheap.
  • It composes with PagedAttention + continuous batching and is shipped by vLLM, NVIDIA Dynamo, DistServe/Splitwise, and the big providers.

Quick check

Quick check

0/4
Q1Why does co-locating prefill and decode on one GPU hurt latency?
Q2What does disaggregated serving do?
Q3What is the main cost that makes or breaks disaggregation?
Q4Besides smoother ITL, what's a key operational benefit of disaggregation?

Next

Disaggregation rests on the prefill/decode metrics and on cheap KV movement — see KV-cache offloading for the tiering that makes large caches affordable, and self-hosting for the engines (vLLM, Dynamo) that implement these modes.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
When the KV cache doesn't fit in GPU VRAM, what are your options?

The practical choices are to reduce or quantize the cache, shard it across GPUs, offload inactive sessions to CPU memory, or evict the cache and recompute the prompt later. SSD and network storage are suitable for cold session state, not latency-sensitive token-by-token decoding.

How do you optimise GPU utilization for model serving, and what role does dynamic batching play?

GPUs execute tensor operations efficiently only when the batch dimension is large enough to saturate all CUDA cores. Dynamic batching collects individual requests arriving within a short window and fuses them into a single GPU call, dramatically improving throughput and cost efficiency without sacrificing per-request latency beyond the configured wait threshold.

What problem does PagedAttention solve, and what is continuous batching?

PagedAttention addresses KV-cache memory waste in LLM serving by storing each sequence in fixed-size, non-contiguous GPU blocks, avoiding large contiguous reservations and reducing fragmentation; shared blocks can also support copy-on-write and prefix reuse. Continuous batching addresses scheduling by admitting and retiring requests between generation iterations, keeping a changing batch full instead of waiting for every request to finish.

What is a KV cache and how does it speed up LLM inference?

A KV cache stores the key and value tensors for previously processed tokens in each transformer layer, so autoregressive decoding computes only the new token's projections instead of rebuilding the whole prefix. This reduces per-step attention work from quadratic to linear in context length, while cache memory grows with layers, sequence length, batch size, and KV-head count.

Related lessons

Explore further