Skip to main content

Efficient Memory Management for Large Language Model Serving with PagedAttention

How PagedAttention (the memory manager behind vLLM) applies OS-style virtual-memory paging to the KV cache — fixed-size blocks, a block table, and copy-on-write prefix sharing — to reduce final-block waste, remove external block fragmentation, and improve throughput in the paper’s measured serving workloads.

TL;DR

  • LLM serving is bottlenecked by the KV cache, whose size grows with every generated token and every concurrent request.
  • The paper’s profiled baselines use only 20.4–38.2% of KV-cache memory for actual token states; the remainder includes reservation and internal/external fragmentation (Figure 2).
  • PagedAttention stores the KV cache in fixed-size blocks mapped through a block table, inspired by OS virtual memory. Per-request blocks grow on demand within a physical pool; final-block waste and table overhead remain.
  • Related sequences can share computed KV blocks with copy-on-write when model, cache and position context agree. The paper measures workload-specific serving gains, rather than a universal throughput guarantee.

The fragmentation problem

The KV cache stores the keys and values of every past token so the model doesn’t recompute them. Classic serving reserves one contiguous buffer per request sized to the maximum possible length — but most requests finish early, leaving huge reserved-but-unused regions (internal fragmentation) and unusable gaps between requests (external fragmentation).

Paging the KV cache

PagedAttention borrows the operating-system solution: split memory into fixed-size blocks and let a block table map each sequence’s contiguous logical blocks to arbitrary physical blocks. The block engine can preallocate the physical pool. Within that pool, blocks are assigned to each sequence on demand as it grows, rather than reserving its maximum possible length. Equal-size blocks remove external block fragmentation; unused final slots and block-table overhead remain (§4.2–4.3).

Sharing prefixes with copy-on-write

Because addressing goes through the block table, related sequences with the same computed prefix under equal model/cache/position context can point at the same physical blocks. Identical words at different positions or contexts do not suffice. Reference counting tracks the sharing; when a shared block must change, it is copied first (copy-on-write). The original paper demonstrates parallel-sampling forks and provider-predefined shared prefixes (§4.4); it does not describe today’s automatic prefix hashing. The following authored fixture starts with an actual same-context fork and a partially filled shared tail.

Implementation

The diagrams establish addressing and sharing. Now track numerical KV vectors through the same ownership transitions and check that scattered storage preserves attention.

Authored seeded offline CPU mechanics: a finite, preallocated NumPy float64 pool, one synthetic attention head and serial ownership. No pretrained model, generated language, native GPU kernel, throughput measurement or benchmark reproduction. The equations for addresses and ownership are authored notation; the attention algebra follows Equation 3.

1. Separate pool capacity from sequence ownership

Original paper

§4.2 · Physical pool and logical tables

The block engine divides its reserved memory into physical blocks.

Math and tensor shapes

K\in\mathbb R^{P\times B\times D}
V\in\mathbb R^{P\times B\times D_v}

This authored CPU pool is preallocated. Creating a sequence reserves no blocks for that request; the free set records ownership within the existing pool. P is the number of physical blocks, B their token capacity, and D/Dv the key/value widths. Context labels declare the model, cache configuration and position origin; they do not authenticate arbitrary language inputs.

K [8,4,3], V [8,4,2], refs [8]; each sequence starts with T=[] and L=0

Python implementation

1. Separate pool capacity from sequence ownership
python
def __init__(self, num_blocks, block_size, key_dim, value_dim,
             allocation_order=None):
    self.num_blocks = positive_integer(num_blocks, "num_blocks")
    self.block_size = positive_integer(block_size, "block_size")
    self.key_dim = positive_integer(key_dim, "key_dim")
    self.value_dim = positive_integer(value_dim, "value_dim")
    try:
        order = (list(range(num_blocks)) if allocation_order is None
                 else list(allocation_order))
    except TypeError as error:
        raise ValueError(
            "allocation_order must be a permutation") from error
    if (any(type(x) is not int for x in order)
            or sorted(order) != list(range(num_blocks))):
        raise ValueError("allocation_order must be a full permutation")
    self.order = tuple(order)
    self.keys = np.zeros((num_blocks, block_size, key_dim))
    self.values = np.zeros((num_blocks, block_size, value_dim))
    self.refs = np.zeros(num_blocks, dtype=np.int64)
    self.free = set(range(num_blocks))
    self.tables, self.lengths, self.contexts = {}, {}, {}

