llm_from_scratch / lessons/07 · transformer blocklesson 8 / 16

Part III — The GPT

The transformer block — norm, GELU, residuals

Lesson 06 finished attention: a causal, multi-head layer that lets each token gather information from every earlier token, in several views at once. But look closely at what it does — and does not — do. It mixes vectors across positions with weighted sums; it never pushes a single token's vector through a nonlinear transform of its own, and if you naively stack a dozen of these layers the whole thing refuses to train. This lesson wraps attention in the three pieces that fix both problems and turn it into a unit you can stack: a feed-forward MLP, LayerNorm, and residual shortcuts.

What the last step left broken
Attention is entirely linear per token: every output vector is a weighted average of value vectors, and averaging can never introduce the nonlinear, per-position computation a language model needs (to decide, feature by feature, "given this blended context, what should this position now represent?"). Worse, depth itself is hostile. Stack many layers and two things go wrong at once: the scale of the activations drifts — growing or shrinking layer by layer until the numbers are unusable — and the gradient that has to travel back to the earliest layers shrinks toward zero, so those layers never learn. A raw deep stack of attention layers is not just weak; it is untrainable.
Linear position
Forced by: attention (06) only mixes information across positions with weighted averages — it does no per-position nonlinear work, and a naive deep stack of it neither holds its activation scale nor passes gradient back to its early layers.
New idea: wrap attention into a reusable block. A feed-forward MLP (d → 4d → GELU → d) does the nonlinear, per-position processing; LayerNorm standardizes each token vector so activations cannot drift; and residual shortcuts (x = x + sublayer(LN(x))) leave a clean identity path so gradients reach the bottom. The block maps (B,T,d) → (B,T,d) unchanged.
Forces next: one block is shallow, and it still outputs vectors, not text. Lesson 08 stacks L of these blocks, caps them with a final norm and a head that returns to the vocabulary, and runs the generation loop for real.
The plan
Four moves. (1) Add the per-position MLP attention lacks — and derive why it widens to 4d, why GELU rather than ReLU, and why it holds two-thirds of the block's parameters. (2) See depth break in two ways and fix each: LayerNorm for drifting activations, residual shortcuts for vanishing gradients. (3) Settle where the norm goes — inside the branch (pre-norm), and why that trains without warmup. (4) Assemble the full block as two pre-norm residual sublayers, then drive a depth stack to watch it live or die.

1 · The piece attention is missing: a per-position MLP

Re-read the attention output from lesson 06 with a skeptical eye. For each position it returns zi = Σj αij vj — a weighted average of value vectors. Averaging is a linear operation. Stack two attention layers and, between the softmaxes, you are still only ever taking linear combinations of linear projections; there is no place where a token's own vector is bent through a nonlinearity. Attention answers "whom should I listen to?" brilliantly, but it never answers "now that I have listened, what should I compute?" That second question is per-position work, and it needs its own module.

The module is a small two-layer neural network — a feed-forward MLP — applied to each token vector independently and identically. It expands the vector to a wider hidden space, applies a nonlinearity, and contracts it back:

FFN(x) = W2 · GELU(W1 x + b1) + b2     where   W1 : (d → 4d),   W2 : (4d → d)
class FeedForward:            # applied to every position, same weights
    W1 = Linear(d,   4*d)     # expand:  768 -> 3072
    act = GELU()              # the nonlinearity
    W2 = Linear(4*d, d)       # contract: 3072 -> 768
    forward(x) = W2(act(W1(x)))

Why widen to 4d?

The expansion is where the nonlinear computation happens. A nonlinearity applied directly at width d can only carve d features; projecting up to 4d first gives GELU a much larger space to bend, letting the layer represent far richer per-position functions before compressing the result back down to the residual width. The factor is the convention GPT-2 and most transformers use — big enough to add real capacity, small enough to stay affordable. The contraction back to d is mandatory, not optional: the block must return a (B,T,d) tensor of the same shape it received, or it could not be stacked (§4).

