Skip to content

Tensor, broadcasting backward, and nograd

ModuleL0.1 · build · Python · Pass 2 · 4 to 6 h
You buildpython/tinyllm/autograd/tensor.py: Tensor (arithmetic, @, **, indexing, backward, detach, numpy) and from_op; python/tinyllm/autograd/mode.py: is_grad_enabled, no_grad
Contractcourse/contracts/py/tinyllm/autograd/tensor.pyi · course/contracts/py/tinyllm/autograd/mode.pyi
Testscourse/tests/L0.1/ (what they check: section 4)
NeedsM06.1 iterative topological sort (toposort) · M08.3 unbroadcast and the matmul VJP · M08.2 the scalar Value (the tests’ oracle) · reading: lang.01 broadcasting (or --ref-deps)
Used byL0.2 op library · L0.3 fused losses · L0.4 modules · L0.5 training loop · L0.6 checkpoint tests · later: L11.1, L2.2, L3.1, L3.2, L3.3, L3.4, L3.6, L4.1, L4.2, L4.3, L5.1, L5.3, L5.4, L5.5, L6.1, L6.2, L6.3, L6.5, L6.6, L6.7, L7.1, L7.2, L7.3, L7.5, L7.6, L7.7, L7.8, L7.9, L8.2, L8.5
MilestoneMS-L0 (your autograd retrains the tracer and survives a kill)
Optional depthBaydin, Pearlmutter, Radul, Siskind, “Automatic Differentiation in Machine Learning: a Survey” (JMLR 2018, free); Karpathy’s micrograd (the scalar version of this module)
  • A tensor op records its inputs and one vector-Jacobian product; backward calls each VJP once, in reverse topological order, so a node’s gradient is complete before it is split among its inputs (test_diamond_graph, test_deep_chain_no_recursion).
  • A value used in several places gets the sum of the gradients from every place, whether the reuse is explicit (a * a + a) or hidden in broadcasting (test_shared_input_accumulates, test_hand_example_broadcast_backward).
  • Undoing broadcasting is a sum over the stretched axes, and indexing with repeated indices is a scatter-add, never an assignment (test_add_sub_mul_grads, test_getitem_repeated_indices_accumulate).
  • Grad mode is a per-thread flag that no_grad turns off and always restores, so evaluation builds no graph and a failed batch cannot leave training broken (test_no_grad_builds_no_graph, test_no_grad_restores_after_exception).
  • A tensor keeps its dtype: constants are converted to it, gradients have it, so float32 models stay float32 (test_float32_stays_float32, test_grad_dtype_matches_data).
Terminal window
ol start L0.1 # stubs tensor.py and mode.py into your repo
ol tests L0.1 # read the test catalog first: rung R0, you write no graded tests here
ol check L0.1 # exit code is the verdict
ol check L0.1 --ref-deps # only if M06.1, M08.2, or M08.3 is not passing yet
ol diff L0.1 # after passing: your code against the reference

tinyllm stays a namespace package: no __init__.py under python/tinyllm/autograd/. Import as from tinyllm.autograd.tensor import Tensor.


Your bigram from Pass 1 is trained by counting. That works for exactly one model: the moment a model has a hidden layer (the digits MLP of MS-L0), a gate, or attention, there is no count to take and the weights must be found by gradient descent. M08.2 gave you reverse mode on scalars, one Value per number. A [256, 256] table is 65,536 Value objects and one Python call per multiply; the digits MLP would take minutes per step. This module moves reverse mode from scalars to numpy arrays, so one node holds a whole matrix and one VJP is one vectorized numpy expression. Every model in the rest of the course trains through the Tensor you write here.

SymbolMeaningType / shape
x,wx, winput tensors of an opndarray, shapes sxs_x, sws_w
y=f(x,w)y = f(x, w)the op’s outputndarray, shape sys_y
LLthe scalar loss at the end of the graphfloat
yˉ=∂L/∂y\bar{y} = \partial L / \partial yupstream gradient (“y-bar”), same shape as yyndarray, sys_y
xˉ=∂L/∂x\bar{x} = \partial L / \partial xthe gradient this op hands back to xxndarray, sxs_x
Jx=∂y/∂xJ_x = \partial y / \partial xthe Jacobian of the op in xx (never built)$
vjp(yˉ)\mathrm{vjp}(\bar{y})vector-Jacobian product: xˉ=yˉ⊤Jx\bar{x} = \bar{y}^\top J_x, computed without JxJ_xfunction
GGthe graph: tensors as nodes, an edge from each input to each outputDAG
unbroadcast(g,s)\mathrm{unbroadcast}(g, s)gg summed over every axis numpy stretched to reach shape ssM08.3

