Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ch08 · Paged Attention and the Block Manager

The problem

ch07 · Continuous Batching’s scheduler wants to admit more sequences than memory allows. To see why that limit is lower than it should be, ask a question the engine cannot answer: how much cache does this request need?

Nobody knows. The request will generate somewhere between one token and its budget, and which one is not determined until it happens. A contiguous allocator has to commit before finding out, and both available answers are bad:

The first is internal fragmentation, the second external. Together they mean a server can be out of memory while most of its memory is doing nothing.

The idea

This problem is old, and operating systems solved it in the 1960s: stop allocating contiguous ranges.

Split KV memory into fixed-size blocks. Give each sequence a block table mapping its logical token positions to physical blocks. Allocate one block at a time, as the sequence actually grows. Three things follow immediately:

This is PagedAttention, introduced by vLLM, and the analogy to virtual memory is exact enough that the terminology carries over wholesale.

Running out of memory becomes a scheduling decision

A contiguous engine that exhausts memory has no move available. A paged engine does: preempt a running sequence, return its blocks to the pool, and requeue it.

Two ways to bring it back, and the choice is a real trade:

We recompute, and preempt the newest sequence because it has generated least, so the least work is lost.

One detail matters enormously here and is easy to get backwards. Preemption discards the cache, never the output. A sequence that has already streamed twenty tokens to a caller keeps them; only its keys and values are rebuilt, from the tokens it has already produced. Clearing the output instead would make the caller receive those twenty tokens a second time — a correctness bug wearing a scheduling costume, and one our tests caught precisely because they check what the caller receives rather than what the engine does.

The build

The allocator is a free list with reference counts. The counts are unused until ch09 · Prefix Caching, and present now because retrofitting them later would mean touching every call site:

blocks.py
class BlockAllocator:
    """A pool of fixed-size KV blocks with reference counting.

    Reference counts exist for chapter 9: when two sequences share a prompt prefix they share the
    blocks holding it, and a block may only be freed once every sequence using it is done. Until
    then every count is 1 and the mechanism is invisible.
    """

    def __init__(self, n_blocks: int, block_size: int) -> None:
        if n_blocks < 1 or block_size < 1:
            raise ValueError("n_blocks and block_size must both be positive")
        self.n_blocks = n_blocks
        self.block_size = block_size
        self._free: list[int] = list(range(n_blocks))
        self._refcount: dict[int, int] = {}

Storage is per layer and indexed by block, not by sequence — which is what makes a sequence merely a list of numbers:

blocks.py
class PagedKVCache:
    """Block-structured KV storage for every layer at once.

    Keys and values live in one tensor per layer, indexed by physical block rather than by
    sequence. A sequence is then just a list of block numbers, which is what makes both the
    fragmentation fix and chapter 9's sharing possible.
    """

Growth allocates only what the next token needs:

paged.py
    def _grow(self, state: RequestState, n_tokens: int) -> None:
        """Make room for ``n_tokens`` more tokens, allocating blocks only as needed."""
        needed = self.cache.allocator.blocks_needed(state.stored + n_tokens)
        if needed > len(state.block_table):
            state.block_table += self.cache.allocator.allocate(needed - len(state.block_table))

And admission has to account for what it has already promised within the same step:

paged.py
        admitted: list[RequestState] = []
        # Blocks are not actually taken until prefill, so the admission loop must subtract what
        # it has already promised. Checking each candidate against the same free count admits a
        # batch that cannot fit, and prefill then fails on a request the scheduler said yes to.
        reserved = 0
        while self.waiting and len(self.running) + len(admitted) < self.max_batch_size:
            candidate = self.waiting[0]
            need = self.cache.allocator.blocks_needed(len(candidate.all_token_ids) + 1)
            if need > self.cache.allocator.n_free - reserved:
                break  # not enough memory; leave it queued rather than thrash
            reserved += need
            admitted.append(self.waiting.pop(0))

That reserved counter fixes a bug worth naming, because the shape of it recurs throughout scheduling. Blocks are not actually taken until prefill runs, so checking each candidate against the same free-block count admits a batch that cannot fit — and prefill then fails on a request the scheduler had already accepted. A scheduler must account for its own promises, not just for the current state.

The measurement

First, what paging bought. Same KV budget, allocated two different ways:

QuantityValue
KV budget24 blocks x 16 tokens = 384 slots
KV bytes per token3,072 B
Budget in bytes1.18 MB
Reserve-max: must reserve2,048 slots per sequence
Reserve-max: sequences that fit0
Paged: allocates on demand~96 slots per sequence
Paged: sequences that fit~4

A reserve-max allocator fits nothing at all in this budget: one sequence would claim the model’s entire maximum context before generating a single token. Paging fits several, because it only ever allocates what has actually been used. Scale that up and it is the difference between a GPU serving a handful of conversations and serving hundreds.

Now what it cost:

ConfigurationTTFT p50TTFT p95ITL p50Output tok/sGoodput req/s
ch07 Continuous, 4 req/s0.0171s0.0197s0.0065s138.54.118
ch08 Paged, 4 req/s0.0274s0.0362s0.0085s137.44.087
ch07 Continuous, 8 req/s0.0179s0.0227s0.0071s266.57.927
ch08 Paged, 8 req/s0.0284s0.0636s0.0119s251.77.486
ch07 Continuous, 16 req/s0.0219s0.0258s0.0116s457.313.6
ch08 Paged, 16 req/s0.0498s0.3515s0.0169s360.710.73

Conditions: TinyGPT (reference, random weights) (5,838,080 params), 4x x86_64 CPU, torch 2.14.0+cu130, poisson, prompt 32-96, output 16-48, seed 7, arrival rate 16.0/s, measured 2026-09-13T15:28:35+00:00.

Throughput fell. At the highest load the paged engine is meaningfully slower than ch07 · Continuous Batching, with worse TTFT and worse ITL. This is not a mistake in the measurement and it is not a bug to be fixed later — it is what this implementation actually costs.

The cause is in the gather. To run a step, each sequence’s cache is copied out of its blocks into a contiguous tensor, the batch is padded, and the result is written back:

blocks.py
    def gather(
        self, layer: int, block_table: list[int], length: int
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """Materialise a sequence's cache as contiguous [1, n_kv_heads, length, head_dim].

        Deliberately the simplest thing that works, and deliberately wasteful: it copies the whole
        cache on every step. Chapter 13 replaces it with a kernel that reads the blocks in place.
        """

That is a full copy of every running sequence’s cache on every single step, in Python, one block at a time. Contiguous storage needed no such copy.

So the honest summary of this chapter is: paging raises the concurrency ceiling and lowers throughput. Whether that is a good trade depends entirely on which one you are short of — and in production you are nearly always short of memory, which is why every serious engine pages. But the trade is only worth it once the gather stops costing this much, and making it stop is a kernel problem rather than a design problem. ch13 · Writing a Paged Attention Kernel in Triton [DRAFT] writes that kernel.

It is worth stating plainly that a book which reported only the memory win here would be misleading you, and a book which reported only the throughput regression would be missing the point.

The cost

Key takeaways

Looking ahead

Blocks can be shared, and reference counting is already in place. ch09 · Prefix Caching uses that to stop recomputing prompts the engine has already seen — which on a realistic chat workload is the largest remaining waste in the system, and unlike this chapter, it costs nothing.

Further reading

Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023, Appendix E) is the primary source and is now worth reading in full: you have implemented its block manager and run into the gather problem its kernel exists to solve.