Skip to content
Residual Stream
Go back

Serving Metrics and Prefill–Decode Disaggregation

Updated:
Tan Le
ENVI

The KV Cache split generation into two phases: prefill processes the whole prompt at once and is compute-bound; decode produces one token per step and is memory-bound. A serving system runs both, for many users at once, on the same hardware. This post is about measuring what users feel, and about the scheduling decisions that follow from the two phases being so different.

Three ways to share prefill and decode worst gap, p99 one decode step, every running user whole prefill colocated prefill a 16k-token prompt 518 ms chunked prefill chunks of 128 chunk chunk chunk chunk chunk 6–7 ms disaggregated 1 + 1 GPUs prefill GPU KV of a 2k-token prompt, 5.2 ms 43–45 ms prefill GPU saturates near 11 requests/s 2 GPUs, 4 to 12 requests a second; SLO limits 20 ms and 50 ms; timelines schematic

How the two phases share hardware sets the worst gap between tokens, the delay users feel as a freeze. In this simulation, chunks sized to the target beat both whole prefills and a fixed split of GPUs.

Data: the simulation's output below, 2-GPU rows
At a glance
  • Users feel two delays: time to first token (queueing plus prefill) and the gaps between later tokens. The average gap hides stalls: with whole prefills at 4 requests a second its 99th percentile was 11 ms, while only 16% met a 50 ms limit on every gap (and 2 s to first token).
  • Prefill and decode on one GPU interfere: in simulation, whole prefills of long prompts froze decoding users for half a second while the average time per token looked fine.
  • Chunked prefill sized to the per-token target removed the stalls: with chunks of 128 tokens, 99% of worst gaps stayed under 7 ms at every load simulated, at some cost in time to first token.
  • Disaggregation trades interference for a KV transfer (about 8% of the prefill) and a GPU split that must match traffic: the transfer of a 16,000-token prompt’s cache takes 42 ms, and long prompts’ transfers make up most of its 43 to 46 ms worst gaps (1 + 1 and 2 + 2 splits). Its real advantages are per-phase tuning and hardware.

Table of contents

Open Table of contents

What to measure

A user of a streaming chat (Streaming Tokens to the User) feels two delays:

  • Time to first token (TTFT): from sending the request to seeing the first token. It is queueing plus prefill, and grows with the prompt.
  • Time per output token (TPOT): the average gap between output tokens over a request.
  • Inter-token latency (ITL), or time between tokens: each individual gap. A gap is one decode step, and grows with how much else the GPU is doing in that step. A request with a 6 ms average can still contain one 500 ms stall, which TPOT hides and ITL doesn’t, and which the user sees as the text freezing.
sent queue prefill a 500 ms stall TTFT: queue + prefill ITL: one gap TPOT: the average gap, which hides the stall

Three numbers for one stream. Only the per-gap view sees the freeze.

A service promises limits on these, a service-level objective (SLO), usually on a high percentile: “99% of requests see their first token within 2 seconds and no gap longer than 50 ms”. High percentiles matter because a chat answer is hundreds of tokens: a stall that hits 1% of steps hits nearly every user. Throughput, tokens or requests per second, says how much the hardware does; it can rise while users suffer. Goodput (Zhong et al., 2024) is the highest request rate per GPU at which a target share of requests (say 90%) still meet the SLO, and is the number that decides how many GPUs a service needs. Near capacity, waiting time grows sharply, as in any queue: time to first token can be fine at moderate load and explode as the load approaches capacity.

Why the phases interfere

With continuous batching (vLLM Architecture), every step of the GPU serves all running requests: one decode token each, plus whatever prompts are waiting to be prefilled. A step’s time is set by the larger of reading the weights and doing the arithmetic (see the roofline of How a GPU Runs Deep Learning). Decode-only steps are cheap: the weights are read once for the whole batch. But a step that also prefills a 2,000-token prompt does 2,000 tokens of compute, and every user who was decoding waits for it: their next token is late by the whole prefill. Long prompts arriving at random make the gaps jumpy, and the high percentiles are what SLOs measure.

