Master MHA, GQA, and MQA — what n_kv_heads buys in cache memory and what it trades in quality.
🏗️ Chapter milestone: M2 — choose your model’s attention shape against a KV-cache budget — the exercise at the end of this chapter; see The Project.
Tip⏱️ Session S1.3
One session, ending in 🏗️ M2 — choose your n_kv_heads against the KV-cache budget. Cards, prerequisites, and done-when tests: the Session Guide.
Setup
# Setup — works from a repo checkout (via ../src) or standalone on Colabimport syssys.path.insert(0, '../src')try:import ai_playground # noqa: F401exceptImportError:%pip install -q git+https://github.com/ggreg/ai_playground.git%config InlineBackend.figure_format ='retina'from ai_playground.plotting import apply_plot_styleapply_plot_style()
Code
import syssys.path.insert(0, '../src')import torchimport torch.nn.functional as Fimport matplotlib.pyplot as pltimport timeimport mathtorch.manual_seed(42)device ='cuda'if torch.cuda.is_available() else'mps'if torch.backends.mps.is_available() else'cpu'print(f'Using device: {device}')
Using device: mps
1. Multi-Head Attention (MHA) from scratch
The classic attention mechanism (§3.2.2 of the paper). For n_heads=8 and head_dim=64: - Q, K, V each have shape (batch, seq, n_heads, head_dim) - KV cache stores 2 * n_heads * head_dim values per token per layer
This is memory-intensive because every head has independent K and V projections.
Concepts implemented below: - Concept 3 (linear projection):Q = x @ W_q, K = x @ W_k, V = x @ W_v — three learned matrices project embeddings into query, key, and value spaces - Concept 9 (multi-head):.view(batch, seq, n_heads, head_dim) splits the single projection into n_heads independent attention patterns
Code
def attention_manual(q, k, v, mask=None):"""Standard scaled dot-product attention, written explicitly. Implements concepts 4-8 from the first-principles list. Reference: "Attention Is All You Need" §3.2.1 https://arxiv.org/abs/1706.03762 """# q, k, v: (batch, n_heads, seq_len, head_dim) head_dim = q.size(-1)# Concept 5 (scaling): precompute 1/sqrt(head_dim) to keep dot products# from growing with dimension, which would saturate softmax. scale =1.0/ math.sqrt(head_dim)# Concept 4 (dot-product similarity): Q @ K^T measures how relevant# each key position is to each query position.# (batch, n_heads, seq_q, head_dim) @ (batch, n_heads, head_dim, seq_k)# -> (batch, n_heads, seq_q, seq_k) scores = torch.matmul(q, k.transpose(-2, -1)) * scale# Concept 8 (causal mask): set future positions to -inf so softmax# gives them zero weight — token t can only attend to tokens 0..t.if mask isnotNone: scores = scores.masked_fill(mask ==0, float('-inf'))# Concept 6 (softmax): normalize scores into probabilities that sum to 1# for each query position. Higher score → more attention. weights = F.softmax(scores, dim=-1)# Concept 7 (weighted sum): blend value vectors by attention weights.# This is how information flows — each token's output is a weighted# combination of all values it attends to.# (batch, n_heads, seq_q, seq_k) @ (batch, n_heads, seq_k, head_dim)# -> (batch, n_heads, seq_q, head_dim) output = torch.matmul(weights, v)return output, weights# Concept 8 (causal mask): lower triangular matrix of onesdef causal_mask(seq_len, device='cpu'):return torch.tril(torch.ones(seq_len, seq_len, device=device)).unsqueeze(0).unsqueeze(0)# Demobatch, seq_len, n_heads, head_dim =2, 32, 8, 64dim = n_heads * head_dim # 512x = torch.randn(batch, seq_len, dim, device=device)# Concept 3 (linear projection): three separate weight matrices# project the same input into Q, K, V spacesW_q = torch.randn(dim, dim, device=device) *0.02W_k = torch.randn(dim, dim, device=device) *0.02W_v = torch.randn(dim, dim, device=device) *0.02# Concept 9 (multi-head): project then reshape into n_heads independent heads.# Each head operates on head_dim dimensions (512 / 8 = 64 per head).q = (x @ W_q).view(batch, seq_len, n_heads, head_dim).transpose(1, 2)k = (x @ W_k).view(batch, seq_len, n_heads, head_dim).transpose(1, 2)v = (x @ W_v).view(batch, seq_len, n_heads, head_dim).transpose(1, 2)mask = causal_mask(seq_len, device)out, weights = attention_manual(q, k, v, mask)print(f'MHA output shape: {out.shape}') # (2, 8, 32, 64)print(f'Attention weights shape: {weights.shape}') # (2, 8, 32, 32)print(f'KV cache per layer per token: {2* n_heads * head_dim} values = {2* n_heads * head_dim *2} bytes (fp16)')
GQA output shape: torch.Size([2, 8, 32, 64])
KV cache per layer per token: 512 values
KV cache reduction vs MHA: 2.0x
4. Visualize Attention Patterns
Each heatmap below shows concept 6 (softmax) output: the attention weights for one head. Rows are query positions, columns are key positions. Bright = high attention.
The causal mask (concept 8) is visible as the black upper triangle — tokens can’t attend to future positions.
The KV cache is often the memory bottleneck during inference. Let’s compute how much memory each variant needs for realistic model sizes.
During autoregressive generation, we store K and V for every past token so we don’t recompute them. This is the tradeoff: memory for speed — without the cache, generation is O(n²); with it, each step is O(n). See inference/generate.py for the implementation, and PagedAttention for how serving systems manage this memory efficiently.
Code
configs = [ ('LLaMA-7B (MHA)', 32, 32, 128, 32), # n_heads, n_kv_heads, head_dim, n_layers ('LLaMA-2-7B (GQA)', 32, 4, 128, 32), ('LLaMA-2-7B (MQA)', 32, 1, 128, 32), ('LLaMA-3-8B', 32, 8, 128, 32), ('Mistral-7B', 32, 8, 128, 32),]seq_lengths = [1024, 4096, 16384, 32768, 131072]print(f'{"Model":<22} | '+' | '.join(f'{s:>7}'for s in seq_lengths))print('-'*80)for name, nh, nkv, hd, nl in configs: kv_per_token =2* nl * nkv * hd *2# 2 for K+V, *2 for fp16 bytes sizes = [kv_per_token * s / (1024**2) for s in seq_lengths] # in MBprint(f'{name:<22} | '+' | '.join(f'{s:>5.0f}MB'for s in sizes))print(f'\n(Batch size = 1, FP16)')
🏗️ Project milestone M2 — pick your attention shape
Serving memory is KV-cache memory. For your M1 config, compute KV bytes/token (2 x n_layers x n_kv_heads x head_dim x 4 in fp32) for MHA, GQA, and MQA variants, and choose the largest n_kv_heads that keeps batch 8 x 512 tokens under 8 MB of cache. Rerun this chapter’s quality comparison at your sizes before deciding — cheaper cache is not free. Update n_kv_heads in checkpoints/myllm/config.json.
Accept when: the recomputed cache size meets the budget and the config still passes M1’s parameter check. Part of The Project: Serve Your Own LLM.
Session S1.3 — verify: uv run pytest tests/milestones/test_m02_kv_budget.py.
Key Takeaways
MHA: Best quality but highest memory. KV cache grows linearly with n_heads. Paper
GQA: Sweet spot for most use cases. LLaMA 2/3 uses this. 4-8x KV cache reduction. Paper
MQA: Maximum efficiency but slight quality loss. Great for high-throughput serving. Paper
The KV cache, not the model weights, is often the bottleneck for long-context inference. See PagedAttention / vLLM for how serving systems address this.
GQA is a Pareto improvement for inference — same quality as MHA (when trained from scratch) with less memory.