8. Multi-Head Attention From Scratch
ai-labs-pytorch.) Confirmed as a topic area: 1point3acres' crowdsourced Anthropic interview-question database (103 entries, 29 tagged machine-learning-engineer) lists "transformer/attention implementation" among reported interview topics. Multi-head attention is the natural extension of that reported topic, since production transformers essentially never use a single head; the specific "reshape by hand, verify against nn.MultiheadAttention" framing below is this problem's own construction, not a verbatim reported question. (Source: 1point3acres.com/interview/problems/company/anthropic)Problem
A single attention head can only learn one notion of "relevance" between tokens. Splitting the model dimension into H parallel heads, each running scaled dot-product attention on a d_model / H-sized slice, lets the model attend to different relations at once (e.g. one head tracking syntactic adjacency, another tracking coreference) for roughly the same compute as one big head, since the per-head matmuls are smaller but there are more of them.
Implement MultiHeadAttention.forward: project the input into per-head queries/keys/values by hand (reshape + transpose, not nn.MultiheadAttention), run causal or non-causal scaled dot-product attention independently per head, then merge the heads back and apply the output projection. __init__ already builds the four linear projections — your job is the reshape-attend-merge logic in forward.
Source: src/8_multi_head_attention.py
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int): ...
def forward(self, x: torch.Tensor, causal: bool = False) -> torch.Tensor: ...
# x: (B, T, d_model) -> (B, T, d_model), self-attention only
>>> torch.manual_seed(0)
>>> mha = MultiHeadAttention(d_model=16, num_heads=4)
>>> x = torch.randn(2, 5, 16)
>>> mha(x, causal=True).shape
torch.Size([2, 5, 16])
Step-by-Step Approach
- Project
xthroughq_proj,k_proj,v_proj— each still shape(B, T, d_model)at this point. - Split each projection into heads:
view(B, T, num_heads, d_head)thentranspose(1, 2)to get(B, H, T, d_head), so each head becomes an independent leading-batch-like dimension. - Compute per-head scaled dot-product attention scores:
q @ k.transpose(-1, -2) / sqrt(d_head), shape(B, H, T, T). - If
causal, add a(T, T)upper-triangular-infmask before softmax so tokenican't attend to tokens after it. - Softmax over the last axis, then multiply by
vto get(B, H, T, d_head)per-head outputs. - Merge heads back:
transpose(1, 2)then.contiguous().view(B, T, d_model)— the.contiguous()is required becausetransposeleaves the tensor non-contiguous in memory. - Apply
out_projto the merged result.
The key insight is that all H heads run in exact parallel through the same batched matmul, using the head dimension as an extra leading axis — there's no Python loop over heads anywhere; the reshape is what turns "one big attention" into "H independent smaller attentions" for free.
Reference solution
import math
import torch
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int):
super().__init__()
assert d_model % num_heads == 0
self.d_model = d_model
self.num_heads = num_heads
self.d_head = d_model // num_heads
self.q_proj = nn.Linear(d_model, d_model)
self.k_proj = nn.Linear(d_model, d_model)
self.v_proj = nn.Linear(d_model, d_model)
self.out_proj = nn.Linear(d_model, d_model)
def _split_heads(self, x: torch.Tensor) -> torch.Tensor:
# (B, T, d_model) -> (B, num_heads, T, d_head)
B, T, _ = x.shape
return x.view(B, T, self.num_heads, self.d_head).transpose(1, 2)
def forward(self, x: torch.Tensor, causal: bool = False) -> torch.Tensor:
B, T, _ = x.shape
q = self._split_heads(self.q_proj(x))
k = self._split_heads(self.k_proj(x))
v = self._split_heads(self.v_proj(x))
# (B, H, T, d_head) @ (B, H, d_head, T) -> (B, H, T, T)
scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head)
if causal:
causal_mask = torch.triu(torch.full((T, T), float("-inf"), device=x.device), diagonal=1)
scores = scores + causal_mask
weights = torch.softmax(scores, dim=-1)
out = weights @ v # (B, H, T, d_head)
# merge heads: (B, H, T, d_head) -> (B, T, d_model)
out = out.transpose(1, 2).contiguous().view(B, T, self.d_model)
return self.out_proj(out)
Key Functions & Tricks
tensor.view(B, T, num_heads, d_head)— splits the feature axis into a heads axis and a per-head axis, cheap because it's a reshape, not a copy.tensor.transpose(1, 2)— moves the heads axis next to the batch axis so batched matmul treats(B, H)together as independent leading dims.tensor.contiguous()— required before the mergeview(), sincetransposeonly changes stride metadata and leaves memory non-contiguous.torch.triu(..., diagonal=1)— builds the strictly-upper-triangular causal mask (diagonal itself stays unmasked, since a token may attend to itself).nn.MultiheadAttention(..., batch_first=True)within_proj_weight/out_projcopied in — the oracle used to verify a from-scratch implementation matches PyTorch's own reference exactly.
How to Recognize This Pattern
Recognize this whenever a problem says "attention" and specifies a number of heads, or asks you to implement anything resembling a real transformer's self-attention layer rather than the bare single-head primitive. The pattern to internalize is reshape-batch-merge: split into heads with a view+transpose, run the exact same batched attention math as single-head (heads ride along as an extra batch dimension, no loop needed), then transpose+reshape back. The most common pitfall is forgetting .contiguous() before the merge view() (PyTorch will raise a clear error, but it's easy to lose time on), or splitting d_model into heads in the wrong order relative to how the output projection expects them reassembled, which produces a shape-correct but numerically wrong result.