AI Infra
0%
Part V · Chapter 32

Memory and Scheduling

AuthorChangkun Ou
Reading time~19 min

A serving engine spends most of its accelerator memory on one growing object, the key-value cache, and most of its scheduling effort deciding which requests share a forward pass. The mechanisms in this chapter form a dependency chain. Continuous batching removes static-batch waste but makes allocation churn visible. PagedAttention answers it, clearing the fragmented cache and making prefix sharing cheap. With sharing now cheap, radix-tree prefix caching reuses shared prompts, but prefill and decode still interfere. That last interference is what phase splitting removes, and the price is a network transfer. Memory layout and scheduling policy are therefore the same serving problem, seen from two sides.

Two facts that make one problem

Chapter 31 named the two phases of generation and their opposite shapes. Prefill reads the whole prompt in one compute-bound pass and produces the first token. Decode then emits one token per forward pass, each step reading the entire key-value cache for one new query, so decode is memory-bandwidth-bound and runs at batch size one per request unless the engine batches across requests. The memory cost and the scheduling cost of those two phases are not separate concerns. They are two faces of one resource problem, and treating them together is what this chapter is for.

The memory fact is that the key-value cache is large, per-request, and grows by one slot per layer per generated token. For a request of length TT, the cache holds 2TLnkvdhead2 \cdot T \cdot L \cdot n_{\text{kv}} \cdot d_{\text{head}} values, the factor of two for keys and values. On a model with billions of parameters the weights are fixed, but the cache for a few dozen concurrent long-context requests can exceed the weights, and it appears and disappears as requests arrive and finish. A naive engine reserves the maximum possible length for every request up front, which wastes most of the reservation for requests that finish early and caps the batch size far below what the hardware could hold.

The scheduling fact is that requests do not share a length. In a batch of generations, one request may stop at twenty tokens and another run to two thousand. If the engine schedules a batch and waits for every member to finish before admitting the next, the short requests sit idle inside a padded batch while the longest one decodes, and newly arrived requests wait behind the whole batch. Throughput collapses to the pace of the slowest member, and latency for a new arrival is the tail of the batch in front of it.

So the problem has two halves that this chapter treats together: pack the key-value cache so memory is not wasted, and schedule the forward passes so no request waits on another's length. The four sections that follow each take one mechanism, and each mechanism earns its place by fixing what the previous one exposed.

Iteration, not request

The first move is to schedule at the granularity of an iteration rather than a request. An iteration is one forward pass that advances every active request by a single token. After each iteration the scheduler is free to remove requests that just emitted their stop token and admit waiting requests into the next iteration's batch. This is continuous batching, also called iteration-level scheduling, introduced by Orca (Yu et al. 2022). The batch is no longer a fixed cohort that lives and dies together; it is a sliding window over the active set, refilled every step. A request that finishes at token twenty leaves immediately and its slot is reused, instead of padding out the batch until the longest member finishes. Figure 32.1 shows the loop: every iteration retires whatever just emitted its stop token and admits waiting requests, so the batch composition changes each step.

waiting Waiting queue form Form batch from active set waiting->form admit fwd One forward pass, advance each request by one token form->fwd sample Sample one token per request fwd->sample done Emitted stop token? sample->done done->form no, keep in active set retire Retire request, free its blocks done->retire yes retire->form slot reused
Figure 32.1. The continuous-batching loop. The scheduler forms a batch, runs one forward pass that advances every active request by one token, retires those that emitted a stop token, and admits waiting requests before the next iteration, so the batch is a sliding window refilled every step. After Yu et al., 2022.

Before Orca, serving systems batched at request granularity: form a batch, run it to completion, return the results. This is request-level scheduling, and it inherits the static-batch pathology of any padded-cohort design. Orca's contribution was to schedule at iteration granularity and to make the batch a living set, refilled every step, which is the single change that lets a serving engine keep accelerators busy under heterogeneous request lengths (Yu et al. 2022). Orca also introduced selective batching, batching the parts of the computation that can be batched across requests of different lengths while running attention per request.

Continuous batching solves the scheduling fact and immediately exposes the memory fact in a sharper form. If admission happens every iteration, the engine must allocate and free key-value cache space every iteration, for requests whose final length it does not know. Reserving a contiguous maximum-length buffer per request, the obvious scheme, fragments memory: a finished request leaves a hole too small for the next, and reserved-but-unused tail space is dead weight. Measurements in the vLLM paper put the waste from this fragmentation and over-reservation at the majority of cache memory in prior systems (Kwon et al. 2023). The next move exists to reclaim it.

