Paged Attention › vLLM Implementation

Michael BrenndoerferJanuary 8, 202610 min read
Language AI HandbookAll chapters
Part 42 of 67Inference Optimization
Chapter 315Paged Attention › vLLM Implementation
Reading section 6 of 13 · vLLM Implementation · 10 minSection 6 of 13 · 10 min

Book contents

Section 6 of 13 · 10 min read

Paged Attention › vLLM ImplementationOne section · Sections in this chapter6 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

vLLM (Variably Large Language Model serving) is the framework that introduced PagedAttention. Understanding its architecture reveals how paging integrates into a complete serving system. vLLM is a full inference engine that uses PagedAttention as the foundation for higher-level serving features, including continuous batching, preemption, and beam search. Each of these features builds on the paging abstraction in a different way.

The vLLM architecture separates concerns cleanly. A scheduler decides which requests to process in each batch, a block space manager handles physical memory allocation, and the model executor runs the actual transformer forward pass using PagedAttention kernels. The three components communicate through block tables: the block space manager maintains them, and the executor reads them at inference time.

Think of vLLM as an operating system for LLM serving. Just as a general-purpose OS virtualizes CPU time and memory for many processes simultaneously, vLLM virtualizes GPU compute and KV cache memory for many concurrent inference requests. The paging mechanism is the memory virtualization layer, and the scheduler is the process scheduler. The model itself is just one of the "programs" running on this virtualized infrastructure.

Memory Manager

The vLLM BlockSpaceManager handles physical block allocation. Its design closely parallels the page frame allocator in an operating system kernel: it maintains a pool of free physical blocks, allocates them on demand to sequences, and returns them to the pool when sequences complete. The reference-counting mechanism it uses for shared blocks (described in the copy-on-write section below) mirrors how operating systems handle shared memory pages.

The key design decision in the BlockSpaceManager is that allocation is always at block granularity, never sub-block. A sequence that needs 17 tokens gets two blocks (32 token slots), not one block plus 1 extra slot. This avoids the complexity of sub-block allocation while keeping internal fragmentation bounded to at most one partially filled block per sequence, which is a small and predictable overhead.

In[23]:
Code
from collections import deque
from dataclasses import dataclass
from typing import Dict, List


@dataclass
class PhysicalBlock:
    """Represents a physical block of GPU memory."""

    block_number: int
    ref_count: int = 0  # For copy-on-write sharing


@dataclass
class LogicalBlock:
    """Represents a logical block in a sequence."""

    block_number: int
    num_tokens: int = 0


class BlockSpaceManager:
    """
    Manages allocation of physical blocks to sequences.
    Simplified version of vLLM's BlockSpaceManager.
    """

    def __init__(
        self,
        block_size: int,
        num_gpu_blocks: int,
        num_cpu_blocks: int = 0,  # For CPU offloading
    ):
        self.block_size = block_size
        self.num_gpu_blocks = num_gpu_blocks
        self.num_cpu_blocks = num_cpu_blocks

        # Free block pools
        self.gpu_free_blocks: deque = deque(range(num_gpu_blocks))
        self.cpu_free_blocks: deque = deque(range(num_cpu_blocks))

        # Block tables for each sequence
        self.block_tables: Dict[int, List[int]] = {}

        # Track block reference counts for sharing
        self.block_ref_counts: Dict[int, int] = {}

    def can_allocate(self, num_blocks: int) -> bool:
        """Check if we can allocate the requested number of blocks."""
        return len(self.gpu_free_blocks) >= num_blocks

    def allocate(self, seq_id: int, num_blocks: int) -> List[int]:
        """Allocate blocks for a new sequence."""
        if not self.can_allocate(num_blocks):
            raise MemoryError(f"Cannot allocate {num_blocks} blocks")

        allocated = []
        for _ in range(num_blocks):
            block = self.gpu_free_blocks.popleft()
            allocated.append(block)
            self.block_ref_counts[block] = 1

        self.block_tables[seq_id] = allocated
        return allocated

    def append_block(self, seq_id: int) -> int:
        """Allocate one more block for an existing sequence."""
        if not self.gpu_free_blocks:
            raise MemoryError("No free blocks available")

        block = self.gpu_free_blocks.popleft()
        self.block_tables[seq_id].append(block)
        self.block_ref_counts[block] = 1
        return block

    def free(self, seq_id: int) -> List[int]:
        """Free all blocks belonging to a sequence."""
        if seq_id not in self.block_tables:
            return []

        freed = []
        for block in self.block_tables[seq_id]:
            self.block_ref_counts[block] -= 1
            if self.block_ref_counts[block] == 0:
                self.gpu_free_blocks.append(block)
                del self.block_ref_counts[block]
                freed.append(block)

        del self.block_tables[seq_id]
        return freed

    def get_block_table(self, seq_id: int) -> List[int]:
        """Get the block table for a sequence."""
        return self.block_tables.get(seq_id, [])

    def get_num_free_blocks(self) -> int:
        """Get the number of available blocks."""
        return len(self.gpu_free_blocks)