A tensor is an array plus its history. Tensor(data, requires_grad=True) is a leaf: a parameter or an input you want gradients for. An op on tensors returns a new tensor that remembers its inputs (its parents) and a function, the VJP, that turns the gradient of the loss with respect to the output into gradients with respect to the inputs. That record is the graph. from_op(data, parents, vjp, op) is the one constructor every op uses: it attaches the parents and the VJP only when grad mode is on and some parent requires grad, otherwise it returns a plain constant.

One VJP per op, never a Jacobian. The chain rule says xˉ=yˉ⊤Jx\bar{x} = \bar{y}^\top J_x. For y=x⊙wy = x \odot w (elementwise), JxJ_x is diagonal with ww on it, so xˉ=yˉ⊙w\bar{x} = \bar{y} \odot w: a product, not a matrix. Every op in this module has such a closed form (M08.3 derived them):

Opxˉ\bar{x}wˉ\bar{w}
x+wx + wyˉ\bar{y}yˉ\bar{y}
x−wx - wyˉ\bar{y}−yˉ-\bar{y}
x⊙wx \odot wyˉ⊙w\bar{y} \odot wyˉ⊙x\bar{y} \odot x
x/wx / wyˉ/w\bar{y} / w−yˉ⊙x/w2-\bar{y} \odot x / w^2
xpx^p (pp a number)yˉ⋅p xp−1\bar{y} \cdot p\, x^{p-1}
XWX W (matmul)YˉW⊤\bar{Y} W^\topX⊤YˉX^\top \bar{Y}
x[idx]x[\text{idx}]zeros with yˉ\bar{y} added at idx

Reverse topological order. Calling loss.backward() seeds Lˉ=1\bar{L} = 1 and walks the graph from the loss back to the leaves. A node may feed several consumers (a diamond: b=2ab = 2a, c=a2c = a^2, d=bcd = bc). Its gradient is complete only after every consumer has added its share, so the walk must visit every consumer before the node: reverse topological order. M06.1’s toposort(root, parents) gives exactly that order iteratively. A recursive walk would hit Python’s recursion limit (about 1000) on an unrolled network of a thousand ops.

Reuse means sum. If aa feeds two ops, LL depends on aa through two paths and ∂L/∂a\partial L/\partial a is the sum of both. So backward keeps a dictionary of pending gradients keyed by node and adds each contribution; it never overwrites. Leaves go one step further, like torch: their .grad adds up across separate backward() calls until you set it back to None (that is how an optimizer’s zero_grad and gradient accumulation over micro-batches both work).

Broadcasting is reuse, so its gradient is a sum. x * w with x of shape (2, 3) and w of shape (3,) uses each w[j] twice, once per row. numpy never copies it, but mathematically the op saw w repeated. The gradient for the repeated copy is the sum over the repeated axis: unbroadcast(g, (3,)) sums g over axis 0. The rule in general (M08.3): sum over the leading axes numpy added, then over every axis where the input had size 1 and the output did not, keeping it as size 1. Every binary op calls unbroadcast on both gradients, because either operand may have been stretched.

Indexing scatters with addition. x[[0, 2, 0]] reads row 0 twice. Its gradient must carry both upstream rows back into row 0. out[idx] = g with a repeated index writes once and keeps the last; np.add.at(out, idx, g) adds every occurrence. An embedding lookup with a repeated token (L0.2) is this exact case.

Constants. A number, an ndarray, or a tensor with requires_grad=False is a constant: it takes part in the forward pass and gets no gradient. A constant is converted to the other operand’s dtype: a Python float is float64, and promoting a float32 model to float64 doubles its memory and makes it disagree with the float32 C and Rust ports. Gradients are cast to their tensor’s dtype too, so p.data -= lr * p.grad keeps the parameter’s type.

