PagedAttention: Solving LLM KV Cache Memory Fragmentation

Michael BrenndoerferJanuary 8, 202668 min read

Part of Language AI Handbook

Explains how PagedAttention uses virtual memory paging to eliminate KV cache fragmentation, enabling 5x better memory utilization in LLM serving systems.

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

Paged Attention

In the previous two chapters, we explored how KV caches accelerate autoregressive generation by avoiding redundant computation, and we examined the substantial memory costs this optimization incurs. As we saw, a single 70B parameter model can require over 2.5 GB of KV cache memory per sequence at full context length. When serving multiple concurrent requests, this memory demand becomes the primary bottleneck limiting throughput. The KV cache was supposed to be a performance win, and it is, but the way most systems implemented it created a new and equally severe problem that canceled out much of that gain.

The problem is memory fragmentation. Even when the GPU has sufficient total free memory, the way that memory is allocated and deallocated can leave large portions of it unusable. Imagine a parking lot where every car must park in a space exactly as large as the longest car ever recorded, even if the actual car is a compact. Most of that parking lot will be empty, and yet you'll keep turning away new cars because there's "no room." That is essentially what happens with traditional KV cache implementations, which can waste 60-80% of available GPU memory, dramatically reducing the number of sequences a server can process simultaneously.

PagedAttention, introduced by the vLLM project in 2023, solves this fragmentation problem by borrowing a basic concept from operating systems: virtual memory with paging. Instead of allocating contiguous memory blocks for each sequence's KV cache, PagedAttention breaks the cache into fixed-size pages that can be stored anywhere in memory. A simple translation table then maps the logical view of each sequence (a tidy, contiguous cache) to the physical reality (blocks scattered wherever space is available). This simple but powerful idea enables near-optimal memory utilization and has become the foundation for most modern LLM serving systems.

The impact of this design choice is concrete and dramatic. Systems using PagedAttention routinely achieve 2-4x higher request throughput compared to naive implementations, using exactly the same hardware and model. This improvement comes entirely from using memory more intelligently, not from any change to the model itself or to the underlying attention algorithm. You do not need a bigger GPU; you need a smarter allocator.

In this chapter, we build from the ground up. We start with why traditional allocation fails so badly, work through the paging concept in detail, examine how the attention algorithm must be modified to accommodate non-contiguous storage, and then look at how vLLM puts all these pieces together into a production serving system. Along the way, we work through concrete numerical examples and build a simulator that makes the abstract concepts tangible. By the end, you will understand what PagedAttention does, why it works, and when its trade-offs matter.

Historical Context

The idea of paging memory is not new. Virtual memory systems with paging were developed in the early 1960s, first implemented on the Atlas computer at Manchester University and later popularized by systems like IBM's OS/360. The core insight was that programs should be able to use more memory than physically available, and that memory need not be stored contiguously. Operating systems have relied on this abstraction for over sixty years. What is remarkable about PagedAttention is that it took until 2023 for the LLM serving community to realize the same principles apply equally well to KV cache management. The vLLM paper, "Efficient Memory Management for Large Language Model Serving with PagedAttention" by Kwon et al., published at SOSP 2023, received the Best Paper Award and rapidly became one of the most cited systems papers of that decade.

The Memory Fragmentation Problem

To understand why fragmentation occurs, we need to examine how traditional KV cache systems allocate memory. When a new request arrives, the system must reserve memory for the KV cache that will grow as tokens are generated. But here is the challenge: the system does not know in advance how long the final sequence will be. A user asking for a one-sentence summary and a user asking for a detailed technical report both arrive as requests, and the system must accommodate both without knowing which is which ahead of time.

The naïve solution is to reserve the maximum possible amount of memory upfront. If your model supports 4,096 tokens, reserve space for 4,096 KV pairs for every single request, regardless of what the request will generate. This approach is safe in the sense that you will never run out of space mid-generation, but it is profligate. Most requests generate only a few hundred tokens, leaving the large majority of the reserved memory permanently empty.

Think of traditional KV cache allocation as a hotel that gives every guest the presidential suite, regardless of how long they are staying. A business traveler checking in for one night gets the same 3,000-square-foot suite as a family staying for a week. The hotel (your GPU) fills up quickly, and yet most of the space is sitting completely empty. The correct solution is to give guests rooms that match their actual needs, and expand to a larger room only if they ask for more space. That is exactly what PagedAttention does.

Two distinct types of fragmentation compound to make this problem severe. Internal fragmentation occurs when the unused portion within a single reservation is wasted. External fragmentation occurs when completed requests free their reservations, leaving gaps in memory that are too small or too scattered to accommodate new requests. Both types are endemic to contiguous-allocation schemes, and together they can make a GPU with 40 GB of memory behave as if it has only 8-16 GB available.

The Contiguous Allocation Dilemma

Traditional systems handle uncertainty about output length by pre-allocating memory based on the maximum possible sequence length. If your model supports 4,096 tokens, the system reserves space for all 4,096 KV pairs for each sequence, even if most requests only generate a few hundred tokens. The logic is defensible: if you don't reserve enough space, generation will fail or require expensive memory reallocation mid-flight. But the cost is enormous wasted capacity.

The issue goes deeper than just wasted space on the current request. Because all that reserved memory is locked up, it cannot be used for other requests. A GPU that could theoretically serve forty concurrent requests might be limited to eight or ten, because each request holds ninety percent of its reservation empty. This directly translates to lower throughput: fewer requests per second, higher latency for queued requests, and underutilized compute hardware.

In[3]:
Code
import numpy as np


# Simulating traditional KV cache allocation
def traditional_allocation_simulation(
    max_seq_len: int = 4096, num_requests: int = 8, actual_lengths: list = None
):
    """
    Simulate memory allocation with contiguous pre-allocation.
    Returns wasted memory statistics.
    """
    if actual_lengths is None:
        # Realistic distribution: most sequences are shorter than max
        actual_lengths = np.random.exponential(scale=500, size=num_requests)
        actual_lengths = np.clip(actual_lengths, 50, max_seq_len).astype(int)

    # Each request reserves max_seq_len slots
    total_allocated = num_requests * max_seq_len
    total_used = sum(actual_lengths)

    return {
        "max_seq_len": max_seq_len,
        "num_requests": num_requests,
        "allocated": total_allocated,
        "used": total_used,
        "wasted": total_allocated - total_used,
        "utilization": total_used / total_allocated,
        "actual_lengths": actual_lengths,
    }


# Run simulation
stats = traditional_allocation_simulation()
Out[4]:
Console
Traditional Contiguous KV Cache Allocation
=============================================
Max sequence length: 4,096 tokens
Number of requests: 8

Actual sequence lengths: [np.int64(234), np.int64(1505), np.int64(658), np.int64(456), np.int64(84), np.int64(84), np.int64(50), np.int64(1005)]

Total slots allocated: 32,768
Total slots actually used: 4,076
Slots wasted: 28,692
Memory utilization: 12.4%

With this deterministic sequence-length sample, traditional allocation achieves only about 12% memory utilization. Nearly 88% of the reserved GPU memory sits unused, unable to serve additional requests. The distribution of real-world request lengths often resembles an exponential distribution: many short requests, fewer long ones. But the reservation is always sized for the longest possible request, so the average waste is enormous.

Out[5]:
Visualization
Horizontal bar chart showing eight requests with actual token usage in green and wasted pre-allocated space in red, showing low memory utilization.
Comparison of used versus allocated token slots in a traditional KV cache. In this deterministic sample, pre-allocation for the maximum sequence length wastes nearly 88% of memory: actually used tokens (green) occupy only a small fraction of each reservation, while unused capacity (red) dominates.

External Fragmentation

External fragmentation is the second and often more damaging type of memory waste. It occurs not within a single allocation but in the gaps between allocations that form as requests complete and free their memory. Even if every individual request used every byte of its reservation perfectly, the pattern of arrivals and completions over time would still fragment the available memory.

Consider what happens over time in a busy serving system. Requests A, B, C, and D all start at roughly the same time and are allocated contiguous memory blocks. Then A finishes, freeing its block. Then C finishes, freeing its block. Now the memory looks like a checkerboard: used, free, used, free. A new request E arrives needing slightly more memory than either gap provides. The allocator cannot give E a single contiguous region, even though the total free space is more than sufficient.

Think of external fragmentation like trying to fit a long sofa into a truck where someone has already loaded boxes of equal size on each end, leaving two separate gaps in the middle. Each gap is half the sofa's length, but the sofa cannot be split, so it won't fit. The truck looks half-empty, but you cannot use the empty space for the furniture you have.

