cs336 / lessons/02 · transformerlesson 3 / 20

Part I — Basics

The Transformer, from scratch

Tokenization (01) handed you a stream of integer ids and a number — the compression ratio that turns bytes into ≈ bytes / 4 tokens. That is all it produced: a sequence of ids and the certainty that something must now turn each prefix into a prediction of the next id. This lesson builds that something — the decoder-only Transformer — shape by shape, deriving every constant (why √d_head, why 4d, why pre-norm) rather than asserting it, and ends by counting its parameters and FLOPs so the budget chain from 00 has its first concrete number to spend.

The previous step left this broken
You have integer sequences and nothing that consumes them. The task is sharp: build a function that, in one parallel forward pass over a length-T sequence, emits p(xt | x<t) for every position t at once, is differentiable end-to-end so gradient descent can lower the joint log-likelihood, and never lets position t peek at tokens > t (or training would be cheating and inference impossible). An RNN does this serially — one step per token, no parallelism, vanishing gradients at depth. We need the parallel, differentiable version.
Linear position
Forced by: a stream of token ids needs a parallel, differentiable next-token predictor that respects causality.
New idea: the decoder-only Transformer — a residual stream of shape (B, T, d) refined by L identical blocks of (causal self-attention + MLP), wrapped by a tied embedding / un-embedding and trained by cross-entropy. Attention mixes information across positions; the MLP transforms features at each position; residuals + normalization make the stack trainable at depth.
Forces next: this vanilla GPT-2-style block trains, but at modern depth and scale the residual stream's scale grows with depth and big runs spike, the block is parameter-wasteful (LayerNorm bias, plain GELU MLP), can't extrapolate context (learned absolute positions), and carries a fat KV cache — the modern recipe (03) fixes all five at ≈ 0 cost.
The plan
Six moves. (1) Token + position embeddings open the residual stream (B, T, d). (2) Self-attention: Q,K,V; scores = QKᵀ/√d_head and why that scale; causal mask; multi-head split/merge. (3) The MLP d → 4d → d and what it is for. (4) Pre-norm residuals x = x + sublayer(LN(x)) and why they make depth trainable. (5) Weight tying, the LM head, cross-entropy = next-token NLL. (6) Count parameters (≈ 12·L·d² + embeddings) and preview the 2N/token FLOP rule. Then drive the shape & param explorer.

1 · Embeddings and the residual stream

Notation, fixed once: B = batch, T = sequence length in tokens, V = vocab size (50,257 for GPT-2), d = model width (the residual stream), h = number of heads, d_head = d / h per-head width, L = number of blocks.

An autoregressive LM factors the joint distribution as a product of conditionals, p(x1…xT) = ∏t p(xt | x<t), and trains by minimizing the summed per-token negative log-likelihood. That is the whole objective. Every shape below exists to compute all T of those conditionals in one differentiable pass.

The input is idx of shape (B, T), int64 ids. Two lookup tables turn ids into vectors: a token table Wte of shape (V, d) and an absolute-position table Wpe of shape (Tmax, d). We add them, not concatenate:

tok = W_te[idx]          # (B, T, d)  — what token is here
pos = W_pe[:T]           #    (T, d)  — where in the sequence (broadcast over B)
x   = tok + pos          # (B, T, d)  — the residual stream is born

Addition keeps the width at d (concatenation would force every downstream matrix wider for no measured gain), and any linear layer can still read off either component since a projection of a sum is the sum of projections. That tensor x of shape (B, T, d) is the residual stream: every block reads it, computes a correction, and adds the correction back. Its shape never changes from here to the final norm — the entire model is a sequence of (B,T,d) → (B,T,d) refinements. (GPT-2 uses a learned position table, which caps usable length at Tmax and cannot extrapolate; lesson 3 replaces it with RoPE.)

2 · Self-attention, shape by shape