numpy must defer. ndarray + Tensor normally runs numpy’s own __add__, which broadcasts the Tensor object into an array of objects. Setting the class attribute __array_ufunc__ = None tells numpy to return NotImplemented, so Python calls Tensor.__radd__ and the result is a Tensor with a graph.

Grad mode. Evaluation and sampling need no gradients, and a graph keeps every intermediate array alive until the output dies. mode.py holds a per-thread flag (threading.local), on by default. no_grad() is a context manager that saves the current value, sets it off, and restores the saved value in a finally, so blocks nest and an exception inside the block cannot leave training without gradients. from_op reads the flag: with it off, outputs have requires_grad=False and no parents.

Take x=[123456]x = \begin{bmatrix}1&2&3\\4&5&6\end{bmatrix} (shape (2, 3)), w=[10,20,30]w = [10, 20, 30] (shape (3,), broadcast down the rows), and y=x⊙w+wy = x \odot w + w, with an upstream gradient of ones, yˉ=12×3\bar{y} = \mathbf{1}_{2\times 3}.

Forward. x⊙w=[10409040100180]x \odot w = \begin{bmatrix}10&40&90\\40&100&180\end{bmatrix}, and adding ww to each row gives y=[206012050120210]y = \begin{bmatrix}20&60&120\\50&120&210\end{bmatrix}.

The graph. Two ops: m=x⊙wm = x \odot w, then y=m+wy = m + w. The leaf ww has two consumers, the multiply and the add, so it must wait until both have reported.

Backward through the add. mˉ=yˉ=1\bar{m} = \bar{y} = \mathbf{1}. The add’s gradient for ww is yˉ\bar{y} too, shape (2, 3), but ww has shape (3,): unbroadcast sums over axis 0, giving [2,2,2][2, 2, 2].

Backward through the multiply. xˉ=mˉ⊙w=[102030102030]\bar{x} = \bar{m} \odot w = \begin{bmatrix}10&20&30\\10&20&30\end{bmatrix}: ww copied into every row, already xx‘s shape. The multiply’s gradient for ww is mˉ⊙x=x\bar{m} \odot x = x, shape (2, 3); unbroadcast sums the rows: [1+4,2+5,3+6]=[5,7,9][1+4, 2+5, 3+6] = [5, 7, 9].

Sum the two paths into ww. wˉ=[5,7,9]+[2,2,2]=[7,9,11]\bar{w} = [5, 7, 9] + [2, 2, 2] = [7, 9, 11].

Check one entry by perturbation. Raise w0w_0 from 10 to 10+h10 + h: column 0 of yy becomes (1+1)(10+h)(1 + 1)(10 + h) and (4+1)(10+h)(4 + 1)(10 + h), so the summed output grows by (2+5)h=7h(2 + 5)h = 7h. The slope is 7, the first entry of wˉ\bar{w}.

These numbers are test_hand_example_broadcast_backward, the first test in section 4.

python/tinyllm/autograd/tensor.py
class Tensor:
__array_ufunc__ = None
data: NDArray; grad: Optional[NDArray]; requires_grad: bool
def __init__(self, data: ArrayLike, requires_grad: bool = False, dtype=np.float32) -> None
shape, dtype, ndim (properties); __len__
def backward(self, grad: Optional[ArrayLike] = None) -> None
def detach(self) -> "Tensor"; def numpy(self) -> NDArray
# + - * / @ ** (by a number) unary -, reflected forms, __getitem__
def from_op(data, parents, vjp, op: str = "") -> Tensor
# python/tinyllm/autograd/mode.py
def is_grad_enabled() -> bool
@contextmanager
def no_grad() -> Iterator[None]

The contracts carry the exact rules: the constructor copies its data and rejects integer dtypes, backward() without an argument needs a one-element tensor, a VJP that returns a wrong-shaped gradient is a RuntimeError naming the op. Build every operator on from_op, keep _parents and _vjp as private attributes, and walk the graph with toposort(self, lambda t: [p for p in t._parents if p.requires_grad]).

