chaowen.← Back to archive

AI systems / inference serving

LLM serving: why prefill and decode want different machines

An LLM request has two very different computational phases: process the existing prompt, then generate one token at a time. Treating them as one batch is simple, but the phases fight over GPU compute, memory bandwidth, and latency. The key to separating them is the key/value cache: a large, structured tensor that must move from the prefill worker to the decode worker without changing its meaning.

AI systems deep dive · KV-cache movement and disaggregated inference

2026-08-19

The concrete problem

Suppose a user sends an 8,000-token prompt and expects the first answer token quickly. The server must run the transformer over all 8,000 input positions before it can generate token 8,001. That initial pass is called prefill. Afterward, each new token depends on all previous tokens, but only one new position is computed per step. That loop is called decode.

Prefill wants large matrix multiplications and enough parallel work to keep tensor cores busy. Decode has a tiny amount of new arithmetic but repeatedly reads the accumulated attention state from memory. Prefill is often compute-bound; decode is commonly memory-bandwidth- and latency-bound. A long prompt arriving at the wrong moment can therefore make existing chat sessions miss their per-token latency target even though the GPU has plenty of arithmetic capacity left during decode.

PhaseWork shapePrimary pressureUser-visible metric
PrefillMany prompt tokens in parallel; produces all prompt K/V states.Matrix compute, input length, prompt batching.Time to first token (TTFT).
DecodeOne new token per active sequence per iteration; reads the full history.HBM bandwidth, cache locality, synchronization, tail latency.Inter-token latency (ITL) and tokens/second.
HandoffMake the prompt K/V blocks available to a decode worker.Bytes transferred, topology, serialization, admission queue.Handoff latency and failed/expired transfers.
Core invariant: decode may begin only when the K/V state for every required layer and prompt position is available under the exact layout, dtype, head mapping, and sequence identity expected by the decode kernel. “The bytes arrived” is not enough; the block table and metadata must agree too.
request(prompt) │ ▼ prefill worker ── compute prompt ──▶ K/V blocks + metadata │ │ │ transfer over NVLink, PCIe, or RDMA │ │ └──────────────────────────────▶ decode worker │ one token per iteration append new K/V block entries │ ▼ stream output tokens

What the key/value cache contains

At each transformer layer, self-attention projects hidden states into queries, keys, and values. A new decode token needs a query, but it must compare that query with the keys and values for every earlier token. Recomputing those earlier projections would make generation needlessly expensive, so the server stores them in the KV cache.

For a model with L layers, HKV key/value heads, head dimension D, and B bytes per scalar, the approximate cache bytes per token are:

bytes_per_token ≈ 2 × L × H_KV × D × B

The factor 2 is for K and V. Grouped-query attention reduces HKV below the number of query heads, which is why two models with similar parameter counts can have very different decode memory requirements.

For L=32, HKV=8, D=128, and BF16 (2 bytes), one token costs about 128 KiB. An 8,192-token prompt then contributes about 1 GiB of K/V state for one sequence. That is the handoff payload before transport headers, staging buffers, alignment, replication, or a second copy on the receiving device.

The cache is not necessarily stored as one giant contiguous tensor. Variable-length requests arrive and finish continuously, and a contiguous allocation would force expensive copies or leave unusable holes. A paged design divides the sequence into fixed-size token blocks. The logical sequence has a block table; the physical blocks can live anywhere in a pool.

logical tokens: [0 ........ 15][16 ....... 31][32 ....... 47] logical blocks: 0 1 2 │ │ │ block table: [41] [07] [93] │ │ │ physical KV pool: block 41 block 07 block 93 The attention kernel walks the block table, not a virtual contiguous address. The final block may be partially full; its unused slots are internal waste.

Why one scheduler becomes two

Early high-performance serving systems improved throughput by scheduling at iteration granularity rather than waiting for an entire batch to finish. That lets a newly arrived request join a running batch and lets finished sequences leave. PagedAttention then made the cache itself shareable and fragment-resistant. Those ideas remain useful even when prefill and decode are split.

A single worker can still mix both phases, but it has to choose a policy. If it admits a large prefill, decode iterations wait behind a compute-heavy operation. If it protects decode, prompt processing is delayed and TTFT rises. Dynamic batching can reduce idle time, yet it cannot remove the physical interference between a long matrix workload and bandwidth-sensitive decode.

Disaggregated serving creates separate pools:

ComponentOwnsTypical scheduling objective
RouterRequest identity, admission, model/tenant routing, retries.Keep queueing and handoff visible; avoid retrying a partially transferred request.
Prefill poolPrompt tokenization, prompt forward pass, initial KV allocation.High compute utilization and bounded TTFT.
Transfer layerBlock metadata, memory registration, copy, checksum/ack, timeout.Maximize useful bandwidth without starving live decode traffic.
Decode poolPer-step attention, sampling, newly appended KV blocks.Stable ITL, fairness, and enough free cache capacity.

There is a practical asymmetry: prefill can often be batched across many prompt tokens, while decode is a repeated synchronization point. A decode worker may hold dozens of active sequences, but each iteration still needs every participating sequence’s current block table and output. This makes queueing policy and straggler handling part of model performance, not merely infrastructure plumbing.

The open-source implementation shape

Names change between releases, but a modern engine has roughly these objects and paths:

In vLLM, useful source landmarks include vllm/attention/layer.py, the v1 scheduler and GPU model runner, and the paged-attention CUDA kernels under csrc/attention/. The exact paths are release-sensitive, so search the checkout for block_table, slot_mapping, num_computed_tokens, and kv_cache. The important interface is not a class name: it is the contract that maps a logical token position to a physical cache slot and makes that mapping available to the kernel.

A transfer-aware control flow looks like this:

1. router assigns request R a model revision and tenant identity 2. prefill scheduler reserves blocks for R and runs prompt forward pass 3. prefill worker publishes: request_id, model/layout version, block IDs, token ranges, dtype, checksum 4. transfer layer copies the K/V payload and metadata to decode worker D 5. D validates ownership, shape, version, and checksum 6. decode scheduler atomically installs R's block table 7. decode loop runs; each sampled token appends K/V into a new slot 8. completion releases blocks; cancellation releases them too

Step 6 must be atomic from the decode scheduler’s point of view. Installing half a block table creates a particularly nasty failure: attention may read a valid-looking address containing another request’s data. The safe failure is to keep the request out of the runnable set until every required block is validated.

How much transfer is too much?

Let K be the prompt-cache bytes, R the effective end-to-end transfer rate, and Q the queueing and coordination delay. The minimum handoff contribution to TTFT is roughly:

handoff_time ≥ K / R + Q

For the example 1 GiB cache, an ideal 200 GiB/s path takes about 5 ms. A PCIe or RDMA path may have a much lower effective rate once staging, registration, copies, topology, flow control, and concurrent traffic are included. If the request will generate hundreds of tokens, that handoff may be worthwhile because it protects decode capacity. If the request is cancelled after its first token, the transfer was pure waste.

Some systems therefore use a threshold: short prompts stay on one worker, while long prompts or latency-sensitive traffic use the split path. Another option is layerwise or streamed transfer, allowing decode to begin when the first required layers arrive. That reduces startup latency but complicates scheduling, because a decode kernel must not touch a layer whose K/V is still in flight. It also increases the number of synchronization points and makes partial-transfer cancellation more expensive.

The current hardware conversation makes this division concrete. A recent report on Cerebras CS-4 describes prompt processing being offloaded to other accelerators while the wafer-scale system emphasizes decode, high on-device SRAM bandwidth, and low-latency interconnect. The durable lesson is architectural: the best device for building the cache need not be the best device for repeatedly reading it.

Failure modes and security boundaries

Debugging method

  1. Split TTFT into queue wait, prefill compute, block export, transfer queue, copy, validation, and decode admission. One “TTFT” histogram hides the actual bottleneck.
  2. For each handoff, log only identifiers and sizes: request ID, model fingerprint, number of blocks, logical token range, bytes, source/destination worker, start/end timestamps, checksum result, and release timestamp. Do not log K/V payloads.
  3. Compare live_tokens, allocated_slots, and free_blocks. A growing difference indicates fragmentation or a release path bug; a falling free-block count with stable traffic suggests a leak.
  4. When output is wrong, first force the same worker for prefill and decode. If the error disappears, compare dtype, tensor-parallel rank order, RoPE position offsets, block-table ordering, and model fingerprints before investigating sampling.
  5. When ITL spikes, correlate GPU memory bandwidth, decode batch size, transfer bytes, NCCL/RDMA counters, and scheduler runnable counts. A high GPU utilization percentage can coexist with poor token latency if the utilization is prefill work.
  6. Test cancellation, retry, worker death, and a full cache pool under load. These paths exercise ownership transitions that a happy-path benchmark never reaches.

Compact reproducible lab

Goal

Calculate the KV-cache payload for a real-sized configuration, estimate ideal handoff time, and see why paged allocation trades external fragmentation for bounded internal waste. This is a model of the memory contract, not a claim that a laptop can reproduce GPU kernel performance.

Prerequisites

Python 3.9 or newer. The lab uses only the standard library and runs on macOS, Linux, or Windows.

Exact commands

python3 - <<'PY'
from math import ceil

def kv_bytes(tokens, layers=32, kv_heads=8, head_dim=128, bytes_per_value=2):
    return tokens * 2 * layers * kv_heads * head_dim * bytes_per_value

def mib(n):
    return n / (1024 ** 2)

layers, kv_heads, head_dim, dtype_bytes = 32, 8, 128, 2
for prompt_tokens in (512, 8192, 32768):
    payload = kv_bytes(prompt_tokens, layers, kv_heads, head_dim, dtype_bytes)
    print(f"{prompt_tokens:5d} tokens: {mib(payload):8.1f} MiB")

payload = kv_bytes(8192, layers, kv_heads, head_dim, dtype_bytes)
print(f"ideal 200 GiB/s handoff: {payload / (200 * 1024**3) * 1000:.2f} ms")

for block_tokens in (16, 32, 64):
    sequences = (100, 37, 257)
    allocated = sum(ceil(n / block_tokens) * block_tokens for n in sequences)
    waste = allocated - sum(sequences)
    print(f"block={block_tokens:2d}: allocated={allocated:3d}, "
          f"waste={waste:2d} tokens ({waste / allocated:.1%})")
PY

Expected result

The 8,192-token prompt should be about 1024.0 MiB, and the idealized 200 GiB/s transfer should be about 5.00 ms. The page-size lines show the tradeoff: larger pages need fewer block-table entries but can waste more capacity in partially filled final pages. Real systems also pay alignment, metadata, transport, and concurrency costs.

Revealing variations

Primary sources