Attention is the only operation in the block that moves information between positions. Each position emits three vectors: a query (what am I looking for?), a key (what do I offer?), and a value (what I'll hand over if matched). One fused linear produces all three at once — one GEMM, one read of x from memory, instead of three:

x (B, T, d) qkv (1 Linear) (B, T, 3d) q, k, v (chunk) each (B, T, d) split heads (B, h, T, d_head) QKᵀ / √d_head (B, h, T, T) + causal mask, softmax (B, h, T, T) attn · V (B, h, T, d_head) merge heads (B, T, d) out proj (d → d) (B, T, d) Each label is the new shape after that step. Reading order: top-left → top-right, wraps left, ends bottom-left.
qkv = x @ W_qkv                              # (B, T, 3d)
q, k, v = qkv.chunk(3, dim=-1)               # each (B, T, d)
# reshape (B,T,d) -> (B,T,h,d_head) -> transpose -> (B,h,T,d_head)
q, k, v = [t.view(B,T,h,d_head).transpose(1,2) for t in (q,k,v)]
scores  = (q @ k.transpose(-2,-1)) / d_head**0.5   # (B, h, T, T)
scores  = scores.masked_fill(causal_mask[:T,:T]==0, float('-inf'))
attn    = softmax(scores, dim=-1)            # (B, h, T, T)
out     = attn @ v                           # (B, h, T, d_head)
out     = out.transpose(1,2).reshape(B,T,d)  # merge heads -> (B, T, d)
out     = out @ W_o                          # output projection (B, T, d)

Why divide by √d_head

Take the entries of q and k to be roughly i.i.d. with mean 0 and variance 1. A single score is a dot product of two d_head-vectors: a sum of d_head independent products, each of variance ≈ 1. Variance of the sum is d_head; its standard deviation is √d_head. So unscaled logits grow like √d_head. Feed those into softmax and the largest logit dominates exponentially — softmax saturates toward one-hot, and a one-hot softmax has a near-zero Jacobian, so the gradient vanishes and the layer stops learning. Dividing by √d_head pins the score variance back to ≈ 1 regardless of head width, keeping softmax in its informative regime.

Worked: GPT-2 small has d = 768, h = 12 ⇒ d_head = 64, so unscaled std ≈ √64 = 8. A logit 8 above its neighbors gives e⁸ ≈ 3,000× the mass — essentially one-hot before training even starts. With /√64 the std is 1 and attention can actually learn where to look. This is one of the rare constants the architecture derives rather than tunes.

The causal mask

The mask is forced, not stylistic. The autoregressive factorization p(x1,…,xT) = ∏t p(xt | x<t) says the predicted distribution at position t is conditioned only on tokens before t. To train all T−1 of those predictions in one parallel pass, position t's output must depend only on positions < t: if it could attend to t+1,…,T, the next-token target would leak straight into the input and the model would learn the trivial copy. Zeroing attention from t to every j > t — the causal mask — is therefore the exact, forced mechanism that makes the parallel forward pass compute the autoregressive factorization honestly.

Concretely the mask is a lower-triangular T × T matrix; entries above the diagonal are set to −∞ before softmax, so their probability is exactly 0. Position t attends only to keys j ≤ t. This is precisely what makes "one forward pass = T−1 honest supervised targets" true: the prediction at t never sees the answer at t+1. Drop the mask and train loss crashes to zero (the model just copies the next token) while inference — where future tokens do not exist — is impossible.

Multi-head split and merge

Why slice d into h heads of width d_head = d/h instead of one big head? A single head computes one similarity geometry — one notion of "relevant." Splitting gives h independent attention patterns (interpretability work finds previous-token heads, induction heads, copy heads emerging on their own), recombined by the output projection Wo. The reshape (B,T,d) → (B,T,h,d_head) → (B,h,T,d_head) is a free view + transpose: the matmul then batches over the leading (B,h) dims and multiplies the trailing (T, d_head) matrices. Total FLOPs are unchanged because the work scales with the full width d; splitting only rearranges where the bytes sit. So you get h attention patterns for free.

One cost the diagram makes visible: the score tensor is (B, h, T, T) — quadratic in T. Materializing it is fine at T = 1,024 but becomes the memory wall at long context, which is the entire reason FlashAttention exists (see the kernel view linked below).

3 · The MLP: d → 4d → d

Attention moved information between positions; it did not transform it much (it is mostly weighted averaging plus a linear projection). The per-position MLP does the heavy nonlinear feature work, applied identically and independently at every t:

h1 = GELU(x @ W_1)   # W_1: (d, 4d)   ->  (B, T, 4d)   expand
y  = h1 @ W_2        # W_2: (4d, d)   ->  (B, T,  d)   contract

It expands the width 4× into a hidden space, applies a pointwise nonlinearity, and contracts back. GELU is a smooth, non-monotonic gate (x · Φ(x), close to SiLU/Swish) — not simply a rounded ReLU (Φ = standard-normal CDF; it actually dips slightly negative for small negative inputs). The smoothness gives a small nonzero gradient for slightly-negative inputs and trains marginally better than ReLU at negligible cost. The 4× ratio is the historical Transformer width: wide enough to give the nonlinearity room, narrow enough not to swamp the budget. Conceptually the MLP is the block's memory — the place that stores "if the features look like X, write feature Y into the stream" associations. It also dominates the parameters: per block attention holds 4d² (QKV is 3d², output proj ) while the MLP holds 8d² (two d × 4d matrices) — about 2/3 of every block's weights live in the MLP, a fact people routinely underestimate.

4 · Pre-norm residuals make depth trainable

A block is two sublayers, each wrapped in a residual add with the normalization placed inside the branch:

x = x + Attn(LN(x))      # pre-norm
x = x + MLP (LN(x))      # pre-norm

Two mechanisms are doing the work, and you need both to train a deep stack.

Residual add
Each sublayer outputs a correction added to the stream, not a replacement. The backward path has a direct +1 shortcut through every block, so gradients reach layer 0 undiluted — no vanishing-gradient collapse at depth L = 32, 64, 96.
LayerNorm
Standardize each token vector to mean 0, variance 1, then rescale by learned γ (and shift β): LN(x) = (x − μ)/√(σ² + ε) · γ + β. Keeps each sublayer's input in a stable numeric range regardless of how the stream's scale drifts with depth.
Pre vs post
Pre-norm normalizes the input to the sublayer (x + f(LN(x))); the original post-norm normalized the output of the add (LN(x + f(x))), stacking L norms in series on the gradient path → spikes that need LR warmup.
Why pre won
In pre-norm the residual variance grows at most linearly in L and a final norm cleans up before the head; the clean skip path means it trains stably without warmup. Every modern LM is pre-norm.

Concretely: post-norm routes the backward gradient through L LayerNorm Jacobians in series, so small errors compound and the run spikes unless you baby it with warmup. Pre-norm leaves a clean additive skip path open, so the gradient at layer 0 is the gradient at layer L plus well-behaved corrections. The cost of switching is exactly zero parameters and zero FLOPs, and it removes a hyperparameter — the kind of free win you always take. After the last block a final LN normalizes the stream before the head.

5 · Weight tying, the LM head, and the loss

The head turns each d-vector into a distribution over the vocabulary by multiplying by a (d, V) matrix. Instead of a fresh matrix, tie it to the embedding: the head is Wte.

x      = LN_final(x)        # (B, T, d)
logits = x @ W_te.T         # (B, T, V)   — head shares storage with the embedding
loss   = cross_entropy(logits[:, :-1], idx[:, 1:])   # scalar

Tying is sound because input and output spaces are the same vocabulary: "the vector for token v" should mean the same thing whether you read v in or predict v out. It saves V·d parameters (for GPT-2: 50,257 × 768 ≈ 38.6M, a large share of the small model), gives the embedding rows far more gradient updates, and empirically improves perplexity.

The loss is cross-entropy of logits at positions [0..T−2] against targets at [1..T−1] — i.e. each position predicts the next token. Cross-entropy of a one-hot target is exactly the negative log-likelihood of the true next token, −log pθ(xt+1 | x≤t); minimizing the mean over all B·T positions is maximum-likelihood training of the joint distribution. There is no separate objective — the entire model exists to make this one number small.

6 · Parameter count and the FLOP preview

Count per block, then scale by L and add the embeddings. The O(d) bias/norm terms are negligible at scale, so:

componentparamsnote
token embedding WteV·dtied → also the head
position embedding WpeTmax·dlearned absolute
per block — QKV3d²fused linear d → 3d
per block — output projattention subtotal 4d²
per block — MLP W1, W24d² + 4d² = 8d²two d × 4d matrices
per block — total12d² + O(d)attn 4d² + MLP 8d²
LM head0tied to Wte
N ≈ 12·L·d²  +  V·d  +  Tmax·d

The block term is quadratic in d and linear in L; the embeddings are only linear in d. So at any real scale the model is "all blocks." Check it on GPT-2 small (L=12, d=768, V=50,257, Tmax=1,024): blocks = 12 · 12 · 768² ≈ 85M; embeddings ≈ (50,257 + 1,024) · 768 ≈ 39M; total ≈ 124M — exactly GPT-2 small's published count.

The FLOP preview, which the whole track turns on: a matmul of shapes (·, in) × (in, out) costs 2·in·out FLOPs per row (one multiply + one add per element). Summed over the model's weight matrices, a forward pass costs ≈ 2N FLOPs per token; the backward pass costs about twice the forward, so training is ≈ 6N per token. Over D training tokens that is the budget identity from lesson 0: C ≈ 6·N·D. (This counts the dense linears and ignores attention's O(T²) term, which is a rounding error until T approaches d — lesson 5 makes the full accounting precise.)

