A field guide · Foundations

What Autograd Actually Does

One number comes out of the loss. A hundred billion numbers have to change because of it. Everything in between is the chain rule applied with unusual discipline about the order of multiplication.

BackpropagationAutomatic differentiationAutogradTraining systems

One number, a hundred billion gradients

Training a language model ends, every single step, with one number. A batch of text goes in, a hundred billion parameters do their work, and out drops a scalar — the loss. Then the optimiser asks a question that ought to be absurd:

$$\text{given } L = f(x; \theta) \in \mathbb{R}, \quad \theta \in \mathbb{R}^{N}, \quad N \approx 10^{11}, \qquad \text{find } \nabla_\theta L \in \mathbb{R}^{N}.$$

Every one of those hundred billion parameters wants to know how much it personally contributed to that one number. And we need the answer within roughly the same amount of compute it took to produce the number in the first place, or training does not happen at all.

Consider what the obvious method would cost. Finite differences: nudge one parameter, run the whole model again, see how the loss moved.

$$\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta + \varepsilon e_i) - L(\theta - \varepsilon e_i)}{2\varepsilon}$$

That is two forward passes per parameter. For a 100B-parameter model at roughly \(2N\) FLOPs per token per forward pass, you would need something like \(4 \times 10^{22}\) FLOPs to differentiate a single training example. An H100 at 1000 TFLOP/s would take about 1.3 million years. And the answer would be numerically garbage besides, because subtracting two nearly equal floats in bf16 destroys most of the precision you were trying to measure.

The remarkable fact — the fact this entire article exists to explain — is that the right method costs roughly twice the forward pass. Not twice per parameter. Twice, total. All hundred billion derivatives, for the price of a couple of extra function evaluations.

Frame 01 / 20

The shape of the problem. Forward, an enormous parameter space funnels down through layer after layer into one number. Backward, that one number has to fan out and reach every parameter it touched. The asymmetry between those two directions is not a nuisance; it is the entire reason reverse-mode differentiation exists, and it is why the cost is what it is.

Why the same method acquired several names
FieldNameWhat changedCore operation
ControlAdjoint methodSensitivities through a dynamical systemAccumulate local derivatives in reverse order
Numerical computingReverse accumulationDifferentiate an executed computation
Programming languagesReverse-mode ADGenerate a gradient program automatically
Neural networksBackpropagationApply the same machinery to learned weights

The plan for this article: build the machinery from a single scalar chain rule, watch it generalise to graphs and then to Jacobians, discover reverse mode by asking a question about matrix multiplication order, derive the backward pass of every layer in a transformer, and then follow the consequences into memory — because the price of reverse mode is not compute, it is that you have to remember the forward pass.

The chain rule, taken literally

Start where everyone starts, but stay there longer than usual, because the whole edifice is this one identity applied with discipline.

$$\frac{d}{dx}\,g(h(x)) \;=\; g'(h(x)) \cdot h'(x).$$

Most people learn this as a symbol-manipulation rule. It is more useful as a statement about sensitivity, so let me state it that way.

Derivatives are exchange rates