In[6]:
Code
def visualize_fragmentation():
    """
    Visualize how external fragmentation develops over time.
    """
    # Memory represented as slots (simplified)
    memory_size = 40  # Total slots available
    request_sizes = {"A": 10, "B": 10, "C": 10, "D": 10, "E": 12}

    # Timeline of events
    events = [
        ("alloc", "A"),  # Request A starts
        ("alloc", "B"),  # Request B starts
        ("alloc", "C"),  # Request C starts
        ("alloc", "D"),  # Request D starts
        ("free", "A"),  # Request A completes
        ("free", "C"),  # Request C completes
        ("alloc", "E"),  # Request E arrives - needs 12 contiguous slots
    ]

    # Track allocations: dict mapping request_id -> (start, end)
    allocations = {}
    snapshots = []

    for event_type, request_id in events:
        if event_type == "alloc":
            request_size = request_sizes[request_id]
            occupied = [False] * memory_size
            for start, end in allocations.values():
                occupied[start:end] = [True] * (end - start)

            start_idx = None
            for candidate in range(memory_size - request_size + 1):
                if not any(occupied[candidate : candidate + request_size]):
                    start_idx = candidate
                    break

            if start_idx is not None:
                allocations[request_id] = (
                    start_idx,
                    start_idx + request_size,
                )
        else:  # free
            if request_id in allocations:
                del allocations[request_id]

        # Create memory snapshot
        snapshot = ["." for _ in range(memory_size)]
        for req_id, (start, end) in allocations.items():
            for i in range(start, end):
                snapshot[i] = req_id

        snapshots.append(
            (f"{event_type} {request_id}", "".join(snapshot), dict(allocations))
        )

    return snapshots, request_sizes["E"]


snapshots, req_size = visualize_fragmentation()

# Pre-calculate display data
display_data = []
for event, memory, _ in snapshots:
    display_data.append((event, memory, memory.count(".")))

total_free = snapshots[-1][1].count(".")
Out[7]:
Console
Memory Fragmentation Timeline
=======================================================
Legend: . = free, A/B/C/D/E = allocated to request
Requests A-D need 10 contiguous slots each
Request E needs 12 contiguous slots

After alloc A : [AAAAAAAAAA..............................]  Free: 30
After alloc B : [AAAAAAAAAABBBBBBBBBB....................]  Free: 20
After alloc C : [AAAAAAAAAABBBBBBBBBBCCCCCCCCCC..........]  Free: 10
After alloc D : [AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDD]  Free: 0
After free A  : [..........BBBBBBBBBBCCCCCCCCCCDDDDDDDDDD]  Free: 10
After free C  : [..........BBBBBBBBBB..........DDDDDDDDDD]  Free: 20
After alloc E : [..........BBBBBBBBBB..........DDDDDDDDDD]  Free: 20

-------------------------------------------------------
After freeing A and C, we have 20 free slots total,
but request E cannot be allocated because no 12
contiguous slots are available!
Out[8]:
Visualization
Seven-row memory timeline. The final row contains two separate ten-slot free regions around allocations B and D; an arrow identifies one gap and explains why a twelve-slot request E cannot fit.
A timeline of memory allocation and deallocation showing external fragmentation. After requests A and C finish, 20 free slots remain as two separate 10-slot regions. Request E needs 12 contiguous slots, so neither region is large enough even though their combined capacity is sufficient.

This is external fragmentation: the memory has 20 free slots, but they're split into two non-contiguous regions of 10 slots each. A new request requiring 12 contiguous slots cannot be served, even though sufficient total memory exists. In practice, with many concurrent requests starting and completing at different times, this fragmentation can become severe. The system will decline new requests not because it has run out of memory in any absolute sense, but because the available memory has been sliced into pieces too small to use.

Internal Fragmentation

Internal fragmentation compounds the problem further. Even if we could perfectly eliminate external fragmentation, we would still waste memory within every single allocation. When we allocate based on maximum sequence length but the actual sequence is shorter, the unused portion within the allocation is wasted for the duration of that request. This waste is guaranteed: unless every request uses exactly the maximum number of tokens, some capacity within each block is always empty.

Internal fragmentation is hardest to avoid with fixed pre-allocation because it is a direct consequence of not knowing the future. You reserved 4,096 tokens; the sequence only used 312. The remaining 3,784 tokens worth of KV cache memory sit idle, warming the GPU's memory banks while giving no useful work.

In[9]:
Code
import matplotlib.pyplot as plt

use_book_style(fixed_canvas=True)
plt.rcParams["figure.figsize"] = (3.0, 3.5)

# Plot 1: Internal Fragmentation
fig, ax = plt.subplots()
fig.subplots_adjust(left=0.26, right=0.97, bottom=0.31, top=0.74)
allocations = [
    ("Request A", 0, 1000, 300),  # allocated, actual used
    ("Request B", 1000, 2000, 750),
    ("Request C", 2000, 3000, 200),
    ("Request D", 3000, 4000, 600),
]
for label, start, end, used in allocations:
    # Used portion
    ax.barh(
        label,
        used,
        left=start,
        color=theme_color("#2ecc71"),
        edgecolor=PALETTE["ink"],
        linewidth=0.5,
    )
    # Wasted portion (internal fragmentation)
    ax.barh(
        label,
        (end - start) - used,
        left=start + used,
        color=theme_color("#e74c3c"),
        alpha=0.5,
        edgecolor=PALETTE["ink"],
        linewidth=0.5,
    )
ax.set_xlabel("Memory Slots")
ax.set_title("Internal Fragmentation\n(Unused space within allocations)")
fig.legend(
    ["Used", "Wasted"],
    loc="lower center",
    bbox_to_anchor=(0.5, 0.025),
    ncol=2,
)
ax.set_xlim(0, 4500)
polish_axes(ax)
plt.show()

# Plot 2: External Fragmentation
fig, ax = plt.subplots()
fig.subplots_adjust(left=0.26, right=0.97, bottom=0.23, top=0.74)
memory_blocks = [
    ("B: 1000", "#2ecc71"),  # In use
    ("Free: 1000", "#e74c3c"),  # External fragment
    ("D: 1000", "#2ecc71"),  # In use
    ("Free: 1000", "#e74c3c"),  # External fragment
]
bottom = 0
for label, color in memory_blocks:
    size = 1000
    ax.bar(
        ["Memory"],
        size,
        bottom=bottom,
        color=theme_color(color),
        edgecolor=PALETTE["ink"],
        linewidth=0.5,
        alpha=0.7 if "Free" in label else 1.0,
    )
    ax.text(0, bottom + size / 2, label, ha="center", va="center")
    bottom += size
ax.set_ylabel("Memory Slots")
ax.set_title("External Fragmentation\n(Scattered free blocks)")
ax.set_ylim(0, 4500)
polish_axes(ax)
plt.show()
Out[9]:
Visualization
Bar chart showing allocated memory blocks with used space in green and wasted internal space in red, showing unused capacity within individual pre-allocated blocks.
Visualization of internal fragmentation within traditional KV cache allocations. Wasted space (red) occurs inside individual reserved blocks when the actual sequence length is shorter than the pre-allocated capacity, with no mechanism to reclaim this space for other uses.
Vertical bar chart showing memory layout with alternating used and free blocks, illustrating how scattered free blocks prevent contiguous allocation despite sufficient total free memory.
Visualization of external fragmentation where free memory blocks are scattered across the address space. Even though the total free memory is sufficient to serve a new request, the lack of a contiguous free region prevents allocation entirely.

The combination of internal and external fragmentation means traditional serving systems achieve only 20-40% memory efficiency. For a GPU with 40 GB of KV cache capacity, this translates to effectively having only 8-16 GB available for actual use. The rest is statistically certain to be wasted, regardless of how cleverly you schedule requests. This is the problem PagedAttention was designed to solve, and it solves it by attacking the root cause: the requirement for contiguous allocation.

Page-Based Memory Allocation

The solution comes from a technique that operating systems have used for decades: virtual memory with paging. Instead of requiring contiguous physical memory, paging allows data to be stored in fixed-size blocks (pages) scattered anywhere in memory, with a page table tracking where each piece resides. This technique changed operating system design in the 1960s, and it proves equally effective for managing KV caches in modern LLM serving systems.

