Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

Why does PagedAttention exist?

Naive KV-cache allocation reserves a contiguous slab for the worst-case sequence length, then watches 60–80% of it sit unused. PagedAttention asks: what if we treated GPU memory the way an operating system treats RAM?

AI & ML intermediate Apr 30, 2026 · updated Aug 25, 2026 · 13 min read

On this page

The picture version

Six pictures for a reader who has never thought about how a chat server budgets memory. The prose below fills in the seams the pictures skip.

1 · The problem

A four-word answer, holding memory for two thousand words it never wrote.

“what’s the capital of France?” before writing a single word, the server must reserve room for the longest answer allowed reserved: 2,048 slots used: 12 The other 2,036 sat locked and empty for the whole request. multiply that by every conversation running at once measured across the systems of the day: 60–80% of that memory wasted
The server can’t know how long an answer will run, so the safe move is to reserve the worst case up front. The Paris question is the running example — four words back, and a reservation sized for two thousand.

2 · Two ways that memory goes missing

Space stranded inside reservations, and space stranded between them.

stranded inside each answer stopped early; the tail of its reservation is dead space stranded between plenty of free memory in total — no single gap big enough for the next request Both have one cause: each answer demanded one unbroken slab, sized for a length nobody could predict.
One kind of waste is the unused tail of an over-sized reservation; the other is free space chopped into gaps too small to use. Both come from insisting each answer’s memory be one unbroken run, which is the requirement worth attacking.

3 · Why the obvious repair is worse

“Reserve less and grow later” means copying, mid-sentence.

nearly full the answer needs one more slot so find a bigger hole… …and copy everything into it while the reader is waiting for the next word You have traded a bad property for a worse one. The real fix is to stop demanding one unbroken run at all. which is a fifty-year-old idea, borrowed almost verbatim from how operating systems hand out memory
Shrinking the reservation only moves the problem: growing an unbroken run means finding a larger unbroken hole and copying into it on the critical path. The requirement itself is what has to go.

4 · The fix

Hand out small numbered trays, and keep a ticket listing which ones are yours.

your ticket words 0–15tray 427 words 16–31tray 88 words 32–47tray 903 to read word 20, look up the tray, then step 4 in the shelves, wherever there’s room yours are scattered; nothing has to be next to anything The Paris answer now takes one tray. Four slots wasted, not 2,036. and the waste is capped forever: at most one part-full tray per answer, however big a limit the caller declared the gaps problem disappears too, because every tray is the same size — no scrap is ever the wrong shape
Chop the memory into small fixed-size blocks scattered anywhere, and give each answer a table mapping its positions to physical blocks. Growth is now just adding a row to the table — no reservation for words not yet written, and no copying.

5 · The second win, which is the bigger one

Two answers can point their tickets at the same tray.

answer 1answer 2answer 3 the shared prompt, stored once three answers to the same question, and only one copy of the question then they diverge a fresh tray each, only from here on Allocate-on-demand killed the wasted slots. Sharing is what keeps paying afterwards. asking for several answers to one prompt, exploring several continuations at once, or reusing a prompt someone already sent — all of them become nearly free and when one of them writes into a shared tray, the system copies just that tray, leaving the earlier ones shared
Once positions are decoupled from physical placement, two answers can point at the same block for as long as they agree. That is why the biggest measured speedups came from exploring several continuations at once, where the shared prefixes are longest — not from the fragmentation win alone.

6 · Keep this card

The whole thing on one index card.

the whole trick = small fixed-size trays, scattered anywhere + a ticket saying which trays are yours, in order + and two tickets may name the same tray the third line is the one the name doesn’t tell you it isn’t free: every read now costs one extra lookup, and the hard part was making that lookup cheap bigger trays mean less looking up but more waste — and, where sharing is matched whole-tray, fewer chances to share this also isn’t the only way to attack cache waste; it stacks with eviction, compression and sliding windows rather than replacing them
Picture to keep: a coat check instead of a private closet — your answer’s cached words are handed out one small numbered tray at a time, scattered wherever there’s room, and a ticket stub lists which trays are yours in order.

Why it exists

You type “what’s the capital of France?” into a chat app and get back four words. That request carried a generation cap with it — whatever the app set, or its default, say “up to 2,048 tokens.” And here’s the awkward part: in the serving systems that predate this post’s subject, the engine had to decide how much GPU memory to set aside for your answer’s KV cache before it generated a single token, and the only safe guess was that cap. Twelve tokens came back. Memory for the other 2,036 sat locked and empty for the whole request.

