A2 — Backpropagation and AdamW (CPU)
Files: train_gpt2.c
Needs: no GPU
Builds on: A0, A1
Check with: ./grade A2
Overview
You will implement the backward pass of GPT-2 and the AdamW optimizer. By the end of this assignment, ./train_gpt2 will complete a full training loop: forward pass → loss → backward pass → parameter update → repeat, and the loss will actually go down.
Backpropagation is the chain rule applied recursively. Each backward function receives a gradient with respect to its output (dout) and must compute gradients with respect to its inputs (dinp, dweight, dbias) so those can be passed further down the graph.
This is CPU-only and requires no GPU. The CUDA AdamW kernel in llmc/adamw.cuh is part of A3, not A2.
To list your work:
grep -n "TODO (A2)" train_gpt2.c
When these notes and the in-file HINT disagree, the code is authoritative — please report the discrepancy so it can be fixed.
One complication versus a textbook treatment: gradients are accumulated, not assigned. Every backward function does += into its output buffers. This is what makes gradient accumulation across micro-steps possible in A3/A4, and it means the gradient buffers must be explicitly zeroed before each new backward pass.
Where this fits
flowchart LR
A0["A0
tokens, batches, PRNG"] --> A1["A1
forward pass (CPU)"]
A1 --> A2["A2
backward + AdamW (CPU)"]
A2 --> A3["A3
port to CUDA (1 GPU)"]
A3 --> A4["A4
scale to N GPUs"]
A3 --> A5["A5
schedules, clipping, checkpoints"]
style A2 fill:#2563eb,stroke:#1e40af,color:#fff
You are building the half that makes the network learn: the derivative of every
operator in A1, and the AdamW step that turns those derivatives into a weight
update. After this assignment ./train_gpt2 is a complete training system.
flowchart TB
subgraph fwd["forward (A1) — save what backward needs"]
direction LR
f1["inp"] --> f2["ln1, ln1_mean, ln1_rstd"] --> f3["qkv"] --> f4["att"] --> f5["fch, fch_gelu"] --> f6["logits, probs"]
end
f6 --> loss["loss = -log probs[target]"]
loss --> b1["dlogits = probs - onehot"]
subgraph bwd["backward (A2) — consume it in reverse"]
direction RL
b2["dlnf"] --> b3["dresidual3"] --> b4["dfch_gelu"] --> b5["datt"] --> b6["dqkv"] --> b7["dencoded"]
end
b1 --> b2
b7 --> upd["encoder_backward → dwte, dwpe"]
upd --> adamw["AdamW
m, v, bias correction,
decoupled weight decay"]
adamw --> params["updated weights"]
Why the forward pass saves mean, rstd, att and fch_gelu. Each
backward function needs some of what its forward counterpart computed.
Recomputing them would be correct but slow, so A1 stashed them in the activation
tensors. This is the classic memory-for-compute trade, and it is why activations
dominate training memory — A3 revisits it with a recompute flag that trades
back the other way.
Why every backward function uses +=. A value used in two places gets
gradient from both. wte is the clearest case: it feeds encoder_forward at the
bottom of the network and the classifier matmul at the top, so dwte is the
sum of two contributions computed at opposite ends of the backward pass. Writing
= silently discards one of them. The same convention is what makes gradient
accumulation over micro-batches work in A3.
Why AdamW and not SGD. Adam keeps a per-parameter running estimate of the
gradient’s mean (m) and uncentred variance (v), and divides by the square
root of the latter — so a parameter with consistently small gradients still moves.
The “W” is the fix from Loshchilov & Hutter: apply weight decay directly to the
parameter rather than adding it to the gradient, so it does not get rescaled by
v. That one-line difference is worth a measurable amount of final loss.
Background Reading
| Source | Read | Why it matters here |
|---|---|---|
| Karpathy, micrograd | engine.py, ~100 lines |
A scalar autograd engine. If the += convention in Part 2 does not feel obvious, read this first — it is the same idea with the array indexing removed. |
| Rumelhart, Hinton & Williams 1986, Learning representations by back-propagating errors | all 4 pages | The original. Short, and worth reading once for the framing of backprop as repeated application of the chain rule over a graph. |
| Kingma & Ba 2015, Adam | §2 and Algorithm 1 | The update rule, including the bias-correction terms. Algorithm 1 maps line-for-line onto what you write in Part 4. |
| Loshchilov & Hutter 2019, Decoupled Weight Decay Regularization | §2 and Algorithm 2 | Why AdamW is not Adam-plus-L2. Algorithm 2 highlights the one-line difference in green; that line is the whole point. |
| Bishop, Pattern Recognition and Machine Learning | §5.3 | Optional. A careful derivation of backpropagation if you want the maths spelled out more slowly than Rumelhart does. |
On deriving layernorm_backward yourself. It is the one gradient here that is
genuinely fiddly, because mean and rstd both depend on every element of the
row — so dinp[i] gets a contribution through inp[i] directly and through
both statistics. Working it out on paper for C = 2 first is a good use of
twenty minutes. Part 2 gives the two-reduction form the reference uses.
—
Gradient Flow Map
The backward pass traverses the computation graph in reverse:
dlosses (= 1.0f / (B*T) per position)
↓ crossentropy_softmax_backward
dlogits [B, T, Vp]
↓ matmul_backward (final classifier, weight-shared with wte)
dlnf [B, T, C], dwte += ...
↓ layernorm_backward (final layernorm)
dresidual3[L-1]
↓ (for each layer l = L-1 .. 0)
matmul_backward (fcproj: 4C→C)
gelu_backward
matmul_backward (fc: C→4C)
layernorm_backward (ln2)
residual_backward
matmul_backward (attproj: C→C)
attention_backward
matmul_backward (qkv: C→3C)
layernorm_backward (ln1)
residual_backward
↓
dencoded
↓ encoder_backward
dwte +=, dwpe +=
Part 1 — Gradient Infrastructure
gpt2_zero_grad(model)
Set every element of model->grads_memory and model->grads_acts_memory to zero, if they have been allocated. Use memset. This runs at the start of each optimizer step (in a gradient-accumulation setup, not between micro-steps).
Part 2 — Backward Functions
encoder_backward(dwte, dwpe, dout, inp, B, T, C)
dwte[inp[b*T+t], c] += dout[b, t, c] // += because multiple positions may map to the same token
dwpe[t, c] += dout[b, t, c]
layernorm_backward(dinp, dweight, dbias, dout, inp, weight, mean, rstd, B, T, C)
Let x̂[c] = (inp[c] - mean) * rstd (the normalized value before the affine transform).
dbias[c] += dout[b,t,c]
dweight[c] += dout[b,t,c] * x̂[c]
For dinp, the cleanest formulation uses two reductions over C. With dnorm[c] = dout[c] * weight[c]:
dnorm_mean = (1/C) Σ_c dnorm[c]
dnorm_norm_mean = (1/C) Σ_c dnorm[c] * x̂[c]
dinp[c] += rstd * (dnorm[c] - dnorm_mean - x̂[c] * dnorm_norm_mean)
This is algebraically the same as the mean/variance form you may derive by hand, but needs only two passes and is much less error-prone. A common bug is keeping only the first term.
matmul_backward(dinp, dweight, dbias, dout, inp, weight, B, T, C, OC)
dinp[i, c] += Σ_o dout[i, o] * weight[o, c]
dweight[o, c] += Σ_i dout[i, o] * inp[i, c]
dbias[o] += Σ_i dout[i, o] // skip when dbias == NULL
dbias is NULL for the final classifier. Parallelise the two loops separately — the dinp loop parallelises over b,t, the dweight loop over o — so that no two threads accumulate into the same location.
attention_backward(dinp, dpreatt, datt, dout, inp, att, B, T, C, NH)
Work backwards through the four steps of the forward pass. Only t2 <= t1 participates; the masked region contributes nothing.
dV[b,t2,h] += att[b,h,t1,t2] * dout[b,t1,h]anddatt[b,h,t1,t2] += Σ_c dout[b,t1,h,c] * V[b,t2,h,c]- Softmax backward:
dpreatt[b,h,t1,t2] += att[b,h,t1,t2] * (datt[b,h,t1,t2] - Σ_t att[b,h,t1,t] * datt[b,h,t1,t]) dQ[b,t1,h] += dpreatt[b,h,t1,t2] * K[b,t2,h] / sqrt(HS)anddK[b,t2,h] += dpreatt[b,h,t1,t2] * Q[b,t1,h] / sqrt(HS)
All three write back into the single dinp[B, T, 3C] buffer at the Q/K/V offsets described in A1 Part 5.
gelu_backward(dinp, inp, dout, N)
let x = inp[i]
arg = GELU_SCALING_FACTOR * (x + 0.044715 * x³)
t = tanh(arg)
sech² = 1 / cosh(arg)² (equivalently 1 - t²)
GELU'(x) = 0.5 * (1 + t) + 0.5 * x * sech² * GELU_SCALING_FACTOR * (1 + 3 * 0.044715 * x²)
dinp[i] += GELU'(x) * dout[i]
Note:
gelu_backwarduses+=, matching the accumulation convention of every other backward function here (and the fused GPU path in A3, where the residual gradient is already present indinp).
residual_backward(dinp1, dinp2, dout, N)
dinp1[i] += dout[i]
dinp2[i] += dout[i]
crossentropy_softmax_backward(dlogits, dlosses, probs, targets, B, T, V, Vp)
The fused cross-entropy + softmax backward. The gradient of loss = -log(probs[target]) through the softmax simplifies to:
dlogits[b, t, v] += dlosses[b*T+t] * (probs[b, t, v] - indicator(v == targets[b*T+t]))
Loop v only over [0, V) — the padded columns [V, Vp) must keep a zero gradient — but index with the Vp row stride.
Part 3 — Full Backward Pass
gpt2_backward(model)
- Lazily allocate
grads_memory/grads_acts_memoryon the first call. Error out ifmean_lossis-1.0f(forward was run without targets). - Initialise
dlosses[b*T+t] = 1.0f / (B*T)— the gradient of the mean loss. crossentropy_softmax_backward→dlogitsmatmul_backwardfor the classifier: accumulates intodwte(weight-shared with the embedding) anddlnflayernorm_backwardfor the final LayerNorm- Loop
l = L-1down to0, mirroring the forward order in reverse. Naming the gradient buffers by the struct field they live in (grads.for parameters,grads_acts.for activations):residual_backward→ splitsgrads_acts.residual3intograds_acts.residual2andgrads_acts.fcprojmatmul_backward→grads.fcprojw,grads.fcprojb,grads_acts.fch_gelugelu_backward→grads_acts.fchmatmul_backward→grads.fcw,grads.fcb,grads_acts.ln2layernorm_backward→grads.ln2w,grads.ln2b, and back intograds_acts.residual2residual_backward→ splits into the layer’s residual input andgrads_acts.attprojmatmul_backward→grads.attprojw,grads.attprojb,grads_acts.attyattention_backward→grads_acts.qkv(viagrads_acts.preattandgrads_acts.att)matmul_backward→grads.qkvw,grads.qkvb,grads_acts.ln1layernorm_backward→grads.ln1w,grads.ln1b, and back into the residual stream
encoder_backward→grads.wte,grads.wpe
For layer l, the “residual stream” gradient is grads_acts.residual3 of layer l-1, or grads_acts.encoded
when l == 0. Getting this indexing right is most of the work in gpt2_backward; the per-layer weight
pointers follow the same l * <tensor stride> pattern as the forward pass.
Part 4 — AdamW Optimizer
gpt2_update(model, learning_rate, beta1, beta2, eps, weight_decay, t)
Lazily allocate and zero m_memory and v_memory on the first call. Then for each parameter θ with gradient g, where t is the 1-based step count:
m ← β₁·m + (1-β₁)·g
v ← β₂·v + (1-β₂)·g²
m̂ = m / (1 - β₁ᵗ)
v̂ = v / (1 - β₂ᵗ)
θ ← θ - lr · (m̂ / (sqrt(v̂) + ε) + weight_decay · θ)
Decoupled weight decay: apply weight decay directly to the parameter, not through the gradient. This is what distinguishes AdamW from L2-regularized Adam.
The CPU implementation applies a single
weight_decayto every parameter, which is whattest_gpt2checks against. Selective decay — 2D tensors (embeddings and weight matrices) decayed, 1D tensors (biases, LayerNorm scales/shifts) not — is introduced with the CUDA optimizer in A3.
Testing
make test_gpt2
./test_gpt2
test_gpt2 runs 10 forward+backward+update steps against gpt2_124M_debug_state.bin, a fixed batch exported from PyTorch. It is not a finite-difference check — there is no (loss(θ+ε) - loss(θ-ε)) / 2ε anywhere in the harness. It compares your analytic gradients directly against PyTorch’s analytic gradients for the same inputs, which is both faster and stricter.
Shape of the output once A1 and A2 are correct:
OK (LOGITS), max_diff = <small>
LOSS OK: <yours> <expected>
dwte
OK <yours> <expected> <- first 5 elements of each tensor, printed for eyeballing
...
TENSOR OK, maxdiff = <small>
dwpe
...
TENSOR OK, maxdiff = <small>
... one block per parameter tensor, 16 in all ...
step 0: loss <...> (took <...> ms) OK = 1
...
step 9: loss <...> (took <...> ms) OK = 1
overall okay: 1
overall okay: 1 is the pass condition. Tolerances actually enforced:
| Check | Tolerance | Where |
|---|---|---|
| Logits | 1e-2 absolute |
main |
| Loss (step 0 and each of the 10 steps) | 1e-2 absolute |
main |
| Every gradient tensor, element-wise | 2e-2 absolute |
check_tensor |
The 16 gradient tensors are checked in this order: dwte, dwpe, dln1w, dln1b, dqkvw, dqkvb, dattprojw, dattprojb, dln2w, dln2b, dfcw, dfcb, dfcprojw, dfcprojb, dlnfw, dlnfb. Because the backward pass runs from the output end, the last tensors in that list (dlnfw, dlnfb) are computed first — if only dwte/dwpe are wrong, the bug is in encoder_backward; if everything is wrong, suspect the final classifier or crossentropy_softmax_backward.
The per-step losses at the bottom exercise the optimizer: they must track PyTorch’s within 1e-2 for all 10 steps, which will not happen unless AdamW’s bias correction and step counting are right.
Common Mistakes
- Forgetting the
+=convention. Every backward function accumulates. If you use=you will silently drop gradients from other paths — most visibly ondwte, which is written by both the classifier matmul andencoder_backward. - Weight sharing.
wteis used both inencoder_forwardand as the classifier head. Both backward passes must accumulate intodwte. - LayerNorm gradient. The formula needs both reductions over
C. Keeping onlyrstd * dnorm[c]gives gradients that look plausible and are wrong. - Not zeroing gradients.
test_gpt2callsgpt2_zero_gradbetween steps; if it is a no-op, step 0 passes and steps 1-9 diverge. - Off-by-one on
t. AdamW’s bias correction uses a 1-based step;test_gpt2passesstep+1. VvsVpin the classifier backward. Writing gradient into the padded columns corruptsdwterows that should stay untouched.
Checking your work
./grade A2
Runs ./test_gpt2 and maps its 16 gradient tensors onto the table below, so a
failure points at the function responsible. Lines marked (inferred) cannot be
isolated by the harness and are attributed from the tensors they feed.
Scoring
What ./grade A2 checks, and what each part is worth.
| Component | Points |
|---|---|
gpt2_zero_grad and gradient buffer management |
5 |
encoder_backward matches reference |
10 |
layernorm_backward matches reference |
15 |
matmul_backward matches reference |
10 |
attention_backward matches reference |
20 |
gelu_backward and residual_backward match reference |
10 |
crossentropy_softmax_backward matches reference |
10 |
gpt2_backward wires all backward functions correctly |
10 |
AdamW update — all 10 step losses within 1e-2, overall okay: 1 |
10 |
| Total | 100 |