Every training step of a neural network requires the gradient of the loss with respect to every parameter. The underlying principle is the chain rule from elementary calculus, but a deep network easily has parameters: deriving the gradient by hand is both error-prone and impossible to scale, while numerical differentiation (finite differences) must perturb each parameter individually, with cost growing linearly in parameter count and no good choice of step size — too large gives approximation error, too small gives floating-point precision loss.
This post introduces backpropagation + automatic differentiation (AD) — backpropagation applies the chain rule in reverse topological order over a computational graph, and AD abstracts that recursion into an operator that does not need to materialize Jacobians explicitly. Together they bring the cost of one gradient computation down to the same order as one forward pass ( rather than ), and form the algorithmic core of differentiable-programming frameworks like PyTorch and JAX.
This post is the deep-dive companion to § 3.4 Backpropagation: Computational Graph, Chain Rule, Jacobian of Deep Learning Foundations: From Perceptrons to Backpropagation to Training Deep Networks — the main post gives the high-level why; this post walks §§ 0-5 of how the gradient is actually computed: first the requirement and the choice of method (§ 0); then multidim calculus and the Jacobian (§ 1) and the matrix chain rule with the worked derivation (§ 2); then computational graphs and backprop (§ 3), VJP and reverse-mode AD (§ 4), and the memory accounting and dynamic-vs-static-graph tradeoffs (§ 5); § 6 wraps up with takeaways.
0. Why We Need Automatic Differentiation
Think of a neural network as a scalar function where stacks every parameter. Each training step needs , and is routinely . There are three common approaches.
Symbolic / by-hand derivation: write the partials for every layer and every parameter by hand. The chain rule guarantees correctness, but in a network beyond ten layers, a single misplaced transpose propagates a systematic bias through the entire gradient; any architectural change forces a full re-derivation. Readable, teachable, not scalable.
Numerical differentiation (finite differences): perturb each by and estimate (where is the -th standard basis vector, 1 in position and 0 elsewhere, so adds to only the -th parameter and leaves the rest fixed). It is model-agnostic and trivial to implement. The first drawback is cost — each parameter needs one extra forward pass, so parameters means forwards, which alone rules it out for training.
The second drawback is subtler: no choice of step size works. Take the large- end first. Taylor-expanding along the direction and substituting back into the difference formula,
leaves the true gradient plus a remainder term proportional to . This is truncation error — the difference quotient is a first-order approximation, and the larger is, the more it deviates.
Intuitively, then, smaller means smaller truncation error — but too small an triggers an error from the other end of floating-point arithmetic. Floating-point numbers have finite precision (fp32 ≈ 7 significant decimal digits, i.e. 23-bit explicit + 1-bit implicit = 24-bit precision), so the moment any value is written to memory, everything past digit 7 is rounded off, carrying a relative error of order — those low-order bits no longer correspond to the true value and are noise in themselves. Write the true values as and ; stored, they become and (with the respective rounding noise). When is small, and their high-order digits are identical, so subtraction cancels the reliable high-order part exactly: . The signal is cancelled down to almost nothing while the noise stays put — the absolute error is unchanged, but the relative error blows up to .
Concretely (fp32, , true gradient 1): at , is mathematically , but fp32 stores only 7 significant digits and rounds it back to , so the difference is 0 and dividing by still gives 0 (true gradient 1 estimated as 0); at , the literal looks exact, but stored in fp32 already carries a rounding error of about (), and dividing by inflates it into a relative error of about — the center value is indeed close to 1, but only about 3-4 significant digits are trustworthy. This is catastrophic cancellation — the smaller is, the weaker the signal and the larger the noise fraction, so the fewer significant digits survive the subtraction.
Writing both error types as functions of the step , the total error is a sum of one rising and one falling term:
The first term is truncation error (the Taylor remainder from above; is the magnitude of the second derivative , measuring curvature), growing linearly with ; the second is roundoff error — is a relative error, and multiplying by the value’s magnitude gives the absolute rounding noise from storing one value, which survives the subtraction and is then amplified by dividing by (the from the previous paragraph), worsening as shrinks. A sum of the form , differentiated and set to zero, is minimized at with value ; here . Since and are typically of order in neural networks, dropping the constant yields the optimal step and the minimal total error is also — the two effects offset, and total error cannot be pushed below that floor.
Plugging in numbers: fp64 (machine epsilon ) has optimal step about and minimal error (~8 significant digits), while fp32 () has optimal step about and keeps only 3-4 digits. Numerical differentiation is therefore good only for gradient checking, not for training.
Analytic automatic differentiation (analytic AD): split the network into primitive ops (matmul, add, , loss, …) and write each primitive’s analytic local partial once; the framework then stitches the local partials back into by walking the computational graph in reverse. Each primitive’s local derivative runs in that primitive’s own asymptotic class, and the total cost is the same order as one forward pass.
Automatic differentiation is not about “computing more accurately” — it is about using analytic Jacobians plus reverse topological order to bring the per-step cost from ” forwards” down to “one forward”. The rest of this post unpacks that mechanism from math to algorithm to engineering.
1. Multidimensional Calculus: From Scalars to the Jacobian
1.1 Scalars, Vectors, and the Gradient
For a scalar function the derivative is a single number — geometrically, the slope of the curve at . Lift the input to dimensions, : the scalar admits one partial per coordinate, and arranging those partials into a vector gives the gradient vector .
The geometric picture survives: points in the direction of fastest increase, with magnitude equal to the instantaneous rate of increase along that direction. By convention is a column vector (same shape as ) — the default starting point before § 1.2 settles the numerator-vs-denominator question.
1.2 Vector-Valued Functions and the Jacobian
When the output is also a vector, , each output component has a partial with respect to each input component — of them; arranging these partials into a matrix gives the first-order information at a point, the Jacobian matrix. But “arranging them into a matrix” admits two conventions, transposes of each other.
- Numerator layout: rows = output dimension , columns = input dimension . .
- Denominator layout: the transpose of numerator layout. Rows = input dimension, columns = output dimension.
Both conventions are mathematically valid, but the chain rule looks different under each: under numerator layout it reads “upstream on the left, current layer on the right”, (a right multiply); under denominator layout you must transpose first to line up the product. Mix the two anywhere in an implementation and the transpose bug propagates systematically through every gradient formula. The rest of this post uses numerator layout throughout, with the chain rule arranged as a right multiply.
Under numerator layout, the three common neural-network layers have these Jacobians:
- Linear layer (, , ): (shape ); element-wise w.r.t. , .
- Activation layer (element-wise): , a diagonal matrix.
- Loss layer (scalar output): , a row vector.
The Jacobian of a linear layer is the weight matrix itself, and the Jacobian of an element-wise activation is diagonal — these two observations collapse most layer VJPs (vector-Jacobian products) into structured products, and they are the reason backprop can pass an activation gradient in instead of in § 4.
2. Matrix Calculus and the High-Dimensional Chain Rule
2.1 The Chain Rule for Composed Multivariate Functions
Lay out a two-level composition: , , with , , . The scalar chain rule is ; the vector form lifts it verbatim:
Vectorized, the chain rule is just a product of Jacobians — the shape check tells you the formula is right. A composition chain of length , , has end-to-end Jacobian ; if every intermediate dimension is , the explicit product takes matrix-matrix multiplies, multiply-adds in total (naive algorithm; Strassen and friends drop this to , but deep learning still uses the Basic Linear Algebra Subprograms, i.e. BLAS, in practice). The real waste is not memory (streaming keeps only for one dense Jacobian at a time), but two things: these are matrix-matrix products, per layer; and they compute the full Jacobian, whereas in backprop the loss is scalar and all we ultimately need is the loss’s gradient with respect to each layer — a length- vector ( is the upstream gradient, a row vector under numerator layout); and need not be obtained by building first and then multiplying — every primitive op has a backward rule that maps the upstream directly to (for an element-wise activation, is diagonal and is just , , with no matrix built), so the matrix never needs to exist explicitly. This is the direct motivation for the “dimensionality disaster” in § 3.2 and the VJP refactor in § 4 — VJP propagates a single gradient vector backward layer by layer, so each layer is a vector-times-matrix product (matrix-vector, per layer) and never forms a dense .
The Basic Linear Algebra Subprograms (BLAS) mentioned above is a standardized low-level linear-algebra interface that virtually every numerical stack routes its matrix operations through. It splits into three levels by problem size — Level 1 vector-vector (), Level 2 matrix-vector (), and Level 3 matrix-matrix (gemm, , where almost all of deep learning’s compute lives). BLAS is only an interface spec; the implementations are hardware-tuned by vendors: OpenBLAS and Intel MKL on CPU, cuBLAS on NVIDIA GPUs. These implementations push the constant factor of the naive algorithm to its limit (cache blocking, SIMD, Tensor Core scheduling) rather than switching to Strassen, whose larger constant and weaker numerical stability make it slower at practical matrix sizes.
2.2 Worked Example: Aligning Gradients for the Linear Layer
We take the most common building block in deep learning — the linear layer — as our worked example. Assume the loss is scalar and backpropagation has already produced the upstream gradient at this layer’s output; the goal is and .
The shapes (this is exactly what a fully-connected layer computes, and what the Q/K/V projections in attention compute):
- Parameter matrix .
- Input matrix ( is batch size or sequence length).
- Output matrix .
- Upstream gradient (same shape as ).
Target: and . Before doing any algebra, fix one engineering invariant — the shape-matching principle: the gradient of a scalar with respect to a parameter matrix must have exactly the same shape as that matrix. Every derivation result is verified against this rule; transposes become unambiguous.
We derive the gradient two ways: the naive “index notation” method and the elegant “matrix differential + trace trick” method. They reach the same answer, but each carries a different value: index notation makes every multiply-and-add visible to the reader and removes any sense of a black box; matrix differentials show the industrial-strength technique you need when later layers (attention, softmax, layer norm) produce expressions where unrolling indices is no longer practical.
Method 1: Index Notation (Scalar Partials with Summation)
Decompose each matrix into scalar entries, apply the elementary chain rule, then reassemble. For any entry is
Solving . Consider the influence of a single entry on the final scalar . In , appears only when and , i.e. it touches only the elements of row (any ):
Sum over all paths via the chain rule:
The right-hand side is the inner product of row of with row of . Folding it into a matrix product requires transposing so that row becomes column :
Shape check: , matching .
Solving . Same drill for . In , appears only when and , affecting column of the output, :
Substitute into the chain rule:
This is the inner product of column of with column of . Transpose to fold it into a matrix product:
Shape check: , matching .
Method 2: Matrix Differential and the Trace Trick
Index notation never lies, but for Self-Attention’s the cascading subscript sums become unmanageable. Matrix calculus offers a no-index alternative: differentials and the trace.
In matrix calculus, the differential of a scalar with respect to a matrix and its gradient are linked by an inner product written as a trace:
Combined with the cyclic property of the trace, , this is enough to peel the gradient back out of any expression for .
Solving . Hold fixed and differentiate : . Substitute into the canonical form of :
The first step on the second line uses the cyclic property to move to the front; the second uses . Comparing with the canonical form , peel off the wrapping:
Solving . Hold fixed and differentiate : . Substitute:
The last step uses to move outside. Compare with the canonical form:
Both methods reach the same conclusion. Parameter gradient = upstream gradient × the transpose of the layer’s input; input gradient = the transpose of the layer’s parameter × upstream gradient — the shape constraint determines the transpose positions uniquely. This intuition is the reusable framework behind gradient derivations for attention, softmax, normalization, and the rest.
3. From Math to Algorithm: The Computational Graph and Backpropagation
3.1 Decomposing the Forward Pass: Building the DAG
The lowest-level abstraction shared by modern deep-learning frameworks (PyTorch / TensorFlow / JAX) is the computational graph — a directed acyclic graph where nodes are all the variables (inputs, parameters and , intermediates, output) and edges carry the local partial derivative (a Jacobian) from an upstream node to its downstream. Note this differs from the “network architecture diagrams” in § 1.1 / § 2.1 of the main post: those put weights on the edges (the weighted connection between neurons), while a computational graph treats each weight as a node, and the edge between that node and a downstream intermediate carries the local partial , not the weight itself.
Take the simplest regression loss . Split it into intermediates , , ; the corresponding computational graph is below.
graph LR x((x)) --> z[z] W((W)) --> z b((b)) --> z z --> a[a] a --> diff["a - y"] y((y)) --> diff diff --> L[L]
Each edge carries a local derivative: (the linear layer’s parameter partial under numerator layout, derived in § 2.2), , , and so on. To compute , multiply the local derivatives along the path using the chain rule:
Backpropagation isn’t a new differentiation rule — it’s the chain rule executed in reverse topological order over a DAG using local derivatives. Any function expressible as a DAG admits this procedure; all the framework has to do is remember the local-derivative operator on each edge.
3.2 The Dimensionality Disaster: Why You Can’t Just Multiply Jacobians
Apply the § 2.1 cost to a deep network: a 10-layer, 1000-wide network, multiplying ten layer Jacobians explicitly, takes roughly multiply-adds, with each dense layer Jacobian occupying floats. That’s one sample; with a batch, the matrix-matrix compute quickly becomes unaffordable.
The escape route lies in the structure of those Jacobians. From § 1.2: an element-wise activation has , with only independent nonzeros instead of ; a linear layer’s Jacobian is dense but already in memory, so it requires no extra storage. The Jacobian of an element-wise activation is diagonal — this is the observation that lets backprop pass a layer’s activation gradient in instead of . If the activation is “fully coupled” (such as softmax, which couples its input and output dimensions), the Jacobian is no longer sparse and both storage and compute explode.
§ 4 pushes this structural observation to its limit: never explicitly store or construct ; only ask the operator “given upstream , return “.
4. The Core of Modern Autodiff: Vector-Jacobian Products
4.1 Forward Mode (JVP) vs Reverse Mode (VJP)
View the § 2.1 product as a string of matrix multiplications; there are two ways to evaluate it.
Forward mode (JVP, Jacobian-vector product): pick an input direction (typically is a standard basis ) and accumulate from the left: , arriving at — the name order (Jacobian then vector) is exactly what it computes, . Each step is a matrix-vector product; cost grows with input dimension — recovering the full Jacobian requires JVPs (one per basis vector).
Reverse mode (VJP, vector-Jacobian product): take an upstream-gradient row vector (in backprop, ) and accumulate from the right: , arriving at — the name order (vector then Jacobian) is exactly what it computes, . Each step is also a matrix-vector product; cost grows with output dimension — recovering the full Jacobian requires VJPs.
In deep learning the loss is scalar () while parameters reach ( huge), so reverse mode recovers the full gradient in a single VJP, whereas forward mode would need JVPs — this is the fundamental reason PyTorch and JAX default to reverse mode. At GPT-3 scale (175 B parameters, scalar loss), reverse mode finishes the gradient in one backward sweep; forward mode would need JVPs, which is simply impractical. The roles flip for Jacobian-vector second-order quantities like Hessian-vector products — that is exactly where forward mode earns its keep.
4.2 The VJP Workflow Abstraction
VJP is defined as: given an upstream row vector and a function with Jacobian , compute . Under the chain rule, propagating gradient from the loss backward to any intermediate is exactly a VJP recursion:
The key point: this step does not require explicitly constructing — a routine that, given , returns is enough. For an activation layer with , the VJP collapses to an element-wise product , ; for a linear layer with , the VJP is and reuses the already in memory.
The skeleton of reverse-mode automatic differentiation (pseudocode using the § 2.2 column-vector convention: and is a column-vector gradient with the same shape as ):
# Forward pass: cache activations needed by backward
for l in range(1, L + 1):
z_l = W_l @ x_prev + b_l
x_l = sigma(z_l)
cache(x_prev, z_l)
L_value = loss(x_L, y_true)
# Backward pass: propagate VJP
g = dL_dx_L # initial upstream gradient, shape (m,)
for l in range(L, 0, -1):
g = g * sigma_prime(z_l) # VJP through activation, O(n)
dW_l = g[:, None] @ x_prev[None, :] # outer product, shape (m, n)
db_l = g
g = W_l.T @ g # VJP through linear layer, O(m * n)
return {dW_l, db_l for l in 1..L}
dW_l = g[:, None] @ x_prev[None, :] is the batch-size-1 form of the § 2.2 result (with column-vector gradient and column-vector input , the outer product produces the parameter gradient). W_l.T @ g is the corresponding .
Each backward layer does only two things: propagate the upstream gradient through the current layer via a VJP, and combine the propagating with the cached to form the parameter gradient . Reverse-mode AD matches the forward pass in compute cost, but pays a memory cost equal to caching every forward activation — and that is exactly where the next section’s engineering tradeoffs start.
5. Engineering Tradeoffs in Modern Frameworks
5.1 The Compute-Memory Tradeoff
Reverse-mode AD’s compute budget is friendly (same order as forward); its memory budget is another matter. To make backward able to produce each layer’s parameter gradient, forward must cache each layer’s input (and sometimes , to avoid recomputing ). Written as accounting:
where is the number of layers, is the batch size, and is the per-layer feature width. A 12-layer transformer block with , batch 32, sequence 512, fp32: a single activation tensor is about ; a real transformer block holds ~5-10 independent caches (attention intermediates, MLP intermediates, residuals), easily summing to several GB. The bottleneck for training deep networks is usually not parameter memory but the activation cache reverse-mode AD demands — it grows linearly with depth, batch, and sequence length.
Gradient checkpointing (Chen et al., 2016) offers a time-for-memory trade: keep activations only at checkpoint positions during forward and drop the rest; during backward, when execution lands on a position with no cached activation, re-run forward locally from the nearest upstream checkpoint to rebuild it. The price is roughly extra forward compute; activation memory drops from to . At large-model scale, gradient checkpointing is the most common activation-memory mitigation; choosing which layers to checkpoint becomes its own engineering question.
5.2 Dynamic vs Static Graphs: The Trace Question
Reverse-mode AD also needs to know “what the graph looks like”. The two mainstream lines differ on when the graph is built.
Dynamic graphs (eager + autograd, the PyTorch line): forward runs ordinary user-written Python, and each tensor op records itself into a transient graph as it executes; on loss.backward() the framework walks that graph in reverse and runs VJPs. Strengths: control flow (if, while, Python exceptions) is fully free; debuggers, breakpoints, and print work as-is. Weaknesses: the graph is rebuilt every forward, so the compiler never sees a full op sequence and has no opportunity for cross-op global optimization.
Static graphs (trace + jit, the JAX / TensorFlow 2 line): trace the function once (run it with placeholder tensors) into a static graph, then hand the graph to a compiler (XLA) for global op fusion, memory pre-allocation, and parallel scheduling; backward then runs against the compiled graph. Strengths: op fusion, kernel merging, controlled inference latency. Weaknesses: native Python control flow cannot be traced and must be rewritten with explicit control-flow primitives such as lax.scan and lax.cond; debugging looks more like working in a compiler stack.
Eager defers the backprop graph to runtime; trace pulls it forward to compile time — the choice determines where you pay the cost: control-flow freedom, compiler optimization headroom, or inference latency. PyTorch 2.x with torch.compile adds a hybrid path (“run eager, then trace and compile the hot region”); JAX with jit moves the decorated function fully to the static-graph side; both ecosystems are converging from their respective extremes.
6. Wrapping Up
Backpropagation is the chain rule turned into an algorithm — applied systematically in reverse topological order over a DAG. Starting from “why do we need AD” (§ 0), this post walked through multidim calculus (§ 1), the matrix chain rule (§ 2, with two derivations of ), the computational graph and backpropagation (§ 3), VJP and reverse-mode AD (§ 4), and the memory accounting and graph-mode tradeoffs in real frameworks (§ 5). A few items that were not foregrounded in the body but are worth knowing before you reach for these tools:
- A layout convention must hold end-to-end. Numerator layout (this post) writes the chain rule as a right multiply; denominator layout writes it as a left multiply — both are valid, but mixing them anywhere leaves a systematic transpose error in the gradient formulas. Pick one for code, derivations, and comments alike.
- Shape-matching is both a check and a shortcut. Any gradient derivation — even one as elaborate as attention — can be verified by “gradient shape = parameter shape”; when uncertain about a transpose, writing shapes before forms is faster than grinding through indices.
- VJP replacing explicit Jacobians is the core of AD. Frameworks never materialize the full in memory; what they cache is forward activations, and backward computes on demand. This is the defining difference between PyTorch / JAX and hand-derived gradients — and why training remains feasible even when a network’s Jacobian dimensions would otherwise be ruinous.
- Activation memory grows linearly with depth, batch, and sequence length. The activation cache usually hits before parameter memory or compute does in deep-network training; gradient checkpointing is the standard mitigation, trading roughly extra compute for an memory reduction.
- Reverse mode is not the only choice. When the output dimension dominates the input dimension — as it does for Jacobian-vector and Hessian-vector products — forward-mode JVP is the more economical operator. Deep learning’s “scalar loss + huge parameter count” is just the extreme corner case where reverse mode wins overwhelmingly.