Autoregressive Generation › Key-Value Caching

Michael BrenndoerferUpdated July 30, 202518 min read
Language AI HandbookAll chapters
Part 28 of 67GPT Architecture
Chapter 201Autoregressive Generation › Key-Value Caching
Reading section 4 of 12 · Key-Value Caching · 18 minSection 4 of 12 · 18 min

Book contents

Section 4 of 12 · 18 min read

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 t−1t-1 don't change when we generate token tt. Only the new token at position tt 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: ki=WKhik_i = W_K h_i and vi=WVhiv_i = W_V h_i, where hih_i is the hidden state at position ii. In a decoder-only model, the hidden state at position ii depends only on tokens at positions 11 through ii due to the causal mask. When we generate token t+1t+1, the hidden state at position ii (for i≤ti \leq t) does not change, because the causal mask prevents position ii from attending to position t+1t+1. 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.

KV Cache

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 TT, we compute QKV projections from the input, then use them to compute a weighted sum:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

where:

  • Q∈RT×dkQ \in \mathbb{R}^{T \times d_k}: the query matrix containing a query vector for each of the TT positions
  • K∈RT×dkK \in \mathbb{R}^{T \times d_k}: the key matrix containing a key vector for each position
  • V∈RT×dvV \in \mathbb{R}^{T \times d_v}: the value matrix containing a value vector for each position
  • dkd_k: the dimension of each key (and query) vector, typically 64 in GPT-2
  • QKT∈RT×TQK^T \in \mathbb{R}^{T \times T}: the attention score matrix where entry (i,j)(i, j) measures how much position ii attends to position jj
  • dk\sqrt{d_k}: a scaling factor that prevents the dot products from growing too large, which would push softmax into regions with vanishing gradients
  • softmax(⋅)\text{softmax}(\cdot): 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 KK and VV 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 KcacheK_{\text{cache}} and VcacheV_{\text{cache}} that accumulate the keys and values for all tokens processed so far. When generating token tt:

  1. Compute only qtq_t, ktk_t, vtv_t for the new token (a single vector each)
  2. Append ktk_t to KcacheK_{\text{cache}} and vtv_t to VcacheV_{\text{cache}} (cache grows by one row)
  3. Compute attention using qtq_t (single query) against the full cache

The attention computation for the new token becomes:

Attention(qt,Kcache,Vcache)=softmax(qtKcacheTdk)Vcache\text{Attention}(q_t, K_{\text{cache}}, V_{\text{cache}}) = \text{softmax}\left(\frac{q_t K_{\text{cache}}^T}{\sqrt{d_k}}\right)V_{\text{cache}}

where:

  • qt∈R1×dkq_t \in \mathbb{R}^{1 \times d_k}: the query vector for the single new token at position tt
  • Kcache∈Rt×dkK_{\text{cache}} \in \mathbb{R}^{t \times d_k}: the cached key matrix containing keys for all tt positions seen so far
  • Vcache∈Rt×dvV_{\text{cache}} \in \mathbb{R}^{t \times d_v}: the cached value matrix containing values for all tt positions
  • qtKcacheT∈R1×tq_t K_{\text{cache}}^T \in \mathbb{R}^{1 \times t}: a single row of attention scores (the new token attending to all previous positions)
  • dk\sqrt{d_k}: the same scaling factor as before

The key insight is that qtKcacheTq_t K_{\text{cache}}^T produces just a single row of attention scores rather than a full T×TT \times T matrix. We compute tt dot products instead of t2t^2, reducing the per-step computation from O(T2)O(T^2) to O(T)O(T). Over the full generation of TT tokens, this changes the total cost from O(T3)O(T^3) to O(T2)O(T^2), a dramatic improvement for long sequences.

Out[13]:
Visualization
Loading weights:   0%|          | 0/148 [00:00<?, ?it/s]
Heatmap showing attention weights from GPT-2 with darker colors showing stronger attention, displaying a lower-triangular pattern due to causal masking.
Actual attention weights from GPT-2's first layer for 'The cat sat on'. Each row shows how a token distributes its attention across previous positions. Later tokens can attend to all earlier tokens but not future ones. The 'on' token attends strongly to 'sat' (the verb it modifies) and 'The' (the sentence start).
In[14]:
Code
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_value
Out[15]:
Console
After 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 O(T)O(T) (one query dot-producted with TT cached keys), rather than O(T2)O(T^2) (the full attention matrix). Summed over TT generation steps, total cost is O(T2)O(T^2) rather than O(T3)O(T^3), 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 LL 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 LL layers, HH attention heads per layer, head dimension dd, batch size BB, and sequence length TT, the total cache memory requirement is:

Cache memory=2×L×B×H×d×T×sizeof(dtype)\text{Cache memory} = 2 \times L \times B \times H \times d \times T \times \text{sizeof(dtype)}

where:

  • 22: accounts for storing both keys and values (two tensors per layer)
  • LL: number of transformer layers (each layer has its own cache)
  • BB: batch size (number of sequences being generated in parallel)
  • HH: number of attention heads per layer
  • dd: dimension of each head (typically dmodel/Hd_{\text{model}} / H)
  • TT: current sequence length (grows during generation)
  • sizeof(dtype)\text{sizeof(dtype)}: bytes per element (4 for float32, 2 for float16/bfloat16)

The product H×dH \times d equals the model's hidden dimension dmodeld_{\text{model}}, so we can also write this as 2×L×B×dmodel×T×sizeof(dtype)2 \times L \times B \times d_{\text{model}} \times T \times \text{sizeof(dtype)}. The key insight is that cache memory grows linearly with both model depth LL and sequence length TT.

For GPT-2 Small (12 layers, 12 heads, 64-dimensional heads, giving dmodel=768d_{\text{model}} = 768) with float16 precision:

Out[16]:
Console
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).

Out[17]:
Visualization
Line plot showing KV cache memory in GB versus sequence length for three model sizes: GPT-2 Small, GPT-2 XL, and GPT-3 175B, with GPT-3 showing dramatically steeper growth.
KV cache memory requirements across model sizes and sequence lengths. Larger models with longer contexts require substantially more cache memory. At 4096 tokens, GPT-3's cache alone exceeds 4 GB, often exceeding the model weights' memory footprint in relative terms.

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.

Out[18]:
Visualization
Diagram showing KV cache growing from prompt processing through token generation steps.
KV cache growth during generation. The prompt is processed once (prefill phase), populating the initial cache. Each subsequent token only adds one key-value pair per layer, letting efficient incremental computation.

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:

In[19]:
Code
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)
Out[20]:
Console
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.

Out[21]:
Visualization
Line plot comparing generation time in seconds versus number of tokens generated, showing quadratic growth for naive approach and linear growth for cached approach.
Generation time scaling with and without KV caching. Naive generation shows quadratic growth because each step reprocesses the entire sequence. Cached generation grows linearly since each step only processes the new token. The gap widens dramatically at longer sequence lengths.

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