The cache as virtual memory

The second move removes that waste by borrowing virtual memory from operating systems. The insight is that the key-value cache is exactly the kind of object virtual memory was invented for: a per-process allocation that grows unpredictably and must share a fixed physical pool without fragmentation. PagedAttention (Kwon et al. 2023) stores the key-value cache in fixed-size blocks, each holding the keys and values for a fixed number of tokens, and lets a request's logical sequence of blocks map to physically non-contiguous blocks through a block table. The attention kernel is rewritten to gather keys and values through that table instead of assuming contiguity. Now allocation is per block, not per request: a request grows by appending a block when its current block fills, memory is shared at block granularity across all requests, and the only internal waste is the partial final block. The analogy is precise. The block is a page, the block table is a page table, and the engine is a memory manager that hands out and reclaims pages as sequences grow and finish. Figure 32.2 shows the indirection: a request's logical sequence of token blocks maps through a block table to physically scattered blocks in a shared pool.

cluster_logical Logical KV (per request) cluster_table Block table cluster_phys Physical blocks (shared pool) t0 tokens 0-15 t1 tokens 16-31 t0->t1 e0 slot 0 t0->e0 t2 tokens 32-47 t1->t2 e1 slot 1 t1->e1 e2 slot 2 t2->e2 b7 block 7 e0->b7 b2 block 2 e1->b2 b9 block 9 e2->b9
Figure 32.2. PagedAttention indirection. A request's logical token blocks map through a block table to non-contiguous physical blocks in a shared pool, so allocation is per block and the only waste is the partial final block. After Kwon et al., 2023.
Figure 32.3. A pool of KV-cache blocks, the same four requests under each scheme. Toggle to contiguous and every request books a full row, its maximum length, so the dashed blocks sit reserved but unused; toggle to paged and the pool packs to the realized length, leaving blocks free for more requests. Watch the utilization readout as the sequences grow. Illustrative.

vLLM is the serving system built on this idea, and it reported two to four times the throughput of the prior generation at the same latency by lifting the achievable batch size (Kwon et al. 2023). The lift comes from where the waste goes. Figure 32.4 contrasts the two schemes: reserving a maximum-length buffer per request leaves utilization at the ratio of average to maximum length, so a workload of mostly short requests strands most of the pool, while paging holds only the realized length plus a partial final block and keeps utilization near one regardless of length spread, which is what lifts the achievable batch size. The rest of vLLM, the block manager, the swapping of blocks to host memory under pressure, the copy-on-write sharing, all follows from treating the cache as paged virtual memory. The result reset the throughput baseline for open serving. It also opened a door: once the cache is paged and blocks can be shared, identical content across requests need not be stored or computed twice.

2026-06-23T21:05:50.652622 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 0.0 0.2 0.4 0.6 0.8 1.0 mean / max request length (length spread) 0.0 0.2 0.4 0.6 0.8 1.0 KV memory utilization reclaimed utilization, and thus higher batch size paged (block by block) reserve max length
Figure 32.4. Schematic of why paging lifts the achievable batch size, after Kwon et al. (2023). Reserving a maximum-length buffer per request leaves utilization at the mean-to-maximum length ratio, so most of the pool sits dead when requests finish early, while paged allocation wastes only the partial final block and holds utilization near one across the whole length spread, which is what lifts the achievable batch size. Idealized synthetic data, not measured.

Sharing prefixes

Paging makes sharing cheap, and that is the third move. Two requests that begin with the same prompt, a shared system prompt or a few-shot preamble, compute identical keys and values for that prefix. Under paging, they can point their block tables at the same physical blocks for the shared prefix and diverge only where their tokens diverge, with copy-on-write when one writes into a shared block. The open question is how to find shareable prefixes automatically across a stream of requests with arbitrary overlap. RadixAttention (Zheng et al. 2024), the prefix caching mechanism of the SGLang runtime, answers it by storing all cached prefixes in a radix tree keyed by token sequence, with least-recently-used eviction. A new request walks the tree to find the longest cached prefix of its prompt, reuses those blocks, and computes only the uncached suffix. The radix tree turns prefix reuse from a special case, handled by hand for a known shared prompt, into a default the runtime applies to all traffic. Prefix caching turns the repeated structure of real workloads, shared system prompts, multi-turn chat history, branching agent calls, into reused computation rather than recomputed computation. The same reuse is now a billed product surface: API providers sell it as prompt caching, with cached input tokens priced at roughly a tenth of the standard input rate and some providers charging for cache writes (Anthropic 2025), so putting the stable prefix first in a prompt became a caller's cost lever as well as a server's scheduling win. Figure 32.5 shows two requests sharing a system-prompt prefix: they point at the same cached blocks up to the branch point and compute only their divergent suffixes.