TestKINDChecksWhy it matters downstream
test_hand_example_broadcast_backwardunitthe section 3 numbers: wˉ=[7,9,11]\bar{w} = [7, 9, 11], xˉ\bar{x} = ww per rowyou and the test agree on broadcasting and reuse
test_matches_scalarized_valuedifferentialthe same expression built from M08.2’s scalar Value gives the same gradientsa Tensor op is a batch of scalar ops
test_add_sub_mul_gradsgradcheckevery binary op under five broadcasting patterns against central differencesbiases, scales, and masks broadcast everywhere
test_div_gradsgradcheck∂(a/b)/∂b=−a/b2\partial(a/b)/\partial b = -a/b^2normalization layers divide
test_pow_gradsgradcheckp xp−1p\,x^{p-1} for pp integer, fractional, negativeL0.4’s LayerNorm uses (⋅)−1/2(\cdot)^{-1/2}
test_matmul_gradsgradcheckYˉW⊤\bar{Y}W^\top, X⊤YˉX^\top\bar{Y} with 1-D operands and batch broadcastingevery linear layer
test_getitem_basic_gradsgradcheckslices and ints route the gradient to the selected entriesslicing activations
test_getitem_repeated_indices_accumulateboundaryx[[0, 2, 0]]: row 0 gets both upstream rowsembedding lookups with repeated tokens
test_shared_input_accumulatesunita⋅a+aa \cdot a + a at a=3a = 3 gives 7weight tying, residual connections
test_diamond_graphunitd=2a⋅a2d = 2a \cdot a^2 at a=2a = 2 gives 24any graph with a fork
test_grad_accumulates_across_backward_callsproperty.grad adds up over two backward() callsmicro-batch accumulation (L11.1)
test_backward_on_constant_raisesboundarybackward on a tensor without grad is an errora forgotten requires_grad fails loudly
test_backward_needs_grad_for_non_scalarboundaryno-argument backward on a vector is an erroran unreduced loss fails loudly
test_vjp_shape_mismatch_raisesboundarya wrong-shaped VJP result names the opa missing unbroadcast in L0.2 is found at once
test_from_op_custom_opunita custom op on from_op with a constant parentthe seam L0.2 builds every op on
test_grad_dtype_matches_databoundary.grad has the data’s dtypein-place optimizer updates
test_constants_mix_inunitnumbers, arrays, and flagless tensors are constantsmasks and targets in model code
test_float32_stays_float32boundarya float32 tensor times a Python float stays float32Python agrees with the C kernels (L9)
test_reflected_operatorsunit2 - x, 3 / x, A @ x keep their operand orderconstants on the left
test_ndarray_on_the_leftboundaryndarray + Tensor is a Tensor with a graphmask * scores in attention
test_constructor_copies_and_checks_dtypeboundarythe leaf owns a copy; integer dtypes are rejecteda parameter is not an alias of a batch
test_detach_stops_gradientunitdetach() shares data and passes no gradienttargets and frozen values
test_no_grad_builds_no_graphpropertyops inside no_grad keep no parentsevaluation memory (L0.5)
test_no_grad_restores_after_exceptionboundaryan exception inside the block restores grad modea failed eval batch cannot stop training
test_no_grad_nestsboundarythe inner block restores the outer block’s “off”nested evaluation helpers
test_deep_chain_no_recursionboundarya chain of 5,000 additions backpropagatesunrolled RNNs (L3)
test_shape_and_lenunitshape, dtype, ndim, len read throughmodel code reads them every forward
PitfallSymptomCaught by
1. returning yˉ\bar{y} as is for a broadcast operandValueError or a gradient of the output’s shape for a bias; with unbroadcast to the other operand’s shape, wrong sumstest_add_sub_mul_grads (mutants s01, s20), test_hand_example_broadcast_backward
2. assigning a node’s gradient instead of adding, or visiting a node before its consumersa⋅a+aa \cdot a + a gives 3 or 6 instead of 7; the diamond gives half its gradienttest_shared_input_accumulates, test_diamond_graph (mutants s02, s17)
3. a leaf’s .grad overwritten on each backward()gradient accumulation over micro-batches silently uses the last onetest_grad_accumulates_across_backward_calls (mutant s03)
4. out[idx] = g in the indexing VJPa token used twice in a batch trains half as fasttest_getitem_repeated_indices_accumulate (mutant s04)
5. no_grad that sets the flag back to True, or restores only on a normal exitafter a failed eval batch, or inside nested blocks, training runs without gradients and the loss stays flattest_no_grad_restores_after_exception (mutant s05), test_no_grad_nests (mutant s06)
6. no __array_ufunc__ = Nonemask * scores with an ndarray mask on the left is an array of Tensor objectstest_ndarray_on_the_left (mutant s14)
7. a missing transpose in the matmul VJPsquare matrices give a gradient of the right shape and the wrong valuestest_matmul_grads (mutant s07)
8. ∂(a/b)/∂b=+a/b2\partial(a/b)/\partial b = +a/b^2division layers train in the wrong directiontest_div_grads (mutant s08)
9. backward() on a non-scalar silently seeding onesan unreduced [B] loss trains on its sum without anyone deciding sotest_backward_needs_grad_for_non_scalar (mutant s13)
10. constants converted to their own dtypex * 0.5 makes a float32 model float64test_float32_stays_float32 (mutant s10)

