Skip to content

PagedAttention: Virtual Memory for the KV Cache

Scroll horizontally to inspect the diagram.

A serving engine cannot know how much KV cache a request will eventually need. Reserving its entire context limit up front is simple, but ties up memory that other requests could use. PagedAttention lets the cache grow in small blocks while attention still sees tokens in their original order.

The useful analogy is an operating system's page table: a logical address need not identify a contiguous physical allocation. Here the serving engine manages the table, and the attention kernel follows it in software. There is no CPU-style page fault on each new token.

Where contiguous allocation wastes space

Consider three requests with 25, 47, and 12 stored tokens, counting prompt tokens and generated tokens whose keys and values have been computed. If each reserves 2,048 slots, the engine allocates 6,144 slots for 84 tokens.

With 16-token blocks, the requests need two, three, and one blocks: 96 slots in total. The remaining 12 slots are padding in their final blocks. In this small example, paged allocation reserves one sixty-fourth as many slots. The calculation measures reserved capacity, not execution speed.

Three requests with 25, 47, and 12 stored tokens

Reserve 2,048 per request6144 slots (6060 unused)
Allocate pages on demand96 slots (12 unused)

Both bars use the same 0–6,144-slot scale. Paged allocation uses 96 / 6144 of the reservation in this deliberately short-request example. It does not predict a typical workload’s savings.

Change the block size. Both bars use the same slot scale; the numbers distinguish occupied slots from reserved capacity. The requests contain 84 stored tokens in total.

This is internal fragmentation: unused space inside an allocation. Variable-sized contiguous allocations can also suffer external fragmentation. For example, separate free regions of 1,500 and 800 slots cannot satisfy a contiguous 2,048-slot request even though they contain enough free space together. Fixed-size blocks avoid that particular placement problem.

The original PagedAttention paper measured substantial cache waste in the serving systems it compared and reported 2–4× throughput improvements for vLLM in its tested configurations.

A block table preserves token order

Each request has a list mapping logical block numbers to physical blocks in a shared pool. With a block size of 16, token position 37 belongs to logical block 2 at offset 5. If table[2] is 9, the kernel reads offset 5 of physical block 9.

Block Table Indirection
block = 16 tokens
REQUEST + BLOCK TABLEPHYSICAL BLOCK POOLRequest A25 tokensL0→ P3L1→ P7Request B47 tokensL0→ P0L1→ P5L2→ P9Request C12 tokensL0→ P4P0req BP1freeP2freeP3req AP4req CP5req BP6freeP7req AP8freeP9req BP10freeP11freeRequest A: 25 tokens → 2 blocks, mapped to P3, P7

Scroll horizontally to inspect the diagram.

Logical order belongs to each request's block table. Physical blocks can sit elsewhere in the pool; unrelated requests do not share the contents of those blocks.

Address calculation changes; the intended attention function does not.

For an unshared request of length TT and block size BB, the allocation is

B⌈TB⌉B\left\lceil\frac{T}{B}\right\rceil

token slots. Waste is less than BB slots per request, not a constant for the entire server. Across many short requests, those partially filled blocks can still matter.

The block's byte size depends on the model's KV heads, head dimension, layers represented by the allocation, and storage dtype. It is not generally block_size × hidden_dimension: grouped-query attention stores fewer KV heads than query heads.

Growing a request

Suppose the 25-token request uses physical blocks [3, 7], the 47-token request uses [0, 5, 9], and the 12-token request uses [4]. A pool of 64 blocks has 58 blocks free after these six allocations. It has only 1,024 slots altogether, so the earlier reservation of 6,144 slots would not fit in this pool.

If the 47-token request stores ten more tokens, it grows to 57 tokens and needs a fourth block. The engine allocates P11 and extends its table to [0, 5, 9, 11]. Existing KV entries stay where they are. Twenty additional tokens would instead require five blocks.

KV cache growth

Hypothetical head layouts: 80 layers, 64 query heads, 8 KV heads, head dimension 128. K and V use two bytes per element.