In[24]:
Code
# Simulate vLLM-style block management
manager = BlockSpaceManager(block_size=16, num_gpu_blocks=100)

# Sequence arrives with prompt
seq_0_prompt_len = 45  # tokens
initial_blocks_needed = (seq_0_prompt_len + 16 - 1) // 16  # Ceiling division
manager.allocate(seq_id=0, num_blocks=initial_blocks_needed)

# Another sequence arrives
seq_1_prompt_len = 128
blocks_needed = (seq_1_prompt_len + 16 - 1) // 16
manager.allocate(seq_id=1, num_blocks=blocks_needed)

status_after_prompts = {
    "free_blocks": manager.get_num_free_blocks(),
    "seq_0_blocks": manager.get_block_table(0),
    "seq_1_blocks": manager.get_block_table(1),
}

# Generation proceeds, each sequence needs more blocks
for _ in range(3):  # Generate 3 more blocks worth of tokens for seq 0
    manager.append_block(seq_id=0)

# Seq 1 completes
manager.free(seq_id=1)

status_final = {
    "free_blocks": manager.get_num_free_blocks(),
    "seq_0_blocks": manager.get_block_table(0),
}
Out[25]:
Console
vLLM Block Space Manager Simulation
==================================================
Total GPU blocks: 100
Block size: 16 tokens

After processing prompts:
  Sequence 0 (45 tokens): blocks [0, 1, 2, 11, 12, 13]
  Sequence 1 (128 tokens): blocks [3, 4, 5, 6, 7, 8, 9, 10]
  Free blocks remaining: 89

After seq 0 generates more + seq 1 completes:
  Sequence 0 blocks: [0, 1, 2, 11, 12, 13]
  Free blocks: 94

Freed blocks from sequence 1 are immediately available for reuse. The simulation illustrates the manager's efficiency: when Sequence 1 completes, its blocks are returned to the free pool in constant time, with no compaction or reorganization required. These reclaimed blocks are then available for Sequence 0's expansion or new requests, preventing the memory fragmentation issues seen in static allocation. The block table for Sequence 0 grows by three entries during generation, recording the three newly acquired blocks, and this growth is also constant-time per block.

One elegant feature of paged memory is letting copy-on-write (CoW) semantics for parallel decoding strategies like beam search. This technique addresses a basic challenge in beam search: multiple candidate sequences (beams) that share a common prefix would traditionally require duplicating the KV cache data for that prefix, even though it is identical across all beams. With a 48-token prompt and a beam width of 8, that is eight identical copies of 48 tokens' worth of KV data, consuming roughly 8x the necessary memory for that portion of the cache.

With copy-on-write, when multiple beams share a common prefix, they share the same physical blocks for that prefix rather than duplicating the data. The block manager tracks reference counts for each physical block. When a block has multiple references, multiple sequences are sharing that block. Only when a sequence needs to write new data to a shared block does the system create a private copy for that sequence. The write (the generation of new tokens that differ between beams) is the trigger for the copy; hence, copy-on-write.

