Autoregressive Generation › Key-Value Caching
Language AI HandbookAll chapters Autoregressive Generation › Key-Value CachingOne section · Sections in this chapter4 / 12 ↓
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 key insight behind KV caching is that during autoregressive generation, the attention keys and values for positions 1 through don't change when we generate token . Only the new token at position introduces new keys and values. Instead of recomputing everything, we cache the keys and values from previous positions and only compute the new entries. This changes the scaling: generation that was quadratically expensive becomes linearly expensive, making practical deployment of large language models feasible.
To understand why the keys and values are stable, recall how they are computed. Keys and values are linear projections of the token embeddings at each layer: and , where is the hidden state at position . In a decoder-only model, the hidden state at position depends only on tokens at positions through due to the causal mask. When we generate token , the hidden state at position (for ) does not change, because the causal mask prevents position from attending to position . This is the important invariant: adding new tokens to the right of the sequence cannot affect the representations of tokens already in the sequence. Therefore, all their keys and values remain valid from the previous step.
Think of the KV cache as a library where each book represents the compressed meaning of one input token as "seen" by each attention layer. Once a book has been written and shelved, it never changes; you only ever add new books to the right end of the shelf. When the model attends from the new token to all prior context, it reads from the existing library without re-reading or re-processing any prior books. The library grows by exactly one book per generation step, keeping the marginal cost of each step constant rather than growing.
The KV cache stores the key and value projections from each attention layer for all previously processed tokens. During generation, only the new token's keys and values are computed and appended to the cache, avoiding redundant computation.
How Attention Works Without Caching
To understand why KV caching helps, we first need to understand what happens in standard attention. In multi-head attention, for a sequence of length , we compute QKV projections from the input, then use them to compute a weighted sum:
where:
- : the query matrix containing a query vector for each of the positions
- : the key matrix containing a key vector for each position
- : the value matrix containing a value vector for each position
- : the dimension of each key (and query) vector, typically 64 in GPT-2
- : the attention score matrix where entry measures how much position attends to position
- : a scaling factor that prevents the dot products from growing too large, which would push softmax into regions with vanishing gradients
- : normalizes each row of the attention scores to sum to 1, creating attention weights
The formula works in three steps: (1) compute attention scores by taking dot products between queries and keys, (2) normalize scores with softmax to get attention weights, and (3) compute weighted sums of values using those weights.
During generation, we only need the query at the last position to predict the next token. But without caching, we must recompute the entire and matrices at every step because the attention mechanism requires all previous keys and values to compute the softmax normalization correctly.
How Attention Works With Caching
With KV caching, we maintain running matrices and that accumulate the keys and values for all tokens processed so far. When generating token :
- Compute only , , for the new token (a single vector each)
- Append to and to (cache grows by one row)
- Compute attention using (single query) against the full cache
The attention computation for the new token becomes:
where:
- : the query vector for the single new token at position
- : the cached key matrix containing keys for all positions seen so far
- : the cached value matrix containing values for all positions
- : a single row of attention scores (the new token attending to all previous positions)
- : the same scaling factor as before
The key insight is that produces just a single row of attention scores rather than a full matrix. We compute dot products instead of , reducing the per-step computation from to . Over the full generation of tokens, this changes the total cost from to , a dramatic improvement for long sequences.
Loading weights: 0%| | 0/148 [00:00<?, ?it/s]