7 · Drive the shapes and the budget

Move the sliders. The left column prints every intermediate tensor shape for one forward pass; the right column breaks the parameter count into emb / attn-per-block / mlp-per-block, multiplies the block by L, ties the head to 0, and reports total N plus the 2N forward and 6N train FLOPs per token. Push d up and watch the 12Ld² block term swamp the embeddings; push L up and watch N — and the FLOPs you'll pay per token — climb linearly. Dragging T resizes every activation (watch the score tensor (B,h,T,T) balloon) but leaves N untouched: the position table Wpe is (Tmax, d), fixed at the model's max context (1,024 here), so it never grows with the runtime sequence length. The constraint d mod h = 0 is enforced (changing d snaps h to a divisor).

Shape & param explorer
Proves two things: (1) the residual stream is (B,T,d) end-to-end while the score tensor balloons to (B,h,T,T); (2) parameters are ≈ 12·L·d² + embeddings, FLOPs are 2N (fwd) / 6N (train) per token — the budget the rest of the course spends. Emb shapes are blue, attention orange, MLP green, head purple.
d_head = d/h
64
Total params N
Fwd FLOPs / token (2N)
Train FLOPs / token (6N)
Forward-pass shapes (one block expanded)

    
Parameter breakdown

    

Failure modes & checklist

