S109 · Multi-Head Attention — Parallel Views
Why one attention head is not enough. How 8-128 heads let a Transformer look at the same tokens from different angles simultaneously — with almost the same compute cost.
🎯 Implement multi-head attention, understand why splitting d_model into N heads works, and see what different heads actually learn in a trained Transformer.
Why this session exists
Single-head attention gives one view of the input — one attention pattern per layer. Multi-head attention runs N attention operations in parallel with different learned projections, letting the model attend to different things simultaneously: syntactic parents, coreferences, positional patterns, semantic categories. It's the reason a 12-layer BERT can capture linguistic structure that a single-head 12-layer model can't. The engineering trick is beautiful — split the embedding dim across heads instead of stacking N full-dim heads, and you get parallelism practically free.
- Implement multi-head attention correctly with shape wrangling (view / transpose / reshape).
- Explain why d_model is split across heads instead of running N independent full-dim heads.
- Describe what different heads learn — coreference, syntax, positional.
- Distinguish MHA, MQA, and GQA and know when to use each.
- Understand how the output projection W_o recombines head outputs.
Prerequisites
- S107 · Attention Intuition — the concept of attention.
- S108 · Q/K/V Math — the exact scaled dot-product formula.
- S102 · CNNs — for the 'multiple filters = multiple views' analogy.
(a) Intuition · 5 min
You read a poem. On the first read you notice the rhythm. Second read: the imagery. Third read: how the ending refers back to the beginning. Fourth read: sound repetitions like alliteration. Each read looks at the SAME text but through a different lens.
Now imagine reading it with 8 pairs of eyes at once — each pair looking through a different lens simultaneously. That's multi-head attention.
Single-head attention gives one attention pattern per layer. Multi-head runs N heads (typically 8-128) in parallel, each with its own learned W_q, W_k, W_v. Each head produces its own T×T attention pattern. Outputs from all heads are concatenated and mixed with a final linear projection W_o.
The clever engineering trick: don't use N full-dim heads. Split d_model into N chunks of d_model/N each. Same total compute as one full-dim head, but N different perspectives.
Why multiple heads win over one big head
- Different heads specialise. Empirically, head 5 might attend to syntactic parents, head 8 to coreferring pronouns, head 11 to punctuation. One head cannot do all of these simultaneously — softmax forces sharpness.
- Ensemble effect for free. N heads averaged is more robust than one head with the same total params. Similar to why a random forest beats a single deep tree.
- Parallel compute. All heads compute independently — no data dependency between them. Modern GPU kernels fuse all heads into one matmul via reshape tricks.
Why splitting d_model across heads is genius
- naiveN independent full-dim headsEach head has W_q, W_k, W_v of shape [d, d]. N heads = 3·N·d² params. For d=512, N=8: 6M params. Slow.
- cleverSplit d_model across headsOne big W_q of shape [d, d], reshape to [d, N, d/N]. Same 3d² params total, N heads worth of diversity.
- why it worksHead dimension is d_model / NFor d=512, N=8: head_dim=64. Each head is a 64-dim attention. Enough to be expressive, small enough for N of them.
- in codereshape + transpose + matmulOne matmul + a view/transpose gives you N attention operations simultaneously. GPU is happy.
(b) Visual walkthrough · 15 min
Multi-head attention pipeline
Key insight: the "N parallel attention ops" is really a single batched matmul on tensor shape [B, N, T, d/N] — GPU treats the head dim like an extra batch dim.
The shape-wrangling recipe
Q = X @ W_q. All shapes: [B, T, d_model]. Standard linear layer.
Q.view(B, T, N, d_head) — split the last dim into N chunks of d_head = d_model/N.
Q.transpose(1, 2) — moves head dim to position 1 so matmul treats it as a batch dim.
attention(Q, K, V) where Q, K, V shape is [B, N, T, d_head]. Output: [B, N, T, d_head].
transpose(1, 2) → [B, T, N, d_head], then view(B, T, d_model) — puts head outputs side-by-side.
output = concatenated @ W_o. This gives the heads a chance to mix — otherwise each head would just contribute independently.
Anatomy of the multi-head layer
What each component contributes
MHA vs MQA vs GQA — the modern variants
Original 2017 design
- N Q heads, N K heads, N V heads
- Full expressiveness
- KV cache: N × T × d_head × 2 per layer
- GPT-2, GPT-3, LLaMA-1
One K, one V for all Q heads
- N Q heads, 1 K head, 1 V head
- KV cache is N× smaller
- Modest accuracy drop (~1-2%)
- PaLM, Falcon, StarCoder
Groups of Q share K/V
- N Q heads, N/g K/V heads
- Sweet spot between MHA and MQA
- Accuracy loss \<0.5% with g=8
- LLaMA-2 70B, Mistral, Gemma
DeepSeek's 2024 innovation
- Compress K/V into a smaller latent
- Even smaller KV cache
- Complex to implement
- DeepSeek-V2, DeepSeek-V3
What different heads actually learn (Clark et al. 2019)
Different heads specialise reliably during training. In BERT:
- Head 8-10 (layer 6) — attends to the direct object of the current verb (semantic role).
- Head 8-11 (layer 4) — attends to coreferring mentions of the same entity.
- Head 8-2 (layer 2) — attends to the previous token (essentially a shift operator).
- Head 4-11 (layer 8) — attends heavily to [SEP] tokens (a form of 'no-op' for irrelevant queries).
You can visualise attention patterns with bertviz — it's genuinely enlightening.
"More heads means more capacity. If 8 heads are good, 32 heads on the same model dimension should be better."
With dhead = dmodel/h, multi-head attention has the same parameter count and roughly the same FLOPs regardless of h. Adding heads does not add capacity — it re-partitions a fixed budget into more, narrower subspaces. Past some point each head is too low-rank to represent a useful comparison, and quality falls.
Because "more X is better" is true for width and depth, and heads look like another width knob. They are not: heads are a reshape of the same tensor. The confusion is reinforced by the fact that going from 1 head to 8 does help a lot — that gain comes from allowing multiple simultaneous attention patterns, not from added parameters, and it saturates.
Confirm the parameter count is invariant to head count:
import torch.nn as nn
d = 512
for h in (1, 4, 8, 16, 64):
m = nn.MultiheadAttention(d, h, bias=False)
print(h, sum(p.numel() for p in m.parameters()))
# identical for every h: 4 * d * dThen note the corollary: at h=64, dhead=8, so each head's QK circuit is at most rank 8 — it can express only a very coarse similarity.
Why split into heads at all? A single attention operation over the full dmodel has the same parameters and strictly more expressive power per score. Deriving why splitting wins.
- 1Each attention head produces exactly one softmax distribution per query position, and its output is a convex combination of value vectors under that one distribution.forced by · there is a single softmax over the score row; one row, one distribution
- 2A single distribution can only emphasise one region of the sequence at a time. If a token needs both its syntactic governor (three tokens back) and a coreferent mention (two hundred tokens back), one distribution must split its mass between them.forced by · softmax weights sum to 1; mass given to one place is taken from another
- 3Splitting mass blends the two value vectors into their average, and averaging is lossy — the downstream layer receives a mixture and cannot separate the contributions.forced by · the sum
Σajvjdiscards which j contributed what - 4So to attend to k distinct things simultaneously without interference, you need k separate softmaxes whose outputs occupy separate subspaces before being recombined.forced by · only disjoint output subspaces let the next layer read the two retrievals independently
- 5Concatenating h head outputs and applying
WOdoes exactly that: each head writes into its owndmodel/hslice, andWOmaps the slices into the residual stream, so the sum of head contributions isΣi WO(i)headi.forced by · block structure in the concatenation makes the heads additive and independently addressable
Therefore heads exist to buy parallel, non-interfering retrievals, not extra parameters. The tradeoff is explicit: h retrievals at rank dmodel/h each, versus one retrieval at full rank.
And note what this predicts: heads should be prunable, because if a token rarely needs h simultaneous retrievals, many heads are doing nothing. Empirically large fractions of heads can be removed from trained transformers with small quality loss — exactly what the derivation implies. It also predicts that K and V can be shared across heads while Q stays per-head, since the diversity that matters is in the queries. That is grouped-query and multi-query attention, and it is why they shrink the KV cache so much at so little cost.
One reader with one highlighter must choose a single thing to highlight. Eight readers, each with their own highlighter and their own narrow instruction — one tracks pronouns, one tracks the subject verb, one tracks the previous occurrence of this exact token — mark up the same page simultaneously. Their notes are then stapled together and handed to the next layer.
The page is fixed. The budget of ink is fixed. What you bought is simultaneity.
dhead = dmodel/his a convention, not a law — but it is what makes parameter count independent of h.- Implementation is one big projection then a reshape to (B, h, T, dhead), never h separate matmuls. Getting the transpose order wrong is the single most common bug.
- Heads specialise but are not individually meaningful; behaviour is distributed and many heads are redundant enough to prune.
- The KV cache scales with h × dhead × T × layers. Sharing K and V across heads (GQA/MQA) is the standard lever for serving.
Fire this model the moment you see: a head-count hyperparameter being tuned upward "for capacity" · a KV-cache memory problem · a reshape/transpose bug in attention · a paper claiming head X does task Y · a model with dmodel not divisible by h.
At fixed dmodel, how many heads — few wide heads, or many narrow ones? And do heads get their own K/V?
Practically everyone lands on dhead around 64–128 and derives h from it, rather than choosing h directly. That is the right frame: dhead is the quantity with a meaningful floor (rank of the similarity you can express), and h is whatever falls out.
For serving, GQA is close to a free lunch and is why modern open models ship with it. The signal to reach for it is simple and measurable: if your batch size is capped by KV-cache bytes rather than by compute, you have the exact condition GQA was designed for.
(c) Hands-on · 25 min
Implement a full multi-head attention module in PyTorch and inspect the attention patterns of each head on a real input.
# multi_head_attention.py — full MHA with shape wrangling + per-head inspection.
# Run: uv run multi_head_attention.py
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttention(nn.Module):
"""Standard multi-head self-attention, following the Transformer paper."""
def __init__(self, d_model: int, n_heads: int, causal: bool = False):
super().__init__()
assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
self.d_model = d_model
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.causal = causal
# Single big projections — will reshape to (N heads, d_head) later.
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)
def forward(
self, x: torch.Tensor, return_weights: bool = False
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
B, T, D = x.shape
assert D == self.d_model
# Project + reshape into (B, T, N, d_head), then transpose to (B, N, T, d_head).
Q = self.W_q(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
K = self.W_k(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
V = self.W_v(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
# Scaled dot-product per head (batch dim = B*N).
scores = Q @ K.transpose(-2, -1) # [B, N, T, T]
scores = scores / math.sqrt(self.d_head)
if self.causal:
mask = torch.triu(
torch.ones(T, T, device=x.device), diagonal=1
).bool()
scores = scores.masked_fill(mask, float("-inf"))
weights = F.softmax(scores, dim=-1) # [B, N, T, T]
out = weights @ V # [B, N, T, d_head]
# Concatenate heads: [B, N, T, d_head] → [B, T, N, d_head] → [B, T, d_model].
out = out.transpose(1, 2).contiguous().view(B, T, D)
out = self.W_o(out) # final mix
if return_weights:
return out, weights # weights: [B, N, T, T]
return out
class GroupedQueryAttention(nn.Module):
"""GQA — N Q heads but only n_kv_heads K/V heads. Q groups share K/V."""
def __init__(self, d_model: int, n_heads: int, n_kv_heads: int, causal: bool = False):
super().__init__()
assert n_heads % n_kv_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.n_kv_heads = n_kv_heads
self.groups = n_heads // n_kv_heads
self.d_head = d_model // n_heads
self.causal = causal
self.W_q = nn.Linear(d_model, n_heads * self.d_head, bias=False)
self.W_k = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False)
self.W_v = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False)
self.W_o = nn.Linear(d_model, d_model, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, D = x.shape
Q = self.W_q(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
K = self.W_k(x).view(B, T, self.n_kv_heads, self.d_head).transpose(1, 2)
V = self.W_v(x).view(B, T, self.n_kv_heads, self.d_head).transpose(1, 2)
# Repeat K, V to match n_heads (each K/V shared across group Q heads).
K = K.repeat_interleave(self.groups, dim=1) # [B, N, T, d_head]
V = V.repeat_interleave(self.groups, dim=1)
scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_head)
if self.causal:
mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
scores = scores.masked_fill(mask, float("-inf"))
w = F.softmax(scores, dim=-1)
out = (w @ V).transpose(1, 2).contiguous().view(B, T, D)
return self.W_o(out)
def demo() -> None:
torch.manual_seed(0)
B, T, D, N = 1, 6, 32, 4
x = torch.randn(B, T, D)
mha = MultiHeadAttention(D, N)
out, weights = mha(x, return_weights=True)
print(f"MHA input: {x.shape}")
print(f"MHA output: {out.shape} (should match input)")
print(f"weights: {weights.shape} (B, N_heads, T, T)")
print("\nAttention pattern per head (row = query token, col = key token):")
for h in range(N):
print(f"\n head {h}:")
w = weights[0, h].detach().numpy()
for i in range(T):
row = " ".join(f"{v:.2f}" for v in w[i])
print(f" t{i}: {row}")
# Verify GQA has fewer params in K/V projections.
print("\n--- GQA comparison ---")
for n_kv in [4, 2, 1]:
gqa = GroupedQueryAttention(D, N, n_kv)
n_params = sum(p.numel() for p in gqa.parameters())
print(f"GQA n_heads={N}, n_kv_heads={n_kv}: params={n_params:,}")
# Verify PyTorch's builtin agrees with our MHA.
print("\n--- Sanity check vs F.scaled_dot_product_attention ---")
Q = mha.W_q(x).view(B, T, N, D // N).transpose(1, 2)
K = mha.W_k(x).view(B, T, N, D // N).transpose(1, 2)
V = mha.W_v(x).view(B, T, N, D // N).transpose(1, 2)
builtin = F.scaled_dot_product_attention(Q, K, V)
builtin = builtin.transpose(1, 2).contiguous().view(B, T, D)
builtin = mha.W_o(builtin)
diff = (out - builtin).abs().max().item()
print(f"Max diff (ours vs builtin+W_o): {diff:.2e}")
if __name__ == "__main__":
demo()Anatomy of the script
What the interesting lines do
Try training both on a tiny language-modelling task (character-level LSTM predict-next-char, but with a Transformer). Same total params, different heads = different results. The 2019 paper "Analyzing Multi-Head Self-Attention" measured this — some heads are dispensable but the diversity itself is valuable.
(d) Production reality · 15 min
PaLM (540B) had to run inference across many TPU pods. The KV cache with standard MHA (32 heads at d_head=256) was enormous — for 2048-token context per user, several GB per request. Serving many concurrent users was infeasible.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why split d_model across heads instead of running N independent full-dim heads?
- What does the output projection W_o do, and what breaks if you skip it?
- What is GQA, and why does every 2024 LLM use it?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.