Backpropagation on computational graphs
Before this lesson: lesson 01 (the MLP in matrix form) and lesson 02 (losses, softmax cross-entropy, the training loop). From other tracks: the chain rule as a product of Jacobians (calculus track); JVPs, VJPs and forward vs reverse mode in linear-algebra lesson 15; round-off and the unit roundoff in linear-algebra lesson 13. Time: about 65 minutes, plus the notebook.
Why this matters #
Every call to loss.backward() in PyTorch or jax.grad in JAX runs the algorithm of this lesson.
With the explicit matrix formulas you can:
- write a custom backward pass (a PyTorch
autograd.Functionor a fused GPU kernel) and find bugs where a gradient is silently wrong: a wrong sum over the batch, a missing mask, a missing transpose; - predict the cost of training. One training step of a model with parameters costs about floating-point operations (FLOPs) per token, and this number sets the compute budgets of large language models;
- understand where training memory goes and what activation checkpointing does;
- trust a new layer, because a gradient check confirms its backward pass.
In ML for science, the same reverse mode is called the adjoint method. It gives the sensitivity of a simulator or a PDE solver to all its parameters at once.
1. Predict first #
2. The picture #
The chain rule, read from the end #
Start with a chain of scalars: . The chain rule gives . Backpropagation evaluates this product from the end:
- Start with .
- Multiply by the local derivative . This gives .
- Multiply by the local derivative . This gives .
We write and call it the adjoint of : the derivative of the final scalar loss with respect to that variable. Each step needs only two numbers: the adjoint that arrives from above (the upstream gradient) and the derivative of one operation (the local gradient). The rule to remember is
In this lesson is always the scalar loss and is the number of layers.
Computational graphs #
A computational graph has one node per operation and one edge per value. Here is a tiny network with one hidden ReLU unit, a residual connection and a squared loss:
The value is used twice: directly in (the residual path) and inside . We say that fans out. Two rules make backprop work on any such graph:
- Each node maps the adjoint of its output to adjoints of its inputs (upstream times local). For vectors this is a vector-Jacobian product (VJP): . The full Jacobian is never formed.
- Fan-out sums. If a variable feeds several nodes, its adjoint is the sum of the contributions from all of them. This is the multivariable chain rule. Residual connections and tied (shared) weights are fan-out.
Press Forward step until the loss appears, then Backward step. Forward values are blue, adjoints are orange, parameters are violet, and the node being computed is yellow. The panel shows "upstream × local" for each step. Watch node : two orange contributions arrive and add up. Move the sliders and compare the backprop gradients with the finite differences in the table.
The default numbers are , , , , .
Forward pass (top to bottom): ; ; ; ; ; ; .
Backward pass (from the loss down). Each line is "upstream × local":
| adjoint | upstream × local | value |
|---|---|---|
| (the seed) | ||
| (two paths) | ||
| , | ||
Check one of them directly: , so (the ReLU is active, so its derivative is 1). The same number, without ever writing the whole formula.
This answers question 3 of section 1: the contributions add, so .
In the graph above, set . Then for every input. What is ?
The two paths add: (here ). This is correct: when the output does not depend on at all. Forgetting one path of a fan-out gives a wrong, nonzero gradient.
Why reverse order? #
The loss is a map from parameters to one number. Its gradient is one row of length . The chain rule writes it as a product of Jacobians: , where (the last one) has only one row.
- Reverse mode multiplies from the left. It starts with the seed and every intermediate result is a single row. Each step is a VJP and costs about as much as the forward operation. One backward pass gives all derivatives.
- Forward mode multiplies from the right. It pushes one input direction through the graph per pass (a Jacobian-vector product, JVP). It needs passes for parameters.
For a scalar loss and millions of parameters, reverse mode wins by a factor of millions. Linear-algebra lesson 15 treats JVPs and VJPs in detail.
Build the Jacobian one pass at a time. Press Run all passes in both modes. A forward pass (JVP) fills one column of ; a reverse pass (VJP) fills one row. For a loss ( output) one reverse pass gives the whole gradient. Switch to the simulator shape ( inputs, outputs): now forward mode needs fewer passes.
A model has parameters and a scalar loss. About how many passes through the graph does each mode need for the full gradient?
Forward mode gets one column of the Jacobian per pass, so it needs passes. Reverse mode gets one row per pass, and the Jacobian of a scalar loss has only one row.
3. Definitions and main results #
The setting #
A batch has examples, one per row (the row convention of lesson 01). A linear layer computes
where is a column of ones, so adds the row to every row. Write . It has the same shape as : entry is the derivative of the loss with respect to entry . In the same way, has the shape of and the shape of .
Rule 1: the linear layer #
First, entry by entry. Entry appears in column of , in every row: , so . By the chain rule, summed over every entry of that touches (one per example):
In the same way, appears in row of , in every column, with . So . And appears in every row of column , so .
Theorem (backward pass of a linear layer). For and :
Shapes as a memory aid. must have the shape of , . From () and (), the only product with that shape is . From () and (), the only product with the shape of is . When you forget a formula, check the shapes.
A compact proof with differentials
For a scalar , , the Frobenius inner product . If we can write , then . Now . Move each factor to the other side of the inner product (cyclic property of the trace):
- , so .
- , so .
- , so .
Rule 2: an elementwise activation #
For , applied entry by entry, each depends only on . So the Jacobian is diagonal, and the VJP is an elementwise product ():
For ReLU, is the mask : the gradient passes where the unit was active and is blocked where it was off.
Rule 3: softmax cross-entropy #
Take one example with logits , probabilities and a target with (one-hot, or smoothed). The loss is , and . So
Lesson 02 derived this too. For the batch mean , each example's loss is divided by , so
The softmax Jacobian and its VJP
The softmax Jacobian is (). Its VJP with a row is , computed in without forming . With (entrywise), , and the formula gives again. Attention (lesson 09) applies a softmax to every row of a score matrix and uses this VJP.
is and is . Which expression is ?
is , so its gradient must be . is . has the transposed shape , and , are . The sum over the 32 examples is the inner dimension of .
Theorem: the MLP backward pass #
Forward pass. ; for ; for ; row by row; and with one-hot rows in .
Theorem. With , for :
- .
- and .
- .
- (for ).
Proof (induction from the top layer down).
- Step 1 is rule 3.
- Suppose is correct. The graph is a chain: depends on , and only through . Rule 1 with and gives steps 2 and 3.
- elementwise, and depends on only through . Rule 2 gives step 4, which is . So the claim holds for .
Note where enters: once, in . The products and then sum over the batch, so every gradient is a mean over the batch.
Step through a 2-layer MLP on a batch. Each matrix is drawn to scale: forward values blue, weights violet, gradients orange. In each product the inner sizes must match (they are marked). Change , the widths and the classes, and check that every gradient has the shape of its variable. Press Show a wrong version to see the shape error of a common bug in magenta.
Cost: time and memory #
Time. A forward matmul with , costs about FLOPs (one multiply and one add per term). The backward pass needs two matmuls of the same size, and . So the backward pass costs about 2× the forward pass, and one training step costs about 3× the forward pass. Elementwise steps cost , which is small next to the matmuls.
- For a transformer, the forward pass costs about FLOPs per token for parameters (one multiply-add per weight). So a training step costs about FLOPs per token (attention scores are not counted in this estimate).
- The cheap gradient principle: reverse mode computes the gradient of any scalar program for a small constant times the cost of the program, whatever the number of inputs. Baur and Strassen (1983) prove a constant between 3 and 5 for arithmetic circuits, depending on how operations are counted.
Memory. Steps 2–4 for layer need and . The forward pass produces them in the order ; the backward pass uses them in the order . So all of them are in memory when the backward pass starts: numbers, on top of the weights, gradients and optimizer state. For large models with long sequences, these stored activations are the largest part of training memory.
What each operation saves for the backward pass: a matmul saves its input; ReLU needs only the sign (PyTorch saves its output); GELU saves its input; dropout saves its mask; softmax saves its output.
Activation checkpointing #
Activation checkpointing (also called rematerialization) trades compute for memory. Split the layers into segments.
- Forward pass: store only the input of each segment. Do not keep the activations inside a segment.
- Backward pass, segment by segment from the last one: run the segment's forward pass again from its stored input (now keeping its activations), backpropagate through it, then free them.
- Memory: about layer activations ( segment inputs, plus the activations of one segment). This is smallest at , where it equals .
- Compute: at most one extra forward pass. A step costs about instead of , about 33% more.
Choose the number of layers and of segments . Each box is a layer.
Blue marks an activation stored in the forward pass and
orange a layer that is run a second time in the backward pass. The chart shows
the peak number of stored activations against ; find its minimum and compare it with .
The counts follow PyTorch's checkpoint_sequential, which does not checkpoint its last segment.
For layers (PyTorch's checkpoint_sequential, counted in section 5):
| segments | layer forward calls | stored activations |
|---|---|---|
| 1 (no checkpointing) | 16 | 17 |
| 2 | 24 | 10 |
| 4 | 28 | 8 |
| 16 | 31 | 17 |
With only 8 activations are stored, for 12 extra layer calls. With 16 segments nothing is gained: every layer input is a segment input.
More memory-compute trades
- Recursive schedules reach memory for forward compute (Chen et al. 2016, §4).
- In practice, large models checkpoint each transformer block and keep only the block inputs. Megatron's selective recomputation (Korthikanti et al. 2022) recomputes only the attention-score part, which is large in memory but cheap in FLOPs.
- FlashAttention's backward pass recomputes the attention matrix from , , and the softmax statistics instead of storing it: the same trade inside one GPU kernel.
Gradient checking #
A gradient check compares the gradient from your backward pass with a numerical estimate. The central difference with step in coordinate is
where is the -th unit vector. (PyTorch's gradcheck calls the step eps. We write
because means machine epsilon in
linear-algebra lesson 13.) Two errors compete.
Truncation error. By Taylor's theorem, . In the difference, the even terms cancel:
So the error is . The forward difference keeps the term and has error , which is much worse.
Round-off error. Each computed value of has a relative error of about the unit roundoff ( in float64, in float32). The difference of two nearly equal numbers keeps this absolute error, about , and the division by makes it about . It grows when gets small.
The best step. Add the two errors and minimize over :
When , this gives in float64, with an error near . In float32, and the best error is only about : too coarse to separate a small bug from round-off. Check gradients in float64.
Move the point and switch the function and the precision. The chart shows the error of the central difference and of the forward difference against the step , on log-log axes. The dashed lines are the two error models: truncation (falls as gets smaller) and round-off (grows). Notice the V shape, its minimum near in float64, and how float32 moves the minimum up and to the right.
How to run a gradient check in practice.
- Use the relative error . Gradients of different parameters differ by orders of magnitude, and the relative error does not depend on scale. For a smooth function in float64, expect or less. A relative error of or more almost always means a bug (the rule of thumb of Stanford CS231n).
- Avoid kinks. If crosses a ReLU joint or a tie in a max, the numerical derivative is wrong. Use smooth activations for the check, or check that no pre-activation is within of 0.
- Save time on big models. The check needs two evaluations of per coordinate. Check a few random coordinates, or one random direction : .
Answers to section 1. (1) About : the backward pass does two matmuls of the same size for every forward matmul. (2) , close to . At the truncation error dominates; at and the round-off error dominates.
A gradient check in float64 with and tanh activations gives a relative error of for one weight matrix and for all the others. What is the most likely cause?
With tanh there are no kinks, and in float64 gives errors near , as the other matrices show. An error of in one place only points to that layer's backward formula.
4. Worked example #
A batch of examples, two inputs, one hidden ReLU layer of width 2, classes, all biases zero:
Forward pass.
- . Row 1: . Row 2: . So and . Both hidden units are off for example 2.
- . Row 1: . Row 2 is because has a zero row.
- All logits are 0, so every row of is , and .
Backward pass.
- .
- , and .
- .
- . The mask removes example 2.
- , and .
Shape check. is like ; is like . Every gradient has the shape of its variable.
Read the result. Both hidden units are off for example 2. Its row of is zero, so it adds nothing to , and the ReLU mask removes it from . So example 2 reaches only the output bias , and there its gradient cancels the gradient of example 1. The first-layer weights learn from example 1 alone.
In the worked example, why is ?
adds the rows of . Example 1 wants class 0 to go up and example 2 wants class 1 to go up, by the same amount, so the bias gradients cancel. ReLU acts only below , and only makes uniform.
Finite differences on a known function. For at (so ), the central difference has these errors in float64:
| error |
For the error equals the truncation term : it falls by when falls by 100. Near the two sources have the same size. Below that the error grows like (round-off).
5. In code #
NumPy checks the worked example, compares it with central differences, and reproduces the table of errors. The notebook has these cells and the exercises.
import numpy as np
X = np.array([[1., -1.], [-1., 0.]])
W1 = np.array([[1., 2.], [-1., 1.]])
W2 = np.array([[1., -1.], [-2., 2.]])
Y = np.eye(2) # labels (0, 1) as one-hot rows
B = X.shape[0]
def loss(W1, W2):
Z1 = X @ W1; A1 = np.maximum(Z1, 0); Z2 = A1 @ W2
Z2 = Z2 - Z2.max(axis=1, keepdims=True) # stable softmax
P = np.exp(Z2) / np.exp(Z2).sum(axis=1, keepdims=True)
return -np.mean(np.log((P * Y).sum(axis=1))), (Z1, A1, P)
L, (Z1, A1, P) = loss(W1, W2)
G2 = (P - Y) / B
dW2 = A1.T @ G2
G1 = (G2 @ W2.T) * (Z1 > 0)
dW1 = X.T @ G1
print(round(L, 4)) # 0.6931
print(dW2.tolist(), G2.sum(axis=0).tolist()) # [[-0.5, 0.5], [-0.25, 0.25]] [0.0, 0.0]
print(dW1.tolist(), G1.sum(axis=0).tolist()) # [[-0.5, 1.0], [0.5, -1.0]] [-0.5, 1.0]
# central differences for every entry of W1
# (no pre-activation is near 0, so no ReLU kink is crossed)
h = 1e-6
num = np.zeros_like(W1)
for i in range(2):
for j in range(2):
E = np.zeros_like(W1); E[i, j] = h
num[i, j] = (loss(W1 + E, W2)[0] - loss(W1 - E, W2)[0]) / (2 * h)
rel = np.abs(num - dW1).max() / np.abs(dW1).max()
print(rel < 1e-8) # True
# error of the central difference for sin'(1) against h
hs = 10.0 ** -np.arange(1, 12, 2)
err = np.abs((np.sin(1 + hs) - np.sin(1 - hs)) / (2 * hs) - np.cos(1))
print(" ".join(f"{a:.0e}:{e:.1e}" for a, e in zip(hs, err)))
# 1e-01:9.0e-04 1e-03:9.0e-08 1e-05:1.1e-11 1e-07:1.9e-10 1e-09:3.0e-09 1e-11:1.2e-06
PyTorch's autograd gives the same numbers, and it can count what checkpointing stores (16 Linear+ReLU layers, width 256):
import torch, torch.nn.functional as F
Xt, W1t, W2t = (torch.tensor(M, dtype=torch.float64) for M in (X, W1, W2))
W1t.requires_grad_(); W2t.requires_grad_()
F.cross_entropy(torch.relu(Xt @ W1t) @ W2t, torch.tensor([0, 1])).backward()
print(W1t.grad.tolist()) # [[-0.5, 1.0], [0.5, -1.0]]
Xg = Xt.clone().requires_grad_()
print(torch.autograd.gradcheck(lambda t: torch.tanh(t @ W2t).sum(), (Xg,))) # True
# checkpoint_sequential on 16 layers (the counting code is in the notebook)
# segments, forward calls, saved activations: 1 16 17 | 2 24 10 | 4 28 8 | 16 31 17
6. Where it shows up #
- Deep-learning frameworks. Autograd in PyTorch and JAX is rules 1–3 written for hundreds of operations. Each operation has a forward function and a VJP. Fused GPU kernels come with a hand-written backward pass, and a gradient check is how their authors test it.
- Large-scale training. The estimate of FLOPs per token sets compute budgets and the model FLOPs utilization (MFU) of a training run. Activation memory decides the micro-batch size, where to checkpoint, and when to split sequences across GPUs.
- Normalization and residual connections (lesson 05) are fan-out: the residual stream receives the adjoint from both paths, so gradients reach early layers directly.
- ML for science. The adjoint method for ODE and PDE solvers is reverse mode applied to the solver's steps; neural ODEs use its continuous-time form. Variational data assimilation in weather prediction (4D-Var) computes its gradient with an adjoint model, and long adjoint runs use checkpointing schedules such as Griewank's revolve.
- Physics-informed losses. A PINN (physics-informed neural network) loss contains derivatives
such as computed by autograd. Training then backpropagates through that
derivative (double backward,
create_graph=Truein PyTorch). This is one reason smooth activations matter (lesson 04).
7. Common mistakes #
- Dividing by twice, or not at all. Divide once, in . Otherwise the gradient has the wrong scale and depends on , and the learning rate seems to change when changes.
- A wrong order or a missing transpose. , not (that is , the transposed shape). Check shapes at every step.
- The mask from the wrong layer. Step 4 uses , the pre-activation below , not .
- Not summing at fan-out. Residual connections and shared weights get the sum of all
contributions. In PyTorch, the opposite bug is forgetting
optimizer.zero_grad(), so the gradients of different steps add up. - A bad gradient check: float32, , a step across a ReLU joint, or absolute instead of relative errors.
- In-place operations that overwrite a tensor saved for the backward pass. PyTorch stops with an error about a modified variable version.
8. Check yourself #
- has shape and has shape , for . Give , , and their shapes.
Answer
(), (), (, the column sums of over the batch). - Why does also hold for a label-smoothed target (one-hot , smoothing , classes)?
Answer
The derivation used only , and the smoothed target also sums to 1: . - You switch from the mean loss over the batch to the sum. What changes in the backward pass?
Answer
Only loses the factor , so every gradient is times larger. With SGD that is the same as a times larger learning rate. Adam is almost invariant to it (except through its small constant in the denominator). - A 64-layer network uses checkpointing. About how many layer activations are stored, and what is the extra compute?
Answer
segments: about activations instead of 64, for at most one extra forward pass (a step of about instead of ). - What is the truncation error of the central difference for at with ?
Answer
. (The quotient is exactly ) - Reverse mode is right for training. When is forward mode better?
Answer
When there are few inputs and many outputs, for example the Jacobian of (the sensitivity of a field to 3 physical parameters): 3 forward passes (JVPs) against reverse passes (VJPs).
What is the adjoint of a variable ?
, the derivative of the final scalar loss with respect to .
How does backprop compute the adjoint of a node's input from the adjoint of its output?
Upstream gradient times local gradient: a vector-Jacobian product . The full Jacobian is never formed.
A variable feeds two operations that send it adjoints and . What is its adjoint?
. Fan-out sums (multivariable chain rule); residual connections and shared weights are fan-out.
For with , what are and ?
and . Check: has the shape of .
What is for softmax cross-entropy averaged over a batch of examples?
, with the softmax probabilities and the one-hot targets.
Write the backward recursion from to in an MLP.
.
Why does training use reverse mode and not forward mode?
The loss is one scalar of parameters. Reverse mode gets the whole gradient in one backward pass; forward mode needs passes.
About how much does one training step cost compared with a forward pass, and how many FLOPs per token for parameters?
About 3× the forward pass (backward ≈ 2×), so about FLOPs per token.
Which step is best for a central-difference check in float64, and why?
About to , near : it balances the truncation error against the round-off error .
What does activation checkpointing trade, and what is the best number of segments?
It stores only segment inputs and recomputes the rest: less memory for at most one extra forward pass. With segments, memory is about activations.
9. Interview angle #
- "Derive the gradient of softmax cross-entropy." , from and .
- "Write the backward pass of a two-layer MLP in NumPy." The four steps of the theorem in section
3, with shapes:
G2 = (P - Y) / B,dW2 = A1.T @ G2,db2 = G2.sum(0),G1 = (G2 @ W2.T) * (Z1 > 0),dW1 = X.T @ G1,db1 = G1.sum(0). - "Where does training memory go, and how do you reduce activation memory?" Weights, gradients, optimizer state and stored activations. Reduce activations with checkpointing ( segments, or selective recomputation), smaller micro-batches, or by splitting sequences across devices.
- "How do you know your custom backward pass is correct?"
gradcheckin float64, relative error, , smooth functions or no kinks near the test point, or a random-direction check. - "Why does a training step cost about FLOPs per token?" for the forward pass (one multiply-add per weight), and two matmuls of the same size in the backward pass for every forward matmul.
10. Practice #
The notebook for this lesson runs in Colab. Its core exercises use NumPy only; PyTorch is used as a reference when it is installed.
- Write a tiny scalar autograd engine: a
Valueclass with+,*,reluandbackward(), and check it on the graph of section 2. - Write
mlp_backwardfor a 2-layer MLP with softmax cross-entropy in matrix form (no loop over examples), and check it on the worked example. - Write
numerical_gradient(central differences) andrel_error, and use them to check exercise 2 on random data. - Plot the error of central and forward differences against , and find the best .
- Write
checkpoint_counts(L, s)that returns the forward calls and stored activations ofcheckpoint_sequential, and find the best for .
11. Watch and read #
- Simon Prince, Understanding Deep Learning (free PDF), chapter 7, §7.1–7.4: the toy example, the backpropagation algorithm and algorithmic differentiation.
- Zhang, Lipton, Li and Smola, Dive into Deep Learning, §5.3 Forward propagation, backward propagation, and computational graphs.
- Stanford CS231n notes, Neural networks part 3, section "Gradient checks".
- Chen, Xu, Zhang and Guestrin, Training deep nets with sublinear memory cost (2016): the and recursive checkpointing schedules.
- Korthikanti et al., Reducing activation recomputation in large transformer models (2022): selective recomputation.
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.