To understand why this matters, consider a beam search with width 4. Without copy-on-write, each beam would need its own complete copy of the KV cache for the shared prefix, requiring 4x the memory for that prefix. With copy-on-write, all four beams point to the same physical blocks for the prompt. Memory is only allocated separately when the beams diverge and begin generating different tokens. Since beams often share long prefixes (the entire user prompt is shared), the savings can be substantial.

In[26]:
Code
def demonstrate_copy_on_write():
    """
    Show how PagedAttention enables memory-efficient beam search
    through block sharing.
    """
    # Parameters
    prompt_tokens = 48
    block_size = 16
    num_beams = 4

    # Initial sequence (3 blocks at 16 tokens each)
    num_prompt_blocks = prompt_tokens // block_size
    prompt_blocks = [
        f"P{i}" for i in range(num_prompt_blocks)
    ]  # Physical blocks for prompt

    # Beam search with 4 beams
    # All beams share the prompt blocks initially
    beams = {}
    for i in range(num_beams):
        beams[f"beam_{i}"] = {
            "blocks": list(prompt_blocks),
            "ref_count_contribution": 1,
        }

    # Reference counts: each prompt block has 4 references
    block_refs = {block: 4 for block in prompt_blocks}

    # After generating 16 tokens, each beam diverges
    # Each beam gets its own new block
    for i, beam in enumerate(beams.keys()):
        new_block = f"G{i}"  # Generation block
        beams[beam]["blocks"].append(new_block)
        block_refs[new_block] = 1

    return beams, block_refs, prompt_tokens, block_size, num_beams


beams, refs, prompt_tokens, block_size, beam_width = demonstrate_copy_on_write()

# Calculate memory savings statistics
num_beams = len(beams)
blocks_per_beam = len(next(iter(beams.values()))["blocks"])
naive_blocks = num_beams * blocks_per_beam

# Count unique physical blocks used (all active blocks are in refs)
cow_blocks = len(refs)
savings_pct = (1 - cow_blocks / naive_blocks) * 100
Out[27]:
Console
Copy-on-Write with Beam Search
==================================================
Prompt: 48 tokens in 3 blocks
Beam width: 4

Block sharing (after 16 generated tokens):
  beam_0: ['P0', 'P1', 'P2', 'G0']
  beam_1: ['P0', 'P1', 'P2', 'G1']
  beam_2: ['P0', 'P1', 'P2', 'G2']
  beam_3: ['P0', 'P1', 'P2', 'G3']

Block reference counts:
  P0: 4 references (shared by all beams)
  P1: 4 references (shared by all beams)
  P2: 4 references (shared by all beams)
  G0: 1 references
  G1: 1 references
  G2: 1 references
  G3: 1 references

Memory comparison:
  Without CoW: 16 blocks
  With CoW: 7 blocks
  Savings: 56%
Out[28]:
Visualization
Grid diagram showing 4 beams each with 4 blocks of their own color arranged in rows, representing the naive approach with full duplication of prompt data.
Memory allocation for beam search without copy-on-write optimizations. Each beam maintains its own redundant copy of the shared prompt blocks, resulting in a total of 16 blocks allocated and extensive duplication of identical data.
Diagram with 3 shared purple prompt blocks in the center and 4 small unique generation blocks on the right in beam colors, annotated with shared reference count 4 and block savings.
Efficient memory usage for beam search using copy-on-write. All beams share the common physical blocks for the prompt prefix (purple), only branching into unique blocks (beam colors) during the generation phase, achieving a 56% reduction in total memory footprint.

This 56% memory reduction highlights the efficiency of copy-on-write. By letting multiple beams share the same physical blocks for their common prefix, we significantly reduce memory footprint compared to duplicating the data for each beam. The savings become even more dramatic with longer prompts or wider beam widths, as the shared prefix represents a larger fraction of the total cache. A system serving beam search requests with prompt lengths of 512 tokens and beam width 8 could reduce its KV cache usage for those requests by over 90%, since nearly all of the cache is shared prefix.