The KV cache is the chunk of state that grows by one entry per generated token and is read on every subsequent step; the engine can’t know in advance how many entries there will be. So the safe move is to allocate the worst case: a contiguous slab big enough for 2,048 tokens, right now, before generation even starts.

Multiply that by every concurrent request and you get the situation the vLLM team measured across the systems they benchmarked in 2023: those systems wasted 60–80% of the HBM they had set aside for KV caches (Kwon et al., 2023). Some of it was internal fragmentation — slots reserved for tokens the model never generated. Some was external fragmentation — the engine knows there are 200 MB free somewhere across all the gaps between live caches, but no single contiguous 200 MB block, so a new request has to wait or be rejected. Both failure modes have the same cause: KV caches were allocated as one contiguous tensor per sequence, sized for the worst case, and contiguous worst-case allocation does not survive a workload of varied generation lengths.

The fix turns out to be a 50-year-old idea from operating systems.

Why it matters now

LLM serving is overwhelmingly KV-cache-bound when you push the batch size up. Continuous batching already wins back the GPU cycles wasted on padding; the next bottleneck is whether you can fit enough sequences in memory at once for that scheduler to have something useful to schedule. Reduce KV-cache waste from 60–80% to under 4% (the figure the vLLM team reports) and you fit materially more concurrent sequences on the same hardware — enough, in the paper’s measurements, to translate into 2–4× higher throughput at matched latency vs the contemporary baselines (FasterTransformer, Orca).

That is why block-based KV-cache management is now table stakes rather than a differentiator: vLLM originated PagedAttention, and other serving stacks ship their own paged KV cache — NVIDIA’s TensorRT-LLM, for instance, documents a paged KV-cache manager built on the same block idea. It also unlocks a class of features that look unrelated until you see the mechanism: prefix caching across requests, parallel sampling without duplicating the prompt’s KV state, beam search that doesn’t blow up memory in the number of beams. All of those are paging tricks in disguise.

The short answer

PagedAttention = KV cache stored in fixed-size non-contiguous blocks + page table mapping logical positions to physical blocks

Picture to keep: a coat check instead of a private closet — your answer’s cached tokens get handed out one small numbered tray at a time, scattered wherever there’s room, and a ticket stub lists which trays are yours in order.

Instead of giving each sequence one contiguous slab of GPU memory for its KV cache, chop the cache into small fixed-size blocks (16 tokens in the original vLLM paper; current vLLM treats block_size as platform/backend-dependent rather than a hard default) scattered anywhere in HBM. Keep a per-sequence table that maps “logical token positions 0–15” to “physical block #427”, “positions 16–31” to “physical block #88”, and so on. The attention kernel reads through this table the way a CPU reads through a page table. Allocation now happens one block at a time, on demand, instead of worst-case up front. Fragmentation collapses, and a bunch of useful sharing patterns become almost free.

How it works

The trick is borrowed almost verbatim from how operating systems manage RAM. A process doesn’t get a contiguous block of physical memory; it gets a virtual address space, and the OS maintains a page table that maps virtual pages to wherever they happen to live in physical RAM. Two processes can share a page (e.g. a shared library) by pointing their page tables at the same physical frame.

Now rebuild the allocator one failure at a time, with the Paris request in hand.

Naive attempt: one contiguous slab per sequence, sized to the cap. This is what makes the attention kernel simple — token t lives at keys[t], a single stride away from token t−1. It’s also what produced the two failures above: 2,036 reserved-and-unused slots per short answer, and a free-memory map full of gaps too small to hold the next request’s slab.

Why the obvious repair fails. You could shrink the reservation and grow it later — but “grow” on a contiguous tensor means finding a bigger contiguous hole and copying the whole cache into it, mid-generation, on the critical path. That trades one bad property for a worse one.

