Autograd from scratch
A large language model has billions of parameters, and every training step needs the derivative of the loss with respect to every one of them. If you estimated those derivatives with finite differences, as in §0.2, you would need one extra forward pass per parameter, billions of passes per step. Training would never finish.
Yet loss.backward() computes all of them at a cost of roughly two forward passes. That is not an approximation. It is exact, and it works for any program built from differentiable pieces. The method is reverse-mode automatic differentiation, and the neural-network special case is called backpropagation. It is arguably the single most important algorithm in modern AI, and it is short enough that you will build a working version of it in this section, then a second version that trains a real network.
A computation is a graph
Any computation built from simple operations can be drawn as a directed acyclic graph (a DAG). Each node is a value, and each edge says "this value was used to compute that one". Take
with , , . The forward pass evaluates nodes in an order where every node comes after its inputs, giving , , , and . Notice that is used twice, once in and once in . That detail will matter.
Now we want , , and . Step through the widget below, first forward, then backward, and watch each node receive its gradient.
Reverse mode, one local rule at a time
The backward pass walks the graph in the opposite order, from the loss toward the inputs. For every node it computes the adjoint , how much the loss would change per unit change in . It starts with , since the loss changes one-for-one with itself. Then each node passes gradient to its inputs using only its local derivative, by the chain rule of §0.2.
- , so and .
- , so .
- . Addition passes gradient through unchanged, so , and receives from this edge.
- , so , and receives from this edge.
So gets gradient from two places, and its total is the sum, . This is the multivariate chain rule, which says a variable's influence on the loss is the sum of its influences along every path. In code it means adjoints accumulate with +=, never with =. Getting this wrong is the most common bug in hand-written autograd engines. (PyTorch sums the contributions of every path inside its engine during a backward pass, and separately it adds each backward pass's result into .grad rather than overwriting it, which we come back to below.)
The order matters, so we sort
A node may only pass gradient to its inputs once its own adjoint is complete, meaning every node that uses it has already sent its contribution. In our graph, must wait for both and . The order that guarantees this is a topological order of the graph, every node after all its inputs, run in reverse.
A depth-first search produces one. Visit a node, recursively visit all its inputs first, then append the node to a list. Each node lands in the list after everything it depends on. Reversing the list gives an order in which every node comes before its inputs, which is exactly what the backward pass needs.
Topological order of a computation graph
Implement topo_order(root, inputs_of) that returns a list of all nodes reachable from root, where each node appears exactly once and after all of its inputs. inputs_of(node) returns the inputs of a node (an empty list for leaves). Nodes may be shared (a diamond, where two nodes use the same input), and they may be any hashable objects.
A scalar autograd engine in about fifty lines
We now have everything. Each value is an object that stores its number (data), its adjoint (grad), the values it was computed from, and a small closure, _backward, that applies its local rule. Every operation builds a new value and attaches that closure. This is the design of Karpathy's micrograd (linked below), and in miniature it is the design of PyTorch.
The backward pass sorts the graph and calls every closure in reverse order.
These match the hand computation, including the two-path gradient of . A tiny engine is already enough to train a neural network. Here a network with one hidden layer of 4 tanh units learns XOR, the function no single linear unit can represent, using nothing but Value objects.
The outputs head toward . Note the line that zeroes the gradients before each backward pass. Because adjoints accumulate, skipping it would add this step's gradient to the last one's, which is exactly the optimizer.zero_grad() you call in PyTorch.
A complete micrograd engine
Build a Value class from scratch that supports +, -, *, /, ** (with a plain number as the exponent), unary -, the reflected forms (2 + v, 2 - v, 2 * v, 2 / v), and the methods tanh(), exp(), relu(), and backward(). backward() must handle a value used many times (accumulate) and very deep graphs, thousands of nodes long, without hitting Python's recursion limit. Each value has attributes data and grad.
Forward mode, and why machine learning runs in reverse
There is a second way to apply the chain rule automatically. Instead of sending adjoints backward from the output, forward mode sends derivatives forward from one input. Attach to every value a tangent for one chosen input , set , and update tangents alongside values. For , the product rule gives . For , it is . When the computation finishes, the output's tangent is the derivative.
A neat way to implement this is with dual numbers, pairs with the rule . Multiplying two of them gives , the product rule for free. Overloading arithmetic on such pairs gives exact derivatives of any program.
The two modes compute the same derivatives at very different costs. For a function , one forward-mode pass computes one column of the Jacobian (a Jacobian-vector product), and one reverse-mode pass computes one row (a vector-Jacobian product). Each pass costs a small constant multiple of evaluating (Griewank & Walther, 2008, show that for the usual operation sets, the function and its full gradient together cost at most about 3 to 4 times the function alone in reverse mode).
| one pass gives | passes for the full Jacobian | memory | best when | |
|---|---|---|---|---|
| forward mode (JVP) | one column, | , one per input | small, nothing is stored | few inputs, many outputs |
| reverse mode (VJP) | one row, | , one per output | stores the forward values | many inputs, few outputs |
Training is the extreme case of the second row. There are inputs (the parameters) and output (the loss). Reverse mode gets the entire gradient in one backward pass. Forward mode would need a billion passes. That asymmetry is the whole reason deep learning frameworks are built around reverse mode.
The price of reverse mode is memory. The backward pass needs values from the forward pass (multiplication's rule needs both factors, tanh's needs its output), so the engine must keep them until the backward pass reaches them. For a large model those stored activations are often the biggest consumer of GPU memory, more than the weights. You already have one number. The attention scores of GPT-2 small, kept for the backward pass, take about 4.8 GB at a batch of 8 sequences of 1,024 tokens (§0.1), ten times the fp32 weights. Trading compute for memory by recomputing some of them during the backward pass is called activation checkpointing, and it is a standard tool in where the memory goes (§10.1).
Forward mode with dual numbers
Implement a Dual class holding val and eps (the tangent), supporting +, -, *, / (each also with a plain number on either side), unary -, ** with a number exponent, and the methods sin(), exp(), and log(). Then write derivative(f, x), which returns for a scalar function using one forward pass, and grad_forward(f, xs), which returns the gradient of (taking a list of n inputs) as a list, using exactly n forward passes.
From scalars to tensors
A scalar engine creates one Python object per number, which is far too slow for real networks. Real frameworks apply the same idea to whole tensors. Each node holds an array, each operation's backward rule is a tensor VJP (the ones we derived in §0.2), and a matrix multiply is one node instead of millions.
One new issue appears, and it is worth understanding properly. Tensor operations broadcast (§0.1). When a bias is added to activations of shape , the bias is used times, once per row. By the accumulation rule, its gradient is the sum of the row gradients. In general, if an input of shape was broadcast to a larger output shape, the input's gradient is the output gradient summed over every axis that was stretched. Framework code calls this "unbroadcasting" or "sum to size", and every tensor engine needs it.
Unbroadcast a gradient
Implement unbroadcast(grad, shape) that reduces grad (the gradient of a broadcast result) back to shape (the shape of the input before broadcasting) by summing over the broadcast axes. Handle both kinds of stretching, extra leading axes and size-1 axes. For example, a (2, 3, 4) gradient for an input of shape (3, 1) must be summed over axis 0 (added on the left) and over the last axis (stretched from 1 to 4), giving shape (3, 1).
Here is a small tensor engine with exactly four operations, broadcasting addition, matrix multiplication, ReLU, and a fused softmax cross-entropy whose backward rule is the of §0.2. It uses an iterative topological sort, so deep graphs are fine.
And it trains a real classifier. Three interleaved spirals cannot be separated by any linear model, but a 64-unit hidden layer trained by our engine separates them, reaching 100% training accuracy.
Notice that the engine never needed to know it was training a neural network. It knows four VJP rules and how to order them. Everything PyTorch adds on top (hundreds of operations, GPU kernels, a C++ core, memory management) is engineering around this same idea.
What PyTorch's autograd adds
PyTorch's engine is our engine with some important refinements. Knowing them prevents a whole class of bugs.
- Leaves and
requires_grad. Tensors you create directly are leaves. Only leaves withrequires_grad=True(parameters, typically) keep their.gradafterbackward(). Intermediate results get gradients during the pass, but PyTorch frees them unless you call.retain_grad(). - The graph is freed after
backward(). Callingbackward()twice on the same graph raises an error unless you passretain_graph=True, as long as any operation in it saved tensors for the backward pass (almost all do), because those saved activations were released to save memory. - Gradients accumulate. Like our
+=, eachbackward()adds into.grad. That is a feature, since it lets you sum gradients over several small batches before one optimizer step (gradient accumulation, §0.9). It is also why you must zero gradients between steps. - In-place operations are tracked. If you modify a tensor in place after it was saved for the backward pass, PyTorch notices (each tensor has a version counter) and raises an error instead of silently using the wrong value.
Stopping gradients with detach and no_grad
Sometimes you want a value in the forward pass but no gradient through it. x.detach() returns a tensor that shares x's data but is cut off from the graph, so gradient stops there. You'll use it for targets that should not be trained toward, such as the reference values in some RL losses, and for the stop-gradient trick that keeps self-supervised methods from collapsing, as in the JEPA family of models, where one branch's output is treated as a fixed target. Wrapping code in torch.no_grad() is different. It tells PyTorch not to record a graph at all inside the block, which saves the memory of stored activations. Use it for evaluation and for the parameter update itself. torch.inference_mode() is a stricter, slightly faster version for pure inference.
Higher-order gradients
The backward pass is itself a computation built from differentiable operations. If the engine records it as a graph too (PyTorch does this with create_graph=True), you can differentiate the gradient, which gives second derivatives.
Write for all of a model's parameters, of them, and for the Hessian, the matrix of second derivatives. It has entries, hopeless for large . But the Hessian-vector product is cheap. Since , where is held constant, you can compute it with one backward pass through the gradient computation, at a cost of about two to three gradient evaluations (Pearlmutter, 1994). It is also approximately the finite difference of two gradients, . Hessian-vector products power curvature estimates (the sharpness of a minimum, or the largest Hessian eigenvalue that limits stable step sizes in §0.6, which power iteration finds from HVPs alone, as §0.10 shows), influence functions, some second-order optimizers, and meta-learning methods like MAML that differentiate through an inner training step.
Hessian-vector products from gradients alone
Implement hvp(grad_f, x, v, h=1e-5) that approximates using two calls to grad_f (a central difference of gradients along ), and hessian(grad_f, x) that builds the full Hessian from Hessian-vector products with the standard basis vectors , then symmetrizes it as to remove rounding noise.
In PyTorch
The modern torch.func module offers these as composable functions (grad, vjp, jvp, jacrev, jacfwd, hessian), in the style of JAX. Note that it exposes forward mode too, as jvp and jacfwd, for the cases in the table above where it wins.
Read more
- Karpathy. micrograd. The tiny scalar engine this section's design follows, with a neural-network library on top, in about 150 lines.
- Baydin, Pearlmutter, Radul & Siskind (2018). Automatic Differentiation in Machine Learning: a Survey. The clearest overview of forward and reverse mode, dual numbers, and their costs.
- Griewank & Walther (2008). Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation. The standard reference on automatic differentiation, including the cheap-gradient bound.
- Rumelhart, Hinton & Williams (1986). Learning representations by back-propagating errors. The paper that made backpropagation the way neural networks are trained.
- Paszke et al. (2019). PyTorch: An Imperative Style, High-Performance Deep Learning Library. How PyTorch records graphs on the fly and runs reverse mode efficiently.
- PyTorch documentation. Autograd mechanics. Leaves, saved tensors, in-place correctness checks, and inference mode.
- Pearlmutter (1994). Fast Exact Multiplication by the Hessian. The Hessian-vector product trick.