The paging philosophy separates two views of memory. The logical view is the programmer's (or in our case, the sequence's) idealized picture: a contiguous array of token slots numbered 0 through N. The physical view is the hardware's reality: a collection of fixed-size blocks sitting at various physical addresses. A page table bridges these views by recording, for each logical block, which physical block holds that data. Neither view changes the other; the translation happens transparently at access time.

Think of paging like a library's call number system. Every book has a shelf number (logical address) that tells you how the library has organized its collection. But physically, books can be moved between branches, stored in offsite warehouses, or placed on reserve shelves. The catalog (page table) always knows where each book is, regardless of what call number it carries. You look up the call number, the catalog tells you the physical location, and you retrieve the book. You never need to know that your 600-series science book is physically sitting in a different wing from your neighboring 601-series book.

The key insight is that the fragmentation problem arose precisely because we required the logical and physical layouts to match. By decoupling them, we eliminate that requirement. Physical blocks can come from anywhere in the free pool, and the logical sequence still appears perfectly ordered through the page table. No fragmentation is possible because there is no requirement for contiguity in physical memory.

The Paging Abstraction

To understand how paging solves fragmentation, we must distinguish between logical and physical memory organization. A sequence's KV cache appears contiguous from a logical perspective, with tokens numbered 0, 1, 2, and so on. However, the actual physical storage need not mirror this logical arrangement. As long as we maintain a mapping that tells us where each piece of data resides in physical memory, we can store the data anywhere we like.

In the context of KV caches, paging works through four key concepts that build on each other:

  • Physical blocks: GPU memory is divided into fixed-size blocks, each capable of holding a small number of KV pairs (typically 16 tokens worth). These blocks represent the actual memory locations where data is stored. All physical blocks are identical in size, which means any sequence can use any block.
  • Logical blocks: Each sequence's KV cache is divided into logical blocks of the same size. These represent the abstract, contiguous view of the sequence's cache. Logical block 0 holds the first 16 tokens' KV pairs, logical block 1 holds the next 16, and so on.
  • Block table: A mapping from logical block indices to physical block locations. This data structure is the key that enables the translation between the sequence's logical view and the scattered physical reality. Each sequence has its own block table, typically a short array of physical block indices.
  • Dynamic allocation: Physical blocks are allocated only when needed, not pre-allocated. This on-demand approach means we never reserve memory for tokens that haven't been generated yet.

This approach decouples how we think about the data (logically contiguous per sequence) from how we store it (potentially scattered across physical memory). Let's implement a simulator to see these concepts in action:

In[10]:
Code
class PagedKVCacheSimulator:
    """
    Simulates paged KV cache allocation to demonstrate the concept.
    """

    def __init__(self, total_physical_blocks: int, block_size: int = 16):
        self.total_blocks = total_physical_blocks
        self.block_size = block_size  # Tokens per block

        # Physical memory: list of blocks, None if free
        self.physical_memory = [None] * total_physical_blocks

        # Block tables: sequence_id -> list of physical block indices
        self.block_tables = {}

        # Free block list
        self.free_blocks = list(range(total_physical_blocks))

    def allocate_block(self, sequence_id: int) -> int:
        """Allocate a single physical block for a sequence."""
        if not self.free_blocks:
            raise MemoryError("No free blocks available")

        # Get a free physical block
        physical_idx = self.free_blocks.pop(0)
        self.physical_memory[physical_idx] = sequence_id

        # Add to sequence's block table
        if sequence_id not in self.block_tables:
            self.block_tables[sequence_id] = []
        self.block_tables[sequence_id].append(physical_idx)

        return physical_idx

    def free_sequence(self, sequence_id: int):
        """Free all blocks belonging to a sequence."""
        if sequence_id in self.block_tables:
            for physical_idx in self.block_tables[sequence_id]:
                self.physical_memory[physical_idx] = None
                self.free_blocks.append(physical_idx)
            del self.block_tables[sequence_id]

    def add_tokens(self, sequence_id: int, num_tokens: int):
        """Add tokens to a sequence, allocating blocks as needed."""
        if sequence_id not in self.block_tables:
            self.block_tables[sequence_id] = []

        current_tokens = len(self.block_tables[sequence_id]) * self.block_size
        tokens_needed = num_tokens

        while tokens_needed > 0:
            # Check if current block has space
            current_blocks = len(self.block_tables[sequence_id])
            if current_blocks == 0 or current_tokens % self.block_size == 0:
                # Need a new block
                self.allocate_block(sequence_id)

            # Fill current block
            space_in_block = self.block_size - (
                current_tokens % self.block_size
            )
            tokens_to_add = min(space_in_block, tokens_needed)
            tokens_needed -= tokens_to_add
            current_tokens += tokens_to_add

        return current_tokens

    def get_utilization(self) -> dict:
        """Calculate memory utilization statistics."""
        used_blocks = self.total_blocks - len(self.free_blocks)
        return {
            "total_blocks": self.total_blocks,
            "used_blocks": used_blocks,
            "free_blocks": len(self.free_blocks),
            "utilization": used_blocks / self.total_blocks,
            "sequences": len(self.block_tables),
        }


# Demonstrate paged allocation
paged_cache = PagedKVCacheSimulator(total_physical_blocks=120, block_size=16)
In[11]:
Code
# Create a fresh paged cache sized so request E must reuse freed blocks.
# Requests A-D initially occupy 117 of the 120 physical blocks. After A and C
# complete, E needs 32 blocks: three unused blocks plus 29 recycled blocks.
paged_cache = PagedKVCacheSimulator(total_physical_blocks=120, block_size=16)

# Define request sizes
req_a = 300
req_b = 750
req_c = 200
req_d = 600
req_e = 500

# Simulate the same scenario that caused fragmentation earlier
# Request A
paged_cache.add_tokens(sequence_id=0, num_tokens=req_a)
# Request B
paged_cache.add_tokens(sequence_id=1, num_tokens=req_b)
# Request C
paged_cache.add_tokens(sequence_id=2, num_tokens=req_c)
# Request D
paged_cache.add_tokens(sequence_id=3, num_tokens=req_d)

before_free = paged_cache.get_utilization()

# Free requests A and C (simulating completion)
paged_cache.free_sequence(0)
paged_cache.free_sequence(2)

after_free = paged_cache.get_utilization()

# Now allocate a new request E
paged_cache.add_tokens(sequence_id=4, num_tokens=req_e)

after_new = paged_cache.get_utilization()
Out[12]:
Console
Paged KV Cache Allocation Demonstration
==================================================
Block size: 16 tokens per block
Total physical blocks: 120

After allocating requests A(300), B(750), C(200), D(600):
  Used blocks: 117
  Utilization: 97.5%

After freeing requests A and C:
  Used blocks: 85
  Free blocks: 35

After allocating new request E(500 tokens):
  Used blocks: 117
  Sequences active: 3
  Utilization: 97.5%

No fragmentation. Request E was allocated using freed blocks from requests A and C, even though those freed blocks are non-contiguous in physical memory.

The key insight is that request E's blocks do not need to be contiguous in physical memory. The block table maintains the logical ordering, while physical blocks can be scattered anywhere. This eliminates external fragmentation entirely. When the simulator freed sequences A and C, their physical blocks returned to the free pool. Request E then claimed blocks from this pool, assembling its logical sequence from whatever physical locations happened to be available. From request E's perspective, it has a perfectly contiguous cache; the block table handles all the translation behind the scenes.

Block Table Structure

The block table is the data structure that enables this flexibility. For each sequence, it maintains an ordered list of physical block indices. The position in this list corresponds to the logical block number, while the value at that position indicates where in physical memory that block resides.

The address translation happens in two steps. First, given a token position tt in a sequence, we compute the logical block number as ⌊t/B⌋\lfloor t / B \rfloor, where BB is the block size in tokens. We also compute the offset within that block as t mod Bt \bmod B. Second, we look up the physical block index by reading position ⌊t/B⌋\lfloor t / B \rfloor from the block table. Combining the physical block address with the within-block offset gives us the exact location of the KV pair for token tt.

physical_addr(t)=block_table ⁣[⌊tB⌋]⋅B+(t mod B)\text{physical\_addr}(t) = \text{block\_table}\!\left[\left\lfloor \frac{t}{B} \right\rfloor\right] \cdot B + (t \bmod B)

where:

  • tt: the token index within the sequence (0-indexed)
  • BB: the block size in tokens (e.g., 16)
  • block_table[⋅]\text{block\_table}[\cdot]: the array mapping logical block index to physical block index
  • ⌊t/B⌋\lfloor t / B \rfloor: the logical block number for token tt
  • t mod Bt \bmod B: the position of token tt within its logical block

