Paged Attention › The Memory Fragmentation Problem

Michael BrenndoerferJanuary 8, 202613 min read
Language AI HandbookAll chapters
Part 42 of 67Inference Optimization
Chapter 315Paged Attention › The Memory Fragmentation Problem
Reading section 2 of 13 · The Memory Fragmentation Problem · 13 minSection 2 of 13 · 13 min

Book contents

Section 2 of 13 · 13 min read

Paged Attention › The Memory Fragmentation ProblemOne section · Sections in this chapter2 / 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

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.