Why GELU, not ReLU?

The classic choice is ReLU — max(0, x) — which hard-clips every negative input to exactly zero. That has a nasty side effect: any neuron pushed slightly negative gets a gradient of exactly zero and stops learning (a "dead" unit), and the function has a sharp corner at the origin that makes optimization bumpier. GELU (Gaussian Error Linear Unit) is a smooth gate instead: it multiplies the input by the probability that a standard-normal draw falls below it,

GELU(x) = x · Φ(x)  ≈  0.5 · x · (1 + tanh[ √(2/π) · (x + 0.044715 · x³) ])

where Φ is the standard-normal CDF. (GPT-2 was trained with exactly this tanh approximation, so we match it.) The payoff is a curve that is smooth everywhere and, crucially, has a small nonzero gradient for almost all negative inputs — it only fully zeroes out near x ≈ −0.75. So a neuron nudged negative can still recover and contribute, and the smooth shape gives the optimizer cleaner slopes to follow. It is one of the small, quiet changes that make deep transformers train more reliably than they would with ReLU.

The MLP is where the model remembers
Count the weights. Attention's four projections (Wq, Wk, Wv, Wo), each d×d, total 4d². The MLP's two matrices are d×4d and 4d×d, totalling 8d²twice attention, about two-thirds of every block. Attention is the cheap router that decides what to read; the fat MLP is where most of the block's learned knowledge actually lives — a large per-position lookup that turns "here is my blended context" into "here is what I now am." When people say a transformer stores facts in its feed-forward layers, this 8d² is what they mean.

2 · Depth breaks two ways

Now try to make the network deep — which we must, since one block is far too shallow to model language. Two independent failures appear, and each has its own fix.

Failure A: activations drift → LayerNorm

Every sublayer transforms its input, and the statistics of the output almost never match the input. Feed a layer a batch of vectors with some mean and variance and its outputs land at a different mean and variance; the next layer shifts them again. Over a dozen layers the scale can snowball toward huge values or collapse toward zero, and softmax, GELU, and the gradients all misbehave outside their healthy range. The training dynamics become unstable and the loss struggles to fall.

The fix is LayerNorm: before a sublayer acts, standardize each token vector across its own feature dimension so it has mean 0 and variance 1, then re-scale and shift it with two learned vectors γ (scale) and β (shift):

LN(x) = γ ⊙ (x − μ) / √(σ² + ε) + β     μ, σ² computed over the d features of that one token
class LayerNorm:
    gamma = ones(d)                          # learned scale
    beta  = zeros(d)                         # learned shift
    eps   = 1e-5                             # keeps the denom finite
    forward(x):
        mu  = mean(x, axis=features)         # per-token, over the d dims
        var = var(x,  axis=features)         # biased (÷ n), GPT-2 style
        return gamma * (x - mu)/sqrt(var+eps) + beta