whole prefill prefill a 16,000-token prompt every decoding user waits ≈ 0.5 s chunked prefill chunk chunk chunk chunk chunk each step bounded by the chunk: decode keeps its rhythm decode step schematic: one row of GPU steps, time to the right

Why one long prompt hurts everyone sharing the GPU, and what chunking changes. Schematic.

Two fixes:

  • Chunked prefill (Agrawal et al., 2024, Sarathi-Serve): split each prompt into chunks and add one chunk to each step, so every step’s time is bounded by the chunk size, which is chosen to fit the per-token target (Sarathi-Serve calls it the token budget). Prefill takes more steps, which costs some TTFT; decode stalls disappear.
  • Disaggregation (Zhong et al., 2024, DistServe; Patel et al., 2024, Splitwise): run prefill and decode on different GPUs. Decode GPUs never see a prefill, so TPOT is as smooth as it can be. The prompt’s KV cache has to move from the prefill GPU to the decode GPU, and the number of GPUs given to each phase has to match the traffic.

What the KV transfer costs

For the model below, the KV cache is 128 KiB per token: a 2,000-token prompt’s cache is 250 MiB, 5 ms over a 50 GB/s network link, while its prefill takes 65 ms. Both grow with the prompt, the prefill a little faster once attention’s quadratic cost counts, so there is no prompt length where the transfer starts to dominate. It is about 8% of the prefill’s time on an InfiniBand-class link, and much less over NVLink within a server. And it needn’t wait for the prefill to finish: Splitwise streams the cache layer by layer as it is computed, and DistServe places paired prefill and decode GPUs in the same server so the transfer stays on NVLink. The simulation below adds it after the prefill, without overlap, so it is pessimistic there. Small next to the prefill is not small next to a gap between tokens: a 16,000-token prompt’s cache takes 42 ms on the same link, and the request’s first decode step waits for all of it. In the simulation the transfer sets disaggregation’s worst gap, and the split of GPUs sets its goodput.

prefill GPU compute-bound decode GPU memory-bound, never stalls KV cache: 250 MiB for 2,000 tokens 5.2 ms over 50 GB/s; can stream layer by layer for a 2,000-token prompt prefill 65 ms KV transfer 5.2 ms the ratio of GPUs must match the traffic

The transfer is small next to the prefill that produced it; the harder part is matching the ratio of prefill to decode GPUs to the traffic.

Data: the output below

A simulation

A step-by-step simulation of serving an 8B model on H100s, with each step’s time from the roofline (weights and KV cache read at 3.35 TB/s, FLOPs at half of peak). Requests arrive at random, with prompts around 2,000 tokens (200 to 16,000) and 256 output tokens each. Every gap between a request’s tokens is recorded, and the SLO is on the worst one: first token within 2 seconds and every later token within 50 ms (loose) or 20 ms (tight). Chunked prefill is run with chunks of 512 and of 128 tokens. The step time, from serving_sim.py:

import numpy as np

# Serving one 8B model (Llama-3-8B shapes: 32 layers, 8 KV heads of 128,
# BF16) on H100s, simulated step by step. A step processes some prompt
# tokens (prefill) and one new token for every running sequence (decode);
# its time is the roofline of "How a GPU Runs Deep Learning": the larger of reading the weights
# and the KV cache it touches, and doing its FLOPs at half of peak.
N, BW, FLOPS = 8.0e9, 3.35e12, 0.5 * 989e12
KV_TOKEN = 2 * 32 * 8 * 128 * 2                         # bytes per token of KV cache (128 KiB)

def step_time(prefill_tokens, decode_seqs, decode_ctx):
    """Seconds for one step: prefill_tokens prompt tokens, decode_seqs
    sequences generating one token each with decode_ctx total context."""
    flops = 2 * N * (prefill_tokens + decode_seqs)
    bytes_ = 2 * N + KV_TOKEN * decode_ctx
    return max(bytes_ / BW, flops / FLOPS)

