9. A Tensor library and your first MNIST
You can now compile any shape graph into C, cut it into kernels, and differentiate it. But nobody
wants to write shape graphs. Typing A.reshape((M, 1, K)).expand((M, N, K)) every time you mean
A @ B is how compiler people punish themselves, not how machine-learning people work. This chapter
builds the thin layer that turns the compiler into something that feels like PyTorch: a Tensor
class with broadcasting, reductions over any axes, matrix multiplication, convolution, softmax and
cross-entropy; a handful of layers; two optimizers. Then we use it to train a classifier on MNIST,
the course's week-5 milestone, and look closely at the 32 C kernels that make up one training step.
None of this needs a new compiler feature. Every operation in this chapter is a few lines of graph
building, and that is the point: if your IR is right, the frontend is small.
What you'll build
uopc/tensor.py: a lazyTensorthat wraps a shaped UOp graph, with NumPy-style broadcasting, movement ops, reductions over arbitrary axes,matmul,conv2d,softmax,log_softmax,argmax,one_hotandcross_entropy.uopc/nn.py:Linear,Conv2d,LayerNorm,Embedding, aparameters()walker, and theSGDandAdamWoptimizers.examples/mnist_mlp.py: a 784-128-10 MLP that trains to 94% test accuracy in about nine seconds.
Where this fits. Chapter 7 taught the compiler to split a graph into kernels and Chapter 8 taught it to differentiate one. This chapter is the user-facing API on top of both. Chapter 10 then makes the kernels it produces fast.
9.1 Why a lazy Tensor
A NumPy array is eager: a + b computes and stores a new array immediately. An eager design is
simple, but it hides the program from the compiler. If y = (x * 2 + 1).relu().sum(1) ran eagerly,
it would be four separate loops with three temporary arrays, and no later pass could fuse them,
because by the time sum runs the temporaries already exist.
Our Tensor is lazy. It is a thin wrapper around a UOp, and each operator builds a bigger UOp graph.
Nothing runs until somebody asks for numbers (.numpy(), .item(), or .realize()). At that moment
the whole pending graph goes through the pipeline from Chapter 7: kernelize,
rangeify, render, compile (or fetch from cache), run.
Laziness buys three things, each of which this chapter relies on:
- Fusion. The compiler sees the whole expression and decides where to cut it into kernels.
- Autodiff on graphs.
gradient()from Chapter 8 needs the forward graph as data. An eager library would have to record a tape; a lazy one already is the tape. - Whole-step JIT caching. A training step builds the same graph shape every iteration, so its
compiled plan can be reused (
_structure_keyinkernelize.py).
The price is that errors and costs show up late. A shape mismatch is caught when you build the graph
(the UOp type checker runs at construction), but a slow kernel only shows up at realize().
Here is a session, run with DEBUG=1 so that every realize prints one line:
>>> x = Tensor(np.arange(6, dtype=np.float32).reshape(2, 3))
>>> y = (x * 2 + 1).relu().sum(1)
>>> y
<Tensor (2,) f32>
>>> y.uop.op, y.uop.src[0].op, y.uop.src[0].arg
Ops.REDUCE Ops.WHERE None
>>> y.numpy()
realize: 1 kernels, schedule 6.1 ms, run 0.1 ms
[ 9. 27.]
>>> y.uop.op, y.uop.arg
Ops.BUFFER ParamArg(slot=2, dtype=dtypes.f32, shape=(2,), addrspace=None)
Before .numpy(), y is a REDUCE over a WHERE (that is relu) over arithmetic over a BUFFER. Building
it ran no C at all. After .numpy() the tensor has been rebound: y.uop is now a BUFFER that holds
the result, so asking again costs nothing, and any graph built on y later starts from data rather than
recomputing the whole chain.
9.2 The Tensor class
The class starts with a constructor that accepts either a UOp (the internal path, used by every operator) or anything NumPy can turn into an array (the user path):
def __init__(self, data, dtype: DType | None = None, requires_grad: bool = False):
if isinstance(data, UOp): self.uop = data
else:
arr = np.asarray(data)
if dtype is None: dtype = dtypes.f32 if arr.dtype.kind == "f" else dtypes.bool if arr.dtype.kind == "b" else dtypes.i32
arr = arr.astype(np_dtype(dtype))
buf = new_buffer(dtype, arr.size, arr)
self.uop = buf.reshape(arr.shape) if arr.shape != buf.shape else buf
self.requires_grad = requires_grad
self.grad: Tensor | None = None
User data is copied into a fresh BUFFER UOp (new_buffer in kernelize.py attaches the NumPy array to
the UOp itself, so the memory lives exactly as long as the node does; see Chapter 7).
BUFFERs are flat, so a 2-D array becomes BUFFER(6).reshape((2, 3)). dtypes are inferred the obvious
way: floats become f32, bools bool, everything else i32.
Realization is where the frontend hands over to the compiler:
@staticmethod
def realize_all(*ts: Tensor):
todo = [t for t in ts if t.uop.op is not Ops.BUFFER and not (t.uop.op is Ops.RESHAPE and base_buffer(t.uop).op is Ops.BUFFER and prod(t.shape) == base_buffer(t.uop).arg.shape[0])]
if not todo: return
bufs = _realize([t.uop for t in todo], Tensor.fuse_reduce, Tensor.opt)
for t, b in zip(todo, bufs):
t._graph = t.uop # keep the graph: loss.item() before loss.backward() must still have gradients
t.uop = b.reshape(t.shape) if b.shape != t.shape else b
Three details matter. First, tensors that are already data (a BUFFER, or a reshape of a whole BUFFER)
are skipped; realizing them would launch a useless copy kernel. Second, realize_all realizes many
tensors in one call. That is how an optimizer step updates every parameter, every moment estimate and
the loss in a single schedule: one structural cache lookup, one batch of kernels. Realizing them one by
one would work, but it would compute shared subexpressions (the gradients) once per tensor. Third, before
rebinding a tensor to its result BUFFER, realize_all keeps the graph it came from in t._graph, so that
loss.item() followed by loss.backward(...) still has a graph to differentiate (the Pitfalls tell how
the first version got this wrong).
assign is the only "mutation" in the library, and it is not really one:
def assign(self, x: Tensor) -> Tensor:
"""Make this tensor (e.g. a parameter) hold x's value. Functional: rebinds to a new buffer."""
assert x.shape == self.shape and x.dtype == self.dtype, f"assign {x.shape} to {self.shape}"
self.uop = x.uop
return self
It rebinds the Python object to a new graph. Buffers are never written in place by user code; a
parameter update builds p - lr * g and then makes p point at that expression. When the expression
is realized, p.uop becomes the new BUFFER and the old one is garbage-collected along with its UOp. This
functional style keeps autodiff sound (no node ever changes its value) and lets the kernelizer
reason purely about graphs.
9.3 Broadcasting
Elementwise ops in the IR are strict: ADD demands that both operands have the same shape. The type
checker from Chapter 1 says so, loudly. Users expect NumPy's broadcasting instead:
(2, 3) + (3,) adds the vector to every row. The rules are:
- Line up the shapes from the right; pad the shorter one with leading 1s.
- In each position the sizes must be equal, or one of them must be 1.
- The result takes the larger size in each position.
def _broadcast_shape(*shapes):
n = max(len(s) for s in shapes)
shapes = [(1,) * (n - len(s)) + tuple(s) for s in shapes]
out = []
for dims in zip(*shapes):
d = max(dims)
if any(x not in (1, d) for x in dims): raise ValueError(f"can't broadcast {shapes}")
out.append(d)
return tuple(out)
Turning a shape into a broadcast shape takes exactly two primitive movement ops: a RESHAPE to add the leading 1s, and an EXPAND to stretch the 1s:
def broadcast_to(self, shape) -> Tensor:
shape = tuple(shape)
if self.shape == shape: return self
u = self.uop
if len(u.shape) < len(shape): u = u.reshape((1,) * (len(shape) - len(u.shape)) + u.shape)
return Tensor(u.expand(shape) if u.shape != shape else u)
and every binary operator funnels through one helper:
def _binop(self, op: Ops, x, reverse=False) -> Tensor:
# numpy-style promotion for python scalars: int tensor (op) 0.5 is a float computation
if isinstance(x, float) and not self.dtype.is_float: return self.float()._binop(op, x, reverse)
x = self._const(x)
a, b = (x, self) if reverse else (self, x)
shape = _broadcast_shape(a.shape, b.shape)
return Tensor(UOp(op, (a.broadcast_to(shape).uop, b.broadcast_to(shape).uop)))
So a + b with a of shape (2, 3) and b of shape (3,) becomes
ADD(a, EXPAND(RESHAPE(b, (1, 3)), (2, 3))). In the REPL:
>>> c = a + b
>>> c.uop.op, [s.op for s in c.uop.src]
Ops.ADD [Ops.RESHAPE, Ops.EXPAND]
>>> c.uop.src[1].op, c.uop.src[1].shape, c.uop.src[1].src[0].op, c.uop.src[1].src[0].shape
Ops.EXPAND (2, 3) Ops.RESHAPE (1, 3)
(a itself shows up as a RESHAPE because it is a flat BUFFER viewed as (2, 3).) Does the EXPAND cost
anything? Not a byte. Rangeify (Chapter 6) turns EXPAND into "use index 0 on the
stretched axis", so the generated C simply never looks at ridx0 when it reads b:
void e_2_3(float* restrict data0, float* restrict data1, float* restrict data2) {
for (int ridx0 = 0; ridx0 < 2; ridx0++) {
for (int ridx1 = 0; ridx1 < 3; ridx1++) {
int alu1 = ((ridx0*3)+ridx1);
float val2 = data0[alu1];
float val3 = data1[ridx1];
data2[alu1] = (val2+val3);
}
}
}
Key idea: broadcasting is free because it is indexing, not copying. A broadcast operand is read through an index expression that ignores the broadcast loop. The same fact is what makes matmul, one-hot and argmax cheap to express below.
The rest of the elementwise vocabulary
With _binop in hand, the arithmetic operators are one-liners. Several are derived rather than
primitive, because the IR deliberately has a small ALU (ADD, MUL, MAX, CMPLT, CMPNE, RECIP, EXP2, LOG2,
SQRT, SIN, WHERE, CAST):
def __add__(self, x): return self._binop(Ops.ADD, x)
def __radd__(self, x): return self._binop(Ops.ADD, x, True)
def __mul__(self, x): return self._binop(Ops.MUL, x)
def __rmul__(self, x): return self._binop(Ops.MUL, x, True)
def __neg__(self): return self * -1
def __sub__(self, x): return self + -self._const(x)
def __rsub__(self, x): return self._const(x) + -self
def __truediv__(self, x): return self * self._const(x).reciprocal()
def __rtruediv__(self, x): return self._const(x) * self.reciprocal()
def __lt__(self, x): return self._binop(Ops.CMPLT, x)
def __gt__(self, x): return self._binop(Ops.CMPLT, x, True)
def __ge__(self, x): return (self < x).logical_not()
def __le__(self, x): return (self > x).logical_not()
def eq(self, x): return self._binop(Ops.CMPNE, x).logical_not()
def ne(self, x): return self._binop(Ops.CMPNE, x)
def logical_not(self): return self._binop(Ops.CMPNE, True)
def maximum(self, x): return self._binop(Ops.MAX, x)
def minimum(self, x): return -((-self).maximum(-self._const(x)))
def where(self, a, b) -> Tensor:
a = a if isinstance(a, Tensor) else Tensor.full((), a)
b = b if isinstance(b, Tensor) else Tensor.full((), b, a.dtype)
shape = _broadcast_shape(self.shape, a.shape, b.shape)
return Tensor(UOp(Ops.WHERE, (self.broadcast_to(shape).uop, a.broadcast_to(shape).uop, b.broadcast_to(shape).uop)))
def cast(self, d: DType) -> Tensor: return Tensor(self.uop.cast(d))
def float(self) -> Tensor: return self.cast(dtypes.f32)
The first line of _binop (shown above) is NumPy-style type promotion: an integer tensor combined with
a Python float is cast to f32 first, so Tensor([1, 2, 3]) * 0.5 is [0.5, 1.0, 1.5] (see the
Pitfalls for what happened without it). Subtraction is addition of a negation, division is multiplication by a reciprocal, > is < with the
operands swapped, == is "not !=". Why not add SUB and DIV to the IR? Because every op you add must be
handled by the renderer, the symbolic simplifier, the bounds analysis and autodiff. Deriving a - b
as a + b * -1 costs nothing at runtime (gcc sees x + y*-1.0f and emits a subtraction) and saves four
rules in four files.
Transcendentals follow the same philosophy:
def reciprocal(self): return Tensor(self.uop.recip())
def exp2(self): return Tensor(UOp(Ops.EXP2, (self.uop,)))
def log2(self): return Tensor(UOp(Ops.LOG2, (self.uop,)))
def exp(self): return (self * LOG2E).exp2()
def log(self): return self.log2() * LN2
def sqrt(self): return Tensor(UOp(Ops.SQRT, (self.uop,)))
def rsqrt(self): return self.sqrt().reciprocal()
def sin(self): return Tensor(UOp(Ops.SIN, (self.uop,)))
def cos(self): return (self + math.pi / 2).sin()
def square(self): return self * self
def abs(self): return self.maximum(-self)
def relu(self): return (self > 0).where(self, 0.0)
def sigmoid(self): return ((-self).exp() + 1).reciprocal()
def tanh(self): return (self * 2).sigmoid() * 2 - 1
def gelu(self): return 0.5 * self * (1 + (math.sqrt(2 / math.pi) * (self + 0.044715 * self * self * self)).tanh())
def silu(self): return self * self.sigmoid()
The only exponential in the IR is EXP2, because that is what hardware has (exp2f on CPUs,
ex2.approx on NVIDIA GPUs). exp(x) is exp2(x * log2(e)) and log(x) is log2(x) * ln 2. You will
see the constant 1.4426950408889634f (that is log2 e) all over the generated C in this chapter. relu
is a WHERE, sigmoid is built from exp and a reciprocal, and tanh from sigmoid. Every one of these
gets a gradient for free, because autodiff only needs rules for the primitives.
9.4 Movement ops
The movement methods are thin conveniences over the four primitive movement ops from
Chapter 6. reshape accepts a -1 and works out the missing size, permute accepts
negative axes, transpose swaps two axes, unsqueeze inserts a 1, flatten merges trailing axes.
Indexing with ints and step-1 slices is a SHRINK followed by a RESHAPE that drops the integer-indexed axes:
def __getitem__(self, idx) -> Tensor:
"""Ints and step-1 slices only."""
idx = idx if isinstance(idx, tuple) else (idx,)
arg, squeeze = [], []
for d, i in enumerate(idx):
n = self.shape[d]
if isinstance(i, int):
i = i % n
arg.append((i, i + 1)); squeeze.append(d)
else:
b, e, step = i.indices(n)
assert step == 1, "only step-1 slices"
arg.append((b, max(b, e)))
arg += [(0, s) for s in self.shape[len(arg):]]
t = self.shrink(arg)
return t.reshape(tuple(s for d, s in enumerate(t.shape) if d not in squeeze))
pad and flip call the composite constructions from composite.py (built from STACK, RESHAPE and
SHRINK; see Chapter 6). Concatenation reuses them: each piece is zero-padded into its
place along the axis, and the padded pieces are added.
@staticmethod
def cat(ts: list[Tensor], dim: int = 0) -> Tensor:
"""Concatenate via pad + add: each piece is padded into place, then summed."""
dim = dim % ts[0].ndim
total = sum(t.shape[dim] for t in ts)
out, off = None, 0
for t in ts:
p = [(0, 0)] * t.ndim
p[dim] = (off, total - off - t.shape[dim])
piece = t.pad(p)
out = piece if out is None else out + piece
off += t.shape[dim]
return out
Adding zeros sounds wasteful, but after rangeify each padded piece becomes a bounds-checked WHERE, so
cat costs a few comparisons per element, not extra memory traffic.
9.5 Reductions over arbitrary axes
The IR's REDUCE has a deliberately narrow contract: it reduces the last n axes. Users want
x.sum((0, 2)). The bridge is a permutation: move the kept axes to the front, the reduced axes to the
back, then pop:
def _reduce(self, op: Ops, axis=None, keepdim=False) -> Tensor:
axes = list(range(self.ndim)) if axis is None else [a % self.ndim for a in (axis if isinstance(axis, (tuple, list)) else [axis])]
keep = [i for i in range(self.ndim) if i not in axes]
t = self.permute(keep + axes)
r = Tensor(t.uop.reduce(op, len(axes)))
if keepdim: r = r.reshape(tuple(1 if i in axes else s for i, s in enumerate(self.shape)))
return r
For x of shape (2, 3, 4) and axis=(0, 2), keep = [1], so we permute by (1, 0, 2) to shape
(3, 2, 4) and REDUCE the last 2 axes, giving shape (3,). With keepdim=True a reshape puts the 1s
back where the reduced axes were, which is what makes x - x.max(-1, keepdim=True) broadcast correctly.
>>> x = Tensor(np.arange(24, dtype=np.float32).reshape(2, 3, 4))
>>> x.sum((0, 2)).numpy(), x.sum((0, 2)).uop.src[0].arg
[ 60. 92. 124.] (1, 0, 2)
The permutation, like the expand, is free. It only changes which loop variable multiplies which stride:
void r_3(float* restrict data0, float* restrict data1) {
for (int ridx0 = 0; ridx0 < 3; ridx0++) {
float acc0;
acc0 = 0.0f;
for (int ridx1 = 0; ridx1 < 2; ridx1++) {
for (int ridx2 = 0; ridx2 < 4; ridx2++) {
float val1 = acc0;
float val2 = data0[(((ridx1*12)+(ridx0*4))+ridx2)];
acc0 = (val1+val2);
}
}
float val3 = acc0;
data1[ridx0] = val3;
}
}
The outer loop is the kept axis (stride 4 in memory), the two inner loops are the reduced axes (strides
12 and 1). The accumulator acc0 lives inside the output loop and outside the reduce loops, exactly the
placement rule from Chapter 6.
The derived reductions are short:
def mean(self, axis=None, keepdim=False):
s = (self if self.dtype.is_float else self.float()).sum(axis, keepdim)
return s * (s.numel() / self.numel())
def var(self, axis=None, keepdim=False):
return (self - self.mean(axis, keepdim=True)).square().mean(axis, keepdim)
mean sums in f32 (casting integer tensors first) and multiplies by numel(result) / numel(input),
a Python float that becomes a CONST. var is the
mean squared deviation. min is a negated max of the negation (the IR has only ADD and MAX reductions).
softmax, log_softmax and logsumexp get their own section below.
9.6 Matrix multiplication is broadcast, multiply, reduce
Here is the most important three-line function in the library:
def matmul(self, w: Tensor) -> Tensor:
"""(..., M, K) @ (..., K, N): broadcast to (..., M, N, K), multiply, reduce K."""
x = self if self.ndim > 1 else self.reshape(1, -1)
wt = w.reshape(-1, 1) if w.ndim == 1 else w
wt = wt.transpose() # (..., N, K)
x = x.reshape(x.shape[:-1] + (1, x.shape[-1])) # (..., M, 1, K)
wt = wt.reshape(wt.shape[:-2] + (1,) + wt.shape[-2:]) # (..., 1, N, K)
out = (x * wt).sum(-1)
if self.ndim == 1: out = out.reshape(out.shape[:-2] + out.shape[-1:])
if w.ndim == 1: out = out.reshape(out.shape[:-1])
return out
Read it as geometry. A matrix product C[i, j] = sum_k A[i, k] * B[k, j] has three indices, so lay
the computation out on an (M, N, K) box. A has no j index, so it is reshaped to (M, 1, K) and
broadcast along N. B has no i index; transposed to (N, K) and reshaped to (1, N, K) it broadcasts
along M. Multiplying gives the full box of products A[i, k] * B[k, j], and summing the last axis (K)
collapses the box onto the (M, N) face, which is C.
The box is never stored. It is a description of the iteration space, and rangeify turns it into a loop
nest in which each multiply happens once, right before it is accumulated. For a (4, 8) @ (8, 3) product
with the optimizer switched off (OPT=0), the graph and the code are:
[('REDUCE', (4, 3), (Ops.ADD, 1)), ('MUL', (4, 3, 8), None), ('EXPAND', (4, 3, 8), (4, 3, 8)),
('RESHAPE', (4, 1, 8), (4, 1, 8)), ('RESHAPE', (4, 8), (4, 8))]
void r_4_3(float* restrict data0, float* restrict data1, float* restrict data2) {
for (int ridx0 = 0; ridx0 < 4; ridx0++) {
for (int ridx1 = 0; ridx1 < 3; ridx1++) {
float acc0;
acc0 = 0.0f;
for (int ridx2 = 0; ridx2 < 8; ridx2++) {
float val1 = acc0;
float val2 = data0[((ridx0*8)+ridx2)];
float val3 = data1[((ridx2*3)+ridx1)];
acc0 = (val1+(val2*val3));
}
float val4 = acc0;
data2[((ridx0*3)+ridx1)] = val4;
}
}
}
That is the textbook triple loop, produced by a library function that never mentions a loop. (The first
line above follows src[0] down from the result; the max error against NumPy was 2.4e-07.) Look at the
two loads in the inner loop. A is read at ridx0*8 + ridx2: consecutive k are adjacent in memory.
B is read at ridx2*3 + ridx1: consecutive k are a whole row apart. For a 3-wide matrix that does
not matter; for a 1024-wide one it is the difference between 1.9 and 8 GFLOP/s, and it is the first
thing Chapter 10 fixes.
Batched matmul falls out of the same code. All leading axes (...) ride along untouched, so
(B, M, K) @ (B, K, N) broadcasts to (B, M, N, K) and reduces K. The test
test_ops[matmul_batched] checks a @ b.transpose() on (2, 3, 4) tensors against NumPy.
linear is x @ w + b, with the bias broadcast across the batch.
9.7 Convolution as im2col of shrinks
A 2-D convolution with a KH x KW kernel computes, for every output pixel, a dot product between the
kernel and the KH x KW window of input under it, summed over input channels. The classic way to make
convolution a matrix multiply is im2col: gather every window into a row of a big "patches" matrix,
then multiply by the flattened weights. Libraries usually implement im2col as a copy. We get it from
movement ops alone:
def conv2d(self, w: Tensor, b: Tensor | None = None, padding: int = 0, stride: int = 1) -> Tensor:
"""x (N, C, H, W), w (O, C, KH, KW). im2col from shrinks, then a matmul."""
x = self.pad(((0, 0), (0, 0), (padding, padding), (padding, padding))) if padding else self
N, C, H, W = x.shape
O, _, KH, KW = w.shape
OH, OW = (H - KH) // stride + 1, (W - KW) // stride + 1
cols = []
for ky in range(KH):
for kx in range(KW):
win = x.shrink(((0, N), (0, C), (ky, ky + stride * (OH - 1) + 1), (kx, kx + stride * (OW - 1) + 1)))
if stride > 1: win = win._stride2d(stride, OH, OW)
cols.append(win) # (N, C, OH, OW) each
patches = cols[0].stack_with(*cols[1:]) if len(cols) > 1 else cols[0].unsqueeze(0) # (KH*KW, N, C, OH, OW)
patches = patches.permute(1, 3, 4, 2, 0).reshape(N, OH, OW, C * KH * KW)
out = patches @ w.reshape(O, C * KH * KW).T # (N, OH, OW, O)
out = out.permute(0, 3, 1, 2)
return out + b.reshape(1, O, 1, 1) if b is not None else out
For each kernel offset (ky, kx) there is a SHRINK of the (padded) input that selects the pixels that
offset touches for every output position; each has shape (N, C, OH, OW). STACKing the KH*KW shrinks
gives (KH*KW, N, C, OH, OW). Permuting to (N, OH, OW, C, KH*KW) and merging the last two axes gives
the patches matrix (N, OH, OW, C*KH*KW), whose column order (channel-major, then ky, then kx)
matches w.reshape(O, C*KH*KW). One matmul later, a permute puts channels back in NCHW order.
Strided convolution needs every s-th window. With no strided SHRINK in the IR, _stride2d pads the
window up to a multiple of s, reshapes (OH*s) into (OH, s), and takes index 0 of the s axis:
def _stride2d(self, s: int, OH: int, OW: int) -> Tensor:
N, C, H, W = self.shape
t = self.pad(((0, 0), (0, 0), (0, OH * s - H), (0, OW * s - W)))
return t.reshape(N, C, OH, s, OW, s)[:, :, :, 0, :, 0]
Max pooling uses the same reshape trick without the pad: (H, W) becomes (H/k, k, W/k, k) and the two
k axes are max-reduced.
Is it right? A (1, 2, 5, 5) input, (3, 2, 3, 3) weights, padding=1, checked against a NumPy loop:
(1, 3, 5, 5) max err 1.4305115e-06
Is it fast? Here is the reduce kernel, with OPT=0. It is worth reading closely, because it is the plain
output of a compiler that has not yet learned anything about convolutions:
void r_1_5_5_3(float* restrict data0, float* restrict data1, float* restrict data2) {
for (int ridx0 = 0; ridx0 < 5; ridx0++) {
int alu1 = (ridx0*5);
int alu2 = ((ridx0+5)%6);
int alu3 = (alu2*5);
int alu4 = ((ridx0+7)%6);
int alu5 = (alu4*5);
for (int ridx1 = 0; ridx1 < 5; ridx1++) {
int alu6 = (ridx1+5);
int alu7 = (alu6%6);
int alu8 = (alu7%5);
int alu9 = ((ridx1+7)%6);
int alu10 = (alu9%5);
int alu11 = (ridx1+17);
int alu12 = (alu11%6);
int alu13 = ((ridx1+19)%6);
int alu14 = (ridx1+29);
int alu15 = (alu14%6);
int alu16 = (alu15%5);
int alu17 = ((ridx1+31)%6);
int alu18 = (alu17%5);
for (int ridx2 = 0; ridx2 < 3; ridx2++) {
float acc0;
acc0 = 0.0f;
for (int ridx3 = 0; ridx3 < 18; ridx3++) {
float val19 = acc0;
int alu20 = (ridx3%9);
int alu21 = (ridx3/9);
int alu22 = (alu21*25);
int alu23 = (alu22+((alu2%5)*5));
float val24 = data0[(alu23+alu8)];
float val25 = data0[(alu23+ridx1)];
float val26 = data0[(alu23+alu10)];
int alu27 = (alu22+alu1);
float val28 = data0[(alu27+(alu12%5))];
float val29 = data0[(alu27+ridx1)];
float val30 = data0[(alu27+(alu13%5))];
int alu31 = (alu22+((alu4%5)*5));
float val32 = data0[(alu31+alu16)];
float val33 = data0[(alu31+ridx1)];
float val34 = data0[(alu31+alu18)];
float val35 = data1[((((ridx2*18)+(alu21*9))+(((ridx3/3)%3)*3))+(ridx3%3))];
acc0 = (val19+(((alu20<1)?(((alu6/6)<1)?0.0f:(((alu7/5)<1)?(((((alu1+alu8)+25)/30)<1)?0.0f:((((alu3+alu8)/25)<1)?val24:0.0f)):0.0f)):((alu20<2)?(((((alu1+ridx1)+25)/30)<1)?0.0f:((((alu3+ridx1)/25)<1)?val25:0.0f)):((alu20<3)?(((alu9/5)<1)?(((((alu1+alu10)+25)/30)<1)?0.0f:((((alu3+alu10)/25)<1)?val26:0.0f)):0.0f):((alu20<4)?((((alu11/6)%2)<1)?0.0f:(((alu12/5)<1)?val28:0.0f)):((alu20<5)?val29:((alu20<6)?(((alu13/5)<1)?val30:0.0f):((alu20<7)?((((alu14/6)%2)<1)?0.0f:(((alu15/5)<1)?((((alu5+alu16)/25)<1)?val32:0.0f):0.0f)):((alu20<8)?((((alu5+ridx1)/25)<1)?val33:0.0f):(((alu17/5)<1)?((((alu5+alu18)/25)<1)?val34:0.0f):0.0f)))))))))*val35));
}
float val36 = acc0;
data2[(((ridx0*15)+(ridx1*3))+ridx2)] = val36;
}
}
}
}
Three things are visible. The 18-long reduce loop is C * KH * KW flattened, and ridx3 % 9 picks which
of the nine kernel taps this iteration belongs to: that is the STACK, lowered to a chain of WHERE
(alu20<1 ? ... : alu20<2 ? ...). The %6, /30 and /25 terms are the composite PAD, which
Chapter 6 builds from concatenation with zeros; each tap carries its own bounds check.
And all nine candidate loads are hoisted above the select, so every iteration performs nine loads to
use one. The kernel is correct and general, and roughly nine times more memory-hungry than it should be.
A convolution-aware compiler would make the tap index a real loop (or unroll it) instead of selecting
from a STACK; that is exercise 9.10.
9.8 Softmax, log-softmax and cross-entropy
Softmax turns a vector of scores into a probability distribution:
softmax(x)_i = exp(x_i) / sum_j exp(x_j). Computed literally in f32, it overflows as soon as a score
exceeds about 88 (exp(89) is larger than the largest float). The cure is an identity: for any constant
m, exp(x_i - m) / sum_j exp(x_j - m) equals exp(x_i) / sum_j exp(x_j), because the exp(-m) factors
cancel. Choosing m = max_j x_j makes every exponent at most 0, so nothing overflows, and the largest
term is exactly 1, so the sum is at least 1 and nothing underflows to a 0/0.
Classification losses want the log of the probability of the right class. Taking the log of a
softmax is numerically poor (a tiny probability rounds to 0 and its log to -inf), so we compute it
directly:
log_softmax(x)_i = (x_i - m) - log(sum_j exp(x_j - m)).
def softmax(self, axis=-1):
e = (self - self.max(axis, keepdim=True)).exp()
return e / e.sum(axis, keepdim=True)
def log_softmax(self, axis=-1):
m = self - self.max(axis, keepdim=True)
return m - m.exp().sum(axis, keepdim=True).log()
def logsumexp(self, axis=-1):
m = self.max(axis, keepdim=True)
return (self - m).exp().sum(axis, keepdim=True).log() + m
Cross-entropy for a batch of B examples with integer labels y_b is
L = -(1/B) * sum_b log_softmax(z_b)[y_b]. Picking "the entry at index y_b" is a gather, and gathers
with data-dependent indices are exactly what the IR cannot differentiate (_rule for INDEX refuses).
So we turn the label into a one-hot row and use a multiply-and-sum, which is an ordinary reduce:
def one_hot(self, n: int) -> Tensor:
return self.unsqueeze(-1).eq(Tensor.arange(n)).float()
def cross_entropy(self, labels: Tensor) -> Tensor:
"""self: (B, C) logits, labels: (B,) i32."""
return -(self.log_softmax(-1) * labels.one_hot(self.shape[-1])).sum(-1).mean()
one_hot is itself a broadcast: labels of shape (B,) become (B, 1), arange(n) has shape (n,), and
comparing them broadcasts to (B, n) with a 1 exactly where the column equals the label:
>>> Tensor(np.array([2, 0, 3], np.int32)).one_hot(4).numpy()
[[0. 0. 1. 0.]
[1. 0. 0. 0.]
[0. 0. 0. 1.]]
>>> logits.cross_entropy(labels).item(), numpy_reference
0.31853973865509033 0.3185397696491856
How many kernels does softmax become? REDUCE is a materialization point (Chapter 7),
so: one kernel for the row max, one for the row sum of exponentials (which reads the max), and one
elementwise kernel for the output. The test suite pins this down:
def test_kernelize_fuses_elementwise_and_splits_reductions():
a = Tensor(r(64, 64))
plan, _ = make_plan([((a * 2 + 1).relu() * 3).uop])
assert len(plan.kernels) == 1 # a chain of elementwise ops: one kernel
plan, _ = make_plan([a.softmax(-1).uop])
assert len(plan.kernels) == 3 # max, sum, and the elementwise output
Here are the three kernels for a (4, 8) softmax, OPT=0:
void r_4(float* restrict data0, float* restrict data1) {
for (int ridx0 = 0; ridx0 < 4; ridx0++) {
float acc0;
acc0 = -INFINITY;
for (int ridx1 = 0; ridx1 < 8; ridx1++) {
float val1 = acc0;
float val2 = data0[((ridx0*8)+ridx1)];
acc0 = ((val1>val2)?val1:val2);
}
float val3 = acc0;
data1[ridx0] = val3;
}
}
void r_4(float* restrict data0, float* restrict data1, float* restrict data2) {
for (int ridx0 = 0; ridx0 < 4; ridx0++) {
float acc0;
acc0 = 0.0f;
float val1 = data1[ridx0];
for (int ridx1 = 0; ridx1 < 8; ridx1++) {
float val2 = acc0;
float val3 = data0[((ridx0*8)+ridx1)];
acc0 = (val2+exp2f(((val3+(val1*-1.0f))*1.4426950408889634f)));
}
float val4 = acc0;
data2[ridx0] = val4;
}
}
void e_4_8(float* restrict data0, float* restrict data1, float* restrict data2, float* restrict data3) {
for (int ridx0 = 0; ridx0 < 4; ridx0++) {
float val1 = data1[ridx0];
float val2 = data2[ridx0];
for (int ridx1 = 0; ridx1 < 8; ridx1++) {
int alu3 = ((ridx0*8)+ridx1);
float val4 = data0[alu3];
data3[alu3] = (exp2f(((val4+(val1*-1.0f))*1.4426950408889634f))*(1.0f/val2));
}
}
}
Notice that exp2f(...) appears in both the second and the third kernel. The exponentials are not
materialized; they are recomputed in the consumer, because the kernelizer only cuts at reductions and
roots. Recomputing one exp2f per element is cheaper than writing and re-reading a temporary, so here
fusion wins. When the recomputation is a whole matmul instead of one exp2f, the answer flips, and that
trade-off is the subject of Chapter 11.
9.9 argmax without an argmax op
To measure accuracy we need the index of the largest logit. The IR has MAX-reduce but no ARGMAX, and adding one would mean a reduction that carries two values (the best value and its index), touching rangeify and the renderer. Instead, a trick with ops we already have:
def argmax(self, axis=-1):
"""Index of the first maximum, as i32."""
axis = axis % self.ndim
n = self.shape[axis]
idx = Tensor.arange(n).reshape((n,) + (1,) * (self.ndim - axis - 1))
is_max = self.eq(self.max(axis, keepdim=True))
return is_max.where(n - idx, 0).max(axis) * -1 + n
Step by step, for a row of length n:
is_maxis a 0/1 mask: where does the row equal its maximum?is_max.where(n - idx, 0)putsn - idxat every maximal position and 0 elsewhere. Sinceidx < n, the maximal positions all get positive values, and the smallest index gets the largest value.- MAX-reducing picks
n - first_idx, and* -1 + nturns it back intofirst_idx.
So ties go to the first occurrence, matching NumPy:
>>> x = Tensor(np.array([[0.1, 3.0, 3.0, -1.0], [2.0, 0.0, 5.0, 5.5]], np.float32))
>>> x.argmax(-1).numpy(), x.argmax(-1).dtype
[1 3] dtypes.i32
and it compiles to two reductions and a tiny epilogue (OPT=0):
void r_2(float* restrict data0, float* restrict data1, int* restrict data2, int* restrict data3) {
for (int ridx0 = 0; ridx0 < 2; ridx0++) {
int acc0;
acc0 = (-2147483647-1);
float val1 = data1[ridx0];
for (int ridx1 = 0; ridx1 < 4; ridx1++) {
int val2 = acc0;
float val3 = data0[((ridx0*4)+ridx1)];
int val4 = data2[ridx1];
acc0 = ((val2>(((val3!=val1)!=1)?((val4*-1)+4):0))?val2:(((val3!=val1)!=1)?((val4*-1)+4):0));
}
int val5 = acc0;
data3[ridx0] = val5;
}
}
The int accumulator starting at INT_MIN is the identity() of an integer MAX-reduce from rangeify,
and (val3!=val1)!=1 is how eq renders: "not not-equal". arange(n) is a real 4-element BUFFER
(data2) here; a slightly smarter frontend would make it a RANGE-derived value and save the load.
9.10 Gradients
The Tensor-level autodiff entry point is short because Chapter 8 did the work:
def backward(self, params: list[Tensor]) -> Tensor:
"""Set p.grad = d(self)/d(p) for each p. Gradients are lazy Tensors like everything else."""
assert self.shape == (), "backward needs a scalar"
root = getattr(self, "_graph", self.uop)
for p, g in zip(params, gradient(root, [p.uop for p in params])):
p.grad = Tensor(g) if p.grad is None else p.grad + Tensor(g)
return self
backward takes the parameter list explicitly, calls gradient(loss, [p.uop ...]), and stores each
result as a lazy Tensor in p.grad. Nothing is computed. Gradients are accumulated (p.grad + g) if
backward is called twice, as in PyTorch. Continuing the session from 9.1:
>>> l = Linear(3, 4)
realize: 1 kernels, schedule 1.0 ms, run 0.1 ms
>>> [p.shape for p in parameters(l)]
[(3, 4), (4,)]
>>> loss = l(x).square().mean(); loss.backward(parameters(l))
>>> l.weight.grad, l.bias.grad
<Tensor (3, 4) f32> <Tensor (4,) f32>
>>> l.bias.grad.numpy()
realize: 4 kernels, schedule 24.1 ms, run 0.2 ms
[ 0.65893304 1.2755529 0.39464036 -1.0922788 ]
(The one kernel during Linear(3, 4) is the zero-initialized bias being realized into a buffer.) The
gradient tests compare every interesting op against central finite differences in float64, with the same
numgrad helper you met in Chapter 8:
@pytest.mark.parametrize("name,fn,npfn", [
("tanh_sum", lambda t: t.tanh().sum(), lambda x: np.tanh(x).sum()),
("softmax_xent", lambda t: t.log_softmax(-1)[:, 1].sum(), lambda x: (x - np.log(np.exp(x).sum(-1, keepdims=True)))[:, 1].sum()),
("max", lambda t: (t.max(-1) * t.sum(0).max()).sum(), lambda x: (x.max(-1) * x.sum(0).max()).sum()),
("pad_shrink", lambda t: t.pad(((1, 1), (0, 2)))[1:3, 1:].square().sum(),
lambda x: (np.pad(x, ((1, 1), (0, 2)))[1:3, 1:] ** 2).sum()),
("sqrt_recip", lambda t: (t.square() + 1).sqrt().reciprocal().sum(), lambda x: (1 / np.sqrt(x ** 2 + 1)).sum()),
("matmul", lambda t: (t @ t.T).sin().sum(), lambda x: np.sin(x @ x.T).sum()),
])
def test_gradients(name, fn, npfn):
x = r(3, 4)
t = Tensor(x, requires_grad=True)
fn(t).backward([t])
np.testing.assert_allclose(t.grad.numpy(), numgrad(npfn, x), rtol=2e-2, atol=2e-3)
9.11 Layers
nn.py is written against the Tensor API only; it never touches a UOp. A layer is a Python object that
holds parameter Tensors (created with requires_grad=True) and has a __call__:
class Linear:
def __init__(self, n_in: int, n_out: int, bias: bool = True):
bound = 1 / math.sqrt(n_in)
self.weight = Tensor.uniform(n_in, n_out, low=-bound, high=bound, requires_grad=True)
self.bias = Tensor.zeros(n_out).realize() if bias else None
if self.bias is not None: self.bias.requires_grad = True
def __call__(self, x: Tensor) -> Tensor: return x.linear(self.weight, self.bias)
The weight is stored as (n_in, n_out) so that x @ w needs no transpose in the user's head (the
compiler will decide where a transpose is worth making; see Chapter 10). The
initialization is PyTorch's default for linear layers: uniform in +-1/sqrt(n_in), which keeps the
variance of activations roughly constant from layer to layer. The bias starts at zero and is realized
immediately, so it is a BUFFER rather than an EXPAND-of-a-CONST; otherwise "the bias" would be a constant
baked into every kernel and its gradient would have nowhere to go.
Conv2d is the same pattern around conv2d, LayerNorm normalizes the last axis
((x - mean) / sqrt(var + eps) * weight + bias), and Embedding is a one-hot matmul:
class Embedding:
"""A one-hot matmul: differentiable without a scatter op."""
def __init__(self, vocab: int, dim: int):
self.vocab, self.weight = vocab, Tensor.randn(vocab, dim) * 0.02
self.weight = self.weight.realize(); self.weight.requires_grad = True
def __call__(self, idx: Tensor) -> Tensor: return idx.one_hot(self.vocab) @ self.weight
A real embedding layer is a gather (weight[idx]), and its gradient is a scatter-add. Our IR has neither,
and autodiff refuses data-dependent INDEX. The one-hot product computes the same thing with ops autodiff
understands: one_hot(idx) @ W selects rows, and its gradient one_hot^T @ dY scatters into them. It
costs vocab times more FLOPs than a gather, which is fine for a character-level GPT
(Chapter 14) and terrible for a 50,000-token vocabulary.
Finally, the optimizer needs a list of parameters. Rather than make every layer register its
parameters, parameters() walks Python attributes, lists and dicts and collects every Tensor with
requires_grad:
def parameters(obj) -> list[Tensor]:
"""Every Tensor with requires_grad reachable through attributes, lists and dicts."""
out, seen = [], set()
def walk(o):
if id(o) in seen: return
seen.add(id(o))
if isinstance(o, Tensor):
if o.requires_grad: out.append(o)
elif isinstance(o, (list, tuple)): [walk(x) for x in o]
elif isinstance(o, dict): [walk(x) for x in o.values()]
elif hasattr(o, "__dict__"): [walk(x) for x in vars(o).values()]
walk(obj)
return out
The seen set handles shared sub-objects (and cycles). The order is the attribute order of each object,
which is deterministic, so the optimizer's state lines up with the same parameters every run.
9.12 Optimizers
An optimizer turns gradients into parameter updates. Every update in this library is built as a graph and realized in one batch, together with any extra tensors the caller wants (the loss).
SGD with momentum
Plain stochastic gradient descent is p <- p - lr * g. Momentum keeps a velocity v <- mu * v + g and
steps along it, p <- p - lr * v, which smooths noisy gradients and speeds progress along consistent
directions:
class SGD(Optimizer):
def __init__(self, params, lr=0.01, momentum=0.0):
super().__init__(params)
self.lr, self.momentum = lr, momentum
self.v = [Tensor.zeros(*p.shape).realize() for p in params] if momentum else []
def step(self, *extra: Tensor):
"""Update every parameter. Extra tensors (e.g. the loss) are realized in the same batch."""
news = []
for i, p in enumerate(self.params):
g = p.grad
if self.momentum:
self.v[i].assign(self.v[i] * self.momentum + g); g = self.v[i]
news.append(p.assign(p.detach() - g * self.lr))
Tensor.realize_all(*news, *self.v, *extra)
The test checks the arithmetic by hand for the loss sum(w^2) (gradient 2w):
def test_sgd_momentum_step():
w = Tensor(np.array([1.0, -2.0], np.float32), requires_grad=True)
opt = SGD([w], lr=0.1, momentum=0.9)
for _ in range(2):
loss = w.square().sum()
opt.zero_grad(); loss.backward(opt.params); opt.step()
# v1 = 2w0, w1 = w0 - .1*v1 = .8 w0; v2 = .9 v1 + 2 w1 = 3.4 w0, w2 = .8 w0 - .34 w0 = .46 w0
np.testing.assert_allclose(w.numpy(), np.array([0.46, -0.92]), rtol=1e-5)
AdamW
Adam keeps two running averages per parameter: the first moment m (mean of the gradient) and the
second moment v (mean of its square). With t counting steps from 1:
m <- b1 * m + (1 - b1) * gv <- b2 * v + (1 - b2) * g^2m_hat = m / (1 - b1^t),v_hat = v / (1 - b2^t)(bias correction: both start at 0, so early averages are too small; dividing by1 - b^tundoes that exactly for a constant gradient)p <- p * (1 - lr * wd) - lr * m_hat / (sqrt(v_hat) + eps)
Dividing by sqrt(v_hat) gives every parameter its own step size: parameters with large, noisy gradients
take smaller steps. The (1 - lr * wd) factor is decoupled weight decay, the "W" in AdamW: it shrinks
weights directly instead of adding wd * p to the gradient, which in Adam would get divided by
sqrt(v_hat) and lose its meaning.
class AdamW(Optimizer):
def __init__(self, params, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8, wd=0.0):
super().__init__(params)
self.lr, self.b1, self.b2, self.eps, self.wd, self.t = lr, b1, b2, eps, wd, 0
self.m = [Tensor.zeros(*p.shape).realize() for p in params]
self.v = [Tensor.zeros(*p.shape).realize() for p in params]
def step(self, *extra: Tensor):
self.t += 1
# bias corrections are python floats baked into the graph as constants: each step's
# graph differs only in two CONSTs, so we pass them as tensors to keep the JIT cache warm
c1 = Tensor([self.lr / (1 - self.b1 ** self.t)])
c2 = Tensor([1 / (1 - self.b2 ** self.t)])
news = []
for p, m, v in zip(self.params, self.m, self.v):
g = p.grad
m.assign(m * self.b1 + g * (1 - self.b1))
v.assign(v * self.b2 + g.square() * (1 - self.b2))
upd = m * c1.reshape(*([1] * p.ndim)) / ((v * c2.reshape(*([1] * p.ndim))).sqrt() + self.eps)
news.append(p.assign(p.detach() * (1 - self.lr * self.wd) - upd))
Tensor.realize_all(*news, *self.m, *self.v, *extra)
The only surprising lines are c1 and c2. The bias corrections depend on t, which changes every step.
Written as Python floats they would become CONST nodes in the graph, the graph would be structurally
different every step, and the structural JIT cache would miss every time. Passed as one-element tensors
they are BUFFERs, whose contents do not participate in the cache key (see section 7.8 in
Chapter 7). Here is the difference, measured
by a scratch subclass AdamWFloats whose step is identical except that c1, c2 are floats (time per
training step of the MNIST MLP, in ms, first eight steps):
AdamW plans cached 5 step ms: 409 14 16 16 12 11 11 13
AdamWFloats plans cached 12 step ms: 228 169 187 190 212 179 164 188
Same math, same results, more than ten times slower: every step misses the plan cache, so it is
re-kernelized, re-rangeified and re-rendered in Python. (In this run gcc did not have to recompile: the
.so files for these exact constants were already on disk from an earlier run of the same script. On a
truly cold cache each step also pays gcc for the update kernels, and an earlier run measured 600-800 ms
per step.) This is pitfall 17 in
book/NOTES.md, and the general lesson is: anything that changes between iterations must be data, not
structure.
9.13 Training MNIST
MNIST is 70,000 grayscale 28x28 images of handwritten digits (60,000 train, 10,000 test). The loader
downloads the four gzip files once into ~/.cache/uopc/data and returns flat float32 images scaled to
[0, 1] and int32 labels:
def mnist():
"""(X_train (60000, 784) f32 in [0,1], Y_train (60000,) i32, X_test, Y_test)"""
def images(n): return np.frombuffer(gzip.open(fetch(MNIST_URL + n, n)).read(), np.uint8, offset=16).reshape(-1, 784).astype(np.float32) / 255
def labels(n): return np.frombuffer(gzip.open(fetch(MNIST_URL + n, n)).read(), np.uint8, offset=8).astype(np.int32)
return (images("train-images-idx3-ubyte.gz"), labels("train-labels-idx1-ubyte.gz"),
images("t10k-images-idx3-ubyte.gz"), labels("t10k-labels-idx1-ubyte.gz"))
The model is a two-layer perceptron, 784 inputs, 128 hidden units with ReLU, 10 outputs, trained with AdamW on random minibatches of 128:
"""Week 5 milestone: train an MLP on MNIST with our own compiler.
python examples/mnist_mlp.py # ~94% test accuracy in 600 steps (~15 ms/step)
DEBUG=2 python examples/mnist_mlp.py # print every kernel with its time
"""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np
from datasets import mnist
from uopc.tensor import Tensor
from uopc.nn import Linear, AdamW, parameters
class MLP:
def __init__(self):
self.l1, self.l2 = Linear(784, 128), Linear(128, 10)
def __call__(self, x: Tensor) -> Tensor: return self.l2(self.l1(x).relu())
def main(steps=600, bs=128):
Xtr, Ytr, Xte, Yte = mnist()
Tensor.manual_seed(0)
model = MLP()
opt = AdamW(parameters(model), lr=1e-3)
rng = np.random.default_rng(0)
t0 = time.perf_counter()
for step in range(steps):
idx = rng.integers(0, len(Xtr), bs)
x, y = Tensor(Xtr[idx]), Tensor(Ytr[idx])
loss = model(x).cross_entropy(y)
opt.zero_grad()
loss.backward(opt.params)
opt.step(loss)
if step % 100 == 0 or step == steps - 1:
print(f"step {step:4d} loss {loss.item():.4f} {(time.perf_counter() - t0) / (step + 1) * 1e3:.1f} ms/step")
pred = np.concatenate([model(Tensor(Xte[i:i + 1000])).argmax(-1).numpy() for i in range(0, len(Xte), 1000)])
acc = (pred == Yte).mean()
print(f"test accuracy {acc * 100:.2f}%")
return acc
if __name__ == "__main__": main()
A training step is five lines: build the forward graph (model(x).cross_entropy(y)), clear the old
gradients, build the backward graph, and let the optimizer realize the new parameters, moments and loss
in one batch. Evaluation runs the model on the test set in chunks of 1000 and compares argmax with the
labels. The real output, from a run with the current code (book/outputs/mnist.txt holds an earlier
capture with identical losses and slightly slower steps: 460.7 ms for step 0 and 14.4 ms/step at the end):
step 0 loss 2.3134 284.8 ms/step
step 100 loss 0.3881 14.2 ms/step
step 200 loss 0.3272 13.1 ms/step
step 300 loss 0.2496 12.4 ms/step
step 400 loss 0.2643 11.9 ms/step
step 500 loss 0.2517 11.6 ms/step
step 599 loss 0.2162 11.6 ms/step
test accuracy 94.06%
(The ms/step column is a running average since step 0, so the expensive first step inflates the early
rows.) The initial loss 2.31 is about ln 10 = 2.30, the loss of a uniform guess over ten classes, which
is a good sanity check that initialization is sane. After 600 steps (about 1.3 epochs) the test accuracy
is 94.06%. A longer run, a learning-rate schedule or a CNN would do better; the milestone is that a model
written in your compiler learns.
Step 0 versus the rest. The first step takes about 300 ms because it plans and renders 32 kernels (and,
on a cold cache, runs gcc 32 times: with an empty UOPC_CACHE the same step took 3.7 s). Every later step
has the same graph structure: same ops, same shapes, different buffers. _structure_key
(Chapter 7) maps it to the cached plan, so the steady-state step only builds graphs,
hashes them, and calls 32 already-loaded C functions.
9.14 Anatomy of one training step
DEBUG=2 prints every kernel with its wall time and a rough GFLOP/s. Here is the second training step
(the first warm one) of the MLP above, from a run made while this chapter was being written (a rerun
with the current code produces the same 32 kernels in the same order; individual times move by tens of
percent from run to run):
e_1_128_784 0.279 ms 0.00 GFLOP/s
r_128_128 0.431 ms 59.65 GFLOP/s
r_128 0.047 ms 0.19 GFLOP/s
e_128_128 0.027 ms 1.84 GFLOP/s
e_1_10_128 0.013 ms 0.00 GFLOP/s
r_128_10 0.180 ms 1.82 GFLOP/s
r_128 0.051 ms 0.05 GFLOP/s
r_128 0.030 ms 0.26 GFLOP/s
r_128 0.041 ms 0.65 GFLOP/s
r_128 0.015 ms 0.43 GFLOP/s
e_128_10 0.048 ms 0.75 GFLOP/s
r_128_128 0.132 ms 2.48 GFLOP/s
e_128_128 0.052 ms 0.95 GFLOP/s
r_128_784 0.626 ms 41.01 GFLOP/s
e_784_128 0.375 ms 0.80 GFLOP/s
e_784_128 0.484 ms 0.83 GFLOP/s
e_784_128 0.597 ms 1.51 GFLOP/s
r_128 0.052 ms 0.32 GFLOP/s
e_128 0.035 ms 0.01 GFLOP/s
e_128 0.015 ms 0.03 GFLOP/s
e_128 0.025 ms 0.05 GFLOP/s
r_10_128 0.187 ms 1.75 GFLOP/s
e_128_10 0.038 ms 0.10 GFLOP/s
e_128_10 0.017 ms 0.30 GFLOP/s
e_128_10 0.029 ms 0.40 GFLOP/s
r_10 0.010 ms 0.13 GFLOP/s
e_10 0.014 ms 0.00 GFLOP/s
e_10 0.013 ms 0.00 GFLOP/s
e_10 0.023 ms 0.00 GFLOP/s
r_128 0.038 ms 0.41 GFLOP/s
r_1 0.010 ms 0.01 GFLOP/s
e_1 0.015 ms 0.00 GFLOP/s
realize: 32 kernels, schedule 1.6 ms, run 4.4 ms
Kernel names encode the output shape: r_ kernels contain a reduction, e_ kernels are purely
elementwise. By reading the C (DEBUG=4) you can assign every kernel to a role:
Summed, the kernels take 3.95 ms; the run 4.4 ms in the realize line also includes the Python and
ctypes cost of 32 calls. The six kernels that touch W1, the 784x128 matrix that holds 99% of the
parameters, account for 2.79 ms of it, and the twelve optimizer kernels together cost more than the
whole forward pass.
Forward (7 kernels). e_1_128_784 copies W1 into a transposed temporary and r_128_128 is x @ W1;
the copy is the layout pass from Chapter 10, which makes the reduce axis
contiguous for both operands. e_128_128 is relu(h + b1), materialized by stage_broadcast_compute
(Chapter 7) because it is broadcast into the next matmul and would otherwise be
recomputed for every output column. e_1_10_128 and r_128_10 are the copy-and-matmul pair for W2.
Two r_128 kernels compute the row max and the sum of exponentials of log_softmax.
Backward (10 kernels). Four small kernels for the gradient of cross-entropy with respect to the
logits (the last of them, e_128_10, writes dL/dlogits), r_128_128 and e_128_128 for dL/dh
through W2 and the ReLU, r_128_784 for dW1, r_10_128 for dW2, and two small reductions over the
batch for the bias gradients. The scheduler is free to interleave phases: the third kernel of the step
(r_128 reading the labels) is already a backward kernel, because it depends only on the labels. The ReLU backward kernel is a one-liner:
void e_128_128(float* restrict data0, float* restrict data1, float* restrict data2, float* restrict data3) {
for (int ridx0 = 0; ridx0 < 128; ridx0++) {
for (int ridx1 = 0; ridx1 < 128; ridx1++) {
int alu1 = ((ridx0*128)+ridx1);
float val2 = data0[alu1];
float val3 = data1[ridx1];
float val4 = data2[alu1];
data3[alu1] = ((0.0f<(val2+val3))?val4:0.0f);
}
}
}
It recomputes h + b1 > 0 from the pre-activation instead of storing a mask: that is the WHERE rule from
autodiff.py (s[0].where(g, 0)) fused with the forward expression that produced the condition.
Update (12 kernels). Three per parameter: new m, new v, new p. They are separate kernels because
m, v and p are all roots of the realize_all call, and roots are materialization points. The W1
update reads the freshly written m and v:
void e_784_128(float* restrict data0, float* restrict data1, float* restrict data2, float* restrict data3, float* restrict data4, float* restrict data5) {
float val1 = data2[0];
float val2 = data4[0];
#pragma omp parallel for
for (int ridx0 = 0; ridx0 < 784; ridx0++) {
for (int ridx1 = 0; ridx1 < 128; ridx1++) {
int alu3 = ((ridx0*128)+ridx1);
float val4 = data0[alu3];
float val5 = data1[alu3];
float val6 = data3[alu3];
data5[alu3] = (val4+(((val5*val1)*(1.0f/(sqrtf((val6*val2))+1e-08f)))*-1.0f));
}
}
}
data2[0] and data4[0] are c1 and c2: one-element BUFFERs, loaded once, outside the loop. With
wd = 0 the decay factor (1 - lr * wd) folded to 1 and vanished.
Loss (3 kernels). Per-example cross-entropy, the sum over the batch, and the final scale by -1/128.
Two observations are worth making before moving on. First, two of the backward kernels (a sum over
classes and a tie count) and part of a third compute the gradient that flows through the max in
log_softmax. Mathematically that
gradient is exactly zero: shifting all logits by a constant does not change log_softmax, so the
derivative with respect to the shift vanishes. Autodiff does not know that, and faithfully computes a
zero the expensive way. Second, the m and v updates for W1 read the gradient at
data1[((ridx1*784)+ridx0)]: the gradient is produced as (128, 784) (that is dW1 transposed) and
read with a stride of 784. Both are opportunities, not bugs.
9.15 What is slow, and why
The steady-state step costs about 12 ms (the last line of the run in 9.13), and the 32 kernels
account for roughly 4 to 6 ms of it. Where does the rest go? A scratch script that times the three phases
of 50 warm steps (graph building for the forward pass, backward, and opt.step) printed:
forward graph 1.87 ms/step
backward graph 1.71 ms/step
step (structure key+run) 9.81 ms/step
and cProfile over 50 warm steps (the profiler inflates everything, but the ranking is what matters):
ncalls tottime percall cumtime percall filename:lineno(function)
1600 0.168 0.000 0.198 0.000 uopc/runtime.py:53(__call__)
150 0.080 0.001 0.157 0.001 uopc/uop.py:131(toposort)
62950/32100 0.076 0.000 0.126 0.000 uopc/uop.py:100(_argkey)
11750 0.075 0.000 0.125 0.000 uopc/uop.py:230(_infer)
18650 0.050 0.000 0.326 0.000 uopc/uop.py:111(__new__)
50 0.037 0.001 0.190 0.004 uopc/kernelize.py:180(_structure_key)
Program.__call__ is the 32 C calls per step (the kernels themselves plus ctypes argument marshalling).
Everything else is Python: about 370 UOp constructions per step (18,650 over 50 steps) (each one hash-consed and type-checked by
_infer), topologically sorting the step's graph, and hashing its structure for the plan cache. This is
the tax of a Python compiler that rebuilds the graph every step. NOTES 22 tells how it was once much worse:
UOp.__eq__/__hash__ written in Python, and Enum.__hash__ being a Python function, made the GPT
compile take 163 s for 12 steps until identity hashing (in C) cut it to 47 s. Production systems avoid the
rebuild entirely by capturing the step once and replaying it (tinygrad's TinyJit, CUDA graphs).
What about the kernels themselves? The two big matmuls run at 41-60 GFLOP/s, the elementwise kernels at
around 1 GFLOP/s or less. For elementwise work that is expected: they are bound by memory and by fixed
per-call overhead, as Chapter 10 will quantify with the roofline model. Running
the whole training with the optimizer switched off (OPT=0, book/outputs/mnist_opt0.txt) gives the same
losses digit for digit and 17.2 ms/step instead of 14.4 (both from the earlier captures): on a model this small the Python overhead hides
most of the kernel speedup. On the 1024x1024 GEMM of the next chapter it is the other way around, and the
optimizer is worth a factor of more than 50.
9.16 Building it yourself: the week-5 milestone
If you are implementing this for the course, here is an order that keeps every step testable:
Tensor.__init__,numpy(),realize()on top of yourrealizefrom Chapter 7. Test:Tensor(a).numpy() == a._broadcast_shape,broadcast_to,_binop, and the arithmetic dunders. Test against NumPy on random shapes, including(2, 3, 4) + (4,),(2, 1, 4) * (3, 1)and scalar operands on both sides.- Movement methods and
__getitem__. Test chains of them against NumPy (test_ops[getitem_cat]). _reducewith arbitrary axes andkeepdim;mean,var,max,min. Testsum((0, 2)).matmul, thensoftmax/log_softmax(checksoftmax(x + 1000)does not produce NaN),one_hot,cross_entropy,argmax(check ties).backward, then gradient-check every op you wrote with finite differences.Linear,parameters,SGD; overfit 64 random points (test_training_reduces_loss). Only thenAdamWand the real dataset.- MNIST. Watch the first loss (should be near 2.30) and the ms/step after step 0 (should drop by more than an order of magnitude; if it does not, your JIT cache is missing).
Pitfalls
A changing Python constant defeats the JIT cache (NOTES 17). Section 9.12 measured it: AdamW's bias corrections as floats made every step more than ten times slower. Learning-rate schedules, dropout masks and step counters must all be tensors (see Chapter 7, Pitfalls).
Forgetting to realize optimizer state. realize_all(*news, *self.m, *self.v, *extra) is not
decoration. A scratch LeakyAdamW that realizes only the parameters still trains correctly, because the
new m and v get computed inside the parameter kernels. But m and v themselves stay lazy, so each
step's graph contains the previous step's graph:
step 0: 385 ms graph behind m[0]: 134 nodes
step 1: 263 ms graph behind m[0]: 235 nodes
step 2: 517 ms graph behind m[0]: 336 nodes
step 3: 616 ms graph behind m[0]: 437 nodes
step 4: 771 ms graph behind m[0]: 538 nodes
step 5: 1102 ms graph behind m[0]: 639 nodes
The graph grows by 101 nodes per step, the structure never repeats, and every step recompiles a bigger program. Any state that outlives a step must be realized every step.
Python scalars took the tensor's dtype. The first version of _binop built every Python scalar with
self.dtype, so Tensor([1, 2, 3]) * 0.5 multiplied by int(0.5) == 0, and Tensor([1, 2, 3]).mean()
was 0: NumPy promotes to float, we silently truncated. The fix is the promotion line at the top of
_binop (an int tensor meeting a Python float is cast to f32) and a mean that always sums in f32.
Now:
>>> t = Tensor([1, 2, 3])
>>> (t * 0.5).numpy(), t.mean().item()
[0.5 1. 1.5] 2.0
def test_python_float_promotes_int_tensors():
t = Tensor(np.array([1, 2, 3], np.int32))
np.testing.assert_allclose((t * 0.5).numpy(), [0.5, 1.0, 1.5])
assert t.mean().item() == 2.0
A kernel whose input simplified away overwrote that input. Found while writing this chapter. In
make_plan, a kernel's argument list (refs) was every BUFFER of its shaped body, collected before
lowering. The renderer, however, emits a C parameter only for buffers the simplified program still
reads. When simplification deletes a read entirely, the two lists disagree and every pointer shifts by
one. t * 0 on an integer tensor folds to the constant 0, the kernel's signature becomes
void e_3(int* restrict data1), and ctypes passed t's own buffer as data1: the first version printed
[0 0 0] [0 0 0] for (t * 0).numpy(), t.numpy(), writing the "output" into the input. (Floats escape,
because x * 0.0 is not folded: it is not 0.0 for NaN or infinity.) The fix keeps only the slots that
survive in the lowered program:
refs = [ph_ref[b] if b.arg.slot < 0 else ("in", in_idx[b]) for b in bufs] + [ref[u]]
# simplification can delete a read (x*0 -> 0): the C signature then has fewer parameters,
# so bind exactly the slots that survived, or every later argument shifts by one
alive = sorted({b.arg.slot for b in ast.toposort() if b.op is Ops.BUFFER})
refs = [refs[s] for s in alive]
The generated kernel still has one parameter, but it is now bound to the right buffer:
void e_3(int* restrict data1) {
for (int ridx0 = 0; ridx0 < 3; ridx0++) {
data1[ridx0] = 0;
}
}
>>> (t * 0).numpy(), t.numpy()
[0 0 0] [1 2 3]
def test_deleted_reads_do_not_shift_kernel_arguments():
t = Tensor(np.array([1, 2, 3], np.int32))
assert (t * 0).numpy().tolist() == [0, 0, 0] # simplifies to a constant: t is no longer read...
assert t.numpy().tolist() == [1, 2, 3] # ...and must not be overwritten
The general lesson: whenever two passes each compute "the list of parameters" independently, they will eventually disagree. Derive one from the other.
Unstable softmax. If you write x.exp() / x.exp().sum(-1, keepdim=True), it works on small random
inputs and returns NaN on real logits above about 88. Always subtract the max; test with x + 1000.
Fusion can recompute a lot (NOTES 15). In softmax the recomputed value is one cheap exp2f, but in a
transformer a LayerNorm output feeding a matmul is recomputed for every output column (see
Chapter 7, Pitfalls). If one kernel in DEBUG=2 output is mysteriously slow, read its C
and look for an expensive expression inside the reduce loop.
Reading the loss before backward used to zero the gradients. .item() realizes the loss, and
realizing rebinds loss.uop to the BUFFER holding the result (9.1). In the first version that was the
end of the story: the graph that connected the loss to the parameters was gone, gradient() found no
path and returned zeros, with no error and no warning:
>>> w = Tensor(np.array([1.0, -2.0], np.float32), requires_grad=True)
>>> loss = w.square().sum()
>>> loss.item()
5.0
>>> loss.backward([w]); w.grad.numpy()
[0. 0.]
The fix is the t._graph = t.uop line in realize_all (9.2): a realized tensor remembers the graph it
came from, and backward differentiates that graph if it exists:
def backward(self, params: list[Tensor]) -> Tensor:
"""Set p.grad = d(self)/d(p) for each p. Gradients are lazy Tensors like everything else."""
assert self.shape == (), "backward needs a scalar"
root = getattr(self, "_graph", self.uop)
for p, g in zip(params, gradient(root, [p.uop for p in params])):
p.grad = Tensor(g) if p.grad is None else p.grad + Tensor(g)
return self
The same session now prints 5.0 and then [ 2. -4.]. It is still good practice to read the loss
after opt.step(loss), as mnist_mlp.py does, so the loss is computed in the same batch of kernels.
detach() used to be a no-op for gradients. The first detach returned a new Tensor around the
same UOp, and gradient() follows UOps, not Tensor objects, so the gradient flowed straight through.
Now detach wraps the value in a marker node:
def detach(self) -> Tensor:
"""Same value, but gradients stop here. (Kernelize strips the marker, so it costs nothing to run.)"""
return Tensor(UOp(Ops.STAGE, (self.uop,), "detach"))
Autodiff's rule for STAGE passes the gradient through unchanged, except when the STAGE's arg is
"detach", where it returns no gradient. The kernelizer removes the marker before anything else, so it
costs nothing at run time:
strip_detach = PatternMatcher([(UPat(Ops.STAGE, arg="detach", src=(UPat.var("x"),)), lambda x: x)])
Both fixes are pinned by one test: realizing before backward keeps the gradient 2w, and
(w.detach() * w).sum() has gradient w, not 2w:
def test_item_before_backward_and_detach():
w = Tensor(np.array([1.0, 2.0], np.float32), requires_grad=True)
loss = (w * w).sum()
loss.item() # realizing the loss must keep its graph
loss.backward([w])
np.testing.assert_allclose(w.grad.numpy(), [2.0, 4.0])
w.grad = None
(w.detach() * w).sum().backward([w]) # d/dw (c * w) with c = w held constant
np.testing.assert_allclose(w.grad.numpy(), [1.0, 2.0])
Exercises
- ★ Count kernels. Predict how many kernels
(x - x.mean(-1, keepdim=True)).square().sum()produces forxof shape(64, 64), then check withmake_plan. Hint: count REDUCEs. - ★ Predict argmax. What does
Tensor(np.array([[3., 1., 3.]], np.float32)).argmax(-1).numpy()print, and why? What would happen if the trick usedidxinstead ofn - idx? - ★ Std and cross-entropy. Implement
Tensor.std(axis, keepdim)andTensor.logsumexp-basedcross_entropy(uselogsumexpinstead oflog_softmax). Gradient-check both. - ★★ A differentiable gather. Implement
Tensor.gather(axis, idx)for integer index tensors usingone_hotand a reduce, so that it is differentiable with respect to the data. What does it cost in FLOPs compared to a real gather? - ★★ A learning-rate schedule. Add a learning-rate schedule (linear warmup, then cosine decay) to
AdamWwithout causing JIT cache misses. Verify withDEBUG=1thatschedulestays around a millisecond after step 0. - ★★ Unused buffers. Revert the
alivefilter inmake_plan(in a scratch copy), watchtest_deleted_reads_do_not_shift_kernel_argumentsfail, then fix it the other way: render every buffer of the body as a C parameter even when it is unused. Which fix is better, and why? Hint: think about the structural cache and about identical kernels rendering identical C. - ★★ Dropout. Implement dropout:
x.dropout(p)in training mode multiplies by a random 0/(1/(1-p)) mask. Where must the randomness come from so that the graph structure is the same every step? - ★★ A small CNN. Train a small CNN on MNIST (
Conv2d(1, 8, 3), ReLU,max_pool2d,Linear). Report accuracy and ms/step, and identify the slowest kernel withDEBUG=2. Explain its cost from its C source. - ★★ Detach the max. Use
detach()to stop the gradient through the max inlog_softmax(it is mathematically zero, see section 9.14). Count the kernels per training step before and after, and check that the losses are unchanged to the printed precision. - ★★★ Fast convolution. Make
conv2dfast: instead of a STACK of shrinks selected with a WHERE chain, express the kernel taps as extra reduce axes (shape(N, O, OH, OW, C, KH, KW)reduced over the last three). Compare the generated C and the speed against section 9.7's kernel on a(64, 16, 28, 28)input.
Solutions to ★ exercises
1. Two: make_plan reports ['r_64', 'r_1']. The mean REDUCE is one materialization point
(r_64; its * (1/64) is elementwise and fuses into its consumer). The subtraction and the square are
elementwise and fuse into the final sum(), which is both a REDUCE and the root, so it is a single
point (r_1). Count materialization points, and remember that a root which is itself a REDUCE counts
once.
2. [0]. The row max is 3, is_max = [1, 0, 1], where(is_max, 3 - idx, 0) = [3, 0, 1], max is 3,
and 3 * -1 + 3 = 0. With idx instead of n - idx the max would pick the last maximal index (2), and
the zero for non-maximal positions would collide with index 0.
3. std = var.sqrt(). For cross-entropy:
(self.logsumexp(-1).reshape(-1) - (self * labels.one_hot(n)).sum(-1)).mean(). Both match finite
differences to about 5e-8. (With the first version of realize_all, reading the loss with .item()
before backward made this check fail with zero gradients; see the Pitfalls.)
Quiz
- Why is broadcasting free in this compiler, while in an eager library
np.broadcast_to(b, shape).copy()costs memory traffic? REDUCEonly reduces the lastnaxes. How doesx.sum(0)on a(4, 5)tensor become a valid graph?- Write
matmulfor(M, K) @ (K, N)as a sequence of movement ops, one MUL and one REDUCE, with the shape after each step. - Why does
log_softmaxsubtract the row max, and why does doing so not change the result? - Why are AdamW's bias corrections passed as one-element tensors rather than Python floats?
- How many kernels does a softmax over the last axis produce, and which values are recomputed rather than stored?
- Why does
Embeddinguse a one-hot matmul rather than indexing? - After
y.numpy(), what isy.uop, and why does that matter for graphs built onylater?
Answers
- EXPAND is lowered to an index expression that ignores the broadcast loop (index 0 on the stretched axis); no data is copied, the same element is simply re-read.
_reducepermutes the reduced axis to the end (permute(1, 0), shape(5, 4)) and REDUCEs 1 axis.A.reshape(M, 1, K).expand(M, N, K),B.permute(1, 0).reshape(1, N, K).expand(M, N, K), MUL gives(M, N, K),REDUCE(ADD, 1)gives(M, N).- To keep every exponent at most 0 (no overflow) with the largest term exactly 1 (no 0/0). The constant cancels between numerator and denominator (or, in log space, between the two terms).
- They change every step; as CONSTs they would change the graph structure and miss the JIT cache, so every step would recompile.
- Three: max, sum of exponentials, and the elementwise output. The exponentials are recomputed in the sum kernel and again in the output kernel.
- Autodiff cannot differentiate a data-dependent INDEX (its gradient is a scatter). One-hot times weights is an ordinary broadcast-multiply-reduce whose gradient is another matmul.
- A BUFFER (or a reshape of one) holding the result. Later graphs start from that data instead of re-including, and recomputing, the whole chain that produced it.
Further reading
- NumPy documentation, "Broadcasting" (
numpy.org/doc/stable/user/basics.broadcasting.html): the rules this chapter implements. - D. Kingma and J. Ba, "Adam: A Method for Stochastic Optimization", ICLR 2015; I. Loshchilov and F. Hutter, "Decoupled Weight Decay Regularization", ICLR 2019 (AdamW).
- K. Chellapilla, S. Puri, P. Simard, "High Performance Convolutional Neural Networks for Document Processing", 2006: the im2col formulation of convolution.
- Y. LeCun, C. Cortes, C. Burges, "The MNIST database of handwritten digits".
- tinygrad,
tinygrad/tensor.py: a production-sized version of this file, with the same lazy design. - A. Karpathy, micrograd: the smallest possible autodiff engine, a good contrast to graph-level autodiff.
Full code for this chapter
Every file this chapter builds, exactly as it is in the repository. Click a file to expand it.
uopc/tensor.py · 333 lines
"""Week 5: a lazy Tensor frontend.
A Tensor is a thin wrapper around a shaped UOp graph. Nothing runs until you call
.realize() / .numpy(): then the graph is kernelized, rangeified, rendered to C,
compiled, and executed. Every op below is a few lines of graph building on top of
the course's primitives -- there is no other math library underneath.
"""
from __future__ import annotations
import math, os
import numpy as np
from uopc.uop import UOp, Ops, prod
from uopc.dtype import DType, dtypes
from uopc.autodiff import gradient
from uopc.composite import pad as _pad, flip_axis
from uopc.kernelize import new_buffer, buffer_data, base_buffer, realize as _realize
from uopc.runtime import np_dtype
LOG2E, LN2 = 1 / math.log(2), math.log(2)
def _broadcast_shape(*shapes):
n = max(len(s) for s in shapes)
shapes = [(1,) * (n - len(s)) + tuple(s) for s in shapes]
out = []
for dims in zip(*shapes):
d = max(dims)
if any(x not in (1, d) for x in dims): raise ValueError(f"can't broadcast {shapes}")
out.append(d)
return tuple(out)
class Tensor:
rng = np.random.default_rng(0)
training = False
fuse_reduce = False
opt = None # week 6: kernel optimizer (set below)
def __init__(self, data, dtype: DType | None = None, requires_grad: bool = False):
if isinstance(data, UOp): self.uop = data
else:
arr = np.asarray(data)
if dtype is None: dtype = dtypes.f32 if arr.dtype.kind == "f" else dtypes.bool if arr.dtype.kind == "b" else dtypes.i32
arr = arr.astype(np_dtype(dtype))
buf = new_buffer(dtype, arr.size, arr)
self.uop = buf.reshape(arr.shape) if arr.shape != buf.shape else buf
self.requires_grad = requires_grad
self.grad: Tensor | None = None
# ---- basics -------------------------------------------------------------
@property
def shape(self) -> tuple[int, ...]: return self.uop.shape
@property
def dtype(self) -> DType: return self.uop.dtype
@property
def ndim(self) -> int: return len(self.shape)
def numel(self) -> int: return prod(self.shape)
def __repr__(self): return f"<Tensor {self.shape} {self.dtype.name}{' grad' if self.requires_grad else ''}>"
def realize(self) -> Tensor:
Tensor.realize_all(self)
return self
@staticmethod
def realize_all(*ts: Tensor):
todo = [t for t in ts if t.uop.op is not Ops.BUFFER and not (t.uop.op is Ops.RESHAPE and base_buffer(t.uop).op is Ops.BUFFER and prod(t.shape) == base_buffer(t.uop).arg.shape[0])]
if not todo: return
bufs = _realize([t.uop for t in todo], Tensor.fuse_reduce, Tensor.opt)
for t, b in zip(todo, bufs):
t._graph = t.uop # keep the graph: loss.item() before loss.backward() must still have gradients
t.uop = b.reshape(t.shape) if b.shape != t.shape else b
def numpy(self) -> np.ndarray:
self.realize()
return buffer_data(base_buffer(self.uop)).reshape(self.shape).copy()
def item(self): return self.numpy().item()
def detach(self) -> Tensor:
"""Same value, but gradients stop here. (Kernelize strips the marker, so it costs nothing to run.)"""
return Tensor(UOp(Ops.STAGE, (self.uop,), "detach"))
def assign(self, x: Tensor) -> Tensor:
"""Make this tensor (e.g. a parameter) hold x's value. Functional: rebinds to a new buffer."""
assert x.shape == self.shape and x.dtype == self.dtype, f"assign {x.shape} to {self.shape}"
self.uop = x.uop
return self
# ---- creation -----------------------------------------------------------
@staticmethod
def full(shape, v, dtype: DType = dtypes.f32) -> Tensor:
c = UOp.const(v, dtype)
return Tensor(c.reshape((1,) * len(shape)).expand(tuple(shape)) if shape else c)
@staticmethod
def zeros(*shape, dtype=dtypes.f32) -> Tensor: return Tensor.full(shape, 0, dtype)
@staticmethod
def ones(*shape, dtype=dtypes.f32) -> Tensor: return Tensor.full(shape, 1, dtype)
@staticmethod
def randn(*shape, requires_grad=False) -> Tensor:
return Tensor(Tensor.rng.standard_normal(shape).astype(np.float32), requires_grad=requires_grad)
@staticmethod
def uniform(*shape, low=-1.0, high=1.0, requires_grad=False) -> Tensor:
return Tensor(Tensor.rng.uniform(low, high, shape).astype(np.float32), requires_grad=requires_grad)
@staticmethod
def kaiming(*shape, requires_grad=True) -> Tensor:
bound = 1 / math.sqrt(prod(shape[1:]) if len(shape) > 1 else shape[0])
return Tensor.uniform(*shape, low=-bound, high=bound, requires_grad=requires_grad)
@staticmethod
def arange(n: int) -> Tensor: return Tensor(np.arange(n, dtype=np.int32))
@staticmethod
def manual_seed(s: int): Tensor.rng = np.random.default_rng(s)
# ---- elementwise ----------------------------------------------------------
def _const(self, x) -> Tensor: return x if isinstance(x, Tensor) else Tensor.full((), x, self.dtype)
def broadcast_to(self, shape) -> Tensor:
shape = tuple(shape)
if self.shape == shape: return self
u = self.uop
if len(u.shape) < len(shape): u = u.reshape((1,) * (len(shape) - len(u.shape)) + u.shape)
return Tensor(u.expand(shape) if u.shape != shape else u)
def _binop(self, op: Ops, x, reverse=False) -> Tensor:
# numpy-style promotion for python scalars: int tensor (op) 0.5 is a float computation
if isinstance(x, float) and not self.dtype.is_float: return self.float()._binop(op, x, reverse)
x = self._const(x)
a, b = (x, self) if reverse else (self, x)
shape = _broadcast_shape(a.shape, b.shape)
return Tensor(UOp(op, (a.broadcast_to(shape).uop, b.broadcast_to(shape).uop)))
def __add__(self, x): return self._binop(Ops.ADD, x)
def __radd__(self, x): return self._binop(Ops.ADD, x, True)
def __mul__(self, x): return self._binop(Ops.MUL, x)
def __rmul__(self, x): return self._binop(Ops.MUL, x, True)
def __neg__(self): return self * -1
def __sub__(self, x): return self + -self._const(x)
def __rsub__(self, x): return self._const(x) + -self
def __truediv__(self, x): return self * self._const(x).reciprocal()
def __rtruediv__(self, x): return self._const(x) * self.reciprocal()
def __lt__(self, x): return self._binop(Ops.CMPLT, x)
def __gt__(self, x): return self._binop(Ops.CMPLT, x, True)
def __ge__(self, x): return (self < x).logical_not()
def __le__(self, x): return (self > x).logical_not()
def eq(self, x): return self._binop(Ops.CMPNE, x).logical_not()
def ne(self, x): return self._binop(Ops.CMPNE, x)
def logical_not(self): return self._binop(Ops.CMPNE, True)
def maximum(self, x): return self._binop(Ops.MAX, x)
def minimum(self, x): return -((-self).maximum(-self._const(x)))
def where(self, a, b) -> Tensor:
a = a if isinstance(a, Tensor) else Tensor.full((), a)
b = b if isinstance(b, Tensor) else Tensor.full((), b, a.dtype)
shape = _broadcast_shape(self.shape, a.shape, b.shape)
return Tensor(UOp(Ops.WHERE, (self.broadcast_to(shape).uop, a.broadcast_to(shape).uop, b.broadcast_to(shape).uop)))
def cast(self, d: DType) -> Tensor: return Tensor(self.uop.cast(d))
def float(self) -> Tensor: return self.cast(dtypes.f32)
def reciprocal(self): return Tensor(self.uop.recip())
def exp2(self): return Tensor(UOp(Ops.EXP2, (self.uop,)))
def log2(self): return Tensor(UOp(Ops.LOG2, (self.uop,)))
def exp(self): return (self * LOG2E).exp2()
def log(self): return self.log2() * LN2
def sqrt(self): return Tensor(UOp(Ops.SQRT, (self.uop,)))
def rsqrt(self): return self.sqrt().reciprocal()
def sin(self): return Tensor(UOp(Ops.SIN, (self.uop,)))
def cos(self): return (self + math.pi / 2).sin()
def square(self): return self * self
def abs(self): return self.maximum(-self)
def relu(self): return (self > 0).where(self, 0.0)
def sigmoid(self): return ((-self).exp() + 1).reciprocal()
def tanh(self): return (self * 2).sigmoid() * 2 - 1
def gelu(self): return 0.5 * self * (1 + (math.sqrt(2 / math.pi) * (self + 0.044715 * self * self * self)).tanh())
def silu(self): return self * self.sigmoid()
# ---- movement -------------------------------------------------------------
def reshape(self, *shape) -> Tensor:
shape = tuple(shape[0]) if len(shape) == 1 and isinstance(shape[0], (tuple, list)) else shape
if -1 in shape:
known = prod(s for s in shape if s != -1)
shape = tuple(self.numel() // known if s == -1 else s for s in shape)
return self if shape == self.shape else Tensor(self.uop.reshape(shape))
def permute(self, *order) -> Tensor:
order = tuple(order[0]) if len(order) == 1 and isinstance(order[0], (tuple, list)) else order
order = tuple(o % self.ndim for o in order)
return self if order == tuple(range(self.ndim)) else Tensor(self.uop.permute(order))
def transpose(self, a=-1, b=-2) -> Tensor:
order = list(range(self.ndim))
order[a], order[b] = order[b], order[a]
return self.permute(order)
@property
def T(self) -> Tensor: return self.transpose()
def expand(self, *shape) -> Tensor:
shape = tuple(shape[0]) if len(shape) == 1 and isinstance(shape[0], (tuple, list)) else shape
return self.broadcast_to(tuple(s if s != -1 else o for s, o in zip(shape, self.shape)))
def flatten(self, start=0) -> Tensor: return self.reshape(self.shape[:start] + (-1,))
def unsqueeze(self, d) -> Tensor:
d = d % (self.ndim + 1)
return self.reshape(self.shape[:d] + (1,) + self.shape[d:])
def shrink(self, arg) -> Tensor:
arg = tuple((0, s) if a is None else a for a, s in zip(arg, self.shape))
return self if all(a == (0, s) for a, s in zip(arg, self.shape)) else Tensor(self.uop.shrink(arg))
def pad(self, arg) -> Tensor: return Tensor(_pad(self.uop, tuple(arg)))
def flip(self, axis: int) -> Tensor: return Tensor(flip_axis(self.uop, axis % self.ndim))
def __getitem__(self, idx) -> Tensor:
"""Ints and step-1 slices only."""
idx = idx if isinstance(idx, tuple) else (idx,)
arg, squeeze = [], []
for d, i in enumerate(idx):
n = self.shape[d]
if isinstance(i, int):
i = i % n
arg.append((i, i + 1)); squeeze.append(d)
else:
b, e, step = i.indices(n)
assert step == 1, "only step-1 slices"
arg.append((b, max(b, e)))
arg += [(0, s) for s in self.shape[len(arg):]]
t = self.shrink(arg)
return t.reshape(tuple(s for d, s in enumerate(t.shape) if d not in squeeze))
@staticmethod
def cat(ts: list[Tensor], dim: int = 0) -> Tensor:
"""Concatenate via pad + add: each piece is padded into place, then summed."""
dim = dim % ts[0].ndim
total = sum(t.shape[dim] for t in ts)
out, off = None, 0
for t in ts:
p = [(0, 0)] * t.ndim
p[dim] = (off, total - off - t.shape[dim])
piece = t.pad(p)
out = piece if out is None else out + piece
off += t.shape[dim]
return out
def stack_with(self, *others) -> Tensor: return Tensor(UOp(Ops.STACK, (self.uop, *(o.uop for o in others))))
# ---- reductions -----------------------------------------------------------
def _reduce(self, op: Ops, axis=None, keepdim=False) -> Tensor:
axes = list(range(self.ndim)) if axis is None else [a % self.ndim for a in (axis if isinstance(axis, (tuple, list)) else [axis])]
keep = [i for i in range(self.ndim) if i not in axes]
t = self.permute(keep + axes)
r = Tensor(t.uop.reduce(op, len(axes)))
if keepdim: r = r.reshape(tuple(1 if i in axes else s for i, s in enumerate(self.shape)))
return r
def sum(self, axis=None, keepdim=False): return self._reduce(Ops.ADD, axis, keepdim)
def max(self, axis=None, keepdim=False): return self._reduce(Ops.MAX, axis, keepdim)
def min(self, axis=None, keepdim=False): return -((-self).max(axis, keepdim))
def mean(self, axis=None, keepdim=False):
s = (self if self.dtype.is_float else self.float()).sum(axis, keepdim)
return s * (s.numel() / self.numel())
def var(self, axis=None, keepdim=False):
return (self - self.mean(axis, keepdim=True)).square().mean(axis, keepdim)
def softmax(self, axis=-1):
e = (self - self.max(axis, keepdim=True)).exp()
return e / e.sum(axis, keepdim=True)
def log_softmax(self, axis=-1):
m = self - self.max(axis, keepdim=True)
return m - m.exp().sum(axis, keepdim=True).log()
def logsumexp(self, axis=-1):
m = self.max(axis, keepdim=True)
return (self - m).exp().sum(axis, keepdim=True).log() + m
def argmax(self, axis=-1):
"""Index of the first maximum, as i32."""
axis = axis % self.ndim
n = self.shape[axis]
idx = Tensor.arange(n).reshape((n,) + (1,) * (self.ndim - axis - 1))
is_max = self.eq(self.max(axis, keepdim=True))
return is_max.where(n - idx, 0).max(axis) * -1 + n
# ---- linear algebra -------------------------------------------------------
def matmul(self, w: Tensor) -> Tensor:
"""(..., M, K) @ (..., K, N): broadcast to (..., M, N, K), multiply, reduce K."""
x = self if self.ndim > 1 else self.reshape(1, -1)
wt = w.reshape(-1, 1) if w.ndim == 1 else w
wt = wt.transpose() # (..., N, K)
x = x.reshape(x.shape[:-1] + (1, x.shape[-1])) # (..., M, 1, K)
wt = wt.reshape(wt.shape[:-2] + (1,) + wt.shape[-2:]) # (..., 1, N, K)
out = (x * wt).sum(-1)
if self.ndim == 1: out = out.reshape(out.shape[:-2] + out.shape[-1:])
if w.ndim == 1: out = out.reshape(out.shape[:-1])
return out
__matmul__ = matmul
def linear(self, w: Tensor, b: Tensor | None = None) -> Tensor:
out = self @ w
return out + b if b is not None else out
def conv2d(self, w: Tensor, b: Tensor | None = None, padding: int = 0, stride: int = 1) -> Tensor:
"""x (N, C, H, W), w (O, C, KH, KW). im2col from shrinks, then a matmul."""
x = self.pad(((0, 0), (0, 0), (padding, padding), (padding, padding))) if padding else self
N, C, H, W = x.shape
O, _, KH, KW = w.shape
OH, OW = (H - KH) // stride + 1, (W - KW) // stride + 1
cols = []
for ky in range(KH):
for kx in range(KW):
win = x.shrink(((0, N), (0, C), (ky, ky + stride * (OH - 1) + 1), (kx, kx + stride * (OW - 1) + 1)))
if stride > 1: win = win._stride2d(stride, OH, OW)
cols.append(win) # (N, C, OH, OW) each
patches = cols[0].stack_with(*cols[1:]) if len(cols) > 1 else cols[0].unsqueeze(0) # (KH*KW, N, C, OH, OW)
patches = patches.permute(1, 3, 4, 2, 0).reshape(N, OH, OW, C * KH * KW)
out = patches @ w.reshape(O, C * KH * KW).T # (N, OH, OW, O)
out = out.permute(0, 3, 1, 2)
return out + b.reshape(1, O, 1, 1) if b is not None else out
def _stride2d(self, s: int, OH: int, OW: int) -> Tensor:
N, C, H, W = self.shape
t = self.pad(((0, 0), (0, 0), (0, OH * s - H), (0, OW * s - W)))
return t.reshape(N, C, OH, s, OW, s)[:, :, :, 0, :, 0]
def max_pool2d(self, k: int = 2) -> Tensor:
N, C, H, W = self.shape
return self[:, :, :H // k * k, :W // k * k].reshape(N, C, H // k, k, W // k, k).max((3, 5))
# ---- losses ---------------------------------------------------------------
def one_hot(self, n: int) -> Tensor:
return self.unsqueeze(-1).eq(Tensor.arange(n)).float()
def cross_entropy(self, labels: Tensor) -> Tensor:
"""self: (B, C) logits, labels: (B,) i32."""
return -(self.log_softmax(-1) * labels.one_hot(self.shape[-1])).sum(-1).mean()
# ---- autodiff ------------------------------------------------------------
def backward(self, params: list[Tensor]) -> Tensor:
"""Set p.grad = d(self)/d(p) for each p. Gradients are lazy Tensors like everything else."""
assert self.shape == (), "backward needs a scalar"
root = getattr(self, "_graph", self.uop)
for p, g in zip(params, gradient(root, [p.uop for p in params])):
p.grad = Tensor(g) if p.grad is None else p.grad + Tensor(g)
return self
# ---- staging -------------------------------------------------------------
def contiguous(self) -> Tensor:
"""Ask for a materialization point: the kernelizer will write this into a temp buffer."""
return Tensor(UOp(Ops.STAGE, (self.uop,)))
if os.environ.get("OPT", "1") != "0":
from uopc.opt import HandOpt, SearchOpt
Tensor.opt = SearchOpt() if os.environ.get("OPT") == "2" else HandOpt()
uopc/nn.py · 100 lines
"""Week 5: layers and optimizers, written against the Tensor API only."""
from __future__ import annotations
import math
from uopc.tensor import Tensor
class Linear:
def __init__(self, n_in: int, n_out: int, bias: bool = True):
bound = 1 / math.sqrt(n_in)
self.weight = Tensor.uniform(n_in, n_out, low=-bound, high=bound, requires_grad=True)
self.bias = Tensor.zeros(n_out).realize() if bias else None
if self.bias is not None: self.bias.requires_grad = True
def __call__(self, x: Tensor) -> Tensor: return x.linear(self.weight, self.bias)
class Conv2d:
def __init__(self, c_in: int, c_out: int, k: int, padding: int = 0, stride: int = 1):
bound = 1 / math.sqrt(c_in * k * k)
self.weight = Tensor.uniform(c_out, c_in, k, k, low=-bound, high=bound, requires_grad=True)
self.bias = Tensor.zeros(c_out).realize()
self.bias.requires_grad = True
self.padding, self.stride = padding, stride
def __call__(self, x: Tensor) -> Tensor: return x.conv2d(self.weight, self.bias, self.padding, self.stride)
class LayerNorm:
def __init__(self, n: int, eps: float = 1e-5):
self.weight, self.bias, self.eps = Tensor.ones(n).realize(), Tensor.zeros(n).realize(), eps
self.weight.requires_grad = self.bias.requires_grad = True
def __call__(self, x: Tensor) -> Tensor:
mu = x.mean(-1, keepdim=True)
xc = x - mu
return xc * (xc.square().mean(-1, keepdim=True) + self.eps).rsqrt() * self.weight + self.bias
class Embedding:
"""A one-hot matmul: differentiable without a scatter op."""
def __init__(self, vocab: int, dim: int):
self.vocab, self.weight = vocab, Tensor.randn(vocab, dim) * 0.02
self.weight = self.weight.realize(); self.weight.requires_grad = True
def __call__(self, idx: Tensor) -> Tensor: return idx.one_hot(self.vocab) @ self.weight
def parameters(obj) -> list[Tensor]:
"""Every Tensor with requires_grad reachable through attributes, lists and dicts."""
out, seen = [], set()
def walk(o):
if id(o) in seen: return
seen.add(id(o))
if isinstance(o, Tensor):
if o.requires_grad: out.append(o)
elif isinstance(o, (list, tuple)): [walk(x) for x in o]
elif isinstance(o, dict): [walk(x) for x in o.values()]
elif hasattr(o, "__dict__"): [walk(x) for x in vars(o).values()]
walk(obj)
return out
class Optimizer:
def __init__(self, params: list[Tensor]): self.params = params
def zero_grad(self):
for p in self.params: p.grad = None
class SGD(Optimizer):
def __init__(self, params, lr=0.01, momentum=0.0):
super().__init__(params)
self.lr, self.momentum = lr, momentum
self.v = [Tensor.zeros(*p.shape).realize() for p in params] if momentum else []
def step(self, *extra: Tensor):
"""Update every parameter. Extra tensors (e.g. the loss) are realized in the same batch."""
news = []
for i, p in enumerate(self.params):
g = p.grad
if self.momentum:
self.v[i].assign(self.v[i] * self.momentum + g); g = self.v[i]
news.append(p.assign(p.detach() - g * self.lr))
Tensor.realize_all(*news, *self.v, *extra)
class AdamW(Optimizer):
def __init__(self, params, lr=1e-3, b1=0.9, b2=0.999, eps=1e-8, wd=0.0):
super().__init__(params)
self.lr, self.b1, self.b2, self.eps, self.wd, self.t = lr, b1, b2, eps, wd, 0
self.m = [Tensor.zeros(*p.shape).realize() for p in params]
self.v = [Tensor.zeros(*p.shape).realize() for p in params]
def step(self, *extra: Tensor):
self.t += 1
# bias corrections are python floats baked into the graph as constants: each step's
# graph differs only in two CONSTs, so we pass them as tensors to keep the JIT cache warm
c1 = Tensor([self.lr / (1 - self.b1 ** self.t)])
c2 = Tensor([1 / (1 - self.b2 ** self.t)])
news = []
for p, m, v in zip(self.params, self.m, self.v):
g = p.grad
m.assign(m * self.b1 + g * (1 - self.b1))
v.assign(v * self.b2 + g.square() * (1 - self.b2))
upd = m * c1.reshape(*([1] * p.ndim)) / ((v * c2.reshape(*([1] * p.ndim))).sqrt() + self.eps)
news.append(p.assign(p.detach() * (1 - self.lr * self.wd) - upd))
Tensor.realize_all(*news, *self.m, *self.v, *extra)
examples/mnist_mlp.py · 43 lines
"""Week 5 milestone: train an MLP on MNIST with our own compiler.
python examples/mnist_mlp.py # ~94% test accuracy in 600 steps (~15 ms/step)
DEBUG=2 python examples/mnist_mlp.py # print every kernel with its time
"""
import sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np
from datasets import mnist
from uopc.tensor import Tensor
from uopc.nn import Linear, AdamW, parameters
class MLP:
def __init__(self):
self.l1, self.l2 = Linear(784, 128), Linear(128, 10)
def __call__(self, x: Tensor) -> Tensor: return self.l2(self.l1(x).relu())
def main(steps=600, bs=128):
Xtr, Ytr, Xte, Yte = mnist()
Tensor.manual_seed(0)
model = MLP()
opt = AdamW(parameters(model), lr=1e-3)
rng = np.random.default_rng(0)
t0 = time.perf_counter()
for step in range(steps):
idx = rng.integers(0, len(Xtr), bs)
x, y = Tensor(Xtr[idx]), Tensor(Ytr[idx])
loss = model(x).cross_entropy(y)
opt.zero_grad()
loss.backward(opt.params)
opt.step(loss)
if step % 100 == 0 or step == steps - 1:
print(f"step {step:4d} loss {loss.item():.4f} {(time.perf_counter() - t0) / (step + 1) * 1e3:.1f} ms/step")
pred = np.concatenate([model(Tensor(Xte[i:i + 1000])).argmax(-1).numpy() for i in range(0, len(Xte), 1000)])
acc = (pred == Yte).mean()
print(f"test accuracy {acc * 100:.2f}%")
return acc
if __name__ == "__main__": main()
examples/datasets.py · 29 lines
"""Tiny dataset loaders. Files are cached in ~/.cache/uopc/data."""
import gzip, urllib.request
from pathlib import Path
import numpy as np
DATA = Path.home() / ".cache" / "uopc" / "data"
MNIST_URL = "https://storage.googleapis.com/cvdf-datasets/mnist/"
SHAKESPEARE_URL = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
def fetch(url: str, name: str) -> Path:
DATA.mkdir(parents=True, exist_ok=True)
p = DATA / name
if not p.exists():
print(f"downloading {url}")
urllib.request.urlretrieve(url, p)
return p
def mnist():
"""(X_train (60000, 784) f32 in [0,1], Y_train (60000,) i32, X_test, Y_test)"""
def images(n): return np.frombuffer(gzip.open(fetch(MNIST_URL + n, n)).read(), np.uint8, offset=16).reshape(-1, 784).astype(np.float32) / 255
def labels(n): return np.frombuffer(gzip.open(fetch(MNIST_URL + n, n)).read(), np.uint8, offset=8).astype(np.int32)
return (images("train-images-idx3-ubyte.gz"), labels("train-labels-idx1-ubyte.gz"),
images("t10k-images-idx3-ubyte.gz"), labels("t10k-labels-idx1-ubyte.gz"))
def shakespeare() -> str:
return fetch(SHAKESPEARE_URL, "tinyshakespeare.txt").read_text()