Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 110m read

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.

🧠SoftwareM07 · Transformers from scratch· Session 037 of 130 110 min

🎯 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.

You will be able to
  • 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:

MHA(X)=Concat(head1,,headh)WO\text{MHA}(X) = \operatorname{Concat}(\text{head}_1, \ldots, \text{head}_h)\, W_O headi=Attention(XWQ(i),XWK(i),XWV(i))\text{head}_i = \text{Attention}(X W_Q^{(i)}, X W_K^{(i)}, X W_V^{(i)})

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):

  • h copies of W_Q^{(i)} each (d_model, d_k) = h · d_model · (d_model/h) = d_model².
  • Same for W_K and W_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.

Same clay, different vases
🌍 Real world
💻 Code world

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 T

Same 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.shape

Input 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:

Attention head archetypes (empirically observed)
  • 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.

War story Most heads are prunable — but you don't know which

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

War story Bug 1 — .view() without .contiguous() after .transpose()
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.

War story Bug 2 — reshaping to (B, h, T, d_k) directly

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.

War story Bug 3 — dropout AFTER softmax, not before

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.

Try it

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 h query heads but a single shared K and V head. KV-cache shrinks by . 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: g shared K/V heads where 1 < g < h. Llama 2 70B, Llama 3, Mistral 7B, Mixtral, Gemma all use GQA with g = 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):

VariantK/V headsKV bytes/token/layerLlama-3 70B 4k KV (80 layers)
MHA6416,384~10.5 GB
GQA g=882,048~1.3 GB
MQA1256~164 MB
MLA (d_c=512)latent 5121,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:


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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?

  1. 1
    Each head produces a d_v-dimensional output computed entirely inside its own subspace, using its own W_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
  2. 2
    Concatenation places these outputs side by side into a d_model-vector, but the result is block-structured: dimensions 0 to d_k - 1 mean 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
  3. 3
    The 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
  4. 4
    That 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
  5. 5
    Therefore a learned linear map from concatenated-head space into residual space is required. W_o is 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

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.

Mental modelA committee of narrow specialists

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 of h.
  • 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_k shrinks until each head cannot represent a useful similarity. The sweet spot in practice keeps d_k in the tens, not single digits.
🔔 Fires when you see

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.

The tradeoff

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)?

Multi-head (MHA) — h key-value heads
+ you gain maximum expressivity: every head has its own private view of what to attend to and what to retrieve, which is the configuration the model was originally designed and trained under
− you pay the KV cache stores 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 rate
pick when training, or any batch-1 offline setting where the cache easily fits and you have no throughput pressure
Multi-query (MQA) — 1 shared key-value head
+ you gain collapses the KV cache by a factor of h, 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 diversity
− you pay every query head now retrieves from the same keys and values, so heads can differ only in what they ask for, never in what is available to find — this measurably costs quality, and models trained from scratch with MQA are noticeably weaker
pick when memory is the hard binding constraint and you can uptrain to recover — long-context serving, or many concurrent sessions on one accelerator
Grouped-query (GQA) — g key-value heads, 1 &lt; g &lt; h
+ you gain a tunable dial between the two: cache shrinks by a factor of h/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 uptrained
− you pay one more hyperparameter that interacts with your tensor-parallel degree — you generally want g to be a multiple of the number of shards, or the key-value heads replicate awkwardly across devices
pick when you are serving a model at scale and want most of the cache saving with a quality loss you can actually measure and accept — which is why this is the default in current open-weight model families
What a senior engineer actually does

Decide 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

Quick recall · click to reveal
★ = stretch question

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.


Previous: ← DL S036 · Next: DL S038 →