This formula looks like pointer arithmetic, but the intuition is simple: divide the token index by the block size to find which block it belongs to, look up that block's physical location in the table, and then add the remainder to find the slot within the block. The formula applies identically regardless of how scattered the physical blocks are.

To visualize this concept, consider a sequence whose KV cache spans three logical blocks. The block table might contain the entries [7, 2, 15], showing that logical block 0 is stored in physical block 7, logical block 1 is stored in physical block 2, and logical block 2 is stored in physical block 15. When the attention mechanism needs to access the key for token 20 (which falls in logical block 1, since each block holds 16 tokens), it consults the block table, finds that logical block 1 maps to physical block 2, and reads the data from that location. The physical blocks are numbered 7, 2, and 15, which is a completely non-sequential arrangement, but the lookup is just an array index operation and takes constant time.

In[13]:
Code
def visualize_block_tables(cache: PagedKVCacheSimulator):
    """Show the block table mappings for active sequences."""
    print("Block Tables (Logical to Physical mapping)")
    print("-" * 45)

    for seq_id, blocks in sorted(cache.block_tables.items()):
        logical_indices = list(range(len(blocks)))
        print(f"\nSequence {seq_id}:")
        print(f"  Logical blocks:  {logical_indices}")
        print(f"  Physical blocks: {blocks}")

        # Show the mapping
        mapping = [f"L{l}->P{p}" for l, p in enumerate(blocks)]
        print(
            f"  Mapping: {', '.join(mapping[:5])}{'...' if len(mapping) > 5 else ''}"
        )
Out[14]:
Console
Block Tables (Logical to Physical mapping)
---------------------------------------------

Sequence 1:
  Logical blocks:  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46]
  Physical blocks: [19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65]
  Mapping: L0->P19, L1->P20, L2->P21, L3->P22, L4->P23...

Sequence 3:
  Logical blocks:  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37]
  Physical blocks: [79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116]
  Mapping: L0->P79, L1->P80, L2->P81, L3->P82, L4->P83...

Sequence 4:
  Logical blocks:  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
  Physical blocks: [117, 118, 119, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75]
  Mapping: L0->P117, L1->P118, L2->P119, L3->P0, L4->P1...

Notice how sequence 4 (request E) uses physical blocks that are scattered throughout memory, reusing the blocks freed by sequences 0 and 2. From the sequence's perspective, it has a contiguous logical address space. The block table handles the translation. This indirection is cheap because the block table is small (one entry per logical block), fits easily in fast GPU caches, and can be preloaded before attention computation begins.

Out[15]:
Visualization
Grid diagram showing three sequences (B, D, E) each with numbered logical blocks arranged contiguously, representing the abstract view of the paged KV cache.
The logical representation of a paged KV cache where sequences appear as contiguous blocks numbered from 0. This abstraction allows the attention mechanism to treat the cache as a linear sequence regardless of where blocks are physically stored.
Grid of 50 selected physical memory blocks from ranges 0 to 24 and 60 to 84, colored by owning sequence: blue for B, red for D, green for E, and pale gray for free blocks. Sequence E appears in separated regions.
A selected view of the physical block pool after sequences A and C complete. Sequence E reuses non-contiguous blocks they freed, while the block table preserves E's contiguous logical ordering. The selected ranges also show active blocks for sequences B and D and a small free region.

This indirection through the block table introduces a small amount of overhead: every memory access must first look up the physical location. However, this cost is negligible compared to the enormous benefits of eliminating fragmentation. The block table itself is small, typically fitting entirely in fast GPU memory or cache, making lookups extremely fast. For a sequence with 1,000 tokens and a block size of 16, the block table contains only 63 entries, each a single integer. The memory footprint of all block tables in a serving system is measured in kilobytes, not gigabytes.

The PagedAttention Algorithm

With KV cache data potentially scattered across non-contiguous memory locations, we need to modify the attention computation to work with this new layout. The PagedAttention algorithm handles this by fetching keys and values according to the block table during attention calculation. This modification preserves the mathematical correctness of attention while accommodating the scattered physical layout.

The modification is conceptually straightforward: instead of indexing into a contiguous key-value tensor using the token position directly, we first compute the logical block number and within-block offset, look up the physical block from the block table, and then index into the KV cache using the physical block address. The attention score computation and the softmax normalization are identical to standard attention; only the data access pattern changes.

Think of the change as the difference between reading a book straight through versus reading it by consulting an index first. The content you read is identical, and you understand it the same way. The only difference is that you now follow a lookup step before accessing each page. The lookup is fast, and the content is exactly what standard attention would have read from a contiguous layout.

The key insight is that attention only needs the keys and values for a specific sequence, in order. It does not care what their physical addresses are, only what values they hold. The block table gives us exactly what we need: a translation from the logical "token 25 in sequence 3" to the physical "slot 9 in GPU memory block 47." Once we have the value, the computation proceeds identically.

Standard Attention vs PagedAttention

In standard attention, keys and values are stored in contiguous tensors. For a sequence of length nn, the attention mechanism computes a weighted sum of values based on the similarity between the query and keys:

Attention(Q,K,V)=softmax ⁣(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right)V

where:

  • QQ: the query matrix, typically for the single current token being generated (shape 1×dk1 \times d_k for autoregressive decoding)
  • KK: the key matrix containing keys for all tokens in the context (contiguous tensor of shape n×dkn \times d_k)
  • VV: the value matrix containing values for all tokens in the context (contiguous tensor of shape n×dvn \times d_v)
  • dkd_k: the dimension of the key vectors, used for scaling
  • dvd_v: the dimension of the value vectors
  • nn: the sequence length (all previously generated tokens plus the prompt)
  • softmax\text{softmax}: the function that normalizes raw similarity scores into a probability distribution summing to 1

The formula consists of three computational stages. First, the dot product QKTQK^T measures the similarity between the query and each key. Intuitively, this operation asks: "how relevant is each previous token to the token we are currently generating?" Keys that are more similar to the query receive higher scores, showing greater relevance. The result is a vector of raw similarity scores, one for each position in the sequence.

The second stage applies the softmax\text{softmax} function to these raw scores. This normalization turns the arbitrary similarity values into a proper probability distribution that sums to 1. The softmax operation accentuates differences: high scores become relatively higher, and low scores become relatively lower. The output is the attention weights, which tell us how much to attend to each previous position. A weight of 0.8 on token 5 means we draw heavily from that token's value vector; a weight of 0.001 means that token contributes almost nothing to the output.

The third stage multiplies these attention weights by the value matrix VV, computing a weighted average of the value vectors. Tokens with higher attention weights contribute more to the final output, while tokens with lower weights contribute less. This mechanism allows the model to selectively focus on the most relevant information from the entire context window, regardless of how long that window is.

The division by dk\sqrt{d_k} in the formula ensures numerical stability. Without this scaling factor, the dot products between queries and keys tend to grow large as the dimension dkd_k increases. Large dot products cause the softmax function to saturate, creating attention distributions that are nearly one-hot (all attention concentrated on a single token). The scaling factor keeps the dot products in a reasonable range, letting the softmax to produce more fine-grained attention patterns and improving gradient flow during training.

In PagedAttention, KK and VV are stored in non-contiguous blocks rather than contiguous tensors. The attention computation must gather the relevant blocks according to the block table. While the mathematical operation remains identical, the implementation must navigate the scattered physical layout:

In[16]:
Code
import torch
import torch.nn.functional as F


