Skip to content
nonconvexcard

Inference Server Architecture

The software inside a replica: the scheduler loop, continuous batching, paged KV memory, preemption, and what disaggregating prefill from decode buys.

AI EngineeringGraduate⏱ 3 hours5 min readinference servercontinuous batchingpaged attentionschedulerpreemption

The loop

DefinitionThe iteration-level scheduler
while True:
    # 1. retire anything that finished on the previous step
    for seq in running.finished():
        emit(seq.final()); kv.free(seq); running.remove(seq)

    # 2. admit what fits, honouring the memory budget rather than a fixed batch size
    while waiting and kv.can_admit(waiting.peek(), running):
        seq = waiting.pop()
        kv.allocate_prompt(seq); running.add(seq)

    # 3. if memory is short, reclaim from the running set
    while kv.pressure() > HIGH_WATER:
        victim = running.select_victim()          # usually newest or longest-remaining
        preempt(victim)                           # swap out or drop and recompute

    # 4. one step for everyone at once
    logits = model.step(running.batch())          # prefill chunks + decode, fused
    running.append_tokens(sample(logits))

Two properties distinguish this from static batching. The batch is re-formed every step, so waiting time is bounded by one step rather than one generation. And admission is governed by the KV memory budget, not by a batch-size constant, because memory is the binding constraint established in Sizing a Serving Cluster End to End.

Key idea

Iteration-level scheduling is what removes the head-of-line blocking described in Scheduling, Admission Control and Fairness. Under static batching a 20-token request queued behind a 2000-token one waits for the whole generation; here it waits for one decode step. The fairness improvement is roughly the ratio of the longest generation to one step, which is three orders of magnitude.

Why the KV cache is paged

PropositionContiguous KV allocation wastes memory proportional to the length spread

Suppose sequence ii reserves space for Limax⁡L_i^{\max} tokens contiguously but uses only LiL_i. The wasted memory is ∑i(Limax⁡−Li)\sum_i (L_i^{\max} - L_i) per-token-bytes. With a common reservation Lmax⁡L^{\max} and actual lengths averaging Lˉ\bar L, the utilisation of the KV budget is

η  =  LˉLmax⁡,\eta \;=\; \frac{\bar L}{L^{\max}} ,

so a workload declaring Lmax⁡=4096L^{\max} = 4096 and averaging Lˉ=300\bar L = 300 uses 7.3%7.3\% of the memory it reserved.

Key idea

Paging fixes this the way operating systems always have: allocate the KV cache in fixed-size blocks and keep a per-sequence block table, so a sequence occupies only the blocks it has actually filled and grows one block at a time. Utilisation rises from Lˉ/Lmax⁡\bar L / L^{\max} to nearly 11, and by the sizing formula in Sizing a Serving Cluster End to End the concurrency ceiling Lmax⁡=ηMkv/mL_{\max} = \eta M_{\text{kv}}/m rises in exact proportion — a tenfold increase in the number of concurrent sequences, from a memory-management change with no effect on the mathematics of the model.

Blocks also make prefix sharing natural: two sequences with a common prompt point at the same blocks, with copy-on-write when they diverge.

Running out of memory mid-generation

Admission is a prediction — a sequence admitted when memory was free may need more blocks later than remain. The server must therefore be able to take memory back.

DefinitionTwo ways to reclaim a running sequence
SwapRecompute
Actioncopy the sequence's KV blocks to host memoryfree the blocks, remember the tokens
Cost to resumetransfer back over PCIere-prefill the whole sequence
Scales withtokens generated so fartokens generated so far
Better whenPCIe is idle, sequence is longcompute is idle, sequence is short

Victim selection is usually newest-first — the most recently admitted sequence has the least invested and is cheapest to restart — which is a last-in-first-out discipline layered under whatever policy governs admission.

Splitting the two phases

DefinitionPrefill/decode disaggregation

Prefill is compute-bound and decode is memory-bandwidth-bound — the asymmetry derived in Inference Arithmetic and the Roofline. Running both on the same replica means each phase interferes with the other: a long prefill stalls every decode in the batch, producing the inter-token latency spikes users perceive as stuttering.

Disaggregation puts them on separate pools:

request ─► [prefill pool]  ──KV transfer──►  [decode pool] ─► tokens
           compute-optimised                 bandwidth-optimised
           large batches, short residency    many sequences, long residency
GainCost
Prefill poolsized for FLOPs, batched aggressively—
Decode poolno prefill interference, smooth inter-token latency—
Transfer—the whole KV cache crosses the network per request
Operationseach pool scales independentlytwo fleets, two failure domains

Key idea

The trade is decided by the KV transfer. Moving a sequence's cache between machines costs mm bytes over the interconnect — hundreds of megabytes for a long prompt. Disaggregation pays when the interconnect is fast relative to the interference being removed, which in practice means a high-bandwidth fabric and prompts long enough that prefill interference is the dominant latency defect. On a modest network, or for short prompts, the transfer costs more than the interference.

What this does not say

This page describes a single replica's internals. The interaction with routing is real and was covered from the other side in GPU Routing and Cache Affinity: paged blocks with prefix sharing are exactly what makes cache affinity worth routing for, and disaggregation changes the affinity question because the decode pool holds the state while the prefill pool is interchangeable.

References

These works are cited as sources to read, not reproduced. All exposition, examples and problems on this site are written independently; where an argument is standard, the citation points to a canonical treatment.

Results stated on this page