A transformer decoder from scratch: attention, RoPE, KV cache and decoding
Before this lesson: lesson 07 (the from-scratch routine in PyTorch). The theory of each piece: attention, positional encodings (RoPE), normalization and residual connections and the transformer block in the Deep Learning track; Linear Algebra 15 (attention in matrix form); the categorical distribution from the Probability and Statistics track. Time: about 105 minutes, plus the notebook.
Why this matters #
"Implement multi-head attention" is one of the most frequent prompts in ML coding rounds for LLM roles. The usual follow-ups are: add a causal mask, add a KV cache and generate, add RoPE, replace LayerNorm by RMSNorm, then sample with top-p or run beam search. Stanford CS336, assignment 1, asks for RMSNorm, RoPE and causal multi-head attention. The round tests whether you can write it fast, with the right shapes, and show that it works. The bugs are silent: a reshape that mixes tokens, a mask aligned to the wrong corner, positions that restart at 0, an unbiased variance, a top-p rule that is off by one. Each one gives a tensor of the right shape with wrong numbers. The same block runs in vision transformers and in scientific foundation models over patch tokens, and the KV cache is the starting point of every LLM serving question (lesson 13).
1. Predict first #
2. The picture #
2.1 One head on one sequence #
There are tokens with vectors . Each token makes a query , a key and a value , by three linear maps. Token scores every key, . A softmax over (over the keys) turns the scores into weights that sum to 1, and the output of token is the weighted average . In a decoder, token may only look at keys : this is the causal mask.
Click a row to pick a query; then turn the causal mask off and on. Left: the scaled scores; right: the weights (each row sums to 1). The output of the selected query is drawn as the weighted average of the value vectors. With the causal mask, row has zeros right of the diagonal: token cannot see later tokens. The first token can see only itself, so its output is exactly its own value vector. Move the scale to 0: every visible weight becomes equal.
2.2 A batch and several heads change only the shapes #
Notation: is the batch size, the sequence length, the model width, the number of
heads, and the head size. The heads are one more batch axis, and @ batches over
all leading axes, so there is no Python loop over heads:
| Step | Code | Shape |
|---|---|---|
| input | x |
|
| fused projection | qkv = x @ W_qkv.T |
|
| split | q, k, v = qkv.split(D, dim=-1) |
each |
| head split | q.view(B, T, H, d_h).transpose(1, 2) |
|
| scores | q @ k.transpose(-2, -1) / sqrt(d_h) |
|
| mask, then softmax over keys | .masked_fill(~mask, -inf).softmax(-1) |
|
| weighted values | probs @ v |
|
| merge heads | .transpose(1, 2).reshape(B, T, D) |
|
| output projection | @ W_o.T |
The head split is the step that goes wrong most often. The features of one token are next to each other in memory. So the split must first cut the last axis into , and only then move the head axis forward.
Switch between the two head splits. Each cell is one feature, coloured by its token
(token 1, token 2, token 3)
and labelled with its feature index. With the bug, view(B, H, T, d_h), the memory is cut into the
right shape but the wrong pieces: rows (magenta frames) hold the wrong token, often a later one. The
shape is the same, so only a test against a reference finds this bug.
Multi-head attention with , , and . What is the shape of the scores tensor ?
After the head split, and have shape . The product over the last two axes gives one score matrix per batch element and head: . is the shape of (and of the output before the merge). The scores do not depend on .
2.3 The other pieces each change one row of the table #
- LayerNorm or RMSNorm rescales each token's features before attention and before the feed-forward network (the pre-norm block).
- RoPE rotates and after the head split, before the scores.
- The KV cache keeps and of past tokens (after RoPE). A new token computes only its own , appends its to the cache, and attends over all cached keys.
RoPE, as a picture. Cut the query and the key into pairs of numbers. Turn each pair by an angle that grows with the token's position. A query at position is turned by , a key at position by . The dot product depends only on the angle between the two vectors, and that angle changes by . So the score sees only the offset .
Press "Shift both by +1" several times. The query and the key turn together, the angle between them stays, and the score in the log stays the same. Then press "Swap m and n": the offset changes sign and, for most and , the score changes, so RoPE can tell "before" from "after". The slider is the frequency of this pair.
The KV cache, as a picture. Generation runs the prompt once (the prefill), then adds one token per step (decode). In a decode step only the new rows of the attention matrix are computed; the keys and values of the old tokens come from the cache. The mask for the new rows must be aligned to the bottom-right corner of the score matrix, because the new queries are the last positions.
Press Next step and compare the two masks. Rows are query positions, columns are key
positions. Blue cells are computed in this step; faint cells were computed
earlier and are not recomputed; the aqua band marks the cached keys. With
is_causal=True the triangle starts in the upper-left corner of the score matrix,
so the new token sees only key 0, and the keys it must see are hidden (magenta ✕).
Switch to tril(diagonal=P): the new rows match the full forward pass. Try 2 new tokens per step.
3. Definitions and main results #
3.1 Attention with a boolean mask #
For , , and a boolean mask :
- Convention. In
F.scaled_dot_product_attention(SDPA) and in CS336,Truemeans "may attend". Innn.MultiheadAttention,Trueinattn_maskorkey_padding_maskmeans "blocked". Write your convention in the docstring. - Scale by , the head size, not by . SDPA's default scale is
with =
q.size(-1), which is after the head split. - Mask before the softmax. Then the masked weights are exactly 0, and the visible weights of each row still sum to 1.
- A row with every key masked gives = NaN in your own softmax (some PyTorch versions return zeros instead; do not rely on either). Make sure every query sees at least one key; this matters for left padding.
- Padding mask: shape , broadcast over heads and queries; combine it with the causal mask by a logical AND.
Why . If the entries of and are independent with mean 0 and variance 1, then each product has mean 0 and variance , and the products are uncorrelated. So has variance : for the scores have a standard deviation of about 11. A softmax over such large numbers is almost one-hot, and its gradient is almost 0. Dividing by brings the variance back to 1.
3.2 Multi-head and grouped-query attention #
- Parameters: , so (plus with biases) for every . Heads split the width; they add no parameters.
- FLOPs per layer for tokens (2 FLOPs per multiply–add): projections ; and each .
- The head split must be
view(B, T, H, d_h)and thentranspose(1, 2).view(B, H, T, d_h)gives the same shape but cuts the wrong pieces (figure above); outputs can even depend on future tokens. - The merge: after
transpose(1, 2)the tensor is not contiguous in memory, so usereshape(or.contiguous().view). - Grouped-query attention (GQA, Ainslie et al. 2023). query heads share
key/value heads, in groups of . Query head uses KV head
: in code,
k.repeat_interleave(g, dim=1)(same for ), or SDPA withenable_gqa=True. Multi-query attention (MQA) is . The and projections shrink to parameters, and the KV cache shrinks by the factor . Llama 2 70B and Llama 3 8B use 8 KV heads.
3.3 LayerNorm, RMSNorm and the pre-norm block #
For one token , with gain , shift and a small :
Here is the all-ones vector and the elementwise product.
- Normalize over the last axis only. LayerNorm has parameters, RMSNorm .
- The variance is the biased one (divide by ).
torch.vardivides by by default; usecorrection=0. - goes inside the root: in
nn.LayerNormand CS336, or in LLaMA models. Pass explicitly in a test; library defaults differ. - Compute in fp32 and cast back. In fp16, overflows (above 65,504) once is above about 256; in bf16 the mean of squares loses precision.
Fact. With , and the same : .
Proof.
- The mean square of is .
- So .
So RMSNorm is LayerNorm without the centering and without : one reduction instead of two, with similar quality. LLaMA, T5 and CS336 use it.
The pre-norm block. With the hidden states :
and one final norm before the output projection to the vocabulary. The FFN (feed-forward network) acts on each token separately (in LLaMA a SwiGLU MLP). The residual path is never normalized, which makes deep stacks train stably.
3.4 RoPE #
The head size is even, and is the base (10,000 in LLaMA 1 and 2, 500,000 in LLaMA 3). The frequencies are for . A query or key at position is cut into pairs , and pair is turned by the angle :
As a matrix, , where is block diagonal with the rotations .
Theorem (relative positions). For all and positions : . The score depends on , and only.
Proof.
- A rotation satisfies and .
- Block-diagonal matrices multiply block by block, so has the blocks ; that is, .
- .
Rules for the code that follow from the theorem:
- RoPE goes on and , never on . Rotated values would carry absolute positions into the output, and these rotations do not cancel. Every head uses the same rotation.
- Positions are absolute token positions. A common shift of and cancels, but and must use the same origin. , and rotations keep norms.
- Build the cos and sin tables once, in fp32, with shape , and index
their rows with a
positionstensor. Near position 4000 the bf16 spacing is 16, so position 4001 would be stored as 4000. - Two layouts. Interleaved pairs : RoFormer, Meta's LLaMA code, CS336. Half-split
pairs : GPT-NeoX and the Hugging Face LLaMA code (
rotate_half). They differ by one fixed permutation of the coordinates of and . Scores do not change if you permute the rows of and in the same way, which is what the Hugging Face conversion script does. Load weights with the wrong layout and the model runs, but its outputs are garbage.
3.5 The KV cache #
Prefill: run the prompt through the model in one pass and keep and of every layer. Decode: one new token per step; compute for it only, append to the cache, and attend over all cached keys.
Theorem (a causal decoder can be cached). In a decoder-only transformer with causal masks, let be the hidden state of token after layer . Then depends only on tokens . So the keys and values of a token never change once they are computed, and decoding with a cache gives the same outputs as a full forward pass over the whole sequence (up to float rounding).
Proof (induction on ).
- : is the embedding of token .
- Assume the claim for layer . In layer , , , are per-token maps of (a norm, a linear map, the rotation ), so they depend only on tokens .
- The causal mask lets query combine only with , and these depend only on tokens , with .
- The residual add, the norm and the FFN act on each token separately. So depends only on tokens .
Each step of the proof is a rule for the code. Let be the number of cached tokens and the number of new tokens (0-based positions from here on).
- Step 2, positions. Rotate each key by its absolute position before you store it. The new tokens have positions , not .
- Step 3, the mask. There are queries and keys. Query has position and
may see key exactly when . In PyTorch:
torch.ones(T, P + T, dtype=torch.bool).tril(diagonal=P)(True = attend). For nothing is masked. SDPA'sis_causal=Truealigns the triangle to the upper-left corner, which is wrong whenever . - Step 4, no other mixing. No operation outside attention may mix tokens. BatchNorm over the time axis, for example, would break the theorem.
Cost per layer and decode step, with tokens in the context. With the cache: projections and FFN for one token, , plus attention over keys, . Without the cache all tokens are recomputed: . The work per step drops by a factor of about . Decoding then becomes memory-bound: every step reads all the weights and the whole cache.
Memory per token, with layers and bytes per number:
where the 2 counts and .
Two implementations. torch.cat on the time axis is simple, but each step copies the whole
cache: copying over steps. A preallocated buffer
with a fill pointer has static shapes, as production code needs. In an interview, write cat
first and mention the buffer.
The cache holds tokens and a chunk of new tokens arrives (a chunked prefill). Which RoPE positions do the new tokens get, and which mask do you build?
The new tokens are the 7th to 9th tokens, so their absolute positions are 6, 7, 8; positions that restart at 0 break the relative-position property against the cached keys. Query (position ) may see keys : a lower triangle shifted right by . Without a mask, query 6 would see keys 7 and 8.
A model has layers, query heads, KV heads, , and a bf16 cache. How many bytes of KV cache does one token need?
bytes = 128 KiB. 512 KiB uses the 32 query heads instead of the 8 KV heads (that is Llama-2-7B without GQA). 64 KiB forgets that both and are stored.
3.6 Decoding: greedy, temperature, top-k and top-p #
At each step the model outputs a logit vector , one number per token of the vocabulary (about to tokens). The softmax turns logits into probabilities . A decoding rule picks the next token, appends it, and repeats until an end-of-sequence token or a maximum length.
- Greedy decoding takes at each step. It is deterministic.
- Temperature. Sample from , with temperature . makes the distribution sharper, flatter; the order of the tokens never changes. gives greedy decoding; gives the uniform distribution. Implement as argmax, not as a division by zero.
- Top-k. Keep the largest logits, set the others to , take the softmax, sample. Weakness: is fixed. When the distribution is flat, a small cuts good tokens; when it is peaked, a large keeps junk.
- Top-p (nucleus) sampling (Holtzman et al. 2020). The nucleus is the smallest set of tokens with total probability at least . Sample from restricted to and divided by the kept mass. Its size adapts to the shape of the distribution: one token for a peaked distribution, many for a flat one.
The top-p algorithm for one row of logits:
- Compute .
- Sort in decreasing order, , and remember the original indices.
- Compute the cumulative sums , with .
- Keep sorted token when the mass before it is below : . This keeps the token that crosses , and it always keeps the top token.
- Set the logits of the removed tokens to , scatter back to the original order, take the softmax, sample.
For a batch, do this on a tensor with sort, cumsum and scatter along the
last axis; never loop over the vocabulary in Python. Order: temperature first, then top-k, then
top-p (the order of the Hugging Face logits processors). Temperature changes the nucleus, so the
order matters.
Predict first: with and , which tokens survive top-p? Then check the table. Faint blue bars are ; solid orange bars are the final distribution after the filters, renormalized. Lower the temperature: the nucleus shrinks to one token. Pick flat and peaked and compare top-k with to top-p with : top-p keeps many tokens for the flat row and one for the peaked row.
Logits give . Which tokens does top-p with keep, and with which probabilities?
The mass before token 1 is 0 and before token 2 is ; both are below , so both are kept. The mass before token 3 is , so it goes. The kept probabilities must be renormalized by their sum : and . The first option is the off-by-one rule , which drops the token that crosses .
Variants. Min-p (Nguyen et al. 2024) keeps token when . The Gumbel-max trick: with and independent , is an exact sample from .
3.7 Beam search #
Goal. Find a sequence with a high log-probability . Exact search over all sequences is impossible. Beam search keeps the best prefixes (the beam; is the beam width) at every step. Work with log-probabilities: a product of many numbers below 1 underflows, a sum of logs does not.
Algorithm (the interview version).
- Start with one hypothesis: the prompt, with score 0.
- For every live hypothesis with score , compute the log-probabilities of the next token. Every pair is a candidate with score .
- Flatten the scores and take the top . For a flat index , the parent beam is and the token is .
- A hypothesis that ends with the end-of-sequence token is finished: keep it with its score and stop expanding it.
- Reorder the KV cache: row of the new cache is the row of the parent beam of hypothesis
(a
gatheralong the batch axis). - Stop when all hypotheses are finished or at the maximum length. Return the finished hypothesis with the best length-normalized score.
Two details that interviewers check. At step 1 all beams would be identical, so start with a single beam (or give beams the score ). And production code (Hugging Face, for example) takes the top candidates, moves finished ones to a separate list and refills the beam with live ones, so the beam never shrinks.
Length normalization. Every extra token adds a term , so raw scores favour short outputs. Normalize the final score:
where is the number of generated tokens and . With the score
is the mean log-probability per token (the default length_penalty = 1.0 of Hugging Face). GNMT
divides by instead.
Press Next step until the search stops, then switch . The toy model has the tokens , and the end token . Blue = kept and live, aqua = kept and finished, faint = pruned, orange = the winner. Greedy decoding takes first and never sees , the best prefix of length 2. With the short "" wins; with the longer "" wins. Try beam width 1: it is greedy decoding.
At step 1, and . After , the most likely next token has probability ; after , it has . Which two-token prefix is most probable, and which one does greedy decoding pick?
. Greedy decoding commits to the best token at each step and never revisits that choice, so a slightly worse first token that leads to a much better continuation is lost. Beam search with width 2 keeps both prefixes and finds .
Cost. Each step runs the model on rows (one batch), scores candidates and keeps KV caches. is greedy decoding.
When it helps and when it fails. Use beam search when one correct output exists (translation, speech recognition, structured outputs) or when you want a ranked top- list. For open-ended text, beam search finds text that is more probable than human text, but repetitive and generic; once a phrase repeats, the model gives the next repetition an even higher probability (Holtzman et al. 2020). Very large beams can even make translation worse (Koehn and Knowles 2017).
3.8 Testing each piece fast #
- Reference: the same inputs through SDPA,
nn.MultiheadAttention(copy the weights),F.layer_normorF.rms_norm, in float64, with a tight tolerance such asatol=1e-10. - No look-ahead: change the tokens after position ; the outputs up to must not move.
- RoPE: a common shift of all positions keeps every score; position 0 is the identity.
- Cache: prefill tokens, then decode one at a time; every output equals the full pass.
- Norms: use a small (the unbiased-variance bug changes the output by , which is invisible for ).
- Sampling: equals argmax; and change nothing; frequencies over draws match the renormalized distribution. Beam: width 1 equals greedy; with a large width the result equals brute force on a toy model.
Answers to section 1. (1) With is_causal=True, SDPA builds the mask ones(1, 5).tril() =
[True, False, False, False, False]: the triangle starts at the upper-left corner, so the new token
attends only to key 0, the first prompt token. It should see all 5 keys (); a single
decode query needs no mask at all. (2) No. In an encoder, the layer-1 output of token 3 attends to
token 10, so the keys and values of token 3 in layers change when token 10 arrives;
cached values would be stale. Only the first layer's keys and values (projections of the
embeddings) stay valid. In a causal decoder they never change: this is the theorem of section 3.5.
4. Worked example #
(a) Attention by hand. One head, , , causal, and with rows , , . The scores are .
- Row 1 sees only key 1. Output .
- Row 2: scores . With the weights are and . Output .
- Row 3: scores . With the sum is , and the weights are . Output .
Row 3 is the same with and without the mask, because the last query sees every key anyway. That is the decode step of a cache. Changing changes only row 3: no look-ahead.
(b) The offset mask. The cache holds tokens, and new tokens arrive (a chunked prefill, or two draft tokens to verify). The keys are , and the queries have positions 3 and 4, which are also their RoPE positions. ✓ = may attend:
| Mask | query | key 0 | key 1 | key 2 | key 3 | key 4 |
|---|---|---|---|---|---|---|
correct, tril(diagonal=3) |
position 3 | ✓ | ✓ | ✓ | ✓ | · |
correct, tril(diagonal=3) |
position 4 | ✓ | ✓ | ✓ | ✓ | ✓ |
wrong, is_causal=True |
position 3 | ✓ | · | · | · | · |
wrong, is_causal=True |
position 4 | ✓ | ✓ | · | · | · |
(c) RoPE by hand. (two pairs) and , so and . Take in the interleaved layout. A pair turned by is .
- Query at : .
- Key at : .
- Score: for two unit pairs, the dot product is the cosine of the angle between them, so the score is .
- Shift both positions by 10 (, ): again . Same position (): , as without RoPE.
The fast pair () changes sign within a few positions and separates neighbours. The slow pair () changes slowly and encodes coarse, long-range position.
(d) LayerNorm and RMSNorm. , , , .
- LayerNorm: , , output .
- RMSNorm: , output .
- With the unbiased variance , the LayerNorm output would be multiplied by : a 13% error. With the same bug gives the factor , which a loose test misses.
(e) KV-cache memory. Llama-2-7B has layers, heads and . In fp16: bytes = 512 KiB per token. One 4096-token sequence needs 2 GiB, and eight of them need 16 GiB: more than the 13.5 GB of fp16 weights (6.74 billion parameters × 2 bytes). With GQA and 8 KV heads (Llama 3 8B, same and ) it is 128 KiB per token, 4 times less.
(f) Sampling. Five tokens with logits .
| 0.563 | 0.207 | 0.126 | 0.076 | 0.028 | |
| 0.829 | 0.112 | 0.041 | 0.015 | 0.002 | |
| 0.375 | 0.227 | 0.177 | 0.138 | 0.084 |
- Top-k with , : keep (mass 0.770). Renormalized: .
- Top-p with , : the cumulative sums are The mass before is , so stays. The mass before is , so goes. Nucleus , renormalized .
- Temperature, then top-p with : at the nucleus is alone (), so sampling becomes greedy. At it is . The same keeps 1, 3 or 4 tokens.
(g) Beam search by hand. Vocabulary , where is the end token. The toy model (every two-token prefix other than has the same row as ; at most 3 tokens):
| prefix | |||
|---|---|---|---|
| (start) | 0.5 | 0.4 | 0.1 |
| 0.25 | 0.15 | 0.6 | |
| 0.9 | 0.05 | 0.05 | |
| 0.1 | 0.1 | 0.8 |
- Greedy: (0.5), then (0.6). Output "" with probability .
- Beam with , step 1: keep () and ().
- Step 2: six candidates: 0.125, 0.075, 0.30, 0.36, 0.02, 0.02. Keep (0.36, live) and (0.30, finished). Greedy dropped at step 1, but is the best prefix of length 2.
- Step 3: expand : ; and have 0.036 each. Keep (0.30) and (0.288). Both are finished, so stop.
- Pick the winner. Raw log-probabilities: beats , so the answer is "", the same as greedy. With : against , so the answer is "". Length normalization changed the result.
5. In code #
PyTorch's own functions check the claims above. The notebook rebuilds every piece in NumPy.
import numpy as np
import torch
import torch.nn.functional as F
f64 = torch.float64
sdpa = F.scaled_dot_product_attention
# (a) one head, T = 3, d_h = 2, Q = K = V = X, causal
X = torch.tensor([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=f64)[None, None] # (1, 1, 3, 2)
out = sdpa(X, X, X, is_causal=True)
print(out[0, 0].numpy().round(4)) # rows (1, 0), (0.3302, 0.6698), (0.7517, 0.7517)
# no look-ahead: change the last token; rows 0 and 1 do not move
X2 = X.clone()
X2[0, 0, 2] = torch.tensor([5.0, -3.0], dtype=f64)
print(torch.equal(sdpa(X2, X2, X2, is_causal=True)[0, 0, :2], out[0, 0, :2])) # True
# (b) the cache theorem for one layer: cached computations = rows of the full causal pass
torch.manual_seed(0)
B, H, T, Dh = 2, 4, 6, 8
q, k, v = (torch.randn(B, H, T, Dh, dtype=f64) for _ in range(3))
full = sdpa(q, k, v, is_causal=True) # (B, H, T, Dh)
offset = torch.ones(2, 6, dtype=torch.bool).tril(diagonal=4) # P = 4, T = 2
checks = {
"decode 1 query, 5 keys": (sdpa(q[:, :, 4:5], k[:, :, :5], v[:, :, :5]), full[:, :, 4:5]),
"chunk 2, offset mask": (sdpa(q[:, :, 4:], k, v, attn_mask=offset), full[:, :, 4:]),
"chunk 2, is_causal": (sdpa(q[:, :, 4:], k, v, is_causal=True), full[:, :, 4:]), # wrong
}
for name, (got, ref) in checks.items():
print(f"{name:24s} {(got - ref).abs().max().item():.1e}")
# decode 1 query, 5 keys 6.7e-16
# chunk 2, offset mask 0.0e+00
# chunk 2, is_causal 2.7e+00
# GQA: 8 query heads share 2 KV heads (groups of 4)
q8 = torch.randn(B, 8, T, Dh, dtype=f64)
k2, v2 = torch.randn(B, 2, T, Dh, dtype=f64), torch.randn(B, 2, T, Dh, dtype=f64)
a = sdpa(q8, k2, v2, is_causal=True, enable_gqa=True)
b = sdpa(q8, k2.repeat_interleave(4, dim=1), v2.repeat_interleave(4, dim=1), is_causal=True)
print(f"{(a - b).abs().max().item():.1e}") # 0.0e+00
# MHA parameters do not depend on the number of heads: 4 D^2 + 4 D
print([sum(p.numel() for p in torch.nn.MultiheadAttention(512, h).parameters()) for h in (1, 8, 16)])
# [1050624, 1050624, 1050624]
# (d) LayerNorm vs RMSNorm, and the variance trap
x = torch.tensor([1.0, 3.0, 5.0, 7.0], dtype=f64)
ln, rms = F.layer_norm(x, (4,), eps=0.0), F.rms_norm(x, (4,), eps=0.0)
print(ln.numpy().round(4), rms.numpy().round(4))
# [-1.3416 -0.4472 0.4472 1.3416] [0.2182 0.6547 1.0911 1.5275]
print(torch.allclose(ln, F.rms_norm(x - x.mean(), (4,), eps=0.0))) # True: LN = RMSNorm of x - mean
print(round(x.var().item(), 4), x.var(correction=0).item()) # 6.6667 5.0
# (c) RoPE scores, d_h = 4, base 100, q = k = (1, 0, 1, 0)
omega = 100.0 ** (-np.arange(0, 4, 2) / 4) # [1.0, 0.1]
def rotated_unit(m): # each pair (1, 0) turned by m * omega_i
return np.stack([np.cos(m * omega), np.sin(m * omega)], axis=1).ravel()
for m, n in [(3, 1), (13, 11), (5, 5)]:
print(m, n, round(float(rotated_unit(m) @ rotated_unit(n)), 5)) # 0.56392, 0.56392, 2.0
# (f) top-p with p = 0.8 on z = (2, 1, 0.5, 0, -1), and the Gumbel-max trick
z = np.array([2.0, 1.0, 0.5, 0.0, -1.0])
p = np.exp(z - z.max()); p /= p.sum()
keep = (np.cumsum(p) - p) < 0.8 # mass before each (already sorted) token
print(keep, (p * keep / (p * keep).sum()).round(3))
# [ True True True False False] [0.629 0.231 0.14 0. 0. ]
rng = np.random.default_rng(0)
g = -np.log(-np.log(rng.uniform(size=(200_000, 5))))
freq = np.bincount(np.argmax(z + g, axis=1), minlength=5) / 200_000
print(np.abs(freq - p).max() < 5e-3) # True
The three cache checks agree to float64 rounding, except is_causal=True on the chunk, which is off
by about 2.7 because it uses the wrong mask. The notebook checks the beam search of 4(g) against a
brute force over all sequences.
6. Where it shows up #
- LLM architecture. The LLaMA-style block is pre-norm RMSNorm, RoPE, GQA and a SwiGLU FFN. CS336 assignment 1 builds it piece by piece; nanoGPT is the same block with LayerNorm and learned absolute positions.
- LLM serving. The KV cache is the reason decoding is memory-bound, and the reason servers page it (vLLM's PagedAttention), quantize it, and share it between requests with a common prefix (lesson 13, ML Systems: inference serving). Sliding-window attention bounds the cache (Mistral 7B uses a window of 4096 tokens). FlashAttention computes the same attention without storing the matrix (ML Systems: FlashAttention).
- Chat models and RL. Deployments usually combine a temperature below 1 with top-p near 0.9. Regression tests use greedy decoding, which is deterministic. RL fine-tuning samples rollouts, and the temperature sets how much the policy explores.
- Speech and translation. Whisper decodes with beam search (5 beams) and falls back to sampling at a higher temperature when the output looks repetitive. Machine translation has used beam search with length normalization since GNMT.
- ML for science. Weather, climate and other scientific transformers use patches of a grid as tokens. 2-D (axial) RoPE turns half of the pairs with the row index and half with the column index. Missing observations or land–sea masks become key-padding masks. Sequence models for molecules and proteins use temperature and top-p to set the diversity of generated candidates, and beam search for ranked lists (reaction prediction, retrosynthesis).
7. Common mistakes #
- Softmax over the queries (
dim=-2) instead of the keys (dim=-1). - Scaling by instead of .
view(B, H, T, d_h)instead ofview(B, T, H, d_h).transpose(1, 2): right shape, mixed tokens.viewaftertranspose: an error, because the tensor is not contiguous. Usereshape.- Mask polarity:
masked_fill(mask, -inf)with a mask whereTruemeans "attend", or the SDPA convention mixed with thenn.MultiheadAttentionone. is_causal=Truewith a KV cache, or a square-matrix rule such astriu(diagonal=1)applied to a score matrix.- RoPE positions that restart at 0 in each decode step; keys cached before RoPE and then rotated again; RoPE on ; angles computed in bf16; mixing the interleaved and half-split layouts.
- LayerNorm with
x.var()(unbiased), outside the root, or the wrong axis; RMSNorm with a mean subtraction (that is LayerNorm without ). - A query row with every key masked (left padding): NaN in your own softmax.
- Top-p off by one: keeping only the tokens with drops the token that crosses , and removes every token when . The rule is .
- Masking without renormalizing, temperature applied after top-p, and as a division.
- Beam bookkeeping: identical beams at step 1;
i // Vandi % Vswapped; the KV cache not reordered by parent beam; products of probabilities instead of sums of logs; no length normalization. - A test that checks only shapes, or that compares with your own earlier code instead of a library reference.
8. Check yourself #
- A model has layers, , query heads, KV heads, and a bf16
cache. How many bytes of KV cache per token? For one sequence of 32,768 tokens?
Answer
. Per token: bytes = 48 KiB. For 32,768 tokens: bytes = 1.5 GiB. - Your LayerNorm uses
x.var(-1, keepdim=True). With , how wrong is the output? Why can a test with miss the bug?Answer
The unbiased variance is , so the output is multiplied by : for . For the factor is , inside a loose tolerance. - Why does RoPE rotate and but not , and why does every head use the same rotation?
Answer
The theorem needs both and rotated; then the score depends only on . On , the output would carry the absolute positions of the keys, and these rotations do not cancel. Position belongs to the token, not to the head, so all heads use the same tables. - Your test "cached decoding = full forward pass" gives a maximum error of in float64 but
in bf16. Is there a bug?
Answer
Probably not. bf16 has 8 significant bits (machine epsilon ), and the two paths add numbers in different orders, so differences of order are expected. Check the logic in float64 with a tight tolerance; run bf16 only as a smoke test with a loose tolerance. - Four tokens have equal logits. With top-p and , how many tokens are kept?
Answer
Three. Each has probability 0.25. The mass before tokens 1–4 is 0, 0.25, 0.5, 0.75, and only the first three values are below 0.6. Which three are kept depends on how the sort breaks ties. - Does temperature change greedy decoding? Does it change top-p sampling?
Answer
Greedy: no, because has the same argmax for every . Top-p: yes, a lower sharpens , so fewer tokens are needed to reach the mass (worked example (f): 1, 3 or 4 tokens).
Write scaled dot-product attention with a mask.
, with where key is visible to query and elsewhere; the softmax runs over the keys.
Why divide the scores by ?
For independent entries with mean 0 and variance 1, has variance . Without the scale the softmax saturates and its gradients vanish.
Write RMSNorm for one token .
: no mean subtraction, no shift , inside the root.
State the relative-position property of RoPE.
: the score depends only on , and the offset , because .
How many bytes of KV cache does one token need?
(keys and values, all layers and KV heads, bytes per number).
Why is a KV cache exact in a causal decoder but not in a bidirectional encoder?
With causal masks, the hidden state of token depends only on tokens , so its keys and values never change. In an encoder, later tokens change the hidden states of earlier ones from layer 2 on.
With cached tokens and new ones, which mask do the new queries need?
A mask where query sees key exactly when :
ones(T, P+T).tril(diagonal=P).State the top-p (nucleus) rule.
Sort the probabilities; keep sorted token while the mass before it is (the smallest set with mass ); renormalize and sample.
What does temperature do as and as ?
: all mass on the argmax (greedy). : the uniform distribution. The order of the tokens never changes.
Why does beam search need length normalization?
Each token adds a negative log-probability, so raw scores favour short outputs. Divide by (with : the mean log-probability per token).
9. Interview angle #
Recognize the task from the wording.
| The prompt says | What to write first |
|---|---|
| "implement multi-head attention" | the shape legend, fused QKV, head split by reshape, a test against SDPA in float64 |
| "make it causal", "GPT-style" | a boolean mask (True = attend), applied before the softmax; a no-look-ahead test |
| "generate text fast", "add a cache" | forward(x, cache) returning (out, cache); offset positions and the tril(diagonal=P) mask; a cached = full test |
| "LLaMA-style block" | RMSNorm (pre-norm), RoPE on and , GQA with repeat_interleave, SwiGLU |
| "sample from the model" | temperature, then top-k, then top-p on a tensor; as argmax |
| "best sequence", "translation" | beam search with log-probabilities, finished list, length normalization |
What to say aloud before coding attention (about 30 seconds): "I use batch-first tensors:
is , heads, . One fused projection to , split into , then
view(B, T, H, d_h).transpose(1, 2) so each head stays inside one token. Scores are ,
scaled by , masked with where the boolean mask is False, softmax over the last
axis. Then merge with transpose and reshape, and the output projection. I will test it against
SDPA in float64 and with a no-look-ahead test."
A plan for 45 minutes.
- 0–3 min: clarify (batch-first? biases? mask convention? may you use
F.softmaxandnn.Linear? is a cache needed?); write the signature and a legend of the shape letters. - 3–8 min: the skeleton below, with a shape comment per step.
- 8–20 min: attention forward; the test against SDPA and the no-look-ahead test.
- 20–27 min: RMSNorm; test against
F.rms_normwith the same . - 27–37 min: RoPE and the cache; test that cached decoding equals the full pass at every position.
- 37–45 min: complexity, memory, follow-ups.
import torch
import torch.nn as nn
class CausalSelfAttention(nn.Module):
"""Causal self-attention with RoPE, GQA and a KV cache.
Legend: B batch, T new tokens, P cached tokens, D = d_model, H query heads,
H_kv KV heads (H % H_kv == 0), Dh = D // H. Masks: True = may attend."""
def __init__(self, d_model: int, n_heads: int, n_kv_heads: int | None = None,
rope_base: float = 10_000.0, max_len: int = 4096):
super().__init__()
... # W_qkv: D -> (H + 2 H_kv) Dh, no bias; W_o: D -> D; cos/sin buffers (max_len, Dh/2), fp32
def forward(self, x: torch.Tensor, cache: tuple | None = None) -> tuple:
# x: (B, T, D); cache: (k, v), each (B, H_kv, P, Dh), keys already rotated
# 1. qkv = W_qkv(x) (B, T, (H + 2 H_kv) Dh)
# 2. split, view(B, T, heads, Dh).transpose(1, 2) q (B, H, T, Dh); k, v (B, H_kv, T, Dh)
# 3. RoPE on q and k at positions P + arange(T)
# 4. k, v = cat(cache, new) on dim 2 (B, H_kv, P + T, Dh)
# 5. repeat k, v to H heads; s = q k^T / sqrt(Dh) (B, H, T, P + T)
# 6. key j visible to query i iff j <= P + i; probs = softmax of s over the last axis
# 7. (probs @ v).transpose(1, 2).reshape(B, T, D), then W_o; return out, (k, v)
...
Typical questions and short model answers.
- "Why divide by ?" — "If the entries of and have mean 0 and variance 1, the dot product has variance . For that is a standard deviation of about 11, so the softmax is nearly one-hot and its gradient nearly zero. Dividing by keeps the scores at unit scale at initialization."
- "What does attention cost?" — "For tokens and width : the projections are , the scores and the weighted sum over all heads, and the score matrix needs memory per head. FlashAttention computes the same result in tiles without storing that matrix, so the memory becomes , but the FLOPs stay ."
- "Why a KV cache, and why is it exact?" — "In a causal decoder, token 's hidden states depend only on tokens up to , so its keys and values never change. Caching them turns each decode step from into , for stored numbers per token. Two details: new tokens get positions for RoPE, and the mask is aligned to the bottom-right corner."
- "MHA, GQA or MQA?" — "Fewer KV heads mean a smaller cache and less memory traffic per decode step, so faster decoding and larger batches, for a small loss in quality. GQA with 8 KV heads is the common middle ground; MQA with one KV head is the extreme."
- "Why RoPE?" — "It puts relative position into the score with no parameters, because rotations satisfy . Each key is rotated once by its absolute position, so it fits the cache. For longer contexts you can interpolate positions or raise the base."
- "Top-k or top-p?" — "Top-p adapts to the shape of the distribution: one token when the model is sure, many when it is not. Top-k keeps a fixed number, which is too many in the first case and too few in the second. I apply temperature first, then the filters, and renormalize."
Typical follow-ups. "Prompts of different lengths in one batch?" (pad on the left, take
positions from the attention mask with cumsum - 1, mask the pad keys, and make sure no query row
is fully masked). "Why pre-norm?" (the residual path is never normalized, so deep stacks train
stably without a long warmup). "Beam search with a KV cache?" (gather the cache rows by parent beam
after each step). "How does speculative decoding stay exact?" (a draft token from the draft
distribution is accepted with probability ; after a rejection, sample from
renormalized; the output distribution is exactly the target ).
10. Practice #
The notebook for this lesson runs in Colab, in NumPy. Each exercise is tested against a reference:
mha_forward: causal multi-head attention, checked against a loop over heads in float64, with a no-look-ahead test and a test that catches the scale.rms_norm: checked against the formula and against LayerNorm of the centered input.apply_rope: interleaved RoPE with apositionsargument; the relative-position property is checked numerically.decode_step: one decode step of a tiny 2-layer decoder with a KV cache; every step must equal the full forward pass.top_p_filterandbeam_search: the nucleus on a batch of rows, and beam search checked against the hand-solved example and a brute force.
Timed practice (25–30 minutes each, talking aloud): "implement causal multi-head attention"
(also on Deep-ML: Implement Multi-Head Attention, Implement Masked Self-Attention); "implement
LayerNorm for sequence data"; "add RoPE with a positions argument"; "add a KV cache with GQA and
generate"; "implement top-p sampling for a batch"; "implement beam search given a
step(tokens, cache) -> logits function". Then run the CS336 assignment 1 tests for RMSNorm, RoPE,
scaled dot-product attention and multi-head self-attention with RoPE on your code.
Say it aloud drill. Without notes, in 2 minutes: the shape of every tensor in the attention forward pass. Then in 1 minute: why cached decoding is exact, and the two rules (positions and mask) that follow from the proof.
11. Watch and read #
- Stanford CS336 (Spring 2025), Lecture 3: Architectures, hyperparameters (video on YouTube; embedding is disabled): pre-norm, RMSNorm, RoPE, GQA and the choices of current LLMs.
- Stanford CS336, assignment 1 handout and tests: RMSNorm, RoPE, softmax and scaled dot-product attention (the boolean mask convention), causal multi-head self-attention.
- PyTorch,
scaled_dot_product_attention: the reference implementation, the mask convention,is_causal,enable_gqa. - Sebastian Raschka, Understanding and Coding the KV Cache in LLMs from Scratch.
- Vaswani et al. (2017), Attention Is All You Need, §3.2 (scaled dot-product and multi-head attention).
- Su et al. (2021), RoFormer: Enhanced Transformer with Rotary Position Embedding, §3.4.
- Zhang and Sennrich (2019), Root Mean Square Layer Normalization.
- Ainslie et al. (2023), GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints.
- Holtzman et al. (2020), The Curious Case of Neural Text Degeneration, §3 (nucleus sampling, top-k, temperature).
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.