Paged Attention › Page-Based Memory Allocation

Michael BrenndoerferJanuary 8, 202612 min read
Language AI HandbookAll chapters
Part 42 of 67Inference Optimization
Chapter 315Paged Attention › Page-Based Memory Allocation
Reading section 3 of 13 · Page-Based Memory Allocation · 12 minSection 3 of 13 · 12 min

Book contents

Section 3 of 13 · 12 min read

Paged Attention › Page-Based Memory AllocationOne section · Sections in this chapter3 / 13 ↓
Reading tools Definitions & inline links

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

Article links

Make inline references clickable

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.