| Forward | L11.1 | Registered call site uses this module. | | Forward | L2.2 | Registered call site uses this module. | | Forward | L3.1 | Registered call site uses this module. | | Forward | L3.2 | Registered call site uses this module. | | Forward | L3.3 | Registered call site uses this module. | | Forward | L3.4 | Registered call site uses this module. | | Forward | L3.6 | Registered call site uses this module. | | Forward | L4.1 | Registered call site uses this module. | | Forward | L4.2 | Registered call site uses this module. | | Forward | L4.3 | Registered call site uses this module. | | Forward | L5.1 | Registered call site uses this module. | | Forward | L5.3 | Registered call site uses this module. | | Forward | L5.4 | Registered call site uses this module. | | Forward | L5.5 | Registered call site uses this module. | | Forward | L6.1 | Registered call site uses this module. | | Forward | L6.2 | Registered call site uses this module. | | Forward | L6.3 | Registered call site uses this module. | | Forward | L6.5 | Registered call site uses this module. | | Forward | L6.6 | Registered call site uses this module. | | Forward | L6.7 | Registered call site uses this module. | | Forward | L7.1 | Registered call site uses this module. | | Forward | L7.2 | Registered call site uses this module. | | Forward | L7.3 | Registered call site uses this module. | | Forward | L7.5 | Registered call site uses this module. | | Forward | L7.6 | Registered call site uses this module. | | Forward | L7.7 | Registered call site uses this module. | | Forward | L7.8 | Registered call site uses this module. | | Forward | L7.9 | Registered call site uses this module. | | Forward | L8.2 | Registered call site uses this module. | | Forward | L8.5 | Registered call site uses this module. |

DirectionModuleHow it uses this
BackM06.1toposort orders the graph from the loss to the leaves, iteratively
BackM08.3unbroadcast and the matmul VJP are the derivations this module runs
BackM08.2the scalar Value graph, the oracle for the differential test
Backlang.01numpy broadcasting rules and np.add.at
ForwardL0.2every op of the library is a from_op call with one VJP
ForwardL0.3the fused losses are single from_op nodes
ForwardL0.4parameters are Tensors with requires_grad=True
ForwardL0.5train_step calls backward, evaluate runs under no_grad
ForwardL0.6the checkpoint tests train a model with this autograd before saving it

If you skip this module, ol check L0.2 stops with L0.2 needs L0.1: build it, or rerun with --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
Tensor + from_opPyTorch autograda C++ graph of Nodes, saved tensors with version counters to catch in-place edits, hookstorch/csrc/autograd/engine.cpp, function.h
backward in topological orderPyTorch’s enginea ready queue per device and dependency counts instead of a full sortEngine::evaluate_function in engine.cpp
unbroadcastsum_to_sizethe same reduction, emitted by the derivative generator for every broadcasting optools/autograd/derivatives.yaml
no_gradtorch.no_grad, torch.inference_modeinference mode also skips version counters and view trackingtorch/autograd/grad_mode.py
reverse mode on arraysJAX vjpa functional transform over traced programs, with jit compiling the backward passjax/_src/interpreters/ad.py