← Root to End ML track · 1 of 2 · checked against PyTorch 2.14.1 on 2,001 programs

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 // ….

Frame 0 · the root

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.

tools/autograd/derivatives.yamltrimmed: the table of local derivatives

  

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.

Frame 1 · recording

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.

torch/csrc/autograd/node.h · struct Nodetrimmed

    
data/capture.json · walking grad_fn.next_functionsreal PyTorch 2.14.1

    

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.

Frame 2 · leaves

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().

torch/csrc/autograd/functions/accumulate_grad.cpp · AccumulateGrad()verbatim

    
torch/csrc/autograd/functions/accumulate_grad.h · accumulateGrad()trimmed

    
data/capture.json · three backward() calls, no zero_grad()real PyTorch 2.14.1

  
Frame 3 · the engine

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.

torch/csrc/autograd/engine.cpp · compute_dependencies()trimmed

    
torch/csrc/autograd/engine.h · ReadyQueue::CompareNodeTaskTimeverbatim

    
torch/csrc/autograd/engine.cpp · evaluate_function()trimmed to the dependency bookkeeping

  
Lab · backward, step by step

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
namevaluegrad_fn
Leaf .grad
leafvalue.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.

Frame 4 · saved tensors

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.)

torch/csrc/autograd/engine.cpp · evaluate_function()trimmed

    
data/capture.json · loss.backward(); loss.backward()real PyTorch 2.14.1 error

    
Frame 5 · our code

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).

torch/autograd/__init__.py · _make_grads() and backward()trimmed

    
a training stepwhere each line lands

    
Real output

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.

Execution order, from hooks
#nodeseqgrad in

torch.profiler, the neuron
µseventseq
Frame 6 · practice

Predict, then check

See it on our own machine

What to seeHow
The recorded graphout.grad_fn, then .next_functions repeatedly. grad_fn._sequence_nr() gives the sequence number.
The order backward runsnode.register_prehook(lambda g: print(node.name())) on every node, then out.backward(). This is what our checker does.
Saved tensorsout.grad_fn._saved_self and _saved_other on a MulBackward0; after backward() they raise an error, because they were freed.
The engine in a profiletorch.profiler.profile() around forward and backward; the events named autograd::engine::evaluate_function: … are the nodes.
A gradient checktorch.autograd.gradcheck(f, inputs) compares autograd with finite differences, like the lab's button, in float64.

Exercises

  1. 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.
  2. Use the preset "ReLU at zero". What gradient does relu give at exactly 0, and which line of derivatives.yaml decides it?
  3. Write micrograd's Value class for + and * with a topological sort, and check it against the lab on "A variable used twice".
  4. Add retain_graph=True to the first call in the "backward twice" capture and run it. What is w.grad after both calls, and why?
  5. For out = x ** y with x = 0 and y = 2, predict both gradients from pow_backward_exponent, then check them in the lab.
Exam practice

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.

Self-marks stay on this page only; refreshing clears them.
For 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.

For 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.

Compare forward-mode and reverse-mode automatic differentiation for a function from n inputs to one output. Which does neural network training use, and what does reverse mode cost in memory?

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.

A student calls 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.

Write a short note on why PyTorch's backward pass needs no explicit topological sort.

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.

Reading list

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.

Sources

Original files and data

PyTorch code is used under its BSD-style license, quoted for study.