Automatic Differentiation¶
When a model doesn't fit the Sequential mold (weight sharing, custom losses, exotic architectures), build the computation directly and let reverse-mode autodiff derive the gradients. The engine is micrograd-style: a dynamically built graph of Nodes, define-by-run, single-use.
A Node holds an n-dimensional Tensor, so the same ops that run on a (rows, cols) matrix run on a (batch, sequence, model) activation: element-wise arithmetic broadcasts NumPy-style and MatMul multiplies whole stacks of matrices. A Matrix is taken as a zero-copy 2-D view wherever a leaf is built, so two-dimensional code reads the way it always did.
Params, Inputs, and the Trainer¶
w1 := autograd.Param(tensai.RandomMatrix(2, 8, rng))
b1 := autograd.Param(tensai.NewMatrix(1, 8))
w2 := autograd.Param(tensai.RandomMatrix(8, 1, rng))
trainer := autograd.NewTrainer(optim.NewAdam(0.05), w1, b1, w2)
for step := 0; step < 2000; step++ {
loss := autograd.Input(x).MatMul(w1).AddRow(b1).Tanh().MatMul(w2).Sigmoid().MSELoss(y.Tensor())
trainer.Step(loss) // backward + update + zero grads, returns the loss value
}
Param wraps a value whose gradient should be tracked and updated; Input wraps data. Both accept a *tensai.Matrix or a *tensai.Tensor — a matrix becomes a 2-D tensor view sharing the same backing array, so a parameter built from a matrix keeps updating that matrix.
For manual control, the pieces are public: loss.Backward(), p.Grad(), and autograd.ZeroGrads(params...). Backward starts from a single-element node and accumulates into the gradient, which is why a training step clears the gradients afterwards (Trainer.Step does it for you).
On a node, Value() and Grad() return *tensai.Tensor. They are methods rather than fields because a value does not have to live in host memory: on a device-resident graph they are where it comes back. Shape() returns the shape without fetching anything, Matrix() returns a matrix view of a 2-D node, Scalar() reads a single-element node, and Named("w1") labels a leaf for ToDot.
Ops¶
| Op | Shapes |
|---|---|
MatMul(o) |
(…, m, k) * (…, k, n) → (…, m, n); the leading axes broadcast, so one 2-D weight applies to a whole batch |
Add, Sub, Mul (MulElem), Div |
element-wise, NumPy broadcasting |
Scale(s), Neg() |
element-wise by a scalar |
AddRow(row) |
(m, n) + (1, n); Add already broadcasts, this only states the intent |
T(), Transpose(perm...) |
swap the last two axes / permute every axis |
Reshape(shape...) |
one dimension may be -1; shares the buffer |
Softmax() |
over the last axis |
LayerNorm(gain, bias, eps) |
over the last axis; gain and bias hold one element per feature and may be nil or trainable |
Embed(ids, shape...) |
(vocab, d) table plus len(ids) indices → shape…, d; repeated ids accumulate on backward |
Conv2D(w, bias, cfg) |
(batch, channels, h, w) convolved with (channels*k*k, outChannels) weights → (batch, outChannels, outH, outW) |
Im2Col(cfg) |
the patch expansion a convolution multiplies, on its own |
MaxPool2D(size), AvgPool2D(size) |
square windows over the last two axes, stride equal to the window |
Sum(), Mean() |
reduce everything to one element |
SumAxis(axis, keepDims), MeanAxis(axis, keepDims) |
reduce one axis; a negative axis counts from the end |
ReLU(), LeakyReLU(a), Sigmoid(), Tanh(), GELU(), Exp(), Log(), Sqrt() |
element-wise |
Dropout(rate, rng) |
zeroes elements and scales the survivors; the mask is drawn once and the backward pass reuses it |
MSELoss(target), SoftmaxCELoss(target), CrossEntropy(labels []int) |
scalar losses; the cross-entropies read the last axis as classes |
Graphs are built dynamically per step and are single-use. Shape mismatches panic during construction rather than returning an error: chaining would be unusable otherwise, and a wrong shape is a programming mistake. Every op's gradient is verified against finite differences in the test suite.
Convolutions¶
A convolution is an Im2Col and a product: the expansion turns each output pixel into a row of the patch that produced it, and the weights are one column per output channel.
// x is (batch, channels, height, width); w is (channels*k*k, outChannels).
h := x.Conv2D(w, bias, autograd.Conv{Kernel: 3, Pad: 1})
h = h.ReLU().MaxPool2D(2)
Conv2D composes the pieces -- x.Im2Col(cfg).MatMul(w).Transpose(0, 2, 1).Reshape(...) plus the bias -- so the product it spends its time in is the same one the GPU accelerates. Im2Col is exposed on its own for anything the composition does not cover, and MaxPool2D and AvgPool2D route their gradients to the position that won and over the window respectively. The forward pass matches layer.Conv2D, which the Sequential models use.
Broadcasting¶
An element-wise op aligns shapes at their trailing axes and stretches any axis of length 1, so a (1, 1, d) bias adds to a (batch, seq, d) activation. Gradients follow the same rule in reverse: whatever axes an operand was stretched along, its gradient is summed back over. That is why a bias, a per-feature LayerNorm gain, and a weight shared across a batch all collect the contributions of every position that used them without any special case in the op.
Building attention¶
Multi-head attention is a reshape, a transpose, and two batched products:
// x is (batch, seq, model); wq, wk, wv, wo are (model, model).
heads := func(t *autograd.Node) *autograd.Node {
// (batch, seq, model) -> (batch, head, seq, headDim)
return t.Reshape(batch, seq, nHeads, headDim).Transpose(0, 2, 1, 3)
}
q, k, v := heads(x.MatMul(wq)), heads(x.MatMul(wk)), heads(x.MatMul(wv))
// (batch, head, seq, seq) scores for every head of every sequence at once.
att := q.MatMul(k.T()).Scale(1 / float32(math.Sqrt(headDim))).Add(mask).Softmax()
y := att.MatMul(v).Transpose(0, 2, 1, 3).Reshape(batch, seq, model).MatMul(wo)
mask is a constant Input of shape (1, 1, seq, seq) holding 0 on and below the diagonal and math.Inf(-1) above it, broadcast over batch and head; softmax then gives future positions no weight. _example/tinygpt puts exactly this into a working character-level transformer — pre-norm blocks, a GELU feed-forward, and next-character cross-entropy — that memorizes a page of text in about a minute.
Reusing buffers with a tape¶
A graph is built and thrown away every step, so by default every step asks the allocator for every intermediate value and gradient it touches. A Tape recycles them. Bind the parameters once, then reset it at the end of each step:
tape := autograd.NewTape()
tape.Bind(w1, b1, w2, b2) // ops inherit the tape from their parents
for step := 0; step < steps; step++ {
trainer.Step(forward(x).MSELoss(y))
tape.Reset() // hands this step's buffers back to the pool
}
The rule is the one a training loop already follows: after Reset, nothing from the finished step may be read — no node's Value, no node's Grad. Parameter values are never recycled (the tape only owns what operations produce), so trained weights are always safe to keep; copy anything else with Clone before resetting. A Tape is not safe for concurrent use, so give each training goroutine its own.
On _example/charrnn, which unrolls 32 time steps per iteration, the tape takes a training step from 22 MB of allocation to 0.75 MB and cuts about a quarter off its wall time.
The same reuse is available one layer down: MatMulInto, MatMulTNInto, MatMulNTInto, AddInto, SubInto, MulInto and DivInto write into a tensor you already own, the way DotInto does for matrices.
Running the graph on a GPU¶
A tape can put the whole graph on a device. Values, gradients and the Adam update stay there; only the loss comes home each step:
dev, err := gpu.Open(gpu.HighPerformance)
if err == nil {
defer dev.Close()
tape.UseDevice(dev)
}
tape.Bind(params...) // parameters upload once and stay resident
Value() and Grad() download on demand, so reading a node still works — that is what makes them methods. Resident() reports where a node's value currently is.
Operations the device has kernels for run there; anything else falls back to the CPU, bringing home only what that operation needs. That covers the three products, element-wise arithmetic with a repeating operand, Scale, ReLU/tanh/sigmoid/GELU and their gradients, LayerNorm, Softmax, Transpose, Reshape, Embed (its scatter-add included), the sum a broadcast collects, and the Adam update — so a whole transformer block stays resident, and only the loss crosses the bus.
On an AMD 780M through -tags wgpu24, one step of x @ w1 -> GELU -> @ w2 -> MSE:
| model width | CPU (AVX2) | accelerator hook | resident graph |
|---|---|---|---|
| 512 | 31.1ms | 30.8ms | 13.1ms |
| 1024 | 172.8ms | 93.2ms | 33.3ms |
| 2048 | 1382ms | 442ms | 193ms |
A transformer block -- pre-norm attention with a causal mask and a GELU feed-forward, batch 8, sequence 128, vocabulary 512 -- trains at:
| model width | CPU (AVX2) | resident graph |
|---|---|---|
| 256 | 83.5ms | 47.0ms |
| 512 | 209.3ms | 54.3ms |
The hook (tensai.UseAccelerator) sends each product to the device on its own, so it pays a round trip per product; residency pays one upload per step for the batch and one download for the loss. Both are opt-in, and they compose: with a tape on a device the hook is not consulted.
Visualizing the graph¶
loss.ToDot() returns Graphviz DOT (label leaves with .Named("w1")):
Recurrent networks¶
rnn.Cell and rnn.LSTMCell are built on the autograd engine, so unrolling a sequence is a plain Go loop and backpropagation through time comes for free:
cell := rnn.NewLSTMCell(inSize, hidden, rng)
wOut := autograd.Param(tensai.RandomMatrix(hidden, numClasses, rng))
bOut := autograd.Param(tensai.NewMatrix(1, numClasses))
trainer := autograd.NewTrainer(optim.NewAdam(0.01), append(cell.Params(), wOut, bOut)...)
for step := 0; step < epochs; step++ {
h, c := cell.InitState(batch)
for _, x := range steps { // one (batch x inSize) matrix per time step
h, c = cell.Step(autograd.Input(x), h, c)
}
logits := h.MatMul(wOut).AddRow(bOut)
trainer.Step(logits.CrossEntropy(labels)) // labels is a []int of class indices
}
_example/charrnn trains a character-level LSTM on an embedded public-domain text and generates samples from the reloaded parameters.
rnn.SelfAttention is the single-head, one-sequence form: attn.Forward(x) computes softmax(Q*K^T/sqrt(d))*V on a (seqLen, inSize) node with learned projections, and the raw rnn.Attention(q, k, v) is also exposed. For batches and heads, write the block above.
Saving parameters¶
Autograd parameters are saved and restored positionally:
autograd.SaveParamsFile("cell.json", cell.Params()...)
// build the same cell, then
autograd.LoadParamsFile("cell.json", cell.Params()...)
Parameters of any rank round-trip; two-dimensional ones keep the encoding earlier checkpoints used, so files written before the engine went n-dimensional still load.