def paged_attention_reference(
    query: torch.Tensor,  # Shape: (batch, num_heads, 1, head_dim) for single token
    key_cache: torch.Tensor,  # Shape: (num_blocks, block_size, num_heads, head_dim)
    value_cache: torch.Tensor,  # Shape: (num_blocks, block_size, num_heads, head_dim)
    block_tables: torch.Tensor,  # Shape: (batch, max_blocks) - physical block indices
    context_lens: torch.Tensor,  # Shape: (batch,) - actual sequence lengths
    block_size: int,
    scale: float,
) -> torch.Tensor:
    """
    Reference implementation of PagedAttention for understanding.
    Not optimized - real implementations use custom CUDA kernels.
    """
    batch_size, num_heads, _, head_dim = query.shape
    output = torch.zeros_like(query)

    for b in range(batch_size):
        seq_len = context_lens[b].item()
        num_blocks_used = (seq_len + block_size - 1) // block_size

        # Gather keys and values from physical blocks
        keys_list = []
        values_list = []

        for block_idx in range(num_blocks_used):
            physical_block = block_tables[b, block_idx].item()

            # Calculate how many tokens to use from this block
            start_pos = block_idx * block_size
            end_pos = min(start_pos + block_size, seq_len)
            tokens_in_block = end_pos - start_pos

            # Fetch from physical memory location
            keys_list.append(key_cache[physical_block, :tokens_in_block])
            values_list.append(value_cache[physical_block, :tokens_in_block])

        # Concatenate gathered keys and values
        # Shape: (seq_len, num_heads, head_dim)
        keys = torch.cat(keys_list, dim=0)
        values = torch.cat(values_list, dim=0)

        # Reshape for attention computation
        # keys: (num_heads, seq_len, head_dim)
        keys = keys.permute(1, 0, 2)
        values = values.permute(1, 0, 2)

        # Query shape: (num_heads, 1, head_dim)
        q = query[b]  # (num_heads, 1, head_dim)

        # Compute attention scores
        # (num_heads, 1, head_dim) @ (num_heads, head_dim, seq_len) -> (num_heads, 1, seq_len)
        attn_scores = torch.matmul(q, keys.transpose(-2, -1)) * scale
        attn_probs = F.softmax(attn_scores, dim=-1)

        # Apply attention to values
        # (num_heads, 1, seq_len) @ (num_heads, seq_len, head_dim) -> (num_heads, 1, head_dim)
        attn_output = torch.matmul(attn_probs, values)
        output[b] = attn_output

    return output

This reference implementation shows the core idea: before computing attention, we gather the keys and values from their physical locations according to the block table. The attention computation itself remains unchanged. The gathering process iterates through logical block indices, looks up the corresponding physical block in the block table, and fetches the data from that physical location. Once all the keys and values are assembled into contiguous tensors, the standard attention formula applies exactly as before.

Demonstration with Non-Contiguous Blocks

Let's verify that PagedAttention produces correct results even when blocks are scattered:

In[17]:
Code
# Set up a small example
torch.manual_seed(42)

num_blocks = 8
block_size = 4
num_heads = 2
head_dim = 8
batch_size = 2

# Physical KV cache - blocks can be used by any sequence
key_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)
value_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)

# Block tables - note non-contiguous physical blocks
# Sequence 0 uses physical blocks [0, 3, 5] (scattered!)
# Sequence 1 uses physical blocks [1, 7]
block_tables = torch.tensor(
    [
        [
            0,
            3,
            5,
            0,
            0,
            0,
            0,
            0,
        ],  # Sequence 0: 3 blocks (12 tokens max, using 10)
        [
            1,
            7,
            0,
            0,
            0,
            0,
            0,
            0,
        ],  # Sequence 1: 2 blocks (8 tokens max, using 7)
    ]
)

# Actual sequence lengths
context_lens = torch.tensor([10, 7])

# Query for current token being generated
query = torch.randn(batch_size, num_heads, 1, head_dim)
scale = 1.0 / (head_dim**0.5)

# Run paged attention
output = paged_attention_reference(
    query, key_cache, value_cache, block_tables, context_lens, block_size, scale
)

# Extract block mappings for visualization
sequence_mappings = []
for i, seq_len in enumerate(context_lens):
    # Calculate number of blocks used by this sequence
    n_blocks = (seq_len.item() + block_size - 1) // block_size
    # Slice the block table to get only the used physical blocks
    blocks = block_tables[i, :n_blocks].tolist()
    sequence_mappings.append((i, seq_len.item(), blocks))
Out[18]:
Console
PagedAttention Demonstration
=============================================
Block size: 4 tokens
Number of physical blocks: 8

Block table mappings:
  Sequence 0 (len=10): uses physical blocks [0, 3, 5]
  Sequence 1 (len=7): uses physical blocks [1, 7]

Note: blocks are non-contiguous in physical memory!

Output shape: torch.Size([2, 2, 1, 8])

Optimized CUDA Kernels

The reference implementation above gathers blocks into contiguous memory before computing attention. This defeats some of the purpose of paging, as it requires temporary memory allocation for the gathered keys and values. Production implementations like vLLM use custom CUDA kernels that compute attention directly on the paged data structure, avoiding the explicit gather step entirely.

The core challenge in writing these kernels is that standard attention kernels, including FlashAttention, assume contiguous tensors. A custom paged attention kernel must interleave block-table lookups with the attention computation, load blocks into fast shared memory, and then compute scores and apply the weighted sum, all without ever assembling a full contiguous KV tensor.

The key optimization insight is that attention can be computed incrementally as blocks are loaded, rather than requiring all data to be assembled first. This approach uses the online softmax algorithm (also known as the flash attention technique covered in the efficient attention chapter) to maintain numerical stability while processing blocks one at a time. The online softmax technique keeps a running maximum and a running sum, updating them as each new block is processed. This allows the kernel to compute the final attention output without ever needing to store all the keys and values in a contiguous buffer.

In[19]:
Code
# Pseudocode for optimized PagedAttention kernel
# This shows the conceptual approach - actual CUDA code is more complex


def paged_attention_kernel_pseudocode():
    """
    Conceptual flow of the optimized PagedAttention CUDA kernel.

    Key optimizations:
    1. No explicit gather step - compute attention directly from blocks
    2. Each thread block processes a portion of the attention
    3. Leverage shared memory for frequently accessed block table entries
    """
    kernel_description = """
    CUDA Kernel: paged_attention_v1

    Grid: (num_heads, batch_size, 1)
    Block: (NUM_THREADS,)

    For each (head, batch) pair:
        1. Load query vector for this head into registers
        2. Initialize running softmax numerator and denominator

        For each logical block in sequence:
            a. Look up physical block index from block_table
            b. Load key block from global memory (coalesced access)
            c. Compute partial attention scores for this block
            d. Update running softmax using online softmax trick

        For each logical block in sequence:
            a. Load value block from global memory
            b. Weight by final attention probabilities
            c. Accumulate to output

        Write output for this (batch, head, token)
    """
    return kernel_description


kernel_pseudocode = paged_attention_kernel_pseudocode()
Out[20]:
Console

    CUDA Kernel: paged_attention_v1

    Grid: (num_heads, batch_size, 1)
    Block: (NUM_THREADS,)

    For each (head, batch) pair:
        1. Load query vector for this head into registers
        2. Initialize running softmax numerator and denominator

        For each logical block in sequence:
            a. Look up physical block index from block_table
            b. Load key block from global memory (coalesced access)
            c. Compute partial attention scores for this block
            d. Update running softmax using online softmax trick

        For each logical block in sequence:
            a. Load value block from global memory
            b. Weight by final attention probabilities
            c. Accumulate to output

        Write output for this (batch, head, token)

The necessary optimization is computing attention incrementally as blocks are loaded, rather than gathering all blocks first. By processing blocks sequentially and maintaining running statistics about the maximum score seen so far and the running normalization denominator, the kernel achieves the same mathematical result as gathering all keys first, but with dramatically reduced memory requirements. This combination of paging and incremental attention computation is what makes PagedAttention both memory-efficient and computationally practical on real hardware.

Worked Example: Tracing a Complete Lookup

To make the block table mechanism concrete, let us trace a specific attention computation step by step. This example uses small numbers to keep the arithmetic manageable, but the same procedure applies at full production scale.

Suppose we have a sequence that has generated 38 tokens so far. The block size is 16 tokens. The sequence's block table contains three entries: [7, 2, 15]. We want to compute the attention score between the query for the current (39th) token and the key for token 20.

Step 1: Identify the logical block. Token 20 falls in logical block ⌊20/16⌋=1\lfloor 20 / 16 \rfloor = 1 (the second block, zero-indexed). Its position within that block is 20 mod 16=420 \bmod 16 = 4 (the fifth slot in the block).

Step 2: Look up the physical block. We read entry 1 from the block table: block_table[1]=2\text{block\_table}[1] = 2. So logical block 1 is stored in physical block 2.

Step 3: Compute the physical address. The key for token 20 lives at physical block 2, slot 4. If each block stores keys for one attention head in a contiguous sub-array of shape [16,dk][16, d_k], the exact memory address is: physical block 2's base address + 4 * (size of one key vector).

Step 4: Compute the attention score. Load the key vector from that physical address. Compute the dot product with the current query vector and divide by dk\sqrt{d_k}.