Full script: serving_sim.py

The rest of the script draws the arrivals and prompt lengths and schedules them with continuous batching on colocated GPUs and on separate prefill and decode GPUs. On a colocated GPU, each step takes a budget of prompt tokens: the whole first waiting prompt, or at most one chunk, mixed with one token for every running sequence:

            budget = chunk if chunk else (waiting[0][2] if waiting else 0)
            pre, done = 0, []
            for w in waiting:
                take = min(w[2], budget - pre)
                if take <= 0:
                    break
                w[2] -= take; pre += take
                if w[2] == 0:
                    done.append(w)
            ctx = sum(r[0] for r in running)
            t += step_time(pre, len(running), ctx)

Full script: serving_sim.py

On the disaggregated GPUs, each prompt is prefilled whole on the first free prefill GPU, and its first decode step waits for that prefill to end plus the KV cache’s transfer, KV_TOKEN * prompt / link. The script reports each design:

KV cache per token 128 KiB; a 2,000-token prompt's cache is 250 MiB, 5.2 ms over a 50 GB/s link; its prefill alone takes 65 ms; a step stays weight-bound up to 148 tokens

2 GPUs; share of requests whose first token comes within 2 s and every later token within 50 ms (loose) or 20 ms (tight)
req/s design                     TTFT p50    p99  TPOT p99 worst gap p99  loose  tight
    4 colocated, whole prefill     0.07s   0.47s    10.8ms    517.8ms    16%     5%
    4 colocated, chunks of 512     0.07s   0.48s     8.7ms     16.9ms   100%   100%
    4 colocated, chunks of 128     0.09s   0.59s     5.8ms      6.1ms   100%   100%
    4 disaggregated 1 + 1          0.09s   0.61s     6.6ms     44.8ms   100%    83%
    8 colocated, whole prefill     0.08s   0.52s    21.3ms    517.9ms     2%     0%
    8 colocated, chunks of 512     0.09s   0.54s    12.4ms     17.4ms   100%   100%
    8 colocated, chunks of 128     0.13s   1.04s     6.2ms      6.6ms   100%   100%
    8 disaggregated 1 + 1          0.22s   2.11s     7.2ms     43.8ms    99%    77%
   12 colocated, whole prefill     0.10s   0.60s    27.2ms    518.8ms     0%     0%
   12 colocated, chunks of 512     0.10s   0.65s    14.5ms     17.5ms   100%   100%
   12 colocated, chunks of 128     0.21s   1.45s     6.2ms      6.7ms   100%   100%
   12 disaggregated 1 + 1          6.25s  10.05s     7.3ms     43.3ms     6%     4%

4 GPUs
req/s design                     TTFT p50    p99  TPOT p99 worst gap p99  loose  tight
   24 colocated, chunks of 128     0.20s   1.31s     6.2ms      6.7ms   100%   100%
   24 disaggregated 2 + 2          5.85s  12.80s     7.9ms     45.7ms    12%     9%
   24 disaggregated 3 + 1          0.10s   0.51s    18.9ms     57.7ms    98%     7%