Failure modes

  • Dropping the √d_head scale. Logits grow like √d_head, softmax saturates to one-hot, gradients vanish. Signal: loss flatlines near init; attention maps are spiky and frozen from step 1.
  • Forgetting the causal mask (or off-by-one in it). Positions attend to the future. Signal: train loss crashes implausibly low (near 0) while generation is incoherent — the model learned to copy the next token.
  • Shipping post-norm at depth without warmup. Gradient passes through L norms in series. Signal: loss spikes / diverges early at L > ~16; "fixed" only by a fragile warmup.
  • Counting embeddings as the bulk of params. Reasoning about cost from V·d instead of 12Ld². Signal: your FLOP/memory estimates are off by 2–3× at any real scale.
  • Missing .contiguous() before reshape on a transposed tensor. Signal: a runtime stride error, or silently wrong head-merge if you bypass it with the wrong view.

Checklist

  • Residual stream is (B,T,d) in and out of every block — verify by printing shapes.
  • Scores divided by √d_head; mask applied before softmax with −∞.
  • Pre-norm: x = x + sublayer(LN(x)), plus one final norm before the head.
  • Head tied to Wte; loss = cross-entropy of logits[:,:-1] vs idx[:,1:].
  • Sanity-check N ≈ 12Ld² + V·d against your framework's parameter count before launching.

Checkpoint

Try it
Open the widget and set GPT-2 small: d=768, h=12, L=12, V=50,257, T=1,024. Confirm N ≈ 124M and d_head = 64. Now read the forward FLOPs/token (≈ 248M) and compute the cost of one full training run on D = 300B tokens by hand: C ≈ 6·N·D ≈ 6 · 124e6 · 3e11 ≈ 2.2e26? Recheck — that's too big; 6 · 1.24e8 · 3e11 = 2.2e20 FLOPs. Then double d to 1,536 and watch the block term ∝ d² quadruple while total N rises only ~3.4× (the V·d / Tmax·d embeddings merely double) — doubling L instead doubles N exactly. Confirm which knob is the expensive one, and why the course spends so much effort on width vs depth.

Where this points next

You now have a Transformer that trains and a parameter/FLOP count to plan with. But this is the 2019 block. Push it to modern depth and scale and five cracks open at once: learned absolute positions cap and cannot extrapolate context; LayerNorm's mean-centering and bias are dead weight; plain GELU MLPs are wasteful per parameter; the full multi-head KV is a fat cache you'll pay for at inference; and the residual stream's scale grows with depth so big runs spike and lean on warmup. Each is a real efficiency or stability leak under a fixed budget. Lesson 03, The modern recipe — what changed since GPT-2, swaps in RMSNorm, RoPE, SwiGLU, GQA, and bias-free linears with QK-norm — five changes that each buy stability or efficiency at essentially zero cost.

Takeaway
The decoder-only Transformer is forced, not arbitrary: integer ids demand a parallel, differentiable, causal next-token predictor, and this is the minimal machine that is one. A residual stream (B,T,d) is refined by L blocks; attention mixes information across positions (Q,K,V; scores = QKᵀ/√d_head with the scale derived to keep softmax unsaturated; a causal mask to keep the T−1 targets honest; h heads for h free attention patterns); the MLP (d→4d→d, GELU, ≈2/3 of the weights) does the per-position nonlinear work; pre-norm residuals (x = x + sublayer(LN(x))) keep the gradient path clean so depth is trainable; a tied head + cross-entropy make training plain maximum-likelihood. It costs N ≈ 12·L·d² + V·d parameters (attn 4d² + MLP 8d² per block) and 2N FLOPs/token forward, 6N training — the C ≈ 6ND budget from lesson 0, now with its first concrete number.

Interview prompts

Companion reads: gpt_mini · 01 Architecture walks the same block line-by-line as runnable code; gpu_kernels · 11 Attention kernel pipeline shows how that (B,h,T,T) score tensor is actually computed on the GPU.