Chapter 08 — Training: making the machine learn¶
Part III — Learning
Chapter 7 ended with a superpower: call backward() on a
loss and every parameter's gradient appears, computed by the graph hidden in
the live tensors. But a gradient is only a direction. Nothing has learned
anything yet. This chapter builds the machinery that turns gradients into
learning — the training step as a fixed ritual, loss functions with their
sharp edges, optimizers from a three-line SGD to the research frontier,
learning-rate schedules, gradient clipping and accumulation, checkpoints that
resume bit-exactly, and mixed precision that halves memory without
corrupting the math. At the end, the payoff: examples/spirals/main.zig, a
complete trainable model in under 500 lines, which you will run and watch
learn.
Everything here lives in one file pair you can actually read:
src/optim.zig (the optimizers, schedules, clipping, persistence — about
2,900 lines) and docs/TRAINING.md (the manual this chapter follows and
quotes). No trainer framework, no callback hierarchy, no Trainer god
object. A training loop in Fucina is a for loop you write yourself, and
every line of it does something you can point at.
8.1 Anatomy of a training step¶
Here is a complete training step, verbatim from docs/TRAINING.md §1 — this
snippet is the spine of the whole chapter, and each of its stages gets a
section below:
const fucina = @import("fucina");
const optim = fucina.optim;
var opt = optim.AdamW.init(allocator, .{ .lr = 1e-3, .weight_decay = 0.01 });
defer opt.deinit();
try opt.addParam(&w1); // params must outlive opt
try opt.addParam(&b1);
var sched = optim.LrSchedule.init(allocator);
defer sched.deinit();
try sched.attach(&opt.config.lr);
for (0..total_steps) |step_i| {
sched.apply(optim.warmupCosineFactor(step_i, total_steps, warmup, 0.1));
const scope = ctx.openExecScope(); // scope owns the step's intermediates
defer ctx.closeExecScope(scope);
const h = try x.dot(&ctx, &w1, .in); // no keeps, no defers
const a = try h.add(&ctx, &b1);
const z = try a.tanh(&ctx);
const loss = try z.crossEntropy(&ctx, .class, labels);
try loss.backward(&ctx);
_ = try opt.clipGradNorm(&ctx, 1.0); // after backward, before step
try opt.step(&ctx);
opt.zeroGrad();
}
Read it as six stages, in an order that is not negotiable:
- Forward — ordinary op calls, exactly the inference code from Chapter 5. The eager graph builds itself as a side effect (Chapter 7).
- Loss — one more op, reducing the batch to a scalar (§8.3).
backward()— walk the graph, add each parameter's gradient into its persisted accumulator (that word "add" matters; it is what makes gradient accumulation free in §8.8).clipGradNorm— optionally rescale the gradients if their global norm exceeds a bound (§8.7). It must see the complete gradients, hence after backward; it must run before they are consumed, hence before step.step()— the optimizer reads the gradients (non-destructively) and updates the parameters (§8.4–8.6).zeroGrad()— clear the accumulators, ending the step.
docs/REFERENCE.md §11 calls this the canonical step order:
"backward → clipGradNorm → step → zeroGrad", and the repo pins it
with tests. Everything else in this chapter — schedules, groups, mixed
precision, checkpointing — is machinery that makes one of these six stages
correct, fast, or resumable.
ML note — Why is
zeroGrada separate call instead of somethingstep()does implicitly? Because not calling it is a feature: gradients that survive across steps are exactly how you train on batches larger than memory (gradient accumulation, §8.8). PyTorch made the same choice for the same reason. The rule of thumb: every knob in this API corresponds to a real degree of freedom in training, not to framework ceremony.
8.2 Training lifetime is not inference lifetime¶
Chapter 7 introduced exec scopes; here is why training needs them. In
inference, the discipline you learned in
Chapter 3 is: deinit every intermediate the
moment its consumer has run. Released buffers go straight back to the pool;
memory stays flat. docs/TRAINING.md §2 states the training rule that
replaces it:
Training rule: every tensor on the path from the parameters to the loss must stay alive until
backward()returns.
The reason is an ownership asymmetry inside each op result. Its value
(the RawTensor) is refcounted — backward functions clone views of the
operand values they need, so releasing a value early never dangles data. Its
GradState — the autograd graph node — is not. Quoting TRAINING.md §2:
it is "single-owner, not refcounted: the tensor owns it,
tensor.deinit() destroys it unconditionally, and the consumers' backward
functions hold raw *GradState pointers to it". Deinit an intermediate
before backward and the backward pass walks a dangling node — undefined
behavior, not a catchable error. The design is deliberate: "no atomic
refcount traffic on the eager hot path, no ownership cycles, and inference
(where grad_state == null) pays nothing. The price is a rule"
(docs/TRAINING.md).
The rule would make training code miserable — every intermediate carefully
kept, then carefully released after backward — if the library asked you to
follow it by hand. It doesn't. Exec scopes invert the discipline: while
a scope is open, every op result is owned by the scope, its deinit is a
safe no-op, and closing the scope releases everything, newest first:
const scope = ctx.openExecScope();
defer ctx.closeExecScope(scope); // releases everything, newest first
const logits = try forwardLogits(&ctx, &model, &x); // zero ceremony inside
The consequence is the property this library keeps advertising: training
forward code looks like inference. You write the forward once, in the plain
deinit-ASAP style; run it bare and it infers; wrap a scope around it and the
graph survives to backward(). Two boundaries remain yours: tensors you
create explicitly (variable, constant, fromSlice) and gradients you
fetch stay caller-owned, and you must never use a scope-owned borrow after
the scope closes — that is the one hazard the scope_owned flag cannot
remove (docs/TRAINING.md §12).
Two roads not taken, both recorded in TRAINING.md §2: an explicit per-tensor
owner (Tape) "was prototyped early on and removed unused", and scopes
deliberately do not replace deinit-ASAP inside the inference engines —
"that option was evaluated and rejected; see the note at the end of
MEMORY-MODEL §5". When a design decision in this repo loses, the losing
option gets a tombstone you can read.
Zig note — A scope is arena semantics grafted onto an eager runtime: the owner (the
ExecContext, already threaded through every op call) adopts allocations and frees them in bulk at a boundary. If you know Zig'sstd.heap.ArenaAllocator, the shape is familiar; the twist is that adoption happens inside the op tails (finishOpinsrc/ag/tensor.zig), so user code opts in by opening a scope, not by passing a different allocator. And because the scope owns the prefix of results the moment each op returns, a failed op mid-forward leaks nothing — model code needs noerrdeferchains at all.
8.3 Losses: cross-entropy and its knobs¶
A loss is just one more differentiable op, and for classification the op is
cross-entropy. The plain form is what the training-step snippet used —
x.crossEntropy(ctx, .class, labels), mean cross-entropy over rows, with
the class axis named by tag (Chapter 4) rather than
by position. The extended form x.crossEntropyExt(ctx, .class, labels,
options) adds the PyTorch-parity knobs (exec.CrossEntropyOptions,
src/exec/loss.zig; the summary below follows docs/TRAINING.md §5):
ignore_index— a sentinel label; those positions contribute zero loss and zero gradient, and are excluded from the.meandenominator. This is what makes supervised fine-tuning work: prompt tokens get the sentinel, response tokens get real labels, and the model is only graded on what it should learn to say (Chapter 15). One documented divergence from PyTorch: when every position is ignored the loss is 0, not NaN.reduction = .mean | .sum | .none—.nonereturns per-position losses with the class tag removed..sumlooks like a convenience and is actually load-bearing: it is the correct arm for gradient accumulation with uneven token counts (§8.8).label_smoothingin [0, 1) — target = (1−ε)·onehot + (ε/K)·uniform, PyTorch semantics.
Determinism gets a sentence of its own, because it underwrites §8.9: the row
kernels are SIMD and parallel over rows, but "each row writes its loss to a
per-row buffer and the .mean/.sum reduction is ONE serial sum in row
order …, so the loss is bitwise stable across thread counts"
(docs/TRAINING.md §5). Speed and reproducibility, not speed instead of it.
As a dated, machine-specific snapshot (M1 Max, ReleaseFast,
zig build bench-ce; docs/TRAINING.md §5): CE forward at 1024×151936 went
354.5 ms → ~18 ms and backward 426 ms → ~22 ms (~19–20×) when the row
kernels went SIMD and parallel over rows — measured, not asserted, and
specific to that machine.
For language models there is one more trick worth knowing now, because it
shows what "the graph is just the values" makes possible. The final
projection of an LLM multiplies a [rows, hidden] activation by a
[vocab, hidden] weight to produce [rows, vocab] logits — at a 151936-word
vocabulary, the gradient of that logit tensor is hundreds of megabytes.
x.linearCrossEntropyExt(ctx, &w, labels, options) fuses projection and
loss into ONE differentiable op: the logits never enter the graph, and the
backward overwrites them in place with the logit gradient before the two
gradient GEMMs, "so the full [rows, classes] gradient never costs a second
buffer (−622 MB peak and ~4% faster than the composed dot + crossEntropyExt
backward at 1024x151936x1024 on M1)" (docs/TRAINING.md §5 — again a dated
M1 snapshot, not a promise).
8.4 Optimizers from scratch: SGD, then momentum¶
Strip everything away and an optimizer is one line. The following is course
code (not from the repo; compile-checked with zig test):
// Course code — NOT from the Fucina repo.
/// Plain gradient descent: walk downhill, step size lr.
fn sgdStep(params: []f32, grads: []const f32, lr: f32) void {
for (params, grads) |*p, g| p.* -= lr * g;
}
That is genuinely all of stochastic gradient descent: move each parameter a
small step against its gradient. Paired with Chapter 7's backward(), this
one-liner already trains the spirals MLP to convergence — deep learning's
dirty secret is how far the simplest possible update goes.
Zig note —
for (params, grads) |*p, g|iterates two slices in lockstep (they must have equal length — checked in safe builds), capturing the first element by pointer (*p, so we can mutate it) and the second by value. This multi-sliceforwith mixed captures is the idiomatic Zig replacement for an indexed loop, and the real optimizer kernels insrc/optim.ziguse it with four slices at once.
The first upgrade is momentum: keep a velocity buffer that remembers the recent direction of travel, and step along the smoothed direction instead of the raw gradient. Course code again:
// Course code — NOT from the Fucina repo.
fn momentumStep(params: []f32, grads: []const f32, velocity: []f32, lr: f32, momentum: f32) void {
for (params, grads, velocity) |*p, g, *v| {
v.* = momentum * v.* + g; // accumulate direction
p.* -= lr * v.*; // step along the smoothed direction
}
}
ML note — Why does this help? Gradients from small batches are noisy, and loss surfaces have narrow valleys: raw SGD zig-zags across the valley walls while making slow progress along the floor. The velocity buffer is an exponential moving average of gradients — perpendicular noise cancels, the consistent along-the-valley component accumulates. The physical analogy in the name is exact: a heavy ball rolling downhill doesn't reverse direction at every pebble.
Now meet the real thing. optim.SGD (src/optim.zig) is not "an SGD-like
optimizer"; it is a port of torch.optim.SGD, quirks included. Its config
struct is the whole hyperparameter story — one field per knob, defaults
visible in the source (src/optim.zig:2038):
pub const SgdConfig = struct {
lr: f32 = 1e-3,
/// 0 disables the momentum buffer entirely (no state RAM).
momentum: f32 = 0,
dampening: f32 = 0,
/// COUPLED L2 (g += wd*p), like PyTorch SGD — not AdamW-style decoupled.
weight_decay: f32 = 0,
/// Requires momentum > 0 and dampening == 0 (PyTorch constructor rule).
nesterov: bool = false,
/// Storage dtype of the momentum buffer; step math stays f32. ...
state_dtype: StateDType = .f32,
};
Two quirks are worth pausing on, because each teaches something:
The first-step clone. PyTorch initializes the momentum buffer not to
zeros but to a clone of the first (decayed) gradient, and so does this port
(the comment at src/optim.zig:2059-2067 spells it out). Getting this wrong
would still converge — it would just diverge numerically from the
reference, and this repo's bar for an optimizer is a golden parity test
against the actual reference implementation ("PyTorch 2.12 / Keller
Jordan's muon.py / apollo_torch", docs/TRAINING.md §3; the goldens live in
src/optim_tests.zig). An optimizer that is "basically Adam" is a bug you
cannot see until your training run mysteriously underperforms a paper.
The constructor panic. From src/optim.zig:2069-2076:
pub fn init(allocator: Allocator, config: SgdConfig) SGD {
// PyTorch constructor rule, enforced in every build mode (a debug
// assert would vanish exactly where training runs: ReleaseFast).
if (config.nesterov and (config.momentum == 0 or config.dampening != 0)) {
@panic("SGD: nesterov requires momentum > 0 and dampening == 0");
}
return .{ .allocator = allocator, .config = config };
}
Zig note — Zig gives you three tools for "this must not happen", and this snippet is a masterclass in choosing between them. An error union (
!SGD) is for conditions the caller can meaningfully handle — but a nonsensical hyperparameter combination is a bug in the program, not a runtime circumstance. Astd.debug.assertcompiles away in ReleaseFast — which is exactly the build mode training runs in, so the check would vanish precisely where it matters.@panicsurvives every build mode and names the invariant in the crash message. Config validation at a constructor is the@paniccase.
8.5 AdamW: the workhorse, ported exactly¶
SGD treats every parameter alike. Adam gives each parameter its own
adaptive step size by tracking two exponential moving averages: m, the
mean of the gradient (momentum), and v, the mean of its square (a
per-element estimate of gradient scale). The update divides by sqrt(v),
so parameters with consistently large gradients take small steps and
vice versa. AdamW is Adam with one change that took the field years to get
right: decoupled weight decay. The difference is literally one line —
the module doc of src/optim.zig:5-8 states both variants:
Adam— PyTorchtorch.optim.Adamsingle-tensor path (coupled L2 weight decay:g += weight_decay * pbefore moment updates).AdamW— PyTorchtorch.optim.AdamWsingle-tensor path (decoupled decay applied to the parameter BEFORE the Adam step;denom = sqrt(v)/sqrt(1-b2^t) + eps).ML note — Coupled decay adds
wd·pto the gradient, which then flows through Adam's adaptive rescaling — so the effective regularization strength varies per parameter with its gradient history, which is almost never what you meant. Decoupled decay shrinks the parameter directly, uniformly, outside the adaptive machinery. That one-line difference is the "W" in AdamW and the subject of an entire paper (Loshchilov & Hutter, 2017).
Here is the whole per-element AdamW update as it actually ships, verbatim
from src/optim.zig:756-767 — twelve lines:
fn runScalar(c: @This(), start: usize, end: usize) void {
for (c.p[start..end], c.g[start..end], c.m[start..end], c.v[start..end]) |*pi, gi, *mi, *vi| {
const decayed = pi.* * c.s.keep;
const m0 = stateLoad(md, mi);
const v0 = stateLoad(vd, vi);
const m1 = m0 + c.s.one_minus_b1 * (gi - m0);
const v1 = c.s.beta2 * v0 + c.s.one_minus_b2 * gi * gi;
stateStore(md, mi, m1);
stateStore(vd, vi, v1);
pi.* = decayed - c.s.step_size * (m1 / (@sqrt(v1) / c.s.bc2s + c.s.eps));
}
}
Walk it: decayed is the decoupled decay (keep = 1 − lr·wd); m1/v1
are the two EMAs (the stateLoad/stateStore indirection is mixed
precision for the optimizer state, §8.10); the last line is the
bias-corrected step. The scalars arrive pre-computed, and where they come
from matters (src/optim.zig:776-799, trimmed):
fn adamwUpdate(ctx: *ExecContext, config: AdamWConfig, p: []f32, g: []const f32, m: StateBuf, v: StateBuf, step_count: u64) void {
const t: f64 = @floatFromInt(step_count);
const bc1 = 1 - std.math.pow(f64, config.beta1, t);
const bc2_sqrt = @sqrt(1 - std.math.pow(f64, config.beta2, t));
const s = AdamWScalars{
.keep = if (config.weight_decay != 0) @floatCast(1.0 - @as(f64, config.lr) * @as(f64, config.weight_decay)) else 1,
// ... one_minus_b1, one_minus_b2, step_size = lr / bc1, bc2s, eps ...
};
switch (m) {
.f32 => |ms| switch (v) {
.f32 => |vs| adamwRun(.f32, .f32, ctx, s, p, g, ms, vs),
.bf16 => |vs| adamwRun(.f32, .bf16, ctx, s, p, g, ms, vs),
},
.bf16 => |ms| switch (v) { /* mirror arms */ },
}
}
Two things to notice. First, the bias corrections bc1/bc2_sqrt are
computed once per step, in f64, then rounded to f32 — matching, per
docs/REFERENCE.md §11, "torch's Python-float scalars to within a few f32
ulps". Early in training, 1 − β₂ᵗ is a tiny number computed by subtracting
two nearly-equal ones; doing that in f32 per element would both waste time
and lose precision. Second, the nested switch on the two state-dtype
unions monomorphizes the kernel: each of the four (m, v) dtype combinations
gets its own compiled loop, and the dispatch happens once per tensor, not
once per element.
ML note — Bias correction, demystified: an EMA initialized at zero is biased toward zero for its first ~1/(1−β) steps (
vstarts at 0 and only slowly fills up). Dividing by1 − βᵗrescales the estimate to be unbiased from step one. Without it, early steps divide by an underestimatedsqrt(v)and blow up — which is why the correction is in the algorithm and not a tuning nicety.
If you want to feel the algorithm before trusting the port, here is a from-scratch version (course code, compile-checked; it converges on a toy quadratic and reproduces the decay-pulls-below-the-minimum effect):
// Course code — NOT from the Fucina repo.
const CourseAdamW = struct {
lr: f32 = 1e-3,
beta1: f32 = 0.9,
beta2: f32 = 0.999,
eps: f32 = 1e-8,
weight_decay: f32 = 0.01,
t: u64 = 0, // step count, for bias correction
fn step(self: *CourseAdamW, p: []f32, g: []const f32, m: []f32, v: []f32) void {
self.t += 1;
const t: f64 = @floatFromInt(self.t);
const bc1: f32 = @floatCast(1.0 - std.math.pow(f64, self.beta1, t));
const bc2_sqrt: f32 = @floatCast(@sqrt(1.0 - std.math.pow(f64, self.beta2, t)));
const keep = 1.0 - self.lr * self.weight_decay;
for (p, g, m, v) |*pi, gi, *mi, *vi| {
pi.* *= keep; // decoupled decay: shrink the param, not the gradient
mi.* += (1.0 - self.beta1) * (gi - mi.*); // EMA of the gradient
vi.* = self.beta2 * vi.* + (1.0 - self.beta2) * gi * gi; // EMA of its square
pi.* -= (self.lr / bc1) * (mi.* / (@sqrt(vi.*) / bc2_sqrt + self.eps));
}
}
};
The real AdamWConfig (src/optim.zig:526) reads like the course struct
plus the two state-dtype knobs: lr: f32 = 1e-3, beta1: f32 = 0.9, beta2:
f32 = 0.999, eps: f32 = 1e-8, weight_decay: f32 = 0.01, state_dtype:
StateDType = .f32, second_moment_dtype: StateDType = .f32.
One more piece of the shared surface deserves a look: how a parameter gets
into an optimizer. addParam(&w) accepts any variable tensor via
anytype and builds a Param handle (src/optim.zig:403): a refcounted
view of the storage, a raw *GradState pointer, and a rows/cols flattening
for the matrix-aware optimizers. Two ownership rules follow directly and
are documented in the module header (src/optim.zig:22-28): parameters
must outlive the optimizer, and each variable belongs to exactly one
optimizer — duplicates within one instance are rejected
(error.DuplicateParam), while the same tensor in two different
instances is per-instance undetectable and silently double-steps (§8.7
shows how OptimizerSet closes that gap).
Finally, determinism — the property §8.9 will cash in. The update loops
parallelize across the worker pool, and the comment at
src/optim.zig:85-91 explains why that costs nothing in reproducibility:
/// Elementwise update loops chunk across the worker pool above this length.
/// Every parallel loop here is an element-independent map (no reductions), so
/// the results are bitwise identical to the serial path for any thread count
/// — goldens and bit-exact resume are unaffected.
// … (the comment goes on to cover reductions; paraphrased below) …
const parallel_map_min_len: usize = 1 << 17;
An element-independent map has no cross-thread arithmetic, so the chunking
is invisible to the result. Reductions (the norms used by clipping) go
through sumSquares — a fixed chunk grid with a pinned combine order —
so they are equally thread-count-invariant.
8.6 The frontier: Muon and APOLLO¶
AdamW treats a weight matrix as a bag of independent scalars. The two
frontier optimizers in the tree stop doing that, in opposite directions:
Muon exploits matrix structure to take better steps; APOLLO exploits it
to take cheaper ones. Both are faithful ports pinned by goldens against
their reference implementations (Keller Jordan's muon.py and
apollo_torch; docs/TRAINING.md §3, src/optim_tests.zig).
Muon: orthogonalized momentum¶
Muon's idea: take the momentum-averaged gradient of a weight matrix,
replace it with the nearest orthogonal matrix (approximately — via
Newton-Schulz iteration), and step along that. The update has balanced
singular values, so no direction in parameter space dominates. The
orthogonalization kernel is 30 lines and worth reading whole — verbatim
from src/optim.zig:1386-1416:
pub fn newtonSchulz5(ctx: *ExecContext, u: *const RawTensor, steps: u32) !RawTensor {
const rows = u.shape.at(0);
const cols = u.shape.at(1);
const transposed = rows > cols;
var x = if (transposed) try transpose2D(ctx, u) else try ctx.materialize(u);
errdefer x.deinit();
const sumsq = try sumSquares(ctx, x.dataConst());
const inv_norm: f32 = @floatCast(1.0 / (@sqrt(sumsq) + 1e-7));
for (x.data()) |*value| value.* *= inv_norm;
for (0..steps) |_| {
var gram = try ctx.matmulTransB(&x, &x);
defer gram.deinit();
var quad = try ctx.matmul2D(&gram, &gram);
defer quad.deinit();
for (quad.data(), gram.dataConst()) |*qi, gi| qi.* = ns_coeff_b * gi + ns_coeff_c * qi.*;
var bx = try ctx.matmul2D(&quad, &x);
errdefer bx.deinit();
for (bx.data(), x.dataConst()) |*oi, xi| oi.* = ns_coeff_a * xi + oi.*;
x.deinit();
x = bx;
}
if (transposed) {
const out = try transpose2D(ctx, &x);
x.deinit();
return out;
}
return x;
}
Normalize the matrix, then iterate a fixed odd polynomial in X·Xᵀ five
times: each pass pushes the singular values toward 1 while leaving the
singular vectors alone. The transpose trick keeps the Gram matrix at the
smaller of the two dimensions. And note the doc comment right above it
(src/optim.zig:1384-1385): "The result approximates U*V^T with singular
values in roughly (0.5, 1.5) — by design, not a bug." Approximate
orthogonality is all the optimizer needs, and exact SVD would cost far
more.
MuonConfig (src/optim.zig:1074; defaults per docs/REFERENCE.md §11):
lr: f32 = 0.02, momentum: f32 = 0.95, nesterov: bool = true, ns_steps:
u32 = 5, weight_decay: f32 = 0, scale: MuonScale = .spectral, state_dtype:
StateDType = .f32, fallback: AdamWConfig = .{ .lr = 3e-4, .beta1 = 0.9,
.beta2 = 0.95, .eps = 1e-10, .weight_decay = 0 }. Two details:
scaleselects the update-scale convention: Keller's spectralsqrt(max(1, rows/cols))or Moonlight's RMS-matching0.2*sqrt(max(rows, cols))(MuonScale = enum { spectral, match_rms_adamw }).- That
fallbackfield is a whole embedded AdamW, because Muon's math only makes sense for weight matrices. Biases and norms (0D/1D) auto-route to the fallback; embeddings and classifier heads are 2D but must not be orthogonalized, so you route them withaddFallbackParam— forgetting is one of the checklist pitfalls: "silently wrong algorithm for those weights" (docs/TRAINING.md §12).
Muon's cost is real and honestly documented: the Newton-Schulz GEMMs are "~395 GFLOP per block in f32 at 5 iterations" (docs/TRAINING.md §11), which is why in the bench table below it sits two orders of magnitude above AdamW. That is the algorithm, not the implementation.
APOLLO: moments in a compressed space¶
APOLLO (arXiv 2412.05270) attacks the other end: AdamW's state costs
8 bytes per parameter, which at LLM scale is more than the model. APOLLO
projects each gradient matrix through a random low-rank projection,
keeps the Adam moments in that compressed space, and derives per-channel
(or per-tensor) scaling factors for a scaled-SGD update. ApolloConfig
(src/optim.zig:1432; defaults per docs/REFERENCE.md §11): lr: f32 =
0.01, beta1: f32 = 0.9, beta2: f32 = 0.999, eps: f32 = 1e-6, weight_decay:
f32 = 0, rank: usize = 128, update_proj_gap: u64 = 200, scale: f32 = 1.0,
scale_type: ApolloScaleType = .channel, correct_bias: bool = true, …,
seed: u64 = 0, plus ApolloConfig.mini() for the rank-1 variant.
The detail with the biggest lesson in it: the projection matrices are
regenerated, not stored. From TRAINING.md §8: "P is a deterministic
function of (seed, step / update_proj_gap) computed by the repo-owned
splitmix64 + Box-Muller generator … deliberately not std.Random, so the
mapping survives Zig toolchain upgrades." A random matrix that can be
recomputed from a counter does not need to live in the checkpoint — the
same idea will return as dropout masks (Chapter 7), data-loader shuffles,
and the entire noise model of evolution strategies
(Chapter 9). The (seed → values)
mapping is a checkpoint contract, which is why it must not depend on a
standard-library RNG whose sequence could change between Zig versions.
Also deliberately preserved: APOLLO's AdamW fallback reproduces the
reference's legacy-HuggingFace AdamW ("eps OUTSIDE the bias correction,
decay AFTER the step) — deliberately different from AdamW above",
src/optim.zig:18-20). Faithful porting means porting the reference's
inconsistencies too, because the goldens compare against the reference,
not against your aesthetic preferences.
What they cost¶
zig build bench-optim -Doptimize=ReleaseFast measures step kernels at
Qwen3-0.6B-class shapes. The repo's snapshot (M1 Max, 2026-06-10, native
backend + Accelerate; docs/TRAINING.md §11 — a dated, machine-specific
measurement, not a general claim) for one 15.7M-param transformer block:
sgd 1.8–3.0 ms/step, adamw 5.2–6.0 ms, apollo-mini 23–24 ms, apollo-r256
44–46 ms, muon 446–457 ms. TRAINING.md's reading: the elementwise
optimizers run near memory bandwidth ("A full Qwen3-0.6B AdamW step costs
~200 ms of optimizer time — small next to the forward/backward"), Muon
pays GEMM FLOPs, APOLLO sits between. When someone tells you optimizer
choice is free, this table is the counterexample.
8.7 Param groups, clipping, and schedules¶
Param groups are just optimizer instances¶
Real training recipes never use one hyperparameter set for everything —
the standard move is weight decay on matrices, none on biases and norms.
PyTorch models this with "param groups" inside one optimizer;
Fucina's observation (docs/TRAINING.md §4) is that a param group is
{hyperparams, params, state} — which is exactly what an optimizer instance
already is. So there is no group abstraction to learn: make two instances,
and let OptimizerSet make them feel like one (from TRAINING.md §4):
var decay = optim.AdamW.init(allocator, .{ .lr = 1e-3, .weight_decay = 0.1 });
var no_decay = optim.AdamW.init(allocator, .{ .lr = 1e-3, .weight_decay = 0 });
// ... addParam matrices to `decay`, biases/norms to `no_decay` ...
var set = optim.OptimizerSet.init(allocator);
try set.add(&decay);
try set.add(&no_decay);
// set.step / set.zeroGrad / set.clipGradNorm / set.saveState / set.loadState
Mixing optimizer types in one set works — Muon for the trunk, AdamW for
an adapter — because the set is type-erased through AnyOptimizer.
Zig note —
AnyOptimizer(src/optim.zig:2426) is dynamic dispatch without inheritance: a hand-rolled vtable. It stores a type-punnedptr: *anyopaqueplus avtable: *const VTableof function pointers (step,zeroGrad,gradSquaredNorm,scaleGradients,saveState,loadState);anyOptimizer(opt: anytype)builds the vtable at comptime for whatever concrete optimizer you hand it. This is the same patternstd.mem.Allocatoruses, and it is the idiomatic Zig answer to "I need an interface": explicit, inspectable, and only where dynamic dispatch is actually required — everywhere else in this chapter,anytype+ comptime monomorphization does the job with zero indirection.
OptimizerSet.add also fixes the double-step hazard from §8.5: it calls
the member's collectGradStates to check every parameter against all
previously-added members, so registering one variable into two groups
returns error.DuplicateParam instead of silently stepping it twice
(docs/REFERENCE.md §11.3).
Clipping¶
clipGradNorm(ctx, max_norm) is torch.nn.utils.clip_grad_norm_
semantics: compute the global L2 norm over all registered params
(deterministic fixed-chunk f64 reduction); if it exceeds max_norm, scale
every gradient by max_norm / (total + 1e-6); return the pre-clip norm —
which is worth logging, because a rising gradient norm is the classic
early warning of a diverging run. On an OptimizerSet the norm is global
across all groups, matching clip_grad_norm_(model.parameters()). And
note the exception that proves the recipes are read before being ported:
"the APOLLO recipes train with clipping disabled — its norm-growth limiter
replaces it" (docs/TRAINING.md §4).
Schedules¶
A learning-rate schedule in this library is not an object hierarchy; it is
a pure function of the step plus a tiny hook that rescales config.lr
from a captured base:
try sched.attach(&opt.config.lr); // captures the current lr as the base
sched.apply(optim.warmupCosineFactor(step_i, total_steps, warmup, 0.1));
The one built-in factor function, whole, from src/optim.zig:2406-2415:
pub fn warmupCosineFactor(step: u64, total_steps: u64, warmup_steps: u64, min_factor: f64) f64 {
if (warmup_steps > 0 and step < warmup_steps) {
return @as(f64, @floatFromInt(step + 1)) / @as(f64, @floatFromInt(warmup_steps));
}
if (total_steps <= warmup_steps) return min_factor;
const num = @as(f64, @floatFromInt(step - warmup_steps));
const den = @as(f64, @floatFromInt(total_steps - warmup_steps));
const progress = @min(num / den, 1.0);
return min_factor + (1.0 - min_factor) * 0.5 * (1.0 + @cos(std.math.pi * progress));
}
Linear ramp from 1/warmup_steps to 1, then a half-cosine down to
min_factor. Ten lines cover the entire "LR scheduler" concept, and the
purity is not a style point — it is what makes resume trivial: "Because
the factor is a pure function of the step, resuming from a checkpoint just
re-applies it — this is why lr is deliberately NOT a validated field of
optimizer checkpoints" (docs/REFERENCE.md §11.4). The checkpoint
validates structural config (a changed APOLLO rank is
error.CheckpointConfigMismatch) but not lr, because schedules
legitimately change lr every step.
ML note — Why warmup at all? Early in training, Adam's
vestimate is built from a handful of noisy gradients, and the parameters are at a random init where gradients are large and unrepresentative; full-size steps there can lock the run into a bad region. Ramping the lr over the first few hundred steps lets the moment estimates settle first. The cosine tail is gentler than step decays and has one fewer hyperparameter — which is most of why it became the default in LLM recipes.
8.8 Gradient accumulation: a fresh graph per backward¶
Suppose the batch you want does not fit in memory. The fix costs nothing,
because the substrate already does it (docs/TRAINING.md §4): backward()
adds into each parameter's persisted gradient, leaf gradients live
outside exec scopes, step() reads them non-destructively, and
zeroGrad() is the only clear. So a batch of N micro-batches is: N
forward+backward passes, then ONE clip, ONE step, ONE zeroGrad. Skipping
zeroGrad isn't a bug — it is gradient accumulation.
One rule has no workaround: each backward needs a fresh graph. A second
backward() over the same retained graph fails with
error.BackwardAlreadyRun (Chapter 7); accumulation is one backward per
freshly-built forward, which the recommended shape — one exec scope per
micro-batch — gives you naturally. Here is the real accumulation window
from examples/finetune/main.zig:340-374 (trimmed; comments original):
// Exact token-weighted normalization: the samples differ in
// supervised-token counts, so mean-of-means (`.mean` + 1/N)
// would mis-weight them. `.sum` CE scaled by 1/total_valid makes
// the accumulated gradient — and the reported sum of scaled
// losses — the true mean over the window's supervised tokens.
...
const loss_scale = 1.0 / @as(f32, @floatFromInt(total_valid));
for (window) |idx| {
// One exec scope per micro-batch: each graph is freed right
// after its backward; the leaf grads accumulate outside the
// scopes (backward ADDS until zeroGrad).
const sample = &samples[idx];
const scope = ctx.openExecScope();
defer ctx.closeExecScope(scope);
const loss = try trainer.lossExt(&ctx, sample.inputs, sample.labels, .{
.reduction = .sum,
.loss_scale = loss_scale,
});
try loss.backward(&ctx);
loss_value += try loss.item();
step_tokens += sample.inputs.len;
}
// ONCE per window: clip reads the full accumulated gradients
// (a mid-window clip would rescale partial sums).
_ = try set.clipGradNorm(&ctx, 1.0);
try set.step(&ctx);
set.zeroGrad();
Four disciplines hide in those lines, each documented in TRAINING.md §4:
- Mean-of-means is not the batch mean.
.meanper micro-batch + averaging works only when every micro-batch has the same number of supervised tokens. Withignore_indexmasking, counts differ — so use.sumCE scaled by1/total_valid. The sum of the scaled losses then is the true mean over the window. - Normalization is loss-side and canonical. Scaling the gradients
afterward (
scaleGradients(ctx, 1.0/N)) is deterministic too, but a different floating-point order: "NOT bitwise-equal to loss-side scaling. Pick one; tests here pin loss-side." - Clip ONCE, after the window — a mid-window clip rescales a partial
sum, "silently wrong", and there is a regression test for it
(
src/optim_tests.zig). - Checkpoint only at window boundaries. Accumulated gradients live in
the GradStates and are never serialized; a save mid-window silently
drops the partial sum on resume. Watch for the two counters: the
trainers'
stepcounts micro-batches, the optimizer slots count macro steps; a resumable state hasstep % accum_steps == 0. LR schedules are keyed by the macro step.
8.9 Checkpoints and bit-exact resume¶
Training runs crash, machines reboot, experiments fork. Checkpointing in
this repo is built around one uncompromising definition of "it works": a
resumed run must produce bit-for-bit the same parameters as the run
that never stopped. Not "close", not "statistically equivalent" —
identical, testable with ==. This section builds up the pieces; §8.11
shows the test that enforces it.
Parameter values, two formats (docs/TRAINING.md §8):
optim.saveTensors(writer, .{ &w1, &b1, ... })/loadTensors— legacy positional FZT1, f32-only: the loader must list the same tensors in the same order. Trivially simple, brittle by construction.optim.saveStateDict/loadStateDict— named state dicts: per entry a unique name, dtype (f32/f16/bf16), shape, and bytes; load matches by name so entry order is free; strict mode (default) demands a one-to-one match. The file format underneath is plain safetensors, so a Fucina state dict opens in any safetensors tool (Chapter 11).
Naming without boilerplate: ParamRegistry (src/param_registry.zig).
Hand-listing every tensor twice (once for the optimizer, once for
persistence) is the kind of duplication that rots. registry.collect(&model)
reflects over your model struct at comptime, finds the tensor fields, and
names them by field path — the flat spirals Model yields "w1", "b1",
…; a nested model gets dotted paths automatically. One registry then feeds
both worlds: saveStateDict/loadStateDict for persistence and
addParamsTo(opt) for optimizer registration.
Optimizer state. opt.saveState(writer) / loadState(reader) persist
moments, step counts, and structural config fields in native frames
(magics like FZAD/FZM3/FZP3; FZO3 wraps an OptimizerSet). Slots are matched
by name — explicit via addParamNamed, otherwise the auto-name param<i>
from the slot's index — so unnamed params must keep their relative
registration order to reproduce their auto-names (docs/TRAINING.md §8).
Loads validate:
a changed APOLLO rank is error.CheckpointConfigMismatch; a bf16-state
checkpoint loaded into an f32-configured optimizer (or vice versa) is
error.CheckpointDtypeMismatch, because "There is deliberately NO implicit
f32<->bf16 conversion on load: it would silently break the bit-exact-resume
contract" (docs/TRAINING.md §8). A failed load leaves the target partially
restored — treat load errors as fatal for that instance.
The directory protocol (src/training_checkpoint.zig):
checkpoint/
model.safetensors # or adapters.safetensors
optimizer.fucina # native optimizer frames
trainer_state.json # written last; commit sentinel
The JSON sentinel is written last, so "a directory with a parseable
trainer_state.json is a complete, committed checkpoint; a crash mid-save
leaves a sentinel-less directory" (docs/REFERENCE.md §11) that a loader
can recognize as uncommitted. Individual files are written atomically
(writeFileAtomic). This is a database commit protocol in three files —
no fsync heroics, just careful ordering.
Why bit-exact resume is even possible is the sum of choices you have already met: element-independent parallel update maps (§8.5), pinned-order norm reductions, serial row-order loss sums (§8.3), regenerated-not-stored randomness (§8.6), and pure-function LR schedules (§8.7). The one documented caveat (docs/TRAINING.md §8): a tensor receiving 3+ gradient contributions whose consumers' heavy backward branches run async is accumulated in completion order; two contributions are always order-safe.
ML note — Why obsess over bitwise equality when training is stochastic anyway? Because determinism is the difference between debugging and guessing. If resume is bit-exact, then any divergence between two runs is a real change you made — not scheduler noise, not a lost partial gradient window. It also makes "did my refactor change the math?" a yes/no question answerable in one run. Determinism here is not a purity aesthetic; it is the repo's primary debugging instrument.
8.10 Mixed precision: 16-bit leaves, f32 gradients, f32 masters¶
Half-precision parameters halve your memory, and on some ISAs speed up the GEMMs. They also introduce a genuinely subtle failure mode, and this library's design walls it off with one contract (docs/TRAINING.md §10):
The contract: gradients are always f32. Parameters and activations may be f16/bf16; every gradient in the engine is an f32 tensor (
GradStateholds f32 only). This is torch AMP's numeric contract, and it makes loss scaling unnecessary: loss scaling exists to rescue f16 GRADIENT underflow, and gradients here never narrow.
If you have used PyTorch AMP you have met GradScaler — the machinery
that multiplies the loss by 2¹⁶ so f16 gradients do not flush to zero,
then divides it back out and skips steps when it overflows. All of that
exists to work around gradients being stored in f16. Keep gradients f32
and the entire apparatus evaporates.
The second half of the contract handles the update: an lr-sized nudge is
often smaller than a bf16 parameter can represent, so stepping 16-bit
storage directly would round most updates away. Fucina's answer is the
standard one, implemented in Param (src/optim.zig:463-499): 16-bit
params step through an f32 master copy the optimizer owns —
/// The f32 buffer the update kernels step: the param storage itself for
/// f32 params, the master for 16-bit params.
fn data(self: *Param) []f32 {
return switch (self.value) {
.f32 => |*t| t.data(),
else => self.master,
};
}
// … ensureMaster / refreshMasterFromValue elided …
/// Narrow the stepped master back into the 16-bit param storage (no-op
/// for f32 params).
fn publish(self: *Param) void {
switch (self.value) {
.f32 => {},
.f16 => |*t| exec_convert.castF32ToF16(t.data(), self.master),
.bf16 => |*t| exec_convert.castF32ToBf16(t.data(), self.master),
}
}
— so sub-resolution updates accumulate in the master instead of rounding
away, and each step narrows the master back into the served storage. The
masters persist in v5 optimizer frames, keeping resume bit-exact
(docs/TRAINING.md §10). The recommended pattern is 16-bit leaves: create
f16/bf16 variables, register them with any optimizer, and enter the graph
through the differentiable to(.f32) widen or through dot/einsum with
the 16-bit tensor as RHS. examples/nanochat trains its transformer
matrices this way (bf16 leaves, f32 embeddings; docs/TRAINING.md §10).
A separate, orthogonal knob — easy to confuse with the above — is 16-bit
optimizer state: storing the m/momentum buffers in bf16
(state_dtype = .bf16) while step math stays f32. Since the step is
memory-bound, narrower state is faster, not slower — the repo's dated
M1 Max snapshot (2026-07-03, docs/TRAINING.md §11) has the block AdamW
step at 3.605 → 2.028 ms with bf16 m. But AdamW's second moment gets its
own opt-in (second_moment_dtype) for a reason worth reading twice,
because it is a rare quantitative precision argument a newcomer can fully
follow (docs/TRAINING.md §3): with β₂ = 0.999 the second moment changes
~0.1% per step, which "sits BELOW bf16's ~2^-8 ≈ 0.39% resolution, so the
EMA can round to a no-op and stall". Scale matters too: full-param AdamW
on Qwen3-0.6B drops from 4.76 GB of state to 3.57 GB (m bf16) or 2.38 GB
(m+v bf16), while the default LoRA fine-tune "saves only ~4.6 MB of its
9.2 MB state — the knob matters at full-parameter/embedding scale, not for
small adapters" (docs/TRAINING.md §3).
Zig note — Where does a dtype policy like "state may be bf16 but math stays f32" live in the code? In the type system, monomorphized: the
StateBuf = union(StateDType)tagged union carries the buffer, the nestedswitchin §8.5 dispatches once per tensor, andstateLoad/stateStorewiden/narrow at the boundary. And because "LLVM does NOT auto-vectorize these fused sqrt/div update loops on aarch64" (docs/TRAINING.md §11), the bf16 arms are hand-vectorized with@Vector(state_vec_len,src/optim.zig) — the portable-SIMD story from Chapter 6 paying off in the optimizer. Also note the comptime environment check guarding all of this persistence:src/optim.zig:387-391refuses to compile optimizer checkpoints on a big-endian target at all.
8.11 The payoff: spirals, end to end¶
Time to collect. examples/spirals/main.zig (494 lines) is the whole chapter
in one runnable file: a typed model struct, a forward pass, cross-entropy,
five optimizers, param groups with a schedule and clipping, checkpointing
halfway, and a resume that must be — literally, or the program errors —
bit-exact. The task is the classic two-spirals problem (Lang & Witbrock):
two interleaved spiral arms in the plane, one class each. It is tiny,
nonlinearly inseparable, and impossible to solve by accident — the course's
fruit fly, as promised in Chapter 2.
The model¶
const Model = struct {
w1: Tensor(.{ .h1, .in }),
b1: Tensor(.{.h1}),
w2: Tensor(.{ .h2, .h1 }),
b2: Tensor(.{.h2}),
w3: Tensor(.{ .class, .h2 }),
b3: Tensor(.{.class}),
(from examples/spirals/main.zig:29-35) — a model is a plain struct of typed
tensors. No nn.Module, no parameter registration ceremony; the tags from
Chapter 4 document the architecture in the types:
w1 maps .in (2 coordinates) to .h1 (64 hidden units), w2 maps
.h1 to .h2, w3 maps .h2 to .class (2 classes). Misconnect the
layers and it will not compile.
Model.initRandom (examples/spirals/main.zig:38-67) fills the weights with
scaled uniform noise, and builds the six tensors with an errdefer after
each — if the fourth allocation fails, the first three are freed, a
pattern you will now recognize everywhere in the repo. initConstZero
(:70-90) builds the same struct out of gradient-free constants — the
inference target for a finished checkpoint: same forward code, no autograd
overhead.
Registration, forward, step¶
fn registerParams(opt: anytype, model: *Model) !void {
try opt.addParam(&model.w1);
try opt.addParam(&model.w2);
if (comptime @hasDecl(@TypeOf(opt.*), "addFallbackParam")) {
try opt.addFallbackParam(&model.w3);
} else {
try opt.addParam(&model.w3);
}
try opt.addParam(&model.b1);
try opt.addParam(&model.b2);
try opt.addParam(&model.b3);
}
(from examples/spirals/main.zig:116-127) — one function registers the model
with any optimizer, using comptime duck typing: if the optimizer type
declares addFallbackParam (Muon, APOLLO), the classifier head w3 routes
to the fallback (§8.6 — heads must not be orthogonalized); otherwise it is
an ordinary param. Biases auto-route by rank. @hasDecl is resolved at
compile time, so the branch not taken never exists in the binary.
The forward and the step are the payoff for §8.2 — read the comment first,
it is the whole lifetime story (from examples/spirals/main.zig:129-153):
/// Forward pass inside an exec scope (ExecContext.openExecScope): every op result
/// is owned by the scope, so the eager autograd graph — whose nodes the
/// backward pass walks through raw pointers — stays alive until the scope
/// closes. No keeps, no defers: training forward code looks like inference.
fn forwardLogits(ctx: *ExecContext, model: *const Model, x: *const Tensor(.{ .batch, .in })) !Tensor(.{ .batch, .class }) {
const z1 = try x.dot(ctx, &model.w1, .in);
const s1 = try z1.add(ctx, &model.b1);
const a1 = try s1.tanh(ctx);
const z2 = try a1.dot(ctx, &model.w2, .h1);
const s2 = try z2.add(ctx, &model.b2);
const a2 = try s2.tanh(ctx);
const z3 = try a2.dot(ctx, &model.w3, .h2);
return try z3.add(ctx, &model.b3);
}
fn trainStep(ctx: *ExecContext, model: *const Model, x: *const Tensor(.{ .batch, .in }), labels: []const usize, opt: anytype) !f32 {
const scope = ctx.openExecScope();
defer ctx.closeExecScope(scope); // releases the whole step's graph
const logits = try forwardLogits(ctx, model, x);
const loss = try logits.crossEntropy(ctx, .class, labels);
try loss.backward(ctx);
try opt.step(ctx);
opt.zeroGrad();
return loss.item();
}
Eight op calls of pure inference-style code; one scope wrapping them; the
six-stage ritual from §8.1 (this tiny full-batch demo skips clipping — the
groups demo below adds it). accuracy (:155-167) reuses the same
forwardLogits under its own scope, takes argmax over .class, and
counts matches — the same-forward-infers-and-trains property, live.
Data and checkpointing¶
makeSpirals (examples/spirals/main.zig:169-187) generates the dataset: 200
points per arm, radius growing with angle over ~1.75 turns, class 1 being
class 0 rotated by π, plus a whisper of Gaussian noise. Four hundred
points, two floats each — the whole dataset is a stack array.
saveCheckpoint / loadCheckpoint (:189-229) implement §8.9's directory
protocol with the real API: beginSave, writeFileAtomic for
model.safetensors (via ParamRegistry.collect — the reflective walk that
names tensors by field name, :231-247) and optimizer.fucina (via
opt.saveState), then saveTrainerState writing the JSON sentinel last.
Note the comptime @TypeOf(opt) != @TypeOf(null) guard: the same function
serves phase 3, which loads weights with no optimizer at all.
The three-phase gauntlet¶
The demo driver (examples/spirals/main.zig:261-326) runs each optimizer
through three phases:
- Train 2000 full-batch steps, checkpointing model + optimizer at step 1000.
- Resume: build a fresh model — deliberately initialized from a different seed, so nothing can leak — and a fresh optimizer, load the checkpoint, retrain steps 1000–2000, and compare every final parameter against phase 1:
var max_diff: f32 = 0;
for (reference, replayed) |a, b| max_diff = @max(max_diff, @abs(a - b));
...
if (max_diff != 0) return error.ResumeNotBitExact;
(from examples/spirals/main.zig:310-315) — not a tolerance check. != 0.
One flipped bit anywhere in a thousand replayed steps — a stored moment
rounded differently, a thread-order-dependent reduction, an lr factor
applied off by one step — and the example fails its build. This is
§8.9's whole argument compressed into one line: determinism is a
testable property, so test it.
3. Inference: load the final weights into the gradient-free
initConstZero model and report its accuracy — proving the checkpoint
round-trips into a model with no autograd attached.
main (:328-381) runs the gauntlet for SGD (nesterov momentum), AdamW,
Muon (with a retuned fallback lr — the comment explains that the reference
default 3e-4 is tuned for LLM heads, not a toy head), APOLLO, and
APOLLO-Mini. Note the roster: plain Adam is not among the demos, so its
resume path is not covered by this gate — its behavior is pinned by the
unit and golden tests in src/optim_tests.zig instead. Then groupsDemo
(:389-494) composes the full §8.7 recipe — matrices in a weight-decayed
AdamW, biases in a no-decay AdamW, one OptimizerSet, a warmup-cosine
schedule attached to both lrs, clip at 1.0 — and pushes that composition
through the same halfway-checkpoint bit-exact-resume gauntlet.
Also worth a look in main: the allocator.
var gpa = std.heap.DebugAllocator(.{}){};
defer if (gpa.deinit() == .leak) @panic("leak");
(from examples/spirals/main.zig:329-330) — the example panics if it leaks a
single allocation. TRAINING.md §11's advice: train in ReleaseFast,
validate in Debug — the DebugAllocator catches lifetime mistakes.
Run it¶
zig build spirals -Doptimize=ReleaseFast
You will see one header, then three lines per optimizer (the groups demo
prints two), in this format
(these are the actual print format strings from
examples/spirals/main.zig:292-322,348 — the numbers are for your machine to
fill in):
two spirals: {d} points, MLP 2-{d}-{d}-2 (tanh), full-batch, {d} steps
[{s}] trained {d} steps: loss {d:.4} accuracy {d:.1}%
[{s}] resume from step {d}: max |delta param| = {d} ({s})
[{s}] inference from checkpoint: accuracy {d:.1}%
Watch the loss column: every optimizer drives it down, at visibly
different speeds and to visibly different depths — the optimizer sections
of this chapter, rendered as data. Watch the resume line say bit-exact
six times. Then break something on purpose, in phase 2 only so the phases
genuinely diverge: register b1 and b2 in swapped order for the
resumed optimizer (both are [64], so shape validation passes but the
moments land in the wrong slots — the unnamed-param auto-name contract
from §8.9), or restart groupsDemo's resumed loop counter at 0 instead of
ckpt_step (the schedule is a pure function of the step — key it wrong
and the lr trajectory differs). Either way, watch the gate catch you.
8.12 What we skipped, and where it lives¶
- Activation checkpointing — trading compute for backward memory —
belongs to autograd and was covered in Chapter 7;
fucina.checkpointcomposes with everything here (the LLM trainer's--checkpoint-layersuses it, with bitwise-equal gradients). - Training without gradients at all — evolution strategies, the other
half of
fucina's training story — is the next chapter. - LoRA fine-tuning of a real quantized LLM, the SFT data pipeline, and the fine-tune → merge → quantize → serve loop are Chapter 15; you have already seen its accumulation window (§8.8).
- How gradients are verified — finite differences, PyTorch-golden
replicas, and causal real-model probes — is described in
docs/TRAINING.md §9 and belongs to the verification story of
Chapter 16. One honesty note if you run
zig build finetune -- --verify-gradsyourself: TRAINING.md documents a known open issue where the Q4_K_S Taylor-probe leg reads R ≈ 0.59 and the tool exits non-zero at the published commit, while the finite-difference and torch-golden checks still pass — the discrepancy is confined to that quantization-staircase-sensitive probe. The repo documents its own open issues; the course keeps them documented too.
What you now know¶
- A training step is a fixed six-stage ritual — forward → loss →
backward→clipGradNorm→step→zeroGrad— and every piece of training machinery serves one of those stages. - Training inverts inference's deinit-ASAP discipline because graph nodes are single-owner; exec scopes make the training lifetime rule implicit, so the same forward code infers and trains.
- Cross-entropy's knobs (
ignore_index,reduction,label_smoothing) are load-bearing for real training, and the fusedlinearCrossEntropyExtshows why owning your ops pays at LLM scale. - SGD is one line; momentum is an EMA of gradients; AdamW is twelve lines of decoupled decay + two EMAs + bias correction, with f64 scalar prep — and each optimizer in the repo is a golden-parity port of its reference, quirks included.
- Muon orthogonalizes matrix updates via Newton-Schulz (paying GEMM FLOPs); APOLLO compresses Adam's moments into a regenerated random projection (paying almost nothing); both route non-matrix params to an embedded AdamW fallback.
- Param groups are just optimizer instances under an
OptimizerSet; clipping is global-L2 with pre-clip norm returned; LR schedules are pure functions of the step, which is exactly what makes them resume-safe. - Gradient accumulation is the substrate's default behavior plus discipline: fresh graph per backward, loss-side normalization, clip once, checkpoint at window boundaries.
- Checkpoints are a directory with a last-written JSON sentinel; state
dicts are named safetensors via
ParamRegistry; and bit-exact resume is an enforced,error.ResumeNotBitExact-gated property, not a hope. - Mixed precision has one contract — gradients are always f32 — plus f32 masters for 16-bit leaves; bf16 optimizer state is a separate, memory-bound-therefore-faster knob with one precision trap (AdamW's second moment).
Explore the source¶
examples/spirals/main.zig— the chapter as a program; read it top to bottom now, it will all be familiar.docs/TRAINING.md— the manual this chapter quotes; §12 is a pitfalls checklist worth keeping open while you write your first loop.src/optim.zig— all five optimizers, schedules, clipping, persistence; start at the module doc comment, thenadamwUpdateandnewtonSchulz5.src/optim_tests.zig— the golden parity tests against PyTorch/muon.py/apollo_torch; how "faithful port" is enforced.src/param_registry.zigandsrc/training_checkpoint.zig— reflective parameter naming and the checkpoint directory protocol, ~370 and ~290 lines respectively.examples/finetune/main.zig— the same machinery at LLM scale: accumulation windows, OptimizerSet, periodic checkpoint directories.
Exercises¶
- Break the ritual. In
examples/spirals/main.zig, moveopt.zeroGrad()aboveopt.step(ctx)intrainStepand rerun. Explain what the optimizer now sees. Then deletezeroGradentirely and explain why the loss behaves the way it does (§8.8 has the vocabulary). - Add plain
Adamto the gauntlet.maindemos five optimizers but notoptim.Adam. Add ademo(optim.Adam, "adam", …)call with a sensible config and confirm it passes all three phases — you will have extended the bit-exact-resume gate to cover an optimizer it currently does not. - A linear-warmup-only schedule. Write your own factor function
fn warmupOnlyFactor(step: u64, warmup_steps: u64) f64(course-code style, pure), use it ingroupsDemoinstead ofwarmupCosineFactor, and verify resume stays bit-exact — then explain why it must (§8.7). - Momentum from scratch, verified. Extend this chapter's course-code
SGD with PyTorch's dampening and nesterov variants (the formulas are in
the
torch.optim.SGDdocs), then check your implementation againstoptim.SGDon a few hand-built gradients. You are writing your first golden parity test. - (Hard) A new optimizer behind the shared surface. Implement
Lion (Chen et al., 2023: sign of an interpolated momentum, decoupled
decay) as a struct with the shared optimizer surface from §8.5
(
init/deinit/addParam/step/zeroGrad/clipGradNorm), reusingoptim.Param.of. Wire it intoregisterParamsand the spirals gauntlet. For full marks: makesaveState/loadStateround-trip and pass the bit-exact resume gate.
Previous: Autograd — the graph hidden in the values · Next: Training without gradients