Step 5: Repeat for all 38 tokens. The same lookup applies for tokens 0 through 37. Tokens 0-15 live in physical block 7, tokens 16-31 live in physical block 2, and tokens 32-37 live in the first six slots of physical block 15. The scores for all 38 tokens are computed by following block-table lookups for each one.

Step 6: Apply softmax and weighted sum. Once all 38 scores are computed, apply softmax to get attention weights, then compute the weighted sum of value vectors (retrieved using the same physical block lookups).

The entire computation is mathematically identical to standard attention on a contiguous 38-token KV tensor. The only difference is that instead of reading keys[20] directly, we follow the two-level addressing: block table lookup, then within-block offset. On GPU hardware, this extra indirection costs only a tiny fraction of the total compute time because the block tables fit in shared memory or registers and the block lookups can be pipelined with the arithmetic.

Let us verify this numerically with a small concrete example:

In[21]:
Code
import torch

# Worked example parameters
torch.manual_seed(0)
block_size_ex = 16
d_k = 8
num_tokens = 38

# Block table: logical blocks [0, 1, 2] -> physical blocks [7, 2, 15]
# We only have 16 physical blocks in this example, so cap at 15
block_table_ex = [7, 2, 15]

# Create a small physical KV cache with 16 blocks
num_phys_blocks = 16
key_cache_ex = torch.randn(num_phys_blocks, block_size_ex, d_k)
value_cache_ex = torch.randn(num_phys_blocks, block_size_ex, d_k)

# Query for the new token
query_ex = torch.randn(d_k)
scale_ex = 1.0 / (d_k**0.5)

# Step-by-step paged attention for token 20
token_idx = 20
logical_block = token_idx // block_size_ex
within_block_offset = token_idx % block_size_ex
physical_block = block_table_ex[logical_block]
key_for_token20 = key_cache_ex[physical_block, within_block_offset]

# Gather all keys for the full sequence
gathered_keys = []
for t in range(num_tokens):
    lb = t // block_size_ex
    wb = t % block_size_ex
    pb = block_table_ex[lb]
    gathered_keys.append(key_cache_ex[pb, wb])
all_keys = torch.stack(gathered_keys)  # (38, d_k)

# Compute attention scores
scores = torch.matmul(all_keys, query_ex) * scale_ex  # (38,)
attn_weights = torch.softmax(scores, dim=0)

# Gather all values and compute output
gathered_values = []
for t in range(num_tokens):
    lb = t // block_size_ex
    wb = t % block_size_ex
    pb = block_table_ex[lb]
    gathered_values.append(value_cache_ex[pb, wb])
all_values = torch.stack(gathered_values)  # (38, d_k)

output_ex = torch.matmul(attn_weights.unsqueeze(0), all_values).squeeze(0)
Out[22]:
Console
Worked Example: PagedAttention Token Lookup
==================================================
Sequence length: 38 tokens
Block size: 16
Block table: [7, 2, 15]