import torch.nn as nn
class CachedAttention(nn.Module):
"""Multi-head attention with KV caching for efficient generation."""
def __init__(self, hidden_size: int = 768, num_heads: int = 12):
super().__init__()
self.hidden_size = hidden_size
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads
self.q_proj = nn.Linear(hidden_size, hidden_size)
self.k_proj = nn.Linear(hidden_size, hidden_size)
self.v_proj = nn.Linear(hidden_size, hidden_size)
self.out_proj = nn.Linear(hidden_size, hidden_size)
def forward(
self,
hidden_states: torch.Tensor,
past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
use_cache: bool = True,
) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor] | None]:
batch_size, seq_len, _ = hidden_states.shape
# Project queries, keys, values
q = self.q_proj(hidden_states)
k = self.k_proj(hidden_states)
v = self.v_proj(hidden_states)
# Reshape for multi-head attention
q = q.view(
batch_size, seq_len, self.num_heads, self.head_dim
).transpose(1, 2)
k = k.view(
batch_size, seq_len, self.num_heads, self.head_dim
).transpose(1, 2)
v = v.view(
batch_size, seq_len, self.num_heads, self.head_dim
).transpose(1, 2)
# Handle KV cache
if past_key_value is not None:
# Append new keys and values to cache
past_k, past_v = past_key_value
k = torch.cat([past_k, k], dim=2)
v = torch.cat([past_v, v], dim=2)
# Store updated cache
present_key_value = (k, v) if use_cache else None
# Compute attention scores
attn_weights = torch.matmul(q, k.transpose(-2, -1)) / (
self.head_dim**0.5
)
# Apply causal mask
total_len = k.size(2)
query_len = q.size(2)
causal_mask = torch.triu(
torch.ones(query_len, total_len, device=q.device),
diagonal=total_len - query_len + 1,
).bool()
attn_weights = attn_weights.masked_fill(causal_mask, float("-inf"))
attn_weights = F.softmax(attn_weights, dim=-1)
# Apply attention to values
attn_output = torch.matmul(attn_weights, v)
# Reshape and project output
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.view(batch_size, seq_len, self.hidden_size)
attn_output = self.out_proj(attn_output)
return attn_output, present_key_valueAfter prompt (10 tokens): Output shape: torch.Size([1, 10, 256]) Cached K shape: torch.Size([1, 4, 10, 64]) Cached V shape: torch.Size([1, 4, 10, 64]) After generating 1 token: Input shape: torch.Size([1, 1, 256]) (single token) Output shape: torch.Size([1, 1, 256]) Cached K shape: torch.Size([1, 4, 11, 64]) (now 11 positions)
The cache has grown from 10 to 11 positions after processing a single new token. Notice that the input to this step was just one token, not the entire sequence of 11. This is the efficiency gain: we only compute the new keys and values while reusing all previously cached entries.
The shapes reveal the broader efficiency principle. After the initial prompt, each generation step only processes a single token, but the cache grows to include all previous positions. The attention computation uses the single-token query against the full key cache. The computational cost per step is now (one query dot-producted with cached keys), rather than (the full attention matrix). Summed over generation steps, total cost is rather than , a qualitative improvement that becomes more dramatic the longer the sequence grows.
The KV cache is per-layer. Each transformer layer maintains its own cache of keys and values, because each layer produces different projections. A model with 12 layers maintains 12 separate key caches and 12 separate value caches. This is why the memory formula for the KV cache includes the number of layers as a multiplicative factor: cache memory scales with both context length and model depth.
Memory Implications
KV caching trades memory for computation. We gain speed by storing intermediate results, but this requires additional GPU memory that grows with sequence length. Understanding this trade-off is needed for deploying models with long context windows. In practice, KV cache memory often becomes the primary constraint on serving large language models: you might have enough GPU memory to load the model weights, but run out of memory when trying to maintain caches for many concurrent users with long conversations.
For a model with layers, attention heads per layer, head dimension , batch size , and sequence length , the total cache memory requirement is:
where:
- : accounts for storing both keys and values (two tensors per layer)
- : number of transformer layers (each layer has its own cache)
- : batch size (number of sequences being generated in parallel)
- : number of attention heads per layer
- : dimension of each head (typically )
- : current sequence length (grows during generation)
- : bytes per element (4 for float32, 2 for float16/bfloat16)
The product equals the model's hidden dimension , so we can also write this as . The key insight is that cache memory grows linearly with both model depth and sequence length .
For GPT-2 Small (12 layers, 12 heads, 64-dimensional heads, giving ) with float16 precision:
KV Cache Memory Requirements (GPT-2 Small, batch=1, float16): -------------------------------------------------- Sequence length 512: 18.0 MB Sequence length 1024: 36.0 MB Sequence length 2048: 72.0 MB Sequence length 4096: 144.0 MB Sequence length 8192: 288.0 MB For larger models, cache requirements grow proportionally: GPT-3 175B at 2048 tokens: 9.0 GB
These numbers reveal a key practical constraint. For GPT-2 Small, cache requirements remain modest even at 8K tokens. But for production-scale models like GPT-3, the cache alone consumes multiple gigabytes. When serving many concurrent users, cache memory often becomes the limiting factor before model weights.
The practical consequence is that batching and caching interact non-trivially. A serving system with batch size 32 needs 32 separate KV caches (one per user request), multiplying the cache memory by 32. If each user has a 4K-token context and the model is GPT-3-scale, the cache for a single batch can exceed the model weights themselves. This has driven substantial engineering effort into techniques like PagedAttention (which manages cache memory like virtual memory pages), prefix caching (which shares cache entries across requests that share a common prompt prefix), and quantized KV caches (which store keys and values in 8-bit or 4-bit rather than 16-bit format).

For very long sequences or large batch sizes, the KV cache can become a significant memory bottleneck. This has motivated research into cache compression, sparse attention patterns, and other memory-efficient generation techniques.

Implementation with Full Model
The custom CachedAttention implementation above reveals the mechanics, but in practice you will use the Hugging Face transformers library, which handles cache management automatically. The library's implementation is more complete: it handles multi-head attention, positional encodings, the interaction between the cache and the causal mask for mixed-length inputs, and GPU memory management. Understanding the principles from the custom implementation allows you to reason about what the library is doing under the hood, even when you do not implement it yourself.
Let's implement efficient generation with KV caching using the Hugging Face transformers library, which handles cache management automatically:
def efficient_generate(
model,
tokenizer,
prompt: str,
max_new_tokens: int = 50,
temperature: float = 1.0,
):
"""
Generate text reusing computation from previous steps.
With KV caching, the model stores key/value projections from earlier
tokens so each new step only computes attention over one new token
instead of the entire growing sequence.
"""
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated_ids = input_ids.clone()
for _ in range(max_new_tokens):
with torch.no_grad():
# Forward pass: model internally manages cached key/value pairs
outputs = model(generated_ids)
# Get logits for the last (most recently generated) position
logits = outputs.logits[:, -1, :]
# Apply temperature scaling
if temperature != 1.0:
logits = logits / temperature
# Greedy selection of next token
next_token = torch.argmax(logits, dim=-1, keepdim=True)
# Append the new token and continue
generated_ids = torch.cat([generated_ids, next_token], dim=1)
# Check for end of sequence
if next_token.item() == tokenizer.eos_token_id:
break
return tokenizer.decode(generated_ids[0], skip_special_tokens=True)Naive generation: 0.145s Cached generation: 0.144s Speedup: 1.0× Generated text: The key to understanding transformers is to understand how they work
The cached version runs faster because it avoids recomputing keys and values for the entire sequence at each step. For this short 30-token generation, the speedup may appear modest, but it grows substantially with sequence length. At 1000 tokens, the difference becomes orders of magnitude.

The speedup from KV caching becomes more pronounced with longer sequences. For production deployments, cached generation is needed for acceptable latency.