Read down each block as traffic grows.

  • Interference is in the worst gaps, not the averages. With whole prefills, a 16,000-token prompt stalls every decoding user for about half a second: the 99th-percentile worst gap is 518 ms at every load, and even at 4 requests a second only 16% of requests never see a gap over 50 ms. The mean (TPOT) hides this: its 99th percentile is 11 ms.
  • Chunked prefill, sized to the target, fixes it. With chunks of 512, 99% of requests’ worst gaps are under 18 ms; with chunks of 128, under 7 ms, and chunks of 128 meet even the tight target for every request at every load tried, including 24 requests a second on 4 GPUs. Smaller chunks cost time to first token (the 99th percentile rises from 0.65 to 1.45 seconds at 12 requests a second), still within target.
  • Disaggregation, in this model, doesn’t beat tuned chunking. Its decode GPU never stalls on a prefill, but a request’s first decode step waits for the cache to arrive and for the next step. The transfer grows with the prompt, to 42 ms for 16,000 tokens, so long prompts’ transfers make up most of the 43 to 46 ms 99th-percentile worst gap here. And a fixed split of GPUs can run out of one phase: one prefill GPU tops out at about 11 requests a second, so at 12 the prompts queue for seconds; with 4 GPUs at 24 requests a second, 2 + 2 starves prefill and 3 + 1 crowds decode (19 ms per token).
5 10 20 50 100 200 500 tight loose 99th-percentile worst gap between tokens (ms, log scale), 2 GPUs colocated, whole prefill chunks of 512 chunks of 128 disaggregated 1 + 1 dots: 4, 8 and 12 requests a second (top to bottom)

The worst gap is set by the design, not the load: whole prefills always stall, chunks bound every step, and a disaggregated request’s first decode step waits for its cache.

Data: the output above
0% 25% 50% 75% 100% 4 8 12 requests per second, 2 GPUs requests meeting the loose SLO chunked, 128 or 512 disaggregated 1 + 1 whole prefill one prefill GPU tops out near 11 requests a second loose SLO: first token within 2 s, every later gap within 50 ms

Goodput is where a line falls off. The 1 + 1 split collapses when its one prefill GPU saturates near 11 requests a second.

Data: the output above

Two cautions on reading this. The comparison isn’t symmetric: two colocated GPUs both prefill, so they have twice the prefill capacity of a 1 + 1 split, which, more than interference, is why the split collapses at 12 requests a second. And the model is idealized in ways that flatter chunking. Its roofline assumes compute and memory overlap perfectly, so in it a step is free up to the ridge point of 148 tokens, and a 128-token chunk rides along almost at no cost. Real steps pay per-step overheads, each chunk re-reads the prompt’s earlier KV cache, and attention’s quadratic FLOPs are left out. It also has no memory limits or preemption, no batching of prefills (harmless above the ridge point), and no prefix caching.

Disaggregation’s advantages in practice come from what the model leaves out: running prefill and decode with different parallelism and batch settings, and so tuning each phase’s latency separately (DistServe); different hardware for each phase, since decode needs memory bandwidth and prefill needs compute (Splitwise); and isolating the very long prompts that make any shared step slow. Mooncake, Kimi’s serving system, runs disaggregation in production around a distributed pool of cached KV in the cluster’s otherwise idle CPU memory and SSDs, so a prompt whose prefix was seen before skips its prefill, and it rejects requests early under overload, which is how a service protects its goodput when traffic outruns it.

Where this leads

Takeaways

  • Users feel time to first token (queueing plus prefill) and the gaps between later tokens; TPOT averages the gaps and hides stalls, ITL doesn’t. SLOs bound high percentiles, and goodput is the request rate per GPU that still meets them.
  • Prefill and decode on the same GPU interfere: in simulation, whole prefills of long prompts froze decoding users for about half a second, while the average time per token looked fine.
  • Chunked prefill with a chunk sized to the per-token target removed the stalls at every load simulated, at some cost in time to first token.
  • Disaggregation removes the interference at the price of a KV-cache transfer (about 8% of the prefill time here, but 42 ms for a 16,000-token prompt, which sets the worst gap between tokens) and a split of GPUs that must match the traffic; a wrong split loses badly. Its real advantages are per-phase tuning, per-phase hardware and isolating long prompts, which an idealized model doesn’t capture.

References

Start with DistServe for goodput and disaggregation, then Sarathi-Serve for chunked prefill; Mooncake shows disaggregation in production.


Written by

Tan Le

AI engineer. I write these notes to learn LLM internals and AI engineering in depth, and to keep what I learn where I can find it again.