Backward#
Every operation with a derivative has a backward operation with the same guarantees: each gradient is the exact value rounded once, with a tolerance and, where the forward has one, a normal answer.
Calling a backward#
There are two ways. The standalone .backward takes the forward's inputs and
the output gradient. A forward result's backward(grad) reuses its inputs:
import exactref as er
# A 2x2 and a 2x1 float32 matrix, and the gradient of their 2x1 product
A = er.asarray([[1.0, 2.0], [3.0, 4.0]], "float32")
B = er.asarray([[0.5], [0.25]], "float32")
grad = er.asarray([[1.0], [1.0]], "float32")
# The standalone backward: the forward's inputs, then the output gradient
g = er.matmul.backward(A, B, grad)
# Each gradient, rounded once to float32, by name
print("grad_A:", g.rounded.grad_A.tolist())
print("grad_B:", g.rounded.grad_B.tolist())
# The same grad_B from the forward result's backward, which reuses A and B
print("grad_B again:", er.matmul(A, B).backward(grad).rounded.grad_B.tolist())
grad_A: [[0.5, 0.25], [0.5, 0.25]]
grad_B: [[4.0], [6.0]]
grad_B again: [[4.0], [6.0]]
The gradient is required; there is no default of ones. Each output is named
grad_ plus the input's name:
Backward |
Gradients |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
matrix_exp_frechet has no backward. grad must have the forward output's
shape. A scalar operand of add or multiply gets no gradient.
Checking against PyTorch's autograd#
Run PyTorch's backward, then compare each gradient with covers():
import math
import random
import torch
# Seeded, so every run uses the same values
rng = random.Random(0)
# Like torch.randn, but drawing from rng
def randn(*shape):
# Normally distributed, mean 0 and standard deviation 1, one per element
values = [rng.gauss(0, 1) for _ in range(math.prod(shape))]
return torch.tensor(values).reshape(shape)
# Random float32 inputs of a 16x32 by 32x8 product, and its output gradient
A, B, grad = randn(16, 32), randn(32, 8), randn(16, 8)
# Copies of A and B that record gradients for PyTorch's autograd
A_, B_ = A.clone().requires_grad_(), B.clone().requires_grad_()
# PyTorch's backward, which stores the gradients in A_.grad and B_.grad
(A_ @ B_).backward(grad)
# ExactRef's gradients for the same inputs and output gradient
g = er.matmul.backward(A, B, grad)
# g["grad_A"] is a result holding just grad_A, so it has covers()
print("grad_A covered:", g["grad_A"].covers(A_.grad).all())
print("grad_B covered:", g["grad_B"].covers(B_.grad).all())
grad_A covered: True
grad_B covered: True
Attention works the same way:
# Query, key and value of shape (batch, heads, positions, features)
q, k, v = randn(1, 2, 8, 16), randn(1, 2, 8, 16), randn(1, 2, 8, 16)
# Copies that record gradients for PyTorch's autograd
q_, k_, v_ = (t.clone().requires_grad_() for t in (q, k, v))
# PyTorch's attention with a causal mask: no query attends to a later key
out = torch.nn.functional.scaled_dot_product_attention(q_, k_, v_, is_causal=True)
# A random output gradient, and PyTorch's backward from it
grad = randn(1, 2, 8, 16)
out.backward(grad)
# ExactRef's forward, then its backward from the same inputs
g = er.scaled_dot_product_attention(q, k, v, is_causal=True).backward(grad)
# Each gradient's name, with the copy whose .grad PyTorch filled
names = [("grad_query", q_), ("grad_key", k_), ("grad_value", v_)]
for name, t in names:
# g[name] holds one gradient, and covers() judges PyTorch's against it
print(f"{name} covered:", g[name].covers(t.grad).all())
grad_query covered: True
grad_key covered: True
grad_value covered: True
What each backward computes#
matmul:
grad_A = grad @ B^Handgrad_B = A^H @ grad, where^His the conjugate transpose, as PyTorch uses for complex inputs. Each gradient is one matmul, with the copies that broadcasting made of its operand folded into the sum, so every element is rounded once and its tolerance and normal answer are that matmul's. PyTorch folds the same way, except when both operands keep batch dimensions that broadcast against each other: then it rounds each batch's product before summing the copies, an extra rounding the default tolerance does not budget.gemm:
grad_A = conj(alpha) grad @ B^H,grad_B = conj(alpha) A^H @ gradandgrad_C = conj(beta) grad, each summed over its operand's broadcast copies and rounded once.add:
grad_x = gradandgrad_y = conj(alpha) * grad, each summed over its operand's broadcast copies.multiply:
grad_x = grad * conj(y)andgrad_y = grad * conj(x), each summed over its operand's broadcast copies. Where an operand is broadcast, PyTorch rounds each product before summing the copies, an extra rounding the default tolerance does not budget.sum:
grad_xholds, at every element ofx, the gradient of the output it was summed into.scaled_dot_product_attention: with
Pthe attention weights,dP = grad V^T,D = sum P * dPper row anddS = P * (dP - D), it returnsgrad_value = P^T grad,grad_query = scale * dS Kandgrad_key = scale * dS^T Q. The tolerance follows PyTorch's math-path autograd, and so do the special values. Dropout must be 0, and a float mask gets no gradient. A fused backward kernel rounds differently: its tolerance isAttentionAlgorithm.backward_tolerance.matrix_exp:
grad_A = matrix_exp_frechet(A^H, grad), which is what PyTorch computes.
One case can raise instead of returning: attention's grad_key,
grad_value, and grad_query summed over broadcast copies of a query row,
are found exact only when every term is rational. Such a sum that is exactly
a rounding boundary, or exactly zero by cancellation, raises
ReferenceUnavailableError. See Guarantees and limits.