KV Cache Memory Bottlenecks in Autoregressive Serving
During autoregressive generation in decoder-only Transformers, generating each subsequent token requires computing self-attention over all previous key and value tensor representations. Storing these intermediate states in the Key-Value (KV) Cache eliminates redundant quadratic recomputations () during sequential decoding:
For a standard parameter model (, FP16 precision), each token consumes . A sequence of tokens consumes of High Bandwidth Memory (HBM) per concurrent request.
Under traditional contiguous memory allocation, servers pre-allocate static contiguous buffers sized to the maximum possible sequence length . This paradigm introduces three critical memory pathologies:
- Reserved Over-Allocation: Memory is locked for tokens even if requests terminate early (), leaving of allocated HBM idle.
- Internal & External Fragmentation: Varied sequence lengths fragment GPU memory, preventing new requests from finding contiguous buffers even when aggregate free memory is sufficient.
- Redundant Duplicate Allocations: Shared system prompts (e.g., in multi-turn chat or agentic workflows) duplicate identical KV cache pages for every active stream.
PagedAttention (Kwon et al., SOSP 2023) resolves these bottlenecks by translating operating system paged virtual memory principles to GPU KV cache management.
KV Cache Allocation Paradigms Comparison
| Allocation Paradigm | Memory Contiguity | Internal Fragmentation | External Fragmentation | Prefix Sharing & CoW | GPU Memory Utilization () | Serving Throughput |
|---|---|---|---|---|---|---|
| Static Pre-Allocation (Naive) | Fully Contiguous | () | Severe (Static Slabs) | Impossible | (Baseline) | |
| Dynamic Ring Buffer | Segment Contiguous | Moderate | Moderate (Re-alloc Spikes) | Complex / Limited | ||
| Chunked Slab Allocator | Power-of-Two Slabs | Up to per Slab | Moderate | No | ||
| PagedAttention (vLLM) | Non-Contiguous (Block-Paged) | Tokens () | (Fixed Block Size) | Native Zero-Cost (CoW) | ||
| RadixAttention (SGLang) | Radix-Tree Paged Blocks | Tokens () | Tree-Structured (Radix) | |||
| MLA Paging (DeepSeek-V3) | Compressed Latent Block-Paged | Tokens | Native Latent CoW |
Mathematical Foundations
Figure 1: PagedAttention virtual memory mapping translating contiguous logical token positions to non-contiguous physical GPU HBM blocks.
1. Paged Virtual Memory Mapping
Let denote the fixed block size (number of tokens per physical memory block).
A request sequence of length is partitioned into logical blocks:
Each request maintains a dynamic Block Table (page table) mapping logical block indices to physical block identifiers :
where .
2. Memory Utilization Bound and Fragmentation Analysis
Under naive contiguous allocation, memory efficiency for a request with actual length under maximum allocation evaluates to:
Under PagedAttention, internal fragmentation occurs strictly within the final allocated block:
The effective memory utilization is analytically bounded by:
For block size and sequence length :
Eliminating memory over-reservation increases concurrent batch capacity by , directly multiplying decoding throughput.
3. Non-Contiguous Online Softmax Attention Kernel
Standard self-attention computes over contiguous tensors. PagedAttention factors the reduction across non-contiguous physical blocks via tiled online softmax (FlashAttention-style):
Let represent the running maximum logit, represent the running normalization factor, and represent the unnormalized output accumulator. For each physical block :
Final normalized output evaluates to: .
Figure 2: Online softmax reduction iterating across non-contiguous physical blocks without materializing intermediate global attention matrices.
4. Automatic Prefix Caching (APC) and Copy-on-Write (CoW)
When multiple requests share identical prompt prefixes, PagedAttention shares physical memory blocks using reference counting:
When an active stream modifies or appends to a shared block (), the allocator triggers Copy-on-Write (CoW):
- Allocate a fresh physical block from the free list.
- Copy the tokens from the shared block to the new block.
- Decrement the shared block’s reference count ().
- Update the requesting stream’s block table pointer to the newly allocated block.
Production PyTorch Implementation
Below is a complete, modular PyTorch implementation of the PagedBlockAllocator (with reference counting and Automatic Prefix Caching) and a vectorized PagedAttention decoding kernel with online softmax.
Step 1: Paged Block Allocator & Page Table Manager
from __future__ import annotations
import hashlib
import math
from collections import deque
import torch
class PagedBlockAllocator:
"""Manages physical GPU memory blocks and per-request block tables."""
def __init__(self, num_blocks: int, block_size: int = 16) -> None:
self.num_blocks = num_blocks
self.block_size = block_size
self.free_blocks: deque[int] = deque(range(num_blocks))
# Mapping: request_id -> list of physical block IDs
self.block_tables: dict[str, list[int]] = {}
# Reference counting for prefix sharing / CoW: block_id -> count
self.ref_counts: dict[int, int] = {i: 0 for i in range(num_blocks)}
# Prefix cache: hash(tokens) -> physical_block_id
self.prefix_cache: dict[str, int] = {}
def _hash_tokens(self, tokens: list[int]) -> str:
return hashlib.sha256(str(tokens).encode("utf-8")).hexdigest()
def allocate_prompt(self, request_id: str, prompt_tokens: list[int]) -> list[int]:
num_blocks_needed = math.ceil(len(prompt_tokens) / self.block_size)
table: list[int] = []
for i in range(num_blocks_needed):
chunk = prompt_tokens[i * self.block_size : (i + 1) * self.block_size]
chunk_hash = self._hash_tokens(chunk) if len(chunk) == self.block_size else None
# Check Prefix Cache Hit
if chunk_hash and chunk_hash in self.prefix_cache:
phys_id = self.prefix_cache[chunk_hash]
self.ref_counts[phys_id] += 1
else:
if not self.free_blocks:
raise MemoryError("GPU HBM Exhausted: No free blocks available for allocation.")
phys_id = self.free_blocks.popleft()
self.ref_counts[phys_id] = 1
if chunk_hash:
self.prefix_cache[chunk_hash] = phys_id
table.append(phys_id)
self.block_tables[request_id] = table
return table
def append_token_slot(self, request_id: str, current_seq_len: int) -> int:
"""Allocates a new block if the token crosses the block boundary."""
table = self.block_tables[request_id]
if current_seq_len % self.block_size == 0:
if not self.free_blocks:
raise MemoryError("GPU HBM Exhausted during autoregressive decode step.")
new_block = self.free_blocks.popleft()
self.ref_counts[new_block] = 1
table.append(new_block)
return table[-1]
def free_request(self, request_id: str) -> None:
if request_id not in self.block_tables:
return
table = self.block_tables.pop(request_id)
for phys_id in table:
self.ref_counts[phys_id] -= 1
if self.ref_counts[phys_id] == 0:
self.free_blocks.append(phys_id)
Step 2: Vectorized PagedAttention Kernel (Online Softmax)
from __future__ import annotations
import math
import torch
def paged_attention_decode_step(
q: torch.Tensor, # [B, H_q, D] (Single decode query token)
k_cache: torch.Tensor, # [Total_Blocks, Block_Size, H_kv, D]
v_cache: torch.Tensor, # [Total_Blocks, Block_Size, H_kv, D]
block_tables: torch.Tensor, # [B, Max_Blocks_Per_Seq] (Physical block IDs)
seq_lens: torch.Tensor, # [B] (Current sequence length per request)
block_size: int = 16,
) -> torch.Tensor:
"""Executes non-contiguous paged attention across requests via online softmax."""
batch_size, num_heads_q, head_dim = q.shape
num_heads_kv = k_cache.shape[2]
num_queries_per_kv = num_heads_q // num_heads_kv # Grouped-Query Attention ratio
scale = 1.0 / math.sqrt(head_dim)
outputs = torch.zeros_like(q)
for b in range(batch_size):
cur_len = seq_lens[b].item()
num_blocks = math.ceil(cur_len / block_size)
q_b = q[b] # [H_q, D]
# Initialize online softmax accumulators per head
m_i = torch.full((num_heads_q, 1), float("-inf"), device=q.device, dtype=torch.float32)
l_i = torch.zeros((num_heads_q, 1), device=q.device, dtype=torch.float32)
acc_o = torch.zeros((num_heads_q, head_dim), device=q.device, dtype=torch.float32)
for block_idx in range(num_blocks):
phys_block_id = block_tables[b, block_idx].item()
k_block = k_cache[phys_block_id] # [Block_Size, H_kv, D]
v_block = v_cache[phys_block_id] # [Block_Size, H_kv, D]
# Expand KV heads for GQA if necessary
if num_queries_per_kv > 1:
k_block = k_block.repeat_interleave(num_queries_per_kv, dim=1)
v_block = v_block.repeat_interleave(num_queries_per_kv, dim=1)
# Compute valid tokens in block
valid_tokens = min(block_size, cur_len - block_idx * block_size)
k_block = k_block[:valid_tokens] # [Valid, H_q, D]
v_block = v_block[:valid_tokens] # [Valid, H_q, D]
# Block attention scores: [H_q, Valid]
scores = torch.einsum("hd,vhd->hv", q_b, k_block) * scale
# Online Softmax Update
m_block = torch.max(scores, dim=-1, keepdim=True).values
m_new = torch.maximum(m_i, m_block)
p_block = torch.exp(scores - m_new)
alpha = torch.exp(m_i - m_new)
l_i = l_i * alpha + torch.sum(p_block, dim=-1, keepdim=True)
acc_o = acc_o * alpha + torch.einsum("hv,vhd->hd", p_block, v_block)
m_i = m_new
outputs[b] = (acc_o / (l_i + 1e-8)).to(q.dtype)
return outputs
Step 3: Production Serving Pipeline Simulation
from __future__ import annotations
import torch
from paged_allocator import PagedBlockAllocator
from paged_attention_kernel import paged_attention_decode_step
def run_continuous_batching_simulation() -> None:
# 1. Hardware & Memory Geometry
num_physical_blocks = 128
block_size = 16
num_layers = 32
num_heads_q = 32
num_heads_kv = 8 # GQA
head_dim = 128
allocator = PagedBlockAllocator(num_blocks=num_physical_blocks, block_size=block_size)
# Physical HBM KV Cache Pool
k_cache = torch.randn(num_physical_blocks, block_size, num_heads_kv, head_dim, device="cuda", dtype=torch.bfloat16)
v_cache = torch.randn(num_physical_blocks, block_size, num_heads_kv, head_dim, device="cuda", dtype=torch.bfloat16)
# 2. Admit Prompt with Shared System Prefix (Automatic Prefix Caching)
system_prefix = [101, 2054, 2003, 1037] * 4 # 16 tokens (1 Block)
req1_prompt = system_prefix + [1001, 1002, 1003]
req2_prompt = system_prefix + [2001, 2002, 2003]
table1 = allocator.allocate_prompt("req_1", req1_prompt)
table2 = allocator.allocate_prompt("req_2", req2_prompt)
print(f"Req 1 Physical Block Allocation: {table1}")
print(f"Req 2 Physical Block Allocation: {table2}")
print(f"Shared Prefix Block Ref Count: {allocator.ref_counts[table1[0]]} (Expected: 2)")
# 3. Simulate Iteration-Level Continuous Batching Step
max_blocks = max(len(table1), len(table2))
block_table_tensor = torch.zeros(2, max_blocks, dtype=torch.long, device="cuda")
block_table_tensor[0, :len(table1)] = torch.tensor(table1)
block_table_tensor[1, :len(table2)] = torch.tensor(table2)
seq_lens = torch.tensor([len(req1_prompt), len(req2_prompt)], device="cuda")
q = torch.randn(2, num_heads_q, head_dim, device="cuda", dtype=torch.bfloat16)
out = paged_attention_decode_step(q, k_cache, v_cache, block_table_tensor, seq_lens, block_size)
print(f"Decoded Output Tensor Shape: {out.shape} (Success)")
if __name__ == "__main__":
if torch.cuda.is_available():
run_continuous_batching_simulation()
Empirical Benchmark Evaluation
Serving throughput, memory efficiency, and time-to-first-token (TTFT) across high-concurrency benchmarks:
| Model Architecture | Serving Engine | Memory Utilization () | Max Concurrency (A100-80GB) | Shared Prefix Throughput () | P50 Inter-Token Latency |
|---|---|---|---|---|---|
| LLaMA-2 (7B) | HuggingFace (Static) | ||||
| vLLM (PagedAttention) | () | ||||
| LLaMA-2 (70B, TP=4) | HuggingFace (Static) | ||||
| vLLM (PagedAttention) | () | ||||
| SGLang (RadixAttention) | () | ||||
| DeepSeek-V3 (671B, TP=8) | TensorRT-LLM (Static) | ||||
| vLLM V1 (Paged + MLA) | () |
Troubleshooting Common PagedAttention Faults
1. CUDA Out-of-Memory on Engine Warmup
- Symptom: Server crashes during initialization before receiving traffic:
torch.cuda.OutOfMemoryError. - Root Cause:
gpu_memory_utilizationset too high (), leaving insufficient headroom for PyTorch CUDA context, model weights, activations, and NCCL communication buffers. - Remedy: Set
gpu_memory_utilization = 0.90(or0.85for multi-GPU Tensor Parallelism).
2. Zero Prefix Cache Hit Rate in Production
- Symptom:
vllm:prefix_cache_hit_ratemetric reports , and system prompt prefill latencies spike. - Root Cause: Non-deterministic prompt string formatting (e.g., dynamic timestamps, random whitespace, or varying BOS/EOS token additions) altering hash signatures.
- Remedy: Ensure system prompt strings and token IDs are strictly identical across requests; place dynamic variables at the end of the prompt sequence.
3. CPU Swap Thrashing & High Inter-Token Latency Spikes
- Symptom: Inter-token latency suddenly degrades from to under heavy concurrency.
- Root Cause: Active working set exceeds physical GPU HBM, triggering synchronous PCIe block swaps between GPU and host CPU RAM.
- Remedy: Increase
max_num_seqsthrottling limits, configure--swap-space 8(GB), or deploy horizontal model replicas.
References
- Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H., & Stoica, I. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. Proceedings of the 29th ACM Symposium on Operating Systems Principles (SOSP 2023).
- Dao, T. (2023). FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. International Conference on Learning Representations (ICLR 2024).
- Zheng, L., et al. (2024). SGLang: Efficient Execution of Structured Language Model Programs. arXiv:2312.07104.
- DeepSeek-AI. (2024). DeepSeek-V3 Technical Report. arXiv:2412.19437.