From the chain rule to loss.backward()
Every neural network is trained with one call: loss.backward(). We follow it from the chain rule, through the graph PyTorch records while our forward pass runs, into the C++ engine that counts dependencies, picks nodes from a priority queue and multiplies local derivatives, down to the line that adds the result into .grad. The lab runs the same algorithm on any small program we type and matches real PyTorch node for node.
ML track: 1 · From the chain rule to loss.backward() · next: 2 · One token through a transformer (planned)
How this was checked: on 2,001 distinct programs, the model builds the same graph as PyTorch 2.14.1 (every node's name and edges), creates the nodes in the same order, runs them in the same order (recorded with a hook on every node), and gives the same float64 gradients. The captures are real PyTorch output, in the folder's data/ directory with the script that made them. PyTorch excerpts are from tag v2.14.1, trimmed with // ….
The chain rule, run backwards
A program is a chain of small operations whose derivatives we know. For y = tanh(w·x + b), write u = w·x and v = u + b. The chain rule says the derivative of the output with respect to anything is the product of the local derivatives along the way, summed over every path that connects them.
We can evaluate these products in two directions. Forward mode starts at one input and carries its derivative forward; it needs one pass per input. Reverse mode starts at the output with ∂y/∂y = 1 and carries the gradient backwards; one pass gives the derivative with respect to every input. A loss is one number and a model has millions of weights, so training uses reverse mode. That is what "backpropagation" is.
This is neither symbolic differentiation (no formula for the whole derivative is ever built) nor numerical differentiation (no small steps h). Each local derivative is exact, and the engine only multiplies and adds numbers. A variable used twice gets the sum of the gradients from both uses: that is the "summed over every path" part.
PyTorch's code generator reads derivatives.yaml and writes a C++ class for each entry: mul.Tensor becomes MulBackward0, tanh becomes TanhBackward0. The self: and other: lines are the formulas each class runs during backward.
Every operation leaves a Node behind
PyTorch does not compile our model. It runs our Python line by line ("define by run"), and each operation on a tensor with requires_grad=True does two things: computes its result, and creates a backward Node. The node is stored in the result's grad_fn. It keeps whatever its formula will need (for mul, both inputs; for tanh, its own result) and a list of next edges, one per input, pointing at the node that produced that input.
Every node also gets a sequence number from a counter that goes up by one per node created on this thread. It is how the engine will later decide which node to run first, and how the profiler links a forward operation to its backward node.
Python operators have details of their own, and the model keeps them because PyTorch does. 2 / x is not a division node: Tensor.__rtruediv__ computes x.reciprocal() * 2, so the graph has ReciprocalBackward0 and MulBackward0. 2 - x is RsubBackward1, and 2 ** x is PowBackward2. A constant gets no edge at all: next_functions shows None in its place.
AccumulateGrad: the node that writes .grad
A tensor we created ourselves with requires_grad=True, such as a weight, has no grad_fn; it is a leaf. The edge that points at it leads to an AccumulateGrad node, which has no outputs. When it runs, it adds the gradient it received into the leaf's .grad.
Two lines of its source explain two everyday facts. Its constructor sets its sequence number to UINT64_MAX, so it runs as soon as it is ready and its gradient is written early. And the existing gradient is updated with variable_grad += new_grad: gradients accumulate across backward() calls. That is why every training loop calls optimizer.zero_grad().
Count dependencies, then pop the highest sequence number
Engine::execute starts with compute_dependencies: a walk over the graph from the root that counts, for each node, how many edges point at it. A node may only run when all of its incoming gradients have arrived, so this count is how many arrivals it waits for.
Then the root goes on a ready queue, a priority queue ordered by CompareNodeTaskTime: the task whose node has the higher sequence number runs first. The worker pops a task and calls evaluate_function, which runs the node's formula and, for each output, decrements the next node's dependency count and adds the gradient into that node's input buffer. When a count reaches zero, the node is pushed on the queue with its finished buffer. When the queue is empty, backward is done.
Why the highest sequence number? A node made later in the forward pass depends on nodes made earlier, so running later nodes first tends to finish gradients before they are needed. The dependency counts make it correct either way; the priority decides which ready node goes first. Karpathy's micrograd does the same job with an explicit topological sort.
Type a program, then watch the engine run it
Write Python as we would with PyTorch. The first lines that assign a number are leaves (torch.tensor(…, requires_grad=True)); after that, any expression with + - * / ** and tanh exp log sin cos sigmoid relu. The last line is the output, and we call backward() on it. Each step pops one node from the ready queue.
What it is. Our autograd engine for scalar float64 programs: PyTorch's node types and formulas, sequence numbers, compute_dependencies, a ready queue that reproduces std::priority_queue exactly, input buffers, and AccumulateGrad.
Checked against. PyTorch 2.14.1 on 2,001 distinct programs (8 presets and 1,993 random, from four seeded batches): the same graph and node names, the same creation order, the same execution order recorded by hooks on every node, and the same gradients, including nan and inf (verify/compare-with-pytorch.py). Live: Check with finite differences compares every gradient with a numerical estimate in your browser.
Not covered. Tensors with more than one element and broadcasting, in-place operations, no_grad, hooks, create_graph (higher derivatives), and GPU streams. In these programs two ready nodes never had equal priority in a way that mattered, so the exact heap order is modelled but never decided a result.
This step
Ready queue after this step (runs left to right)
Forward pass
| name | value | grad_fn |
|---|
Leaf .grad
| leaf | value | .grad |
|---|
The check against PyTorch caught two things in our first version. torch.relu passes nan through (relu of nan is nan, and its backward then passes the gradient on), while ours returned 0. And where two gradients cancel, as in a / a, PyTorch's CPU kernels and JavaScript can disagree in the last bit of a product or quotient, which is all that is left after the cancellation. So the comparison allows a difference of 10−12 times the largest gradient that flowed through the graph, not of the tiny final result.
Why a second backward() on the same graph fails
A node keeps the values its formula needs: MulBackward0 keeps both inputs, TanhBackward0 keeps its result. Those are saved tensors, and for a real model they are most of the memory a training step uses. So unless we ask for retain_graph=True, the engine frees them as soon as each node has run: evaluate_function calls release_variables() right after the formula.
The graph's structure survives, but its numbers are gone. Calling backward() on the same output a second time reaches a node whose saved values were freed, and PyTorch stops with the error below. (The lab's "backward() again" button does what a training loop does instead: a new forward pass, which records a new graph.)
loss.backward(), from Python into the C++ engine
Tensor.backward calls torch.autograd.backward. Because a loss is one number, it may skip the gradient argument: _make_grads creates torch.ones_like(out), which is the ∂loss/∂loss = 1 the chain starts from. For any other shape it raises "grad can be implicitly created only for scalar outputs". Then _engine_run_backward calls Variable._execution_engine.run_backward, the C++ Engine::execute above, with accumulate_grad=True (that is what makes it write .grad; torch.autograd.grad passes False and returns the gradients instead).
PyTorch's own record of the backward pass
Two views from real PyTorch 2.14.1. On the left, a pre-hook on every node of our mixed example recorded the order the engine ran them, with each node's sequence number and the gradient it received; the lab's preset "Our mixed example" gives the same order. On the right, the profiler recorded the neuron: forward operations carry the same sequence numbers as the backward nodes they created, and inside MulBackward0 we can see the two multiplications of its formula.
What it is. Output of data/capture.py with PyTorch 2.14.1 on CPU in float64.
Checked against. It is the real system; the same hooks are what verify/compare-with-pytorch.py uses on every program.
Not covered. Timing, which depends on the machine (the profiler times are shown only to give the order), and GPU execution, where backward also involves CUDA streams.
| # | node | seq | grad in |
|---|
| µs | event | seq |
|---|
Predict, then check
See it on our own machine
| What to see | How |
|---|---|
| The recorded graph | out.grad_fn, then .next_functions repeatedly. grad_fn._sequence_nr() gives the sequence number. |
| The order backward runs | node.register_prehook(lambda g: print(node.name())) on every node, then out.backward(). This is what our checker does. |
| Saved tensors | out.grad_fn._saved_self and _saved_other on a MulBackward0; after backward() they raise an error, because they were freed. |
| The engine in a profile | torch.profiler.profile() around forward and backward; the events named autograd::engine::evaluate_function: … are the nodes. |
| A gradient check | torch.autograd.gradcheck(f, inputs) compares autograd with finite differences, like the lab's button, in float64. |
Exercises
- In the lab, write a program in which one leaf is used three times. Predict its dependency count and the number of times its AccumulateGrad runs, then step through it.
- Use the preset "ReLU at zero". What gradient does relu give at exactly 0, and which line of
derivatives.yamldecides it? - Write micrograd's
Valueclass for+and*with a topological sort, and check it against the lab on "A variable used twice". - Add
retain_graph=Trueto the first call in the "backward twice" capture and run it. What isw.gradafter both calls, and why? - For
out = x ** ywithx = 0andy = 2, predict both gradients frompow_backward_exponent, then check them in the lab.
Exam-style questions, with model answers
Five questions in the style of a machine learning or numerical methods exam. Write our answer first, then open the model answer and mark ourselves honestly. Numbers and orders in the answers were produced by the lab's engine and match PyTorch.
y = tanh(w·x + b) with x = 0.5, w = −1.5, b = 0.25, compute ∂y/∂w, ∂y/∂x and ∂y/∂b by reverse mode, showing the gradient on each edge.Forward: u = w·x = −0.75, v = u + b = −0.5, y = tanh(−0.5) = −0.462117.
Backward: start with 1. TanhBackward0 sends 1 · (1 − y²) = 0.786448. AddBackward0 sends 0.786448 to both MulBackward0 and b. MulBackward0 sends 0.786448 · x = 0.393224 to w and 0.786448 · w = −1.179672 to x.
So ∂y/∂w = 0.393224, ∂y/∂x = −1.179672, ∂y/∂b = 0.786448 (the lab's "A neuron" preset; PyTorch agrees to all printed digits).
Marking: 1 for the forward values, 1 for tanh's local derivative, 1 for the add node, 1 for each of the three gradients.
out = x * y + x with x = 3, y = 2, give the dependency count of every node after compute_dependencies, and the order in which PyTorch's engine runs the nodes.Nodes: AddBackward0 (sequence 1, the root), MulBackward0 (sequence 0), AccumulateGrad(x), AccumulateGrad(y). Dependencies: MulBackward0 1 (from Add); AccumulateGrad(x) 2 (from Add and from Mul); AccumulateGrad(y) 1 (from Mul).
Order: AddBackward0 runs first. It sends 1 to MulBackward0 (count 1 → 0, pushed) and 1 to x (count 2 → 1, buffered). MulBackward0 runs, sends 2 to x (count → 0, buffer 1 + 2 = 3, pushed) and 3 to y (pushed). The two AccumulateGrad tasks both have sequence UINT64_MAX; x was pushed first and pops first. So: AddBackward0, MulBackward0, AccumulateGrad(x), AccumulateGrad(y), with x.grad = 3, y.grad = 3.
Marking: 2 for the counts, 2 for the order with reasons, 1 for the buffer sum.
Forward mode carries one directional derivative forward with each evaluation, so all n partial derivatives need n passes (or n-wide dual numbers). Reverse mode runs the program once, then one backward pass from the single output gives all n partial derivatives, at a small constant multiple of the forward cost. Training has one scalar loss and millions of parameters, so it uses reverse mode. The price is memory: every intermediate value a local derivative needs must be kept from the forward pass until backward reaches it (PyTorch's saved tensors), which is why activation memory dominates training and why techniques such as checkpointing trade recomputation for memory.
Marking: 1 per mode's cost, 1 for the choice with reason, 1 for the memory cost.
loss.backward() twice on the same loss and gets "Trying to backward through the graph a second time". Explain the cause using the engine's code, give two fixes, and say what w.grad holds after the fixed version.After a node's formula runs, evaluate_function calls fn.release_variables() unless keep_graph (retain_graph) is set, so the saved tensors of every node are freed during the first backward. The second call reaches a node whose saved values are gone and raises the error. Fix 1: loss.backward(retain_graph=True) on the first call. Fix 2 (the normal one): recompute the loss with a new forward pass, which records a new graph. In both cases AccumulateGrad adds the second gradient to the first, so w.grad is twice the single-step gradient unless w.grad is zeroed between the calls.
Marking: 2 for release_variables and saved tensors, 1 per fix, 1 for the accumulated value.
compute_dependencies counts incoming edges for every node reachable from the root. During backward, each output decrements the count of the node it feeds, and a node is pushed on the ready queue only when its count reaches zero, that is, when every gradient it will receive has arrived and been summed in its input buffer. This is Kahn's algorithm for topological order, done lazily while the gradients flow. The ready queue's priority (highest sequence number first, AccumulateGrad first of all) only chooses among nodes that are already safe to run.
Marking: 1 for the counting, 1 for "pushed at zero", 1 for naming it a topological order (Kahn), 1 for the role of the priority.
Everything this page cites, by depth
The numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the survey and papers the code rests on.
Original files and data
- tools/autograd/derivatives.yaml
- torch/csrc/autograd/node.h
- torch/csrc/autograd/functions/accumulate_grad.cpp
- torch/csrc/autograd/functions/accumulate_grad.h
- torch/csrc/autograd/engine.cpp
- torch/csrc/autograd/engine.h
- torch/csrc/autograd/FunctionsManual.cpp
- torch/autograd/__init__.py
- Our capture script and its output (data/)
PyTorch code is used under its BSD-style license, quoted for study.