KV cache is the unseen database eating GPU memory in LLM decode
The KV cache quietly eats GPU memory during LLM inference: prefill vs decode, KV math, GQA and MQA, PagedAttention, quantization and eviction policies.
This article is published in English.
Sources: The KV Cache: AI’s Unseen Database Dominating GPU Memory
The real bottleneck in LLM inference
During inference, GPU compute is often idle while memory bandwidth feeds an ever-growing key–value cache. Every new token appends K/V tensors that must be reread on later steps. Deployments that ignore KV growth hit context and latency walls long before FLOPs run out.
Under the hood: how the KV cache works
Attention needs keys and values for every prior token. Caching them avoids recomputing the whole prefix each step. The tradeoff is memory linear in sequence length × layers × heads × dim.
Prefill vs decoding
Prefill processes the prompt and fills the cache. Decoding appends one token at a time, reading the full cache each step. Prefill is compute-heavy; decoding is bandwidth-heavy.
import torch
import torch.nn as nn
# Configuration representing a simplified LLM layer
batch_size = 1
num_heads = 8
head_dim = 64
hidden_dim = 512
# Projection layers for Keys and Values
W_k = nn.Linear(hidden_dim, num_heads * head_dim)
W_v = nn.Linear(hidden_dim, num_heads * head_dim)
# --- 1. PREFILL PHASE ---
# The prompt has 5 tokens. We process them simultaneously.
prompt_len = 5
prompt_embeddings = torch.randn(batch_size, prompt_len, hidden_dim)
# Project to K and V, then reshape to: [Batch, Heads, SeqLen, HeadDim]
kv_cache_k = W_k(prompt_embeddings).view(batch_size, prompt_len, num_heads, head_dim).transpose(1, 2)
kv_cache_v = W_v(prompt_embeddings).view(batch_size, prompt_len, num_heads, head_dim).transpose(1, 2)
print(f"Prefill complete. Cache K shape: {kv_cache_k.shape}")
# Output: [1, 8, 5, 64] (5 tokens cached)
# --- 2. DECODING PHASE ---
# We generate 3 subsequent tokens, one by one.
for step in range(3):
# Input is only the single, newly generated token (SeqLen = 1)
new_token_embedding = torch.randn(batch_size, 1, hidden_dim)
# Project only the new token's K and V
new_k = W_k(new_token_embedding).view(batch_size, 1, num_heads, head_dim).transpose(1, 2)
new_v = W_v(new_token_embedding).view(batch_size, 1, num_heads, head_dim).transpose(1, 2)
# Append the new token's representations to our historical cache
# This prevents recalculating the past prompt_len + step tokens
kv_cache_k = torch.cat([kv_cache_k, new_k], dim=2)
kv_cache_v = torch.cat([kv_cache_v, new_v], dim=2)
print(f"Decoding Step {step + 1} | Cache K shape: {kv_cache_k.shape}")
# Step 1 Output: [1, 8, 6, 64]
# Step 2 Output: [1, 8, 7, 64]
# Step 3 Output: [1, 8, 8, 64]
def calculate_kv_cache_gb(batch_size: int, seq_len: int, layers: int, hidden_dim: int, precision_bytes: int = 2) -> float:
# 2 represents the key and value states
total_bytes = batch_size * seq_len * layers * hidden_dim * 2 * precision_bytes
return total_bytes / (1024 ** 3) # Convert to Gigabytes
# Llama 2 70B architecture parameters (assuming standard MHA)
llama_70b_gb = calculate_kv_cache_gb(
batch_size=32,
seq_len=8192,
layers=80,
hidden_dim=8192
)
print(f"KV Cache Size: {llama_70b_gb:.2f} GB")
# Output: KV Cache Size: 320.00 GB
+---------------------------------------------------------------+
| GPU VRAM (80 GB) |
+------------------------------+--------------------------------+
| Static Model Weights (Fixed) | KV Cache (Dynamic / Variable) |
| [==========================] | [=======================> ] |
| FP16 Weights (e.g., ~35GB/GPU| Grows linearly with: |
| across split tensor setups) | Batch Size * Sequence Length |
+------------------------------+--------------------------------+
MHA (1:1 Ratio) GQA (Grouped Ratio) MQA (Many:1 Ratio)
[Q1][Q2][Q3][Q4] [Q1][Q2] [Q3][Q4] [Q1][Q2][Q3][Q4]
| | | | \ / \ / \ | / /
[K1][K2][K3][K4] [ K1 ] [ K2 ] [ K1 ]
[V1][V2][V3][V4] [ V1 ] [ V2 ] [ V1 ]
# A simple simulation of PagedAttention block mapping
class VirtualPageTable:
def __init__(self, block_size: int):
self.block_size = block_size
self.logical_to_physical = {} # Maps (request_id, block_index) -> physical_block_id
self.free_blocks = list(range(1000)) # Pool of physical memory blocks
def allocate_block_for_token(self, request_id: int, token_index: int) -> int:
# Determine which logical block the token falls into
logical_block_idx = token_index // self.block_size
key = (request_id, logical_block_idx)
# Allocate a new physical block only when the previous one is full
if key not in self.logical_to_physical:
physical_block = self.free_blocks.pop(0)
self.logical_to_physical[key] = physical_block
return self.logical_to_physical[key]
# Instantiating a table with a block size of 4 tokens
table = VirtualPageTable(block_size=4)
# Token 5 belongs to logical block 1 (tokens 4-7). We allocate physical space on demand.
physical_id = table.allocate_block_for_token(request_id=42, token_index=5)
print(f"Logical token mapped to physical block: {physical_id}")
FP16/BF16 Value: [0.12345678] (16 bits of precision)
↓ Quantization via Scaling Factor (e.g., scale = 127.0)
INT8 Value: [15] (8 bits of precision, 50% memory saved)
Sequence: [T1] [T2] [T3] [T4] [T5] [T6] [T7]
└───── Window Size (4) ─────┘
(T1 and T2 are evicted from active memory)
# Example: Initializing a vLLM engine with memory optimizations
from vllm import LLMEngine, EngineArgs
# Define engine arguments optimized for memory management
engine_args = EngineArgs(
model="meta-llama/Llama-3-8B-Instruct",
# 1. Quantization: Reduces KV cache footprint
quantization="fp8",
# 2. Paged KV Cache: Eliminates physical fragmentation
gpu_memory_utilization=0.90,
# 3. Dynamic Batching: Prevents static padding overhead
max_num_seqs=256
)
# Initialize the engine with built-in memory guardrails
engine = LLMEngine.from_engine_args(engine_args)
Appending mechanics
Implementations store per-layer K/V buffers and write the new row each step. Bugs here corrupt attention silently—prefer battle-tested kernels.
The sobering memory math
Bytes ≈ 2 (K and V) × layers × kv_heads × head_dim × seq_len × bytes_per_element. Long context on large models can consume more memory than weights.
Optimization techniques
Architectural redesign: MHA, GQA, MQA
Grouped-query and multi-query attention shrink KV heads so the cache is smaller for the same sequence length.
PagedAttention
Treat KV like paged virtual memory: allocate blocks on demand, reduce fragmentation, enable higher batching.
KV quantization
Store cache entries at lower precision when quality budgets allow.
Sliding-window / eviction
Drop or compress distant tokens when the task tolerates limited attention span.
Production decision matrix
Pick GQA/MQA models when memory-bound; enable paged attention for high concurrency; quantize KV only after eval; use windows for chat with local context needs.
Closing
The KV cache is the unseen database of autoregressive decoding. Optimize it explicitly—architecture, paging, precision, eviction—or pay in GPUs and latency.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Document max context per SKU so product cannot silently request 128k on a card provisioned for 8k.
When batching, remember decode steps synchronize on the longest sequence; padding policy and continuous batching matter as much as raw bandwidth.
Measure tokens/second and KV bytes/token together. A “faster” kernel that doubles cache footprint can lose under multi-tenant load.
Prefill/decoding asymmetry also affects autoscaling: spikes in prompt length hammer prefill compute, while long generations hammer bandwidth. Size pools for both shapes.
Compare vendor claims on “long context” by asking for KV bytes at max length with your dtype and batch size—not marketing tokens.
Eviction policies must be product-aware: summarizing dropped spans beats silent forgetting for support agents that cite earlier turns.
Continuous batching schedulers that ignore KV fragmentation still OOM; paged layouts and defrag hooks belong in the same rollout.
Prefill/decoding asymmetry also affects autoscaling: spikes in prompt length hammer prefill compute, while long generations hammer bandwidth. Size pools for both shapes.
Compare vendor claims on “long context” by asking for KV bytes at max length with your dtype and batch size—not marketing tokens.
Eviction policies must be product-aware: summarizing dropped spans beats silent forgetting for support agents that cite earlier turns.
Continuous batching schedulers that ignore KV fragmentation still OOM; paged layouts and defrag hooks belong in the same rollout.
Prefill/decoding asymmetry also affects autoscaling: spikes in prompt length hammer prefill compute, while long generations hammer bandwidth. Size pools for both shapes.
Compare vendor claims on “long context” by asking for KV bytes at max length with your dtype and batch size—not marketing tokens.
Eviction policies must be product-aware: summarizing dropped spans beats silent forgetting for support agents that cite earlier turns.
Continuous batching schedulers that ignore KV fragmentation still OOM; paged layouts and defrag hooks belong in the same rollout.
Prefill/decoding asymmetry also affects autoscaling: spikes in prompt length hammer prefill compute, while long generations hammer bandwidth. Size pools for both shapes.
Compare vendor claims on “long context” by asking for KV bytes at max length with your dtype and batch size—not marketing tokens.
Eviction policies must be product-aware: summarizing dropped spans beats silent forgetting for support agents that cite earlier turns.