Paged Attention › Page-Based Memory Allocation
Language AI HandbookAll chapters 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:
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)# 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()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 in a sequence, we compute the logical block number as , where is the block size in tokens. We also compute the offset within that block as . Second, we look up the physical block index by reading position 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 .
where:
- : the token index within the sequence (0-indexed)
- : the block size in tokens (e.g., 16)
- : the array mapping logical block index to physical block index
- : the logical block number for token
- : the position of token 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.
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 ''}"
)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.


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.