one gradient, backward
I built a small autograd engine, used it to differentiate one expression, and compared it with PyTorch and a numerical estimate:
forward: L = -0.9051482536
my tape: dL/da = -0.1807066389
torch64: dL/da = -0.1807066389
central difference (h=0.1): dL/da = -0.1815845310
The tape and PyTorch print the same ten decimal places. The central difference with h=0.1 differs by 0.0008778921. Calling the third row wrong would already assume the first two are the oracle. It is more useful to ask what each method computed. The tape applies stored local derivative rules. PyTorch executes its own automatic-differentiation graph. The central difference measures a secant across an interval of width 0.2. Only the limit of that measurement as the interval approaches zero defines the derivative, and binary floating point prevents taking that limit literally.
The discrepancy is therefore the entrance, not a failed check. Following dL/da backward exposes why a reused value needs accumulated contributions, why graph order matters, why one scalar loss makes reverse mode attractive, what a matrix multiply saves for its backward operation, and which tensors a framework must keep alive. The numerical path reaches fp16 only after those mechanics make a tiny gradient necessary.
the first nudge was ordinary subtraction
Start without derivative notation. Let
y = x²
At x=3, the output is 9. Increase x by 0.1 and the output becomes 3.1² = 9.61. The input changed by 0.1 and the output changed by 0.61, so the measured change per unit input is
(9.61 - 9) / (3.1 - 3) = 6.1
Repeating with a smaller nudge moves that ratio toward 6:
nudge measured output change per input change
0.1 6.1
0.01 6.01
0.001 6.001
For x², expanding (x+h)² shows why:
((x+h)² - x²) / h
= (x² + 2xh + h² - x²) / h
= 2x + h
As h approaches zero, the extra h approaches zero and the ratio approaches 2x. That limiting rate is the derivative. At x=3, it is 6. The notation dy/dx = 2x records which output changes with respect to which input.
The opening expression has two inputs:
z = a*b + a
L = tanh(z)
Changing a while holding b fixed asks for a partial derivative, written dL/da here to keep the typography compact. Changing b while holding a fixed asks for dL/db. The vector containing all partial derivatives of one scalar output with respect to its inputs is its gradient:
gradient of L with respect to [a,b] = [dL/da, dL/db]
The operations can be separated into named intermediate values:
m = a*b
z = m+a
L = tanh(z)
At a=1.5 and b=-2:
m = -3
z = -1.5
L = -0.9051482536
Each line has a local rate. For multiplication m=a*b, changing a by a small amount changes m by approximately b times that amount, so dm/da=b. Changing b gives dm/db=a. For addition z=m+a, either input passes its change through with factor 1. For tanh, the local derivative is 1 - tanh(z)². At z=-1.5, that factor is:
dL/dz = 1 - (-0.9051482536)²
= 0.1807066389
To reach L from b, a nudge travels through b -> m -> z -> L. Each stage scales the incoming nudge, so the total scale is the product:
dL/db = dL/dz * dz/dm * dm/db
= 0.1807066389 * 1 * 1.5
= 0.2710599584
That multiplication of local rates is the chain rule. It is not specific to neural networks. It is what happens when the output of one numeric function becomes the input of another.
The route from a is different because a appears twice. One route is a -> m -> z -> L. The other is the direct a -> z -> L. Their contributions add:
route through m:
dL/dz * dz/dm * dm/da
= 0.1807066389 * 1 * -2
= -0.3614132778
direct route:
dL/dz * dz/da
= 0.1807066389 * 1
= 0.1807066389
sum:
dL/da = -0.1807066389
This is why the opening derivative is negative even though the last +a route contributes positively. The multiplication route has twice the magnitude and the opposite sign.
The intermediate values and arrows form a directed graph. Each operation creates an output node and records edges to the values it consumed. The forward evaluation computes m, then z, then L. A reverse traversal starts with dL/dL=1 and distributes that quantity toward the leaves. An operation needs its saved forward values and its own local derivative. It does not need to understand the whole expression.
The automatic differentiation survey by Baydin and colleagues distinguishes this numeric graph transformation from symbolic differentiation and finite differences. The program executes ordinary operations while recording enough structure to apply the chain rule. The result is a derivative evaluated at the actual input values, subject to the same floating point arithmetic as the forward program.
the number remembered where it came from
Every scalar is wrapped in a Value. It stores the numeric value, its accumulated gradient, the parent nodes that produced it, and a function that applies one local backward rule. Python’s __add__ and __mul__ method names let the ordinary + and * operators build these nodes:
class Value:
def __init__(self, x, parents=()):
self.x = x
self.grad = 0.0
self.parents = parents
self.back = lambda: None
def __add__(self, other):
result = Value(self.x + other.x, (self, other))
def backward():
self.grad += result.grad
other.grad += result.grad
result.back = backward
return result
def __mul__(self, other):
result = Value(self.x * other.x, (self, other))
def backward():
self.grad += other.x * result.grad
other.grad += self.x * result.grad
result.back = backward
return result
def tanh(self):
value = math.tanh(self.x)
result = Value(value, (self,))
def backward():
self.grad += (1 - value*value) * result.grad
result.back = backward
return result
def backward(self):
order = []
seen = set()
def visit(node):
if id(node) in seen:
return
seen.add(id(node))
for parent in node.parents:
visit(parent)
order.append(node)
visit(self)
self.grad = 1.0
for node in reversed(order):
node.back()
The nested backward functions are closures. A closure retains access to variables from the operation call after that call returns. The multiplication closure retains self, other, and result, including the two forward values needed by the local rule. A tensor framework saves tensors for the same reason, although it uses native objects and generated operation nodes rather than thousands of Python closures.
visit performs a depth-first traversal. A node enters order only after its parents. That produces a topological order: every forward dependency appears before the node that consumes it. Reversing the list makes every consumer run before its inputs. The seen set matters when a node is reused. Without it, the traversal could insert the same node once per incoming path and apply its backward rule repeatedly.
Setting the final node’s gradient to 1 expresses dL/dL=1. Every earlier gradient begins at zero. A local rule reads the gradient that reached its output, scales it, and accumulates the result in its parents.
The engine runs the opening expression directly:
a = Value(1.5)
b = Value(-2.0)
loss = (a*b + a).tanh()
loss.backward()
my tape: dL/da = -0.1807066389 dL/db = 0.2710599584
The number agrees with the analytic calculation from the two paths. It is not an “exact analytic gradient” in the mathematical sense. math.tanh and every multiplication are binary64 evaluations. The result is an automatic derivative of the floating point values produced along this path.
PyTorch 2.9.1 produces the same printed values in torch.float64:
torch64: dL/da = -0.1807066389 dL/db = 0.2710599584
The PyTorch 2.9 backward documentation says the engine differentiates the graph with the chain rule and accumulates results into leaf .grad fields. Agreement here checks one graph and two leaf gradients. It does not validate the tape’s behavior for every operation, mutation, view, device, dtype, or reused graph.
two harmless edits broke the graph
I broke two lines independently and reran the same expression. The first change replaced every += with =. The second traversed the graph in forward topological order rather than reverse order.
The expression L = tanh(a·b + a) is chosen to make this bite: a appears twice, once inside a·b and once in the bare + a. When gradient flows backward, it arrives at a along both of those paths, and the two contributions have to add. That’s what += does. Here’s the correct engine, the overwrite bug (= instead of +=), and the analytic answer, side by side:
exact: dL/da = -0.1807066389 dL/db = 0.2710599584
correct +=: dL/da = -0.1807066389 dL/db = 0.2710599584
BUG =(overwrite): dL/da = -0.3614132778 dL/db = 0.2710599584
The program still completes. It leaves dL/db correct because b has one outgoing path. For a, the multiply contribution lands last and overwrites the direct addition contribution. The remaining value is -0.3614132778, exactly the route-through-m result derived above. The factor of two relative to the correct total is a property of b=-2 and the direct +a route in this expression, not a general signature of an overwrite bug.
The second load-bearing line is the ordering. The backward pass has to visit a node only after every node that consumes its output, because a node’s back reads out.grad, and out.grad isn’t finished accumulating until all its children have run. Reverse topological order guarantees that. To see what “guarantees” is buying, I kept the correct += rules and just called the closures in forward build order instead of reversed:
BUG wrong order: dL/da = 0.0000000000 dL/db = 0.0000000000
Every leaf remains zero because its consumer runs before a gradient reaches that consumer’s output. The root runs last and deposits a gradient into z, but z.back has already run. No second traversal propagates the late value. Neither deliberate bug raises an exception because all operations remain valid floating point assignments. Correctness depends on the graph invariant, not on type checking.
the interval could not become zero
The central difference evaluates the expression at two nearby points:
(L(a+h) - L(a-h)) / (2h)
It is the slope of the chord between those points. The derivative is the limit as h approaches zero. A program must choose a finite, representable h, so the method is always an approximation. I swept powers of ten against the analytic binary64 calculation:
h=1e-01 err=8.78e-04 <- the fourth-decimal disagreement from the top
h=1e-02 err=8.78e-06
h=1e-04 err=8.78e-10
h=1e-05 err=6.68e-12 <- smallest observed error in this sweep
h=1e-06 err=4.88e-11
h=1e-08 err=3.82e-10
h=1e-10 err=7.81e-08
h=1e-12 err=1.78e-05 <- getting worse again
The left side of the valley is truncation error. The function curves across the interval, so the chord differs from the tangent. Reducing h shrinks that error. The right side comes from binary64 evaluation. L(a+h) and L(a-h) become nearly equal. Their leading bits cancel during subtraction, leaving a small difference with fewer trustworthy bits. Division by 2h magnifies the remaining error. The floating point investigation followed the same cancellation mechanism in ordinary arithmetic.
There is no step size that removes both errors. In this declared sweep, 1e-5 has the smallest observed error. A different function, input, or dtype can put the bottom elsewhere. Finite differences remain useful for detecting a sign error, a missing path, or a large factor. They are not a source of exact final bits.
I fitted the slope of log(error) against log(h) over h=1e-1 through 1e-4, before cancellation dominates. Central difference measures slope 2.000. Forward difference, (L(a+h)-L(a))/h, measures 0.993:
forward-diff log-log slope (h=1e-1..1e-4): 0.993 (theory 1)
central-diff log-log slope (h=1e-1..1e-4): 2.000 (theory 2)
A slope near one means reducing h by ten reduces truncation error by about ten. A slope near two means the error falls by about one hundred. The central formula cancels the first-order truncation term by sampling both sides:
forward difference (L(a+h)-L(a))/h :
h=1e-04 err=1.636e-05
h=1e-06 err=1.636e-07
h=1e-08 err=3.819e-10 <- forward's best
h=1e-10 err=7.810e-08
The forward estimate’s smallest error in this sweep is 3.819e-10 at h=1e-8. The central estimate reaches 6.679e-12 at h=1e-5. Neither minimum is a general recommendation. The table records the error surface of one function in one dtype.
central difference (L(a+h)-L(a-h))/2h :
h=1e-05 est=-0.180706638930 err=6.679e-12
h=1e-06 est=-0.180706638875 err=4.883e-11
h=1e-08 est=-0.180706638542 err=3.819e-10
the subtraction could be removed
For analytic functions, a complex perturbation avoids subtracting two nearby real outputs. Evaluate at a+ih, take the imaginary component, and divide by h:
For a function with a valid analytic continuation, its Taylor series contains:
f(a+ih) = f(a) + ih f'(a) - h² f''(a)/2 - ih³ f'''(a)/6 + ...
The imaginary component divided by h is f'(a) plus an error proportional to h². There is no subtraction of two nearly equal values. I changed math.tanh to cmath.tanh and swept the step:
complex-step Im(L(a+ih))/h :
h=1e-001 est=-0.1798282148870546 err=8.784e-04
h=1e-002 est=-0.1806978572767289 err=8.782e-06
h=1e-006 est=-0.1807066389235607 err=8.785e-14
h=1e-008 est=-0.1807066389236485 err=2.776e-17
h=1e-100 est=-0.1807066389236485 err=2.776e-17
h=1e-200 est=-0.1807066389236485 err=2.776e-17
h=1e-250 est=-0.1807066389236486 err=0.000e+00
Below h=1e-8, the comparison with the analytic binary64 expression stays within 2.776e-17, and one row happens to subtract to zero. That means both computations produced the same binary64 value in that row. It does not prove either is the exact real-number derivative.
The method has a structural limit. A function must accept complex input and preserve an analytic continuation. I captured three controls:
complex step:
tanh at 1.5: 0.18070663892364855
ReLU branch at +2: 1.0 expected local real slope 1
ReLU branch at -2: 0.0 expected local real slope 0
ReLU branch at 0: 0.0 real derivative undefined
abs at +2: 0.0 real derivative is 1
Python cannot compare a complex number with zero, so max(0, 2+ih) raises TypeError. I supplied a ReLU-like branch based on the real component. It reproduces the local slopes away from zero but returns one arbitrary branch value at the kink, where the real derivative does not exist. abs is the silent failure: abs(2+ih) is real, so its imaginary component is zero and the complex-step estimate is zero even though the real derivative at 2 is one.
The method is a strong check for the smooth scalar expression. It is not a drop-in gradient checker for a program containing non-analytic operations, comparisons, casts, indexing decisions, or complex-unsupported kernels.
The dtype comparison remains separate. The tape uses Python binary64 values. PyTorch float32 gives dL/da = -0.1807066202, differing by 1.87e-8. Both computations apply the same derivative expression to rounded intermediate values with different precision.
one checked coordinate left seven places to be wrong
Checking a in the scalar expression says nothing about the tape’s derivative
with respect to b. The XOR check perturbs one of seventeen parameters.
Selecting a few individual coordinates becomes weaker as a model grows. A
single attention matrix in GPT-2 has hundreds of thousands of entries, and a
bug can be confined to a slice the checker never touches.
A finite difference can check a whole direction at once. Let the parameters be
a vector x, choose another vector v, and evaluate:
L(x + h*v) - L(x - h*v)
-------------------------
2h
This estimates the rate at which the loss changes when every parameter moves
in direction v. If the analytic gradient is g, the chain rule predicts the
same directional derivative as a dot product:
g dot v = sum over i of g[i] * v[i]
The dot product works because parameter x[i] moves by v[i] units for one
unit of movement along the chosen direction. Every coordinate contribution
adds into the one scalar rate.
I built an eight-parameter float64 function:
L(x) = sum(tanh(A*x)²)
The fixed matrix A has six rows. PyTorch computes the eight-entry gradient.
Five random direction vectors are normalized to length one, then checked with
central differences at h=1e-5:
direction numerical slope gradient dot v absolute error
0 -0.053887421725 -0.053887421730 5.501e-12
1 0.019459103751 0.019459103748 2.887e-12
2 0.790708188125 0.790708188117 7.882e-12
3 0.748836225295 0.748836225108 1.871e-10
4 -0.031304150250 -0.031304150274 2.380e-11
The largest error is 1.871e-10. To make the negative control precise, I add
0.1 to gradient coordinate three while leaving coordinate zero untouched.
A coordinate-zero check reports zero error. The five directional checks report
errors between 0.004327 and 0.06596, so each one exposes the altered
coordinate through its nonzero component in that direction.
Random directions do not prove a gradient correct. A bad gradient can be orthogonal to one chosen direction, and a small bad component can fall below finite-difference error. Several independent directions make a large, structured discrepancy harder to miss while requiring two forward evaluations per direction instead of two per parameter.
The checker also needs a deterministic function. If dropout draws a different
mask at x+h*v and x-h*v, the numerator contains the effect of both the
parameter change and the mask change. Batch statistics, data augmentation,
concurrent reductions, and nondeterministic kernels can create the same
problem. Holding random state and inputs fixed is part of the measurement, not
an optional cleanup.
A point at a nonsmooth boundary requires another interpretation. ReLU at zero has no ordinary derivative, yet an autograd framework must choose a backward value for execution. A finite difference crossing the boundary can disagree with that chosen convention even when the implementation follows its documented rule. Moving the check point away from the boundary tests the local branch. Testing the boundary checks the framework’s convention rather than a unique mathematical derivative.
Gradient checking therefore asks a narrower question than training: does the recorded local backward program agree with independent local measurements at this fixed state? It can catch a missing transpose, an overwrite instead of an accumulation, a sign error, or a wrong scale. It cannot show that the loss surface is easy to optimize or that the gradient remains useful after a large step.
one output changed the useful direction
The tape uses reverse mode, but automatic differentiation does not have to run backward. Forward mode carries a value and its derivative together. A pair
(value, tangent)
records the ordinary value and how that value changes for one selected input direction. Addition adds both components. Multiplication follows the product rule:
(a, da) * (b, db)
= (a*b, da*b + a*db)
To get the derivative with respect to the first input, seed its tangent with 1 and every other input with 0. Every operation propagates that one nudge alongside its value. This is often implemented with dual numbers, but the pair above contains the mechanism.
I applied forward and reverse mode to the same expression with four scalar inputs and one scalar output:
L = tanh(x0*x1 + x2*x2 + x3*x3)
Both modes return:
dL/d[x0,x1,x2,x3]
= [-0.068031297262,
0.051023472946,
0.017007824315,
-0.051023472946]
One forward-mode seed traverses the six graph operations once and gives one directional derivative. Obtaining all four partial derivatives takes four seeds and 24 graph-operation evaluations. Reverse mode runs the six forward operations once, seeds the one output, then invokes six backward closures to produce all four leaf gradients.
forward graph operations per seed: [6, 6, 6, 6]
forward operations for full gradient: 24
reverse backward closures for gradient: 6
maximum gradient difference: 0
These counts exclude the arithmetic inside each tangent or closure and do not predict wall time in a tensor framework. They expose the scaling choice. Forward mode is attractive when there are few input directions and many outputs. Reverse mode is attractive when many parameters feed a small number of outputs. Training usually reduces a batch to one scalar loss, while the model can have millions or billions of parameters. One reverse seed can reach every parameter.
For a vector output y and vector input x, all pairwise partial derivatives form the Jacobian matrix J, where J[i,j] is dy[i]/dx[j]. Materializing that whole matrix can be enormous. Reverse mode instead computes a vector-Jacobian product: an upstream vector v weights the output rows and the engine returns v^T J. A scalar loss is the special case with one output and seed 1.
This is why PyTorch requires an explicit gradient argument when backward starts from a non-scalar tensor. The argument is the upstream vector, not a request to build the full Jacobian. The same object appeared as out.grad in the scalar tape. Every local backward rule consumes an upstream gradient shaped like its output and returns contributions shaped like its inputs.
the transpose appeared because indices had to match
The model in the previous investigation repeatedly multiplies matrices. Let
Y = X W
with X shaped (2,3) and W shaped (3,2):
X = [[1,2,3], W = [[ 1, -1],
[4,5,6]] [ 2, 0],
[0.5, 3]]
Y = [[ 6.5, 8],
[17.0, 14]]
Backward does not begin with dY=1 unless Y is itself the scalar loss. Suppose later operations send this upstream gradient:
G = dL/dY = [[ 1, 2],
[-1, 0.5]]
Take one input value, X[0,1], which is 2. It contributes to both outputs in row 0:
Y[0,0] includes X[0,1] * W[1,0]
Y[0,1] includes X[0,1] * W[1,1]
The two paths contribute:
dL/dX[0,1]
= G[0,0] * W[1,0] + G[0,1] * W[1,1]
= 1*2 + 2*0
= 2
Repeating that path sum for every input produces:
dL/dX = G W^T
= [[-1.0, 2.0, 6.5],
[-1.5, -2.0, 1.0]]
The transpose makes the inner dimensions match and aligns each output coordinate with the weight that connected it to an input coordinate.
One weight, W[0,1], is reused by both input rows:
Y[0,1] includes X[0,0] * W[0,1]
Y[1,1] includes X[1,0] * W[0,1]
Its contributions add:
dL/dW[0,1]
= X[0,0] * G[0,1] + X[1,0] * G[1,1]
= 1*2 + 4*0.5
= 4
All weight gradients can be written:
dL/dW = X^T G
= [[-3.0, 4.0],
[-3.0, 6.5],
[-3.0, 9.0]]
The probe computes both formulas from the arrays and compares them with PyTorch float64 gradients. Every entry is equal, with maximum difference 0. The two transposes are not backward conventions to memorize. They fall out of which row and column participated in each forward output.
The operation also reveals what backward needs from forward. Computing dL/dX needs W. Computing dL/dW needs X. If those values are not otherwise available, the autograd node must retain them. For a transformer MLP, saved activation rows can be far larger than the small example. That retained state becomes the memory problem later in the article.
the target logit moved every other logit
The GPT-2 loss used below starts from one target token, but its first backward value is not confined to that token. The model produces one logit for each possible next token. A logit is an unrestricted score. Softmax turns the scores into positive probabilities that sum to one:
exp(z[i])
p[i] = -----------------------
sum over k of exp(z[k])
For three logits [2.0, 0.5, -1.0], the direct calculation gives:
p = [0.785597034589,
0.175290392140,
0.039112573271]
The first class is the target. Its negative log likelihood is:
L = -log(p[0])
= 0.241311296657
The logarithm changes a product of probabilities across several tokens into a sum of token losses. It also assigns a large penalty when the probability of the observed target is close to zero. The derivative can be found without differentiating a quotient for every pair of logits. Substitute the softmax definition into the loss:
L = -log(exp(z[target]) / sum(exp(z[k])))
= -z[target] + log(sum(exp(z[k])))
The derivative of the first term with respect to logit z[j] is negative one
when j is the target and zero otherwise. For the second term, let
S = sum(exp(z[k])). Its derivative is:
d log(S) / dz[j]
= (1 / S) * dS/dz[j]
= exp(z[j]) / S
= p[j]
Combining the two paths gives one compact rule:
dL/dz[j] = p[j] - 1 when j is the target
= p[j] otherwise
For the three logits:
dL/dz = [-0.214402965411,
0.175290392140,
0.039112573271]
PyTorch float64 agrees with each entry to within 6.939e-18 in the probe. The
gradient entries sum to 2.776e-17, which is zero at the rounding scale of
this calculation. That near-zero sum is not an accident. Adding the same
constant to every logit leaves all softmax probabilities unchanged because the
new exponential factor cancels between numerator and denominator. A movement
that raises every score equally cannot change the loss, so the gradient has no
component in that direction.
The signs make the update concrete. Gradient descent subtracts the gradient. It raises the target logit because its gradient is negative. It lowers every other logit because their gradients are positive. The size of each non-target push equals that token’s current probability. A wrong token receiving 0.17 probability is pushed down more strongly than one receiving 0.039.
Softmax also creates a numerical trap before backward begins. Evaluating
exp(1000) in binary64 overflows, even though the probabilities for logits
[1000, 999, 998] are ordinary:
stable probabilities:
[0.665240955775, 0.244728471055, 0.090030573170]
Subtracting the largest logit before exponentiation changes the vector to
[0, -1, -2]. Softmax is unchanged by that common shift, and every
exponential is now at most one. The probe’s naive Python calculation raises
OverflowError; the shifted calculation succeeds. A fused cross entropy
implementation can compute the equivalent logsumexp expression without
forming an unstable probability followed by a separate logarithm.
The upstream vector then meets the output matrix. If the last hidden row is
h with width D, and each vocabulary row W[j] produces one logit,
z[j] = sum over d of h[d] * W[j,d]
then the output weight gradient is an outer product:
dL/dW[j,d] = dL/dz[j] * h[d]
Every vocabulary row receives a gradient, not only the target row. The hidden state receives a weighted sum of all output rows:
dL/dh[d] = sum over j of dL/dz[j] * W[j,d]
That vector is the first upstream gradient entering the final normalization and the transformer blocks. It has exactly the shape of the final hidden row. The vocabulary dimension disappears because contributions from all logits add into it.
For a batch or a sequence, the program must also declare a reduction. Summing token losses makes the gradient scale with token count. Taking their mean divides every token contribution by that count. Ignored padding tokens change the denominator if the implementation defines the mean over non-ignored tokens. This is why two runs can use the same examples and still have different gradient magnitudes if one averages per token and another averages per sequence.
The small loss probe uses one row, one target, and the default mean, which is identical to the single token loss. The GPT-2 probe below also uses one target position. That keeps the first upstream vector traceable. It does not represent the weighting used by a complete language model training batch.
the softmax sent the error down three roads
The output gradient reaches an attention block as one tensor, then splits through values, attention weights, scores, queries, and keys. Writing those paths for one head exposes more accumulation than the scalar expression did.
The forward operations are:
S = Q K^T / sqrt(D)
A = softmax each row of S
O = A V
Q, K, and V each have one row per token and D columns for one head.
Row i of S contains the scores from query position i to every key
position. Row i of A contains probabilities that sum to one. Row i of
O is a weighted sum of all value rows.
I used two positions and width two:
Q = [[1.0, 0.0], K = [[1.0, 1.0], V = [[2.0, -1.0],
[0.5, 1.0]] [0.0, 2.0]] [0.0, 3.0]]
The score scale is sqrt(2). Forward produces:
S = [[0.7071, 0.0000],
[1.0607, 1.4142]]
A = [[0.6698, 0.3302],
[0.4125, 0.5875]]
O = [[1.3395, 0.3210],
[0.8250, 1.3499]]
Suppose the rest of the model returns this upstream gradient:
G = dL/dO = [[ 1.0, -0.5],
[-1.0, 2.0]]
The last forward operation was O=A V, so the matrix rule already derived
gives:
dL/dV = A^T G
dL/dA = G V^T
The numbers are:
dL/dV = [[ 0.2572, 0.4902],
[-0.2572, 1.0098]]
dL/dA = [[ 2.5, -1.5],
[-4.0, 6.0]]
One value row is reused by every query row. Its gradient adds contributions
from both rows of G, weighted by how much each query attended to that value.
One attention weight affects every output feature in its row. Its gradient is
the dot product between that row of G and the corresponding value row.
Softmax is the part where changing one input changes every output in the same
row. For one row with probabilities a and upstream gradient g, the
pairwise derivative is:
da[j]/ds[k] = a[j] * (1 - a[j]) when j equals k
= -a[j] * a[k] otherwise
A score raises its own probability and lowers the others because the row must still sum to one. Multiplying that Jacobian by the upstream vector can be written without constructing a square matrix:
dot = sum over j of g[j] * a[j]
dL/ds[k] = a[k] * (g[k] - dot)
The shared dot subtracts the component that would move all logits together.
Applying it to both rows gives:
dL/dS = [[ 0.8847, -0.8847],
[-2.4235, 2.4235]]
Each row sums to zero within floating point error. The measured row sums are
1.110e-16 and -4.441e-16. This is the same shift invariance seen in the
vocabulary softmax. Adding one constant to every score in a row cannot change
its probabilities, so backward removes that direction.
The score matrix came from Q K^T / sqrt(D). Applying the matrix rule and the
scale gives:
dL/dQ = (dL/dS) K / sqrt(D)
dL/dK = (dL/dS)^T Q / sqrt(D)
For the small arrays:
dL/dQ = [[ 0.6256, -0.6256],
[-1.7137, 1.7137]]
dL/dK = [[-0.2312, -1.7137],
[ 0.2312, 1.7137]]
The hand formulas match PyTorch float64 with maximum differences
2.220e-16 for queries, 2.776e-17 for keys, and zero for values. Those
differences are rounding in operation order. They are not a tolerance chosen
after a failed float32 check.
The division by sqrt(D) appears in backward too. It scales both query and key
gradients because it scaled every score in forward. Omitting it would make
those two gradients too large by sqrt(D) while leaving the value gradient
correct. A test that inspected only V would miss the bug.
GPT-2 adds a causal mask before softmax. A future position receives a value that acts like negative infinity, so its softmax probability is zero. In exact arithmetic, its local contribution remains zero. Implementations use a large negative finite value or a specialized masked kernel, and the backward definition must follow that representation without producing an invalid infinity subtraction. The two-position probe deliberately has no mask, so it checks the dense formulas rather than claiming to exercise that kernel path.
Queries, keys, and values are not independent leaves in the model. They are
three projections of the same residual input X:
Q = X Wq
K = X Wk
V = X Wv
Backward produces three contributions to the residual:
dL/dX = dL/dQ Wq^T
+ dL/dK Wk^T
+ dL/dV Wv^T
That plus sign is the original reused a problem at matrix scale. Overwriting
one route would erase information from an entire projection. The three weight
gradients similarly reuse X:
dL/dWq = X^T dL/dQ
dL/dWk = X^T dL/dK
dL/dWv = X^T dL/dV
Multiple heads repeat these operations on slices, concatenate their outputs, and pass them through another projection. Backward splits the concatenated gradient into head slices, runs each head’s paths, then adds their input contributions. A fused kernel may never materialize these named matrices in memory. It must still produce a result equivalent to these dependencies, subject to its declared dtype and numerical tolerance.
Layer normalization couples features in another direction. For one token row
with D features, it computes:
mean = sum(x[i]) / D
variance = sum((x[i] - mean)²) / D
xhat[i] = (x[i] - mean) / sqrt(variance + epsilon)
y[i] = gamma[i] * xhat[i] + beta[i]
Changing one feature changes the mean and variance used by every other
feature. Backward cannot treat this as D independent scalar divisions. Let
g be dL/dy, let u = g*gamma, and let
r = 1/sqrt(variance+epsilon). Combining the direct normalization path with
the mean and variance paths gives:
dL/dx[i] = r/D * (
D*u[i]
- sum(u)
- xhat[i] * sum(u*xhat)
)
The two sums are reductions across the whole feature row. They remove the components caused only by a common shift and a common rescaling of the row. The parameter gradients are simpler:
dL/dgamma[i] = g[i] * xhat[i]
dL/dbeta[i] = g[i]
The float64 probe uses x=[1,2,4], gamma=[0.5,1.5,-1],
beta=[0.25,-0.5,1], and upstream gradient [1,-2,0.5]. Its forward
statistics are:
mean: 2.333333333333
variance: 1.555555555556
inverse standard deviation: 0.801781148588
xhat: [-1.069041531450, -0.267260382863, 1.336301914313]
The hand formula and PyTorch return:
dL/dx:
[ 1.088132295123, -1.632197154115, 0.544064858993]
dL/dgamma:
[-1.069041531450, 0.534520765725, 0.668150957156]
dL/dbeta:
[1.0, -2.0, 0.5]
Maximum differences are 2.220e-16, zero, and zero. The three input
gradients sum to -2.220e-16. Adding a constant to every input leaves the
normalized row unchanged, so the backward result has no meaningful component
in that common-shift direction.
epsilon prevents division by zero when every feature is equal, but it also
changes the derivative. If the variance is much larger than epsilon, its
effect is small. If the variance is comparable to epsilon, the inverse
standard deviation and every input gradient depend materially on the chosen
constant. The probe uses 1e-5, matching the pinned GPT-2 configuration.
Changing it would define a different forward function, so a gradient check
must use the same value on both paths.
The formula also explains why accumulating its reductions in a wider dtype can
matter. Mean, variance, sum(u), and sum(u*xhat) combine many features.
Rounding in those reductions is then broadcast back into every element of the
row. The float64 probe tests the algebra with little rounding pressure. It
does not establish the error of a fused float16, bfloat16, or float32 kernel.
GPT-2 applies normalization before each attention or MLP sublayer and adds the sublayer output back to the residual. If one block is written:
y = x + F(layer_norm(x))
then the upstream gradient reaches x by two routes. One is the direct
residual route. The other runs through F and the coupled normalization
formula. The direct route is why the derivative contains an identity term,
but the second route can reinforce or oppose it. The measured residual norms
below are not expected to fall monotonically with depth.
Layer normalization backward needs the normalization result or enough saved statistics to reconstruct it. A fused implementation may save the mean and inverse standard deviation rather than every named intermediate. This is another place where the mathematical graph specifies dependencies while the implementation chooses which values to retain and which to recompute.
the tape reached the model from the previous run
The scalar rules and matrix transposes should reach the exact GPT-2 artifact from one forward pass. I loaded the same revision and made one artificial training target: increase the next-token probability of Paris after The capital of France is. The starting probability was 0.0322432, so its negative log likelihood was 3.434449.
The run used gpt2 revision
607a30d783dfa663caf39e06633721c8d4cfcd7e, Transformers 4.57.1,
PyTorch 2.9.1, eager attention, float32 model weights, evaluation mode, four
Torch CPU threads, and the Apple M4 in the current machine. The model reports
124,439,808 unique parameter elements. Evaluation mode disables its dropout;
use_cache=False prevents inference cache state from changing this backward
path. No GPU participated in the measurement.
This is not a claim that the prompt came from GPT-2’s training data with Paris as its observed target. It is a controlled loss chosen to produce one gradient through the pinned forward path.
I retained the thirteen residual states, called backward once, and inspected the last-token gradient norm at each state:
state 0: 2.18991232 state 5: 1.05256379 state 10: 0.29460910
state 1: 0.41888338 state 6: 0.95679313 state 11: 0.25041935
state 2: 0.74908227 state 7: 0.74968779 state 12: 2.86155653
state 3: 0.98113734 state 8: 0.50454092
state 4: 1.03733277 state 9: 0.36300051
The norms are not monotonic. A residual state participates in normalization, attention, MLP, residual, and final projection paths. Norm alone does not tell which parameter update is important or whether a layer is vanishing. It establishes that an upstream gradient reached every retained state.
The combined attention projection stores query, key, and value weights beside one another. Splitting its gradient on the output dimension gives:
layer ||dL/dWq|| ||dL/dWk|| ||dL/dWv||
0 1.309296 1.447122 15.353137
5 9.655268 6.891427 14.710448
11 3.499980 3.177557 5.239401
These are Frobenius norms over whole slices, not per-weight averages. The value slice has the largest norm in the three sampled layers except neither conclusion generalizes to other prompts, losses, or layers without a sweep.
I checked the direction on layer zero’s complete query slice. Moving the slice by L2 distance 0.01 against its normalized gradient lowered the loss from 3.434449 to 3.421283. Moving the same distance with the gradient raised it to 3.447407. Copying the original weights back restored 3.434449 exactly in the rerun.
That local intervention gives the gradient its operational meaning. For a sufficiently small step, negative gradient is a descent direction for this loss. It does not say that taking repeated fixed steps of 0.01 would train the model, preserve other prompts, or remain in the local linear region.
The scalar tape still creates one Python object, parent tuple, and closure for every elementary operation. I compared a length-1,000 dot product followed by tanh, forward and backward, with one Torch thread. After warmup, I randomized eleven rounds and used the median:
length-1000 dot + tanh, forward+backward
three process runs, eleven randomized rounds within each:
Python tape median range: 6474.8 to 6557.5 us
Torch median range: 8.7 to 10.2 us
per-process ratio range: 642.6 to 741.8 times
The range belongs to this microbenchmark, Python version, Torch build, and M4 CPU. It is not a framework throughput claim. The spread between process runs is also why one exact slowdown would imply more stability than the measurement has. The tape expresses the dot as thousands of scalar object operations. Torch expresses it as tensor operations over contiguous storage and sends the work to compiled kernels. Both use reverse accumulation, but that shared algorithm does not make their execution paths comparable at the instruction level.
the old gradient was still in the leaf
The tape initializes grad to zero, but it never clears gradients before a second backward call. PyTorch deliberately behaves the same way for leaf tensors. I ran backward twice on one retained graph:
maximum |gradient after two calls - 2*gradient after one call| = 0
This is accumulation, not stale memory by accident. A parameter can receive contributions from several graph paths in one backward call and from several backward calls before an optimizer step. The same += rule supports both.
It also creates a common failure. If an optimizer step is supposed to use one batch and the program forgets to clear .grad, the next step includes the previous batch’s gradient. PyTorch’s documentation explicitly says backward accumulates into leaf fields. Calling
optimizer.zero_grad(set_to_none=True)
sets those fields to None. A later backward can allocate them on first contribution. Setting them to numeric zero also works, but can require touching every gradient buffer. In the probe, every parameter gradient is None after the call.
Intentional accumulation is useful when one full batch does not fit in memory. Split seven examples into microbatches of three and four. The full loss is the mean squared error over all seven. If each microbatch computes its own mean, its backward contribution must be weighted by its fraction of the full batch:
for start, stop in [(0, 3), (3, 7)]:
micro_loss = mse(
model(inputs[start:stop]),
targets[start:stop],
reduction="mean",
)
fraction = (stop - start) / 7
(fraction * micro_loss).backward()
The fractions are 3/7 and 4/7. Their weighted sum is the seven-example mean. On a float64 linear layer:
full-batch gradient norm: 1.2828833995
weighted microbatch maximum difference: 1.11e-16
The remaining difference is at binary64 rounding scale. The negative control calls backward on each microbatch mean without weighting:
unweighted microbatch maximum difference: 0.6625104282
unweighted/full gradient-norm ratio: 2.0097860545
Adding two means gives each microbatch equal weight even though one has three examples and the other has four. It also roughly doubles the scale because two mean losses are summed. Dividing by the number of microbatches would fix equal-size microbatches, but it is wrong for the unequal split used here. Weighting by example count states the invariant directly.
Gradient accumulation can reduce the number of examples whose activations are live at once. It does not shrink model parameters, their gradient buffers, or optimizer state. It can also differ from a true large-batch step when the forward path depends on batch composition, random masks, running statistics, or a loss that couples examples. The exact equality above belongs to a deterministic linear layer with a separable mean loss.
The same scale problem appears when examples are split across processes instead of across backward calls. I simulated two data-parallel ranks holding three and four examples. Each rank has an identical two-output linear model and computes its local gradient.
If both ranks take a local mean and the system averages the two rank gradients equally, each rank receives half the influence:
naive global gradient = (rank0_mean + rank1_mean) / 2
That gives each of the three examples on rank zero weight 1/6. Each of the
four examples on rank one receives weight 1/8. A global seven-example mean
should give every example weight 1/7. The measured maximum gradient error is
0.16242961.
Weighting by local example count restores the intended mean:
global gradient = (3*rank0_mean + 4*rank1_mean) / 7
The maximum difference from a single-process seven-example gradient falls to
2.220e-16. An equivalent convention has each rank compute a sum, adds those
sums across ranks, then divides by the global number of loss elements. Because
the probe’s mean squared error has two output elements per example, that
denominator is 7*2. It also agrees within 2.220e-16.
This arithmetic precedes any choice of communication algorithm. An all-reduce can return a sum or a framework can divide by the world size around it. Either is correct only when the loss reduction and local batch sizes make the final scale equal to the intended objective. Equal local batch sizes make an average of rank means correct, which can hide the assumption until one rank receives a short final batch, a filtered example, or a variable number of tokens.
Language model batches make the denominator especially easy to lose. One rank may have the same number of sequences but fewer non-padding target tokens. Averaging rank-level mean token losses equally weights ranks, not tokens. A global token mean requires the summed loss numerator and valid-token count to be reduced consistently.
The simulator runs in one process and performs ordinary tensor additions. It does not measure all-reduce latency, overlap, bucket scheduling, or floating point differences caused by another reduction tree. It isolates the invariant that communication must preserve: every local contribution must arrive once, and the final divisor must describe the objective actually being optimized.
the leaf was a different kind of node
The small tape stores a grad field on every Value, so inspecting an
intermediate after backward works by default. PyTorch separates the gradient
needed while executing the graph from the gradient retained for the user. A
tensor produced by an operation has a grad_fn that points into the backward
graph. A parameter created directly by the user is a leaf. It has no creating
operation, but it can own a persistent .grad buffer.
This program makes the distinction visible:
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = x * x
print(x.is_leaf, x.grad_fn) # True, None
print(y.is_leaf, y.grad_fn) # False, MulBackward...
y.sum().backward()
print(x.grad) # tensor([4., 6.])
print(y.grad) # not retained by default
The backward operation needs dL/dy in order to compute dL/dx, but that
does not require keeping y.grad after its consumer has run. Releasing
intermediate gradient buffers shortens their lifetime. Calling
y.retain_grad() asks PyTorch to retain this one:
leaf grad: [4.0, 6.0]
retained nonleaf grad: [1.0, 1.0]
The nonleaf gradient is one because the scalar loss was y.sum(). Each element
of y contributes to the sum with local derivative one.
The PyTorch 2.9 autograd mechanics note
states that the forward pass builds a graph of Function objects and that only
leaf tensors with requires_grad=True accumulate into .grad by default.
It also says the graph is recreated on each iteration. An if statement can
select a different operation path on the next example because eager autograd
records the path that actually executed. The backward pass cannot send a
gradient through a branch that the forward pass did not take.
The versioned C++ source shows the machinery that replaces the tape’s one
Python list. In
engine.cpp for PyTorch 2.9.1,
a backward invocation is represented by a GraphTask. Work is placed in
ready queues associated with devices. The engine counts unresolved
dependencies and schedules a node when the contributions expected from its
consumers are ready. Input buffers collect those contributions before the node
function runs.
That dependency count performs the same logical job as the tape’s reversed topological list. Consider a reused value:
square
/ \
x -> branch A branch B -> add -> loss
The square node cannot run backward as soon as branch A contributes. Branch B
still owes another contribution. Running early would send an incomplete
gradient to x. The small tape avoids that by giving every consumer a chance
to run before its parent. The engine can execute independent ready nodes
without imposing one global serial list, but it must preserve the same
dependency invariant.
At the boundary, an
AccumulateGrad node
owns the policy for adding an incoming gradient to a leaf’s existing buffer.
The implementation has cases for stealing a new gradient buffer, cloning it
when layout or reference-count conditions require that, and adding into an
existing buffer. Sparse, dense, and higher-order differentiation conditions
make this more complicated than self.grad += contribution. The observable
contract in this article is narrower: repeated backward calls accumulate, and
clearing the field changes the next call from addition to first assignment.
Device execution adds another constraint absent from the Python tape.
engine.cpp documents ready queues per worker device and stream handling for
CUDA and other device backends. A gradient tensor produced on one stream
cannot be consumed on another until the required ordering is established.
Graph order and device completion order are related but not identical. A node
being next in the mathematical dependency graph does not mean its device work
has already completed.
I did not instrument these C++ queues or run a CUDA backward trace here. The queue and accumulator statements come from the pinned source and official mechanics note. The local probes establish the user-visible consequences: path contributions add, leaves retain gradients, nonleaves do not retain them unless asked, and a second call adds to the first call’s result.
the backward pass asked for values that had been discarded
The matrix rule needs forward X to compute dW and forward W to compute dX. GELU backward needs enough information to evaluate its local slope. Attention backward needs queries, keys, values, and softmax-related state or a way to recompute them. Keeping every useful intermediate makes backward faster but can make activation memory larger than the parameter payload.
Gradient checkpointing chooses selected boundaries to keep. Intermediate values inside a checkpointed region are discarded after forward. During backward, the framework reruns part of that region to recreate the values needed by local derivative rules. The PyTorch checkpoint documentation describes this as trading compute for memory and requires the use_reentrant choice to be explicit in version 2.9. The probe uses use_reentrant=False.
I built twelve residual MLP blocks with width 256 and batch 32, then split them into six checkpoint segments. A saved-tensor hook counted every tensor payload the autograd graph requested to retain. It does not measure allocator reservation or process peak RSS:
plain checkpointed
saved tensors 61 21
saved-tensor payload 13.91 MiB 2.50 MiB
block forward calls 12 22
median time range over 3 runs 2.56 to 2.97 ms 4.52 to 4.73 ms
The checkpointed graph requests 17.98% as many saved bytes. Block forward calls rise from 12 to 22 because backward recomputes blocks inside segments. Non-reentrant checkpointing can stop recomputation once all needed tensors have been recreated, so the count does not have to reach 24.
The payload counter records every tensor presented to the saved-tensor hook. That is closer to “bytes the backward definitions asked to retain” than to “bytes newly allocated.” Two saved tensor objects may refer to the same storage. An allocator may reserve more memory than the live tensors request. Temporary workspace outside the saved set can raise the actual peak. The hook is still useful for comparing these two executions because the model, inputs, allocator, and counting rule remain fixed.
The shapes explain why activations eventually dominate. A residual tensor with
batch B, sequence length T, hidden width D, and element size s occupies:
B * T * D * s bytes
For the pinned GPT-2 configuration, D=768. One fp32 residual state for the
five-token prompt occupies:
1 * 5 * 768 * 4 = 15,360 bytes
Thirteen states, one before the first block and one after each of twelve blocks, total 199,680 bytes. This is a derived lower bound for those particular states, not the model’s total saved activation memory. At sequence length 1,024, one such state is 3 MiB and thirteen are 39 MiB. Increasing the batch to eight makes that 312 MiB. Parameter bytes did not change; activation bytes grew with both batch and sequence length.
Attention introduces a different shape. Materializing one score per head for
each query and key position uses B*H*T*T elements. With twelve heads,
sequence length 1,024, batch one, and fp32 elements:
1 * 12 * 1024 * 1024 * 4 = 50,331,648 bytes = 48 MiB
That is per layer. Twelve dense layers would make the score payload 576 MiB if each layer materialized and retained one full tensor of that shape. The five-token probe’s corresponding tensor is only 1,200 bytes per layer. Sequence length grew by about 205 times, while the score element count grew by about 41,943 times because both position axes grew.
This calculation is not a measurement of the pinned implementation. A fused attention kernel can avoid materializing the complete score matrix, save different statistics, or recompute parts during backward. Masks, dtype, attention layout, and kernel choice change the actual storage. The formula identifies the pressure those implementations try to remove.
The checkpoint probe contains residual MLP blocks, not attention. Its 82 percent hook-payload reduction cannot be applied to a transformer by percentage. Checkpoint boundaries also matter. More segments retain more boundary tensors but shorten recomputation intervals. Fewer segments save fewer boundaries and redo more work. The useful configuration is constrained by the peak live set, not by the total sum of tensor sizes across the whole iteration.
Each process run contains eleven randomized timing rounds. The per-process checkpoint-to-plain median ratio ranges from 1.53 to 1.79. That interval is specific to this CPU model and short benchmark. The output arrays match exactly and the maximum parameter-gradient difference is zero in every verification run. Those controls matter because recomputation can be incorrect if the function depends on changed global state or produces different random behavior. PyTorch preserves random-number-generator state by default for checkpointed regions, which itself has overhead.
The original checkpointing paper, Training Deep Nets with Sublinear Memory Cost, analyzes schedules that retain selected boundaries and recompute between them. My hook result is not a reproduction of its large-network memory or runtime numbers. It is the mechanism on a local graph: fewer saved tensor bytes, more forward calls, identical checked gradients.
the saved value changed while the graph waited
Retaining a tensor is useful only if backward sees the same value that forward
used. I made y = sum(x*x), changed x in place, and then called backward:
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = (x * x).sum()
with torch.no_grad():
x.add_(1.0)
y.backward()
The multiplication’s derivative needs the original x, [2,3]. The storage
now contains [3,4]. Silently using the new values would return [6,8]
instead of the forward expression’s gradient [4,6]. PyTorch 2.9.1 rejects
the call:
one of the variables needed for gradient computation has been modified
by an inplace operation: [torch.DoubleTensor [2]] is at version 1;
expected version 0 instead.
The abbreviated message in the probe continues with a suggestion to enable anomaly detection. The autograd mechanics note explains the check. A tensor has a version counter. Saving a tensor for backward also saves its current version. An in-place operation increments the counter. Retrieving a saved tensor compares the versions and raises when they differ.
torch.no_grad() in the example prevents the update itself from becoming a
new differentiable operation. It does not prevent the storage mutation or the
version increment. Grad mode answers whether operations are recorded. It does
not turn an in-place write into a harmless write.
The reason this check cannot be replaced by a blanket ban is that some
backward formulas do not need every forward input. Addition can send the same
upstream gradient to both inputs without reading their old numeric values.
Multiplication needs the other operand. Exponential backward may save its
output rather than its input because the derivative of exp(x) equals the
output. The framework’s generated backward definition determines what is
saved. Code that mutates an unrelated or unsaved tensor may run, while a
seemingly similar mutation of a saved tensor fails.
Views make the storage question less obvious. A transpose or slice can have a different shape and stride while referring to the same underlying bytes. Writing through one view can invalidate a value saved through another. Object identity alone is therefore insufficient; the version follows the shared storage relationship that matters to autograd.
Saved values have another lifetime boundary. After a normal backward call,
PyTorch releases graph state that is no longer needed. Calling backward again
on the same y produces:
Trying to backward through the graph a second time
(or directly access saved tensors after they have already been freed).
This is distinct from gradient accumulation. The leaf .grad field persists,
but the operation graph that could produce another contribution has been
released. Passing retain_graph=True to the first backward retains that graph.
In the local control, clearing x.grad between calls and running the retained
graph a second time reproduces [4,6].
Retaining a graph extends the lifetime of saved tensors and therefore its memory cost. Rebuilding the forward pass usually creates a fresh graph with fresh values, which is why an ordinary training loop does not retain every iteration. A program should retain the graph only when another traversal of that exact execution is necessary.
Higher-order differentiation asks for something stronger. The first backward
calculation must itself be recorded as differentiable operations. For
f(x)=x³ at x=2, the probe uses torch.autograd.grad with
create_graph=True:
first derivative: 3x² = 12
second derivative: 6x = 12
retain_graph keeps the old derivative graph available. create_graph
records a new graph for the derivative computation so another derivative can
be taken. They solve different lifetime problems. The thirty-line tape stores
local derivative arithmetic as ordinary Python floats inside closures, so its
backward pass cannot be differentiated again. Supporting higher-order
derivatives would require its backward rules to build Value operations
instead of consuming the graph into numbers.
seventeen parameters found a bad corner
I attached the scalar engine to a two-input, four-hidden-unit, one-output MLP with tanh activations. Its seventeen parameters try to fit the four XOR rows. A linear decision boundary cannot separate XOR, so the hidden nonlinear layer is necessary.
I added subtraction and negation to Value, formed a sum of squared errors, and checked one weight before training:
gradcheck W1[0][0]: tape=0.00007575 central-diff=0.00007575 |diff|=9.89e-13
The central difference differs from the tape by 9.89e-13 at h=1e-5. That checks one derivative at one parameter state. It does not prove all seventeen gradients or every training step, but it makes a gross backward-rule error less likely.
Full-batch gradient descent with learning rate 0.5 stalls for seed 0:
step 0 loss 5.260514
step 400 loss 4.000015
step 2000 loss 3.999534
final predictions (want -1,+1,+1,-1):
0,0 -> -0.9883 (target -1)
0,1 -> +0.9985 (target +1)
1,0 -> +0.9999 (target +1)
1,1 -> +0.9998 (target -1)
Three rows are close to their targets. The (1,1) error is almost 2, whose square accounts for almost all of the loss near 4. The largest absolute parameter gradient after 2,000 steps is 1.757e-3. That is an observed small gradient, not by itself proof that every hidden unit saturated or that no descent path exists.
I repeated the fixed architecture, uniform initialization, learning rate, and 2,000-step budget for ten seeds:
plain SGD lr=0.5, 2000 steps, per seed final loss:
seed 0: 3.9995 STUCK seed 1: 0.0001 seed 2: 0.0002
seed 3: 0.0002 seed 4: 3.9998 STUCK seed 5: 4.0000 STUCK
seed 6: 8.0000 STUCK seed 7: 4.0000 STUCK seed 8: 3.8899 STUCK
seed 9: 4.0000 STUCK
stuck 7/10 seeds
Seven finish above the probe’s arbitrary failure threshold of 0.5. This is not a success-rate estimate for XOR networks. Changing width, activation, initialization scale, learning rate, loss, or step count changes the experiment. It establishes sensitivity within one declared protocol.
Changing seed 0 to Adam with global step size 0.05 reaches:
Adam lr=0.05 on seed 0: final loss 0.000089
0,0 -> -0.9973 0,1 -> +0.9952 1,0 -> +0.9948 1,1 -> -0.9944
The optimizer changes the first update, which changes the next parameter state and therefore every later gradient. This run shows a rescue under one Adam setting. It does not show that Adam is the only optimizer that can solve XOR or that full-batch gradient descent cannot succeed under another schedule.
the first optimizer comparison was rigged by one setting
The update
weights = weights - learning_rate * gradient
is full-batch gradient descent when gradient is computed from every example. It becomes stochastic gradient descent when the gradient comes from a sampled example or minibatch. The existing quadratic probe uses all 200 rows every step, so calling it SGD was incorrect.
Its second input feature is scaled by 100. Mean squared error produces a two-dimensional quadratic whose Hessian, the matrix of second derivatives, has eigenvalues:
lambda_min = 1.840138
lambda_max = 21180.873551
condition number = 11510.48
An eigenvector is a direction in which the Hessian only scales rather than turns a vector. Along a direction with curvature lambda, one gradient-descent error component is multiplied each step by:
1 - learning_rate * lambda
For that component to shrink rather than oscillate outward, its absolute value must remain below one. With positive curvature this requires:
0 < learning_rate < 2/lambda
The most curved direction sets the global ceiling:
2 / 21180.873551 = 9.44248e-5
A larger fixed step is unstable on this exact quadratic. A safe step remains slow along the low-curvature direction. That conflict explains why full-gradient descent makes little progress in 2,000 steps. It does not imply that every neural-network loss has the same spectrum or that gradient descent is obsolete.
The original article compared one momentum setting, one RMSprop setting, and one Adam setting. A bad hyperparameter can make any of them look uniquely broken. I replaced that comparison with declared grids, each starting from zero and receiving 2,000 full-gradient steps:
method settings tried finite runs best final loss
gradient descent 8 7 0.51200307
momentum 28 28 0.00009920
RMSprop 21 21 0.00009920
Adam 28 28 0.00009920
The best configurations were:
gradient descent: lr=8e-5
momentum: lr=1e-4, momentum=0.98
RMSprop: lr=0.01, squared-gradient decay=0.999
Adam: lr=0.01, beta1=0.9, beta2=0.999
Tuned momentum, RMSprop, and Adam all reach the noise floor of this synthetic fit. The earlier conclusions that momentum necessarily diverged and RMSprop inherently crawled came from one setting each. The grid does not rank the optimizers in general either. It tunes and evaluates on the same deterministic problem, uses a narrow hand-selected search, ignores per-step cost, and reports training loss rather than held-out behavior.
Adam is still worth following because its state appears in training memory budgets. The original Adam paper maintains an exponential average of gradients and an exponential average of squared gradients:
m[t] = beta1*m[t-1] + (1-beta1)*g[t]
v[t] = beta2*v[t-1] + (1-beta2)*g[t]²
Both start at zero, which biases early averages toward zero. Bias correction divides by the missing accumulated mass:
m_hat = m[t] / (1-beta1^t)
v_hat = v[t] / (1-beta2^t)
The update is:
weight -= alpha * m_hat / (sqrt(v_hat) + epsilon)
alpha remains a global step-size parameter. The denominator rescales coordinates according to their squared-gradient history, and m_hat contributes direction and momentum. Calling alpha/(sqrt(v_hat)+epsilon) the complete per-parameter learning rate omits the numerator and can misdescribe the actual step.
For P parameters, Adam commonly retains two state values per parameter in addition to parameters and gradients. If all four collections are fp32, the simple payload is:
parameters: 4P bytes
gradients: 4P bytes
first moment: 4P bytes
second moment: 4P bytes
total: 16P bytes
That arithmetic excludes activations, temporary buffers, allocator overhead, mixed-precision copies, and distributed sharding. Optimizer choice therefore changes both the update path and persistent memory, even when several methods reach the same loss on the two-parameter probe.
The pinned GPT-2 count makes 16P less abstract:
P = 124,439,808 parameter elements
one fp32 collection:
124,439,808 * 4 bytes
= 497,759,232 bytes
= 0.464 GiB
parameters + gradients + two Adam moments:
4 * 497,759,232 bytes
= 1,991,036,928 bytes
= 1.854 GiB
This is derived payload, not measured peak memory. It counts a tied parameter
once because model.parameters() reports the unique parameter objects. It
does not count optimizer bookkeeping objects, alignment, cached allocator
blocks, or a temporary update buffer. Some optimizer implementations allocate
moment state lazily on the first step, so memory immediately after model load
can be lower than memory after the first update.
Mixed precision does not have one universal bytes-per-parameter number. One arrangement may keep fp16 weights for forward, fp16 or fp32 gradients, an fp32 master weight, and two fp32 moments. With fp16 gradients that simple list is:
fp16 working weights: 2P bytes
fp16 gradients: 2P bytes
fp32 master weights: 4P bytes
fp32 first moment: 4P bytes
fp32 second moment: 4P bytes
total: 16P bytes
Using fp32 gradient buffers changes the total to 18P. Keeping no separate
master copy changes it again. Quantized optimizers, factored state, or state
offload add other representations and execution costs. A memory estimate must
name which copies exist rather than attach one multiplier to the phrase
“mixed precision.”
At larger model sizes, data-parallel replication puts these persistent collections on every rank. Sharding can partition gradients, optimizer state, and parameters so a rank owns only part of a collection, but the missing values then have to be communicated when an operation needs them. The memory problem becomes a scheduling and network problem rather than disappearing. The checkpoint probe addresses activation retention, which scales with batch and sequence shape. Sharding optimizer state addresses persistent parameter-related payload. They act on different terms in the peak.
thirty local slopes multiplied into almost nothing
The scalar engine can isolate depth without an optimizer. I built N repetitions of multiply then activation and measured the input gradient. With tanh and w=1:
tanh chain, w=1.0, dL/dx reaching the input:
depth 1: dL/dx=7.864e-01
depth 10: dL/dx=2.228e-01
depth 50: dL/dx=3.396e-02
depth 100: dL/dx=1.306e-02
Because w=1, each backward factor here is just 1-tanh², at most one in magnitude. The product reaches 0.013 at depth 100. With w=3, each local factor also includes the multiplication by 3, but tanh saturates close enough to one that its derivative dominates and the product collapses:
tanh chain, w=3.0 (units saturate):
depth 5: dL/dx=8.204e-07
depth 10: dL/dx=2.170e-14
depth 20: dL/dx=1.518e-29
depth 30: dL/dx=1.062e-44
At depth 6 the binary64 input gradient is 2.50e-8, below fp16’s smallest positive subnormal 5.96e-8, so this value rounds to zero when cast by the local probe.
ReLU provides a different control. Its local derivative is one on a positive branch and zero on a negative branch. With positive input and w=1, every selected branch has derivative one:
relu chain, w=1.0 (active path):
depth 1: dL/dx=1.000e+00
depth 50: dL/dx=1.000e+00
depth 100: dL/dx=1.000e+00
The result remains one because this chain deliberately keeps every unit active and every weight equal to one. It does not test negative branches, changing activation patterns, matrices, or data. Raising every scalar weight to 1.2 makes the product grow:
relu chain, w=1.2:
depth 10: dL/dx=6.192e+00
depth 20: dL/dx=3.834e+01
depth 50: dL/dx=9.100e+03
The exact result is 1.2^50 = 9,100.44. The probe has constructed an exploding product rather than discovered an emergent property of a trained network.
ReLU is not the universal replacement for tanh. The pinned GPT-2 path uses GELU, and many other architectures use other smooth or gated activations. Residual connections create an additional derivative route. If
y = x + F(x)
then locally
dy/dx = 1 + dF/dx
For vectors, the identity matrix is added to the Jacobian of F. This gives the upstream gradient a direct route around F, but it does not guarantee stable norms. The two routes can reinforce or cancel, and normalization and weight structure also matter. The scalar chains isolate products of local factors. They do not explain the training stability of a complete architecture by themselves.
the gradients vanished before the update
The depth experiment ended with a binary64 gradient of 2.50e-8. The number
was nonzero, but casting it to fp16 produced zero. That is a second way for a
gradient to vanish. The chain rule may deliver a signal and the chosen number
format may then erase it.
I did these casts on the CPU. They establish what the formats can represent. They do not measure the speed, instruction selection, or accumulation behavior of an accelerator. The distinction matters because a format can exist in memory while an operator accumulates internally in another format.
Binary16, usually called fp16, has exact landmarks:
smallest positive normal: 2^-14 = 6.104e-5
smallest positive subnormal: 2^-24 = 5.960e-8
largest finite value: 65504
gap from 1.0 to the next value: 9.766e-4
The normal range is where every represented value has the format’s full
significand precision. Subnormal values extend the range towards zero by
giving up leading significant bits. Zero is not reached at the smallest
subnormal itself. With round to nearest, a positive value below half that
subnormal rounds to zero. Values on either side of a midpoint also depend on
the tie rule. Saying that everything below 5.960e-8 becomes zero would
therefore be wrong.
The local casts make the boundary visible:
grad 1e-04: fp16=1.000e-04
grad 1e-06: fp16=1.013e-06
grad 1e-07: fp16=1.192e-07
grad 1e-08: fp16=0.000e+00
The value 1e-7 rounds to two subnormal steps. The value 1e-8 is below half
of one step and rounds to zero. Long before that final rounding, representation
error is already changing the gradient:
relative representation error, |cast(x) - x| / |x|
x=1e-03: fp16 error=4.044e-04
x=3e-05: fp16 error=6.288e-04
x=1e-06: fp16 error=1.328e-02
x=3e-08: fp16 error=9.868e-01
At 1e-6, the nearest fp16 number differs by 1.3 percent. At 3e-8, it
rounds to one minimum subnormal, almost twice the source value. A gradient does
not need to become zero before its magnitude is badly distorted.
The chain rule supplies a direct way to move these values. If the loss is replaced by
L_scaled = S * L
then every leaf gradient is multiplied by the same scale S:
dL_scaled/dw = S * dL/dw
The optimizer must divide by S before using the gradient. With
S = 65536, the local cast gives:
1e-8 * 65536 -> fp16 6.552e-4
6.552e-4 / 65536 = 9.997e-9
The recovered result is not the original bit pattern. The fp16 rounding error remains. It is far smaller than the error from rounding the unscaled value to zero.
This technique also has an upper boundary. The scale multiplies large gradients along with small ones:
grad 0.1 * 65536 = 6553.6 -> fp16 6552
grad 1.0 * 65536 = 65536.0 -> fp16 inf
grad 2.0 * 65536 = 131072.0 -> fp16 inf
A fixed scale that rescues one tensor may overflow another. The small dynamic
scaling probe starts at 65536, checks all scaled gradients for nonfinite
values, discards an overflowing attempt, and halves the scale. For a batch
containing gradients 2.0 and 1e-8, it settles at 16384:
big: 2.0 * 16384 -> 32768 -> divide -> 2.0000
tiny: 1e-8 * 16384 -> 1.638e-4 -> divide -> 9.997e-9
This is a mechanism demonstration, not a complete scaler. A production implementation also decides when to grow the scale after successful steps, how often to inspect it, and how skipped optimizer and scheduler steps interact. The mixed precision paper describes the fp16 method with an fp32 copy of the weights and loss scaling. Current framework behavior must be checked against the particular framework version, device, and operator.
Loss scaling protects a gradient while it passes through a narrow format. It
does not solve the update at the other end. The local trajectory starts a
weight at 1.0 and adds 0.0003 two thousand times:
pure fp16 weight after 2000 updates: 1.000000
fp32 master after 2000 updates: 1.600100
arithmetic result before rounding: 1.600000
Near 1.0, adjacent fp16 values are 0.0009765625 apart. The update is less
than half that distance, so each individual fp16 addition rounds back to
1.0. The update itself did not underflow. It was lost when added to a much
larger value, the same swamping mechanism measured in
the earlier floating point experiment.
The fp32 trajectory differs from 1.600000 by about 0.0001 because two
thousand fp32 additions round too. More precision reduces the accumulated
error; it does not turn floating point into real arithmetic. Casting the fp32
weight to fp16 for a forward operation also rounds the value seen by that
operation. The important difference is that the next small update is added to
the fp32 state rather than to the rounded fp16 copy.
The spacing grows with magnitude because floating point gives approximately constant relative precision:
weight next fp16 spacing does +0.0003 change it?
1.0 9.77e-4 no
8.0 7.81e-3 no
64.0 6.25e-2 no
512.0 5.00e-1 no
This probe intentionally repeats a constant positive update. An optimizer has signed, changing updates and may keep its own fp32 state. The result isolates one arithmetic failure. It does not predict that a whole model would remain frozen for exactly two thousand steps.
Bfloat16 makes a different trade. It retains eight exponent bits, like fp32,
but only seven explicit fraction bits. Its smallest positive normal is
2^-126, about 1.175e-38. Its gap above 1.0 is 0.0078125, eight times
the fp16 gap:
value fp16 cast bfloat16 cast
1e-8 0.000e+00 1.001e-08
1e-10 0.000e+00 1.000e-10
gap above 1.0
fp16 9.766e-4
bfloat16 7.812e-3
fp32 1.192e-7
The two tiny values survive the local bfloat16 casts because its normal range
extends much farther towards zero. Its coarser significand means that a direct
bfloat16 weight update has an even larger swamping problem near 1.0.
Bfloat16 can remove the particular fp16 range pressure that motivated loss
scaling while still requiring careful accumulation. Whether it improves an
actual training run depends on accelerator support, kernel behavior, and which
tensors remain in fp32. None of those performance claims were measured here.
the original disagreement had nowhere left to hide
The opening comparison mixed two different questions. The tape and PyTorch
evaluate analytic derivative rules in floating point. The central difference
estimates the same derivative by measuring a secant across a finite interval.
At h=0.1, that interval was wide enough for curvature to appear in the fourth
decimal. Reducing h initially removed truncation error, then subtraction and
division amplified floating point error. The observed minimum near h=1e-5
was therefore a property of this expression, dtype, and machine, not a
universal step size.
The investigation did reach a real attention block. On the pinned GPT-2
revision, the next token loss sent nonzero gradients through all thirteen
recorded residual states and through query, key, and value slices in layers
zero, five, and eleven. Moving a layer-zero query weight slice a small distance
against the normalized gradient reduced the measured loss from 3.43444896
to 3.42128277; moving with it increased the loss to 3.44740677. Restoring
the parameters restored the original loss exactly in that run. This is a local
direction check, not evidence that a complete optimizer step improves a
training objective.
The checkpoint probe measured saved tensor payload through PyTorch hooks, not peak process memory or GPU memory. The low precision probes measured CPU casts, not tensor core arithmetic. The optimizer grid covered one deterministic quadratic and the XOR sweep covered one tiny network. I did not train the transformer, run mixed precision on a GPU, or measure distributed gradient reduction.
Those limits no longer affect the first number. For
L = tanh(a*b + a), the graph records exactly which local derivatives the
backward pass needs. Reverse traversal accumulates every path into each leaf.
The hand derivative, the tape, PyTorch, a central difference at its observed
best interval, and a complex step on this analytic expression now converge on
the same prediction. The value at h=0.1 was not a failed gradient. It was a
measurement of a curve pretending, over an interval that was still too wide,
to be a tangent.