radix root root shared shared system prompt (cached blocks, refcount 2) root->shared sufA request A suffix (computed) shared->sufA branch sufB request B suffix (computed) shared->sufB branch
Figure 32.5. Prefix sharing in a radix tree. Two requests with the same system prompt share its cached blocks up to the branch point and compute only the suffix where their tokens diverge. The shared node is reference-counted and copied on first write past it. After Zheng et al., 2024.

Paging and prefix sharing both assume prefill and decode share a device and a batch, and that assumption is the next thing to break. Running both in one batch means a long prefill stalls the decodes waiting behind it, because the two phases want different parallelism and different batch shapes. One way out keeps the single device and schedules around the interference. Sarathi-Serve (Agrawal et al. 2024) addresses it within a single device by splitting prefill into chunks and coalescing each chunk with ongoing decodes, so a long prefill never stalls decode. The fourth move takes the opposite route.

Splitting the phases

The fourth move separates the two phases onto different hardware. Prefill is compute-bound and bursty; decode is memory-bandwidth-bound and steady. Prefill/decode disaggregation runs prefill on one pool of machines and decode on another, transferring the key-value cache between them over the network. Each pool is provisioned and parallelized for its own phase, and neither interferes with the other's latency. DistServe (Zhong et al. 2024) optimizes the placement for goodput, the rate of requests that meet both a time-to-first-token target for prefill and a time-per-output-token target for decode. Mooncake (Qin et al. 2024) centers the whole architecture on the key-value cache, pooling CPU dynamic random-access memory (DRAM) and SSD across the cluster into a disaggregated cache that prefill writes and decode reads. Figure 32.6 sketches the split: the prompt enters a prefill pool, the resulting key-value cache crosses the network, and the decode pool reads it to emit tokens, each pool sized and parallelized for its own phase.

cluster_prefillpool Prefill pool (compute-bound) cluster_decodepool Decode pool (bandwidth-bound) prompt Prompt pp Read full prompt, one pass prompt->pp ft First token pp->ft kv Build KV cache pp->kv dp Read KV cache kv->dp KV cache transfer over network step Emit one token per pass dp->step step->dp append to KV out Output stream step->out
Figure 32.6. Prefill/decode disaggregation. A compute-bound prefill pool produces the first token and the key-value cache, which transfers over the network to a memory-bandwidth-bound decode pool that streams the remaining tokens. Each pool is provisioned and parallelized for its own phase. After Zhong et al., 2024 and Qin et al., 2024.

This is the most recent move, and it inverts the assumption the first three shared. The within-device line, exemplified by chunked-prefill scheduling (Agrawal et al. 2024), keeps one resource pool and schedules carefully; the disaggregated line (Zhong et al. 2024; Qin et al. 2024) accepts a network transfer of the key-value cache to remove interference entirely. At scale the two lines composed rather than one displacing the other: fleets disaggregate across pools while each pool's scheduler still chunks and interleaves, and disaggregation is the core design of the production orchestration layers, NVIDIA Dynamo and the CNCF llm-d project among them (NVIDIA 2025; llm-d project 2025).

Once the cache can cross the network, its placement stops being a single device's problem. Production systems now treat KV blocks as a tiered storage hierarchy of their own: hot blocks in accelerator memory, warm blocks spilled to host DRAM and SSD, and shared stores such as LMCache and Mooncake's disaggregated store keeping them addressable across instances, so a prefix computed on one replica can be reused on another (LMCache team 2025; Qin et al. 2024). Routing follows the cache: a KV-aware scheduler such as Dynamo's sends a request to the replica that already holds its prefix rather than to the least loaded one (NVIDIA 2025). The cache this chapter began by scheduling within one device ends as a placed, routed resource at fleet scale.

What's contested