Lookup for token 20:
  Logical block:      1  (= 20 // 16)
  Within-block offset:4  (= 20 % 16)
  Physical block:     2  (block_table[1])

Key vector norm at token 20: 1.6488
Max attention weight: 0.1318 at position 9
Output vector norm: 0.7807

Block coverage:
  Logical block 0 -> Physical block 7: tokens 0 to 15
  Logical block 1 -> Physical block 2: tokens 16 to 31
  Logical block 2 -> Physical block 15: tokens 32 to 37

The output confirms that the block-table indirection correctly assembles the full key and value tensors. Each token's KV data was retrieved by first mapping its position to a logical block number, then looking up the physical block, and finally reading from the correct within-block slot. The attention scores and the weighted output are computed identically to standard attention, and the result is mathematically exact regardless of how scattered the physical blocks are.

vLLM Implementation

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.

Working with vLLM in Practice

Let's see how PagedAttention benefits manifest when using vLLM for inference. From a user perspective, vLLM exposes a simple API that feels like any other inference library. The PagedAttention machinery is entirely internal: you do not manage block tables or physical blocks directly. You simply submit requests, and vLLM handles all the memory management transparently.

In[43]:
Code
# This code shows vLLM usage patterns (requires GPU and vllm installed)
from vllm import LLM, SamplingParams

# vLLM automatically uses PagedAttention
llm = LLM(
    model="meta-llama/Llama-2-7b-hf",
    # Block size can be configured (default is typically 16)
    block_size=16,
    # GPU memory utilization for KV cache
    gpu_memory_utilization=0.9,
    # Maximum number of sequences to batch together
    max_num_seqs=256,
    # CPU swap space in GB (for swapping out preempted requests)
    swap_space=4,
)

# Multiple requests can be batched efficiently
prompts = [
    "Explain quantum computing in simple terms:",
    "Write a haiku about programming:",
    "What are the benefits of exercise?",
    # ... many more prompts
]

sampling_params = SamplingParams(temperature=0.8, top_p=0.95, max_tokens=256)

# vLLM handles dynamic batching and memory management automatically
outputs = llm.generate(prompts, sampling_params)

Key Parameters

Understanding the key parameters that control PagedAttention behavior helps you tune vLLM for your specific workload. The right settings depend heavily on your request distribution, target latency, and available hardware.

The key parameters for vLLM related to PagedAttention are:

  • block_size: Number of tokens per physical block. Smaller blocks (8 or less) reduce internal fragmentation but increase block table size and lookup overhead. Larger blocks (32 or more) are more cache-friendly but waste more memory on partially filled final blocks. The default of 16 is a well-tuned starting point for most workloads.
  • gpu_memory_utilization: Fraction of GPU memory reserved for KV cache. Higher values enable more concurrent sequences but leave less memory for activations and model weights. Setting this too high can cause out-of-memory errors during forward passes.
  • max_num_seqs: Maximum concurrent sequences. PagedAttention enables much higher limits than traditional approaches because it eliminates fragmentation. In practice, this is often limited by the number of free blocks rather than any artificial cap.
  • swap_space: CPU memory for block swapping when GPU memory is exhausted. vLLM can swap the KV cache blocks for lower-priority requests to CPU memory, letting the GPU to serve higher-priority requests. This adds latency for swapped requests but prevents starvation.

The interplay between these parameters is important. A large gpu_memory_utilization with a small block_size maximizes the number of sequences you can serve but increases the overhead per attention step. For latency-sensitive workloads, prefer slightly larger blocks and slightly lower utilization to reduce per-step overhead. For throughput-optimized batch serving, push utilization higher and accept more block table overhead.

Measuring Fragmentation Reduction

Let's quantify the improvement PagedAttention provides over traditional allocation. This comparison is useful for understanding the magnitude of the problem that PagedAttention solves and for setting expectations about real-world throughput improvements.

The comparison is inherently unfair to traditional allocation because traditional allocation was never designed for dynamic request serving. It was designed for static, pre-known workloads. PagedAttention is specifically designed for the dynamic, variable-length nature of LLM inference. The numbers below reflect this design mismatch.

In[29]:
Code
import numpy as np


def compare_allocation_strategies(
    num_sequences: int = 50,
    max_seq_len: int = 2048,
    block_size: int = 16,
):
    """
    Compare traditional contiguous vs paged allocation.
    """
    # Generate realistic sequence lengths (biased toward shorter)
    actual_lengths = np.random.exponential(scale=300, size=num_sequences)
    actual_lengths = np.clip(actual_lengths, 50, max_seq_len).astype(int)

    results = {
        "traditional": {
            "peak_memory": 0,
            "avg_utilization": [],
            "fragmentation_events": 0,
        },
        "paged": {
            "peak_memory": 0,
            "avg_utilization": [],
            "fragmentation_events": 0,  # Should be 0
        },
    }

    # Traditional: pre-allocate max_seq_len for each
    trad_total_allocated = num_sequences * max_seq_len
    trad_total_used = sum(actual_lengths)
    results["traditional"]["max_seq_len"] = max_seq_len
    results["traditional"]["peak_memory"] = trad_total_allocated
    results["traditional"]["avg_utilization"] = (
        trad_total_used / trad_total_allocated
    )

    # Paged: allocate only what's needed (+ partial block overhead)
    paged_blocks_used = sum(
        (l + block_size - 1) // block_size for l in actual_lengths
    )
    paged_tokens_capacity = paged_blocks_used * block_size
    results["paged"]["peak_memory"] = paged_tokens_capacity
    results["paged"]["avg_utilization"] = (
        trad_total_used / paged_tokens_capacity
    )

    return results, actual_lengths


results, lengths = compare_allocation_strategies()

# Calculate improvement metrics
trad_peak = results["traditional"]["peak_memory"]
paged_peak = results["paged"]["peak_memory"]
memory_reduction = 1 - (paged_peak / trad_peak)
capacity_multiplier = 1 / (1 - memory_reduction)
Out[30]:
Console
Allocation Strategy Comparison
==================================================
Sequences: 50
Max sequence length: 2,048 tokens
Actual lengths: min=50, max=1051, avg=292

Traditional Contiguous Allocation:
  Peak memory: 102,400 token slots
  Actual data: 14,582 tokens
  Utilization: 14.2%

Paged Allocation:
  Peak memory: 14,976 token slots
  Actual data: 14,582 tokens
  Utilization: 97.4%

Memory reduction: 85.4%
Could serve 6.8x more sequences with same memory
In[31]:
Code
import matplotlib.pyplot as plt
import numpy as np

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

plt.figure()
plt.hist(
    lengths,
    bins=30,
    edgecolor=PALETTE["ink"],
    alpha=0.7,
    color=theme_color("#3498db"),
)
plt.axvline(
    x=2048,
    color=theme_color("#e74c3c"),
    linestyle="--",
    linewidth=1,
    label="Max length (allocation)",
)
plt.axvline(
    x=np.mean(lengths),
    color=theme_color("#2ecc71"),
    linestyle="-",
    linewidth=1,
    label=f"Mean length ({np.mean(lengths):.0f})",
)
plt.xlabel("Sequence Length (tokens)")
plt.ylabel("Count")
plt.title("Actual Sequence Length Distribution")
plt.legend()
plt.show()
Out[31]:
Visualization
Histogram of sequence lengths showing exponential distribution with most sequences clustered at short lengths, well below the 2048-token maximum allocation line.
Histogram of sequence length distribution compared to maximum allocation capacity. Real-world sequences follow an exponential-like pattern, with most requests falling far below the 2,048-token limit (red dashed line), while the mean (green line) sits at only a fraction of maximum capacity. This mismatch between actual and allocated length is the root cause of traditional KV cache fragmentation.
In[32]:
Code
import matplotlib.pyplot as plt

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

fig, ax = plt.subplots()
strategies = ["Traditional\n(Contiguous)", "Paged\n(vLLM)"]
utilizations = [
    results["traditional"]["avg_utilization"] * 100,
    results["paged"]["avg_utilization"] * 100,
]
colors = theme_color(["#e74c3c", "#2ecc71"])

bars = ax.bar(strategies, utilizations, color=colors, edgecolor=PALETTE["ink"])
ax.set_ylabel("Memory Utilization (%)")
ax.set_title("KV Cache Memory Utilization")
ax.set_ylim(0, 105)

# Add percentage labels
label_bars(ax, bars, labels=[f"{util:.1f}%" for util in utilizations])
polish_axes(ax)
plt.show()
Out[32]:
Visualization
Two-bar chart comparing low memory utilization for traditional contiguous allocation with near-complete utilization for paged allocation; exact percentages are printed above the bars.
Comparison of memory utilization for the same deterministic workload. Traditional allocation reserves the full maximum sequence length for every request, so only a small fraction is used. Paged allocation reserves only the blocks each request needs and therefore approaches complete utilization.

These results show the massive inefficiency of contiguous allocation. The exact values printed above come from the executed deterministic workload: paged allocation approaches full utilization while contiguous pre-allocation uses only a small fraction of its reserved capacity. The reported capacity multiplier shows how many more sequences the same memory could hold under this workload. It bears stressing that this improvement requires no changes to the model, no new hardware, and no change in the quality of the generated output. It is a pure systems optimization.

Benefits and Performance Impact

PagedAttention provides several interconnected benefits that compound to deliver substantial throughput improvements. Understanding how these benefits interact helps you predict the gains you will see in your own deployment and identify which workload characteristics make PagedAttention most effective.

The most direct benefit is higher batch sizes, which translates to higher GPU utilization. Modern GPUs are designed to process large batches efficiently; the hardware has enough parallelism to handle hundreds of requests simultaneously. Traditional KV cache allocation prevented systems from filling the GPU because memory was being wasted on fragmentation. PagedAttention removes that constraint. When batch size doubles, throughput typically more than doubles, because larger batches improve the arithmetic intensity of the computation and allow the GPU's memory bandwidth to be used more efficiently.

A secondary but important benefit is reduced latency for queued requests. With traditional allocation, a server that is nominally at capacity may have most of its GPU memory sitting empty. New requests queue up not because the GPU is busy computing but because there is nowhere to put their KV caches. PagedAttention eliminates this artificial queuing, allowing the server to accept and begin processing more requests immediately.

Higher Batch Sizes

The most direct benefit is the ability to serve more concurrent sequences. With 5x better memory utilization, a serving system can batch 5x more requests simultaneously. Since transformer inference is typically compute-bound for large batches, higher batch sizes improve GPU utilization:

In[33]:
Code
import numpy as np


def estimate_throughput_improvement(
    traditional_batch_size: int = 8,
    memory_improvement: float = 5.0,
    compute_efficiency_curve: callable = None,
):
    """
    Estimate throughput improvement from better memory utilization.
    """
    if compute_efficiency_curve is None:
        # Typical curve: efficiency improves with batch size up to a point
        def compute_efficiency_curve(batch_size):
            # Logarithmic improvement, saturating around batch 64
            return min(1.0, 0.3 + 0.7 * np.log2(batch_size + 1) / np.log2(65))

    paged_batch_size = int(traditional_batch_size * memory_improvement)

    trad_efficiency = compute_efficiency_curve(traditional_batch_size)
    paged_efficiency = compute_efficiency_curve(paged_batch_size)

    # Throughput = batch_size x efficiency
    trad_throughput = traditional_batch_size * trad_efficiency
    paged_throughput = paged_batch_size * paged_efficiency

    return {
        "traditional": {
            "batch_size": traditional_batch_size,
            "compute_efficiency": trad_efficiency,
            "relative_throughput": trad_throughput,
        },
        "paged": {
            "batch_size": paged_batch_size,
            "compute_efficiency": paged_efficiency,
            "relative_throughput": paged_throughput,
        },
        "throughput_multiplier": paged_throughput / trad_throughput,
    }


throughput_results = estimate_throughput_improvement()
Out[34]:
Console
Throughput Improvement Estimate
=============================================
Traditional allocation:
  Batch size: 8
  GPU compute efficiency: 67%
  Relative throughput: 5.3

Paged allocation:
  Batch size: 40
  GPU compute efficiency: 92%
  Relative throughput: 36.9

Throughput improvement: 6.9x
Out[35]:
Visualization
Dual-axis line chart. Aggregate throughput rises with batch size on the left axis, GPU efficiency rises toward saturation on the right axis, and arrowed labels identify traditional batch size 8 and paged batch size 40.
The relationship between batch size, GPU efficiency, and aggregate throughput. The left axis shows throughput, while the right axis makes the lower-magnitude efficiency curve readable. Paged allocation permits a larger operating batch (green point) than traditional allocation (red point), increasing both efficiency and total throughput in this illustrative model.

The throughput improvement demonstrates that memory efficiency directly translates to performance. By fitting more sequences into the GPU simultaneously, we operate in a regime of higher compute intensity, extracting more value from the hardware with each forward pass.

Dynamic Batching Support

Paged memory enables continuous batching (which we will explore in an upcoming chapter), where new requests can join an ongoing batch as old ones complete. With traditional contiguous allocation, adding a new request requires finding a large enough contiguous memory region. With paging, new requests simply acquire free blocks as needed, and those free blocks can come from anywhere in the pool, including from requests that just completed.

This property is what makes continuous batching viable at scale. In a continuous batching system, the scheduler maintains a running batch and swaps completed sequences out while adding newly arrived sequences in. Each swap requires only returning the old sequence's blocks to the free pool and allocating new blocks for the new sequence. Both operations are constant time with paged allocation, making continuous batching tractable even at very high request rates.

In[36]:
Code
import numpy as np


def simulate_dynamic_batching(
    arrival_rate: float = 2.0,  # requests per time unit
    service_rate: float = 1.5,  # completions per time unit
    duration: int = 100,
    max_concurrent_traditional: int = 8,
    max_concurrent_paged: int = 40,
):
    """
    Simulate request handling with dynamic batching.
    """
    results = {"traditional": [], "paged": []}

    for strategy, max_concurrent in [
        ("traditional", max_concurrent_traditional),
        ("paged", max_concurrent_paged),
    ]:
        active_requests = 0
        queued = 0
        total_processed = 0
        queue_times = []

        queue_depths = []

        for t in range(duration):
            # Arrivals (Poisson process)
            arrivals = np.random.poisson(arrival_rate)

            # Process completions
            if active_requests > 0:
                completions = min(
                    np.random.poisson(
                        service_rate * active_requests / max_concurrent
                    ),
                    active_requests,
                )
                active_requests -= completions
                total_processed += completions

            # Handle new arrivals
            for _ in range(arrivals):
                if active_requests < max_concurrent:
                    active_requests += 1
                else:
                    queued += 1

            # Process queue
            while queued > 0 and active_requests < max_concurrent:
                queued -= 1
                active_requests += 1
                queue_times.append(t)

            queue_depths.append(queued)

        results[strategy] = {
            "arrival_rate": arrival_rate,
            "service_rate": service_rate,
            "total_processed": total_processed,
            "avg_queue_depth": np.mean(queue_depths) if queue_depths else 0,
            "max_concurrent": max_concurrent,
        }

    return results


batching_results = simulate_dynamic_batching()
Out[37]:
Console
Dynamic Batching Simulation
=============================================
Request arrival rate: 2.0/time unit
Service rate: 1.5/time unit

Traditional:
  Max concurrent: 8
  Total processed: 139

Paged:
  Max concurrent: 40
  Total processed: 110

Paged allocation significantly outperforms the traditional approach in dynamic settings. While the traditional allocator quickly becomes constrained by its small batch capacity, the paged allocator continuously reuses freed blocks, maintaining high throughput across the entire simulation. The difference in total processed requests reflects the basic capacity advantage that paging provides in dynamic workloads.

Preemption and Priority Scheduling

With paged memory, preempting a low-priority request to serve a high-priority one becomes straightforward and efficient. The preempted request's blocks can be either swapped to CPU memory or simply marked as reusable if the request can be restarted from the prompt. When the request resumes, it reloads its KV cache from the saved state and continues generation from where it left off.

This preemption capability enables sophisticated scheduling policies that traditional contiguous allocation cannot support. A system with preemption can guarantee quality-of-service for high-priority users, implement latency-sensitive queuing policies like SRPT (shortest remaining processing time), and avoid head-of-line blocking where one long request prevents shorter requests from being served. These scheduling capabilities are not specific to PagedAttention, but PagedAttention makes them practical by so that preemption and resumption are efficient operations rather than expensive memory copies.

Limitations and Practical Considerations

While PagedAttention represents a major advancement in LLM serving efficiency, it comes with trade-offs and limitations worth understanding in depth. No technique is universally superior, and a clear-eyed view of PagedAttention's limitations helps you make better deployment decisions and anticipate where the technique may not deliver its advertised benefits.

The most basic overhead is the block table indirection itself. Every attention computation must look up physical block locations from the block table before reading the KV data. On GPU hardware, this means an extra pointer dereference per block, which disrupts the memory access pattern that standard attention kernels have been optimized for. Modern GPU memory systems work best with sequential, coalesced access patterns; paged access introduces non-sequential accesses that can reduce effective memory bandwidth. For very short sequences (50-100 tokens), this overhead can outweigh the memory savings, because short sequences fit in one or two blocks and the fragmentation savings are minimal. Most serving workloads involve longer sequences where the memory efficiency gains far exceed the indexing overhead, but if your workload consists primarily of short requests, you should benchmark carefully before assuming PagedAttention will help.

Block size selection presents a tuning challenge with no universally correct answer. Smaller block sizes (8 or fewer tokens per block) reduce internal fragmentation: the worst case is that the last block is only partially filled, so smaller blocks limit the maximum wasted space. But smaller blocks mean longer block tables, more block-table lookups per attention step, and potentially worse memory access locality as more physical block boundaries are crossed during a single attention computation. Larger block sizes (32 tokens or more) are more efficient for the memory access pattern but guarantee more waste at block boundaries. The default block size of 16 tokens in vLLM represents a carefully chosen balance, tuned against real serving workloads, but your specific workload distribution may benefit from a different value. Workloads with highly variable sequence lengths often benefit from smaller blocks; uniform-length workloads can use larger blocks without meaningful fragmentation cost.

Implementing PagedAttention from scratch is complex. Custom CUDA kernels must handle the non-contiguous memory access patterns efficiently while maintaining the performance characteristics of optimized attention implementations. The kernel must correctly implement the online softmax algorithm, handle partial last blocks, and coalesce memory accesses as much as possible across threads within the same warp. These are non-trivial requirements, and subtle bugs can produce incorrect results that are difficult to detect without careful testing. This is why you should use established frameworks like vLLM, TensorRT-LLM, or similar rather than implementing PagedAttention from scratch. The engineering investment to get a production-quality implementation right is substantial.

A subtler limitation is that PagedAttention addresses memory capacity constraints but not the basic memory bandwidth limitations of KV cache access. Even with perfect memory utilization, the KV cache still requires significant memory bandwidth to read during attention computation. For long sequences and large models, this bandwidth cost can be the dominant bottleneck, and no amount of clever allocation can reduce it. Techniques covered in later chapters, such as KV cache compression, quantization, and multi-query attention, complement PagedAttention by reducing the amount of data that must be transferred, rather than just improving how that data is laid out.

Finally, there is a coordination overhead in multi-GPU serving. When a model is distributed across multiple GPUs using tensor parallelism, each GPU must maintain consistent block tables for the KV shards it holds. Block allocation and deallocation must be coordinated across all GPUs simultaneously, which adds communication overhead and complexity. Single-GPU serving enjoys the simplest possible block management, while large multi-GPU deployments must handle this synchronization carefully to avoid inconsistencies.

Summary

PagedAttention transforms LLM inference efficiency by applying virtual memory principles to KV cache management. The key insights and techniques include:

  • The fragmentation problem arises because traditional KV cache allocation reserves contiguous memory based on maximum sequence length. This causes internal fragmentation (unused space within allocations) and external fragmentation (scattered free blocks that cannot be combined). Together, these issues can waste 60-80% of available memory.

  • Page-based allocation divides memory into fixed-size blocks that can be assigned to any sequence. A block table maps each sequence's logical blocks to their physical locations. This approach eliminates external fragmentation entirely and reduces internal fragmentation to at most one partial block per sequence.

  • The PagedAttention algorithm modifies attention computation to work with non-contiguous KV storage. During attention, keys and values are gathered according to the block table. Optimized CUDA kernels perform this gathering efficiently using online softmax techniques, processing blocks one at a time without requiring an explicit gather step.

  • Copy-on-write semantics enable memory-efficient beam search and other parallel decoding strategies. Sequences can share physical blocks for common prefixes, with new blocks allocated only when sequences diverge. This can reduce KV cache memory for beam search by 50% or more.

  • Practical benefits include 2-4x higher throughput from increased batch sizes, support for continuous batching with dynamic request handling, preemption capabilities for priority scheduling, and near-optimal memory utilization that removes the capacity waste endemic to contiguous allocation.

  • Limitations include block-table lookup overhead (small but non-zero), the complexity of choosing optimal block sizes, the implementation difficulty of production-quality paged attention CUDA kernels, and the fact that paging addresses capacity but not bandwidth.

These improvements have made PagedAttention the foundation of modern LLM serving systems including vLLM, TensorRT-LLM, and others. Understanding its design helps you configure these systems effectively, predict where they will perform well, and recognize the workload characteristics that determine how much benefit you will see in practice.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about PagedAttention and memory management for LLM serving.

Paged Attention

Question 1 of 80 of 8 completed
What is external fragmentation in the context of KV cache memory?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026pagedattentionsolving, author = {Michael Brenndoerfer}, title = {PagedAttention: Solving LLM KV Cache Memory Fragmentation}, year = {2026}, url = {https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2026). PagedAttention: Solving LLM KV Cache Memory Fragmentation. Retrieved from https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management
MLAAcademic
Michael Brenndoerfer. "PagedAttention: Solving LLM KV Cache Memory Fragmentation." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management>.
CHICAGOAcademic
Michael Brenndoerfer. "PagedAttention: Solving LLM KV Cache Memory Fragmentation." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management.
HARVARDAcademic
Michael Brenndoerfer (2026) 'PagedAttention: Solving LLM KV Cache Memory Fragmentation'. Available at: https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). PagedAttention: Solving LLM KV Cache Memory Fragmentation. https://mbrenndoerfer.com/writing/paged-attention-vllm-kv-cache-memory-management

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.