The fix: stop requiring contiguity. Split the sequence’s KV cache into blocks of a fixed token count — 16 in the original vLLM paper (block size is a configurable knob, and current vLLM picks a default per backend). Each block lives wherever there’s room in HBM. Give each running sequence a small block table: a list of physical block IDs in logical order. To attend to token t, the kernel looks up block_table[t // 16], then offsets by t % 16 inside that block. Allocation happens one block at a time, when the current last block fills up — no reservation for tokens not yet generated, and no copying, because growth just appends an entry to the table.

Run the Paris answer through that: twelve tokens now occupy one 16-token block. The waste is four slots, not 2,036. Internal fragmentation is bounded by block_size − 1 tokens per sequence — at most 15 tokens of waste in the last partial block, no matter how large a limit the caller declared. External fragmentation effectively disappears, because every allocation is the same size, so the free list never gets stuck holding unusable scraps.

Why the OS analogy actually works here

You might reasonably ask: paging in operating systems exists because processes have unpredictable, sparse memory access patterns and need isolation. LLM attention reads a sequence’s KV cache fully and densely on every step. Why does the same mechanism help?

It helps because the useful part of OS paging here isn’t the “isolation” part or the “trap on miss” part. It’s the decoupling of logical address from physical layout. Once you have that, two properties fall out:

  1. Allocate-on-write. No worst-case reservation; cache grows one block at a time.
  2. Cheap sharing. If two sequences share a prefix — same prompt, same first k tokens — they can point their block tables at the same physical blocks for that prefix. No copy. When one of them later diverges and writes into a shared block, the system does copy-on-write at block granularity: allocate one fresh block, copy that single shared block’s contents in, redirect just that sequence’s block-table entry. Earlier shared blocks stay shared.

The second property is where PagedAttention pays for itself a second time. Parallel sampling of n completions from one prompt used to mean n full copies of the prompt’s KV cache. With PagedAttention, all n candidates share the prompt blocks by reference; only the divergent suffixes consume new memory. Beam search, which the vLLM paper benchmarks specifically, gets an even bigger lift because beams share long prefixes among themselves, not just with the prompt — the paper reports vLLM’s improvement over Orca going from 1.3× in basic sampling to 2.3× in beam search at width 6 on OPT-13B with the Alpaca dataset.

What it costs

Paging is not free. Every attention computation now has an extra indirection: instead of keys[t], it’s physical_blocks[block_table[t // 16]][t % 16]. The vLLM team wrote custom CUDA kernels (and rewrote them several times since the 2023 paper) so that this indirection happens at the warp level without serializing memory loads. On the hardware they tested, the per-token cost was small enough to be dominated by the throughput gains from fitting more sequences in the batch — but it is a real cost, and a from-scratch implementation that doesn’t fuse the lookup into the attention kernel will give some of it back. Your Paris answer pays that indirection on every one of its twelve decode steps; it earns it back because a hundred other Paris-sized answers now fit alongside it.

There’s also a tuning knob: block size. Larger blocks mean less table-walking overhead but more internal fragmentation per sequence — and, where an engine matches shared prefixes at whole-block granularity, worse prefix-cache hit rates, since two requests have to agree on an entire block to share it. Smaller blocks invert that trade. The paper’s 16 is a working default, not a universal optimum, and modern engines expose the size (and sometimes decouple the prefix-matching unit from it, which softens the hit-rate half of the trade).

Where the seams show

A few honest caveats:

You started with PagedAttention = fixed-size blocks + a page table. What did this post add that the name doesn’t tell you? — + block tables that two sequences can point at the same time. Allocate-on-demand is what kills the 2,036 wasted slots behind your Paris answer; sharing is what makes parallel sampling, beam search, and cross-request prefix caching nearly free, and that second half is where paging keeps paying after the fragmentation win is banked.

Check yourself

Before you go — an engine that matches shared prefixes block-by-block switches from block size 16 to block size 256, and its prefix-cache hit rate drops even though nothing about the incoming traffic changed. Why?

Answer

Sharing happens at whole-block granularity: two requests can share a cached prefix only for the blocks they match on completely. With 16-token blocks, two prompts that agree on their first 200 tokens share 12 blocks; with 256-token blocks they agree on less than one full block, so they share nothing. Bigger blocks also mean more internal fragmentation (up to 255 wasted slots per sequence instead of 15). The payoff is less table-walking indirection per attention step — and note the escape hatch this reasoning exposes: an engine that matches prefixes at a finer unit than its physical block size can raise block size without paying the hit-rate penalty.

And one more: paging cut KV-cache waste dramatically, but the vLLM paper still reports a larger speedup for beam search than for plain sampling. What does that tell you about where the second win comes from?

Answer

From sharing, not from allocation. Plain sampling gets the allocate-on-demand benefit only. Beams, by construction, hold long common prefixes with each other and with the prompt, so copy-on-write block sharing removes duplicate KV state that the naive design would have copied outright. The paper’s numbers on OPT-13B with Alpaca reflect this: 1.3× over Orca in basic sampling, 2.3× at beam width 6.

Going deeper