Six cells stand for each token row; they are not individual heads or vector dimensions. The first three tokens are prefilled. Each step adds one K row and one V row at every layer.

3 tokens · 960.0 KiB per request across all layers
Bar maximum: 20.0 MiB for eight tokens with MHA.
2 × 80 × 8 × 128 × 3 × 2 = 983,040 bytes
KV entries are appended when token positions are processed by the model. Paging changes their storage locations, not the values that the cache represents.

Allocation does not guarantee that every admitted request can grow forever. If the pool fills, the scheduler needs an explicit policy: delay admission, preempt work, evict reusable cache entries, or otherwise recover capacity.

Sharing a prefix and copying a partial block

Two continuations of the same prompt can point to the same immutable prefix blocks. A reference count tracks how many users still need each block. This avoids both duplicate storage and, when cached computation can be reused, repeated prefill work.

A full shared block needs no copy when generation moves into a new block. For example, an 800-token prefix exactly fills fifty 16-token blocks; the next stored token can go into a new private block.

A partially filled shared final block is different. Suppose A and B share P0 with 16 tokens and P1 with eight tokens. Before B writes its next KV entry into P1, it needs a private copy. The engine copies the existing eight entries to P2, redirects B's table, adjusts reference counts, and writes the new entry to P2. A retains its original view.

Two continuations share 24 stored tokens

A table: [P0, P1]L0 → P0; L1 → P1

B table: [P0, P1]L0 → P0; L1 → P1

BlockStored entriesRequest references
P016 / 162
P18 / 162
P2free0

P0 is full; P1 is partial. Neither request may append into shared P1 in place.

A full shared final block would instead be followed by a new private block, with no copy. This diagram omits eviction and synchronization details.

Select a phase to inspect which table pointer and reference counts change when B appends.

Prefix reuse requires compatible model state, token positions, and exact relevant inputs; matching visible text alone is insufficient. Implementations may restrict reuse to complete blocks. SGLang's RadixAttention organizes reusable prefixes in a radix tree.

Block size is a kernel choice too

Smaller blocks reduce final-block padding, but require larger tables and may make the attention kernel less efficient. Larger blocks trade some capacity for coarser addressing and different memory-access behavior. Supported sizes and good choices depend on the backend, model, and workload.

Block Size Tradeoff
47 tokens, three block sizes
BLOCKS ALLOCATEDSTATSblock = 412 blocks12 table entries1 wasted (2%)48 slots totalblock = 163 blocks3 table entries1 wasted (2%)48 slots totalblock = 641 blocks1 table entries17 wasted (27%)64 slots totalSmaller blocks → more table entries, less waste   |   Larger blocks → fewer table entries, more waste

Scroll horizontally to inspect the diagram.

For 47 tokens: size 4 allocates 48 slots in 12 blocks; size 16 allocates 48 in three; size 64 allocates 64 in one. Table entries are counted here, not measured kernel lookups or execution time.

Paged storage and FlashAttention's tiling can coexist, but a cache block is not automatically the same thing as an on-chip compute tile. The kernel must support the chosen layout.

The indexing rule in code

This small example checks allocation counts and address translation. It models bookkeeping only: it does not store actual KV tensors, implement copy-on-write, or schedule a GPU.

from math import ceil
 
block_size = 16
lengths = [25, 47, 12]
blocks = [ceil(n / block_size) for n in lengths]
assert blocks == [2, 3, 1]
assert sum(blocks) * block_size == 96
 
b_table = [0, 5, 9]
position = 37
logical_block, offset = divmod(position, block_size)
physical_block = b_table[logical_block]
assert (physical_block, offset) == (9, 5)
 
new_length = lengths[1] + 10
assert ceil(new_length / block_size) == 4
b_table.append(11)  # allocated from the free pool

Paging reduces wasted cache capacity. The next question is how much capacity remains after weights and runtime buffers: Memory Management for LLMs.

Additional reading