def create(self, sequence, context):
    """Create an empty table; do not reserve a per-request block."""
    self._new_identity(sequence)
    context = cache_context(context)
    self.tables[sequence] = []
    self.lengths[sequence] = 0
    self.contexts[sequence] = context

2. Translate a live logical token to physical storage

Original paper

§4.3, Figure 6 · Block table translation

Logical token order is independent of physical block placement.

Math and tensor shapes

b=\lfloor i/B\rfloor,\quad r=i\bmod B
p=T_s[b],\quad 0\le i<L_s

The zero-based address equations here are authored notation for Figure 6. Integer division selects the logical block and the remainder selects its slot. Only indices below the sequence length are live. Gathering returns copies in logical order, so neither scattered placement nor unused final slots changes the context seen by attention.

T is a list of physical IDs; gather returns K [L,D] and V [L,Dv]

Python implementation

2. Translate a live logical token to physical storage
python
def address(self, sequence, token):
    """Translate only live, zero-based token indices."""
    self._existing(sequence)
    if (type(token) is not int
            or not 0 <= token < self.lengths[sequence]):
        raise ValueError("token index must address a live token")
    logical, offset = divmod(token, self.block_size)
    return self.tables[sequence][logical], offset

def gather(self, sequence):
    """Gather live K/V copies in logical order, excluding unused tails."""
    self._existing(sequence)
    addresses = [self.address(sequence, i)
                 for i in range(self.lengths[sequence])]
    keys = np.empty((len(addresses), self.key_dim))
    values = np.empty((len(addresses), self.value_dim))
    for i, (physical, offset) in enumerate(addresses):
        keys[i] = self.keys[physical, offset]
        values[i] = self.values[physical, offset]
    return keys, values

3. Grow by one token and allocate at a block boundary

Original paper

§4.3 · On-demand per-sequence growth

A filled final block requires another physical block for the next token.

Math and tensor shapes

|T_s|=\lceil L_s/B\rceil
r=L_s\bmod B,\quad L_s^+=L_s+1

At lengths 0, 4 and 8, this B=4 fixture claims another block; other appends use an existing private tail or request a copy. It validates finite real vectors before any ownership change. Exhausted allocation raises MemoryError before changing tables, lengths, refcounts, data or the free set. This serial transaction is a reference API choice, not a scheduler.

append key [D], value [Dv]; a single append needs at most one new block

Python implementation

3. Grow by one token and allocate at a block boundary
python
def append(self, sequence, key, value):
    """Validate first; allocate on a boundary or copy a shared partial tail.

    A single-token append needs at most one free block. Exhaustion is
    checked before changing tables, refcounts, data or the free set.
    """
    self._existing(sequence)
    key = vector(key, self.key_dim)
    value = vector(value, self.value_dim)
    length = self.lengths[sequence]
    offset = length % self.block_size
    if offset == 0:
        physical = self._allocate()
        self.tables[sequence].append(physical)
    else:
        physical = self._private_tail(sequence)
    self.keys[physical, offset] = key
    self.values[physical, offset] = value
    self.lengths[sequence] = length + 1

4. Fork the actual existing cache, keeping its context

Original paper

§4.4, parallel sampling · Shared prefix ownership

Parallel samples can reuse the computed prompt KV state.

Math and tensor shapes

T_c=\operatorname{copy}(T_p),\quad L_c=L_p
c_b^+=c_b+1\quad(b\in T_p)

The child inherits its parent’s actual data and model/cache/position context. Duplicate sequence names and a changed context are rejected before mutation. Sharing is created only by fork here. Identical words alone do not establish equal KV tensors; this example does not implement modern automatic prefix hashing across independent requests.

Separate tables and lengths; shared K/V blocks, with one count per owner

Python implementation

4. Fork the actual existing cache, keeping its context
python
def fork(self, parent, child, context=None):
    """Fork actual KV data in the same model/cache/position context."""
    self._existing(parent)
    self._new_identity(child)
    inherited = self.contexts[parent]
    if context is not None and cache_context(context) != inherited:
        raise ValueError("fork requires identical cache context")
    table = self.tables[parent].copy()
    self.tables[child] = table
    self.lengths[child] = self.lengths[parent]
    self.contexts[child] = inherited
    for physical in table:
        self.refs[physical] += 1

5. Copy a shared partial tail before either branch writes

