Chapter 0 · Section 0.3 · 70 min

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

e=a⋅b,d=e+a,t=tanh⁡(d),L=t⋅c,e = a \cdot b, \qquad d = e + a, \qquad t = \tanh(d), \qquad \mathcal{L} = t \cdot c ,

with a=1a = 1, b=−0.5b = -0.5, c=2c = 2. The forward pass evaluates nodes in an order where every node comes after its inputs, giving e=−0.5e = -0.5, d=0.5d = 0.5, t=tanh⁡(0.5)=0.4621t = \tanh(0.5) = 0.4621, and L=0.9242\mathcal{L} = 0.9242. Notice that aa is used twice, once in ee and once in dd. That detail will matter.

Now we want ∂L/∂a\partial\mathcal{L}/\partial a, ∂L/∂b\partial\mathcal{L}/\partial b, and ∂L/∂c\partial\mathcal{L}/\partial c. Step through the widget below, first forward, then backward, and watch each node receive its gradient.

How does a gradient flow backward through a computation graph?
Step through the forward pass, then the backward pass, of L = tanh(a·b + a)·c. Each node only applies its own local derivative. Watch a collect gradient from two paths.
Loading interactive figure…

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 vv it computes the adjoint vˉ=∂L/∂v\bar v = \partial\mathcal{L}/\partial v, how much the loss would change per unit change in vv. It starts with Lˉ=1\bar{\mathcal{L}} = 1, 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.

  • L=t⋅c\mathcal{L} = t \cdot c, so tˉ=c⋅Lˉ=2\bar t = c \cdot \bar{\mathcal{L}} = 2 and cˉ=t⋅Lˉ=0.4621\bar c = t \cdot \bar{\mathcal{L}} = 0.4621.
  • t=tanh⁡(d)t = \tanh(d), so dˉ=(1−t2) tˉ=0.7864×2=1.5729\bar d = (1 - t^2)\, \bar t = 0.7864 \times 2 = 1.5729.
  • d=e+ad = e + a. Addition passes gradient through unchanged, so eˉ=dˉ=1.5729\bar e = \bar d = 1.5729, and aa receives 1.57291.5729 from this edge.
  • e=a⋅be = a \cdot b, so bˉ=a eˉ=1.5729\bar b = a\, \bar e = 1.5729, and aa receives b eˉ=−0.7864b\, \bar e = -0.7864 from this edge.

So aa gets gradient from two places, and its total is the sum, aˉ=1.5729−0.7864=0.7864\bar a = 1.5729 - 0.7864 = 0.7864. 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, aa must wait for both ee and dd. 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.

Warm-up

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.

Hidden tests, hints, and AI help, from $1.99 a month.

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.

Python · cell 1

The backward pass sorts the graph and calls every closure in reverse order.

Python · cell 2

These match the hand computation, including the two-path gradient of aa. 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.

Python · cell 3

The outputs head toward (−1,1,1,−1)(-1, 1, 1, -1). 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.

Interview

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.

Hidden tests, hints, and AI help, from $1.99 a month.

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 vv a tangent v˙=∂v/∂x\dot v = \partial v / \partial x for one chosen input xx, set x˙=1\dot x = 1, and update tangents alongside values. For v=a⋅bv = a \cdot b, the product rule gives v˙=a˙ b+a b˙\dot v = \dot a\, b + a\, \dot b. For v=tanh⁡(a)v = \tanh(a), it is v˙=(1−v2) a˙\dot v = (1 - v^2)\, \dot a. When the computation finishes, the output's tangent is the derivative.

A neat way to implement this is with dual numbers, pairs a+a˙ ϵa + \dot a\,\epsilon with the rule ϵ2=0\epsilon^2 = 0. Multiplying two of them gives (a+a˙ϵ)(b+b˙ϵ)=ab+(a˙b+ab˙)ϵ(a + \dot a \epsilon)(b + \dot b\epsilon) = ab + (\dot a b + a \dot b)\epsilon, 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 f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m, one forward-mode pass computes one column of the m×nm \times n 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 ff (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 givespasses for the full Jacobianmemorybest when
forward mode (JVP)one column, Jv\mathbf{J}\mathbf{v}nn, one per inputsmall, nothing is storedfew inputs, many outputs
reverse mode (VJP)one row, u⊤J\mathbf{u}^\top\mathbf{J}mm, one per outputstores the forward valuesmany inputs, few outputs

Training is the extreme case of the second row. There are n=109n = 10^9 inputs (the parameters) and m=1m = 1 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).

Core

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 f′(x)f'(x) for a scalar function using one forward pass, and grad_forward(f, xs), which returns the gradient of f:Rn→Rf: \mathbb{R}^n \to \mathbb{R} (taking a list of n inputs) as a list, using exactly n forward passes.

Hidden tests, hints, and AI help, from $1.99 a month.

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 b∈Rd\mathbf{b} \in \mathbb{R}^{d} is added to activations of shape (N,d)(N, d), the bias is used NN times, once per row. By the accumulation rule, its gradient is the sum of the NN row gradients. In general, if an input of shape ss 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.

Core

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

Hidden tests, hints, and AI help, from $1.99 a month.

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 p−y\mathbf{p} - \mathbf{y} of §0.2. It uses an iterative topological sort, so deep graphs are fine.

Python · cell 4
Python · cell 5

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.

Python · cell 6

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 with requires_grad=True (parameters, typically) keep their .grad after backward(). Intermediate results get gradients during the pass, but PyTorch frees them unless you call .retain_grad().
  • The graph is freed after backward(). Calling backward() twice on the same graph raises an error unless you pass retain_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 +=, each backward() 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 θ\theta for all of a model's parameters, nn of them, and H=∇θ2L\mathbf{H} = \nabla_\theta^2\mathcal{L} for the Hessian, the n×nn \times n matrix of second derivatives. It has n2n^2 entries, hopeless for large nn. But the Hessian-vector product Hv\mathbf{H}\mathbf{v} is cheap. Since Hv=∇θ(∇θL⊤v)\mathbf{H}\mathbf{v} = \nabla_\theta\big(\nabla_\theta \mathcal{L}^\top \mathbf{v}\big), where v\mathbf{v} 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, Hv≈(∇L(θ+hv)−∇L(θ−hv))/2h\mathbf{H}\mathbf{v} \approx (\nabla\mathcal{L}(\theta + h\mathbf{v}) - \nabla\mathcal{L}(\theta - h\mathbf{v}))/2h. 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.

Core

Hessian-vector products from gradients alone

Implement hvp(grad_f, x, v, h=1e-5) that approximates H(x)v\mathbf{H}(\mathbf{x})\mathbf{v} using two calls to grad_f (a central difference of gradients along v\mathbf{v}), and hessian(grad_f, x) that builds the full n×nn \times n Hessian from nn Hessian-vector products with the standard basis vectors ei\mathbf{e}_i, then symmetrizes it as (H+H⊤)/2(\mathbf{H} + \mathbf{H}^\top)/2 to remove rounding noise.

Hidden tests, hints, and AI help, from $1.99 a month.

In PyTorch

🔥 In PyTorchfor a machine with 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

Watch next

🎬 The spelled-out intro to neural networks and backpropagation: building micrograd · Andrej KarpathyThe classic build of a scalar autograd engine, the same design as this section.
🎬 PyTorch Autograd Explained - In-depth Tutorial · Elliot WaiteDiagrams of the graph PyTorch actually builds, with leaves, grad_fn, and accumulation.
🎬 Backpropagation, intuitively | Deep Learning Chapter 3 · 3Blue1BrownWhat backpropagation is doing, before any formulas.