DL S037 · Multi-Head Attention — Why Eight Heads Beat One
Split d_model across h heads, run attention in parallel, concatenate, project. Understand why multi-head is a capacity trick AND a specialisation trick, and know exactly which head learned what.
🎯 Implement multi-head attention in ~30 lines of PyTorch and explain why splitting into heads outperforms a single big head at the same parameter count.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 37 / 80 · Module M07 · ~2 hours
The story
You built single-head self-attention in S036. It works. It even scales — a d_model = 512 head with d_k = 512 has more capacity than a d_k = 64 head. So why not just make one really big head?
Because empirically, splitting the same parameter budget across h = 8 heads of d_k = 64 each beats one head of d_k = 512. Consistently. On every benchmark. The original transformer paper's ablation table (Table 3) is worth taping to your wall — halving heads (single head) costs ~1 BLEU on English–German, doubling heads (16) gains almost nothing. Eight is a sweet spot that's held up for eight years.
The reason "why" has two parts. The obvious one is capacity through specialisation: different heads can learn different types of dependencies (short-range syntax, long-range coreference, position-relative patterns, etc.) rather than one head having to average across all of them. The subtler one is ensembling in the attention distribution: soft-max is a peaky operator, and with one head you're forced to pick one focus per query, while with eight heads you can attend to eight different things simultaneously and let the output projection blend them.
This session we build multi-head attention, count parameters carefully so you never confuse d_model and d_k, look at real attention patterns from a trained BERT, and hit the two shape gotchas that eat 30 minutes of every first-time implementer's life.
- Explain in one sentence why h heads of dimension d_model/h beat one head of dimension d_model at equal params.
- Implement MultiHeadAttention in PyTorch in ~30 lines, with shape assertions.
- Compute the parameter count of MHA as a function of d_model and h.
- Reshape (B, T, d_model) → (B, h, T, d_k) and back without thinking about it.
- Describe two attention patterns you'd expect to find in a trained transformer's heads.
- Avoid the three classic MHA implementation bugs.
Prerequisites
- Session 036 — scaled dot-product attention. This session is a wrapper around it.
- Session 003 — the reshape + permute sequence in MHA is where broadcast fluency pays off.
1 · The one-line change
Take S036's Attention(Q, K, V). Multi-head attention is:
with W_Q^{(i)}, W_K^{(i)}, W_V^{(i)} ∈ ℝ^{d_{model} \times d_k} per head, and W_O ∈ ℝ^{h·d_v \times d_{model}} mixing the concatenated heads back into d_model.
The trick, which is not stated in the equation but is critical for implementation, is that we set d_k = d_v = d_model / h. So the total width of the concatenated heads is exactly d_model, and W_O is (d_model, d_model). This means MHA has the same parameter count as a single head with d_k = d_model (plus the W_O), but the attention pattern is now h different (T, T) matrices instead of one.
2 · Parameter count — do it once, know it forever
For MHA with model dim d_model and h heads (so d_k = d_v = d_model / h):
hcopies ofW_Q^{(i)}each(d_model, d_k)=h · d_model · (d_model/h)=d_model².- Same for
W_KandW_V. Total so far:3 · d_model². W_O:(d_model, d_model)=d_model².- Total:
4 · d_model².
Note: no h in the formula. Number of heads does not change parameter count. It only changes how you slice the same parameters. This is the most common thing people get wrong on transformer interview questions.
3 · The efficient implementation — one big projection, then reshape
Naively, you'd write h separate nn.Linear(d_model, d_k) layers. In practice, everyone uses one nn.Linear(d_model, d_model) for Q (and one each for K, V), then reshapes the output into (B, h, T, d_k). Mathematically identical, dramatically faster on GPU because it's one big matmul instead of h small ones.
Here's the shape journey. Start with X: (B, T, d_model).
Q = X @ W_Q # (B, T, d_model) — one big linear
Q = Q.view(B, T, h, d_k) # split last dim into heads
Q = Q.transpose(1, 2) # (B, h, T, d_k) — put heads before TSame reshape+transpose for K and V. Now compute attention per head, in parallel using broadcasting:
scores = Q @ K.transpose(-2, -1) / sqrt(d_k) # (B, h, T, T)
weights = softmax(scores, dim=-1) # (B, h, T, T)
out = weights @ V # (B, h, T, d_k)Reverse the reshape:
out = out.transpose(1, 2).contiguous() # (B, T, h, d_k)
out = out.view(B, T, d_model) # concat heads
out = out @ W_O # (B, T, d_model)Study those two "reshape journeys" — forward and reverse — until they're automatic. This is the shape choreography every transformer implementation performs.
4 · The implementation
import torch, torch.nn as nn, torch.nn.functional as F, math
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, h, dropout=0.0):
super().__init__()
assert d_model % h == 0, "d_model must be divisible by h"
self.d_model = d_model
self.h = h
self.d_k = d_model // h
self.W_Q = nn.Linear(d_model, d_model, bias=False)
self.W_K = nn.Linear(d_model, d_model, bias=False)
self.W_V = nn.Linear(d_model, d_model, bias=False)
self.W_O = nn.Linear(d_model, d_model, bias=False)
self.dropout = nn.Dropout(dropout)The __init__ allocates the four projections. Now the forward:
def forward(self, x, mask=None):
B, T, _ = x.shape
# 1. Project and split into heads
Q = self.W_Q(x).view(B, T, self.h, self.d_k).transpose(1, 2)
K = self.W_K(x).view(B, T, self.h, self.d_k).transpose(1, 2)
V = self.W_V(x).view(B, T, self.h, self.d_k).transpose(1, 2)
# Q, K, V: (B, h, T, d_k)
# 2. Scaled dot-product attention per head
scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k) # (B, h, T, T)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
weights = F.softmax(scores, dim=-1)
weights = self.dropout(weights)
out = weights @ V # (B, h, T, d_k)
# 3. Merge heads and project
out = out.transpose(1, 2).contiguous().view(B, T, self.d_model)
return self.W_O(out)Thirty lines. That is multi-head attention. Every transformer library on the planet is a variation on this snippet.
4.1 · Sanity-check test
mha = MultiHeadAttention(d_model=64, h=8)
x = torch.randn(2, 10, 64) # batch 2, seq len 10
y = mha(x)
print(y.shape) # torch.Size([2, 10, 64])
assert y.shape == x.shapeInput and output shapes must match. That's the "residual-compatibility" contract — S039 depends on it.
5 · What do the heads actually learn?
If you visualise attention patterns from a trained BERT or GPT, you'll see distinct roles. Voita et al. (2019) and Clark et al. (2019, "What does BERT look at?") catalogued them; the reproducible categories are:
- Positional / diagonal — attends to itself. Preserves current-token information through the layer.
- Previous-token — attends to token i-1. Basically an RNN's cell inside a transformer.
- Next-token — attends to i+1 (in bidirectional models like BERT). Useful for local dependencies.
- Delimiter / CLS / SEP — attends to special tokens. Acts as a scratchpad or 'null' attention.
- Coreference — long-range: 'she' attends to the earlier occurrence of the person's name.
- Syntax — attends from verb to object, adjective to noun. Learned dependency parses without labels.
The specialisation isn't perfect and isn't stable across seeds — some heads are messy. But the pattern is real, and it explains why multi-head works: language has many kinds of dependencies, and parallel heads let one model handle several at once instead of forcing every head to be a generalist.
Voita et al. showed that after training, ~60% of heads in a trained transformer can be pruned with no measurable loss drop. The remaining 40% do all the work. But the identity of which heads matter varies across runs (different random seeds → different specialisations). This is the classic overparameterisation story: you need the extra heads during training even if you don't need them after.
Practical implication: if you're deploying under latency budget, prune heads after training with a validation-based procedure — never pre-commit to a small number of heads to save compute during pretraining.
6 · Three implementation bugs to know cold
RuntimeError: view size is not compatible with input tensor's size and stride
Fix: .transpose(1, 2).contiguous().view(...). Every. Time. Alternatively use .reshape() which handles non-contiguous automatically at a small perf cost.
x.view(B, h, T, d_k) looks reasonable but is WRONG. It interleaves head dims in a way that scrambles which head owns which features. You MUST go view(B, T, h, d_k) then transpose(1, 2). If you skip the transpose, the model still trains but slowly and to worse loss — because effectively you're mixing "head channels" and "position" incorrectly.
Attention dropout is applied to the softmaxed weights, not the raw scores. Applying it to raw scores (before softmax) has almost no regularisation effect because the softmax re-normalises. The standard PyTorch reference implementation applies dropout AFTER softmax and BEFORE multiplying V. Match that.
7 · Mermaid — the multi-head data flow
8 · Multi-head vs single-head — quick benchmark
Try this at home. Train a 4-layer transformer LM on Tiny Shakespeare (we'll do this in S041) with:
- Config A:
d_model = 256,h = 1(single 256-dim head). - Config B:
d_model = 256,h = 8(8 heads of dim 32).
Same param count. Same optimiser. Same steps. Config B beats Config A by ~0.05–0.10 val loss consistently. Small but reliable. And attention pattern visualisations show Config B's heads have picked up distinct behaviours (some are diagonal, some skip-1, some CLS-focused) while Config A's one head is a smeary compromise.
Predict: does h = 32 (dim 8) beat h = 8 (dim 32)? Predict before you look. (Answer: no — very small per-head dim cripples each head's expressiveness. There's a sweet spot around d_k ∈ [32, 128] in practice.)
9 · Modern-2025 twist — MQA, GQA, and MLA
The MHA you just built has a scaling wart: at inference time you cache K and V for every past token and every head. For Llama 3 70B (d_model = 8192, h = 64, 80 layers), a single 4k-context conversation's KV-cache is ~10 GB in FP16. That memory is what caps how many concurrent users you can serve on one H100. The fix — reduce the number of KV heads while keeping the number of Q heads — turned into a small research industry between 2019 and 2024:
- Multi-Query Attention (MQA) — Shazeer, 2019. Use
hquery heads but a single shared K and V head. KV-cache shrinks byh×. First deployed at scale in PaLM (2022) and Falcon-40B. Downside: perplexity worsens slightly and training becomes less stable. - Grouped-Query Attention (GQA) — Ainslie et al., Google, 2023. Compromise:
gshared K/V heads where1 < g < h. Llama 2 70B, Llama 3, Mistral 7B, Mixtral, Gemma all use GQA withg = 8. Nearly MHA quality with MQA-ish memory savings. - Multi-head Latent Attention (MLA) — DeepSeek-V2/V3, 2024. Compress K and V into a joint low-rank latent (
d_c ≈ 4·d_h), decompress inside the attention op. Cuts KV cache 93% vs MHA, matches or beats MHA quality. Only DeepSeek and a few 2025 forks ship it because it interacts subtly with RoPE (they use "decoupled RoPE" — half the head is positional, half is content-latent).
A cheat table to file away (Llama-3-scale numbers, per token, FP16):
| Variant | K/V heads | KV bytes/token/layer | Llama-3 70B 4k KV (80 layers) |
|---|---|---|---|
| MHA | 64 | 16,384 | ~10.5 GB |
| GQA g=8 | 8 | 2,048 | ~1.3 GB |
| MQA | 1 | 256 | ~164 MB |
| MLA (d_c=512) | latent 512 | 1,024 | ~660 MB |
We rebuild MHA cleanly this session because it's the pedagogical foundation. In S042 you switch it to GQA (four lines of code) and in the wildcard track we sketch MLA. Every 2025 open model above 7B parameters uses one of MQA/GQA/MLA — none ship pure MHA anymore. It's the single biggest inference-time architectural shift since the transformer itself.
Further reading:
- Shazeer 2019 — MQA original
- Ainslie et al. 2023 — GQA
- DeepSeek-V2 paper §2.1 — MLA derivation with the decoupled-RoPE trick
- Sebastian Raschka: "Understanding MHA, MQA, GQA" (Jan 2024)
"More heads means more capacity. Eight heads give the model eight times the attention power of one head — so if I want a stronger model, I add heads."
The standard implementation keeps total width fixed: d_model is split across heads, so d_k = d_model / h. Eight heads do not add anything — they partition the same budget. The parameter count of multi-head attention is identical to single-head attention at the same d_model, and so is the FLOP count to within the output projection. What you buy is not capacity but independence: several separate low-dimensional attention patterns computed in parallel, instead of one that must serve every relationship at once. What you pay is per-head expressivity, because each head now works in a narrower subspace.
Because in almost every other part of a network, more of a thing genuinely is more capacity — more layers, more channels, more experts. Heads look like the same kind of knob, and the word "multi" reinforces it. The illusion also survives because adding heads usually does improve the model, so the prediction is confirmed even though the reason is wrong: you gained by decomposing a hard mixed objective into several easier independent ones, not by adding parameters. The belief only breaks when you push it — go to 64 heads at d_model = 512 and quality falls, which is unexplainable if heads were capacity and obvious the moment you notice d_k has dropped to 8.
Count the parameters yourself and watch the head count cancel out:
import torch.nn as nn
def attn_params(d_model, h):
d_k = d_model // h
# Wq, Wk, Wv: each is h heads of (d_model x d_k) = d_model x d_model
# Wo: d_model x d_model
return 4 * d_model * d_model, d_k
for h in (1, 2, 4, 8, 16, 64):
p, d_k = attn_params(512, h)
print(h, p, 'params, d_k =', d_k)
# param count is IDENTICAL for every h.
# d_k falls: 512, 256, 128, 64, 32, 8.
# At h = 64 each head scores in 8 dimensions -- that is the real cost.Why does multi-head attention need an output projection W_o at all? The heads are concatenated back to d_model dimensions already — the shape is right, so why not stop there?
- 1Each head produces a
d_v-dimensional output computed entirely inside its own subspace, using its ownW_q,W_k,W_v. No head ever sees another head's result.forced by · that independence is the entire point of splitting into heads in the first place - 2Concatenation places these outputs side by side into a
d_model-vector, but the result is block-structured: dimensions 0 tod_k - 1mean whatever head 1 decided, the next block means whatever head 2 decided, and the blocks share no coordinate system.forced by · concatenation is a layout operation; it moves numbers next to each other and performs no mixing - 3The residual stream that this output is added to has its own established coordinate system, shared by every other layer in the network. Writing block-structured output straight into it would force each head to permanently own a fixed slice of the residual stream.forced by · a residual add is elementwise, so dimension i of the output lands on dimension i of the stream, with no opportunity to rotate
- 4That would be a severe and arbitrary constraint: head 3 could never contribute to a feature that lives in head 5's coordinates, and the split point between heads — a purely implementational choice — would become a hard architectural partition of the model's feature space.forced by · features useful downstream do not respect an arbitrary division of the embedding into h equal blocks
- 5Therefore a learned linear map from concatenated-head space into residual space is required.
W_ois exactly that: it lets every output dimension be any linear combination of every head's output, so heads can share, cancel, or route into whatever part of the stream they need.forced by · only a dense mixing matrix can undo a block structure imposed by concatenation
Therefore W_o is not a formality to fix the shape — the shape is already correct. It is the layer that converts h independent subspace results back into the network's shared language, and without it the head boundary would become a permanent wall through the model's representation.
Note the prediction this makes and go look for it: since W_o acts blockwise on the head outputs, it can be read as h separate matrices, one per head, whose contributions are summed into the residual stream. That means each head has its own private read-in (W_q, W_k, W_v) and its own private write-out (its slice of W_o) — so a head is a fully self-contained unit and can be ablated by zeroing its slice of W_o alone, without touching anything else. That is precisely why head-pruning work is able to remove individual heads from a trained model at all.
Instead of one analyst with a wide desk trying to track every relationship in the sentence at once, put h analysts in a room and give each a narrow desk — the same total desk space, sliced up. Each one attends to the sequence through their own small lens, and because their lenses are learned independently, they end up specialising: one tracks the previous token, another tracks the syntactic head, another something you cannot easily name.
Then W_o is the editor who takes all their reports and writes a single summary in the house style — the shared coordinate system of the residual stream. Independence in the analysis, mixing at the write-up.
- Fixed budget:
d_k = d_model / h. Heads redistribute width, they do not add it. Parameter count is independent ofh. - One big projection then reshape and transpose — never a Python loop over heads. The efficient form is
(B, T, d)to(B, T, h, d_k)to(B, h, T, d_k); forgetting that transpose is the classic bug and it produces plausible-looking garbage rather than an error. - Softmax always along the last axis, the key axis. Getting this wrong normalises across queries instead and silently trains anyway.
- Real tension: too few heads and there is no specialisation; too many and
d_kshrinks until each head cannot represent a useful similarity. The sweet spot in practice keepsd_kin the tens, not single digits.
Fire this model the moment you see: a reshape into four dimensions followed by a transpose · d_model // n_heads · attention output that trains but plateaus badly · someone proposing to raise head count to increase capacity · MQA, GQA, or any scheme where the number of key heads differs from the number of query heads.
At inference the KV cache is your memory wall. Keep one key-value pair per query head (MHA), share a single one across all of them (MQA), or share within groups (GQA)?
h separate key and value tensors per layer per token, so cache memory scales with head count — and since decoding is memory-bandwidth bound, the time to read that cache each step is what sets your token rateh, which is a first-order change to how many sequences fit on a device and therefore to achievable batch size and throughput; queries stay fully multi-head, so the read side keeps its diversityh/g while heads within different groups still see different keys and values; and an existing MHA checkpoint can be converted by mean-pooling the key-value heads within each group, then briefly uptrainedg to be a multiple of the number of shards, or the key-value heads replicate awkwardly across devicesDecide this from the arithmetic, not from preference. Multiply out your KV cache — layers, times key-value heads, times head dimension, times context length, times batch, times two for K and V, times bytes per element — and compare it against the memory left after the weights. If that number is comfortable, keep MHA and spend your effort elsewhere. If it dominates, GQA is the move, with g chosen to fit your parallelism.
The deeper point is that this is the first place where an inference constraint reached back and changed the architecture. Nothing in the training objective wanted fewer key-value heads; the memory bandwidth of decoding did. Expect more of that direction of causality, not less.
Retention scaffold
One-line summary (write it in your own words): _______________________________________________
Spaced review: re-read §3 (shape journey) and §9 (MQA/GQA/MLA table) in 24 hours. Revisit the full session on day 7.
Next session (S038): attention is permutation-invariant — shuffle the tokens, you get shuffled outputs. Language cares about order. We fix it with positional encodings — starting with sinusoids from Vaswani 2017, then previewing RoPE, and closing on 2025's YaRN and LongRoPE for million-token contexts.
Sticky note (keep on your desk): MHA = 4·d_model² params, h heads are just a slicing. Modern models slice Q one way and K/V another (GQA/MLA) to shrink the KV cache.