skip to content

Reverse Sweep Mechanics

How the reverse sweep actually runs: why one backward pass beats a forward sweep per parameter, what it costs, and how gradients merge where a tensor is reused or are cut where a branch is detached.

on this pageshow

explore

questions

6

In reverse-mode autodiff, why is a reused tensor's gradient the sum of its consumers' gradients?

level: middleimportance: must knowfreq 62%

answer

  1. one node, several consumers
  2. multivariable chain rule over paths
  3. copy forward means add backward
  4. sum, never average
  5. final only after the last consumer

basics

~20 s

A reused tensor appears in several terms of the chain rule, so its gradient is the sum of one contribution per consumer. The reverse sweep adds each contribution into a single buffer as it reaches that consumer.

solid answer

~50 s

Fan-out in the forward pass is fan-in in the backward pass. If `x` feeds `y1` and `y2`, the multivariable chain rule gives `dL/dx = dL/dy1 * dy1/dx + dL/dy2 * dy2/dx` — a sum over paths, one vector-Jacobian product per consumer. Reverse mode implements that literally: each node owns an adjoint buffer that starts at zero, and every consumer adds its contribution when the sweep reaches it. A node's gradient is therefore only final once *all* of its consumers have reported, which is why the sweep runs in reverse topological order. The practical case is a shared parameter: in a weight-tied language model the same matrix is the input embedding and the output projection, so its gradient is the sum of the lookup path and the projection path. Take only one and the model still trains — towards a different optimum.

code

python · 18 lines
python
# forward:  y1 = 3*x ,  y2 = x*x ,  L = y1 + y2
# expected: dL/dx = 3 + 2*x

x = 2.0
adjoint = {}

def accumulate(name, g):
    adjoint[name] = adjoint.get(name, 0.0) + g

y1 = 3.0 * x
y2 = x * x
L = y1 + y2

# reverse sweep: dL/dy1 = 1.0, dL/dy2 = 1.0
accumulate("x", 1.0 * 3.0)          # path through y1
accumulate("x", 1.0 * (2.0 * x))    # path through y2

print(L, adjoint["x"])              # 10.0 7.0

go deeper

for a junior

Be ready to say that if a value is used twice, its gradient is the sum of the two contributions, not one of them and not their average.

for a middle

Expect to derive it from the multivariable chain rule and to describe the accumulator: each consumer adds into a buffer, and the node's gradient is final only after the last one reports.

for a senior

Show that you can spot the silent version: a tied or shared parameter that receives only one of its paths still trains, just towards a different optimum. Say how you would verify per-path contributions.

for a principal

Own the scaling consequence. Summed gradients from many heads or many uses change the effective step size on shared parameters, so head weighting and a separate learning rate for a shared trunk are design decisions, not defaults.