What remains contested about disaggregating prefill and decode is the crossover point, not the mechanism. Disaggregation removes phase interference and lets each pool scale and parallelize independently, and at large scale with long prompts the reported goodput gains are substantial (Zhong et al. 2024). But it pays a network transfer of the key-value cache on every request, needs enough cross-machine bandwidth to hide that transfer, and complicates routing and fault handling. The colocated camp, exemplified by chunked-prefill scheduling (Agrawal et al. 2024), argues that careful within-device scheduling captures most of the benefit at a fraction of the operational cost and with no transfer. The right answer still depends on prompt length, the time-to-first-token and time-per-output-token targets, and the interconnect: at frontier fleets with long prompts disaggregation has become the norm, while small deployments and short prompts still favor colocation, and the open question is where a scheduler should switch between the modes per workload.

Constraint arrow

The attention design upstream sets how hard this chapter's job is. The size of the key-value cache, the term that paging exists to manage, is fixed by the number of key-value heads chosen in Chapter 8. Multi-query and grouped-query attention shrink that term by sharing key-value heads across query heads, and the latent attention of Chapter 9 compresses it further. A smaller per-token cache means more sequences fit in the same memory, which raises the achievable batch size, which is the lever continuous batching pulls for throughput. An attention variant chosen for cache size is, in effect, a scheduling decision made one layer down.

What each move costs

The cascade buys four benefits, and each comes with a named cost.

  • Block size in paging. A small block wastes little in the partial final block and shares at fine granularity, but multiplies block-table entries and per-kernel gather overhead. A large block cuts that overhead but wastes more tail memory and shares more coarsely. Figure 32.7 plots the two costs against block size: internal fragmentation rises with the block while indirection overhead falls, and their sum is a U-curve with an interior optimum. The block size is a tuning knob, not a constant, and the right value depends on typical sequence length and how much prefix sharing the workload has.
2026-06-21T21:25:09.969367 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/
Figure 32.7. Schematic of the block-size trade-off in paged attention, after Kwon et al. (2023). Internal fragmentation, the partial final block, grows with block size, while indirection overhead, the block-table entries and the gather through them, falls as roughly one over the block size. Their sum is a U-curve with an interior optimum, which is why the block size is a tuning knob. Idealized synthetic data, not measured.

Try changing T, the typical sequence length, and watch the optimal block size move with it, matching the square-root formula.

import numpy as np
import matplotlib.pyplot as plt
T = 512            # typical sequence length, in tokens
w_frag = 1.0       # cost per wasted token in the partial final block
w_indir = 4.0      # cost of one block-table entry and its gather
b = np.arange(1, 257)
frag = w_frag * (b / 2)          # waste grows with block size
indir = w_indir * (T / b)        # indirection falls as 1/block
total = frag + indir
b_star = b[np.argmin(total)]
b_formula = np.sqrt(2 * w_indir * T / w_frag)
print("argmin block size:", b_star, "  sqrt formula:", round(b_formula, 1))
plt.plot(b, frag, "--", label="fragmentation (b/2)")
plt.plot(b, indir, "--", label="indirection (T/b)")
plt.plot(b, total, label="total")
plt.axvline(b_star, color="k", lw=0.8)
plt.xlabel("block size (tokens)"); plt.ylabel("cost"); plt.legend(); plt.show()
  • Prefix-cache retention versus memory. Keeping more prefixes in the radix tree raises the hit rate for workloads with shared structure, but the cache competes with live requests for the same physical blocks. Under memory pressure the engine must evict cached prefixes to admit new work, so the prefix cache is a bet that reuse will pay before the blocks are reclaimed. Workloads with little overlap pay the bookkeeping and get little back.
  • Disaggregation versus colocation. Disaggregation removes interference and lets each phase scale alone, at the cost of a key-value cache transfer per request and the bandwidth to hide it. Colocation with chunked prefill keeps one pool and one transfer-free path, at the cost of scheduling complexity and some residual interference. The crossover is set by prompt length and latency targets.
  • Throughput versus latency in batching. A larger continuous batch raises throughput but lengthens each iteration, which raises time-per-output-token for every request in it. Admission is therefore a latency knob: the engine can cap batch size or reserve headroom to protect tail latency, trading some throughput for it.

The engine as a loop and a memory manager

