PagedAttention and the KV Cache
PagedAttention and the KV Cache
Autoregressive LLM serving is memory-bound in decode and capacity-bound in concurrency: each active sequence carries a growing key/value (KV) cache. Contiguous per-sequence KV allocations waste large fractions of GPU DRAM to fragmentation and over-reservation. PagedAttention (vLLM) borrows the OS virtual-memory idea—fixed-size blocks, a block table, allocate-on-demand—so attention kernels read logically contiguous tokens from physically non-contiguous KV blocks.
This chapter goes deeper than a one-paragraph mention in inference-serving notes: why contiguous KV fails, how block tables work, prefill vs decode implications, layout choices for custom kernels, and how to verify/benchmark paging behavior.
Mental model
| OS analogy | LLM serving |
|---|---|
| Process virtual address space | Logical token positions \(0..T-1\) in a sequence |
| Pages | KV blocks of \(B\) tokens |
| Page table | Per-sequence block table (logical block → physical block) |
| Physical frames | Slots in a GPU KV pool |
| Internal fragmentation | At most one partially filled tail block |
| External fragmentation | Largely eliminated (any free block can be used) |
logical tokens: [t0 t1 ... t15 | t16 ... t31 | t32 ... t40____]
block 0 block 1 block 2 (partial)
block table: L0 → P17
L1 → P3
L2 → P9
physical pool: P0 P1 P2 P3[K/V] ... P9[K/V] ... P17[K/V] ...
Attention still needs \(Q\) against all prior \(K\), and weighted sum over \(V\)—but the kernel gathers \(K,V\) through the block table instead of assuming one contiguous pointer.
Why contiguous KV wastes memory
For a sequence with maximum length \(L_{\max}\) and current length \(t\):
- Reservation waste: many systems allocate KV for \(L_{\max}\) up front even while \(t \ll L_{\max}\).
- External fragmentation: variable-length contiguous regions cannot pack tightly as sequences finish at different times.
- Batching rigidity: contiguous layouts make sharing prefixes or compacting live sequences expensive.
Empirical reports in the PagedAttention / vLLM line of work put waste often in the 60–80% range under contiguous reservation—memory that cannot become additional concurrent sequences. Paging caps internal waste at roughly one block (\(B\) tokens) per sequence (per managed cache partition) and removes most external fragmentation.
Capacity sketch
Let one token’s KV footprint (all layers, all heads, K and V) be \(b\) bytes. Contiguous reservation for \(N\) sequences costs \(\sim N L_{\max} b\). Paged on-demand costs \(\sim b\sum_i t_i\) plus \(< N B b\) partial-block slack (order-of-magnitude). The ratio \(L_{\max}/\bar{t}\) dominates the win when outputs are much shorter than the configured max.
KV cache in Transformers (reminder)
For each layer \(\ell\) and head \(h\), token position \(i\) stores vectors \(k_i^{(\ell,h)}, v_i^{(\ell,h)}\in\mathbb{R}^{d}\). Causality at decode step \(t\) uses
\[ \alpha_{t,i}=\mathrm{softmax}_i\left(\frac{q_t^\top k_i}{\sqrt{d}}\right),\qquad o_t=\sum_{i=1}^{t}\alpha_{t,i}v_i. \]
Prefill computes many \(q\) rows in parallel against the prompt’s \(K,V\); decode appends one new \((k_t,v_t)\) and attends with a single \(q_t\) (or a small speculative set).
Serving reality: weights are shared; KV grows per request. Throughput (tokens/s/GPU) often rises when more sequences fit—hence KV packing is a first-class systems problem, not a minor allocator tweak.
Block structure
Choose block size \(B\) (vLLM commonly discusses values like 16). Logical block \(j\) holds tokens \((j-1)B+1,\ldots,jB\) (or \(0\)-based equivalents):
\[ K_j=(k_{(j-1)B+1},\ldots,k_{jB}),\qquad V_j=(v_{(j-1)B+1},\ldots,v_{jB}). \]
Attention becomes a loop over logical blocks: load the physical block pointed by the table, compute partial scores/outputs, advance. The last block carries a filled-token count \(1..B\).
Worked layout numbers
\(B=16\), head dim \(d=128\), dtype FP16: one head’s K block stores \(16\times 128\) elements \(=2048\) halves \(\approx 4\) KiB (order of magnitude), and V similarly. Multi-layer multi-head caches scale linearly; production engines may use separate block tables per layer/head partition or pack carefully for coalescing—design choice, not a change to the paging idea.
Block table and allocator
Per sequence (and often per KV partition):
| Field | Role |
|---|---|
| Logical index \(j\) | Token range \([jB,(j+1)B)\) |
| Physical block id | Index into GPU (or CPU swap) pool |
| Filled count | Valid tokens in the tail block |
| Refcount (advanced) | Prefix sharing / copy-on-write |
Allocate on demand: when decode would write past the filled count, pull a free physical block, append a table entry. Free on finish: return blocks to the pool (and decrement refcounts if shared).
decode step:
if filled == B:
phys = alloc_block()
table.append(phys)
filled = 0
write k_t,v_t into table[-1] at offset filled
filled += 1
run paged attention(q_t, table)
Prefix sharing (copy-on-write intuition)
If two requests share a prompt prefix, map their early logical blocks to the same physical blocks with elevated refcounts. Divergence (different generated tokens) allocates private blocks from the fork point. Automatic prefix caching builds on the same indirection: hash logical block contents (+prefix identity) to reuse physical KV.
Prefill vs decode under paging
| Phase | Compute character | Paging interaction |
|---|---|---|
| Prefill | Compute-heavy; many tokens written | Allocate enough blocks for prompt; write K/V densely left-to-right; attention over prompt blocks |
| Decode | Memory-bound; one token at a time | Append into tail block; attention gathers all prior blocks for one \(q\) |
Custom kernels must handle variable block lists per sequence in a batch: sequence A may have 3 blocks, sequence B 40. Batching strategies (continuous batching) reshape which sequences sit in a wave while the block tables remain the source of truth for KV addresses.
Implication
A FlashAttention-style tiled softmax over a contiguous \(K,V\) buffer is not drop-in compatible. You need a kernel that:
- Accepts block tables + pool base pointers, or
- Materializes contiguous KV (defeats paging), or
- Uses a hybrid (e.g., paged decode kernels + specialized prefill paths).
vLLM’s historical PagedAttention CUDA kernels (attention_kernels-style designs) are built for case (1): thread blocks iterate paged K/V vectors with careful vectorized loads.
Paged KV layouts (kernel writer view)
Exact layouts evolve; treat the following as a mental checklist when reading kernels or writing prototypes:
Pool: physical_block[p] → storage for B tokens × head_dim (K or V)
K access for logical token t:
j = t / B
o = t % B
p = block_table[seq][j]
addr = base_K + p * stride_block + o * stride_token + head_offset
V access: similar with base_V / possibly different vectorization shape
K vs V vectorization: kernels often pack K for efficient Q·K dots (e.g., threads cooperating on a head vector) while V layouts favor weighting and accumulating into the output. Do not assume K and V share identical lane mapping.
Separate K and V caches: common—two pools, parallel tables or mirrored indices.
Illustrative host-side table (not a production kernel)
# file: toy_block_table.py
# Toy model of logical→physical mapping (host-side). Not CUDA.
from dataclasses import dataclass, field
B = 16
@dataclass
class SequenceKV:
table: list[int] = field(default_factory=list) # physical ids
filled: int = 0 # tokens used in last block
class BlockPool:
def __init__(self, n_blocks: int):
self.free = list(range(n_blocks))
def alloc(self) -> int:
if not self.free:
raise MemoryError("KV pool exhausted")
return self.free.pop()
def free_block(self, p: int) -> None:
self.free.append(p)
def append_token(seq: SequenceKV, pool: BlockPool) -> None:
if not seq.table or seq.filled == B:
seq.table.append(pool.alloc())
seq.filled = 0
# device would write K/V into pool[seq.table[-1]][seq.filled]
seq.filled += 1
def release(seq: SequenceKV, pool: BlockPool) -> None:
for p in seq.table:
pool.free_block(p)
seq.table.clear()
seq.filled = 0Use this only to reason about allocation traffic; real engines track layers/heads, CUDA streams, and fragmentation across devices.
Implications for custom kernels
- Indirection tax: every K/V load pays a table lookup (often cached in shared memory / registers for the active block list).
- Tail blocks: masks for
filled < Bare mandatory—same family of bugs as ragged sequence lengths.
- Batching: one wave may include many tables; prefer grouped launches or paged-friendly attention APIs over naively padding to \(L_{\max}\) contiguous.
- Fusion: paging composes with continuous batching, speculative decoding (extra parallel tokens), and quantization of KV (INT8/FP8 caches)—each changes strides and scales, not the block-table story.
- Swap / multi-tier: physical blocks may live in CPU RAM; kernels and schedulers must not assume all table entries are GPU-resident without a residency check.
When generating kernels with AI assistants: specify block size, K/V separation, dtype, head dim, and tail masking explicitly. Ambiguous “write attention” prompts usually emit contiguous FlashAttention clones that cannot consume a paged pool.
Verification and benchmark notes
Functional checks
| Check | Idea |
|---|---|
| Match contiguous golden | For small \(T\), gather paged KV to contiguous and compare attention outs vs a reference softmax |
| Tail mask | Lengths \(B-1\), \(B\), \(B+1\) across block boundaries |
| Multi-sequence batch | Different table lengths in one launch |
| Prefix share | Refcount: mutate after fork must not clobber peer |
Performance / capacity checks
| Metric | Why |
|---|---|
| Max concurrent sequences at fixed \(L_{\max}\) | Primary paging win |
| KV bytes reserved vs bytes touched | Fragmentation / over-reserve detector |
| Decode tokens/s vs batch size | Memory bandwidth + kernel efficiency |
| Allocation failures under load | Pool sizing / preemption / swap policy |
Microbenchmarks that only time a contiguous GEMM miss the point—page-table attention is about fitting more live KV without catastrophic gather overhead. Profile both allocator and attention kernel.
Profiling tips
- Trace block alloc/free counts per request; spikes may indicate missing prefix sharing.
- Compare achieved occupancy / bytes loaded for paged vs contiguous decode on identical batch shapes.
- Watch for CPU-sync on block-table uploads each step—pin tables or update incrementally.
Pitfalls
| Pitfall | Result |
|---|---|
| Assuming contiguous FlashAttention works unchanged | Wrong answers or illegal loads |
| Ignoring partial-block fill | Garbage tokens in softmax |
| Oversized \(B\) | More internal waste; less flexible packing |
| Undersized pool | Preemption thrash or request rejects |
| Forgetting refcount on shared prefixes | Silent cross-request corruption |
| Benchmarking only prefill TFLOPs | Misses decode memory + paging behavior |
Checkpoint
- Explain why contiguous \(L_{\max}\) reservation destroys concurrency.
- Map OS paging terms onto block tables and KV pools.
- Describe allocate-on-demand during decode and tail
filledcounts.
- List what a paged attention kernel must accept beyond \(Q,K,V\) pointers.
- Name two functional tests and one capacity metric for paging.
Summary
PagedAttention makes KV cache virtually contiguous and physically sparse. The win is utilization—more sequences per GPU—at the cost of attention kernels that understand block tables, tail masks, and pool lifetimes. Prefill still wants heavy compute paths; decode wants lean paged gathers. For custom or AI-written kernels, treat the block table as part of the ABI, verify against contiguous goldens at block boundaries, and benchmark concurrency and KV bytes—not only matmul TFLOPs.
Sources (for further reading)
- Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention” (SOSP 2023; arXiv:2309.06180).
- vLLM design docs on PagedAttention / paged KV layouts.
- vLLM blog: “Easy, Fast, and Cheap LLM Serving with PagedAttention.”