Original paper

§4.4, parallel sampling · Copy-on-write

Reference counts identify a shared block that needs a private copy.

Math and tensor shapes

0<L_s\bmod B<B,\quad c_{\rm old}>1
c_{\rm old}^+=c_{\rm old}-1,\quad c_{\rm new}=1

A seven-token parent has a partial second block. Appending B’s eighth token first copies that block, then writes the new vector. A’s seven rows stay intact and its eighth token can differ. Completed prefix blocks retain shared ownership. Allocating before changing the old count makes COW exhaustion transactional; the code does not decrement first and attempt to repair afterward.

Copy K [B,D] and V [B,Dv]; replace only the writer’s last table entry

Python implementation

5. Copy a shared partial tail before either branch writes
python
def _private_tail(self, sequence):
    """Copy a shared partial tail; leave completed prefix blocks shared."""
    old = self.tables[sequence][-1]
    if self.refs[old] == 1:
        return old
    # Allocate first: MemoryError leaves every ownership field unchanged.
    new = self._allocate()
    self.keys[new] = self.keys[old]
    self.values[new] = self.values[old]
    self.tables[sequence][-1] = new
    self.refs[old] -= 1
    return new

6. Check attention in logical order against contiguous data

Original paper

§2.1, Equation 3; §4.1 · Weighted attention

The logical attention result must be invariant to physical placement.

Math and tensor shapes

z_i=q^{\mathsf T}k_i/\sqrt D
a_i=\frac{e^{z_i-m}}{\sum_{j=0}^{L-1}e^{z_j-m}}
o=\sum_{i=0}^{L-1}a_iv_i,\quad m=\max_i z_i

This is stable, gather-based weighted attention for a query at the next decoding position, grounded in Equation 3. It materializes contiguous live arrays and is explicitly distinct from the paged GPU kernel of §4.1. An independent scalar oracle and a separately allocated contiguous pool verify equivalence. Empty-cache output is zero by the authored API convention; final unused slots never enter the reduction.

query [D], gathered K [L,D], V [L,Dv], scores [L], output [Dv]

Python implementation

6. Check attention in logical order against contiguous data
python
def attention(self, sequence, query):
    """Gather live rows and compute Eq. 3, not the blockwise CUDA kernel.

    The sole query represents the next decoding position and can attend
    to the entire existing cache. The authored empty-cache result is zero.
    """
    self._existing(sequence)
    query = vector(query, self.key_dim)
    keys, values = self.gather(sequence)
    if not len(keys):
        return np.zeros(self.value_dim)
    with np.errstate(over="ignore", invalid="ignore"):
        scores = keys @ query / np.sqrt(self.key_dim)
    if not np.isfinite(scores).all():
        raise ValueError("attention scores overflow float64")
    # Finite opposite-sign scores may subtract to -inf: exp(-inf)=0.
    with np.errstate(over="ignore"):
        shifted = scores - scores.max()
    weights = np.exp(shifted)
    weights /= weights.sum()
    output = weights @ values
    if not np.isfinite(output).all():
        raise ValueError("attention output overflow float64")
    return output

7. Release an owner and free only zero-count blocks

Original paper

§4.4, beam search · Freeing unreferenced blocks

Blocks return to the pool when no live sequence references them.

Math and tensor shapes

c_b^+=c_b-1\quad(b\in T_s)
b\in F^+\quad\text{only if }c_b^+=0

Releasing either branch preserves the surviving branch’s numerical data. Only its private blocks become free; the shared full prefix remains owned. Final release recovers all eight physical blocks. Freed arrays are cleared by this reference’s inspection convention, and an unknown or already released identity is rejected so counts cannot be decremented twice.

refs [P], free set F; released sequence table, length and context are deleted

Python implementation

7. Release an owner and free only zero-count blocks
python
def release(self, sequence):
    """Free only blocks with no remaining owners; reject double release."""
    self._existing(sequence)
    for physical in self.tables[sequence]:
        self.refs[physical] -= 1
        if self.refs[physical] == 0:
            self.free.add(physical)
            self.keys[physical].fill(0)
            self.values[physical].fill(0)
    del self.tables[sequence]
    del self.lengths[sequence]
    del self.contexts[sequence]

8. Measure unique live data and final-block waste

Original paper

§4.3, Figure 6; §4.4 · Ownership transitions

Block allocation and sharing determine usable cache capacity.

Math and tensor shapes