All four moves reduce, in code, to a loop and a memory manager. The loop is iteration-level: each step, the scheduler picks the set of requests to run, the model runs one forward pass over them, the sampler produces one token each, finished requests are retired, and waiting requests are admitted into the next step. In vLLM this is the engine-core step (EngineCore.step, vllm/v1/engine/core.py), which calls the scheduler (vllm/v1/core/sched/scheduler.py) to form the batch and the model runner to execute it. The scheduler tracks queues for running, waiting, and swapped requests, and moves requests between them as cache blocks are allocated and reclaimed.

The memory manager owns the block pool. It maintains a free list of physical blocks and a per-request block table mapping logical positions to physical blocks. Allocation appends a block when the current one fills; freeing returns a request's blocks to the pool on completion. Under pressure the manager can preempt a request, either recomputing its cache later or swapping its blocks to host memory and back, which is the serving analogue of paging to disk. The attention kernel reads through the block table, so the physical layout stays invisible to the rest of the model. For prefix sharing, the block table of a new request is initialized to point at the cached blocks of its longest matching prefix, with a reference count so a shared block is freed only when its last user finishes, and copied on first write past the shared point.

Two operational realities are worth naming. First, the partial final block is the only internal fragmentation paging leaves, so average waste falls as sequences lengthen relative to block size, which is why long-context workloads benefit most from paging. Second, prefix-cache hit rate is workload-shaped: a chat service with a fixed system prompt or an agent loop that re-sends a long tool preamble can see most of its prefill served from cache, while a stream of unrelated single-turn prompts sees almost none. Instrumenting the hit rate, the achievable batch size, and the preemption rate is how an operator reads whether the memory manager is winning.

Further reading

  • Yu et al., “Orca: A Distributed Serving System for Transformer-Based Generative Models,” 2022. usenix.org
  • Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention” (vLLM), 2023. arXiv:2309.06180
    PagedAttention manages LLM KV cache in non-contiguous paged blocks, reducing fragmentation and enabling sharing, which vLLM uses to improve serving throughput 2-4x.
  • Zheng et al., “SGLang: Efficient Execution of Structured Language Model Programs” (RadixAttention), 2024. arXiv:2312.07104
    SGLang introduces RadixAttention for automatic KV cache reuse and compressed finite-state machines for faster constrained decoding, achieving up to 6.4x throughput gains over vLLM and similar systems.
  • Zhong et al., “DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving,” 2024. arXiv:2401.09670
    DistServe improves LLM serving goodput by disaggregating prefill and decoding onto separate GPUs, eliminating interference and enabling independent resource and parallelism optimization for TTFT and TPOT.
  • Qin et al., “Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving,” 2024. arXiv:2407.00079
    Mooncake is a KVCache-centric disaggregated LLM serving platform for Kimi that separates prefill and decoding clusters and uses idle CPU/DRAM/SSD to cache KVCache, achieving up to 525
  • Agrawal et al., “Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve” (chunked prefill), 2024. arXiv:2403.02310
    Sarathi-Serve introduces chunked-prefill and stall-free batching to eliminate the throughput-latency tradeoff in LLM inference, achieving up to 5.6x higher serving capacity over vLLM.
  • NVIDIA, “NVIDIA Dynamo: A Datacenter-Scale Distributed Inference Serving Framework,” 2025. github.com
    NVIDIA Dynamo is an open-source datacenter-scale inference serving framework built around prefill/decode disaggregation, KV-cache-aware request routing, and tiered KV-cache offloading across GPU memory, host DRAM, SSD, and networked storage.
  • llm-d project, “llm-d: Kubernetes-Native Distributed Inference Serving,” 2025. github.com
    llm-d is a CNCF sandbox project founded by Red Hat, Google Cloud, IBM Research, CoreWeave, and NVIDIA that brings prefill/decode disaggregation, prefix-cache-aware routing, and tiered KV-cache offloading to Kubernetes-based LLM serving.
  • LMCache team, “LMCache: A KV Cache Management Layer for LLM Inference,” 2025. github.com
    LMCache is a KV-cache management layer for LLM serving engines that offloads and reuses KV caches across a tiered hierarchy of GPU memory, CPU DRAM, local disk, and remote backends, so a prefix computed once can be served from cache across requests and instances.
  • Anthropic, “Prompt Caching,” 2025. platform.claude.com
    Anthropic's prompt-caching documentation prices cached input reads at one tenth of the base input token rate, with cache writes billed at a premium, making prompt structure an explicit cost lever for API callers.

Comments

Log in to comment