Lab 01 — Attention, RoPE & a Tiny GPT Forward Pass
Phase: 02 — The Transformer From Scratch Difficulty: ⭐⭐⭐☆☆ (the algebra is small; the mechanism is the whole point) Time: 4–6 hours
Every engineer says "I use transformers." Far fewer can build one with no framework — and that gap is exactly what a senior interview probes. This lab makes you write the mechanism by hand: scaled dot-product attention, the causal mask that makes it autoregressive, multi-head attention, RoPE rotary positions, RMSNorm/LayerNorm, a SwiGLU feed-forward, a pre-norm transformer block, the full GPT forward pass (embedding → blocks → norm → LM head → logits), and — the soul of the lab — the KV-cache, proven numerically identical to a full causal recompute for the last position. Pure stdlib lists and
math; no numpy, no torch; deterministic from a seed. When generation is wrong, slow, or leaks the future, this is the muscle memory that lets you fix it.
What you build
matmul/transpose/add_vectors/add_matrices— the linear algebra a transformer is built from.add_matricesis the residual-stream opX + sublayer(X); we write the matmul triple loop so them·n·kMAC cost is visible.softmax— the numerically stable, max-subtracting softmax that turns scores into a distribution that sums to 1 and maps a masked-infto exactly zero weight.layer_norm/rms_norm— the two normalizations: LayerNorm (mean + variance + bias) and the cheaper, mean-free RMSNorm that Llama/Mistral/Gemma use.linear— the affine projectiony = x·W (+ b)used everywhere (Q/K/V/O, the MLP, the LM head).causal_mask— the[T, T]additive mask (0 on/below the diagonal,-infabove) that is autoregression: tokenimay attend tojonly ifj <= i.scaled_dot_product_attention—softmax(Q·Kᵀ / sqrt(d_k) + mask) · V: the core mechanism, including the1/sqrt(d_k)scale that keeps the softmax from saturating.multi_head_attention(with_split_heads/_concat_heads) — project, split into heads, attend per head in parallel subspaces, concat, output-project withWo.rope(with_rope_vector) — rotary position embedding: rotate each(even, odd)dim pair bypos·θ_i; preserves the norm and makes the attention dot product depend on relative offset.gelu/swiglu_ffn(with_silu) — the activations and the gateddown(silu(x·W_gate) ⊙ (x·W_up))feed-forward where ~2/3 of the parameters live.transformer_block— one pre-norm block:X += MHA(norm1(X)); X += FFN(norm2(X)).build_params/gpt_forward— deterministically build a tiny GPT from a seed, then run the full forward pass: token ids →[T, vocab]logits.attention_with_kv_cache— one incremental decode step against a key/value cache, proven equal to the full causal recompute's last row — the entire justification for the cache.
Key concepts
| Concept | What to understand |
|---|---|
softmax(Q·Kᵀ/√d_k)·V | dot product = similarity; /√d_k stops the softmax saturating; softmax → convex combination; ·V mixes value rows |
| The causal mask | additive -inf above the diagonal → exp(-inf)=0 weight → the future cannot leak into the present (autoregression) |
| Multi-head | split d_model into h subspaces of width d_head = d_model/h; each head attends to a different relationship, then concat + Wo |
| RoPE | rotate (even, odd) pairs by pos·θ_i, θ_i = base^(-2i/d); rotation preserves norm, so the QK dot product depends only on relative position |
| RMSNorm vs LayerNorm | RMSNorm drops the mean subtraction and the bias — cheaper, equally good; the modern default |
| SwiGLU FFN | gated MLP down(silu(gate) ⊙ up); the FFN holds ~2/3 of all parameters |
| Pre-norm residual stream | normalize the sublayer input, add the output back; the residual highway is why depth trains |
| KV-cache | store past K/V so decode is O(T) not O(T²); identical to full causal attention's last row |
| Numerical stability | softmax subtracts the max so [1000, 1001, 1002] does not overflow — that detail is the lesson |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
softmaxsubtracts the max and whytest_softmax_numerically_stable_large_logitswouldNaNwithout it. - You can explain why a masked (
-inf) score contributes exactly zero probability, and why that makestest_causal_attention_zero_in_upper_trianglegiveout[0] == V[0]. - You can explain what the
1/sqrt(d_k)factor does and whytest_sqrt_dk_scaling_is_presentdetects it (the unscaled distribution is sharper). - You can explain why RoPE leaves the vector norm unchanged (
test_rope_preserves_norm) and why position 0 is the identity (test_rope_position_dependent). - You can explain why
test_kv_cache_equals_full_recomputeis the soul of the lab: the last query under a causal mask attends to exactly tokens0..t, which is exactly the cache, so the incremental result must equal the full recompute's last row. - You can state where the parameters live (~2/3 in the FFN) and why GQA/MQA shrink the KV-cache.
How this maps to the real stack
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
scaled_dot_product_attention | torch.nn.functional.scaled_dot_product_attention — same softmax(QKᵀ/√d_k + mask)·V, but fused and dispatched to a fast kernel | PyTorch SDPA docs; is_causal=True for the mask |
multi_head_attention (Wq/Wk/Wv/Wo, split/concat) | torch.nn.MultiheadAttention / the attention module in nanoGPT — same project→split→attend→concat→Wo | nanoGPT model.py CausalSelfAttention; HF LlamaAttention |
causal_mask (-inf upper triangle) | the attn_mask / is_causal flag every decoder passes to SDPA; torch.triu builds it | PyTorch scaled_dot_product_attention(is_causal=True) |
rope (_rope_vector) | the RoPE in Llama/Mistral/Qwen/Gemma — apply_rotary_pos_emb, with a rope_theta base | HF LlamaRotaryEmbedding; the config's rope_theta (10000, or 1e6 for long context) |
rms_norm | torch.nn.RMSNorm / LlamaRMSNorm — the modern default over nn.LayerNorm | HF LlamaRMSNorm; the config's rms_norm_eps |
swiglu_ffn | the gated MLP in Llama/Mistral (gate_proj, up_proj, down_proj with SiLU) | HF LlamaMLP; ~2/3 of sum(p.numel() …) |
transformer_block | one decoder layer (pre-norm + residual) stacked n_layers deep | HF LlamaDecoderLayer; nanoGPT Block |
gpt_forward | model(input_ids).logits — embedding → blocks → final norm → LM head | nanoGPT GPT.forward; HF *ForCausalLM |
attention_with_kv_cache | the past_key_values / DynamicCache every decode loop carries; vLLM's PagedAttention is the block-table version of this | HF use_cache=True, past_key_values; vLLM PagedAttention |
Limits of the miniature (be honest in the interview): we apply RoPE once on the residual stream
for simplicity, whereas real models rotate Q and K inside each attention layer (the rotation
must touch the scores, not the residual); we use plain multi-head, not GQA/MQA (the lever that
actually shrinks the KV-cache in production); attention here is the naive O(T²) materialize-the-
scores form, not the IO-aware fused FlashAttention that never writes the full score matrix to
HBM; there is no training, no dropout, no batching, and tiny dims; and our matmul is a Python triple
loop, not a tensor-core GEMM. The algorithm is faithful; the systems engineering is the rest of
the curriculum (P06 quantization, P09 serving/PagedAttention, P17 kernels).
Extensions (build these on real hardware)
- Move RoPE inside the attention layer: rotate Q and K per head before the dot product, and
confirm the QK score depends only on the relative offset
(pos_q − pos_k). - Add GQA: give K/V fewer heads than Q (share K/V across query-head groups) and measure the KV-cache shrink (ties back to Phase 00's KV-cache math).
- Implement a block (tiled) softmax that computes attention without ever materializing the full
[T, T]score matrix — the core idea of FlashAttention's online softmax — and check it matchesscaled_dot_product_attentionto tolerance. - Add a greedy / temperature / top-k sampler on top of
gpt_forwardand generate a sequence token-by-token usingattention_with_kv_cache; verify it equals the prefill-then-decode path. - Port one block to PyTorch and assert your
gpt_forwardlogits matchtorch.nn.functional.scaled_dot_product_attention+nn.RMSNormto a tight tolerance.
Interview / resume
- Talking points: "Why divide attention scores by
√d_k?" "How does the causal mask make a transformer autoregressive — what exactly is-infdoing?" "Why does RoPE encode relative position, and why is that better than learned absolute embeddings?" "Where do most of the parameters live, and why is the FFN ~2/3?" "Prove the KV-cache is correct — why is it identical to recomputing?" - Resume bullet: Implemented a transformer from scratch in pure Python — scaled dot-product attention with the causal mask, multi-head attention, RoPE, RMSNorm, a SwiGLU feed-forward, the full GPT forward pass, and an incremental KV-cache proven numerically identical to the full causal recompute — mapping each piece to its PyTorch / nanoGPT / FlashAttention equivalent.