Suppose \(v = h(x)\) and \(L = g(v)\). Wiggle \(x\) by a tiny amount \(\varepsilon\). Then \(v\) moves by approximately \(h'(x)\varepsilon\) — the derivative is the exchange rate between a perturbation in \(x\) and a perturbation in \(v\). Now that perturbation in \(v\) causes a perturbation in \(L\) of about \(g'(v) \cdot (h'(x)\varepsilon)\), because \(g'\) is the exchange rate from \(v\) to \(L\).

$$\delta x \;\xrightarrow{\;\times h'(x)\;}\; \delta v \;\xrightarrow{\;\times g'(v)\;}\; \delta L$$

Converting dollars to euros to yen: you multiply the rates. The chain rule is currency conversion, and a deep network is a very long chain of currency conversions. Nothing more complicated than that is ever going to happen in this article — everything else is bookkeeping about the order you multiply the rates in.

This framing pays for itself immediately, because it tells you what a vanishing gradient is. If every rate in a chain of fifty conversions is 0.9, the end-to-end rate is \(0.9^{50} \approx 0.005\). A dollar's worth of perturbation at the input becomes half a cent at the output. The chain has not broken; it has simply been multiplied into irrelevance.

Frame 01 / 22

A perturbation released at the input, propagating rightward through five local exchange rates. The running product below the chain is the sensitivity accumulated so far. Then the same chain traversed right to left, accumulating the same product in the opposite order. Both sweeps compute the same number here, because there is exactly one input and one output. The moment there is more than one of either, the two orders stop being equivalent — and that is the whole story.

Two things that go wrong the moment you leave the textbook

The single-variable chain rule is not quite enough, for two reasons that both show up in the first layer of any real model.

A variable can influence the loss through several routes. In a residual block, \(x\) reaches the output both through the sublayer and around it. The rule that covers this is the multivariable chain rule, which says the routes add:

$$\frac{\partial L}{\partial x} \;=\; \sum_{k} \frac{\partial L}{\partial u_k}\,\frac{\partial u_k}{\partial x}$$

summed over every \(u_k\) that \(x\) feeds directly. This is not a technicality. It is the reason a skip connection helps: it adds a route whose exchange rate is exactly 1, and a sum containing a 1 cannot vanish no matter what the other terms do.

The functions are not scalar. Real layers map \(\mathbb{R}^{n} \to \mathbb{R}^{m}\), so the "exchange rate" becomes a matrix. That generalisation is where all the interesting cost structure lives, and I will get to it after the graph machinery is in place — because the graph is what tells you which matrices to multiply, and the shapes are what tell you in which order.

Everything is a graph

Here is the move that turns calculus into an algorithm. Any composite function — any program, really — can be flattened into a sequence of elementary operations, each of whose derivative you already know. That sequence is a directed acyclic graph. Nodes are intermediate values; edges are dependencies.

Take a deliberately tiny example, small enough to hold in your head and rich enough to contain every phenomenon that matters:

$$z = wx + b, \qquad h = \tanh z, \qquad r = h + x, \qquad L = (r - y)^2.$$

That is a one-unit network with a residual connection and a squared-error loss. Four operations, six leaves, one scalar out. Notice that \(x\) appears twice — once inside the linear term and once in the skip. That fan-out is the interesting part.

Frame 01 / 26

Every number here is computed live from the sliders, forward and backward. The first eleven frames run the forward pass, storing each intermediate value on its node. The rest run the backward pass, seeding the loss node with 1 and pushing adjoints down the graph. Watch the node for \(x\): two arrows arrive at it, and its gradient is their sum. Notice how much of that sum arrives along the skip edge, whose local derivative is exactly 1, versus how little survives the \(\tanh\).

The forward pass is just evaluation with note-taking

Running the graph left to right is nothing but computing the function. The only thing autograd adds is that it writes down what it did: which operation produced each node, and which inputs that operation will need again when the derivative is requested. That record — the tape, the graph, the trace, depending on whose framework you are reading — is the entire data structure.

Two design philosophies exist for building it. Define-and-run (Theano, TensorFlow 1) constructs a static graph once and then executes it many times, which permits aggressive whole-graph optimisation but makes control flow that depends on data awkward. Define-by-run (autograd, Chainer, PyTorch) builds the graph as a side effect of executing the forward pass, so the graph is whatever the Python actually did on this particular batch. Loops with data-dependent trip counts, branches on tensor values, early exits — all free. The price is that there is no graph to optimise until after you have run it, which is exactly the gap that torch.compile and JAX's tracing exist to close.

The one structural fact

Reverse-mode differentiation needs to traverse the graph backwards. To traverse it backwards, it must exist. To exist, the forward pass must have finished. Everything hard about autograd memory follows from that single ordering constraint: the values computed first are needed last.

Local derivatives and the adjoint

Give every node \(v\) in the graph a shadow value:

$$\bar{v} \;\equiv\; \frac{\partial L}{\partial v}.$$

This is the adjoint of \(v\) — how much the final loss moves per unit move in \(v\), holding the graph structure fixed. It is the thing backpropagation computes, and it is worth being precise that \(\bar v\) is a property of the whole graph downstream of \(v\), not of \(v\) itself.

Seed the output: \(\bar{L} = \partial L/\partial L = 1\). Then work backwards. If a node \(v\) feeds into children \(u_1, \ldots, u_k\), the multivariable chain rule gives the backward recurrence:

$$\boxed{\;\bar{v} \;=\; \sum_{k \,:\, v \to u_k} \bar{u}_k \cdot \frac{\partial u_k}{\partial v}\;}$$

Read that carefully, because it contains the entire algorithm and one profound engineering consequence.

The term \(\partial u_k / \partial v\) is a local derivative. It depends only on the operation that produced \(u_k\) and the values that went into it. The tanh node knows how to differentiate a tanh; it knows nothing about the loss, the optimiser, the layers above it, or whether it is inside a transformer or a physics simulator. The term \(\bar{u}_k\) is everything global, and it arrives from outside, already computed, as a number.

Why autograd can exist at all

Every node needs exactly two things: the derivative of its own operation, and the adjoint handed down from its children. Global information travels as a single number per edge. That factorisation is what makes automatic differentiation composable — you can implement a new operation, tell the system its local derivative, and it correctly participates in gradients of models nobody has written yet. It is the same reason you can define a new class without recompiling the standard library.

The algorithm, in full:

# forward: evaluate in topological order, recording the trace
for node in topological_order(graph):
    node.value = node.op(*[p.value for p in node.parents])
    node.saved = node.op.what_backward_will_need(...)

# backward: traverse in reverse topological order, accumulating
adjoint = defaultdict(zeros_like)
adjoint[loss] = ones_like(loss)

for node in reversed(topological_order(graph)):
    g = adjoint[node]                      # the incoming adjoint, already summed
    for parent, local in node.op.backward(g, node.saved):
        adjoint[parent] += local           # += is the multivariable chain rule

Three details in that sketch are doing real work.

Reverse topological order is not optional. A node's adjoint is only complete once every child has contributed. Visit a node too early and you will propagate a partial sum, silently, with no error. This is why frameworks maintain a dependency counter per node and only enqueue a node once all its outgoing edges have reported in.

The accumulation is +=, not =. That plus-equals is the multivariable chain rule. It is also, as we will see, the reason .grad accumulates across backward calls rather than overwriting, which surprises people exactly once.

Nothing here is specific to neural networks. This procedure differentiates any program built from differentiable primitives — ODE solvers, ray tracers, physics engines, other people's gradients. Backpropagation is the special case where the program happens to be a neural network.

Forward accumulation, reverse accumulation

The recurrence above walks the graph backwards. There is a perfectly good recurrence that walks it forwards, and comparing them is the cleanest way to understand why deep learning does the thing it does.

Forward mode: pick an input, push

Fix one input variable \(x_j\). Attach to every node a tangent:

$$\dot{v} \;\equiv\; \frac{\partial v}{\partial x_j}.$$

Seed \(\dot{x}_j = 1\) and \(\dot{x}_i = 0\) for \(i \ne j\), then sweep forward in topological order:

$$\dot{u} \;=\; \sum_{v \,:\, v \to u} \frac{\partial u}{\partial v}\,\dot{v}.$$

The tangents can be computed alongside the values in a single pass — this is exactly dual-number arithmetic, where you carry \((v, \dot v)\) as a pair and every primitive knows how to propagate both. When the sweep finishes, \(\dot L = \partial L / \partial x_j\). One derivative. To get all \(N\) of them you run the sweep \(N\) times.

Reverse mode: pick an output, pull

Fix one output. Seed \(\bar L = 1\) and sweep backwards with the adjoint recurrence. When the sweep finishes, every node carries \(\partial L / \partial v\) — including every leaf. All \(N\) derivatives. One sweep.

Frame 01 / 24

Same graph, three inputs, one output. On the left, a forward sweep seeded at one input fills the graph with tangents and delivers exactly one partial derivative; three sweeps are needed to fill the gradient. On the right, a single reverse sweep seeded at the output fills the graph with adjoints and delivers all three at once. The frame counter at the bottom of each lane tracks total sweeps.

The Jacobian view makes the difference obvious

For \(f : \mathbb{R}^{n} \to \mathbb{R}^{m}\), the Jacobian \(J \in \mathbb{R}^{m \times n}\) is the whole derivative. Forward mode, seeded with the basis vector \(e_j\), computes \(J e_j\) — the \(j\)-th column. Reverse mode, seeded with \(e_i\), computes \(e_i^\top J\) — the \(i\)-th row.

So the question "which mode?" is really the question "is your Jacobian tall or wide?"

SituationJacobian shapeCheap modeSweeps needed
Many inputs, one output (deep learning loss)\(1 \times n\), very wideReverse1
One input, many outputs (sensitivity of an ODE trajectory)\(m \times 1\), very tallForward1
Square-ish \(m \approx n\)\(n \times n\)Either; forward has lower memory\(n\)
Hessian-vector products—Forward over reverse1

Training a neural network is the first row, taken to an extreme. \(m = 1\) exactly, and \(n\) is in the hundreds of billions. That ratio, \(n/m \approx 10^{11}\), is the largest asymmetry anyone routinely exploits in numerical computing.

When the wires become buses

Real layers move vectors, not scalars. So promote everything. If \(u = f(v)\) with \(v \in \mathbb{R}^{n}\) and \(u \in \mathbb{R}^{m}\), the local derivative is a matrix:

$$J_{ij} \;=\; \frac{\partial u_i}{\partial v_j}, \qquad J \in \mathbb{R}^{m \times n}.$$

and the chain rule becomes matrix multiplication. For a network of \(k\) layers, \(L = f_k \circ f_{k-1} \circ \cdots \circ f_1\), the derivative of the whole thing is the product of the layer Jacobians:

$$\frac{\partial L}{\partial x} \;=\; J_k \, J_{k-1} \cdots J_2 \, J_1.$$

Matrix multiplication is associative. The value of that product does not depend on how you parenthesise it. The cost depends on it enormously.

The matrix chain problem, which is the whole thing in disguise

Multiplying an \(a \times b\) matrix by a \(b \times c\) matrix costs \(abc\) multiply-adds. Take a three-layer chain with widths \(n_0 \to n_1 \to n_2 \to n_3\):

$$\underbrace{(J_3 J_2) J_1}_{\text{right-to-left in the graph}} \;:\; n_3 n_2 n_1 + n_3 n_1 n_0 \qquad\text{versus}\qquad \underbrace{J_3 (J_2 J_1)}_{\text{left-to-right in the graph}} \;:\; n_2 n_1 n_0 + n_3 n_2 n_0$$

Now set \(n_3 = 1\), because the last thing a network computes is a scalar loss. The first ordering costs \(n_2 n_1 + n_1 n_0\). The second costs \(n_2 n_1 n_0 + n_2 n_0\). With \(n_0 = n_1 = n_2 = 4096\), that is 33.6 million operations against 68.7 billion — a factor of about two thousand, from nothing but the placement of parentheses.

Grouping from the output end means starting with the small dimension and never letting the intermediate result grow. That grouping is reverse mode. Grouping from the input end is forward mode. Backpropagation is, at bottom, the observation that when one end of your matrix chain is a scalar, you should start multiplying from that end.

Reverse (from the loss)
—
Forward (from the input)
—
Ratio
—
Peak intermediate
—

Multiply-add counts for the same Jacobian chain under the two association orders. Drag Outputs upward and watch the advantage erode: at one output, reverse mode wins by orders of magnitude; by the time the output dimension matches the width, the two are comparable; past that, forward mode wins. The crossover is not a heuristic, it is arithmetic. Counts are exact for dense Jacobians of the stated shapes and are meant to show scaling, not to predict the runtime of any particular kernel.

Nobody ever builds these matrices

A word of warning before the next section, because it is the single most common confusion about backpropagation. The Jacobian of a linear layer mapping \(\mathbb{R}^{4096} \to \mathbb{R}^{4096}\) over a batch of 8192 tokens is, formally, a matrix of shape \((8192 \cdot 4096) \times (8192 \cdot 4096)\). That is \(1.1 \times 10^{15}\) entries, four petabytes in fp32.

It is never constructed. It never exists. Not in a compressed form, not in a clever layout — it is simply never a thing. What backpropagation actually manipulates is the action of that matrix on a vector, and the action is cheap even when the matrix is absurd. That distinction is the subject of the next section, and once it clicks, autograd stops being mysterious.

JVP and VJP

Two products. Everything in every autodiff system is one of these two.

The Jacobian-vector product: pushing forward

$$\mathrm{JVP}(f, v, \dot{v}) \;=\; J_f(v)\,\dot{v} \;\in\; \mathbb{R}^{m}.$$

Take a direction in input space, get the direction it induces in output space. This is the derivative along a direction, computed without ever forming \(J\). Geometrically it is a pushforward: you have a tangent vector attached to a point in the input manifold and you carry it forward through the map.

The vector-Jacobian product: pulling back

$$\mathrm{VJP}(f, v, \bar{u}) \;=\; \bar{u}^\top J_f(v) \;\in\; \mathbb{R}^{n}.$$

Take a covector in output space — a sensitivity, a "how much do I care about each output" — and get the corresponding sensitivity in input space. Geometrically it is a pullback, and pullback is the natural direction for covectors, which is a rather elegant way of saying that gradients live in the dual space and always have.

The adjoint recurrence from earlier is exactly a VJP:

$$\bar{v}^\top \;=\; \bar{u}^\top J, \qquad\text{i.e.}\qquad \bar{v} \;=\; J^\top \bar{u}.$$

So the backward pass of a layer is a function with the signature (saved_forward_values, upstream_gradient) -> downstream_gradient, and it is required to compute \(J^\top \bar u\) without ever mentioning \(J\).

Frame 01 / 20

The same Jacobian, entered from two sides. Left: a tangent vector enters on the right of \(J\) and exits with the output's shape. Right: a cotangent enters on the left and exits with the input's shape. Neither picture ever fills in the interior of the matrix — the grey block is drawn only to show what is being avoided.

Concretely: the linear layer

Let \(u = Wv\) with \(W \in \mathbb{R}^{m \times n}\). Then \(J = W\), and:

$$\mathrm{JVP} = W\dot{v} \quad (\text{one matvec}), \qquad \mathrm{VJP} = W^\top \bar{u} \quad (\text{one transposed matvec}).$$

Both cost \(mn\) multiply-adds, the same as the forward pass. The Jacobian was already sitting there as the weight matrix, so this case looks trivial — but it makes the general shape of the trick visible. For a tanh, \(J\) is diagonal and the VJP is an elementwise multiply. For a reshape, \(J\) is a permutation and the VJP is a reshape back. For a softmax, \(J\) is dense but has a rank-one-plus-diagonal structure that collapses into two cheap operations. In every case the structure of \(J\) is known analytically, and the VJP exploits it. Building the matrix would throw that structure away and then pay to multiply through it.

The mental model to keep

A framework's list of "supported operations" is really a list of VJP rules. Every op ships with a function that consumes an upstream gradient and produces downstream gradients, and knows the cheapest way to apply \(J^\top\) for that specific op. Autograd is a dispatch table of pullbacks plus a graph walker. That is genuinely all of it.

Why reverse mode wins, precisely

Assemble the pieces into a cost statement. Let \(\mathrm{ops}(f)\) be the cost of one forward evaluation.

$$\text{full Jacobian by forward mode} \;\approx\; n \cdot c_f \cdot \mathrm{ops}(f), \qquad \text{full Jacobian by reverse mode} \;\approx\; m \cdot c_r \cdot \mathrm{ops}(f)$$

with small constants \(c_f, c_r\) that depend on the primitive mix. For a scalar loss, \(m = 1\), and the entire gradient costs a small constant multiple of one forward pass, independent of \(n\). This is the cheap gradient principle, and it is a theorem, not folklore: Baur and Strassen proved in 1983 that the nonscalar arithmetic complexity of computing a rational function together with all of its first partial derivatives is at most three times that of computing the function alone, with a bound near five when all operations are counted. The constant does not grow with the number of variables. That single fact is why gradient-based optimisation of hundred-billion-parameter models is possible at all.

What it costs instead

Nothing is free. Forward mode can compute the value and the tangent in lockstep and throw away each intermediate as soon as it is consumed — its memory footprint is a constant multiple of the forward pass. Reverse mode cannot. It needs \(\partial u_k / \partial v\) evaluated at the forward values, and it needs them in the reverse order they were produced. So it must keep them.

$$\text{memory}_{\text{reverse}} \;=\; O(\text{number of intermediate values along the graph}) \;=\; O(\text{depth} \times \text{activation size})$$

You buy a factor of \(10^{11}\) in compute and you pay for it in storage. That trade is the source of every activation-memory problem in large-model training, and the second half of this article is about what people do about it.

Forward modeReverse mode
Sweeps for a full gradient\(n\)1
Compute per sweep\(\approx\) forward pass\(\approx 2\times\) forward pass
Extra memory\(O(1)\) relative to forward\(O(\text{depth})\) activations
Needs the graph stored?NoYes
Handles control flowTrivially — you are just running the programNeeds the trace of the branch actually taken
Good for\(n \ll m\); Hessian-vector products; ODE sensitivities\(n \gg m\); anything with a scalar loss

Why the backward pass costs about twice the forward pass

The rule of thumb everyone quotes — forward is \(2N\) FLOPs per token, backward is \(4N\), total \(6N\) — falls out of the linear layer. For \(U = XW\) with \(X \in \mathbb{R}^{B \times n}\), \(W \in \mathbb{R}^{n \times m}\):

$$\text{forward: } XW \;\; (Bnm), \qquad \bar{X} = \bar{U}W^\top \;\; (Bnm), \qquad \bar{W} = X^\top \bar{U} \;\; (Bnm).$$

One matmul forward, two backward, all the same size — one to send the gradient further down the network, one to land it on the weights. Everything else in a transformer is elementwise or small by comparison, so the 1:2 ratio survives into the whole-model estimate. If a layer has no parameters, its backward is a single matmul and the ratio is 1:1; if you do not need the input gradient, because the layer is the first one, you can skip \(\bar X\) entirely.

A sanity check you can run in your head

If someone tells you their backward pass takes six times as long as their forward pass, something is wrong: they are recomputing activations, or thrashing memory, or their backward kernels are unfused. The arithmetic says roughly 2×. Deviations from 2× are almost always a memory story, not a math story.

Softmax, and the cancellation that saves it

Softmax deserves its own section, because its backward pass contains the most satisfying cancellation in the field and the most important piece of numerical hygiene in the whole training loop.

The Jacobian

With \(p_i = e^{z_i} / \sum_k e^{z_k}\), differentiate directly. For \(i = j\), the quotient rule gives \(p_i(1 - p_i)\); for \(i \ne j\), it gives \(-p_i p_j\). Both cases fit one expression:

$$\frac{\partial p_i}{\partial z_j} = p_i(\delta_{ij} - p_j) \qquad\Longleftrightarrow\qquad J = \mathrm{diag}(p) - p\,p^\top.$$

A diagonal matrix minus a rank-one matrix. Dense, but with structure you can exploit: the VJP never needs the matrix.

$$\bar{z} = J^\top \bar{p} = \big(\mathrm{diag}(p) - pp^\top\big)\bar{p} = p \odot \bar{p} - p\,(p^\top \bar{p}) = p \odot \big(\bar{p} - p^\top\bar{p}\big).$$

One elementwise product, one dot product, one broadcast subtraction. \(O(n)\) work for an \(n \times n\) Jacobian. That expression — subtract the \(p\)-weighted mean of the incoming gradient, then reweight by \(p\) — is precisely the line \(dS_{ij} = P_{ij}(dP_{ij} - \sum_\ell P_{i\ell}dP_{i\ell})\) that appears in FlashAttention's backward algorithm, and precisely the \(\bar S\) formula in the attention derivation above.

Read it as a projection again, for the same reason as LayerNorm: softmax is invariant to adding a constant to all logits, so its gradient must be orthogonal to \(\mathbf{1}\) under the \(p\)-weighted inner product. Subtracting \(p^\top \bar p\) is exactly that orthogonalisation. Every softmax gradient sums to zero against \(p\): what one logit gains, the others give up.

Frame 01 / 22

The Jacobian assembled from its two pieces, then the fused path. Frames 1–9 build \(\mathrm{diag}(p) - pp^\top\) and show the row sums collapsing to zero. Frames 10–18 push the cross-entropy gradient \(-y/p\) through it and watch the \(1/p\) singularities cancel against the \(p\) in front. Frames 19–22 show what happens if you do not fuse: a division by a probability that underflowed. Logit values in this figure are chosen to make the structure legible, not measured from a model.

The cancellation

Now compose with cross-entropy, \(L = -\sum_i y_i \log p_i\) for a one-hot \(y\). The incoming gradient is

$$\bar{p}_i = -\frac{y_i}{p_i},$$

which is a numerically alarming object: as the model becomes confident about a wrong answer, \(p_i \to 0\) at the true class and this blows up. Push it through the softmax VJP anyway. The dot product simplifies first:

$$p^\top \bar{p} = \sum_j p_j\left(-\frac{y_j}{p_j}\right) = -\sum_j y_j = -1$$

and then everything falls out:

$$\bar{z}_i = p_i\left(-\frac{y_i}{p_i} + 1\right) = p_i - y_i.$$
The identity worth memorising

\(\bar{z} = p - y\). The gradient of cross-entropy with respect to the logits is the prediction minus the target. No division, no exponentials, no possibility of overflow. Every \(1/p_i\) cancelled against a \(p_i\), and what remains is bounded in \([-1, 1]\).

This is why every framework has a fused cross_entropy rather than making you compose log_softmax and nll_loss yourself, and why the fused version takes logits as its argument rather than probabilities. Computing \(p\) and then dividing by it is not merely wasteful; in bf16, where the smallest normal value is around \(10^{-38}\) but effective precision is about three decimal digits, a confidently wrong prediction produces a \(1/p\) that is meaningless before it is large. The cancellation has to happen symbolically, in the derivation, not numerically, at runtime.

It also has a pleasant interpretation. \(\bar z = p - y\) says: push down every logit in proportion to how much probability mass you put on it, and push up the true one by exactly one unit. Confident and right gives a gradient near zero. Confident and wrong gives a gradient near one. The loss is telling you, in the plainest possible terms, the size of your mistake.

What autograd actually keeps

Reverse mode has to run the forward pass first, and it needs forward values to evaluate local derivatives. So something must be retained. The question of exactly what is the difference between a model that fits and one that does not.

Saved tensors

Each backward rule declares its own dependencies. Look at what each one from the previous section needs:

OperationBackward needsCost per elementAvoidable?
\(u = Wv\)\(v\), \(W\)1 activationNo — \(\bar W\) requires the input
\(u = v_1 + v_2\)nothing0Already free
\(u = v_1 \odot v_2\)\(v_1\), \(v_2\)2 activationsNo
ReLUsign mask1 bit (often stored as a full tensor)Cheap either way
GELU\(v\)1 activationRecomputable from \(v\)
LayerNorm\(\hat v\) or \((v, \sigma)\)1 activation + 2 scalars per rowRecompute \(\hat v\) from \(v\)
Softmax\(p\) (the output)1 activationRecomputable from logits
Attention \(P\)\(P\), size \(N\times N\)quadratic in sequence lengthYes — and this is the big one

Two patterns are visible. First, most saved tensors are inputs, which means they are also the outputs of the previous op — frameworks share the storage rather than copying, so the accounting is per-tensor, not per-op. Second, addition needs nothing at all, which is a small quiet reason residual connections are cheap: they add a route to the gradient and consume no extra memory doing it.

Activation lifetime

Here is the awkward shape of the problem. Layer 1's activation is produced at the very beginning of the forward pass and consumed at the very end of the backward pass. Its lifetime is the entire step. Layer \(k\)'s lifetime is shorter, and the last layer's is shortest of all. Memory therefore ramps up monotonically through the forward pass, peaks at the boundary, and drains through the backward pass.

Frame 01 / 26

Each horizontal bar is one tensor's lifetime; the curve is total live activation memory. The forward pass fills the pool, the backward pass empties it, and the peak sits exactly at the turn. Toggle the attention matrix off to see how much of the peak is one quadratic term, and toggle checkpointing on to see the sawtooth replace the ramp. Bar heights are proportional to a schematic transformer layer, not measured allocations.

For an actual transformer, the per-layer activation footprint has been worked out carefully. Korthikanti and colleagues give it as \(s\,b\,h\left(34 + 5\,\frac{a\,s}{h}\right)\) bytes per layer for a model with sequence length \(s\), batch \(b\), hidden size \(h\) and \(a\) attention heads, before any parallelism. The two terms tell the whole story: the first is linear in sequence length and comes from all the ordinary per-token tensors; the second is quadratic, and it is the attention probability matrix. At short sequences the linear term dominates and nobody thinks about it. At long sequences the quadratic term eats the GPU.

Where the memory goes

Parameters and optimiser state scale with model size and are fixed for a given model. Activations scale with model size times batch size times sequence length, and they exist only because reverse mode needs them. Every technique in the second half of this article — checkpointing, selective recomputation, fused attention kernels — is an attempt to shrink a quantity that pure mathematics says has to exist.

The API is the graph, exposed

Everything in PyTorch's autograd surface is a direct handle on some part of the machinery above. Once you see which part, the semantics stop needing to be memorised.

requires_grad: which subgraph gets built

A tensor with requires_grad=True is one the system will compute a gradient for. The flag is infectious in the forward direction: any operation with at least one input requiring grad produces an output requiring grad, and records a node. If no input requires grad, no node is recorded and the operation is as cheap as raw tensor math.

This is why freezing a backbone by setting requires_grad=False saves memory as well as compute: the frozen ops stop saving tensors, because nothing downstream will ever ask them for a gradient. And it is why freezing only the early layers saves much more than freezing later ones — the graph is only pruned up to the first parameter that still requires grad.

Leaf tensors and .grad

A leaf is a tensor with no grad_fn: it was created by the user, not produced by an operation. Model parameters are leaves. Inputs are leaves. Everything in the middle is not.

After backward(), gradients are written to .grad on leaves that required grad, and discarded everywhere else. That last part surprises people. The gradient with respect to an intermediate activation absolutely was computed — it had to be, it is how the gradient got past — but it is freed the moment its last consumer is done, because keeping every intermediate gradient would double activation memory for no reason. If you want one, retain_grad() asks for it, or a hook catches it in flight without keeping it alive.

And .grad accumulates. It is +=, the same += as the multivariable chain rule, applied one level up. Two backward calls without an intervening zero_grad() give you the sum of two gradients. This is not a design wart; it is the feature that makes the next item possible.

Gradient accumulation

You want an effective batch of 1024 and 128 fits in memory. Run eight microbatches, call backward() on each, step once:

optimizer.zero_grad()
for i, micro in enumerate(microbatches):          # 8 microbatches of 128
    loss = criterion(model(micro.x), micro.y)
    (loss / len(microbatches)).backward()      # the /K matters — see below
optimizer.step()

The mathematics is exact, not approximate, because the gradient of a mean is the mean of the gradients:

$$\nabla_\theta \left[\frac{1}{K}\sum_{k=1}^{K} L_k\right] = \frac{1}{K}\sum_{k=1}^{K} \nabla_\theta L_k.$$

Two things to be careful about. The division by \(K\): if your loss already averages over its microbatch, you need it, or your gradient is \(K\) times too large and your learning rate is silently wrong. And any operation that is not a per-example average — batch normalisation is the notorious case, since its statistics are computed over whatever is present — is not exact under accumulation. Layer and RMS normalisation are per-example, so transformers are fine. In distributed training you also want to skip the all-reduce on every microbatch but the last, which is what no_sync() is for.

Frame 01 / 20

Four microbatches into one optimiser step. Activation memory rises and falls once per microbatch — that is the whole saving. The gradient buffer, one tensor per parameter, persists across all four and is the only thing that carries information between them. The lower lane shows the same run without the \(1/K\) scaling, and where the step lands as a result.

detach and no_grad: two different scissors

They are confused constantly and they do different things.

$$\texttt{x.detach()}: \quad \text{same values, } \frac{\partial \,\texttt{detached}}{\partial x} \equiv 0 \qquad\text{(a cut in the graph, at one tensor)}$$
$$\texttt{with torch.no\_grad()}: \quad \text{no nodes recorded at all} \qquad\text{(a region where no graph is built)}$$

detach() is surgical: the forward value passes through unchanged, and the backward pass stops dead at that tensor. Use it when you want a value but not its history — a target network in RL, the stop-gradient in a self-distillation objective, a running statistic you do not want to differentiate through.

no_grad() is a mode: inside the block, operations do not record nodes and do not save tensors. Use it for inference and for optimiser internals. The memory saving is the real reason it exists; the compute saving is minor by comparison.

A third one exists and is worth knowing: torch.inference_mode(), which is no_grad() plus a promise that the tensors will never be used in autograd later, which lets the runtime skip version counters entirely. Faster, and irreversible — a tensor created inside inference mode cannot later be given a graph.

In-place operations, and why they are dangerous

The backward rule for \(y = x^2\) needs \(x\). If you overwrite \(x\) between the forward pass and the backward pass, the gradient computed will be wrong — not an error, just wrong, quietly, using whatever value happens to be in that memory.

PyTorch prevents this with a version counter on every tensor's storage. Every in-place operation bumps it. Every saved tensor records the version it saw. On backward(), the versions are compared, and if they disagree you get the message everyone has met at least once:

RuntimeError: one of the variables needed for gradient computation
has been modified by an inplace operation: [torch.FloatTensor [128, 512]],
which is output 0 of ReluBackward0, is at version 2; expected version 1

This is a good error. The alternative is silent numerical corruption, and the check costs one integer comparison. Note what it does not say: it does not say in-place operations are forbidden. relu_() is safe, because ReLU's backward needs only the sign of the output, which the in-place version still has. x.add_(1) on a tensor nobody saved is safe. The rule is narrower than the folklore: do not mutate a tensor that some backward rule is holding a reference to. When you hit the error, torch.autograd.set_detect_anomaly(True) will point you at the forward operation whose saved tensor was clobbered, at considerable runtime cost.

Frame 01 / 18

A saved tensor, an in-place write, and the counter that catches it. The upper track shows what the backward rule expected to read; the lower shows what is actually in the buffer by the time it reads. Without the version check, the mismatch produces a plausible-looking gradient that is simply incorrect.

Toggle the three switches and watch which nodes still record a grad_fn, which tensors are still saved, and where the backward pass stops. The counter tracks nodes recorded and activation tensors retained — the two numbers that decide whether the step fits in memory.

Writing your own backward

Autograd will differentiate anything you compose from primitives. Sometimes you should stop it and hand it the answer instead. Four situations where that is right:

You know a cheaper form. The fused cross-entropy is the canonical example: composing softmax and nll_loss gives a correct gradient through a chain that includes a division by \(p\), while the fused rule returns \(p - y\) directly. The autograd-derived version is not wrong on paper; it is worse in floating point and slower besides.

You want to trade memory for compute. A custom backward can recompute intermediates instead of saving them. This is checkpointing, and it is implemented in exactly this way.

The forward pass is not differentiable, or not by autograd. A call into a solver, a sampling step, a straight-through estimator, an implicit layer defined by a fixed point. You supply the mathematics that autograd cannot see.

You want to change the gradient on purpose. Gradient reversal for domain adaptation, straight-through estimators for quantisation, custom clipping. The backward pass need not be the true derivative of the forward pass. It just has to be the thing you want the optimiser to receive — though you should know when you are doing this, because it means you are no longer doing gradient descent on the loss you wrote down.

class FusedThing(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, w):
        y = expensive_forward(x, w)
        ctx.save_for_backward(x, w)      # NOT ctx.x = x — version tracking lives here
        return y

    @staticmethod
    def backward(ctx, grad_y):           # grad_y is the upstream adjoint
        x, w = ctx.saved_tensors
        return grad_y @ w.T, x.T @ grad_y   # one gradient per forward input

Three rules that are easy to get wrong. Use save_for_backward rather than stashing tensors on ctx directly, because that is what participates in version tracking and in reference-count-based freeing. Return exactly as many gradients as forward took inputs, using None for the ones that need none. And test it: torch.autograd.gradcheck compares your rule against finite differences in double precision, which is slow, numerically fussy, and has caught more sign errors than any amount of staring.

The self-check

A VJP is correct if and only if \(\langle \bar u, J\dot v\rangle = \langle J^\top \bar u, \dot v\rangle\) for random \(\bar u, \dot v\). That is the dot-product test, it takes four lines, and it is a stronger check than eyeballing the algebra because it tests the transpose relationship directly rather than the formula you think you derived.

Trading memory for recomputation

Reverse mode's bill is now clear. Compute: about twice the forward pass, regardless of parameter count. Memory: every intermediate, alive from when it was produced until the backward pass reaches it. The first number is wonderful. The second is the one that keeps you awake.

Gradient checkpointing is the resolution, and the idea fits in a sentence: do not store what you can cheaply rebuild.

The arithmetic of segments

Split \(n\) layers into \(k\) segments of \(n/k\) layers each. During the forward pass, keep only the \(k\) segment boundaries and discard everything inside. During the backward pass, when you reach a segment, re-run its forward pass from its stored boundary to regenerate the \(n/k\) activations inside, use them, and throw them away again.

$$\text{memory} \;=\; O\!\left(\underbrace{k}_{\text{boundaries}} + \underbrace{\frac{n}{k}}_{\text{one segment live}}\right), \qquad \text{extra compute} \;=\; \text{one additional forward pass.}$$

Minimise \(k + n/k\) over \(k\). Differentiate, set to zero, and \(k = \sqrt{n}\):

$$\text{memory} \;=\; O(\sqrt{n}) \quad\text{for}\quad O(n) \text{ extra compute.}$$

This is Chen and colleagues' result: \(O(\sqrt{n})\) memory for an \(n\)-layer network at the cost of one extra forward pass per minibatch, with a reported runtime overhead around 30% in their experiments. They also note the extreme point of the trade-off: recursive checkpointing reaches \(O(\log n)\) memory for \(O(n \log n)\) forward compute. The idea itself is older than deep learning — it is the checkpointing literature from AD, brought over and generalised.

Frame 01 / 28

Upper lane: the forward pass keeps only segment boundaries; the backward pass re-runs each segment just before it needs it. Lower lane: peak memory and total forward compute as functions of segment count, with the \(\sqrt{n}\) optimum marked. Set segments to 1 and you have ordinary backpropagation; set it to the layer count and you recompute everything. Units are activations and forward-layer-evaluations, so the curve shows the shape of the trade-off rather than wall-clock time on any particular machine.

Uniform checkpointing is the wrong default

The \(\sqrt{n}\) analysis assumes every layer costs the same to store and the same to recompute. In a transformer that is emphatically false, and exploiting the difference is worth a lot.

Korthikanti and colleagues make the point sharply. The tensors that dominate activation memory are not the ones that dominate recomputation cost. The \(N \times N\) attention matrix is quadratic in sequence length to store but comparatively cheap to regenerate; the big matmul outputs are linear to store but expensive to redo. So instead of checkpointing whole layers uniformly, checkpoint selectively: recompute the parts with a bad memory-per-FLOP ratio and store the rest. Combined with sequence parallelism, they report a 5× reduction in activation memory while cutting the execution-time overhead from recomputation from 36% to 4%.

Follow that logic to its conclusion and you get FlashAttention, which is checkpointing pushed inside a single operation. It never materialises \(S\) or \(P\) in high-bandwidth memory at all; it tiles the computation, keeps only the per-row softmax statistics from the forward pass, and recomputes the attention scores on-chip during the backward pass. The result is memory linear rather than quadratic in sequence length — and it is faster, because the recomputation happens in SRAM while the thing it replaced was a trip to HBM. That is the sharpest possible statement of the trade-off: sometimes recomputing is not a cost at all, because arithmetic is cheap and memory movement is not.

The general rule

Recompute a tensor when \(\dfrac{\text{bytes saved}}{\text{FLOPs to regenerate}}\) is high. Store it when that ratio is low. Uniform layer checkpointing ignores the ratio entirely, which is why it leaves so much on the table.

Where this lands in a real training step

Everything above has been mathematics with occasional glances at memory. Here is where it meets the machine — and the place to go for the full treatment is the systems companion to this article, Inside a training step, which walks the same step from the hardware's point of view.

The backward pass is not one kernel, it is a schedule

Reverse topological order is a partial order, not a total one. Independent branches can be differentiated in any relative order, or concurrently. Real engines exploit this: PyTorch's autograd engine maintains a ready queue with dependency counts and executes nodes on multiple threads and streams. The visual metaphor of a wave sweeping backwards through the network is only accurate for a plain chain.

Gradients become communication

In data-parallel training, each \(\bar W\) must be all-reduced across ranks before the optimiser step. Because \(\bar W\) for the last layer is ready first, you can start reducing it while the backward pass is still working on earlier layers. Bucketing gradients and launching an asynchronous all-reduce per bucket hides most of the communication behind the remaining compute. The backward pass is where distributed training's overlap opportunity lives, and it exists entirely because of the ordering constraint that makes reverse mode expensive in the first place.

Precision is not uniform

Mixed-precision training runs the forward and backward passes in bf16 or fp16 while keeping an fp32 master copy of the weights and accumulating in fp32. The reason is in the adjoint recurrence: gradients are products of many local derivatives, and products of small numbers underflow. With fp16 this required loss scaling — multiply the loss by a constant before backward(), divide it out before the step — to push the gradient distribution into representable range. bf16's wider exponent mostly removes the need, which is why it displaced fp16 for large-model training. Note that scaling the loss scales every gradient by exactly the same factor, because differentiation is linear; the trick is sound, not a hack.

Non-determinism comes from the accumulation

The += in the adjoint recurrence is floating-point addition, which is not associative. When multiple children contribute to a node's gradient and the order depends on thread scheduling, bitwise reproducibility goes away. This is the source of most "why did my run not reproduce" reports, and it is a real property of the algorithm rather than a bug — deterministic modes exist and cost throughput because they serialise exactly this.

The whole step, in one accounting

Per token, per parameter, for a dense transformer: about \(2N\) FLOPs forward, \(4N\) backward, \(6N\) total. Memory: \(2N\) bytes of bf16 weights, \(4N\) of fp32 master weights, \(8N\) of Adam moments, plus activations that scale with batch and sequence. Reverse mode is responsible for the \(4N\) and for the activations, and it is the reason the other numbers are affordable at all. The optimiser-state side of that ledger is the subject of What an Optimizer Actually Does.

What stays genuinely subtle

A few things that the tidy account above smooths over, and that I think are worth leaving open rather than resolving.

Non-differentiability is everywhere and mostly ignored. ReLU at zero, max at ties, abs, clamping, sorting, indexing by argmax. The standard defence — it is a measure-zero set, floating point will not land there — is true and slightly unsatisfying, since the exact points involved are often the ones a network is being pushed toward. The theory of conservative gradients puts this on firmer ground and extends the cheap-gradient principle to the nonsmooth case, but the practical upshot is that "the gradient" of a ReLU network is a choice, and different frameworks can legitimately make different ones.

The memory-versus-recompute optimum is not knowable in advance. The \(\sqrt{n}\) result is exact for a uniform chain and approximately useless for a real model on real hardware, where the answer depends on kernel fusion, on HBM bandwidth relative to arithmetic throughput, on whether recomputation overlaps with communication, and on what else is resident. Selective recomputation is the current best answer and it is still hand-tuned per architecture. There is no theory that tells you the schedule.

Optimal Jacobian accumulation is NP-complete. Between pure forward and pure reverse lie mixed strategies — eliminate graph edges in any order, and each order has a different cost. Finding the cheapest is, in general, NP-complete (Naumann, 2006). Every framework uses pure reverse mode not because it is optimal but because it is optimal for the specific case of one scalar output, and that case is the one we care about.

Higher-order derivatives are cheap in principle and awkward in practice. A Hessian-vector product is one forward-over-reverse composition and costs about the same as a gradient. But it requires the backward pass itself to be differentiable, which means the backward graph must be retained, which means memory again — and many fused kernels do not implement a double-backward at all. The gap between "mathematically a VJP of a VJP" and "runs on your GPU" is wider here than anywhere else in this article.

What backpropagation is not. It computes gradients. It says nothing about whether those gradients point anywhere useful, whether the loss landscape is navigable, or why the resulting model generalises. Those are entirely separate questions, and confusing "we can compute \(\nabla_\theta L\) efficiently" with "we understand what training does" has been a persistent source of muddle. All this machinery buys is an exchange rate. What you do with it is the rest of the field.

The thing that still strikes me, after all of it, is how little was needed. One identity about composing rates of change. One observation about the order you multiply matrices in. Everything else — the frameworks, the memory hierarchies, the fused kernels, the trillion-parameter training runs — is engineering built on top of a chain rule and a decision about parentheses.

References

Grouped by topic. Every entry links to its primary source.

Origins and theory of automatic differentiation

Backpropagation in neural networks

Memory, checkpointing and recomputation

Layer mathematics

Non-smoothness and the limits of the tidy story