## The graph, and what "reuse" means A training step builds a directed acyclic graph: nodes are tensors, edges are operations, and the single scalar loss `L` sits at the sink. **Reuse** means one node has more than one outgoing edge — the same activation feeds two layers, the same parameter matrix is used by two operations, the same embedding table is read twice. That is *fan-out*. Reverse-mode autodiff computes, for every node `v`, its **adjoint** `v_bar = dL/dv`. It walks the graph backwards from the loss, and at each operation it turns the adjoint of the output into adjoints of the inputs (a vector-Jacobian product). The question is what happens at a node with several outgoing edges. ## The chain rule says sum Suppose `x` feeds two consumers, `y1 = f(x)` and `y2 = g(x)`, and `L` depends on `x` only through them. Perturb `x` by a small `dx`. Both `y1` and `y2` move, and both movements change `L`. To first order the two effects add: ``` dL/dx = (dL/dy1)(dy1/dx) + (dL/dy2)(dy2/dx) ``` In vector form each term is `J_i^T * y_i_bar`, the transposed Jacobian of that operation applied to that consumer's adjoint. With `k` consumers you get `k` terms. This is the total derivative — it is not an approximation, and it is not a convention that could have been chosen differently. So the rule is the neat duality: **copy in the forward pass, add in the backward pass**. A broadcast, an explicit copy, using a variable twice in an expression, and a parameter shared between two layers are all the same node with fan-out, and all of them accumulate. ## Why a buffer, and why reverse topological order An implementation cannot know a node's full gradient the moment it processes the first consumer. So each node holds an accumulator initialised to zero; each consumer's backward step *adds into* it. The node's own backward step — pushing gradient further upstream — may only run once every consumer has contributed, otherwise it would propagate a partial sum. Processing nodes in reverse topological order is exactly the scheduling discipline that guarantees this. If you ever hand-write a backward pass, the two bugs are (a) assigning instead of adding, so the last consumer overwrites the others, and (b) propagating before the last consumer has reported. ## Sum, not average Averaging over consumers would be a different, wrong derivative. If you want one path to count for less, scale it in the loss — for example by weighting a head's loss term — and let summation do the rest. A consequence worth internalising: a shared trunk feeding three heads receives the sum of three gradients, so its parameters see a systematically larger update than a single-head model at the same learning rate. The head loss weights and the trunk's learning rate are coupled. ## The silent bug Because accumulation is invisible when it works, dropping a path is one of the quietest failures in deep learning. The canonical case is **weight tying**: one matrix serves as the input embedding (a row lookup for the tokens in the batch — a sparse gradient) and as the output projection (a dense matmul against every row — a dense gradient). The correct gradient is the sum of the sparse and dense contributions. An implementation that keeps two copies of the matrix and only updates one, or that severs one path, produces a model that trains, converges and looks fine on the loss curve — it is simply optimising a different objective from the one you wrote down. The same shape appears in multi-head models: three heads over one trunk, and all three upstream gradients must arrive before the trunk's parameters are updated *once*. Updating the trunk three times, once per head, is not equivalent — three sequential steps of size `lr` from three different points is a different trajectory from one step along the summed direction, and the difference grows with the learning rate. ## How to check Craft a loss that depends on only one path, run one backward pass, and inspect which parameters received a nonzero gradient. Then compare the shared parameter's gradient against the sum of the two single-path gradients. For small models, a finite-difference check on a shared parameter catches a missing path immediately, because the numerical derivative naturally includes every route from that parameter to the loss.

  • A shared trunk feeds three heads with three losses. How does the trunk's update differ from a single-head model's?
    The trunk's parameters receive the sum of three vector-Jacobian products, so at the same learning rate the effective step on the trunk is larger and grows with the number and weight of the heads. That is why head loss weights and the trunk's learning rate have to be tuned together, and why an unweighted extra head can destabilise a previously stable trunk.
  • In a weight-tied language model, what exactly does the tied matrix's gradient consist of?
    Two summed contributions: a sparse one from the embedding lookup, touching only the rows for tokens present in the batch, and a dense one from the output projection, touching every row because every row participates in the logits. Keeping only one path still trains — silently, towards a different optimum — which is why tying is implemented as one parameter, not two synced copies.
  • Why sum the contributions rather than average them?
    Because the chain rule's total derivative is a sum; averaging would divide the true gradient by the number of consumers, an arbitrary rescale that depends on graph topology rather than on the objective. If a path should count for less, weight its term in the loss explicitly — then summation still gives the exact gradient of the weighted objective you wrote.

A shared parameter is like a road used by three delivery routes. The traffic it carries is the total of all three, not the average and not whichever route reported last.

saying these in an interview costs you the question

  • Says the gradients from several uses are averaged
  • Thinks the last consumer's gradient overwrites the earlier ones
  • Believes a tied weight only gets gradient from one of its two roles
  • Updates a shared trunk once per head instead of once per step
  • Propagates a node's gradient upstream before all consumers have contributed

context

open as a page

Why does training a 100-million-parameter network use one reverse sweep instead of one forward sweep per parameter?

level: middleimportance: must knowfreq 68%

basics

~20 s

Reverse mode costs one sweep per output; forward mode costs one per input. Training has one scalar loss and a hundred million inputs, so a single reverse sweep gets every gradient, while forward mode would need a hundred million.

open as a page

How does automatic differentiation differ from symbolic and numerical differentiation?

level: juniorimportance: should knowfreq 48%

basics

~20 s

Automatic differentiation applies exact per-operation derivative rules to numeric values as the program runs, giving machine-precision derivatives at one input point. Symbolic differentiation manipulates formulas and can blow up in size; numerical differentiation perturbs inputs and carries approximation error.

open as a page

Why does a hard argmax in the forward pass hand back a zero gradient to everything upstream?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A hard argmax is flat between its jumps, so its derivative is zero almost everywhere and undefined at ties. Reverse mode multiplies by that zero, and any parameter whose only route to the loss crosses it gets an exactly-zero gradient.

open as a page

Which automatic differentiation mode builds the Jacobian of thousands of residuals against 6 robot-arm parameters in fewer sweeps?

level: seniorimportance: nice to knowfreq 30%

basics

~10 s

Forward mode. One forward sweep produces one Jacobian column, so six sweeps cover the six parameters. Reverse mode produces one row per sweep and would need thousands, one per residual.

open as a page

Where do you place stop-gradients in a model's graph, and what does each cut change?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

A stop-gradient passes its input forward unchanged and returns zero gradient, so the optimizer treats that value as a constant. Put one wherever a model-computed quantity should act as data, not as something the loss can lower by changing it.

open as a page