Three details earn their place. Normalization is over the feature axis (each token independently), not the batch axis — so it behaves identically whether the batch has 1 sequence or 1,000, which is exactly what a language model needs. The ε guards against dividing by zero when a vector is nearly constant. And γ, β restore expressive freedom: forcing every vector to unit variance would be too rigid, so the model is allowed to learn the scale and offset that actually help — including, if it wants, to partly undo the normalization. (We use the biased variance, dividing by n rather than n−1, to match GPT-2's implementation so pretrained weights drop in cleanly later.)

Failure B: gradients vanish → residual shortcuts

The second failure is about the backward pass. In a plain deep stack, the gradient reaching layer is a long product of the per-layer Jacobians above it. If those factors are even slightly below 1 on average, their product shrinks geometrically, and by the time the signal reaches the first few layers it is effectively zero — the vanishing gradient problem. The book makes this vivid with a five-layer toy net: without shortcuts the mean gradient at the input layer is about 0.0002, four orders of magnitude smaller than at the top; the early layers simply do not learn.

The fix is the residual (shortcut / skip) connection: instead of replacing the input with the sublayer's output, add the sublayer's output to the input.

xout = x + f(x)    (instead of  xout = f(x))

Each sublayer now computes only a correction to the running vector — the "residual stream" that flows straight up the network. The backward consequence is the whole point: differentiating x + f(x) gives 1 + f'(x), so there is always a clean +1 term — an identity highway — through which gradient flows to the bottom undiminished, no matter how small f' becomes. Add shortcuts to that same toy net and the input-layer gradient jumps from 0.0002 to around 0.22: the early layers come alive. Residual connections are what make depth trainable at all.

The trap
The shortcut only works if the correction has the same shape as the vector it is added to — which is exactly why the MLP must contract back from 4d to d, and why every sublayer in the block preserves the width d. Break the shape and you cannot add the residual; keep it and the identity path is free.

3 · Where the norm goes: pre-norm vs post-norm

LayerNorm and the residual can be wired in two orders, and the choice matters more than it looks. The original 2017 transformer put the norm after the sublayer and the addition — post-norm: x = LN(x + f(x)). GPT-2 and essentially every modern LLM put the norm inside the branch, before the sublayer, leaving the residual addition un-normalized — pre-norm:

post-norm:   x = LN( x + f(x) )      pre-norm:   x = x + f( LN(x) )

The difference is what sits on the residual highway. In pre-norm the raw x passes to the next layer completely untouched — the identity path is truly clean, so the +1 gradient term survives all the way down. In post-norm every addition is immediately squashed by a LayerNorm, which repeatedly rescales the residual stream and re-introduces the shrinking-product problem the shortcut was meant to solve. The practical result: post-norm transformers need a careful learning-rate warmup and are finicky to train deep, while pre-norm transformers train stably from step one without warmup. That is why we normalize inside the branch. Everything below uses pre-norm.

4 · The full block

Assemble the pieces. A transformer block is two pre-norm residual sublayers stacked in series: first causal multi-head attention (the cross-position mixer from 06), then the feed-forward MLP (the per-position processor from §1). A dropout sits on each branch for regularization. Each sublayer follows the identical template x = x + dropout(sublayer(LN(x))):

class TransformerBlock:
    attn  = CausalMultiHeadAttention(d, heads, ...)   # from lesson 06
    ff    = FeedForward(d)                             # from section 1
    norm1 = LayerNorm(d);  norm2 = LayerNorm(d)
    drop  = Dropout(p)

    forward(x):                       # x : (B, T, d)
        x = x + drop(attn(norm1(x)))  # sublayer 1: mix across positions
        x = x + drop(ff(  norm2(x)))  # sublayer 2: transform per position
        return x                      # -> (B, T, d), same shape
x (B, T, d) — input embeddings LayerNorm 1 Masked multi-head attn + shortcut LayerNorm 2 Feed forward d→4d · GELU · 4d→d + shortcut → (B, T, d) — same shape, stackable

The signature is the headline: (B, T, d) → (B, T, d). The block re-encodes each token's vector to fold in context and per-position computation, but it does not change the sequence length or the width. That invariance is exactly what makes the block a Lego brick — because the output is shape-compatible with the input, you can feed a block's output straight into another block, and another, with nothing to adjust between them. GPT-2 small stacks this block 12 times; the 1.5-billion-parameter GPT-2 stacks the same brick 48 times. Building the block right is building the whole model minus its two end-caps, which is precisely lesson 08's job.

5 · Watch depth live or die

The widget runs a deterministic deep stack of L toy layers and tracks two numbers as the signal moves up: the activation magnitude at each layer (forward pass) and a proxy for the gradient reaching layer 0 (backward pass). Each toy layer multiplies by a fixed factor slightly off from 1 and adds a fixed nonlinearity — no randomness, just the geometric-product effect that real depth suffers. Toggle residual and LayerNorm independently. With neither, watch the activation curve explode or vanish and the layer-0 gradient collapse to ~0 (the stack is dead). Turn on LayerNorm and the forward curve flattens; turn on residual and the gradient reaches the bottom. With both — the real block — the stack stays healthy at any depth. The bar underneath shows the per-block parameter split: attention 4d² versus MLP 8d².

Block stability lab
Proves that LayerNorm + residual are what make depth trainable: remove them and a deep stack's activations blow up or vanish and no gradient reaches layer 0. Add them and the stack stays flat at any depth. The bar shows why the MLP (8d²) holds two-thirds of a block's parameters.
activation norm @ layer L
gradient reaching layer 0
trainable?
params / block (d=768)

Failure modes & checklist

Failure modes

  • No residual connections. Gradient to early layers vanishes geometrically; the bottom of the stack never learns. Signal: deeper models train no better — or worse — than shallow ones, and layer-0 gradients are ~0.
  • No normalization. Activation scale drifts up or down layer by layer until softmax/GELU saturate; loss is unstable or stuck.
  • Post-norm without warmup. Normalizing after the addition squashes the residual highway and destabilizes deep training; pre-norm avoids it.
  • MLP that doesn't contract to d. The residual add fails (shape mismatch) and the block can't be stacked.
  • Normalizing over the batch axis. LayerNorm must reduce over features per token, or behavior changes with batch size.

Checklist

  • FeedForward is d → 4d → GELU → d (expand, nonlinearity, contract).
  • LayerNorm reduces over the feature dim, with learned γ, β and an ε.
  • Every sublayer is wrapped as x = x + sublayer(LN(x)) (pre-norm residual).
  • Two sublayers per block: causal MHA first, then the MLP.
  • Output shape equals input shape, (B,T,d) — the block stacks unchanged.

Checkpoint

Try it
Set depth L to 48 (GPT-2 XL's depth) and turn both toggles OFF. Watch the activation-norm curve run away (or crash) and the "gradient reaching layer 0" read essentially 0 — the stack is untrainable. Now turn LayerNorm ON only: the forward curve flattens, but the layer-0 gradient is still tiny. Turn residual ON too: the gradient climbs back to a healthy value and "trainable?" flips to yes. You have just isolated the two independent failures of depth and seen that each fix cures exactly one. Which of the two — norm or residual — is the one that repairs the backward pass?

Where this points next

You now hold the transformer block: a self-contained unit that mixes context (attention), transforms each position (the GELU MLP), and — thanks to LayerNorm and residual shortcuts wired pre-norm — stays stable and trainable no matter how deep the stack. But a single block is shallow, and its output is still a tensor of d-dimensional vectors, not a next-token prediction. Two things are missing: depth (stack L blocks) and a way back to words (a final norm and a linear head that maps each vector to V logits over the vocabulary). Add those and run the autoregressive loop from lesson 00 for real. That is lesson 08, Assembling GPT & generating text, where the brick becomes a model.

Takeaway
A transformer block wraps attention into a stackable unit by adding what attention lacks. A feed-forward MLP (d → 4d → GELU → d) does the per-position nonlinear work attention's weighted averages cannot, and it holds ~two-thirds of the block's parameters (8d² vs attention's 4d²) — the model's main store of knowledge. Depth then breaks two independent ways and each has one fix: activations drift, cured by LayerNorm (standardize each token's features to mean 0/var 1, then learned γ, β); gradients vanish, cured by residual shortcuts (x = x + f(x), whose +1 derivative is a clean gradient highway). Wire the norm inside the branch — pre-norm, x = x + sublayer(LN(x)) — so the residual path stays clean and the model trains without warmup. The finished block is two such sublayers (causal MHA, then MLP) and maps (B,T,d) → (B,T,d), so it stacks.

Interview prompts

Companion reads: cs336 · 02 The Transformer derives pre-norm and counts the block's parameters and FLOPs in detail; gpt_mini · 01 Architecture shows this exact block as runnable code.