U=|\{b:c_b>0\}|
W=BU-\sum_{b:c_b>0}u_b

These are authored accounting equations, not paper equations. The executed seed-7 trace records actual logical K/V values, tables, lengths and counts. Unique blocks run 0→2→2→3→3→4→3→0; unique live slots run 0→7→7→11→12→13→9→0. Waste runs 0→1→1→1→0→3→3→0 slots. Unowned pool capacity and block-table overhead are excluded from W, so neither is silently counted as eliminated waste.

u is occupied token slots per unique physical block, counted once across owners

Python implementation

8. Measure unique live data and final-block waste
python
    def snapshot(self, event):
        """Measure occupied slots once per physical block, even with sharing."""
        occupied = {}
        live = {}
        for sequence, length in self.lengths.items():
            keys, values = self.gather(sequence)
            live[sequence] = {"keys": keys.tolist(), "values": values.tolist()}
            for logical, physical in enumerate(self.tables[sequence]):
                filled = min(self.block_size,
                             length - logical * self.block_size)
                occupied[physical] = max(occupied.get(physical, 0), filled)
        unique = len(occupied)
        live_slots = sum(occupied.values())
        return {"event": event,
                "tables": {s: t.copy() for s, t in self.tables.items()},
                "lengths": self.lengths.copy(), "refs": self.refs.tolist(),
                "free": sorted(self.free), "live_data": live,
                "logical_live_slots": sum(self.lengths.values()),
                "unique_live_slots": live_slots, "unique_blocks": unique,
                "tail_waste_slots": unique * self.block_size - live_slots}


def ownership_trace(seed):
    """Execute a same-context fork, independent appends and both releases."""
    rng = np.random.default_rng(seed)
    keys = rng.normal(size=(10, 3))
    values = rng.normal(size=(10, 2))
    pool = KVPool(8, 4, 3, 2, [5, 2, 7, 0, 3, 6, 1, 4])
    pool.create("A", ("synthetic-model", "single-head-float64", "origin-0"))
    trace = [pool.snapshot("empty")]
    for key, value in zip(keys[:7], values[:7]):
        pool.append("A", key, value)
    trace.append(pool.snapshot("prompt-7"))
    pool.fork("A", "B")
    trace.append(pool.snapshot("fork"))
    pool.append("B", keys[7], values[7])
    trace.append(pool.snapshot("B-copy-on-write-8"))
    pool.append("A", keys[8], values[8])
    trace.append(pool.snapshot("A-private-8"))
    pool.append("B", keys[9], values[9])
    trace.append(pool.snapshot("B-boundary-9"))
    query = rng.normal(size=3)
    outputs = {s: pool.attention(s, query).tolist() for s in ("A", "B")}
    pool.release("A")
    trace.append(pool.snapshot("release-A"))
    pool.release("B")
    trace.append(pool.snapshot("release-B"))
    return trace, outputs

Run the actual smoke to inspect finite attention outputs and the full live-data trace, source SHA-256, seed, reductions and shapes. Memory conservation can make room for more sequences. Serving capacity also depends on scheduling, model and workload; §4.5 handles exhausted GPU blocks through preemption, swapping or recomputation. This reference raises an error.

pagedattention.pyrequirements-pagedattention.txt

python -m pip install -r requirements-pagedattention.txt
python pagedattention.py --smoke --seed 7

Why it mattered

PagedAttention reframed KV-cache management as a memory-systems problem and imported decades of OS wisdom. Reducing wasted KV capacity and duplicate prefix storage can admit larger batches. The paper reports 2–4× throughput gains at similar latency against its FasterTransformer and Orca baselines in evaluated models and workloads; decoding method, sequence lengths and scheduling affect the gain. These are end-to-end serving measurements, not speedups reproduced by the CPU example (abstract and §6).

  • FlashAttention — the training/prefill-side memory optimization that pairs with PagedAttention’s serving-side one
  • Optimizing Transformer Inference — the broader inference-efficiency survey PagedAttention slots into
  • Attention Is All You Need — the architecture whose KV cache PagedAttention manages
  • Data Movement Is All You Need — the memory-bound view of transformers that explains why KV-cache management dominates serving cost
  • NanoFlow — the follow-on serving system that uses the room PagedAttention frees for larger batches, then overlaps compute, memory and network work inside each GPU

If you found this paper review helpful, consider sharing it with others.

Mastodon