Examples
The gallery
Every example in the repository, with what it printed and its source: from a scalar chain to a transformer.
Every example in the repository, in reading order. Each plate is the exact text cargo run --release --example NAME printed into a pipe: no color, a fixed width, the charts as the plain grid a log file or a language model reads. The story above each plate is the file's own doc comment, and the source is folded beneath it.
The recordings live in site/gallery/ and are remade by ./site/record.sh. Two of the language-model runs need a cached checkpoint and the accelerate build; the rest run from a clean checkout.
The stack on one graph#
Where to start reading: the smallest graphs, and every reading of them printed in turn.
walkthrough
The whole stack on one graph, read five ways.
Topos is an autodiff compiler, and both halves of that phrase
show up here on the smallest graph that still has something to
say: a matrix product, a tanh, and a loss. The loss is one
number that says how wrong the output is; training pushes it
down, and the gradient (how much the loss moves when each weight
moves) is what tells training which way to push.
Recording writes the graph down once, as a list of operations. That list is the tape, and once sealed it is the spec: the one definition of what the program means. Everything after that is a different way of reading the same list, and this file prints each reading in turn:
- The spec: one line per operation, in the order written.
- The derivative:
differentiateappends the chain rule to the same list, so a gradient is just more spec. - The plan: a schedule of what to run, what to keep, and what to free, derived from the spec and holding no state.
- StableHLO: the plan written out as text that an industrial compiler such as XLA accepts.
- The runs: the interpreter, whose bits define the answer, and the compiled plan, which must reproduce them.
Nothing is rewritten between the readings; the spec never changes. A closing section walks its lines by hand, to show that the printed IR is executable and not decorative.
Run with: cargo run --example walkthrough
== 1. the spec ==
One line per recorded operation: its number, its opcode, the numbers of
the operands it reads, and its shape. Shapes were checked as each line
was written; a mismatch panics at the expression that records it.
0 Parameter [2, 2]
1 Input [1, 2]
2 MatMul 1, 0 [1, 2]
3 Tanh 2 [1, 2]
4 Mul 3, 3 [1, 2]
5 Sum 4 []
tape: 6 nodes, 1 parameter, 1 input
== 2. the derivative, as more spec ==
`differentiate` appended 23 nodes to the same tape; nothing above them changed.
The last one is d loss / d weights.
6 Leaf []
7 Broadcast 6 shape=[1, 2] [1, 2]
8 Leaf [1, 2]
9 Add 8, 7 [1, 2]
10 Mul 9, 3 [1, 2]
11 Mul 9, 3 [1, 2]
12 Leaf [1, 2]
13 Add 12, 10 [1, 2]
14 Add 13, 11 [1, 2]
15 Leaf [1, 2]
16 Mul 3, 3 [1, 2]
17 Sub 15, 16 [1, 2]
18 Mul 14, 17 [1, 2]
19 Leaf [1, 2]
20 Add 19, 18 [1, 2]
21 Permute 0 order=[1, 0] [2, 2]
22 MatMul 20, 21 [1, 2]
23 Permute 1 order=[1, 0] [2, 1]
24 MatMul 23, 20 [2, 2]
25 Leaf [1, 2]
26 Add 25, 22 [1, 2]
27 Leaf [2, 2]
28 Add 27, 24 [2, 2]
tape: 29 nodes, 1 parameter, 1 input
How to read them:
- a Leaf holding one is the seed, spread over the shape the sum reduced
- `hidden * hidden` gets the product rule once per operand
- `1 - hidden * hidden` is the derivative of tanh
- the matmul rule pushes the result back as two transposed products,
one per operand
- each Leaf-then-Add pair starts a gradient at zero and adds one
consumer's share; that is how a value used twice collects both
== 3. the plan ==
The same operations, scheduled. Each line also says what happens to the
value once computed: kept for reading, or freed after its last use. The
last line is the plan's own memory accounting: the most elements alive at
once, against the total if nothing were ever freed.
0 Parameter [2, 2] freed after 2
1 Input [1, 2] freed after 23
2 MatMul 1, 0 [1, 2] freed after 3
3 Tanh 2 [1, 2] kept
4 Mul 3, 3 [1, 2] freed after 5
5 Sum 4 [] kept
6 Leaf [] freed after 7
7 Broadcast 6 shape=[1, 2] [1, 2] freed after 9
8 Leaf [1, 2] freed after 9
9 Add 8, 7 [1, 2] freed after 11
10 Mul 9, 3 [1, 2] freed after 13
11 Mul 9, 3 [1, 2] freed after 14
12 Leaf [1, 2] freed after 13
13 Add 12, 10 [1, 2] freed after 14
14 Add 13, 11 [1, 2] freed after 18
15 Leaf [1, 2] freed after 17
16 Mul 3, 3 [1, 2] freed after 17
17 Sub 15, 16 [1, 2] freed after 18
18 Mul 14, 17 [1, 2] freed after 20
19 Leaf [1, 2] freed after 20
20 Add 19, 18 [1, 2] freed after 24
23 Permute 1 order=[1, 0] [2, 1] freed after 24
24 MatMul 23, 20 [2, 2] freed after 28
27 Leaf [2, 2] freed after 28
28 Add 27, 24 [2, 2] kept
plan: forward; 25 of 29 nodes evaluated, 3 readable
live volume: peak 15 elements at node 28, retain-all 56
Not scheduled: nodes 21, 22, 25, 26. The rules also produced the gradient with
respect to the input; nobody declared it, so it is never computed.
Elected patterns: none. Fusion is an offer the catalog makes for shapes it
recognizes (windows, batch-norm); a dense layer this small runs as primitives.
== 4. the plan as StableHLO ==
module @topos {
func.func @main(%arg0: tensor<2x2xf32>, %arg1: tensor<1x2xf32>) -> (tensor<f32>, tensor<2x2xf32>, tensor<1x2xf32>) {
%v2 = stablehlo.dot_general %arg1, %arg0, contracting_dims = [1] x [0] : (tensor<1x2xf32>, tensor<2x2xf32>) -> tensor<1x2xf32>
%v3 = stablehlo.tanh %v2 : tensor<1x2xf32>
%v4 = stablehlo.multiply %v3, %v3 : tensor<1x2xf32>
%v5_seed = stablehlo.constant dense<0.0> : tensor<f32>
%v5 = stablehlo.reduce(%v4 init: %v5_seed) applies stablehlo.add across dimensions = [0, 1] : (tensor<1x2xf32>, tensor<f32>) -> tensor<f32>
%v6 = stablehlo.constant dense<1.0> : tensor<f32>
%v7 = stablehlo.broadcast_in_dim %v6, dims = [] : (tensor<f32>) -> tensor<1x2xf32>
%v8 = stablehlo.constant dense<0.0> : tensor<1x2xf32>
%v9 = stablehlo.add %v8, %v7 : tensor<1x2xf32>
%v10 = stablehlo.multiply %v9, %v3 : tensor<1x2xf32>
%v11 = stablehlo.multiply %v9, %v3 : tensor<1x2xf32>
%v12 = stablehlo.constant dense<0.0> : tensor<1x2xf32>
%v13 = stablehlo.add %v12, %v10 : tensor<1x2xf32>
%v14 = stablehlo.add %v13, %v11 : tensor<1x2xf32>
%v15 = stablehlo.constant dense<1.0> : tensor<1x2xf32>
%v16 = stablehlo.multiply %v3, %v3 : tensor<1x2xf32>
%v17 = stablehlo.subtract %v15, %v16 : tensor<1x2xf32>
%v18 = stablehlo.multiply %v14, %v17 : tensor<1x2xf32>
%v19 = stablehlo.constant dense<0.0> : tensor<1x2xf32>
%v20 = stablehlo.add %v19, %v18 : tensor<1x2xf32>
%v23 = stablehlo.transpose %arg1, dims = [1, 0] : (tensor<1x2xf32>) -> tensor<2x1xf32>
%v24 = stablehlo.dot_general %v23, %v20, contracting_dims = [1] x [0] : (tensor<2x1xf32>, tensor<1x2xf32>) -> tensor<2x2xf32>
%v27 = stablehlo.constant dense<0.0> : tensor<2x2xf32>
%v28 = stablehlo.add %v27, %v24 : tensor<2x2xf32>
return %v5, %v28, %v3 : tensor<f32>, tensor<2x2xf32>, tensor<1x2xf32>
}
}
The function takes the parameter and the input, in recording order, and
returns exactly what the entry declared, in that order: the loss, the
gradient, then `hidden`.
== 5. two runs that must agree ==
loss:
interpreter 0.83054614
exact plan 0.83054614
fast plan 0.83054614
d loss / d weights:
engine scan [[0.6397, 0.75025934], [1.2794, 1.5005187]]
recorded gradient [[0.6397, 0.75025934], [1.2794, 1.5005187]]
hidden, declared readable: [[0.7615942, 0.5005202]]
exact plan == interpreter, bit for bit: asserted
recorded gradient == engine scan, bit for bit: asserted
fast plan == exact plan: yes; no backend claimed any of this graph in this build
loss before training, then after each of five steps of size 0.05:
0.8305, 0.5910, 0.3760, 0.2014, 0.0826, 0.0260
== 6. the spec is executable ==
Walking all 29 lines by hand, outside the crate, reproduces the interpreter's
loss 0.83054614 bit for bit.cargo run --release --example walkthrough printed into a pipe.The source, examples/walkthrough.rs
use topos::{Numerics, Tape, Tensor};
fn main() {
// A tape is a notebook of operations: every expression over a
// `Value` appends one line. `weights` is a parameter, the thing
// training will change; `input` is fed per run and carries a
// default, which this file uses throughout.
let tape: Tape<f32> = Tape::new();
let weights = tape.parameter(Tensor::new([2, 2], [0.5_f32, -0.25, 0.25, 0.4]));
let input = tape.input(Tensor::new([1, 2], [1.0_f32, 2.0]));
let hidden = input.matmul(weights).tanh();
let loss = (hidden * hidden).sum();
banner("1. the spec");
println!("One line per recorded operation: its number, its opcode, the numbers of");
println!("the operands it reads, and its shape. Shapes were checked as each line");
println!("was written; a mismatch panics at the expression that records it.\n");
print!("{}", tape.describe());
let spec_nodes = tape.len();
// A `Value` borrows the tape and cannot outlive it. A `Symbol`
// is the detached name every later phase speaks.
let (weights, hidden, loss) = (weights.symbol(), hidden.symbol(), loss.symbol());
banner("2. the derivative, as more spec");
// The chain rule is applied by the same rules the interpreter's
// reverse scan uses; here they record instead of compute. The
// gradient becomes ordinary nodes: printable, schedulable,
// emittable, and differentiable again.
let adjoints = tape.differentiate(loss, [weights]);
let gradient = adjoints.of(weights);
println!(
"`differentiate` appended {} nodes to the same tape; nothing above them changed.",
tape.len() - spec_nodes
);
println!("The last one is d loss / d weights.\n");
for line in tape.describe().lines().skip(spec_nodes) {
println!("{line}");
}
println!("\nHow to read them:");
println!(" - a Leaf holding one is the seed, spread over the shape the sum reduced");
println!(" - `hidden * hidden` gets the product rule once per operand");
println!(" - `1 - hidden * hidden` is the derivative of tanh");
println!(" - the matmul rule pushes the result back as two transposed products,");
println!(" one per operand");
println!(" - each Leaf-then-Add pair starts a gradient at zero and adds one");
println!(" consumer's share; that is how a value used twice collects both");
// Sealing turns the tape into an immutable `Network`. From here
// on nothing writes the graph: training only replaces the
// caller-owned `Parameters`, which start from the recorded
// initial values.
let network = tape.into_network();
let parameters = network.parameters();
banner("3. the plan");
// An entry declares what a run may read: its roots (the loss and
// the gradient, in that order) plus any interior we also want to
// look at. Reading an undeclared interior panics. That
// declaration is the license the scheduler works under: the
// declared must survive; the undeclared may be skipped, freed,
// or fused.
let declare = || network.entry(adjoints.roots()).observe([hidden]);
let plan = declare().lower();
println!("The same operations, scheduled. Each line also says what happens to the");
println!("value once computed: kept for reading, or freed after its last use. The");
println!("last line is the plan's own memory accounting: the most elements alive at");
println!("once, against the total if nothing were ever freed.\n");
print!("{}", plan.describe());
let scheduled: Vec<usize> = plan.nodes().map(|node| node.symbol().index()).collect();
let skipped: Vec<String> = network
.nodes()
.filter(|node| !scheduled.contains(&node.symbol().index()))
.map(|node| node.symbol().index().to_string())
.collect();
if skipped.is_empty() {
println!("\nEvery node is scheduled.");
} else {
println!(
"\nNot scheduled: nodes {}. The rules also produced the gradient with",
skipped.join(", ")
);
println!("respect to the input; nobody declared it, so it is never computed.");
}
let patterns = plan.patterns();
if patterns.is_empty() {
println!("Elected patterns: none. Fusion is an offer the catalog makes for shapes it");
println!("recognizes (windows, batch-norm); a dense layer this small runs as primitives.");
} else {
println!("Elected patterns:");
for pattern in &patterns {
println!(
" {:?} rooted at node {}",
pattern.kind(),
pattern.root().index()
);
}
}
banner("4. the plan as StableHLO");
// Emission is `describe` in another syntax: the same schedule,
// written as text an industrial compiler accepts. Nothing
// XLA-shaped lives in the crate; the text is the boundary.
let module = plan
.emit_stablehlo()
.expect("every operation in this graph lowers");
print!("{module}");
println!("\nThe function takes the parameter and the input, in recording order, and");
println!("returns exactly what the entry declared, in that order: the loss, the");
println!("gradient, then `hidden`.");
assert_eq!(plan.results(), &[loss, gradient, hidden][..]);
assert!(module.contains("stablehlo.dot_general"));
assert!(module.contains("stablehlo.tanh"));
banner("5. two runs that must agree");
// The interpreter over the whole spec is the oracle. Its bits are
// the same in every build, on every platform, and they define
// what the answer is. Its reverse scan applies the derivative
// rules directly, without recording.
let oracle = network.forward(¶meters, []);
let engine_gradient = oracle.backward(loss).parameters(¶meters);
// A plan under `Exact` must reproduce those bits. Under `Fast`,
// the default, a backend feature may serve large products with
// sums in a different order; that difference is a labeled
// choice on the entry, never a silent effect of a feature flag.
let exact = declare().numerics(Numerics::Exact).lower();
let exact_run = exact.forward(¶meters, []);
let fast_run = plan.forward(¶meters, []);
let recorded_gradient = exact_run.recorded_gradients(&adjoints);
println!("loss:");
println!(" interpreter {}", oracle.of(loss));
println!(" exact plan {}", exact_run.of(loss));
println!(" fast plan {}", fast_run.of(loss));
println!("d loss / d weights:");
println!(" engine scan {}", engine_gradient.of(weights));
println!(" recorded gradient {}", recorded_gradient.of(weights));
println!("hidden, declared readable: {}", exact_run.of(hidden));
assert!(bits_equal(oracle.of(loss), exact_run.of(loss)));
assert!(bits_equal(
engine_gradient.of(weights),
recorded_gradient.of(weights)
));
println!("\nexact plan == interpreter, bit for bit: asserted");
println!("recorded gradient == engine scan, bit for bit: asserted");
if bits_equal(fast_run.of(loss), exact_run.of(loss)) {
println!(
"fast plan == exact plan: yes; no backend claimed any of this graph in this build"
);
} else {
println!("fast plan == exact plan: no; a backend served part of this graph under Fast,");
println!("and Exact is how you ask for the reference bits back");
}
// A few steps of gradient descent close the loop. The plan was
// compiled once and holds no state: each step feeds the current
// parameters, reads the recorded gradient, and mints the next
// parameters. The network never changes, and a clone of the
// state is all a what-if costs. Nothing broadcasts by accident,
// so the scalar learning rate is spread over each gradient
// explicitly.
let learning_rate = Tensor::from(0.05_f32);
let mut current = parameters.clone();
let mut losses = Vec::new();
for _ in 0..5 {
let run = plan.forward(¤t, []);
losses.push(run.of(loss).scalar());
let gradients = run.recorded_gradients(&adjoints);
current = current.step(&gradients, |weight, gradient| {
weight.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
losses.push(plan.forward(¤t, []).of(loss).scalar());
let trajectory: Vec<String> = losses.iter().map(|value| format!("{value:.4}")).collect();
println!("\nloss before training, then after each of five steps of size 0.05:");
println!(" {}", trajectory.join(", "));
assert!(losses[losses.len() - 1] < losses[0]);
banner("6. the spec is executable");
// The printed lines are an instruction list, and walking them is
// the interpreter: sources take their stored payloads, computed
// nodes express their opcode over operands computed earlier. The
// walk runs under the exact posture, as the whole-spec oracle
// does by construction.
let values = Numerics::exactly(|| {
let mut values: Vec<Tensor<f32>> = Vec::new();
for node in network.nodes() {
let value = if node.is_source() {
network
.payload(node.symbol())
.expect("sources hold payloads")
.clone()
} else {
let operands: Vec<&Tensor<f32>> = node
.operands()
.iter()
.map(|symbol| &values[symbol.index()])
.collect();
node.opcode().express(&operands)
};
values.push(value);
}
values
});
assert!(bits_equal(oracle.of(loss), &values[loss.index()]));
println!(
"Walking all {} lines by hand, outside the crate, reproduces the interpreter's",
values.len()
);
println!("loss {} bit for bit.", values[loss.index()]);
}
/// Prints a section heading.
fn banner(title: &str) {
println!("\n== {title} ==\n");
}
/// Compares two tensors bit for bit, the only equality the oracle
/// promises: same shape, same bits in every element.
fn bits_equal(left: &Tensor<f32>, right: &Tensor<f32>) -> bool {
left.shape() == right.shape()
&& left
.to_vec()
.iter()
.zip(right.to_vec())
.all(|(left, right)| left.to_bits() == right.to_bits())
}chain
Demonstrates how values allocated on a Tape chain into an
expression graph that can be evaluated and differentiated.
Run with: cargo run --example chain
allocated 3 leaves
chained -((a + b) * c) + a * c as Value { id: ValueId(7) }
the tape now holds 8 values
forward: expression = -12
gradients: d/da = 0, d/db = -4, d/dc = -3cargo run --release --example chain printed into a pipe.The source, examples/chain.rs
use topos::Tape;
fn main() {
let tape = Tape::new();
// Leaves are the inputs of the graph: learnable parameters or data.
// The tape owns their state; the returned values are `Copy` proxies
// borrowing it.
let a = tape.leaf(2.0_f64);
let b = tape.leaf(3.0);
let c = tape.leaf(4.0);
println!("allocated {} leaves", tape.len());
// Operators record computed nodes on the same tape. Proxies are
// never consumed, so the same value can feed any number of expressions.
let sum = a + b;
let product = sum * c;
let expression = -product + a * c;
println!("chained -((a + b) * c) + a * c as {expression:?}");
println!("the tape now holds {} values", tape.len());
// Symbols are the names every phase after recording speaks; the
// seal consumes the tape and hands back the immutable spec.
let (a, b, c, expression) = (a.symbol(), b.symbol(), c.symbol(), expression.symbol());
let network = tape.into_network();
let parameters = network.parameters();
// The forward pass materializes every payload into per-run storage,
// leaving the network untouched.
let run = network.forward(¶meters, []);
println!("forward: expression = {}", run.of(expression));
// The backward pass produces the gradient of the expression with
// respect to every value. `a` feeds two subexpressions whose
// contributions cancel exactly, hence its zero gradient.
let gradients = run.backward(expression);
println!(
"gradients: d/da = {}, d/db = {}, d/dc = {}",
gradients.of(a),
gradients.of(b),
gradients.of(c)
);
}Training loops#
One recording, many states. Each of these fits a small model with a loop that never touches the graph, and charts what it learned.
gradient_descent
Trains a linear model w * x + b with gradient descent, exercising one
shared network across threads.
Two things run in parallel here. First, a single run of the shared
network feeds concurrent backward sweeps, one per target: runs are
per-thread state, the network is immutable. Second, several training
runs proceed simultaneously over one spec — each learning rate owns
its own Parameters state, and cloning state is all a what-if costs.
Run with: cargo run --example gradient_descent
per-sample d/dw, computed on separate threads: [-6.0, -20.0, -42.0]
their sum -68 equals the total-loss d/dw -68 by linearity
trained 3 states of 500 steps in 0.002s
parallel training on cloned states (target: w = 2, b = 1):
lr = 0.005: loss = 0.000118, w = 2.007, b = 0.983
lr = 0.020: loss = 0.000000, w = 2.000, b = 1.000
lr = 0.050: loss = 0.000000, w = 2.000, b = 1.000
gradient descent per learning rate
── lr = 0.005 ── lr = 0.02 ── lr = 0.05
│█▄
│▐█▚▖
10⁻² ┤▝▟▄██▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
l │ ▝▀▀▀▀▄▄▟█▛▀▀▀▀▀▀▀▀▚▄▄▄▄▄▄▄▄▄▄▖ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
o 10⁻⁶ ┤ ▀▀▀▀▚▄▄▄▄ ▀▀▀▀▀▀▀▀▀▀▜▄▄▄▄▄▄▄▄▄▄▄
s │ ▝▀▀▀▀▄▄▄▄▖ ▝▀▀▀▀▀▀▀▀▀▀▚▄▄▄▄▄
s 10⁻¹⁰ ┤ ▀▀▀▀▜▄▄▄▄
│ ▝▀▀▀▀▄▄▄▄▖
10⁻¹⁴ ┤ ▀▀▀▀▜▄▄▄▄
│ ▝▀▀▀▀▙▄▄▄▖
10⁻¹⁸ ┤ ▀▀▀▀▚▄▄
└┬─────────────┬─────────────┬─────────────┬─────────────┬─────────────┬
0 100 200 300 400 500
stepcargo run --release --example gradient_descent printed into a pipe.The source, examples/gradient_descent.rs
use std::time::Instant;
use rayon::prelude::*;
use malevich::{Frame, Line, Plot};
use topos::model::{Detach, Tape, Tensor};
fn main() {
// The whole recording is one closure; its return value is the
// set of names that leave the tape, detached in one `detach` call.
let (network, (w, b, loss, sample_losses)) = Tape::record(|tape| {
// Learnable parameters, starting from zero.
let w = tape.parameter(0.0_f64);
let b = tape.parameter(0.0);
// Training data for the target line `y = 2 * x + 1`, recorded
// as plain leaves. Each sample's squared error is kept as a
// separate target; the total loss is their sum.
let samples = [(1.0, 3.0), (2.0, 5.0), (3.0, 7.0)];
let mut sample_losses = Vec::new();
for (x, y) in samples {
let x = tape.leaf(x);
let y = tape.leaf(y);
let error = w * x + b - y;
sample_losses.push(error * error);
}
// Values are `Copy`, so the per-sample losses fold into a total
// loss with a plain reduce.
let loss = sample_losses
.iter()
.copied()
.reduce(|total, squared| total + squared)
.expect("at least one sample");
(w, b, loss, sample_losses).detach()
});
let parameters = network.parameters();
// One run feeds many backward sweeps: each rayon thread
// differentiates the same shared run for its own target.
let run = network.forward(¶meters, []);
let per_sample: Vec<f64> = sample_losses
.par_iter()
.map(|&sample_loss| {
let gradients = run.backward(sample_loss);
gradients.of(w).scalar()
})
.collect();
let total_gradient = run.backward(loss).of(w).scalar();
println!("per-sample d/dw, computed on separate threads: {per_sample:?}");
println!(
"their sum {} equals the total-loss d/dw {} by linearity",
per_sample.iter().sum::<f64>(),
total_gradient
);
// Parallel training: each learning rate clones the parameter state
// and descends independently over the one shared spec, keeping its
// whole loss history for the chart.
let learning_rates = [0.005, 0.02, 0.05];
let training = Instant::now();
let runs: Vec<(f64, Vec<f64>, f64, f64)> = learning_rates
.par_iter()
.map(|&learning_rate| {
let mut parameters = parameters.clone();
let mut losses = Vec::with_capacity(501);
for _ in 0..500 {
let run = network.forward(¶meters, []);
losses.push(run.of(loss).scalar());
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * Tensor::from(learning_rate)
});
}
let run = network.forward(¶meters, []);
losses.push(run.of(loss).scalar());
(
learning_rate,
losses,
parameters.of(w).scalar(),
parameters.of(b).scalar(),
)
})
.collect();
println!(
"trained {} states of 500 steps in {:.3}s",
learning_rates.len(),
training.elapsed().as_secs_f64()
);
println!("parallel training on cloned states (target: w = 2, b = 1):");
for (learning_rate, losses, w, b) in &runs {
let final_loss = losses.last().expect("every run records its final loss");
println!(" lr = {learning_rate:5.3}: loss = {final_loss:.6}, w = {w:.3}, b = {b:.3}");
}
// The same three descents as curves: on a log scale a constant
// convergence rate is a straight line, so the slope is the rate.
let mut plot = Plot::new()
.title("gradient descent per learning rate")
.x_label("step")
.y_label("loss")
.log_y();
for (learning_rate, losses, ..) in &runs {
plot = plot.layer(Line::y(&losses[..]).label(format!("lr = {learning_rate}")));
}
println!("{}", plot.render_best(&Frame::detect()));
}regression
Fits a one-hidden-layer MLP to a noisy sine wave and draws the fit: the sampled points, the curve they came from, and the curve the model learned, in one chart.
The graph is recorded once over a [SAMPLE_LEN, 1] input whose
default feed is the training set, so training is a plain forward
per step; charting feeds an evenly spaced grid through the same
expression, so the fit line is the model itself, not an
interpolation of its training outputs.
Run with: cargo run --release --example regression
step 0: loss = 9.0241
step 600: loss = 1.4585
step 1200: loss = 1.3850
step 1800: loss = 1.3429
step 2400: loss = 1.3144
trained 3000 steps in 0.147s
regression training
── full batch
s 9 ┤▌
u │▌
m 7 ┤▌
│▜
o │▐
f 5 ┤▐
│▝▌
s │ ▌
q 3 ┤ ▜
u │ ▝▙▄▄
a 1 ┤ ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
└┬───────────┬────────────┬───────────┬───────────┬────────────┬───────────┬
0 500 1000 1500 2000 2500 3000
step
a tanh mlp fit to noisy sin x
•• samples ── sin x ── fit
1.0 ┤ ▗▚▚▙▄▄▄▄
│ ▖ ▄▄█▜▛▀▀▀▀▀▀▀▀▛█▙▙▄▐
│ ▄▟█▀▘ ▘ ▀█▜▙▄▖
0.5 ┤ ▗▟▛▀ ▝▛█▚▄▄
│ ▗▄█▀ ▘ ▀▚▄
0.0 ┤ ▄▄▀▘▝
│▀▄▖ ▗ ▟▛▀▖
-0.5 ┤▚▟▟▙▟▖ ▌▄▞▀
│ ▝▀▀█▄▄ ▗ ▗ ▗▄█▀▀
│ ▝ ▝▀▛▀█▄▄▄▄▖ ▄▄▟▟█▛▀▘
-1.0 ┤ ▘ ▝ ▖▀▀▀▀▀▀ ▗▝
└┬───────────┬───────────┬────────────┬───────────┬───────────┬───────────┬
-3 -2 -1 0 1 2 3
xcargo run --release --example regression printed into a pipe.The source, examples/regression.rs
use std::time::Instant;
use malevich::{Frame, Line, Plot, Points};
use topos::{Activation, Mlp, Module, Shape, Tape, Tensor, init};
/// How many noisy samples the model trains on; the chart grid reuses
/// the count so the recorded expression serves both.
const SAMPLE_LEN: usize = 96;
/// The half-width of the sampled domain, centered on zero.
const DOMAIN: f64 = 3.0;
/// The standard deviation of the noise added to the sampled curve.
const NOISE_DEVIATION: f64 = 0.1;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 16;
/// How many full-batch gradient descent steps the fit takes.
const STEP_COUNT: usize = 3000;
fn main() {
// The dataset: x uniform over the domain and y = sin x plus noise,
// both drawn from the same seeded initializers the parameters use.
let features: Tensor<f32> = init::uniform(3, DOMAIN)(&Shape::new([SAMPLE_LEN, 1]));
let noise: Tensor<f32> = init::normal(5, NOISE_DEVIATION)(&Shape::new([SAMPLE_LEN, 1]));
let feature_values = features.to_vec();
let target_values: Vec<f32> = feature_values
.iter()
.zip(noise.to_vec())
.map(|(&x, noise)| x.sin() + noise)
.collect();
let tape: Tape<f32> = Tape::new();
let mlp = Mlp::new(
&tape,
&[1, HIDDEN_LEN, 1],
Activation::Tanh,
init::xavier(7),
);
let input = tape.input(features);
let expected = tape.input(Tensor::new([SAMPLE_LEN, 1], target_values.clone()));
let predicted = mlp.express(input);
let error = predicted - expected;
let loss = (error * error).sum();
let (input, predicted, loss) = (input.symbol(), predicted.symbol(), loss.symbol());
let network = tape.into_network();
let mut parameters = network.parameters();
let learning_rate = Tensor::new([], [0.001]);
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..STEP_COUNT {
let run = network.forward(¶meters, []);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step % (STEP_COUNT / 5) == 0 {
println!("step {step:4}: loss = {batch_loss:.4}");
}
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
println!(
"{}",
Plot::new()
.layer(Line::y(&losses[..]).label("full batch"))
.title("regression training")
.x_label("step")
.y_label("sum of squared errors")
.render_best(&Frame::detect())
);
// The fit, read over an evenly spaced grid fed through the trained
// expression in place of the training samples.
let grid: Vec<f32> = (0..SAMPLE_LEN)
.map(|index| (((index as f64 / (SAMPLE_LEN - 1) as f64) * 2.0 - 1.0) * DOMAIN) as f32)
.collect();
let run = network.forward(
¶meters,
[(input, Tensor::new([SAMPLE_LEN, 1], grid.clone()))],
);
let fit = run.of(predicted).to_vec();
println!(
"{}",
Plot::new()
.layer(Points::xy(&feature_values[..], &target_values[..]).label("samples"))
.layer(Line::function(-DOMAIN..DOMAIN, f64::sin).label("sin x"))
.layer(Line::xy(&grid[..], &fit[..]).label("fit"))
.title("a tanh mlp fit to noisy sin x")
.x_label("x")
.render_best(&Frame::detect())
);
}mlp_xor
Trains a small MLP on XOR at tensor granularity, feeding minibatches through a graph recorded once.
A [2, 4, 1] perceptron records a handful of tensor nodes, the samples
arrive as per-run feeds instead of graph state, and every training step
binds a different minibatch while the tape never grows.
Recording once and running anywhere covers the data, not just the targets —
the closing chart rasterizes the learned surface through the very same
two-row expression the training fed.
Run with: cargo run --example mlp_xor
step 0: minibatch loss = 2.254289
step 800: minibatch loss = 0.000000
step 1600: minibatch loss = 0.000000
step 2400: minibatch loss = 0.000000
step 3200: minibatch loss = 0.000000
trained 4000 steps in 0.051s
the tape held 16 nodes through every step
xor training
── minibatch
s │▀▀▀█▖
u 10⁻¹ ┤ █
m │ ▝▙
10⁻⁴ ┤ ▜▖
o │ ▝▙
f │ ▐▖
10⁻⁷ ┤ ▙
s │ ▐▌
q 10⁻¹⁰ ┤ ▜
u │ ▝▙▖
a 10⁻¹³ ┤ ▐█████████████████████████████████████████████████████████████
└┬────────┬───────┬────────┬────────┬────────┬────────┬───────┬────────┬
0 500 1000 1500 2000 2500 3000 3500 4000
step
predictions (target in parentheses):
(0.0, 0.0) -> -1.000 (-1)
(0.0, 1.0) -> +1.000 (+1)
(1.0, 0.0) -> +1.000 (+1)
(1.0, 1.0) -> -1.000 (-1)
the learned xor surface 1.0
1.0 ┤█████████████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░ █
│███████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
0.8 ┤██████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █ 0.5
│▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
│▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓
0.6 ┤▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒ ▓
│▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒ ▓ 0.0
0.4 ┤▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓ ▒
│▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓ ▒
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓██ ▒ -0.5
0.2 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓████████ ░
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓██████████████ ░
0.0 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓██████████████ ░ -1.0
└┬──────┬─────┬──────┬──────┬──────┬─────┬──────┬──────┬──────┬─────┬
0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0cargo run --release --example mlp_xor printed into a pipe.The source, examples/mlp_xor.rs
use std::time::Instant;
use malevich::{Cells, Frame, Line, Plot};
use topos::{Activation, Mlp, Module, Tape, Tensor, init};
/// The resolution of the decision-surface chart: how many grid cells
/// span the unit square along each axis.
const SURFACE_COLUMNS: usize = 24;
const SURFACE_ROWS: usize = 12;
fn main() {
let tape = Tape::new();
// A deterministic seeded initializer keeps the run reproducible
// while hidden-unit symmetry still breaks.
let mlp = Mlp::new(&tape, &[2, 4, 1], Activation::Tanh, init::uniform(7, 0.5));
// Declared inputs: the minibatch arrives per run, so the defaults
// only fix the shapes — two samples of two features, two targets.
let x = tape.input(Tensor::filled([2, 2], 0.0_f32));
let y = tape.input(Tensor::filled([2, 1], 0.0));
let predicted = mlp.express(x);
let error = predicted - y;
let loss = (error * error).sum();
let (x, y, loss, predicted) = (x.symbol(), y.symbol(), loss.symbol(), predicted.symbol());
let recorded_nodes = tape.len();
let network = tape.into_network();
let mut parameters = network.parameters();
// The XOR truth table with targets in [-1, 1], split into two
// minibatches that alternate across training steps.
let minibatches = [
(
Tensor::new([2, 2], [0.0, 0.0, 0.0, 1.0]),
Tensor::new([2, 1], [-1.0, 1.0]),
),
(
Tensor::new([2, 2], [1.0, 0.0, 1.0, 1.0]),
Tensor::new([2, 1], [1.0, -1.0]),
),
];
let learning_rate = Tensor::new([], [0.05]);
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..4000 {
let (batch_x, batch_y) = &minibatches[step % minibatches.len()];
let run = network.forward(¶meters, [(x, batch_x.clone()), (y, batch_y.clone())]);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step % 800 == 0 {
println!("step {step:4}: minibatch loss = {batch_loss:.6}");
}
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
// On a log scale the descent's exponential tail is a straight
// line; the two alternating minibatches braid around it.
println!(
"{}",
Plot::new()
.layer(Line::y(&losses[..]).label("minibatch"))
.title("xor training")
.x_label("step")
.y_label("sum of squared errors")
.log_y()
.render_best(&Frame::detect())
);
println!("predictions (target in parentheses):");
for (batch_x, batch_y) in &minibatches {
let run = network.forward(¶meters, [(x, batch_x.clone()), (y, batch_y.clone())]);
let outputs = run.of(predicted);
for (sample, (prediction, target)) in outputs.iter().zip(batch_y.iter()).enumerate() {
let features = batch_x.as_slice().expect("a fed minibatch is contiguous");
let features = &features[sample * 2..sample * 2 + 2];
println!(
" {:?} -> {prediction:+.3} ({target:+.0})",
(features[0], features[1])
);
}
}
// The learned surface, sampled at grid cell centers over the unit
// square through the same two-row expression training used, fed
// two points per run.
let centers: Vec<(f32, f32)> = (0..SURFACE_ROWS)
.flat_map(|row| {
(0..SURFACE_COLUMNS).map(move |column| {
(
(column as f32 + 0.5) / SURFACE_COLUMNS as f32,
(row as f32 + 0.5) / SURFACE_ROWS as f32,
)
})
})
.collect();
let mut surface = Vec::with_capacity(centers.len());
for pair in centers.chunks(2) {
let &[(x0, y0), (x1, y1)] = pair else {
unreachable!("the even grid splits into exact pairs");
};
let run = network.forward(¶meters, [(x, Tensor::new([2, 2], [x0, y0, x1, y1]))]);
surface.extend(run.of(predicted).to_vec());
}
println!(
"{}",
Plot::new()
.layer(Cells::matrix(SURFACE_COLUMNS, surface).extents((0.0, 1.0), (0.0, 1.0)))
.colorbar()
.title("the learned xor surface")
.render_best(&Frame::detect())
);
}moons
Trains an MLP classifier on two interleaved half-moons and draws the learned decision surface — the classic playground picture for a small neural network, as a before-and-after: the scattered moons first, the surface that separates them at the end, both charts pinned to the same domains so they align.
The tape holds two expressions of the same parameters: a
batch-shaped one for training and a grid-shaped twin whose input
defaults to the chart raster, so one plain forward at the end
rasterizes the decision function.
Run with: cargo run --release --example moons
the two moons
•• class +1 •• class -1
1.5 ┤
│ ▖
1.0 ┤ ▗▖▗▝▖▗▄▗▝▝ ▛▌▝▚▚ ▄ ▐ ▖
│ ▖▘▖▄▖▘▗ ▝ ▘ ▘▀▝▗ ▀ ▝ ▗
0.5 ┤ ▗ ▌▐ ▝ ▗ ▖ ▘▗▘▗ ▗▐▝▘ ▖
│ ▗ ▗▛▝▖ ▝ ▖▝▗▖▝▘ ▗▜ ▜ ▝▘▙▀▖
│ ▖▌▝▝ ▚▗▐▖ ▝▗ ▐ ▞ ▗▖▗▝▝▘
0.0 ┤ ▘ ▝ ▗▖▌▙▖▄▖ ▖ ▖▜▘▝▝
│ ▗ ▖ ▛▘ ▖▖▗ ▘▖▖ ▗▖ ▗ ▜▘▞ ▘▘
-0.5 ┤ ▘ ▝▖▄▝ ▗ ▞▘▞▝▝ ▝
│ ▘▝ ▘
-1.0 ┤
└┬────────┬────────┬────────┬─────────┬────────┬────────┬────────┬────────┬
-1.5 -1.0 -0.5 0.0 0.5 1.0 1.5 2.0 2.5
step 0: loss = 185.8824
step 600: loss = 17.4536
step 1200: loss = 9.9124
step 1800: loss = 8.0722
step 2400: loss = 7.1453
trained 3000 steps in 1.567s
two moons training
── full batch
s 200 ┤▖
u │▌
m 150 ┤▌
│▌
o │▌
f 100 ┤▌
│▙
s │▝▀▜▄
q 50 ┤ ▜▖
u │ ▀▜▄▄▄▄▄▄▄▄▄▄▄▄▖
a 0 ┤ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
└┬───────────┬───────────┬───────────┬───────────┬───────────┬───────────┬
0 500 1000 1500 2000 2500 3000
step
classified 199 of 200 training points
the learned decision surface
1.5 ┤███████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ █
│███████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ █ 2
1.0 ┤████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ █
│███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ █
│█████████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓██▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ▓ 1
0.5 ┤██████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓██▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ▓
│████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ▓ 0
0.0 ┤███▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ▒
│███▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ▒
│███▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ▒ -1
-0.5 ┤███▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░ ░
│█▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░ ░ -2
-1.0 ┤█▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░ ░
└┬───────┬────────┬───────┬────────┬────────┬───────┬────────┬───────┬
-1.5 -1.0 -0.5 0.0 0.5 1.0 1.5 2.0 2.5cargo run --release --example moons printed into a pipe.The source, examples/moons.rs
use std::f32::consts::PI;
use std::time::Instant;
use malevich::{Cells, Frame, Line, Plot, Points};
use topos::{Activation, Mlp, Module, Shape, Tape, Tensor, init};
/// How many points each half-moon holds.
const MOON_LEN: usize = 100;
/// The standard deviation of the noise scattering the moons.
const NOISE_DEVIATION: f64 = 0.1;
/// The resolution of the decision-surface chart grid.
const SURFACE_COLUMNS: usize = 48;
const SURFACE_ROWS: usize = 16;
/// The data window the surface rasterizes: both moons with a margin.
const X_SPAN: (f32, f32) = (-1.5, 2.5);
const Y_SPAN: (f32, f32) = (-1.0, 1.5);
/// How many full-batch gradient descent steps the training takes.
const STEP_COUNT: usize = 3000;
/// Builds one half-moon of `MOON_LEN` noisy points: the upper arch
/// when `flipped` is false, the interleaved lower arch when true.
fn moon(flipped: bool, noise: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mut moon_x = Vec::with_capacity(MOON_LEN);
let mut moon_y = Vec::with_capacity(MOON_LEN);
for index in 0..MOON_LEN {
let angle = PI * index as f32 / (MOON_LEN - 1) as f32;
let (x, y) = if flipped {
(1.0 - libm::cosf(angle), 0.5 - libm::sinf(angle))
} else {
(libm::cosf(angle), libm::sinf(angle))
};
moon_x.push(x + noise[index * 2]);
moon_y.push(y + noise[index * 2 + 1]);
}
(moon_x, moon_y)
}
fn main() {
let noise: Tensor<f32> = init::normal(5, NOISE_DEVIATION)(&Shape::new([2 * MOON_LEN, 2]));
let noise = noise.to_vec();
let (upper_x, upper_y) = moon(false, &noise[..2 * MOON_LEN]);
let (lower_x, lower_y) = moon(true, &noise[2 * MOON_LEN..]);
// The batch interleaves each point's coordinates row by row, the
// upper moon labeled `+1` and the lower `-1`.
let mut feature_values = Vec::with_capacity(4 * MOON_LEN);
for (x, y) in upper_x
.iter()
.zip(&upper_y)
.chain(lower_x.iter().zip(&lower_y))
{
feature_values.push(*x);
feature_values.push(*y);
}
let mut target_values = vec![1.0_f32; MOON_LEN];
target_values.extend(vec![-1.0; MOON_LEN]);
let tape: Tape<f32> = Tape::new();
let mlp = Mlp::new(&tape, &[2, 16, 16, 1], Activation::Tanh, init::xavier(7));
let input = tape.input(Tensor::new([2 * MOON_LEN, 2], feature_values));
let expected = tape.input(Tensor::new([2 * MOON_LEN, 1], target_values.clone()));
let predicted = mlp.express(input);
let error = predicted - expected;
let loss = (error * error).sum();
// The rasterizing twin: the same parameters expressed over the
// chart grid's cell centers, recorded once next to the training
// expression.
let mut surface_centers = Vec::with_capacity(2 * SURFACE_COLUMNS * SURFACE_ROWS);
for row in 0..SURFACE_ROWS {
for column in 0..SURFACE_COLUMNS {
let fraction_x = (column as f32 + 0.5) / SURFACE_COLUMNS as f32;
let fraction_y = (row as f32 + 0.5) / SURFACE_ROWS as f32;
surface_centers.push(X_SPAN.0 + fraction_x * (X_SPAN.1 - X_SPAN.0));
surface_centers.push(Y_SPAN.0 + fraction_y * (Y_SPAN.1 - Y_SPAN.0));
}
}
let surface_input = tape.input(Tensor::new(
[SURFACE_COLUMNS * SURFACE_ROWS, 2],
surface_centers,
));
let surface_predicted = mlp.express(surface_input);
let (predicted, surface_predicted, loss) = (
predicted.symbol(),
surface_predicted.symbol(),
loss.symbol(),
);
let network = tape.into_network();
let mut parameters = network.parameters();
println!(
"{}",
Plot::new()
.layer(Points::xy(&upper_x[..], &upper_y[..]).label("class +1"))
.layer(Points::xy(&lower_x[..], &lower_y[..]).label("class -1"))
.x_domain(f64::from(X_SPAN.0), f64::from(X_SPAN.1))
.y_domain(f64::from(Y_SPAN.0), f64::from(Y_SPAN.1))
.title("the two moons")
.render_best(&Frame::detect())
);
let learning_rate = Tensor::new([], [0.0003]);
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..STEP_COUNT {
let run = network.forward(¶meters, []);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step % (STEP_COUNT / 5) == 0 {
println!("step {step:4}: loss = {batch_loss:.4}");
}
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
println!(
"{}",
Plot::new()
.layer(Line::y(&losses[..]).label("full batch"))
.title("two moons training")
.x_label("step")
.y_label("sum of squared errors")
.render_best(&Frame::detect())
);
// One forward serves both readouts: the training points' signs for
// the accuracy line and the grid twin for the surface chart.
let run = network.forward(¶meters, []);
let classified = run
.of(predicted)
.to_vec()
.iter()
.zip(&target_values)
.filter(|(prediction, target)| prediction.signum() == target.signum())
.count();
println!(
"classified {classified} of {} training points",
2 * MOON_LEN
);
let surface = run.of(surface_predicted).to_vec();
println!(
"{}",
Plot::new()
.layer(Cells::matrix(SURFACE_COLUMNS, surface).extents(
(f64::from(X_SPAN.0), f64::from(X_SPAN.1)),
(f64::from(Y_SPAN.0), f64::from(Y_SPAN.1)),
))
.colorbar()
.title("the learned decision surface")
.render_best(&Frame::detect())
);
}Readings and seams#
The research edge: a new AD mode and a new number type written outside the crate, graded bit for bit against the interpreter, and the backend ladder measured.
forward_mode
Forward mode as an out-of-tree AD mode: the first consumer of the spec read surface, and this file is the seam's proof.
Nothing here is library machinery. Dual is an ordinary
Recordable written against the public surface — forward-mode
knowledge is dual arithmetic, a payload, never a second rule
body — and the walker below replays a frozen spec with
Opcode::express. Over Dual<Tensor> the walk computes a
directional derivative eagerly; over Dual<Trace> it records
the tangent computation as ordinary spec (sources resolve into
the same family, so parameters are shared; interiors re-record
beside their tangents, and a lowered entry's dead-node
elimination drops whichever twins it does not name).
Three gradings, all bitwise at dyadic values:
- the eager JVP equals the recorded JVP run through the engine;
- on a rational spec, both equal the reverse gradient contracted with the seed;
- forward-over-reverse equals reverse-over-reverse on a Hessian-vector product, because the recorded gradient is just more spec.
eager and recorded jvp agree bitwise: -0.007744228143246899 forward slope equals <gradient, seed> bitwise: -3 hessian-vector product agrees across modes: [0.75, -1.5, -4.5]
cargo run --release --example forward_mode printed into a pipe.The source, examples/forward_mode.rs
use std::ops::{Add, Div, Mul, Neg, Sub};
use topos::{Detach, Network, Node, Numerics, Recordable, Shape, Symbol, Tape, Tensor, Trace};
/// A dual number over any recordable payload: the primal value and
/// the tangent riding along. The chain rule is the arithmetic.
#[derive(Clone, Debug)]
struct Dual<R> {
primal: R,
tangent: R,
}
impl<R: Recordable> Dual<R> {
/// Wraps a value carried unchanged through differentiation: a
/// constant's tangent is zero.
fn constant(primal: R) -> Self {
let tangent = primal.zero_like();
Self { primal, tangent }
}
}
impl<R: Recordable> Add for Dual<R> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self {
primal: self.primal + rhs.primal,
tangent: self.tangent + rhs.tangent,
}
}
}
impl<R: Recordable> Sub for Dual<R> {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Self {
primal: self.primal - rhs.primal,
tangent: self.tangent - rhs.tangent,
}
}
}
impl<R: Recordable> Mul for Dual<R> {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
Self {
primal: self.primal.clone() * rhs.primal.clone(),
tangent: self.primal * rhs.tangent + self.tangent * rhs.primal,
}
}
}
impl<R: Recordable> Div for Dual<R> {
type Output = Self;
fn div(self, rhs: Self) -> Self {
let primal = self.primal / rhs.primal.clone();
let tangent = (self.tangent - primal.clone() * rhs.tangent) / rhs.primal;
Self { primal, tangent }
}
}
impl<R: Recordable> Neg for Dual<R> {
type Output = Self;
fn neg(self) -> Self {
Self {
primal: -self.primal,
tangent: -self.tangent,
}
}
}
impl<R: Recordable> Recordable for Dual<R> {
fn shape(&self) -> Shape {
self.primal.shape()
}
fn zero_like(&self) -> Self {
Self::constant(self.primal.zero_like())
}
fn one_like(&self) -> Self {
Self::constant(self.primal.one_like())
}
fn exp(&self) -> Self {
let primal = self.primal.exp();
let tangent = primal.clone() * self.tangent.clone();
Self { primal, tangent }
}
fn ln(&self) -> Self {
Self {
primal: self.primal.ln(),
tangent: self.tangent.clone() / self.primal.clone(),
}
}
fn sqrt(&self) -> Self {
let primal = self.primal.sqrt();
let tangent = self.tangent.clone() / (primal.clone() + primal.clone());
Self { primal, tangent }
}
fn tanh(&self) -> Self {
let primal = self.primal.tanh();
let tangent = self.tangent.clone() * (primal.one_like() - primal.clone() * primal.clone());
Self { primal, tangent }
}
fn sin(&self) -> Self {
Self {
primal: self.primal.sin(),
tangent: self.tangent.clone() * self.primal.cos(),
}
}
fn cos(&self) -> Self {
Self {
primal: self.primal.cos(),
tangent: -(self.tangent.clone() * self.primal.sin()),
}
}
fn log1p(&self) -> Self {
Self {
primal: self.primal.log1p(),
tangent: self.tangent.clone() / (self.primal.one_like() + self.primal.clone()),
}
}
fn expm1(&self) -> Self {
let primal = self.primal.expm1();
let tangent = self.tangent.clone() * (primal.clone() + primal.one_like());
Self { primal, tangent }
}
fn erf(&self) -> Self {
Self {
primal: self.primal.erf(),
tangent: self.tangent.clone() * self.primal.erf_derivative(),
}
}
fn erf_derivative(&self) -> Self {
// The scaled Gaussian differentiates to `-2x` times itself.
let primal = self.primal.erf_derivative();
let tangent =
-((self.primal.clone() + self.primal.clone()) * primal.clone() * self.tangent.clone());
Self { primal, tangent }
}
fn powf(&self, exponent: Self) -> Self {
let primal = self.primal.powf(exponent.primal.clone());
let tangent = primal.clone()
* (exponent.tangent * self.primal.ln()
+ exponent.primal * (self.tangent.clone() / self.primal.clone()));
Self { primal, tangent }
}
fn maximum(&self, other: &Self) -> Self {
let winners = self.primal.step(&other.primal);
Self {
primal: self.primal.maximum(&other.primal),
tangent: winners.clone() * self.tangent.clone()
+ (winners.one_like() - winners) * other.tangent.clone(),
}
}
fn step(&self, threshold: &Self) -> Self {
Self::constant(self.primal.step(&threshold.primal))
}
fn matmul(&self, rhs: &Self) -> Self {
Self {
primal: self.primal.matmul(&rhs.primal),
tangent: self.tangent.matmul(&rhs.primal) + self.primal.matmul(&rhs.tangent),
}
}
fn sum(&self) -> Self {
Self {
primal: self.primal.sum(),
tangent: self.tangent.sum(),
}
}
fn sum_along(&self, axis: usize) -> Self {
Self {
primal: self.primal.sum_along(axis),
tangent: self.tangent.sum_along(axis),
}
}
fn logsumexp(&self, axis: usize) -> Self {
let primal = self.primal.logsumexp(axis);
let extent = self.primal.shape().axes()[axis];
// The softmax weights, from the stable primal itself.
let weights = (self.primal.clone() - primal.broadcast_along(axis, extent)).exp();
Self {
tangent: (weights * self.tangent.clone()).sum_along(axis),
primal,
}
}
fn log_softmax(&self, axis: usize) -> Self {
let primal = self.primal.log_softmax(axis);
let extent = self.primal.shape().axes()[axis];
let mass = self.tangent.sum_along(axis).broadcast_along(axis, extent);
Self {
tangent: self.tangent.clone() - primal.exp() * mass,
primal,
}
}
fn broadcast(&self, shape: Shape) -> Self {
Self {
primal: self.primal.broadcast(shape.clone()),
tangent: self.tangent.broadcast(shape),
}
}
fn broadcast_along(&self, axis: usize, extent: usize) -> Self {
Self {
primal: self.primal.broadcast_along(axis, extent),
tangent: self.tangent.broadcast_along(axis, extent),
}
}
fn reshape(&self, shape: Shape) -> Self {
Self {
primal: self.primal.reshape(shape.clone()),
tangent: self.tangent.reshape(shape),
}
}
fn permute(&self, order: &[usize]) -> Self {
Self {
primal: self.primal.permute(order),
tangent: self.tangent.permute(order),
}
}
fn narrow(&self, axis: usize, start: usize, len: usize) -> Self {
Self {
primal: self.primal.narrow(axis, start, len),
tangent: self.tangent.narrow(axis, start, len),
}
}
fn pad(&self, axis: usize, start: usize, full_extent: usize) -> Self {
Self {
primal: self.primal.pad(axis, start, full_extent),
tangent: self.tangent.pad(axis, start, full_extent),
}
}
fn unfold(&self, axis: usize, size: usize, step: usize, dilation: usize) -> Self {
Self {
primal: self.primal.unfold(axis, size, step, dilation),
tangent: self.tangent.unfold(axis, size, step, dilation),
}
}
fn fold(&self, axis: usize, size: usize, step: usize, dilation: usize, extent: usize) -> Self {
Self {
primal: self.primal.fold(axis, size, step, dilation, extent),
tangent: self.tangent.fold(axis, size, step, dilation, extent),
}
}
fn gather(&self, selection: &Self) -> Self {
// The selection is data, not a differentiable dependency.
Self {
primal: self.primal.gather(&selection.primal),
tangent: self.tangent.gather(&selection.primal),
}
}
fn scatter(&self, selection: &Self) -> Self {
Self {
primal: self.primal.scatter(&selection.primal),
tangent: self.tangent.scatter(&selection.primal),
}
}
}
/// Replays every node of a spec over dual payloads: `source`
/// supplies the sources' duals, and every computed node is one
/// `Opcode::express` over its operands' duals.
fn jvp_walk<R: Recordable>(
nodes: &[Node],
mut source: impl FnMut(&Node) -> Dual<R>,
) -> Vec<Dual<R>> {
let mut duals: Vec<Dual<R>> = Vec::new();
for node in nodes {
let dual = if node.is_source() {
source(node)
} else {
let operands: Vec<&Dual<R>> = node
.operands()
.iter()
.map(|symbol| &duals[symbol.index()])
.collect();
node.opcode().express(&operands)
};
duals.push(dual);
}
duals
}
/// The eager JVP: walks the spec over `Dual<Tensor>` and answers
/// the tangent at `read` for a `seed` planted at `wrt`.
fn jvp_eager(network: &Network<f64>, wrt: Symbol, seed: &Tensor<f64>, read: Symbol) -> Tensor<f64> {
let nodes: Vec<Node> = network.nodes().collect();
let duals = Numerics::exactly(|| {
jvp_walk(&nodes, |node| {
let primal = network
.payload(node.symbol())
.expect("sources hold payloads")
.clone();
let tangent = if node.symbol() == wrt {
seed.clone()
} else {
primal.zero_like()
};
Dual { primal, tangent }
})
});
duals[read.index()].tangent.clone()
}
fn assert_bitwise(expected: &Tensor<f64>, computed: &Tensor<f64>, subject: &str) {
let expected = expected.to_vec();
let computed = computed.to_vec();
assert_eq!(expected.len(), computed.len(), "{subject}: length differs");
for (expected, computed) in expected.iter().zip(&computed) {
assert_eq!(
expected.to_bits(),
computed.to_bits(),
"{subject}: {computed} differs from {expected}"
);
}
}
fn main() {
// Dyadic values keep every product and short sum exact, so the
// gradings below can demand bits, not tolerances (the
// transcendental part compares two runs of the same arithmetic,
// which is bitwise regardless).
// Part one: a transcendental spec, eager against recorded. The
// same dual arithmetic runs over `Dual<Tensor>` (computing) and
// `Dual<Trace>` (recording tangent spec beside resolved
// sources); the engine then runs the recorded form, and the two
// answers must agree bit for bit — the closure contract of an
// out-of-tree mode, exactly as `differentiate` is welded to
// `Run::backward`.
let (network, [weights, _inputs, loss]) = Tape::record(|tape| {
let weights = tape.parameter(Tensor::new(
[3, 2],
vec![0.5, -0.25, 0.125, 0.75, -0.5, 0.25],
));
let inputs = tape.input(Tensor::new([2, 3], vec![0.5, -1.0, 0.25, -0.75, 0.5, 1.25]));
let scores = inputs.matmul(weights).tanh().log_softmax(1);
let loss = (scores * scores).sum();
[weights, inputs, loss].detach()
});
let seed = Tensor::new([3, 2], vec![0.25, -0.5, 1.0, 0.125, -0.25, 0.5]);
let eager = jvp_eager(&network, weights, &seed, loss);
let nodes: Vec<Node> = network.nodes().collect();
let tape = network.into_tape();
let duals = jvp_walk(&nodes, |node| {
// Sources resolve into the same family — parameters are
// shared, not duplicated — and their tangents are fresh
// leaves: the seed at `wrt`, zero elsewhere.
let primal = Trace::of(tape.resolve(node.symbol()));
let payload = tape.payload(node.symbol()).expect("sources hold payloads");
let tangent_payload = if node.symbol() == weights {
seed.clone()
} else {
payload.zero_like()
};
let tangent = Trace::of(tape.leaf(tangent_payload));
Dual { primal, tangent }
});
let tangent_symbol = duals[loss.index()].tangent.value().symbol();
drop(duals);
let jvp_network = tape.into_network();
let recorded_run = jvp_network.forward(&jvp_network.parameters(), []);
assert_bitwise(
&eager,
recorded_run.of(tangent_symbol),
"recorded jvp against eager",
);
println!("eager and recorded jvp agree bitwise: {}", eager.scalar());
// Part two: on a rational spec the directional derivative is
// also the reverse gradient contracted with the seed, and at
// dyadic values every route computes the same rational number —
// so forward mode and reverse mode agree bit for bit.
let (cubic, [x, value]) = Tape::record(|tape| {
let x = tape.parameter(Tensor::new([3], vec![0.5, -0.25, 1.5]));
let value = (x * x * x).sum();
[x, value].detach()
});
let direction = Tensor::new([3], vec![0.25, 1.0, -0.5]);
let forward_slope = jvp_eager(&cubic, x, &direction, value);
let run = cubic.forward(&cubic.parameters(), []);
let gradients = run.backward(value);
let reverse_slope = Numerics::exactly(|| (gradients.of(x).clone() * direction.clone()).sum());
assert_bitwise(&forward_slope, &reverse_slope, "slope against the gradient");
println!(
"forward slope equals <gradient, seed> bitwise: {}",
forward_slope.scalar()
);
// Part three: the recorded gradient is just more spec, so
// forward mode walks straight over it — forward-over-reverse
// and reverse-over-reverse compute the same Hessian-vector
// product, bit for bit at dyadic values.
let tape: Tape<f64> = Tape::new();
let x = tape.parameter(Tensor::new([3], vec![0.5, -0.25, 1.5]));
let value = (x * x * x).sum();
let adjoints = tape.differentiate(value, [x]);
let &(_, gradient_symbol) = &adjoints.pairs()[0];
let direction_leaf = tape.leaf(direction.clone());
let hessian_adjoints = tape.vjp(gradient_symbol, direction_leaf, [x]);
let &(_, reverse_hvp_symbol) = &hessian_adjoints.pairs()[0];
let x = x.symbol();
let network = tape.into_network();
let run = network.forward(&network.parameters(), []);
let reverse_hvp = run.of(reverse_hvp_symbol);
let forward_hvp = jvp_eager(&network, x, &direction, gradient_symbol);
assert_bitwise(
reverse_hvp,
&forward_hvp,
"forward-over-reverse against reverse-over-reverse",
);
println!(
"hessian-vector product agrees across modes: {:?}",
forward_hvp.to_vec()
);
}dual
Dual numbers as a research element: forward mode by
reinterpreting the payloads, where examples/forward_mode.rs got
it by transforming the graph.
This file is the element seam doing what it is for. Dual is a
number — a value and how fast it changes — implementing
[Differentiable] and [Elementary] from outside the crate, and
nothing else changes: Tape<Dual> records the same primal graph
Tape<f64> records, shapes infer identically, and the unchanged
interpreter computes a directional derivative in every payload
slot as a side effect of ordinary arithmetic. Where
element_seam.rs proves the seam's hook is consulted, this
example proves the seam carries new semantics.
The grading is a triangle at dyadic values: the dual interpreter's tangent, the engine reverse scan, and the recorded gradient must agree bit for bit. (The fourth route — the forward-mode example's JVP over recorded payloads — is welded to the same reverse scan by its own asserts, so all four readings agree transitively.)
Deliberately not implemented: the backend hooks stay on their
None defaults (a dual GEMM kernel is a research question of its
own), and duals do not emit — no Emittable.
d/dw (w*0.25 - 1.5)^2 at w = 0.5: -0.6875 on all three routes d/dw (w*1.25 - 0.5)^2 at w = -0.75: -3.59375 on all three routes d/dw (w*-0.5 - -1.25)^2 at w = 2: -0.25 on all three routes forward mode by payload, reverse mode by scan: one answer
cargo run --release --example dual printed into a pipe.The source, examples/dual.rs
use std::ops::{Add, Div, Mul, Neg, Sub};
use topos::{Detach, Differentiable, Element, Elementary, Tape};
/// A dual number: the primal value and the tangent riding along —
/// how fast the value changes when the variable of differentiation
/// nudges. The chain rule is the arithmetic.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Dual {
pub primal: f64,
pub tangent: f64,
}
impl Dual {
/// A constant carried unchanged through differentiation: zero
/// tangent.
pub fn value(primal: f64) -> Self {
Self {
primal,
tangent: 0.0,
}
}
/// The variable of differentiation: unit tangent.
pub fn var(primal: f64) -> Self {
Self {
primal,
tangent: 1.0,
}
}
}
impl Add for Dual {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self {
primal: self.primal + rhs.primal,
tangent: self.tangent + rhs.tangent,
}
}
}
impl Sub for Dual {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Self {
primal: self.primal - rhs.primal,
tangent: self.tangent - rhs.tangent,
}
}
}
impl Mul for Dual {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
Self {
primal: self.primal * rhs.primal,
tangent: self.primal * rhs.tangent + self.tangent * rhs.primal,
}
}
}
impl Div for Dual {
type Output = Self;
fn div(self, rhs: Self) -> Self {
let primal = self.primal / rhs.primal;
Self {
tangent: (self.tangent - primal * rhs.tangent) / rhs.primal,
primal,
}
}
}
impl Neg for Dual {
type Output = Self;
fn neg(self) -> Self {
Self {
primal: -self.primal,
tangent: -self.tangent,
}
}
}
impl Differentiable for Dual {
// Duals accumulate in their own precision: promoting a term
// keeps its tangent, so an inner product's tangent is the sum of
// the product-rule terms — exactly the derivative of the sum.
type Accumulator = Self;
fn promote(&self) -> Self {
*self
}
fn demote(accumulated: Self) -> Self {
accumulated
}
fn zero() -> Self {
Self::value(0.0)
}
fn one() -> Self {
Self::value(1.0)
}
fn from_count(count: usize) -> Self {
Self::value(count as f64)
}
fn is_count(&self, count: usize) -> bool {
self.primal == count as f64 && self.tangent == 0.0
}
}
/// Every map is the chain rule on the primal's own kernel, called
/// through `Elementary` explicitly — `f64`'s *inherent* `exp` is the
/// standard library's, whose last bits vary by platform, while the
/// trait's rides `libm` — so the primal half of a dual run is
/// bit-identical to the `f64` run of the same graph.
impl Elementary for Dual {
fn exp(&self) -> Self {
let primal = Elementary::exp(&self.primal);
Self {
tangent: primal * self.tangent,
primal,
}
}
fn ln(&self) -> Self {
Self {
primal: Elementary::ln(&self.primal),
tangent: self.tangent / self.primal,
}
}
fn sqrt(&self) -> Self {
let primal = Elementary::sqrt(&self.primal);
Self {
tangent: self.tangent / (primal + primal),
primal,
}
}
fn tanh(&self) -> Self {
let primal = Elementary::tanh(&self.primal);
Self {
tangent: self.tangent * (1.0 - primal * primal),
primal,
}
}
fn sin(&self) -> Self {
Self {
primal: Elementary::sin(&self.primal),
tangent: self.tangent * Elementary::cos(&self.primal),
}
}
fn cos(&self) -> Self {
Self {
primal: Elementary::cos(&self.primal),
tangent: -(self.tangent * Elementary::sin(&self.primal)),
}
}
fn log1p(&self) -> Self {
Self {
primal: Elementary::log1p(&self.primal),
tangent: self.tangent / (1.0 + self.primal),
}
}
fn expm1(&self) -> Self {
let primal = Elementary::expm1(&self.primal);
Self {
tangent: self.tangent * (primal + 1.0),
primal,
}
}
fn erf(&self) -> Self {
Self {
primal: Elementary::erf(&self.primal),
tangent: self.tangent * Elementary::erf_derivative(&self.primal),
}
}
fn erf_derivative(&self) -> Self {
let primal = Elementary::erf_derivative(&self.primal);
Self {
tangent: self.tangent * (-2.0 * self.primal) * primal,
primal,
}
}
/// The dual of `x^y` on a positive base; elsewhere the `ln`
/// answers `NaN`, mirroring the operation's own documented
/// domain.
fn powf(&self, exponent: Self) -> Self {
let primal = Elementary::powf(&self.primal, exponent.primal);
Self {
tangent: primal
* (exponent.tangent * Elementary::ln(&self.primal)
+ exponent.primal * self.tangent / self.primal),
primal,
}
}
/// The primal wins by the `f64` rule; the tangent follows the
/// winner, ties to the left like `step`'s ties-answer-one.
fn maximum(&self, other: &Self) -> Self {
Self {
primal: Elementary::maximum(&self.primal, &other.primal),
tangent: if self.primal >= other.primal {
self.tangent
} else {
other.tangent
},
}
}
fn step(&self, threshold: &Self) -> Self {
// The indicator is piecewise constant: zero tangent.
Self::value(Elementary::step(&self.primal, &threshold.primal))
}
}
impl Element for Dual {}
fn main() {
// The README scalar chain, `loss = (w * x - y)^2`, at three
// dyadic points; the derivative with respect to `w` three ways.
// The points keep the error nonzero: at a zero error the two
// modes agree on the value but not the sign of zero (the
// product rule multiplies into `-0.0` where the reverse
// accumulation adds into `+0.0`), and the grading demands bits.
for (w0, x0, y0) in [(0.5, 0.25, 1.5), (-0.75, 1.25, 0.5), (2.0, -0.5, -1.25)] {
// The f64 twin carries both reverse-mode routes: the engine
// scan and the recorded gradient.
let tape: Tape<f64> = Tape::new();
let w = tape.parameter(w0);
let x = tape.input(x0);
let y = tape.input(y0);
let error = w * x - y;
let loss = error * error;
let adjoints = tape.differentiate(loss, [w]);
let (w, loss) = (w.symbol(), loss.symbol());
let network = tape.into_network();
let run = network.forward(&network.parameters(), []);
let engine = run.backward(loss).of(w).scalar();
let recorded = run.of(adjoints.pairs()[0].1).scalar();
// The dual twin records the identical primal spelling; the
// derivative is read off the loss payload's tangent after an
// ordinary forward run.
let (dual_network, [dual_loss]) = Tape::record(|tape| {
let w = tape.parameter(Dual::var(w0));
let x = tape.input(Dual::value(x0));
let y = tape.input(Dual::value(y0));
let error = w * x - y;
[error * error].detach()
});
let dual_run = dual_network.forward(&dual_network.parameters(), []);
let slope = dual_run.of(dual_loss).scalar();
assert_eq!(
slope.tangent.to_bits(),
engine.to_bits(),
"the dual tangent must be the engine gradient, bit for bit"
);
assert_eq!(
slope.tangent.to_bits(),
recorded.to_bits(),
"the dual tangent must be the recorded gradient, bit for bit"
);
println!(
"d/dw (w*{x0} - {y0})^2 at w = {w0}: {} on all three routes",
slope.tangent
);
}
println!("forward mode by payload, reverse mode by scan: one answer");
}element_seam
Plugs the element seam from outside the crate: a third number type joins the engine by implementing the element contracts, and nothing else changes.
The element here is Audited, an f64 that counts how often the
backend chain is offered a matrix multiplication through its gemm
hook and answers it with the published reference kernel. The point
is what the example does not contain: no tensor code, no
derivative rules, no engine hooks — unfold, backward, plans,
and the notebook all come along from the contracts alone.
This file proves the seam's hooks are consulted;
examples/dual.rs is the research consumer of the same seam — a
number whose semantics genuinely differ, computing forward-mode
derivatives through the unchanged interpreter.
Run with: cargo run --example element_seam
gemm hook consulted 1 times; product matches the reference bit for bit trained w = 2.000000 on the audited element (target 2)
cargo run --release --example element_seam printed into a pipe.The source, examples/element_seam.rs
use std::ops::{Add, Div, Mul, Neg, Sub};
use std::sync::atomic::{AtomicUsize, Ordering};
use topos::{Differentiable, Element, Elementary, GemmTask, Numerics, Tape, Tensor, reference};
/// How many gemm tasks the seam has been offered, across all threads.
static OFFERED: AtomicUsize = AtomicUsize::new(0);
/// An `f64` in audit mode: arithmetic delegates, and the acceleration
/// seam counts its offers before answering with the reference kernel,
/// so the example can prove the hook was consulted.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Audited(f64);
impl Add for Audited {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Audited(self.0 + rhs.0)
}
}
impl Sub for Audited {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Audited(self.0 - rhs.0)
}
}
impl Mul for Audited {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
Audited(self.0 * rhs.0)
}
}
impl Div for Audited {
type Output = Self;
fn div(self, rhs: Self) -> Self {
Audited(self.0 / rhs.0)
}
}
impl Neg for Audited {
type Output = Self;
fn neg(self) -> Self {
Audited(-self.0)
}
}
impl Differentiable for Audited {
type Accumulator = Self;
fn promote(&self) -> Self {
*self
}
fn demote(accumulated: Self) -> Self {
accumulated
}
fn zero() -> Self {
Audited(0.0)
}
fn one() -> Self {
Audited(1.0)
}
fn from_count(count: usize) -> Self {
Audited(count as f64)
}
fn is_count(&self, count: usize) -> bool {
self.0 == count as f64
}
}
impl Elementary for Audited {
fn exp(&self) -> Self {
Audited(Elementary::exp(&self.0))
}
fn ln(&self) -> Self {
Audited(Elementary::ln(&self.0))
}
fn sqrt(&self) -> Self {
Audited(Elementary::sqrt(&self.0))
}
fn tanh(&self) -> Self {
Audited(Elementary::tanh(&self.0))
}
fn sin(&self) -> Self {
Audited(Elementary::sin(&self.0))
}
fn cos(&self) -> Self {
Audited(Elementary::cos(&self.0))
}
fn log1p(&self) -> Self {
Audited(Elementary::log1p(&self.0))
}
fn expm1(&self) -> Self {
Audited(Elementary::expm1(&self.0))
}
fn erf(&self) -> Self {
Audited(Elementary::erf(&self.0))
}
fn erf_derivative(&self) -> Self {
Audited(Elementary::erf_derivative(&self.0))
}
fn powf(&self, exponent: Self) -> Self {
Audited(Elementary::powf(&self.0, exponent.0))
}
fn maximum(&self, other: &Self) -> Self {
Audited(self.0.max(other.0))
}
fn step(&self, threshold: &Self) -> Self {
Audited(if self.0 >= threshold.0 { 1.0 } else { 0.0 })
}
/// The acceleration seam: count the offer, then answer with the
/// published reference kernel — the same bits the built-in path
/// computes, which is what makes the differential test below
/// meaningful.
fn gemm(task: &GemmTask<'_, Self>) -> Option<Vec<Self>> {
OFFERED.fetch_add(1, Ordering::Relaxed);
Some(reference::multiply(task))
}
}
impl Element for Audited {}
fn main() {
// The differential test every in-crate element passes: the hook's
// product must be bit-identical to the built-in slice path. The
// `Exact` posture pins the built-in path to the reference bits, so
// the comparison is against the oracle, not a backend.
let elements: Vec<f64> = (0..64)
.map(|at| ((at * 7 % 23) as f64 - 11.0) / 4.0)
.collect();
let audited_left = Tensor::new(
[4, 16],
elements.iter().map(|&e| Audited(e)).collect::<Vec<_>>(),
);
let audited_right = Tensor::new(
[16, 4],
elements
.iter()
.map(|&e| Audited(e * 0.5))
.collect::<Vec<_>>(),
);
let product = audited_left.matmul(&audited_right);
let left = Tensor::new([4, 16], elements.clone());
let right = Tensor::new(
[16, 4],
elements.iter().map(|&e| e * 0.5).collect::<Vec<_>>(),
);
let oracle = Numerics::exactly(|| left.matmul(&right));
let bits: Vec<u64> = product.to_vec().iter().map(|e| e.0.to_bits()).collect();
let oracle_bits: Vec<u64> = oracle.to_vec().iter().map(|e| e.to_bits()).collect();
assert_eq!(bits, oracle_bits);
println!(
"gemm hook consulted {} times; product matches the reference bit for bit",
OFFERED.load(Ordering::Relaxed)
);
// The crate-root regression, recorded over the new element: the
// whole training loop runs on `Audited` without one extra line.
let tape: Tape<Audited> = Tape::new();
let w = tape.parameter(Audited(0.0));
let x = tape.input(Audited(0.0));
let y = tape.input(Audited(0.0));
let error = w * x - y;
let loss = error * error;
let (w, x, y, loss) = (w.symbol(), x.symbol(), y.symbol(), loss.symbol());
let network = tape.into_network();
let mut parameters = network.parameters();
let samples = [(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)];
for step in 0..100 {
let (sample_x, sample_y) = samples[step % samples.len()];
let run = network.forward(
¶meters,
[(x, Audited(sample_x).into()), (y, Audited(sample_y).into())],
);
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |w, g| {
w.clone() - g.clone() * Tensor::from(Audited(0.02))
});
}
let learned = parameters.of(w).scalar().0;
assert!((learned - 2.0).abs() < 1e-6);
println!("trained w = {learned:.6} on the audited element (target 2)");
}throughput
Measures training-step throughput on a deliberately wide dense model: the workload hardware backends exist for.
A [1024 features -> 2048 tanh -> 512] regression over batches of
1024 costs about 19 GFLOP of matrix products per training step —
every one of them large enough for the whole acceleration ladder,
so the same binary spans two orders of magnitude depending on the
features it was built with:
cargo run --release --example throughput
cargo run --release --features accelerate --example throughput
cargo run --release --features accelerate,metal --example throughputWithout any backend the dimensions shrink eightfold so the run still terminates; the printed GFLOP/s stays comparable.
Accelerate: the backend's cargo feature is off in this build Metal: the backend's cargo feature is off in this build Cuda: the backend's cargo feature is off in this build Simd: the backend's cargo feature is off in this build Fused: ready StableHlo: ready raw product 2048 x 2048: 23.5 GFLOP/s training step, batch 1024, 1024 -> 2048 -> 512: 4.110 s/step, 4.7 GFLOP/s of matrix products dispatch: Gemm F32 x6: reference dispatch: Map F32 x1: reference
cargo run --release --example throughput printed into a pipe.The source, examples/throughput.rs
use std::time::Instant;
use topos::{Backend, Shape, Tape, Tensor, init};
fn main() {
let mut accelerated = false;
for backend in Backend::ALL {
let status = backend.status();
accelerated |= status.is_ok();
match status {
Ok(()) => println!("{backend:?}: ready"),
Err(reason) => println!("{backend:?}: {reason}"),
}
}
// On the built-in paths alone, the full workload would take
// minutes per step; an eightfold shrink keeps the run honest
// and short while measuring the same code path.
let scale = if accelerated { 1 } else { 8 };
let batch_len = 1024 / scale;
let feature_len = 1024 / scale;
let hidden_len = 2048 / scale;
let class_len = 512 / scale;
let step_flops = (2 * batch_len * feature_len * hidden_len
+ 2 * batch_len * hidden_len * class_len) as f64
* 3.0;
let tape: Tape<f32> = Tape::new();
let mut initializer = init::kaiming(7);
let hidden_weights = tape.parameter(initializer(&Shape::new([feature_len, hidden_len])));
let output_weights = tape.parameter(initializer(&Shape::new([hidden_len, class_len])));
let features = tape.input(Tensor::filled([batch_len, feature_len], 0.0));
let targets = tape.input(Tensor::filled([batch_len, class_len], 0.0));
let hidden = features.matmul(hidden_weights).tanh();
let prediction = hidden.matmul(output_weights);
let error = prediction - targets;
let loss = (error * error).sum();
let (features, loss) = (features.symbol(), loss.symbol());
let network = tape.into_network();
let mut parameters = network.parameters();
let learning_rate = Tensor::new([], [0.0001_f32]);
let mut batch = init::normal(11, 1.0);
let batch = batch(&Shape::new([batch_len, feature_len]));
// Phase one: the raw product, the number the backend ladder is
// about. One `matmul` at this size is a single backend call.
let side = 2048 / scale;
let left = initializer(&Shape::new([side, side]));
let right = initializer(&Shape::new([side, side]));
let product_flops = 2.0 * (side as f64).powi(3);
let rounds = if accelerated { 20 } else { 2 };
let started = Instant::now();
for _ in 0..rounds {
std::hint::black_box(left.matmul(&right));
}
let per_product = started.elapsed().as_secs_f64() / rounds as f64;
println!(
"raw product {side} x {side}: {:.1} GFLOP/s",
product_flops / per_product / 1e9
);
// Phase two: whole training steps — the honest Amdahl statement.
// Once the products are accelerated, the elementwise operations
// (2M scalar `tanh` calls above all) own the step time; that gap
// is the roadmap's next tier, not the backends'. The steps run
// through an engine-backward plan: `Network::forward` is the
// exact proving road and never engages a backend, so the plan
// tier is where a throughput measurement belongs.
let plan = network.entry([loss]).backward().lower();
let warmup = 2;
let steps = 8;
let mut started = Instant::now();
for step in 0..warmup + steps {
if step == warmup {
started = Instant::now();
}
let run = plan.forward(¶meters, [(features, batch.clone())]);
let gradients = run.backward(loss).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
let elapsed = started.elapsed().as_secs_f64();
let per_step = elapsed / steps as f64;
println!(
"training step, batch {batch_len}, {feature_len} -> {hidden_len} -> {class_len}: \
{per_step:.3} s/step, {:.1} GFLOP/s of matrix products",
step_flops / per_step / 1e9
);
// Where one step's tasks landed: coverage declares what a backend
// may serve, the tally reports what each one did.
let (_, services) = Backend::tallied(|| {
let run = plan.forward(¶meters, [(features, batch.clone())]);
run.backward(loss)
});
for service in services {
let server = match service.backend {
Some(backend) => format!("{backend:?}"),
None => "reference".to_string(),
};
println!(
"dispatch: {:?} {:?} x{}: {server}",
service.formula, service.precision, service.count
);
}
}makemore, act by act#
Karpathy's classroom on a compiler: character-level language models on a list of names, from a bigram table to a small transformer, with the same act rewritten through facades, recorded gradients, Adam, data parallelism, batch norm, and emission.
makemore_bigram
Trains a character-level bigram language model on names — makemore's
opening act, before any MLP: one [vocab, vocab] table of logits
whose row i scores the character that follows token i.
The whole model is a handful of recorded nodes: a gather picks the
context rows out of the table (the one-hot matmul), and
cross_entropy scores them against the next characters. The
gather's scatter-add gradient touches exactly the rows a batch
visits — the differentiable mirror of bigram counting. Minibatches
arrive as per-run feeds and training steps the caller-owned
parameter state, so the graph never changes during training;
sampling reopens the sealed network to record the composite
softmax over the trained table. The run charts its loss curve against the bigram limit
and the learned transition matrix as a heatmap.
Run with: cargo run --release --example makemore_bigram
loaded 32033 names, 228146 bigram pairs
step 0: minibatch loss = 3.2974
step 100: minibatch loss = 2.5046
step 200: minibatch loss = 2.4835
step 300: minibatch loss = 2.5295
step 400: minibatch loss = 2.4520
step 500: minibatch loss = 2.4428
step 600: minibatch loss = 2.4174
step 700: minibatch loss = 2.5227
step 800: minibatch loss = 2.5051
step 900: minibatch loss = 2.4444
trained 1000 steps in 1.017s
the tape held 13 nodes through every step
bigram training
── minibatch ── rolling mean ── bigram limit
│▌
3.2 ┤█
│▐▖
l 3.0 ┤▐▙
o │▐▟▖
s 2.8 ┤ ▙▀▄
s │ ▜▖▝▖
│ █▄█▄
2.6 ┤ ▀███▄▙▄▄▄▖▟ ▖▌ ▖▗ ▖ ▗ ▖ ▗ ▖ ▖ ▖ ▖
│▄▄▄▄█▙████████████████▙██████████▟█▙██▙▙█████████▄▟██▟▙██▙▙██████▄▄██▟▙█▙
2.4 ┤ ▘ ▀▀▀▀▀▝▘▝▘▝▘▘▌▀ ▀▜▘▀▀▀▝▜▝▘▜▀▛▀▜▘▀▜▀▀█▐▀▐▝▌▀▀▜▜▀▘▜▀▀▛▘▜▜▜▜▛▘
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 100 200 300 400 500 600 700 800 900 1000
step
bigram transition probabilities
│░░░▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
25 ┤▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
│▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
│░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
│░░░▒▒░░░░░░░░▒▒░░░░░░░░▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █ 0.6
│░░░▒▒░░░░░░░░▒▒░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
│░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
20 ┤░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ █
c │░░░▒▒░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓
u │░░░▒▒░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓▓░░░░░░░░░░░░ ▓
r │░░░▒▒░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓▓░░░░░░░░░░░░ ▓
r │░░░▒▒░░░░░░░░░░░░░░░▒▒▒░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓
e 15 ┤▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓ 0.4
n │▒▒▒▒▒░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓
t │░░░▓▓░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▓
│░░░▒▒░░░░░░░░▒▒░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
t │░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
o 10 ┤░░░▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
k │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
e │▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
n │░░░░░░░░░░░░░▒▒░░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒ 0.2
│░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ▒
5 ┤▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░
│░░░▒▒░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░
│░░░▒▒░░░░░░░░▒▒░░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░
│░░░▒▒░░░░░░░░▒▒░░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░ ░
│▒▒▒░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░░░░░░░░░░░░░░ ░
│▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░
0 ┤░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ 0.0
└────────────────────────────────────────────────────────────────────
. a b c d e f g h i j k l m n o p q r s t u v w x y z
next token
sampled names:
h
tigele
jaysttisenin
ggsuaithy
h
kh
va
mre
ahassin
adealemiejonahirytecargo run --release --example makemore_bigram printed into a pipe.The source, examples/makemore/bigram.rs
mod chart;
mod corpus;
use std::time::Instant;
use malevich::{Cells, Frame, Plot, Scale};
use topos::{Shape, Tape, Tensor, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many bigram pairs each training step feeds.
const BATCH_LEN: usize = 1024;
fn main() {
let names = load_names();
let mut samples = training_samples::<1>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!(
"loaded {} names, {} bigram pairs",
names.len(),
samples.len()
);
let tape: Tape<f32> = Tape::new();
let table = tape.parameter(init::normal(7, 0.01)(&Shape::new([
VOCABULARY_LEN,
VOCABULARY_LEN,
])));
// Contexts and targets are one-hot selections fed per run; the
// defaults only fix the batch shape.
let contexts = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let logits = table.gather(contexts);
let loss = cross_entropy(logits, targets);
let table_symbol = table.symbol();
let contexts_symbol = contexts.symbol();
let targets_symbol = targets.symbol();
let loss_symbol = loss.symbol();
let network = tape.into_network();
let recorded_nodes = network.len();
// A fresh model is roughly uniform over the vocabulary, so the
// first printed loss should sit near `ln(27) ~ 3.30`; the bigram
// limit on this corpus is about `2.45`.
let learning_rate = Tensor::new([], [10.0]);
let mut parameters = network.parameters();
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..1000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch.iter().map(|&(context, _)| context[0]).collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
// Slice the run to the loss it reads.
let run = network.entry([loss_symbol]).interpret(
¶meters,
[
(
contexts_symbol,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets_symbol,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss_symbol).scalar();
losses.push(batch_loss);
if step % 100 == 0 {
println!("step {step:4}: minibatch loss = {batch_loss:.4}");
}
let gradients = run.backward(loss_symbol).parameters(¶meters);
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("bigram training", &losses));
// The trained logits exponentiate into transition probabilities
// through the composite softmax: reopen the sealed network, record
// one more expression, and carry the trained state across.
let tape = network.into_tape();
let probabilities = tape.resolve(table_symbol).softmax(1).symbol();
let network = tape.into_network();
let parameters = parameters.carried(&network);
// Slice to the freshly recorded softmax: the training expression
// does not re-run just to render the table.
let run = network
.entry([probabilities])
.interpret(¶meters, std::iter::empty());
let probabilities = run
.of(probabilities)
.as_slice()
.expect("a computed softmax is contiguous")
.to_vec();
// The trained table as a picture — the differentiable mirror of the
// classic makemore count matrix. Cell extents shift by half a cell
// so each cell centers on its token's band index, and the frame
// grows to give every one of the 27 rows its own terminal row.
let span = (-0.5, VOCABULARY_LEN as f64 - 0.5);
let letters = (0..VOCABULARY_LEN).map(|token| String::from(from_token(token)));
let mut frame = Frame::detect();
frame.height = VOCABULARY_LEN + 6;
println!(
"{}",
Plot::new()
.layer(Cells::matrix(VOCABULARY_LEN, &probabilities[..]).extents(span, span))
.colorbar()
.x_scale(Scale::bands(letters))
.title("bigram transition probabilities")
.x_label("next token")
.y_label("current token")
.render_best(&frame)
);
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut token = 0;
let mut name = String::new();
loop {
let row = &probabilities[token * VOCABULARY_LEN..(token + 1) * VOCABULARY_LEN];
token = draw(row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
}
println!(" {name}");
}
}makemore_mlp
Trains a character-level MLP language model on names — makemore's
second act, after the bigram: a three-character context embedded
into a small vector space and pushed through one tanh hidden layer
(Bengio et al., 2003), hand-rolled from raw parameters and graph
operations rather than the Mlp facade, so every moving part is on
the page. The makemore_mlp_facade example is the same model built
on the facade, and trains identically.
The tape carries two expressions of the same parameters: a batch-shaped one for training and a single-row twin for sampling, because input shapes are baked in at recording time. Minibatches arrive as per-run feeds, so the tape never grows during training.
Run with: cargo run --release --example makemore_mlp
loaded 32033 names, 228146 samples
step 0: minibatch loss = 3.4845 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5801
steps 500..1000: mean minibatch loss = 2.4296
steps 1000..1500: mean minibatch loss = 2.3882
steps 1500..2000: mean minibatch loss = 2.3544
steps 2000..2500: mean minibatch loss = 2.3312
steps 2500..3000: mean minibatch loss = 2.3320
steps 3000..3500: mean minibatch loss = 2.3033
steps 3500..4000: mean minibatch loss = 2.2815
steps 4000..4500: mean minibatch loss = 2.2517
steps 4500..5000: mean minibatch loss = 2.2450
trained 5000 steps in 2.183s
the tape held 37 nodes through every step
mlp training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▌
│▌
3.0 ┤█▖
l │██▄▄▟ ▖ ▖ ▗ ▖ ▗ ▗ ▗
o │▐███████▄▙▟▙█▙█▖▌▟█▄▖▟▟█▄▄▐▗▄▟▟▄ ▄▟ ▐▙▄▗▗▖▐▟▟▄▗▟▖ ▙▖▟▗ ▄▗ ▗▖▖ ▖▗▗▗ ▙
s 2.5 ┤▟████████████████████████████████████████▙███████▙█▙█████████▙███▟▟▟███▙█
s │ ▐▛▀▜████████████████████████████████████████████████████████████████████
│ ▀▘▌▝▜▜▛▜▛█▜█▀███▜██▜████████████████████████████████████████████████
2.0 ┤ ▘ ▀ ▘▜ ▀ ▘ ▝▘ ▜ ▐▝ ▘ ▌ ▘ ▜▝▝▘▀▜▘▌▀▘▜ █▛▀▜▌█▘▀▜▛█▛▀▀▛▜
│ ▝ ▝ ▝ ▘▘
1.5 ┤
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names:
haxie
jael
yutthoniel
fayvahmay
hark
vainre
alissin
aliah
keliscearoyuc
kenycargo run --release --example makemore_mlp printed into a pipe.The source, examples/makemore/mlp.rs
mod chart;
mod corpus;
use std::time::Instant;
use topos::{Shape, Tape, Tensor, Value, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// The model's parameters as recorded proxies: what the `Mlp` facade
/// would hold for us, laid out by hand.
struct Model<'tape> {
embeddings: Value<'tape, f32>,
hidden_weights: Value<'tape, f32>,
hidden_bias: Value<'tape, f32>,
output_weights: Value<'tape, f32>,
output_bias: Value<'tape, f32>,
}
impl<'tape> Model<'tape> {
/// Allocates the parameters on `tape`: an embedding table, one
/// tanh hidden layer, and an affine output layer, Xavier-scaled
/// with zero biases.
fn new(tape: &'tape Tape<f32>) -> Self {
let mut weights = init::xavier(7);
Self {
embeddings: tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
]))),
hidden_weights: tape
.parameter(weights(&Shape::new([CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN]))),
hidden_bias: tape.parameter(weights(&Shape::new([HIDDEN_LEN]))),
output_weights: tape.parameter(weights(&Shape::new([HIDDEN_LEN, VOCABULARY_LEN]))),
output_bias: tape.parameter(weights(&Shape::new([VOCABULARY_LEN]))),
}
}
/// Records the model's expression over `contexts` — a one-hot
/// `[rows * CONTEXT_LEN, vocab]` selection — and returns the
/// `[rows, vocab]` logits: embed, flatten the context window,
/// squash, and score.
fn express(&self, contexts: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let embedded = self
.embeddings
.gather(contexts)
.reshape([rows, CONTEXT_LEN * EMBED_DIM]);
let product = embedded.matmul(self.hidden_weights);
let hidden = (product + self.hidden_bias.broadcast_along_like(0, product)).tanh();
let product = hidden.matmul(self.output_weights);
product + self.output_bias.broadcast_along_like(0, product)
}
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression, batch-shaped: contexts and targets are
// one-hot selections fed per run, the defaults only fix the shapes.
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let loss = cross_entropy(model.express(contexts, BATCH_LEN), targets);
// The sampling twin: the same parameters expressed over a single
// context row, with the composite softmax on top.
let sample_context = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let sample_probabilities = model.express(sample_context, 1).softmax(1);
let (contexts, targets, loss, sample_context, sample_probabilities) = (
contexts.symbol(),
targets.symbol(),
loss.symbol(),
sample_context.symbol(),
sample_probabilities.symbol(),
);
let recorded_nodes = tape.len();
let network = tape.into_network();
let mut parameters = network.parameters();
// A fresh model is roughly uniform over the vocabulary, so the
// first printed loss should sit near `ln(27) ~ 3.30`; the goal is
// to push below the bigram limit of ~2.45.
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
// Slice the run to the loss: the sampling twin on the same
// tape is skipped during training.
let run = network.entry([loss]).interpret(
¶meters,
[
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("mlp training", &losses));
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = network.entry([sample_probabilities]).interpret(
¶meters,
[(
sample_context,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
)],
);
let row = run.of(sample_probabilities).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_mlp_facade
Trains the same character-level MLP as makemore_mlp, with the
hand-rolled layers replaced by the [Mlp] facade: the embedding
stage stays explicit (gather plus reshape, which the facade does
not cover), and the facade records the tanh hidden layer and the
affine output.
The seeds, the parameter allocation order, and the batches all match
makemore_mlp, and the facade records the same operations the
hand-rolled model does, so the two examples train identically: the
facade is packaging, not different math. Expressing the facade twice
— once batch-shaped for training, once single-row for sampling —
replaces the hand-rolled twin expression.
Run with: cargo run --release --example makemore_mlp_facade
loaded 32033 names, 228146 samples
step 0: minibatch loss = 3.4845 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5801
steps 500..1000: mean minibatch loss = 2.4296
steps 1000..1500: mean minibatch loss = 2.3882
steps 1500..2000: mean minibatch loss = 2.3544
steps 2000..2500: mean minibatch loss = 2.3312
steps 2500..3000: mean minibatch loss = 2.3320
steps 3000..3500: mean minibatch loss = 2.3033
steps 3500..4000: mean minibatch loss = 2.2815
steps 4000..4500: mean minibatch loss = 2.2517
steps 4500..5000: mean minibatch loss = 2.2450
trained 5000 steps in 2.178s
the tape held 37 nodes through every step
mlp (facade) training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▌
│▌
3.0 ┤█▖
l │██▄▄▟ ▖ ▖ ▗ ▖ ▗ ▗ ▗
o │▐███████▄▙▟▙█▙█▖▌▟█▄▖▟▟█▄▄▐▗▄▟▟▄ ▄▟ ▐▙▄▗▗▖▐▟▟▄▗▟▖ ▙▖▟▗ ▄▗ ▗▖▖ ▖▗▗▗ ▙
s 2.5 ┤▟████████████████████████████████████████▙███████▙█▙█████████▙███▟▟▟███▙█
s │ ▐▛▀▜████████████████████████████████████████████████████████████████████
│ ▀▘▌▝▜▜▛▜▛█▜█▀███▜██▜████████████████████████████████████████████████
2.0 ┤ ▘ ▀ ▘▜ ▀ ▘ ▝▘ ▜ ▐▝ ▘ ▌ ▘ ▜▝▝▘▀▜▘▌▀▘▜ █▛▀▜▌█▘▀▜▛█▛▀▀▛▜
│ ▝ ▝ ▝ ▘▘
1.5 ┤
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names:
haxie
jael
yutthoniel
fayvahmay
hark
vainre
alissin
aliah
keliscearoyuc
kenycargo run --release --example makemore_mlp_facade printed into a pipe.The source, examples/makemore/mlp_facade.rs
mod chart;
mod corpus;
use std::time::Instant;
use topos::{Activation, Mlp, Module, Shape, Tape, Tensor, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape: Tape<f32> = Tape::new();
// The embedding table stays a plain parameter; the facade covers
// the dense layers only. The allocation order and seeds match
// `makemore_mlp`, so both examples start from identical weights.
let embeddings = tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
])));
let mlp = Mlp::new(
&tape,
&[CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN, VOCABULARY_LEN],
Activation::Tanh,
init::xavier(7),
);
// The training expression, batch-shaped: contexts and targets are
// one-hot selections fed per run, the defaults only fix the shapes.
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let embedded = embeddings
.gather(contexts)
.reshape([BATCH_LEN, CONTEXT_LEN * EMBED_DIM]);
let loss = cross_entropy(mlp.express(embedded), targets);
// The sampling twin: the same parameters expressed over a single
// context row, with the composite softmax on top.
let sample_context = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let sample_embedded = embeddings
.gather(sample_context)
.reshape([1, CONTEXT_LEN * EMBED_DIM]);
let sample_probabilities = mlp.express(sample_embedded).softmax(1);
let (contexts, targets, loss, sample_context, sample_probabilities) = (
contexts.symbol(),
targets.symbol(),
loss.symbol(),
sample_context.symbol(),
sample_probabilities.symbol(),
);
let recorded_nodes = tape.len();
let network = tape.into_network();
let mut parameters = network.parameters();
// A fresh model is roughly uniform over the vocabulary, so the
// first printed loss should sit near `ln(27) ~ 3.30`; the goal is
// to push below the bigram limit of ~2.45.
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
// Slice the run to the loss: the sampling twin on the same
// tape is skipped during training.
let run = network.entry([loss]).interpret(
¶meters,
[
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("mlp (facade) training", &losses));
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = network.entry([sample_probabilities]).interpret(
¶meters,
[(
sample_context,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
)],
);
let row = run.of(sample_probabilities).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_mlp_compiled
Trains the makemore_mlp model with its backward pass recorded —
the chain rule as tape instead of engine procedure. The model, the
seeds, and the batch schedule are identical to makemore_mlp, so
the two examples train bit-identically; what changes is where the
gradients come from: Tape::differentiate appends them to the
tape once, one forward-only plan compiles [loss, gradients...]
together, and every training step is a single plan run — no
backward pass executes at all.
The recorded gradients are ordinary values, so everything the plan
tier offers applies to them unchanged: dead-node elimination drops
the sampling twin, buffer liveness frees intermediates the gradient
no longer needs, and Plan::describe() prints the whole chain rule
as a schedule.
Run with: cargo run --release --example makemore_mlp_compiled
loaded 32033 names, 228146 samples
recorded the chain rule: 37 forward nodes + 84 gradient nodes
step 0: minibatch loss = 3.4845 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5801
steps 500..1000: mean minibatch loss = 2.4296
steps 1000..1500: mean minibatch loss = 2.3882
steps 1500..2000: mean minibatch loss = 2.3544
steps 2000..2500: mean minibatch loss = 2.3312
steps 2500..3000: mean minibatch loss = 2.3320
steps 3000..3500: mean minibatch loss = 2.3033
steps 3500..4000: mean minibatch loss = 2.2815
steps 4000..4500: mean minibatch loss = 2.2517
steps 4500..5000: mean minibatch loss = 2.2450
trained 5000 steps in 2.168s, one plan run per step, no backward pass
compiled mlp training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▌
│▌
3.0 ┤█▖
l │██▄▄▟ ▖ ▖ ▗ ▖ ▗ ▗ ▗
o │▐███████▄▙▟▙█▙█▖▌▟█▄▖▟▟█▄▄▐▗▄▟▟▄ ▄▟ ▐▙▄▗▗▖▐▟▟▄▗▟▖ ▙▖▟▗ ▄▗ ▗▖▖ ▖▗▗▗ ▙
s 2.5 ┤▟████████████████████████████████████████▙███████▙█▙█████████▙███▟▟▟███▙█
s │ ▐▛▀▜████████████████████████████████████████████████████████████████████
│ ▀▘▌▝▜▜▛▜▛█▜█▀███▜██▜████████████████████████████████████████████████
2.0 ┤ ▘ ▀ ▘▜ ▀ ▘ ▝▘ ▜ ▐▝ ▘ ▌ ▘ ▜▝▝▘▀▜▘▌▀▘▜ █▛▀▜▌█▘▀▜▛█▛▀▀▛▜
│ ▝ ▝ ▝ ▘▘
1.5 ┤
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names:
haxie
jael
yutthoniel
fayvahmay
hark
vainre
alissin
aliah
keliscearoyuc
kenycargo run --release --example makemore_mlp_compiled printed into a pipe.The source, examples/makemore/mlp_compiled.rs
mod chart;
mod corpus;
use std::time::Instant;
use topos::{Shape, Tape, Tensor, Value, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// The model's parameters as recorded proxies, laid out exactly as in
/// `makemore_mlp` so the two examples share their seeds.
struct Model<'tape> {
embeddings: Value<'tape, f32>,
hidden_weights: Value<'tape, f32>,
hidden_bias: Value<'tape, f32>,
output_weights: Value<'tape, f32>,
output_bias: Value<'tape, f32>,
}
impl<'tape> Model<'tape> {
/// Allocates the parameters on `tape`: an embedding table, one
/// tanh hidden layer, and an affine output layer, Xavier-scaled
/// with zero biases.
fn new(tape: &'tape Tape<f32>) -> Self {
let mut weights = init::xavier(7);
Self {
embeddings: tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
]))),
hidden_weights: tape
.parameter(weights(&Shape::new([CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN]))),
hidden_bias: tape.parameter(weights(&Shape::new([HIDDEN_LEN]))),
output_weights: tape.parameter(weights(&Shape::new([HIDDEN_LEN, VOCABULARY_LEN]))),
output_bias: tape.parameter(weights(&Shape::new([VOCABULARY_LEN]))),
}
}
/// Returns the parameters in a fixed order, for pairing with their
/// recorded gradients.
fn parameters(&self) -> [Value<'tape, f32>; 5] {
[
self.embeddings,
self.hidden_weights,
self.hidden_bias,
self.output_weights,
self.output_bias,
]
}
/// Records the model's expression over `contexts` — a one-hot
/// `[rows * CONTEXT_LEN, vocab]` selection — and returns the
/// `[rows, vocab]` logits: embed, flatten the context window,
/// squash, and score.
fn express(&self, contexts: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let embedded = self
.embeddings
.gather(contexts)
.reshape([rows, CONTEXT_LEN * EMBED_DIM]);
let product = embedded.matmul(self.hidden_weights);
let hidden = (product + self.hidden_bias.broadcast_along_like(0, product)).tanh();
let product = hidden.matmul(self.output_weights);
product + self.output_bias.broadcast_along_like(0, product)
}
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression, batch-shaped: contexts and targets are
// one-hot selections fed per run, the defaults only fix the shapes.
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let loss = cross_entropy(model.express(contexts, BATCH_LEN), targets);
// The sampling twin: the same parameters expressed over a single
// context row, with the composite softmax on top.
let sample_context = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let sample_probabilities = model.express(sample_context, 1).softmax(1);
let (contexts, targets, loss, sample_context, sample_probabilities) = (
contexts.symbol(),
targets.symbol(),
loss.symbol(),
sample_context.symbol(),
sample_probabilities.symbol(),
);
// The whole difference from `makemore_mlp` is these lines: the
// chain rule is appended to the tape once, and one forward-only
// plan serves loss and gradients together for every step.
// The sampling twin is not among the targets, so the plan's
// dead-node elimination drops it from every training step.
let forward_nodes = tape.len();
let parameter_symbols = model.parameters().map(|parameter| parameter.symbol());
let adjoints = tape.differentiate(loss, parameter_symbols);
println!(
"recorded the chain rule: {} forward nodes + {} gradient nodes",
forward_nodes,
tape.len() - forward_nodes
);
let network = tape.into_network();
let mut parameters = network.parameters();
let plan = network.entry(adjoints.roots()).lower();
// A fresh model is roughly uniform over the vocabulary, so the
// first printed loss should sit near `ln(27) ~ 3.30`; matched
// seeds make every printed number equal `makemore_mlp`'s.
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
let run = plan.forward(
¶meters,
[
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
// The gradients were computed by the same plan run that
// computed the loss; assembling the update direction is a
// read, not a backward pass.
let gradients = run.recorded_gradients(&adjoints);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s, one plan run per step, no backward pass",
losses.len(),
training.elapsed().as_secs_f64()
);
println!("{}", loss_chart("compiled mlp training", &losses));
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = network.entry([sample_probabilities]).interpret(
¶meters,
[(
sample_context,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
)],
);
let row = run.of(sample_probabilities).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_mlp_adam
Trains the makemore_mlp model twice — once with plain SGD, once
with Adam — and draws both loss curves on one chart: the optimizer
act. The model, seeds, and batch schedule are identical to
makemore_mlp_compiled, gradients come from the same recorded
route (differentiate + one forward-only plan), and the SGD run
reproduces that example's losses bit for bit; the only new moving
part is which instrument the loop hands the gradients to.
Optimizers are caller-owned instruments: the loop owns the
learning-rate arithmetic and the parameter state it steps, and any
Optimizer implementation slots into the same line — the
comparison below iterates strategies through &mut dyn Optimizer,
the sanctioned example-side use of dynamic dispatch.
TOPOS_GRADIENTS=engine flips the gradient source to the
interpreter's backward, projected onto the parameter slots.
The losses are bit-identical either way (the parity contract),
and since gradients and Adam's moments became parameter-aligned
tables, so is the optimizer's memory: the engine route's dense
intermediate cotangents live only inside its backward pass, never
in the moments. (Before the grain split, engine-route moments
carried a dense payload per graph node — the difference a memory
monitor used to show here.)
Run with: cargo run --release --example makemore_mlp_adam
loaded 32033 names, 228146 samples
gradients: recorded (differentiate + one forward-only plan)
sgd 5000 steps in 2.164s (0.43 ms/step), last-500 mean loss 2.2450
adam 5000 steps in 2.216s (0.44 ms/step), last-500 mean loss 2.2287
sgd vs adam, rolling mean
── sgd ── adam ── bigram limit
3.50 ┤▌
│▌
3.25 ┤▌
l │█
o 3.00 ┤█
s │▐▙
s 2.75 ┤▝█▙▖
│ ▝▙▟▙▖
2.50 ┤▄▄▄▟▙███▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
│ ▝▀▀▀▀█▄██████████▙▄█▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖
2.25 ┤ ▝▀▀▘ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▛██████▙▙▄▄▟███
└┬──────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
stepcargo run --release --example makemore_mlp_adam printed into a pipe.The source, examples/makemore/mlp_adam.rs
// The shared corpus module also serves the sampling examples; this act
// never samples, so its `draw`/`from_token` stay unused here.
#[allow(dead_code)]
mod corpus;
use std::time::Instant;
use malevich::stat::Window;
use malevich::{Frame, Line, Plot, Rule};
use topos::{Adam, Optimizer, Sgd, Shape, Tape, Tensor, Value, cross_entropy, init};
use corpus::{VOCABULARY_LEN, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many training steps each optimizer runs.
const STEPS: usize = 5000;
/// The corpus's bigram limit, the line both curves aim below.
const BIGRAM_LIMIT: f64 = 2.45;
/// The model's parameters as recorded proxies, laid out exactly as in
/// `makemore_mlp_compiled` so the runs share their seeds.
struct Model<'tape> {
embeddings: Value<'tape, f32>,
hidden_weights: Value<'tape, f32>,
hidden_bias: Value<'tape, f32>,
output_weights: Value<'tape, f32>,
output_bias: Value<'tape, f32>,
}
impl<'tape> Model<'tape> {
/// Allocates the parameters on `tape`: an embedding table, one
/// tanh hidden layer, and an affine output layer, Xavier-scaled
/// with zero biases.
fn new(tape: &'tape Tape<f32>) -> Self {
let mut weights = init::xavier(7);
Self {
embeddings: tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
]))),
hidden_weights: tape
.parameter(weights(&Shape::new([CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN]))),
hidden_bias: tape.parameter(weights(&Shape::new([HIDDEN_LEN]))),
output_weights: tape.parameter(weights(&Shape::new([HIDDEN_LEN, VOCABULARY_LEN]))),
output_bias: tape.parameter(weights(&Shape::new([VOCABULARY_LEN]))),
}
}
/// Returns the parameters in a fixed order, for pairing with their
/// recorded gradients.
fn parameters(&self) -> [Value<'tape, f32>; 5] {
[
self.embeddings,
self.hidden_weights,
self.hidden_bias,
self.output_weights,
self.output_bias,
]
}
/// Records the model's expression over `contexts` — a one-hot
/// `[rows * CONTEXT_LEN, vocab]` selection — and returns the
/// `[rows, vocab]` logits: embed, flatten the context window,
/// squash, and score.
fn express(&self, contexts: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let embedded = self
.embeddings
.gather(contexts)
.reshape([rows, CONTEXT_LEN * EMBED_DIM]);
let product = embedded.matmul(self.hidden_weights);
let hidden = (product + self.hidden_bias.broadcast_along_like(0, product)).tanh();
let product = hidden.matmul(self.output_weights);
product + self.output_bias.broadcast_along_like(0, product)
}
}
/// Trains a fresh model for `STEPS` under `optimizer`, with the
/// per-step learning rate from `learning_rate`, and returns the
/// per-step losses and the wall-clock seconds. Every run rebuilds the
/// same seeds, so trajectories differ only by the optimizer.
fn train(
samples: &[([usize; CONTEXT_LEN], usize)],
recorded: bool,
optimizer: &mut dyn Optimizer<f32>,
learning_rate: impl Fn(usize) -> Tensor<f32>,
) -> (Vec<f32>, f64) {
let tape = Tape::new();
let model = Model::new(&tape);
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let loss = cross_entropy(model.express(contexts, BATCH_LEN), targets);
let (contexts, targets, loss) = (contexts.symbol(), targets.symbol(), loss.symbol());
let parameter_symbols = model.parameters().map(|parameter| parameter.symbol());
// The recorded route: the chain rule on the tape, one forward-only
// plan per run. The engine route skips both and differentiates
// each interpreter run procedurally, projecting each complete
// field onto the parameter slots — bit-identical losses, and the
// same parameter-aligned gradients either way.
let adjoints = recorded.then(|| tape.differentiate(loss, parameter_symbols));
let network = tape.into_network();
let plan = adjoints
.as_ref()
.map(|adjoints| network.entry(adjoints.roots()).lower());
let mut parameters = network.parameters();
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..STEPS {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
let feeds = [
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
];
let (batch_loss, gradients) = if let (Some(plan), Some(adjoints)) = (&plan, &adjoints) {
let run = plan.forward(¶meters, feeds);
let gradients = run.recorded_gradients(adjoints);
(run.of(loss).scalar(), gradients)
} else {
let run = network.forward(¶meters, feeds);
(
run.of(loss).scalar(),
run.backward(loss).parameters(¶meters),
)
};
losses.push(batch_loss);
parameters = optimizer.step(¶meters, &gradients, &learning_rate(step));
}
(losses, training.elapsed().as_secs_f64())
}
/// Renders both rolling-mean curves on one chart, with the bigram
/// limit as the line to beat.
fn comparison_chart(sgd: &[f32], adam: &[f32]) -> String {
let window_len = (STEPS / 20).max(2);
let smooth = |losses: &[f32]| {
let losses: Vec<f64> = losses.iter().copied().map(f64::from).collect();
Window::new(window_len).mean(&losses)
};
let sgd = smooth(sgd);
let adam = smooth(adam);
Plot::new()
.layer(Line::y(&sgd[..]).label("sgd"))
.layer(Line::y(&adam[..]).label("adam"))
.layer(Rule::h(BIGRAM_LIMIT).label("bigram limit"))
.title("sgd vs adam, rolling mean")
.x_label("step")
.y_label("loss")
.render_best(&Frame::detect())
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let recorded = match std::env::var("TOPOS_GRADIENTS").as_deref() {
Ok("engine") => false,
Ok("recorded") | Err(_) => true,
Ok(other) => panic!("unknown TOPOS_GRADIENTS {other:?}; use recorded or engine"),
};
println!(
"gradients: {}",
if recorded {
"recorded (differentiate + one forward-only plan)"
} else {
"engine (interpreter backward)"
}
);
// SGD keeps `makemore_mlp_compiled`'s schedule, so its losses
// reproduce that example bit for bit; Adam runs at one flat rate —
// the adaptive moments replace the hand-tuned decay.
let fast = Tensor::new([], [0.1_f32]);
let slow = Tensor::new([], [0.01_f32]);
let (sgd_losses, sgd_seconds) = train(&samples, recorded, &mut Sgd, |step| {
if step < 4000 {
fast.clone()
} else {
slow.clone()
}
});
let mut adam = Adam::new(
Tensor::new([], [0.9_f32]),
Tensor::new([], [0.999_f32]),
Tensor::new([], [1e-8_f32]),
);
let flat = Tensor::new([], [0.005_f32]);
let (adam_losses, adam_seconds) = train(&samples, recorded, &mut adam, |_| flat.clone());
for (label, losses, seconds) in [
("sgd", &sgd_losses, sgd_seconds),
("adam", &adam_losses, adam_seconds),
] {
let window: f32 = losses[STEPS - 500..].iter().sum::<f32>() / 500.0;
println!(
"{label:5} {STEPS} steps in {seconds:.3}s ({:.2} ms/step), last-500 mean loss {window:.4}",
seconds * 1000.0 / STEPS as f64
);
}
println!("{}", comparison_chart(&sgd_losses, &adam_losses));
}makemore_mlp_parallel
Trains the same character-level MLP as makemore_mlp_facade, data
parallel: every step splits its minibatch into equal shards, runs
one forward and backward pass per shard concurrently on the shared
network, and averages the shard gradients into a single update.
This leans on the engine's concurrency contract: the sealed network
is immutable, feeds are run state rather than graph state, and each
shard's backward projects onto the shared parameter slots
(Field::parameters), so gradients from concurrent runs over one
parameter state combine with the Parameters algebra — summing
slot-sized tables, never graph-sized buffers. Because cross_entropy
normalizes each shard by its own mass, the average of equal-sized
shard gradients equals the full-batch gradient exactly, and summing
the shards in a fixed pairwise tree keeps the run deterministic
regardless of thread scheduling.
Run with: cargo run --release --example makemore_mlp_parallel
loaded 32033 names, 228146 samples
step 0: minibatch loss = 3.4845 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5801
steps 500..1000: mean minibatch loss = 2.4296
steps 1000..1500: mean minibatch loss = 2.3882
steps 1500..2000: mean minibatch loss = 2.3544
steps 2000..2500: mean minibatch loss = 2.3312
steps 2500..3000: mean minibatch loss = 2.3320
steps 3000..3500: mean minibatch loss = 2.3033
steps 3500..4000: mean minibatch loss = 2.2815
steps 4000..4500: mean minibatch loss = 2.2517
steps 4500..5000: mean minibatch loss = 2.2450
trained 5000 steps on 8 shards in 1.433s
the tape held 37 nodes through every step
mlp (data parallel) training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▌
│▌
3.0 ┤█▖
l │██▄▄▟ ▖ ▖ ▗ ▖ ▗ ▗ ▗
o │▐███████▄▙▟▙█▙█▖▌▟█▄▖▟▟█▄▄▐▗▄▟▟▄ ▄▟ ▐▙▄▗▗▖▐▟▟▄▗▟▖ ▙▖▟▗ ▄▗ ▗▖▖ ▖▗▗▗ ▙
s 2.5 ┤▟████████████████████████████████████████▙███████▙█▙█████████▙███▟▟▟███▙█
s │ ▐▛▀▜████████████████████████████████████████████████████████████████████
│ ▀▘▌▝▜▜▛▜▛█▜█▀███▜██▜████████████████████████████████████████████████
2.0 ┤ ▘ ▀ ▘▜ ▀ ▘ ▝▘ ▜ ▐▝ ▘ ▌ ▘ ▜▝▝▘▀▜▘▌▀▘▜ █▛▀▜▌█▘▀▜▛█▛▀▀▛▜
│ ▝ ▝ ▝ ▘▘
1.5 ┤
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names:
haxie
jael
yutthoniel
fayvahmay
hark
vainre
alissin
aliah
keliscearoyuc
kenycargo run --release --example makemore_mlp_parallel printed into a pipe.The source, examples/makemore/mlp_parallel.rs
mod chart;
mod corpus;
use std::time::Instant;
use rayon::prelude::*;
use topos::{Activation, Mlp, Module, Parameters, Shape, Tape, Tensor, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many concurrent shards each training step fans out.
///
/// The count is fixed rather than detected — the shard partition
/// decides the arithmetic, so a detected count would make runs differ
/// across machines. Rayon still schedules the fixed shards over every
/// core it has. The count trades per-run fixed cost (favoring fewer,
/// larger shards) against load balancing over heterogeneous cores
/// (favoring more, smaller ones); eight-by-eight measured fastest on an
/// eight-performance-core machine, where sixteen-by-four raised
/// utilization but paid more in run overhead than it recovered.
const SHARD_COUNT: usize = 8;
/// How many samples each shard carries; the effective batch is
/// `SHARD_COUNT * SHARD_LEN`, matching the serial examples' 64.
const SHARD_LEN: usize = 8;
/// Sums shard gradients as a pairwise tree whose shape depends only on
/// the shard count: the reduction runs its pairs concurrently and
/// finishes in logarithmic depth, while the tree — not the scheduler —
/// decides the order of additions, keeping the result deterministic.
fn tree_sum(mut layer: Vec<Parameters<f32>>) -> Parameters<f32> {
while layer.len() > 1 {
layer = layer
.par_chunks(2)
.map(|pair| match pair {
[left, right] => left + right,
[single] => single.clone(),
_ => unreachable!("chunks of two hold one or two tables"),
})
.collect();
}
layer.into_iter().next().expect("at least one shard ran")
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape: Tape<f32> = Tape::new();
// The same model as the serial examples, recorded at shard shape:
// the batch size is baked into the graph, so the parallel plan is
// one shard-shaped expression run once per shard, not a wider one.
let embeddings = tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
])));
let mlp = Mlp::new(
&tape,
&[CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN, VOCABULARY_LEN],
Activation::Tanh,
init::xavier(7),
);
let contexts = tape.input(Tensor::selection(
vec![0; SHARD_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; SHARD_LEN], VOCABULARY_LEN, 1.0));
let embedded = embeddings
.gather(contexts)
.reshape([SHARD_LEN, CONTEXT_LEN * EMBED_DIM]);
let loss = cross_entropy(mlp.express(embedded), targets);
// The sampling twin: the same parameters expressed over a single
// context row, with the composite softmax on top.
let sample_context = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let sample_embedded = embeddings
.gather(sample_context)
.reshape([1, CONTEXT_LEN * EMBED_DIM]);
let sample_probabilities = mlp.express(sample_embedded).softmax(1);
let contexts_symbol = contexts.symbol();
let targets_symbol = targets.symbol();
let loss_symbol = loss.symbol();
let sample_context_symbol = sample_context.symbol();
let sample_probabilities_symbol = sample_probabilities.symbol();
let network = tape.into_network();
let recorded_nodes = network.len();
let batch_len = SHARD_COUNT * SHARD_LEN;
let shard_inverse = Tensor::new([], [1.0 / SHARD_COUNT as f32]);
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut parameters = network.parameters();
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * batch_len) % (samples.len() - batch_len);
let batch = &samples[start..start + batch_len];
// Fan out: one immutable forward and backward run per shard,
// all reading the same network and parameter state.
let shard_results: Vec<(f32, Parameters<f32>)> = (0..SHARD_COUNT)
.into_par_iter()
.map(|shard| {
let rows = &batch[shard * SHARD_LEN..(shard + 1) * SHARD_LEN];
let shard_contexts: Vec<usize> = rows
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let shard_targets: Vec<usize> = rows.iter().map(|&(_, next)| next).collect();
// Slice the run to the loss: the sampling twin on the
// same tape is skipped during training.
let run = network.entry([loss_symbol]).interpret(
¶meters,
[
(
contexts_symbol,
Tensor::selection(shard_contexts, VOCABULARY_LEN, 1.0),
),
(
targets_symbol,
Tensor::selection(shard_targets, VOCABULARY_LEN, 1.0),
),
],
);
let shard_loss = run.of(loss_symbol).scalar();
(
shard_loss,
run.backward(loss_symbol).parameters(¶meters),
)
})
.collect();
let (shard_losses, shard_gradients): (Vec<f32>, Vec<Parameters<f32>>) =
shard_results.into_iter().unzip();
let batch_loss = shard_losses.iter().sum::<f32>() / SHARD_COUNT as f32;
let gradients = tree_sum(shard_gradients)
.map(|gradient| gradient.clone() * shard_inverse.broadcast_like(gradient));
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps on {SHARD_COUNT} shards in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("mlp (data parallel) training", &losses));
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = network.entry([sample_probabilities_symbol]).interpret(
¶meters,
[(
sample_context_symbol,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
)],
);
let row = run.of(sample_probabilities_symbol).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_mlp_batchnorm
Trains the character-level MLP with batch normalization — makemore's third act: the hidden preactivation is standardized by its minibatch statistics before the tanh, so the squash stays in its active range whatever the initialization does. The hidden layer loses its bias (the norm's learned shift replaces it), and the norm's scale and shift train like any other parameters.
The two-tape idiom carries the norm's two modes: the training
expression normalizes by the batch's own statistics
([BatchNorm::express]) and the single-row sampling twin
normalizes by running estimates fed per run
([BatchNorm::express_with]). The running estimates live in the
training loop as plain payloads, maintained as an exponential
moving average of the batch statistics — which the loop reads
through the training plan's keep-set, the declared-observability
contract doing exactly what it was built for.
Run with: cargo run --release --example makemore_mlp_batchnorm
loaded 32033 names, 228146 samples
step 0: minibatch loss = 3.6266 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5976
steps 500..1000: mean minibatch loss = 2.4081
steps 1000..1500: mean minibatch loss = 2.3733
steps 1500..2000: mean minibatch loss = 2.3443
steps 2000..2500: mean minibatch loss = 2.3252
steps 2500..3000: mean minibatch loss = 2.3262
steps 3000..3500: mean minibatch loss = 2.3039
steps 3500..4000: mean minibatch loss = 2.2845
steps 4000..4500: mean minibatch loss = 2.2578
steps 4500..5000: mean minibatch loss = 2.2485
trained 5000 steps in 2.717s
the tape held 66 nodes through every step
mlp + batchnorm training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▌
│▌
│█
l 3.0 ┤█▙
o │▜█▟▄▐ ▖ ▖ ▗ ▖ ▗
s │▐████▙██▄▙▄▌█▄▟▖▌▐█▄ ▟▟▄▄▄▐▗▗▄▐▄ ▟ ▐▙▗▐▗ ▗▐▄▐ ▐▖ ▄ ▐▗ ▖▗ ▗ ▖ ▖▗▗▗ ▄
s 2.5 ┤▄███████████████████████████████▙██▙███▟█▙███████▙█▙████▟▟▟██▙█▄█▟▟▟▟▟█▙█
│ ▐▛██████████████████████████████████████████████████████████████████████
│ ▀██▛█████████████▜███████████████████████████████████████████████████
2.0 ┤ ▝▝▌▝▌▀▝▜ ▐▘█▝▝▛▝▀▘▝▀▜▛▀▜▀▜▜▀ ▀▀▌▀▀▘▝█▜▜▛█▜▛▛▜▛█▛██▜█▛█▛█████▀█▛█
│ ▝ ▘ ▘ ▝▝ ▝ ▘ ▝ ▜ ▝▘▀ ▝▌▌ ▝
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names (running statistics fed per draw):
havigah
alaysriir
lor
fayvaithu
hani
vainph
alisshel
deilendelrigennyse
keny
gkinblecargo run --release --example makemore_mlp_batchnorm printed into a pipe.The source, examples/makemore/mlp_batchnorm.rs
mod chart;
mod corpus;
use std::time::Instant;
use topos::{BatchNorm, Shape, Tape, Tensor, Value, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// The exponential-moving-average momentum of the running statistics:
/// each step keeps `1 - MOMENTUM` of the estimate and takes `MOMENTUM`
/// of the batch statistic.
const MOMENTUM: f32 = 0.01;
/// The model's parameters as recorded proxies: the part-2 layout with
/// the hidden bias replaced by a batch-normalization stage.
struct Model<'tape> {
embeddings: Value<'tape, f32>,
hidden_weights: Value<'tape, f32>,
norm: BatchNorm<f32>,
output_weights: Value<'tape, f32>,
output_bias: Value<'tape, f32>,
}
impl<'tape> Model<'tape> {
/// Allocates the parameters on `tape`: the embedding table, the
/// bias-free hidden layer, the norm's scale and shift (ones and
/// zeros, the standard start), and the affine output.
fn new(tape: &'tape Tape<f32>) -> Self {
let mut weights = init::xavier(7);
Self {
embeddings: tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
]))),
hidden_weights: tape
.parameter(weights(&Shape::new([CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN]))),
norm: BatchNorm::new(
tape,
Tensor::filled([HIDDEN_LEN], 1.0),
Tensor::filled([HIDDEN_LEN], 0.0),
Tensor::filled([], 1e-5),
),
output_weights: tape.parameter(weights(&Shape::new([HIDDEN_LEN, VOCABULARY_LEN]))),
output_bias: tape.parameter(weights(&Shape::new([VOCABULARY_LEN]))),
}
}
/// Records the shared head of both expressions: embed, flatten,
/// and the bias-free hidden preactivation.
fn preactivation(&self, contexts: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
self.embeddings
.gather(contexts)
.reshape([rows, CONTEXT_LEN * EMBED_DIM])
.matmul(self.hidden_weights)
}
/// Records the shared tail: squash the normalized preactivation
/// and score.
fn logits(&self, normalized: Value<'tape, f32>) -> Value<'tape, f32> {
let hidden = normalized.tanh();
let product = hidden.matmul(self.output_weights);
product + self.output_bias.broadcast_along_like(0, product)
}
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression: the norm standardizes each feature by
// the minibatch's own statistics, and hands them back for the
// running estimates.
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let normalization = model.norm.express(model.preactivation(contexts, BATCH_LEN));
let loss = cross_entropy(model.logits(normalization.output), targets);
// The sampling twin normalizes by running estimates fed per run:
// the norm's inference mode, one recorded expression for every
// state of the estimates.
let sample_context = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let running_mean = tape.input(Tensor::filled([HIDDEN_LEN], 0.0));
let running_variance = tape.input(Tensor::filled([HIDDEN_LEN], 1.0));
let sample_normalized = model.norm.express_with(
model.preactivation(sample_context, 1),
running_mean,
running_variance,
);
let sample_probabilities = model.logits(sample_normalized).softmax(1);
let (contexts, targets, loss) = (contexts.symbol(), targets.symbol(), loss.symbol());
let (mean, variance) = (normalization.mean.symbol(), normalization.variance.symbol());
let (sample_context, running_mean, running_variance, sample_probabilities) = (
sample_context.symbol(),
running_mean.symbol(),
running_variance.symbol(),
sample_probabilities.symbol(),
);
let recorded_nodes = tape.len();
let network = tape.into_network();
let mut parameters = network.parameters();
// Entry once: the training plan keeps the batch statistics
// readable — the keep-set naming exactly what the loop reads —
// and the sampling plan is forward-only.
let training_plan = network
.entry([loss])
.observe([mean, variance])
.backward()
.lower();
let sampling_plan = network.entry([sample_probabilities]).lower();
// The running estimates: loop-owned payloads, never engine state.
let mut mean_estimate = Tensor::filled([HIDDEN_LEN], 0.0_f32);
let mut variance_estimate = Tensor::filled([HIDDEN_LEN], 1.0_f32);
let keep = Tensor::filled([HIDDEN_LEN], 1.0 - MOMENTUM);
let take = Tensor::filled([HIDDEN_LEN], MOMENTUM);
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
let run = training_plan.forward(
¶meters,
[
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
// The running estimates fold in this batch's statistics, read
// through the keep-set: payload arithmetic in loop land.
let batch_mean = run.of(mean).clone();
let batch_variance = run.of(variance).clone();
mean_estimate = mean_estimate * keep.clone() + batch_mean * take.clone();
variance_estimate = variance_estimate * keep.clone() + batch_variance * take.clone();
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("mlp + batchnorm training", &losses));
println!("sampled names (running statistics fed per draw):");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = sampling_plan.forward(
¶meters,
[
(
sample_context,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
),
(running_mean, mean_estimate.clone()),
(running_variance, variance_estimate.clone()),
],
);
let row = run.of(sample_probabilities).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_embedding_map
Trains the Bengio-style MLP with a two-dimensional character
embedding and draws the embedding space right in the terminal,
letters as malevich text marks — the terminal edition of the
classic makemore scatter plot.
Two dimensions cost some loss against the ten-dimensional examples; they buy a picture. The map prints twice — the seeded blob before training and the organized space after — so the structure the gradient carves out (watch the vowels drift together) is visible as a before-and-after.
Run with: cargo run --release --example makemore_embedding_map
loaded 32033 names, 228146 samples
embedding space before training (the seeded blob)
2.0 ┤
│ q
│
1.5 ┤ r
│ s g
1.0 ┤ z m p
│
0.5 ┤
│ w k
0.0 ┤
│ j b f l
│ ti u x
-0.5 ┤
│ e
-1.0 ┤ n v
│ c h
-1.5 ┤ . a o
│ d
-2.0 ┤
│ y
│
└┬──────┬─────────┬─────────┬────────┬─────────┬─────────┬─────────┬───────
-2.5 -2.0 -1.5 -1.0 -0.5 0.0 0.5 1.0 1.5
steps 0..1000: mean minibatch loss = 2.6094
steps 1000..2000: mean minibatch loss = 2.5049
steps 2000..3000: mean minibatch loss = 2.4720
steps 3000..4000: mean minibatch loss = 2.4366
steps 4000..5000: mean minibatch loss = 2.4117
trained 5000 steps in 1.904s
embedding map training
── minibatch ── rolling mean ── bigram limit
3.5 ┤▖
│▌
│█▖
l 3.0 ┤█▙ ▌ ▗ ▗
o │▐██▙█▌▄▟ ▄▗▗▖ ▙ ▗▟▗ ▖▗▖ ▗ ▗ ▐▐ ▗ ▗ ▗ ▖ ▗ ▖
s │▐███████████████████▖█▟▙███▄▟██▄▄▐█▄▟▙▄▗▐ ▐▟▗█▗▟▌▖█▖▄▟▄▗▟▗▗▙▖▌▄▖▙▐▐▗▗▗▙▖
s │ ████████████████████████████████████████████████▙███████████████▟██▟████
2.5 ┤▄▟███████████████████████████████████████████████████████████████████████
│ ▘ ▀█▛▛▜██▀████▀███████████████████████████████████████████████████████
│ ▝▘▌ ▘▝▛█ ▀▀██▀█ ▘▀ ▘▝▀▘▜▀▜▜▝▀▘▀▌▝▛▝▝█▜▜▜▜▀▀▛▀▛████▜█▛█▛▛███▛▀▜▛█
2.0 ┤ ▝ ▝ ▝ ▌ ▝ ▝▘ ▘▘ ▝▌▛ ▝▘▘
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
embedding space after training (vowels highlighted)
2 ┤
│ q
│
│
│ l
1 ┤ j z m v
│ g p r
│
│ k w d
0 ┤ s t f
│ c b x
│ h
│ n
-1 ┤
│
│ iy
│ . u
-2 ┤
│ e
│ a o
│
└─┬───────┬────────┬───────┬────────┬───────┬────────┬───────┬────────┬──────
-3.0 -2.5 -2.0 -1.5 -1.0 -0.5 0.0 0.5 1.0 1.5cargo run --release --example makemore_embedding_map printed into a pipe.The source, examples/makemore/embedding_map.rs
mod chart;
#[allow(dead_code)]
mod corpus;
use std::time::Instant;
use malevich::{Color, Frame, Plot, Text};
use topos::{Activation, Mlp, Module, Shape, Tape, Tensor, cross_entropy, init};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has: two, so the
/// space is the page.
const EMBED_DIM: usize = 2;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// Spreads `points` so no two share a cell of a `columns` by `rows`
/// grid laid over their bounding box: a point whose cell is taken moves
/// to the nearest free cell within a small ring. A later text mark
/// would otherwise overwrite an earlier one where the space clusters —
/// which is exactly where the interesting letters are — so the chart
/// accepts a slight positional lie to keep every letter visible. A flat
/// axis widens to a unit span so a degenerate cloud still spreads.
fn spread(points: &mut [(f64, f64)], columns: usize, rows: usize) {
let mut x_min = f64::INFINITY;
let mut x_max = f64::NEG_INFINITY;
let mut y_min = f64::INFINITY;
let mut y_max = f64::NEG_INFINITY;
for &(x, y) in points.iter() {
x_min = x_min.min(x);
x_max = x_max.max(x);
y_min = y_min.min(y);
y_max = y_max.max(y);
}
let x_span = if x_max > x_min { x_max - x_min } else { 1.0 };
let y_span = if y_max > y_min { y_max - y_min } else { 1.0 };
let mut taken = vec![vec![false; columns]; rows];
for point in points.iter_mut() {
let column = ((point.0 - x_min) / x_span * (columns - 1) as f64).round() as isize;
let row = ((point.1 - y_min) / y_span * (rows - 1) as f64).round() as isize;
'placed: for radius in 0..=3_isize {
for row_offset in -radius..=radius {
for column_offset in -radius..=radius {
if row_offset.abs().max(column_offset.abs()) != radius {
continue;
}
let target_row = row + row_offset;
let target_column = column + column_offset;
if target_row < 0
|| target_row >= rows as isize
|| target_column < 0
|| target_column >= columns as isize
{
continue;
}
if !taken[target_row as usize][target_column as usize] {
taken[target_row as usize][target_column as usize] = true;
point.0 = x_min + target_column as f64 / (columns - 1) as f64 * x_span;
point.1 = y_min + target_row as f64 / (rows - 1) as f64 * y_span;
break 'placed;
}
}
}
}
}
}
/// Renders the `[vocab, 2]` embedding `table` as a terminal map, one
/// text mark per token: vowels highlighted, the padding token dimmed,
/// consonants in the default foreground.
fn embedding_chart(title: &str, table: &Tensor<f32>) -> String {
// The map wants more rows than the default third-of-terminal strip:
// every letter is one cell, so vertical resolution is legibility.
let mut frame = Frame::detect();
frame.height = frame.height.max(24);
let elements = table.to_vec();
let mut points: Vec<(f64, f64)> = (0..VOCABULARY_LEN)
.map(|token| {
(
f64::from(elements[token * EMBED_DIM]),
f64::from(elements[token * EMBED_DIM + 1]),
)
})
.collect();
// The spread grid stands in for the plot area's cells, held a bit
// coarser than the frame since malevich widens the domains to its
// ticks and spends some cells on axis furniture.
spread(
&mut points,
frame.width.saturating_sub(12).max(20),
frame.height.saturating_sub(6).max(10),
);
// The domains pad one spread cell beyond the bounding box: a text
// mark anchored exactly on the domain's right or bottom edge is
// clipped away (malevich 1.11.1), and edge letters read better with
// a margin anyway.
let mut x_bounds = (f64::INFINITY, f64::NEG_INFINITY);
let mut y_bounds = (f64::INFINITY, f64::NEG_INFINITY);
for &(x, y) in &points {
x_bounds = (x_bounds.0.min(x), x_bounds.1.max(x));
y_bounds = (y_bounds.0.min(y), y_bounds.1.max(y));
}
let x_margin = (x_bounds.1 - x_bounds.0).max(1.0) / 40.0;
let y_margin = (y_bounds.1 - y_bounds.0).max(1.0) / 16.0;
let mut plot = Plot::new()
.title(title)
.x_domain(x_bounds.0 - x_margin, x_bounds.1 + x_margin)
.y_domain(y_bounds.0 - y_margin, y_bounds.1 + y_margin);
for (token, &(x, y)) in points.iter().enumerate() {
let letter = from_token(token);
let color = match letter {
'a' | 'e' | 'i' | 'o' | 'u' => Color::BrightCyan,
'.' => Color::BrightBlack,
_ => Color::Default,
};
plot = plot.layer(Text::at(x, y, String::from(letter)).color(color));
}
plot.render_best(&frame)
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape: Tape<f32> = Tape::new();
let embeddings = tape.parameter(init::normal(8, 1.0)(&Shape::new([
VOCABULARY_LEN,
EMBED_DIM,
])));
let mlp = Mlp::new(
&tape,
&[CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN, VOCABULARY_LEN],
Activation::Tanh,
init::xavier(7),
);
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let embedded = embeddings
.gather(contexts)
.reshape([BATCH_LEN, CONTEXT_LEN * EMBED_DIM]);
let loss = cross_entropy(mlp.express(embedded), targets);
println!(
"{}",
embedding_chart(
"embedding space before training (the seeded blob)",
&embeddings.payload().unwrap(),
)
);
let (embeddings, contexts, targets, loss) = (
embeddings.symbol(),
contexts.symbol(),
targets.symbol(),
loss.symbol(),
);
let network = tape.into_network();
let mut parameters = network.parameters();
// The two-dimensional bottleneck lands near 2.4 where ten
// dimensions reach 2.25: the price of a plottable space.
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
// Slice the run to the loss it reads.
let run = network.entry([loss]).interpret(
¶meters,
[
(
contexts,
Tensor::selection(batch_contexts, VOCABULARY_LEN, 1.0),
),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
window_loss += batch_loss;
if (step + 1) % 1000 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 1000,
step + 1,
window_loss / 1000.0
);
window_loss = 0.0;
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
println!("{}", loss_chart("embedding map training", &losses));
let table = parameters.of(embeddings);
println!(
"{}",
embedding_chart("embedding space after training (vowels highlighted)", table)
);
}makemore_transformer
Trains a small character-level transformer — the attention act of the makemore series, and the consumer that closes the transformer rung: every piece is composition over the existing op set.
The batch packs its samples into one [BATCH_LEN * CONTEXT_LEN]
token row, so each head's attention is a single rank-2 matmul pair
over the packed axis; a block-diagonal causal mask (an additive
0 / -inf leaf) keeps samples independent and time causal, which
is the sequence-packing idiom and the reason no batched matmul is
needed. Masked softmax records as the mask added before the axis
softmax, heads record as a loop of rank-2 scaled_dot_product
attentions joined by
concat, and the per-sample prediction rows come back through a
one-hot gather. The block is pre-norm: attention and feed-forward
each read an RmsNorm of their input and add onto the residual
stream, and mask-fed dropout (keep probability KEEP) guards both
residual writes — fed per training step from a seeded factory,
while the sampling twin runs unfed on the all-ones identity
default. Loss is measured at the last context position only, so
the number is comparable to the MLP acts (uniform cost:
ln 27 ~ 3.30).
Run with: cargo run --release --example makemore_transformer
loaded 32033 names, 228146 samples
step 0: minibatch loss = 3.7755 (a uniform model costs ln 27 ~ 3.30)
steps 0.. 500: mean minibatch loss = 2.5240
steps 500..1000: mean minibatch loss = 2.3829
steps 1000..1500: mean minibatch loss = 2.3484
steps 1500..2000: mean minibatch loss = 2.3113
steps 2000..2500: mean minibatch loss = 2.3029
steps 2500..3000: mean minibatch loss = 2.2827
steps 3000..3500: mean minibatch loss = 2.2850
steps 3500..4000: mean minibatch loss = 2.2633
steps 4000..4500: mean minibatch loss = 2.2313
steps 4500..5000: mean minibatch loss = 2.2456
trained 5000 steps in 44.841s
the tape held 192 nodes through every step
transformer training
── minibatch ── rolling mean ── bigram limit
│▌
3.5 ┤▌
│▙▗
l 3.0 ┤██▖▗▖ ▐ ▗ ▗ ▌ ▐ ▖ ▖
o │████▙▄▐▙▄▟▟▗▄▄▄▌ █▟▙▐▌▐ ▌█▄ ▙▄▗▗ ▗▄▙▖▗▐▄ ▗ ▙▌▟▌ ▗▗▌ ▌▖ ▐ ▐ ▐ ▖▐▗
s │███████████████████████▟███▌██▟██████████▟▙███▙█▟█▟▙▟█▙▄█▙▟▙█▟█▄▌▙█████▙▙
s 2.5 ┤▜████████████████████████████████████████████████████████████████████████
│▝███▜████████████████████████████████████████████████████████████████████
2.0 ┤ ▀ ▐▝ ▌▜▐▛▜▛██▌█▜▛█████▛██▛▛▛██▛███▛▛███████▜███████████████▛████████████
│ ▝ ▘ ▘ ▝▘▘▘▌▝ ▘ ▘▘▌▌▌▘▜▜ ▘▝▜█▛█▌▝▝▐▝▘▘▝▌▐▘▌▝ ▀▛▜▀▘█▀ ██▘▛▘▜▝▀▘
1.5 ┤ ▝ ▘▝ ▘ ▐ ▝▝ ▝
└┬──────┬──────┬───────┬──────┬──────┬──────┬───────┬──────┬──────┬──────┬
0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000
step
sampled names:
haylee
efiattro
senni
geuwajley
hanjay
bryi
alistin
akiel
khalyn
groytecargo run --release --example makemore_transformer printed into a pipe.The source, examples/makemore/transformer.rs
mod chart;
mod corpus;
use std::time::Instant;
use topos::{
Dropout, Embedding, Module, RmsNorm, Shape, Tape, Tensor, Value, concat, cross_entropy, init,
scaled_dot_product,
};
use chart::loss_chart;
use corpus::{VOCABULARY_LEN, draw, from_token, load_names, shuffle, training_samples};
/// How many characters of history the model attends over before
/// predicting the next one.
const CONTEXT_LEN: usize = 8;
/// How many dimensions the residual stream has.
const EMBED_DIM: usize = 32;
/// How many attention heads split the stream.
const HEAD_COUNT: usize = 2;
/// How many dimensions each head reads and writes.
const HEAD_DIM: usize = EMBED_DIM / HEAD_COUNT;
/// How many neurons the feed-forward hidden layer has.
const HIDDEN_LEN: usize = 4 * EMBED_DIM;
/// How many samples each training step packs into the attention row.
const BATCH_LEN: usize = 32;
/// How many token rows the packed training batch holds.
const PACKED_LEN: usize = BATCH_LEN * CONTEXT_LEN;
/// The dropout keep probability: masks carry `1 / KEEP` or zero, and
/// only the training expression is ever fed — the sampling twin runs
/// on the all-ones identity default.
const KEEP: f64 = 0.9;
/// One attention head's projections, each `[EMBED_DIM, HEAD_DIM]`.
struct Head<'tape> {
query: Value<'tape, f32>,
key: Value<'tape, f32>,
value: Value<'tape, f32>,
}
/// The model's parameters as recorded proxies: embeddings, one
/// pre-norm attention block, and the language-model head.
struct Model<'tape> {
embeddings: Embedding<f32>,
positions: Embedding<f32>,
heads: Vec<Head<'tape>>,
projection: Value<'tape, f32>,
attention_norm: RmsNorm<f32>,
hidden_weights: Value<'tape, f32>,
output_weights: Value<'tape, f32>,
hidden_norm: RmsNorm<f32>,
final_norm: RmsNorm<f32>,
logit_weights: Value<'tape, f32>,
logit_bias: Value<'tape, f32>,
scale: Value<'tape, f32>,
}
impl<'tape> Model<'tape> {
/// Allocates the parameters on `tape`: embedding tables for
/// characters and positions, the heads' projections, the block's
/// two norms and feed-forward, the final norm, and the affine
/// logit head. The attention scale `1 / sqrt(HEAD_DIM)` rides
/// along as a single-value leaf.
fn new(tape: &'tape Tape<f32>) -> Self {
let mut weights = init::xavier(7);
let ones = Tensor::filled([EMBED_DIM], 1.0);
let epsilon = Tensor::filled([], 1e-5);
Self {
embeddings: Embedding::new(
tape,
init::normal(8, 1.0)(&Shape::new([VOCABULARY_LEN, EMBED_DIM])),
),
positions: Embedding::new(
tape,
init::normal(9, 1.0)(&Shape::new([CONTEXT_LEN, EMBED_DIM])),
),
heads: (0..HEAD_COUNT)
.map(|_| Head {
query: tape.parameter(weights(&Shape::new([EMBED_DIM, HEAD_DIM]))),
key: tape.parameter(weights(&Shape::new([EMBED_DIM, HEAD_DIM]))),
value: tape.parameter(weights(&Shape::new([EMBED_DIM, HEAD_DIM]))),
})
.collect(),
projection: tape.parameter(weights(&Shape::new([EMBED_DIM, EMBED_DIM]))),
attention_norm: RmsNorm::new(tape, ones.clone(), epsilon.clone()),
hidden_weights: tape.parameter(weights(&Shape::new([EMBED_DIM, HIDDEN_LEN]))),
output_weights: tape.parameter(weights(&Shape::new([HIDDEN_LEN, EMBED_DIM]))),
hidden_norm: RmsNorm::new(tape, ones.clone(), epsilon.clone()),
final_norm: RmsNorm::new(tape, ones, epsilon),
logit_weights: tape.parameter(weights(&Shape::new([EMBED_DIM, VOCABULARY_LEN]))),
logit_bias: tape.parameter(weights(&Shape::new([VOCABULARY_LEN]))),
scale: tape.leaf(Tensor::filled([], 1.0 / (HEAD_DIM as f32).sqrt())),
}
}
/// Records the block over one packed token row and returns the
/// normalized residual stream: embeddings in, attention and
/// feed-forward added on, final norm out.
///
/// `tokens` and `positions` are one-hot selections over the packed
/// row; `mask` is the additive `0 / -inf` block-causal leaf shaped
/// `[rows, rows]`.
fn states(
&self,
_tape: &'tape Tape<f32>,
tokens: Value<'tape, f32>,
positions: Value<'tape, f32>,
mask: Value<'tape, f32>,
dropouts: &[Dropout<f32>; 2],
) -> Value<'tape, f32> {
let stream = self.embeddings.express(tokens) + self.positions.express(positions);
// Pre-norm attention: every head attends over the same
// normalized stream, and `concat` joins the head outputs along
// the feature axis.
let normalized = self.attention_norm.express(stream);
let heads: Vec<Value<'tape, f32>> = self
.heads
.iter()
.map(|head| {
let query = normalized.matmul(head.query);
let key = normalized.matmul(head.key);
let value = normalized.matmul(head.value);
scaled_dot_product(query, key, value, mask, self.scale)
})
.collect();
let stream = stream + dropouts[0].express(concat(&heads, 1).matmul(self.projection));
// Pre-norm feed-forward onto the residual stream.
let normalized = self.hidden_norm.express(stream);
let stream = stream
+ dropouts[1].express(
normalized
.matmul(self.hidden_weights)
.relu()
.matmul(self.output_weights),
);
self.final_norm.express(stream)
}
/// Records the logits of the rows picked by the one-hot
/// `extraction` — the last context position of each packed sample.
fn logits(
&self,
states: Value<'tape, f32>,
extraction: Value<'tape, f32>,
) -> Value<'tape, f32> {
let product = states.gather(extraction).matmul(self.logit_weights);
product + self.logit_bias.broadcast_along_like(0, product)
}
}
/// Returns the additive attention mask for `samples` packed windows of
/// `CONTEXT_LEN` tokens: zero where the key's row is in the same
/// sample and not after the query's row, negative infinity elsewhere.
fn block_causal_mask(samples: usize) -> Tensor<f32> {
let rows = samples * CONTEXT_LEN;
let mut elements = Vec::with_capacity(rows * rows);
for query in 0..rows {
for key in 0..rows {
let same_sample = query / CONTEXT_LEN == key / CONTEXT_LEN;
let causal = key % CONTEXT_LEN <= query % CONTEXT_LEN;
elements.push(if same_sample && causal {
0.0
} else {
f32::NEG_INFINITY
});
}
}
Tensor::new([rows, rows], elements)
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression over the packed batch: per-sample
// prediction rows come back through the fixed one-hot extraction.
let tokens = tape.input(Tensor::selection(vec![0; PACKED_LEN], VOCABULARY_LEN, 1.0));
let positions = tape.leaf(Tensor::selection(
(0..PACKED_LEN)
.map(|row| row % CONTEXT_LEN)
.collect::<Vec<_>>(),
CONTEXT_LEN,
1.0,
));
let mask = tape.leaf(block_causal_mask(BATCH_LEN));
let extraction = tape.leaf(Tensor::selection(
(0..BATCH_LEN)
.map(|sample| sample * CONTEXT_LEN + CONTEXT_LEN - 1)
.collect::<Vec<_>>(),
PACKED_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
// Dropout on both residual writes; the mask inputs default to
// ones, so only the fed training steps ever drop anything.
let dropouts = [
Dropout::new(&tape, [PACKED_LEN, EMBED_DIM]),
Dropout::new(&tape, [PACKED_LEN, EMBED_DIM]),
];
let states = model.states(&tape, tokens, positions, mask, &dropouts);
let loss = cross_entropy(model.logits(states, extraction), targets);
// The sampling twin is the same expression over one window.
let sample_tokens = tape.input(Tensor::selection(vec![0; CONTEXT_LEN], VOCABULARY_LEN, 1.0));
let sample_positions = tape.leaf(Tensor::selection(
(0..CONTEXT_LEN).collect::<Vec<_>>(),
CONTEXT_LEN,
1.0,
));
let sample_mask = tape.leaf(block_causal_mask(1));
let sample_extraction = tape.leaf(Tensor::selection(vec![CONTEXT_LEN - 1], CONTEXT_LEN, 1.0));
// The twin's dropouts are never fed: the identity default is the
// inference mode, with no flag and no second formula.
let sample_dropouts = [
Dropout::new(&tape, [CONTEXT_LEN, EMBED_DIM]),
Dropout::new(&tape, [CONTEXT_LEN, EMBED_DIM]),
];
let sample_states = model.states(
&tape,
sample_tokens,
sample_positions,
sample_mask,
&sample_dropouts,
);
let sample_probabilities = model.logits(sample_states, sample_extraction).softmax(1);
let (tokens, targets, loss, sample_tokens, sample_probabilities) = (
tokens.symbol(),
targets.symbol(),
loss.symbol(),
sample_tokens.symbol(),
sample_probabilities.symbol(),
);
let recorded_nodes = tape.len();
let network = tape.into_network();
let mut parameters = network.parameters();
// Entry once: training keeps only the loss, sampling is
// forward-only.
let training_plan = network.entry([loss]).backward().lower();
let sampling_plan = network.entry([sample_probabilities]).lower();
// The seeded mask factory: two draws per step, one per residual
// write, deterministic in `(seed, step)` so runs replay bitwise.
let mut dropout_masks = init::dropout::<f32>(13, KEEP);
let mask_shape = Shape::new([PACKED_LEN, EMBED_DIM]);
let fast = Tensor::new([], [0.1]);
let slow = Tensor::new([], [0.01]);
let mut window_loss = 0.0;
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..5000 {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let batch = &samples[start..start + BATCH_LEN];
let batch_tokens: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let batch_targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
let run = training_plan.forward(
¶meters,
[
(tokens, Tensor::selection(batch_tokens, VOCABULARY_LEN, 1.0)),
(
targets,
Tensor::selection(batch_targets, VOCABULARY_LEN, 1.0),
),
(dropouts[0].mask(), dropout_masks(&mask_shape)),
(dropouts[1].mask(), dropout_masks(&mask_shape)),
],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 27 ~ 3.30)"
);
}
window_loss += batch_loss;
if (step + 1) % 500 == 0 {
println!(
"steps {:4}..{:4}: mean minibatch loss = {:.4}",
step + 1 - 500,
step + 1,
window_loss / 500.0
);
window_loss = 0.0;
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < 4000 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
println!(
"trained {} steps in {:.3}s",
losses.len(),
training.elapsed().as_secs_f64()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("transformer training", &losses));
println!("sampled names:");
let mut state: u64 = 7;
for _ in 0..10 {
let mut window = [0usize; CONTEXT_LEN];
let mut name = String::new();
loop {
let run = sampling_plan.forward(
¶meters,
[(
sample_tokens,
Tensor::selection(window.to_vec(), VOCABULARY_LEN, 1.0),
)],
);
let row = run.of(sample_probabilities).to_vec();
let token = draw(&row, &mut state);
if token == 0 {
break;
}
name.push(from_token(token));
window.rotate_left(1);
window[CONTEXT_LEN - 1] = token;
}
println!(" {name}");
}
}makemore_mlp_emitted
Trains the makemore_mlp model through an emitted joint step —
E2, the compiled-training shape abroad: differentiate records
the chain rule, one forward-only plan compiles
[loss, gradients...], emit_stablehlo turns it into one
StableHLO function (parameters, batch) -> (loss, gradients...),
and XLA executes every training step while the host keeps the
update loop as plain payload arithmetic. The same binary first
trains the in-crate recorded plan — the oracle trajectory,
bit-identical to makemore_mlp_compiled — so the emitted run's
losses are graded against it on the page.
Two E2 conventions from the design (notes/emission.md sec. 11):
result order is pinned by recording one same-shape reshape
alias per gradient in the order the caller wants results, and
training stages every argument dynamic, because parameters change
every step — the host sends them with the batch and never
writes them back onto the tape.
Serving needs a Python with jax (TOPOS_XLA_PYTHON names
the interpreter; default python3); the backend follows jax's
own selection, CPU by default.
Run with: cargo run --release --example makemore_mlp_emitted
This one trains through an emitted StableHLO step served by XLA, so it needs a Python with jax installed and is not recorded here.
The source, examples/makemore/mlp_emitted.rs
// The shared corpus module also serves the sampling examples; this
// act never samples, so its `draw`/`from_token` stay unused here.
#[allow(dead_code)]
mod corpus;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::Instant;
use topos::{Shape, Tape, Tensor, Value, cross_entropy, init};
use corpus::{VOCABULARY_LEN, load_names, shuffle, training_samples};
/// How many characters of history the model sees before predicting the
/// next one.
const CONTEXT_LEN: usize = 3;
/// How many dimensions the character embedding space has.
const EMBED_DIM: usize = 10;
/// How many neurons the tanh hidden layer has.
const HIDDEN_LEN: usize = 100;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many training steps each route runs.
const STEPS: usize = 5000;
/// The initial parameter payloads, minted identically to
/// `makemore_mlp_compiled`'s `Model::new` (same factories, same call
/// order, same seeds), so both routes and that example share their
/// trajectory. Order is the recording order and therefore the
/// emitted module's leading-argument order.
fn initial_parameters() -> [Tensor<f32>; 5] {
let mut weights = init::xavier(7);
[
init::normal(8, 1.0)(&Shape::new([VOCABULARY_LEN, EMBED_DIM])),
weights(&Shape::new([CONTEXT_LEN * EMBED_DIM, HIDDEN_LEN])),
weights(&Shape::new([HIDDEN_LEN])),
weights(&Shape::new([HIDDEN_LEN, VOCABULARY_LEN])),
weights(&Shape::new([VOCABULARY_LEN])),
]
}
/// Records the model's expression over `contexts` and returns the
/// `[rows, vocab]` logits: embed, flatten the context window, squash,
/// and score. `parameters` arrive in `initial_parameters` order.
fn express<'tape>(
parameters: &[Value<'tape, f32>; 5],
contexts: Value<'tape, f32>,
rows: usize,
) -> Value<'tape, f32> {
let [
embeddings,
hidden_weights,
hidden_bias,
output_weights,
output_bias,
] = *parameters;
let embedded = embeddings
.gather(contexts)
.reshape([rows, CONTEXT_LEN * EMBED_DIM]);
let product = embedded.matmul(hidden_weights);
let hidden = (product + hidden_bias.broadcast_along_like(0, product)).tanh();
let product = hidden.matmul(output_weights);
product + output_bias.broadcast_along_like(0, product)
}
/// One-hot payloads for a batch slice, contexts then targets.
fn batch_payloads(batch: &[([usize; CONTEXT_LEN], usize)]) -> (Tensor<f32>, Tensor<f32>) {
let contexts: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
(
Tensor::selection(contexts, VOCABULARY_LEN, 1.0),
Tensor::selection(targets, VOCABULARY_LEN, 1.0),
)
}
/// The resident XLA server over `tools/serve-stablehlo-xla.py`:
/// compile once, then one request per training step. Every argument
/// is dynamic — parameters change every step.
struct XlaServer {
child: Child,
requests: ChildStdin,
responses: ChildStdout,
response_len: usize,
}
impl XlaServer {
fn new(module: &str, dynamic_shapes: &[&[usize]], response_len: usize) -> Self {
let directory = std::env::temp_dir().join("topos-makemore-e2");
std::fs::create_dir_all(&directory).expect("the staging directory creates");
let module_path = directory.join("joint-step.mlir");
let static_path = directory.join("static.bin");
let manifest_path = directory.join("manifest.json");
std::fs::write(&module_path, module).expect("the module writes");
std::fs::write(&static_path, []).expect("the empty static file writes");
let shapes: Vec<String> = dynamic_shapes
.iter()
.map(|shape| {
let extents: Vec<String> = shape.iter().map(usize::to_string).collect();
format!("[{}]", extents.join(", "))
})
.collect();
std::fs::write(
&manifest_path,
format!("{{\"dynamic\": [{}]}}", shapes.join(", ")),
)
.expect("the manifest writes");
let python = std::env::var("TOPOS_XLA_PYTHON").unwrap_or_else(|_| "python3".to_string());
let mut command: Vec<String> = python.split_whitespace().map(str::to_string).collect();
command.push("tools/serve-stablehlo-xla.py".to_string());
let mut child = Command::new(&command[0])
.args(&command[1..])
.arg(&module_path)
.arg(&static_path)
.arg(&manifest_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("the serving process starts; is `jax` installed for it?");
let requests = child.stdin.take().expect("the server's input pipes");
let responses = child.stdout.take().expect("the server's output pipes");
Self {
child,
requests,
responses,
response_len,
}
}
/// Executes one joint step: sends every argument's elements in
/// order, reads back the results in the pinned order.
fn step(&mut self, arguments: impl Iterator<Item = f32>) -> Vec<f32> {
let request: Vec<u8> = arguments.flat_map(f32::to_le_bytes).collect();
self.requests
.write_all(&request)
.expect("the request writes");
self.requests.flush().expect("the request flushes");
let mut response = vec![0u8; 4 * self.response_len];
self.responses
.read_exact(&mut response)
.expect("the server answers; see its standard error");
response
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().expect("four bytes")))
.collect()
}
}
impl Drop for XlaServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
println!("loaded {} names, {} samples", names.len(), samples.len());
// Record the expression and the chain rule once. The network's
// only job here is to be the spec: after `emit_stablehlo`, the
// emitted route never touches it again.
let tape = Tape::new();
let initial = initial_parameters();
let parameters = initial.clone().map(|payload| tape.parameter(payload));
let contexts = tape.input(Tensor::selection(
vec![0; BATCH_LEN * CONTEXT_LEN],
VOCABULARY_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let loss = cross_entropy(express(¶meters, contexts, BATCH_LEN), targets);
let (contexts, targets, loss) = (contexts.symbol(), targets.symbol(), loss.symbol());
let parameter_symbols = parameters.map(|parameter| parameter.symbol());
// Result order is declared: emission returns the request's roots
// in request order, so the gradients emit in parameter order with
// no aliasing ceremony.
let adjoints = tape.differentiate(loss, parameter_symbols);
let network = tape.into_network();
let plan = network.entry(adjoints.roots()).lower();
let module = plan.emit_stablehlo().expect("the joint step emits");
println!(
"emitted the joint step: {} nodes, {} bytes of StableHLO",
plan.len(),
module.len()
);
let fast = Tensor::new([], [0.1_f32]);
let slow = Tensor::new([], [0.01_f32]);
let learning_rate = |step: usize| if step < 4000 { &fast } else { &slow };
// Route one, the oracle: the in-crate recorded plan, bit-identical
// to `makemore_mlp_compiled`.
let mut oracle_parameters = network.parameters();
let mut oracle_losses = Vec::new();
let oracle_clock = Instant::now();
for step in 0..STEPS {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let (batch_contexts, batch_targets) = batch_payloads(&samples[start..start + BATCH_LEN]);
let run = plan.forward(
&oracle_parameters,
[(contexts, batch_contexts), (targets, batch_targets)],
);
oracle_losses.push(run.of(loss).scalar());
let gradients = run.recorded_gradients(&adjoints);
let rate = learning_rate(step);
oracle_parameters = oracle_parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * rate.broadcast_like(gradient)
});
}
let oracle_seconds = oracle_clock.elapsed().as_secs_f64();
// Route two, abroad: the same module through XLA, parameters held
// host-side as plain payloads and updated with payload arithmetic.
let shapes: Vec<Vec<usize>> = initial
.iter()
.map(|tensor| tensor.shape().axes().to_vec())
.chain([
vec![BATCH_LEN * CONTEXT_LEN, VOCABULARY_LEN],
vec![BATCH_LEN, VOCABULARY_LEN],
])
.collect();
let dynamic_shapes: Vec<&[usize]> = shapes.iter().map(Vec::as_slice).collect();
let gradient_volumes: Vec<usize> = initial
.iter()
.map(|tensor| tensor.shape().volume())
.collect();
let response_len = 1 + gradient_volumes.iter().sum::<usize>();
let mut server = XlaServer::new(&module, &dynamic_shapes, response_len);
let mut hosted: Vec<Tensor<f32>> = initial.to_vec();
let mut emitted_losses = Vec::new();
let emitted_clock = Instant::now();
for step in 0..STEPS {
let start = (step * BATCH_LEN) % (samples.len() - BATCH_LEN);
let (batch_contexts, batch_targets) = batch_payloads(&samples[start..start + BATCH_LEN]);
let response = server.step(
hosted
.iter()
.flat_map(|tensor| tensor.to_vec())
.chain(batch_contexts.to_vec())
.chain(batch_targets.to_vec()),
);
emitted_losses.push(response[0]);
let mut at = 1;
let rate = learning_rate(step);
for (parameter, &volume) in hosted.iter_mut().zip(&gradient_volumes) {
let gradient = Tensor::new(
parameter.shape().clone(),
response[at..at + volume].to_vec(),
);
at += volume;
*parameter = parameter.clone() - gradient.clone() * rate.broadcast_like(&gradient);
}
}
let emitted_seconds = emitted_clock.elapsed().as_secs_f64();
// Tier 1, per step: the first loss is one forward on identical
// payloads — XLA may reassociate, never diverge.
let first_drift = (f64::from(oracle_losses[0]) - f64::from(emitted_losses[0])).abs()
/ f64::from(oracle_losses[0]);
println!(
"step 0: oracle {:.6}, emitted {:.6} (relative drift {first_drift:.2e})",
oracle_losses[0], emitted_losses[0]
);
assert!(
first_drift < 1e-3,
"the emitted first step left the oracle envelope"
);
// Tier 2, trajectory: windowed means over the whole schedule;
// per-step drift compounds legitimately, so the page shows both.
println!(" oracle emitted");
for window in 0..STEPS / 500 {
let range = window * 500..(window + 1) * 500;
let mean = |losses: &[f32]| losses[range.clone()].iter().sum::<f32>() / 500.0;
println!(
"steps {:4}..{:4}: {:.4} {:.4}",
range.start,
range.end,
mean(&oracle_losses),
mean(&emitted_losses)
);
}
println!(
"oracle (in-crate plan): {STEPS} steps in {oracle_seconds:.3}s ({:.2} ms/step)",
oracle_seconds * 1000.0 / STEPS as f64
);
println!(
"emitted (XLA joint step): {STEPS} steps in {emitted_seconds:.3}s ({:.2} ms/step)",
emitted_seconds * 1000.0 / STEPS as f64
);
}makemore_attention_grading
Grades the two recordings of the transformer's training step —
the rank-2 head loop against batched attention — as emission
source material: both record the same joint step
(parameters, batch) -> (loss, gradients...), both emit to
StableHLO, and one resident XLA server times each over repeated
identical requests. The in-crate B1b grading declined the batched
migration at this scale (notes/batched-matmul.md); this harness
measures the batched op's remaining execution claim — that
dot_general with batching dimensions is better source for
someone else's compiler than a head loop it must re-fuse.
TOPOS_XLA_PYTHON names the serving interpreter (default
python3); the backend follows jax's own selection, so
JAX_PLATFORMS picks a PJRT plugin — CPU by default, Metal when
the jax-metal plugin is installed in that interpreter.
Run with: cargo run --release --example makemore_attention_grading
A measurement harness over an XLA server; it needs a Python with jax installed and is not recorded here.
The source, examples/makemore/attention_grading.rs
// The shared corpus module also serves the sampling examples; this
// harness never samples, so most of it stays unused here.
#[allow(dead_code)]
mod corpus;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::Instant;
use topos::{
Module, RmsNorm, Shape, Symbol, Tape, Tensor, Value, concat, cross_entropy, init, stack,
};
use corpus::{VOCABULARY_LEN, load_names, shuffle, training_samples};
/// How many characters of history the model attends over.
const CONTEXT_LEN: usize = 8;
/// How many dimensions the residual stream has.
const EMBED_DIM: usize = 32;
/// How many attention heads split the stream.
const HEAD_COUNT: usize = 2;
/// How many dimensions each head reads and writes.
const HEAD_DIM: usize = EMBED_DIM / HEAD_COUNT;
/// How many neurons the feed-forward hidden layer has.
const HIDDEN_LEN: usize = 4 * EMBED_DIM;
/// How many samples each training step packs into the attention row.
const BATCH_LEN: usize = 32;
/// How many token rows the packed training batch holds.
const PACKED_LEN: usize = BATCH_LEN * CONTEXT_LEN;
/// How many timed requests each module serves, after warmup.
const TIMED_STEPS: usize = 300;
/// How many warmup requests precede the timing.
const WARMUP_STEPS: usize = 20;
/// The parameter payloads in recording order — the emitted module's
/// leading arguments. Identical to the transformer act's seeds.
fn parameter_payloads() -> Vec<Tensor<f32>> {
let mut weights = init::xavier(7);
let ones = Tensor::filled([EMBED_DIM], 1.0);
let mut payloads = vec![
init::normal(8, 1.0)(&Shape::new([VOCABULARY_LEN, EMBED_DIM])),
init::normal(9, 1.0)(&Shape::new([CONTEXT_LEN, EMBED_DIM])),
];
for _ in 0..HEAD_COUNT {
payloads.push(weights(&Shape::new([EMBED_DIM, HEAD_DIM])));
payloads.push(weights(&Shape::new([EMBED_DIM, HEAD_DIM])));
payloads.push(weights(&Shape::new([EMBED_DIM, HEAD_DIM])));
}
payloads.push(weights(&Shape::new([EMBED_DIM, EMBED_DIM])));
payloads.push(ones.clone());
payloads.push(weights(&Shape::new([EMBED_DIM, HIDDEN_LEN])));
payloads.push(weights(&Shape::new([HIDDEN_LEN, EMBED_DIM])));
payloads.push(ones.clone());
payloads.push(ones);
payloads.push(weights(&Shape::new([EMBED_DIM, VOCABULARY_LEN])));
payloads.push(weights(&Shape::new([VOCABULARY_LEN])));
payloads
}
/// Returns the additive block-causal mask for the packed batch.
fn block_causal_mask(samples: usize) -> Tensor<f32> {
let rows = samples * CONTEXT_LEN;
let mut elements = Vec::with_capacity(rows * rows);
for query in 0..rows {
for key in 0..rows {
let same_sample = query / CONTEXT_LEN == key / CONTEXT_LEN;
let causal = key % CONTEXT_LEN <= query % CONTEXT_LEN;
elements.push(if same_sample && causal {
0.0
} else {
f32::NEG_INFINITY
});
}
}
Tensor::new([rows, rows], elements)
}
/// One emitted formulation of the training step: the module text,
/// the tape statistics, and the oracle's first-step loss.
struct Formulation {
module: String,
forward_nodes: usize,
total_nodes: usize,
oracle_loss: f32,
}
/// Records the transformer training step — head loop or batched
/// attention per `batched` — differentiates it, pins the result
/// order, compiles, and emits. Parameters are handed in so both
/// formulations share their payloads exactly.
fn recorded(
payloads: &[Tensor<f32>],
feeds: &(Tensor<f32>, Tensor<f32>),
batched: bool,
) -> Formulation {
let tape = Tape::new();
let mut supply = payloads.iter().cloned();
let mut next = move || supply.next().expect("payloads cover the model");
let embeddings = tape.parameter(next());
let positions_table = tape.parameter(next());
let heads: Vec<[Value<'_, f32>; 3]> = (0..HEAD_COUNT)
.map(|_| {
[
tape.parameter(next()),
tape.parameter(next()),
tape.parameter(next()),
]
})
.collect();
let projection = tape.parameter(next());
let attention_norm = RmsNorm::new(&tape, next(), Tensor::filled([], 1e-5));
let hidden_weights = tape.parameter(next());
let output_weights = tape.parameter(next());
let hidden_norm = RmsNorm::new(&tape, next(), Tensor::filled([], 1e-5));
let final_norm = RmsNorm::new(&tape, next(), Tensor::filled([], 1e-5));
let logit_weights = tape.parameter(next());
let logit_bias = tape.parameter(next());
let scale = tape.leaf(Tensor::filled([], 1.0 / (HEAD_DIM as f32).sqrt()));
let tokens = tape.input(Tensor::selection(vec![0; PACKED_LEN], VOCABULARY_LEN, 1.0));
let positions = tape.leaf(Tensor::selection(
(0..PACKED_LEN)
.map(|row| row % CONTEXT_LEN)
.collect::<Vec<_>>(),
CONTEXT_LEN,
1.0,
));
let mask = tape.leaf(block_causal_mask(BATCH_LEN));
let extraction = tape.leaf(Tensor::selection(
(0..BATCH_LEN)
.map(|sample| sample * CONTEXT_LEN + CONTEXT_LEN - 1)
.collect::<Vec<_>>(),
PACKED_LEN,
1.0,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], VOCABULARY_LEN, 1.0));
let stream = embeddings.gather(tokens) + positions_table.gather(positions);
let normalized = attention_norm.express(stream);
let attended = if batched {
let project = |slot: usize| {
let slices: Vec<_> = heads
.iter()
.map(|head| normalized.matmul(head[slot]))
.collect();
stack(&slices, 0)
};
let scores = project(0).matmul(project(1).permute([0, 2, 1]));
let scaled = scores * scale.broadcast_like(scores);
let weights = (scaled + mask.broadcast_along_like(0, scaled)).softmax(2);
let context = weights.matmul(project(2));
context.permute([1, 0, 2]).reshape([PACKED_LEN, EMBED_DIM])
} else {
let outputs: Vec<_> = heads
.iter()
.map(|head| {
let scores = normalized
.matmul(head[0])
.matmul(normalized.matmul(head[1]).transpose());
let scaled = scores * scale.broadcast_like(scores);
let weights = (scaled + mask).softmax(1);
weights.matmul(normalized.matmul(head[2]))
})
.collect();
concat(&outputs, 1)
};
let stream = stream + attended.matmul(projection);
let normalized = hidden_norm.express(stream);
let stream = stream
+ normalized
.matmul(hidden_weights)
.relu()
.matmul(output_weights);
let states = final_norm.express(stream);
let product = states.gather(extraction).matmul(logit_weights);
let loss = cross_entropy(
product + logit_bias.broadcast_along_like(0, product),
targets,
);
let forward_nodes = tape.len();
let wrt: Vec<Symbol> = std::iter::once(embeddings)
.chain([positions_table])
.chain(heads.iter().flatten().copied())
.chain([
projection,
hidden_weights,
output_weights,
logit_weights,
logit_bias,
])
.map(|value| value.symbol())
.collect();
// Result order is declared: emission returns the request's roots
// in request order, no aliasing needed.
let adjoints = tape.differentiate(loss, wrt);
let (tokens, targets, loss) = (tokens.symbol(), targets.symbol(), loss.symbol());
let network = tape.into_network();
let parameters = network.parameters();
let plan = network.entry(adjoints.roots()).lower();
let run = plan.forward(
¶meters,
[(tokens, feeds.0.clone()), (targets, feeds.1.clone())],
);
Formulation {
module: plan.emit_stablehlo().expect("the joint step emits"),
forward_nodes,
total_nodes: network.len(),
oracle_loss: run.of(loss).scalar(),
}
}
/// Serves one module and times repeated identical requests.
fn timed(module: &str, name: &str, request: &[u8], response_len: usize) -> (f32, f64) {
let directory = std::env::temp_dir().join("topos-attention-grading");
std::fs::create_dir_all(&directory).expect("the staging directory creates");
let module_path = directory.join(format!("{name}.mlir"));
let static_path = directory.join("static.bin");
let manifest_path = directory.join("manifest.json");
std::fs::write(&module_path, module).expect("the module writes");
std::fs::write(&static_path, []).expect("the empty static file writes");
let mut shapes: Vec<String> = parameter_payloads()
.iter()
.map(|payload| {
let extents: Vec<String> = payload
.shape()
.axes()
.iter()
.map(usize::to_string)
.collect();
format!("[{}]", extents.join(", "))
})
.collect();
shapes.push(format!("[{PACKED_LEN}, {VOCABULARY_LEN}]"));
shapes.push(format!("[{BATCH_LEN}, {VOCABULARY_LEN}]"));
std::fs::write(
&manifest_path,
format!("{{\"dynamic\": [{}]}}", shapes.join(", ")),
)
.expect("the manifest writes");
let python = std::env::var("TOPOS_XLA_PYTHON").unwrap_or_else(|_| "python3".to_string());
let mut command: Vec<String> = python.split_whitespace().map(str::to_string).collect();
command.push("tools/serve-stablehlo-xla.py".to_string());
let mut child: Child = Command::new(&command[0])
.args(&command[1..])
.arg(&module_path)
.arg(&static_path)
.arg(&manifest_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("the serving process starts; is `jax` installed for it?");
let mut requests: ChildStdin = child.stdin.take().expect("the server's input pipes");
let mut responses: ChildStdout = child.stdout.take().expect("the server's output pipes");
let mut step = |request: &[u8]| -> Vec<f32> {
requests.write_all(request).expect("the request writes");
requests.flush().expect("the request flushes");
let mut response = vec![0u8; 4 * response_len];
responses
.read_exact(&mut response)
.expect("the server answers; see its standard error");
response
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().expect("four bytes")))
.collect()
};
let first = step(request)[0];
for _ in 0..WARMUP_STEPS {
step(request);
}
let clock = Instant::now();
for _ in 0..TIMED_STEPS {
step(request);
}
let seconds = clock.elapsed().as_secs_f64();
let _ = child.kill();
let _ = child.wait();
(first, seconds)
}
fn main() {
let names = load_names();
let mut samples = training_samples::<CONTEXT_LEN>(&names);
let mut shuffle_state: u64 = 9;
shuffle(&mut samples, &mut shuffle_state);
// One fixed batch: identical feeds for the oracle and every
// server request, so the two modules answer the same question.
let batch = &samples[..BATCH_LEN];
let tokens: Vec<usize> = batch
.iter()
.flat_map(|(context, _)| context.iter().copied())
.collect();
let targets: Vec<usize> = batch.iter().map(|&(_, next)| next).collect();
let feeds = (
Tensor::selection(tokens, VOCABULARY_LEN, 1.0_f32),
Tensor::selection(targets, VOCABULARY_LEN, 1.0_f32),
);
let payloads = parameter_payloads();
let request: Vec<u8> = payloads
.iter()
.flat_map(|payload| payload.to_vec())
.chain(feeds.0.to_vec())
.chain(feeds.1.to_vec())
.flat_map(f32::to_le_bytes)
.collect();
for (name, batched) in [("head-loop", false), ("batched", true)] {
let formulation = recorded(&payloads, &feeds, batched);
let response_len = 1 + payloads
.iter()
.enumerate()
.filter(|&(index, _)| !is_norm_gain(index))
.map(|(_, payload)| payload.shape().volume())
.sum::<usize>();
let dots = formulation.module.matches("dot_general").count();
let (first, seconds) = timed(&formulation.module, name, &request, response_len);
let drift = (f64::from(formulation.oracle_loss) - f64::from(first)).abs()
/ f64::from(formulation.oracle_loss);
// A red row on a flaky target is a finding, not a crash:
// `TOPOS_ENVELOPE=report` keeps timing while naming the
// deviation, the manifest posture for jax-metal.
if std::env::var("TOPOS_ENVELOPE").as_deref() == Ok("report") {
if drift >= 1e-3 {
println!("{name}: RED ROW — left the oracle envelope, drift {drift:.1e}");
}
} else {
assert!(drift < 1e-3, "{name} left the oracle envelope: {drift:e}");
}
println!(
"{name:9} tape {} -> {} nodes, {} module lines, {dots} dot_general; \
first loss {first:.6} (oracle drift {drift:.1e}); \
{:.3} ms/step over {TIMED_STEPS} steps",
formulation.forward_nodes,
formulation.total_nodes,
formulation.module.lines().count(),
seconds * 1000.0 / TIMED_STEPS as f64,
);
}
}
/// Returns whether the payload at `index` is a norm gain, which stays
/// out of the `wrt` set and therefore out of the response.
fn is_norm_gain(index: usize) -> bool {
let after_heads = 2 + 3 * HEAD_COUNT;
index == after_heads + 1 || index == after_heads + 4 || index == after_heads + 5
}Vision#
Convolutional networks on real images: the im2col route, the plan tier under pressure, and the grading twins that settled the memory question.
mnist
Trains a LeNet-style convolutional network on MNIST — the first
consumer of Conv2d, max_pool, and the im2col-over-GEMM route:
two conv/relu/pool stages, a dense head, and cross_entropy, all
recorded once and fed minibatches per run.
The tape carries two expressions of the same parameters: a batch-shaped one for training and a probe-shaped twin for test accuracy, because input shapes are baked in at recording time. Every convolution runs as one rank-2 matrix product over its im2col patches, so the FLOPs land on the GEMM fast path with no backend feature required.
The four IDX files download on first run into
examples/mnist/data/ (about 55 MB unpacked) and are cached.
Run with: cargo run --release --example mnist
loaded 60000 training and 10000 test images
recorded 134 nodes for both expressions
training plan: retain; 75 of 134 nodes evaluated, 1 readable
training live volume: retain-all 12869471, release floor 3672578 at node 39
probe plan: forward; 65 of 134 nodes evaluated, 1 readable
probe fused 4 groups, 34 nodes replaced
probe live volume: peak 18825018 elements at node 86, retain-all 69817546
step 0: minibatch loss = 2.3962 (a uniform model costs ln 10 ~ 2.30)
step 250: minibatch loss = 0.1732, probe accuracy = 92.6%
step 500: minibatch loss = 0.1239, probe accuracy = 95.9%
step 750: minibatch loss = 0.1630, probe accuracy = 96.5%
step 1000: minibatch loss = 0.1851, probe accuracy = 96.8%
step 1250: minibatch loss = 0.1104, probe accuracy = 97.3%
step 1500: minibatch loss = 0.0271, probe accuracy = 97.4%
step 1750: minibatch loss = 0.0807, probe accuracy = 97.5%
step 2000: minibatch loss = 0.0131, probe accuracy = 97.9%
trained 2000 steps in 183.964s (92.0 ms/step)
test accuracy: 98.21% over 10000 images
the tape held 134 nodes through every step
mnist convnet training
── minibatch ── rolling mean ── uniform start
2.5 ┤▖
│█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
2.0 ┤█
l │▐▖
o 1.5 ┤▐▌
s │▐█▖
s 1.0 ┤▐▙▙▖
│ █▟▜▖
0.5 ┤ ▜█▄▙▖ ▄ ▖
│ ▝████████▟▄▙▟▟█▙▄▄▌▙▄▄▄▙▟▖▗▟▄▄ ▙▄▟▄▟▖▐▙▄▟▄▗ ▗▄▄▗▄▄▗▖▖▖▖▄▗▖▖ ▖▗▄ ▗ ▐▄▄▗▐
0.0 ┤ ▝▝▀▀▀▀▀▜▜█▀█▜███▛█▀▛██████▜███████████████████████████████████████████
└┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬
0 250 500 750 1000 1250 1500 1750 2000
stepcargo run --release --example mnist printed into a pipe.The source, examples/mnist/main.rs
mod chart;
mod dataset;
use std::time::Instant;
use topos::{
Conv2d, Linear, Module, Parameters, Plan, Shape, Symbol, Tape, Tensor, Value, cross_entropy,
init, max_pool,
};
use chart::loss_chart;
use dataset::{Split, load, shuffle};
/// The image side length; MNIST digits are `28 x 28`.
const IMAGE_SIDE: usize = 28;
/// How many pixels one image holds.
const PIXELS: usize = IMAGE_SIDE * IMAGE_SIDE;
/// How many digit classes the head scores.
const CLASSES: usize = 10;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many test images the accuracy probe feeds per run.
const PROBE_LEN: usize = 1000;
/// How many filters the first convolution stage learns.
const FILTERS_1: usize = 8;
/// How many filters the second convolution stage learns.
const FILTERS_2: usize = 16;
/// The flattened feature length after two 2x2 pools: `16 * 7 * 7`.
const FLAT_LEN: usize = FILTERS_2 * (IMAGE_SIDE / 4) * (IMAGE_SIDE / 4);
/// How many training steps to run: a bit over two epochs.
const STEPS: usize = 2000;
/// The model's layers, holding parameter symbols.
struct Model {
conv_1: Conv2d<f32>,
conv_2: Conv2d<f32>,
head: Linear<f32>,
}
impl Model {
/// Allocates the parameters on `tape`: two 3x3 same-padded
/// convolution stages with Kaiming-scaled kernels and zero biases,
/// and an affine classification head.
fn new(tape: &Tape<f32>) -> Self {
// Kaiming deviations by kernel fan-in: `sqrt(2 / (c * kh * kw))`.
let conv_1_weights =
init::normal(11, (2.0 / 9.0_f64).sqrt())(&Shape::new([FILTERS_1, 1, 3, 3]));
let conv_2_weights =
init::normal(12, (2.0 / 72.0_f64).sqrt())(&Shape::new([FILTERS_2, FILTERS_1, 3, 3]));
let mut head_weights = init::kaiming(13);
Self {
conv_1: Conv2d::new(tape, conv_1_weights, Tensor::filled([FILTERS_1], 0.0), 1, 1),
conv_2: Conv2d::new(tape, conv_2_weights, Tensor::filled([FILTERS_2], 0.0), 1, 1),
head: Linear::new(
tape,
head_weights(&Shape::new([FLAT_LEN, CLASSES])),
head_weights(&Shape::new([CLASSES])),
),
}
}
/// Records the model's expression over `images` (`[rows, 1, 28,
/// 28]`) and returns the `[rows, 10]` logits: conv, rectify, pool,
/// twice, then flatten and score.
fn express<'tape>(&self, images: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let stage_1 = max_pool(self.conv_1.express(images).relu(), 2, 2);
let stage_2 = max_pool(self.conv_2.express(stage_1).relu(), 2, 2);
self.head.express(stage_2.reshape([rows, FLAT_LEN]))
}
}
/// Builds the image and one-hot label payloads for the sample `indices`.
fn batch_payloads(split: &Split, indices: &[usize]) -> (Tensor<f32>, Tensor<f32>) {
let mut pixels = Vec::with_capacity(indices.len() * PIXELS);
for &index in indices {
pixels.extend_from_slice(&split.pixels[index * PIXELS..(index + 1) * PIXELS]);
}
let labels: Vec<usize> = indices.iter().map(|&index| split.labels[index]).collect();
(
Tensor::new([indices.len(), 1, IMAGE_SIDE, IMAGE_SIDE], pixels),
Tensor::selection(labels, CLASSES, 1.0),
)
}
/// Counts correct predictions over `PROBE_LEN` test images from `start`,
/// one probe-plan run.
fn probe_correct(
parameters: &Parameters<f32>,
probe_plan: &Plan<f32>,
images_symbol: Symbol,
logits_symbol: Symbol,
test: &Split,
start: usize,
) -> usize {
let indices: Vec<usize> = (start..start + PROBE_LEN).collect();
let (images, _) = batch_payloads(test, &indices);
// The forward-only plan skips the training twin and frees every
// probe intermediate right after its last consumer.
let run = probe_plan.forward(parameters, [(images_symbol, images)]);
let logits = run.of(logits_symbol).to_vec();
let mut correct = 0;
for (row, &index) in logits.chunks(CLASSES).zip(&indices) {
let predicted = row
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).expect("logits are finite"))
.map(|(class, _)| class)
.expect("a logit row is never empty");
if predicted == test.labels[index] {
correct += 1;
}
}
correct
}
fn main() {
let (train, test) = load();
println!(
"loaded {} training and {} test images",
train.len(),
test.len()
);
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression: images and one-hot targets are fed per
// run, the defaults only fix the shapes.
let images = tape.input(Tensor::filled(
[BATCH_LEN, 1, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], CLASSES, 1.0));
let loss = cross_entropy(model.express(images, BATCH_LEN), targets);
// The accuracy twin: the same parameters expressed over a probe of
// test images.
let probe_images = tape.input(Tensor::filled(
[PROBE_LEN, 1, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let probe_logits = model.express(probe_images, PROBE_LEN);
let (images, targets, loss, probe_images, probe_logits) = (
images.symbol(),
targets.symbol(),
loss.symbol(),
probe_images.symbol(),
probe_logits.symbol(),
);
let recorded_nodes = tape.len();
println!("recorded {recorded_nodes} nodes for both expressions");
let network = tape.into_network();
let mut parameters = network.parameters();
// Entry once, run every step. The engine plan retains what
// `backward` reads; the probe plan frees as it goes. The
// `mnist_grading` example measures this route against recorded
// gradients.
let training_plan = network.entry([loss]).backward().lower();
let probe_plan = network.entry([probe_logits]).lower();
for line in training_plan.describe().lines().filter(|line| {
line.starts_with("plan:") || line.starts_with("live volume:") || line.starts_with("fused")
}) {
println!("training {line}");
}
for line in probe_plan.describe().lines().filter(|line| {
line.starts_with("plan:") || line.starts_with("live volume:") || line.starts_with("fused")
}) {
println!("probe {line}");
}
let mut order: Vec<usize> = (0..train.len()).collect();
let mut shuffle_state: u64 = 5;
shuffle(&mut order, &mut shuffle_state);
let fast = Tensor::new([], [0.1_f32]);
let slow = Tensor::new([], [0.01_f32]);
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..STEPS {
let start = (step * BATCH_LEN) % (train.len() - BATCH_LEN);
let batch = &order[start..start + BATCH_LEN];
let (batch_images, batch_targets) = batch_payloads(&train, batch);
// The training plan skips the probe expression and outlives
// every step: compiled once, run per step.
let run = training_plan.forward(
¶meters,
[(images, batch_images), (targets, batch_targets)],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 10 ~ 2.30)"
);
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < STEPS * 3 / 4 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
if (step + 1) % 250 == 0 {
let correct = probe_correct(
¶meters,
&probe_plan,
probe_images,
probe_logits,
&test,
0,
);
println!(
"step {:4}: minibatch loss = {batch_loss:.4}, probe accuracy = {:.1}%",
step + 1,
correct as f64 * 100.0 / PROBE_LEN as f64
);
}
}
let elapsed = training.elapsed().as_secs_f64();
println!(
"trained {STEPS} steps in {elapsed:.3}s ({:.1} ms/step)",
elapsed * 1000.0 / STEPS as f64
);
// The full test set, probed chunk by chunk through the same tape.
let correct: usize = (0..test.len() / PROBE_LEN)
.map(|chunk| {
probe_correct(
¶meters,
&probe_plan,
probe_images,
probe_logits,
&test,
chunk * PROBE_LEN,
)
})
.sum();
println!(
"test accuracy: {:.2}% over {} images",
correct as f64 * 100.0 / test.len() as f64,
test.len()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("mnist convnet training", &losses));
}cifar10
Trains a small VGG-style convolutional network on CIFAR-10 — the
plan tier's pressure consumer: three conv/relu/pool stages over
real 32x32 color images, a dense head, and cross_entropy,
compiled once into a training plan and a forward-only probe plan.
The training plan retains what backward reads and outlives every
training step; the probe plan's liveness pass frees each intermediate
after its last consumer, which is what keeps the 500-image probe's
footprint flat. Both print through Plan::describe's summary at
startup, and every convolution runs as one im2col matrix product.
The binary archive (~170 MB) downloads on first run into
examples/cifar10/data/ and is cached.
Run with: cargo run --release --example cifar10
loaded 50000 training and 10000 test images
recorded 190 nodes for both expressions
probe plan: forward; 94 of 190 nodes evaluated, 1 readable
probe fused 6 groups, 51 nodes replaced
probe live volume: peak 24609386 elements at node 115, retain-all 106079778
step 0: minibatch loss = 6.9194 (a uniform model costs ln 10 ~ 2.30)
step 250: minibatch loss = 1.6048, probe accuracy = 41.0%
step 500: minibatch loss = 1.4293, probe accuracy = 48.4%
step 750: minibatch loss = 1.1636, probe accuracy = 55.0%
step 1000: minibatch loss = 1.2969, probe accuracy = 57.8%
step 1250: minibatch loss = 0.9808, probe accuracy = 55.0%
step 1500: minibatch loss = 1.3797, probe accuracy = 60.4%
step 1750: minibatch loss = 1.0720, probe accuracy = 63.8%
step 2000: minibatch loss = 1.0377, probe accuracy = 65.4%
trained 2000 steps in 764.895s (382.4 ms/step)
test accuracy: 65.38% over 10000 images (chance is 10%)
the tape held 190 nodes through every step
cifar-10 convnet training
── minibatch ── rolling mean ── uniform start
12 ┤▌
│▌
9 ┤▌
l │▌
o │▌
s 6 ┤▌
s │▙
│█
3 ┤██▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
│ ▀▀▀▀▀▀▀▛███████████████████▙▙▙▙█▙▄██▙▄▙▙█▙▟▄▙▄▙▙▟▟▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
0 ┤ ▘ ▘ ▝▝ ▀▀ ▘▀▀▀▀▀▀▀▀▘▀▀▀▀▀▀▀▀▝▘
└┬────────┬────────┬────────┬─────────┬────────┬────────┬────────┬────────┬
0 250 500 750 1000 1250 1500 1750 2000
stepcargo run --release --example cifar10 printed into a pipe.The source, examples/cifar10/main.rs
mod chart;
mod dataset;
use std::time::Instant;
use topos::{
Conv2d, Linear, Module, Parameters, Plan, Shape, Symbol, Tape, Tensor, Value, cross_entropy,
init, max_pool,
};
use chart::loss_chart;
use dataset::{Split, load, shuffle};
/// The image side length; CIFAR-10 images are `32 x 32`.
const IMAGE_SIDE: usize = 32;
/// How many values one image holds: three channel planes.
const PIXELS: usize = 3 * IMAGE_SIDE * IMAGE_SIDE;
/// How many classes the head scores.
const CLASSES: usize = 10;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many test images the accuracy probe feeds per run.
const PROBE_LEN: usize = 500;
/// How many filters the three convolution stages learn.
const FILTERS: [usize; 3] = [16, 32, 64];
/// The flattened feature length after three 2x2 pools: `64 * 4 * 4`.
const FLAT_LEN: usize = FILTERS[2] * (IMAGE_SIDE / 8) * (IMAGE_SIDE / 8);
/// How many training steps to run: about two and a half epochs.
const STEPS: usize = 2000;
/// The model's layers, holding parameter symbols.
struct Model {
conv_1: Conv2d<f32>,
conv_2: Conv2d<f32>,
conv_3: Conv2d<f32>,
head: Linear<f32>,
}
impl Model {
/// Allocates the parameters on `tape`: three 3x3 same-padded
/// convolution stages with Kaiming-scaled kernels and zero biases,
/// and an affine classification head.
fn new(tape: &Tape<f32>) -> Self {
// Kaiming deviations by kernel fan-in: `sqrt(2 / (c * kh * kw))`.
let conv_1_weights =
init::normal(21, (2.0 / 27.0_f64).sqrt())(&Shape::new([FILTERS[0], 3, 3, 3]));
let conv_2_weights =
init::normal(22, (2.0 / 144.0_f64).sqrt())(&Shape::new([FILTERS[1], FILTERS[0], 3, 3]));
let conv_3_weights =
init::normal(23, (2.0 / 288.0_f64).sqrt())(&Shape::new([FILTERS[2], FILTERS[1], 3, 3]));
let mut head_weights = init::kaiming(24);
Self {
conv_1: Conv2d::new(
tape,
conv_1_weights,
Tensor::filled([FILTERS[0]], 0.0),
1,
1,
),
conv_2: Conv2d::new(
tape,
conv_2_weights,
Tensor::filled([FILTERS[1]], 0.0),
1,
1,
),
conv_3: Conv2d::new(
tape,
conv_3_weights,
Tensor::filled([FILTERS[2]], 0.0),
1,
1,
),
head: Linear::new(
tape,
head_weights(&Shape::new([FLAT_LEN, CLASSES])),
head_weights(&Shape::new([CLASSES])),
),
}
}
/// Records the model's expression over `images` (`[rows, 3, 32,
/// 32]`) and returns the `[rows, 10]` logits: conv, rectify, pool,
/// three times, then flatten and score.
fn express<'tape>(&self, images: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let stage_1 = max_pool(self.conv_1.express(images).relu(), 2, 2);
let stage_2 = max_pool(self.conv_2.express(stage_1).relu(), 2, 2);
let stage_3 = max_pool(self.conv_3.express(stage_2).relu(), 2, 2);
self.head.express(stage_3.reshape([rows, FLAT_LEN]))
}
}
/// Builds the image and one-hot label payloads for the sample `indices`.
fn batch_payloads(split: &Split, indices: &[usize]) -> (Tensor<f32>, Tensor<f32>) {
let mut pixels = Vec::with_capacity(indices.len() * PIXELS);
for &index in indices {
pixels.extend_from_slice(&split.pixels[index * PIXELS..(index + 1) * PIXELS]);
}
let labels: Vec<usize> = indices.iter().map(|&index| split.labels[index]).collect();
(
Tensor::new([indices.len(), 3, IMAGE_SIDE, IMAGE_SIDE], pixels),
Tensor::selection(labels, CLASSES, 1.0),
)
}
/// Counts correct predictions over `PROBE_LEN` test images from `start`,
/// one probe-plan run.
fn probe_correct(
parameters: &Parameters<f32>,
probe_plan: &Plan<f32>,
images_symbol: Symbol,
logits_symbol: Symbol,
test: &Split,
start: usize,
) -> usize {
let indices: Vec<usize> = (start..start + PROBE_LEN).collect();
let (images, _) = batch_payloads(test, &indices);
let run = probe_plan.forward(parameters, [(images_symbol, images)]);
let logits = run.of(logits_symbol).to_vec();
let mut correct = 0;
for (row, &index) in logits.chunks(CLASSES).zip(&indices) {
let predicted = row
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).expect("logits are finite"))
.map(|(class, _)| class)
.expect("a logit row is never empty");
if predicted == test.labels[index] {
correct += 1;
}
}
correct
}
fn main() {
let (train, test) = load();
println!(
"loaded {} training and {} test images",
train.len(),
test.len()
);
let tape = Tape::new();
let model = Model::new(&tape);
// The training expression: images and one-hot targets are fed per
// run, the defaults only fix the shapes.
let images = tape.input(Tensor::filled(
[BATCH_LEN, 3, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], CLASSES, 1.0));
let loss = cross_entropy(model.express(images, BATCH_LEN), targets);
// The accuracy twin over a probe of test images.
let probe_images = tape.input(Tensor::filled(
[PROBE_LEN, 3, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let probe_logits = model.express(probe_images, PROBE_LEN);
let (images, targets, loss, probe_images, probe_logits) = (
images.symbol(),
targets.symbol(),
loss.symbol(),
probe_images.symbol(),
probe_logits.symbol(),
);
let recorded_nodes = tape.len();
println!("recorded {recorded_nodes} nodes for both expressions");
let network = tape.into_network();
let mut parameters = network.parameters();
// Entry once, run every step.
let training_plan = network.entry([loss]).backward().lower();
let probe_plan = network.entry([probe_logits]).lower();
for line in probe_plan.describe().lines().filter(|line| {
line.starts_with("plan:") || line.starts_with("live volume:") || line.starts_with("fused")
}) {
println!("probe {line}");
}
let mut order: Vec<usize> = (0..train.len()).collect();
let mut shuffle_state: u64 = 3;
shuffle(&mut order, &mut shuffle_state);
let fast = Tensor::new([], [0.05_f32]);
let slow = Tensor::new([], [0.005_f32]);
let mut losses = Vec::new();
let training = Instant::now();
for step in 0..STEPS {
let start = (step * BATCH_LEN) % (train.len() - BATCH_LEN);
let batch = &order[start..start + BATCH_LEN];
let (batch_images, batch_targets) = batch_payloads(&train, batch);
let run = training_plan.forward(
¶meters,
[(images, batch_images), (targets, batch_targets)],
);
let batch_loss = run.of(loss).scalar();
losses.push(batch_loss);
if step == 0 {
println!(
"step 0: minibatch loss = {batch_loss:.4} (a uniform model costs ln 10 ~ 2.30)"
);
}
let gradients = run.backward(loss).parameters(¶meters);
let learning_rate = if step < STEPS * 3 / 4 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
if (step + 1) % 250 == 0 {
let correct = probe_correct(
¶meters,
&probe_plan,
probe_images,
probe_logits,
&test,
0,
);
println!(
"step {:4}: minibatch loss = {batch_loss:.4}, probe accuracy = {:.1}%",
step + 1,
correct as f64 * 100.0 / PROBE_LEN as f64
);
}
}
let elapsed = training.elapsed().as_secs_f64();
println!(
"trained {STEPS} steps in {elapsed:.3}s ({:.1} ms/step)",
elapsed * 1000.0 / STEPS as f64
);
// The full test set, probed chunk by chunk through the same plan.
let correct: usize = (0..test.len() / PROBE_LEN)
.map(|chunk| {
probe_correct(
¶meters,
&probe_plan,
probe_images,
probe_logits,
&test,
chunk * PROBE_LEN,
)
})
.sum();
println!(
"test accuracy: {:.2}% over {} images (chance is 10%)",
correct as f64 * 100.0 / test.len() as f64,
test.len()
);
assert_eq!(network.len(), recorded_nodes);
println!("the tape held {recorded_nodes} nodes through every step");
println!("{}", loss_chart("cifar-10 convnet training", &losses));
}mnist_grading
Grades the training routes of the mnist convnet against each
other — the companion measurement to cifar10_grading, run on
the one consumer where rematerialization ever won. Its 2026-08-14
verdict retired remat: the recorded route beat retain-all (~78 vs
~89 ms/step, ~270 vs ~365 MiB peak RSS) and beat remat's own
numbers (~98 ms/step, ~330 MiB) on both axes at once.
The model, seeds, and batch schedule are identical to mnist, so
the routes train bit-identically; what changes is where the
gradients come from and what the plan retains:
engine:backward()+backward(retain-all).recorded:differentiate+ one forward-only plan over[loss, gradients...]+recorded_gradients— no backward pass executes at all.
One route runs per process so an external monitor attributes the
peak RSS cleanly; pick it with TOPOS_ROUTE and the step
count with TOPOS_STEPS. The end prints the first and final
losses as exact bit patterns, so route parity is checked by
diffing two lines.
Run with: /usr/bin/time -l cargo run --release --example mnist_grading (set TOPOS_ROUTE=engine|recorded).
A measurement: one training route per process, timed and memory-monitored from outside. Its verdict is in the doc comment.
The source, examples/mnist/grading.rs
mod dataset;
use std::time::Instant;
use topos::{
Conv2d, Linear, Module, Shape, Symbol, Tape, Tensor, Value, cross_entropy, init, max_pool,
};
use dataset::{Split, load, shuffle};
/// The image side length; MNIST digits are `28 x 28`.
const IMAGE_SIDE: usize = 28;
/// How many pixels one image holds.
const PIXELS: usize = IMAGE_SIDE * IMAGE_SIDE;
/// How many digit classes the head scores.
const CLASSES: usize = 10;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many filters the first convolution stage learns.
const FILTERS_1: usize = 8;
/// How many filters the second convolution stage learns.
const FILTERS_2: usize = 16;
/// The flattened feature length after two 2x2 pools: `16 * 7 * 7`.
const FLAT_LEN: usize = FILTERS_2 * (IMAGE_SIDE / 4) * (IMAGE_SIDE / 4);
/// The model's layers, holding parameter symbols; identical to
/// `mnist`, including every seed.
struct Model {
conv_1: Conv2d<f32>,
conv_2: Conv2d<f32>,
head: Linear<f32>,
}
impl Model {
/// Allocates the parameters on `tape` exactly as `mnist` does,
/// so the routes train the same trajectory it would.
fn new(tape: &Tape<f32>) -> Self {
let conv_1_weights =
init::normal(11, (2.0 / 9.0_f64).sqrt())(&Shape::new([FILTERS_1, 1, 3, 3]));
let conv_2_weights =
init::normal(12, (2.0 / 72.0_f64).sqrt())(&Shape::new([FILTERS_2, FILTERS_1, 3, 3]));
let mut head_weights = init::kaiming(13);
Self {
conv_1: Conv2d::new(tape, conv_1_weights, Tensor::filled([FILTERS_1], 0.0), 1, 1),
conv_2: Conv2d::new(tape, conv_2_weights, Tensor::filled([FILTERS_2], 0.0), 1, 1),
head: Linear::new(
tape,
head_weights(&Shape::new([FLAT_LEN, CLASSES])),
head_weights(&Shape::new([CLASSES])),
),
}
}
/// Records the model's expression over `images` and returns the
/// `[rows, 10]` logits: conv, rectify, pool, twice, then flatten
/// and score.
fn express<'tape>(&self, images: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let stage_1 = max_pool(self.conv_1.express(images).relu(), 2, 2);
let stage_2 = max_pool(self.conv_2.express(stage_1).relu(), 2, 2);
self.head.express(stage_2.reshape([rows, FLAT_LEN]))
}
/// Returns the parameter symbols in a fixed order, for `wrt` and
/// for pairing with their recorded gradients.
fn parameters(&self) -> [Symbol; 6] {
[
self.conv_1.weights(),
self.conv_1.bias(),
self.conv_2.weights(),
self.conv_2.bias(),
self.head.weights(),
self.head.bias(),
]
}
}
/// Builds the image and one-hot label payloads for the sample `indices`.
fn batch_payloads(split: &Split, indices: &[usize]) -> (Tensor<f32>, Tensor<f32>) {
let mut pixels = Vec::with_capacity(indices.len() * PIXELS);
for &index in indices {
pixels.extend_from_slice(&split.pixels[index * PIXELS..(index + 1) * PIXELS]);
}
let labels: Vec<usize> = indices.iter().map(|&index| split.labels[index]).collect();
(
Tensor::new([indices.len(), 1, IMAGE_SIDE, IMAGE_SIDE], pixels),
Tensor::selection(labels, CLASSES, 1.0),
)
}
fn main() {
let route = std::env::var("TOPOS_ROUTE").unwrap_or_else(|_| "engine".to_string());
let steps: usize = std::env::var("TOPOS_STEPS")
.ok()
.and_then(|steps| steps.parse().ok())
.unwrap_or(300);
let (train, _test) = load();
println!("route {route}: {steps} steps over {} images", train.len());
let recorded = match route.as_str() {
"engine" => false,
"recorded" => true,
other => panic!("unknown TOPOS_ROUTE {other:?}; use engine or recorded"),
};
let tape = Tape::new();
let model = Model::new(&tape);
let images = tape.input(Tensor::filled(
[BATCH_LEN, 1, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], CLASSES, 1.0));
let loss = cross_entropy(model.express(images, BATCH_LEN), targets);
let (images, targets, loss) = (images.symbol(), targets.symbol(), loss.symbol());
let parameter_symbols = model.parameters();
let forward_nodes = tape.len();
// The routes differ only here: what the plan computes and where
// the gradients come from.
let adjoints = recorded.then(|| {
let adjoints = tape.differentiate(loss, parameter_symbols);
println!(
"recorded the chain rule: {forward_nodes} forward nodes + {} gradient nodes",
tape.len() - forward_nodes
);
adjoints
});
let network = tape.into_network();
let mut parameters = network.parameters();
let plan = match &adjoints {
Some(adjoints) => network.entry(adjoints.roots()).lower(),
None => network.entry([loss]).backward().lower(),
};
for line in plan
.describe()
.lines()
.filter(|line| line.starts_with("plan:") || line.starts_with("live volume:"))
{
println!("{line}");
}
let mut order: Vec<usize> = (0..train.len()).collect();
let mut shuffle_state: u64 = 5;
shuffle(&mut order, &mut shuffle_state);
let fast = Tensor::new([], [0.1_f32]);
let slow = Tensor::new([], [0.01_f32]);
let mut first_loss = 0.0_f32;
let mut last_loss = 0.0_f32;
let training = Instant::now();
for step in 0..steps {
let start = (step * BATCH_LEN) % (train.len() - BATCH_LEN);
let batch = &order[start..start + BATCH_LEN];
let (batch_images, batch_targets) = batch_payloads(&train, batch);
let run = plan.forward(
¶meters,
[(images, batch_images), (targets, batch_targets)],
);
let batch_loss = run.of(loss).scalar();
if step == 0 {
first_loss = batch_loss;
}
last_loss = batch_loss;
let gradients = match &adjoints {
Some(adjoints) => run.recorded_gradients(adjoints),
None => run.backward(loss).parameters(¶meters),
};
let learning_rate = if step < steps * 3 / 4 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
let elapsed = training.elapsed().as_secs_f64();
// Exact bit patterns: two routes agree exactly when these two
// lines match.
println!(
"loss step 0: {first_loss:.6} ({:08x}), step {}: {last_loss:.6} ({:08x})",
first_loss.to_bits(),
steps - 1,
last_loss.to_bits(),
);
println!(
"route {route}: {steps} steps in {elapsed:.3}s ({:.1} ms/step)",
elapsed * 1000.0 / steps as f64
);
}cifar10_grading
Grades the training routes of the cifar10 convnet against each
other — the CIFAR memory story the differentiate design left
open. Its 2026-08-14 verdict: conv gradients recorded through
Fold beat the engine postures on both axes (~337 ms/step and
~1.05 GiB against retain-all's ~379/1.35; the since-retired remat
posture measured ~414/1.31).
The model, seeds, and batch schedule are identical to cifar10,
so the routes train bit-identically; what changes is where the
gradients come from and what the plan retains:
engine:backward()+backward(retain-all).recorded:differentiate+ one forward-only plan over[loss, gradients...]+recorded_gradients— no backward pass executes at all.
One route runs per process so an external monitor attributes the
peak RSS cleanly; pick it with TOPOS_ROUTE and the step
count with TOPOS_STEPS. Each step prints nothing; the end
prints the first and final losses as exact bit patterns, so route
parity is checked by diffing two lines.
Run with: /usr/bin/time -l cargo run --release --example cifar10_grading (set TOPOS_ROUTE=engine|recorded).
A measurement: one training route per process, timed and memory-monitored from outside. Its verdict is in the doc comment.
The source, examples/cifar10/grading.rs
mod dataset;
use std::time::Instant;
use topos::{
Conv2d, Linear, Module, Shape, Symbol, Tape, Tensor, Value, cross_entropy, init, max_pool,
};
use dataset::{Split, load, shuffle};
/// The image side length; CIFAR-10 images are `32 x 32`.
const IMAGE_SIDE: usize = 32;
/// How many values one image holds: three channel planes.
const PIXELS: usize = 3 * IMAGE_SIDE * IMAGE_SIDE;
/// How many classes the head scores.
const CLASSES: usize = 10;
/// How many samples each training step feeds.
const BATCH_LEN: usize = 64;
/// How many filters the three convolution stages learn.
const FILTERS: [usize; 3] = [16, 32, 64];
/// The flattened feature length after three 2x2 pools: `64 * 4 * 4`.
const FLAT_LEN: usize = FILTERS[2] * (IMAGE_SIDE / 8) * (IMAGE_SIDE / 8);
/// The model's layers, holding parameter symbols; identical to
/// `cifar10`, including every seed.
struct Model {
conv_1: Conv2d<f32>,
conv_2: Conv2d<f32>,
conv_3: Conv2d<f32>,
head: Linear<f32>,
}
impl Model {
/// Allocates the parameters on `tape` exactly as `cifar10`
/// does, so the routes train the same trajectory it would.
fn new(tape: &Tape<f32>) -> Self {
let conv_1_weights =
init::normal(21, (2.0 / 27.0_f64).sqrt())(&Shape::new([FILTERS[0], 3, 3, 3]));
let conv_2_weights =
init::normal(22, (2.0 / 144.0_f64).sqrt())(&Shape::new([FILTERS[1], FILTERS[0], 3, 3]));
let conv_3_weights =
init::normal(23, (2.0 / 288.0_f64).sqrt())(&Shape::new([FILTERS[2], FILTERS[1], 3, 3]));
let mut head_weights = init::kaiming(24);
Self {
conv_1: Conv2d::new(
tape,
conv_1_weights,
Tensor::filled([FILTERS[0]], 0.0),
1,
1,
),
conv_2: Conv2d::new(
tape,
conv_2_weights,
Tensor::filled([FILTERS[1]], 0.0),
1,
1,
),
conv_3: Conv2d::new(
tape,
conv_3_weights,
Tensor::filled([FILTERS[2]], 0.0),
1,
1,
),
head: Linear::new(
tape,
head_weights(&Shape::new([FLAT_LEN, CLASSES])),
head_weights(&Shape::new([CLASSES])),
),
}
}
/// Records the model's expression over `images` and returns the
/// `[rows, 10]` logits: conv, rectify, pool, three times, then
/// flatten and score.
fn express<'tape>(&self, images: Value<'tape, f32>, rows: usize) -> Value<'tape, f32> {
let stage_1 = max_pool(self.conv_1.express(images).relu(), 2, 2);
let stage_2 = max_pool(self.conv_2.express(stage_1).relu(), 2, 2);
let stage_3 = max_pool(self.conv_3.express(stage_2).relu(), 2, 2);
self.head.express(stage_3.reshape([rows, FLAT_LEN]))
}
/// Returns the parameter symbols in a fixed order, for `wrt` and
/// for pairing with their recorded gradients.
fn parameters(&self) -> [Symbol; 8] {
[
self.conv_1.weights(),
self.conv_1.bias(),
self.conv_2.weights(),
self.conv_2.bias(),
self.conv_3.weights(),
self.conv_3.bias(),
self.head.weights(),
self.head.bias(),
]
}
}
/// Builds the image and one-hot label payloads for the sample `indices`.
fn batch_payloads(split: &Split, indices: &[usize]) -> (Tensor<f32>, Tensor<f32>) {
let mut pixels = Vec::with_capacity(indices.len() * PIXELS);
for &index in indices {
pixels.extend_from_slice(&split.pixels[index * PIXELS..(index + 1) * PIXELS]);
}
let labels: Vec<usize> = indices.iter().map(|&index| split.labels[index]).collect();
(
Tensor::new([indices.len(), 3, IMAGE_SIDE, IMAGE_SIDE], pixels),
Tensor::selection(labels, CLASSES, 1.0),
)
}
fn main() {
let route = std::env::var("TOPOS_ROUTE").unwrap_or_else(|_| "engine".to_string());
let steps: usize = std::env::var("TOPOS_STEPS")
.ok()
.and_then(|steps| steps.parse().ok())
.unwrap_or(300);
let (train, _test) = load();
println!("route {route}: {steps} steps over {} images", train.len());
let recorded = match route.as_str() {
"engine" => false,
"recorded" => true,
other => panic!("unknown TOPOS_ROUTE {other:?}; use engine or recorded"),
};
let tape = Tape::new();
let model = Model::new(&tape);
let images = tape.input(Tensor::filled(
[BATCH_LEN, 3, IMAGE_SIDE, IMAGE_SIDE],
0.0_f32,
));
let targets = tape.input(Tensor::selection(vec![0; BATCH_LEN], CLASSES, 1.0));
let loss = cross_entropy(model.express(images, BATCH_LEN), targets);
let (images, targets, loss) = (images.symbol(), targets.symbol(), loss.symbol());
let parameter_symbols = model.parameters();
let forward_nodes = tape.len();
// The routes differ only here: what the plan computes and where
// the gradients come from.
let adjoints = recorded.then(|| {
let adjoints = tape.differentiate(loss, parameter_symbols);
println!(
"recorded the chain rule: {forward_nodes} forward nodes + {} gradient nodes",
tape.len() - forward_nodes
);
adjoints
});
let network = tape.into_network();
let mut parameters = network.parameters();
let plan = match &adjoints {
Some(adjoints) => network.entry(adjoints.roots()).lower(),
None => network.entry([loss]).backward().lower(),
};
for line in plan
.describe()
.lines()
.filter(|line| line.starts_with("plan:") || line.starts_with("live volume:"))
{
println!("{line}");
}
let mut order: Vec<usize> = (0..train.len()).collect();
let mut shuffle_state: u64 = 3;
shuffle(&mut order, &mut shuffle_state);
let fast = Tensor::new([], [0.05_f32]);
let slow = Tensor::new([], [0.005_f32]);
let mut first_loss = 0.0_f32;
let mut last_loss = 0.0_f32;
let training = Instant::now();
for step in 0..steps {
let start = (step * BATCH_LEN) % (train.len() - BATCH_LEN);
let batch = &order[start..start + BATCH_LEN];
let (batch_images, batch_targets) = batch_payloads(&train, batch);
let run = plan.forward(
¶meters,
[(images, batch_images), (targets, batch_targets)],
);
let batch_loss = run.of(loss).scalar();
if step == 0 {
first_loss = batch_loss;
}
last_loss = batch_loss;
let gradients = match &adjoints {
Some(adjoints) => run.recorded_gradients(adjoints),
None => run.backward(loss).parameters(¶meters),
};
let learning_rate = if step < steps * 3 / 4 { &fast } else { &slow };
parameters = parameters.step(&gradients, |parameter, gradient| {
parameter.clone() - gradient.clone() * learning_rate.broadcast_like(gradient)
});
}
let elapsed = training.elapsed().as_secs_f64();
// Exact bit patterns: two routes agree exactly when these two
// lines match.
println!(
"loss step 0: {first_loss:.6} ({:08x}), step {}: {last_loss:.6} ({:08x})",
first_loss.to_bits(),
steps - 1,
last_loss.to_bits(),
);
println!(
"route {route}: {steps} steps in {elapsed:.3}s ({:.1} ms/step)",
elapsed * 1000.0 / steps as f64
);
}Language models#
Released weights, recorded from the public op surface with no new opcodes. Each has its own page with the four engines, the measurements, and the cautionary tale.
gpt2
Generates text with OpenAI's released GPT-2 (124M) weights — the whole model a module tree recorded on the tape from the existing op surface.
The model lives in model.rs as ordinary [Module]
implementations: twelve pre-norm blocks (structs of Linears and
LayerNorms around a custom attention module) stacked in a plain
Vec, with the GELU tanh approximation's constants held as
scalar leaves — float constants are caller territory. The tree's
visit paths mirror the checkpoint's own tensor names, so loading
is one named_restore over the safetensors name map instead of a
hand-rolled per-tensor loader. The embedded token arrives as a
per-run input (the vocabulary lookup is loop-land data
preparation, a row copy), and the tied language-model head is the
embedding table transposed. Plans at a fixed context serve every
generation step, so generating never regrows the tape.
One tape records two expressions of the same parameters
(notes/carry.md): the full-context window and a one-token decode
step whose per-layer key and value caches are caller-carried
per-run inputs — the [Carry], advanced from each run's declared
cache outputs. The default tape engine generates through the
decode plan (prefill is token-by-token through the same plan);
full keeps the full-context loop for comparison. bf16 records
the identical module tree over Tensor<Bf16> — the genericity the
module tier promises, with the matmuls accumulating in f32 by the
payload's contract. xla emits the full-context f32 plan as
StableHLO and holds a serving process
(tools/serve-stablehlo-xla.py) that compiles it once, keeps the
124M parameters resident, and answers each step over binary pipes —
the parameters cross the boundary once, each step ships only the
embedded window. TOPOS_XLA_PYTHON names the Python (any with
jax; default python3), and JAX_PLATFORMS picks the XLA
backend. Stated as measured, M1 Pro with accelerate: the decode
plan generates at 18 ms/token f32 and 31 bf16 against the
full-context loop's 193 and 341, reproducing its text token for
token; XLA-CPU serves the full-context plan at 132 ms/token and
reproduces the same text; JAX_PLATFORMS=METAL under a
jax-metal environment runs at 26 ms/token but miscomputes this
module (Apple's experimental plugin; the small conformance
modules pass, this one does not) — caught precisely because the
tape, XLA-CPU, and the reference interpreter agree with each
other.
The checkpoint (548 MB) and tokenizer download and cache on first
run. Run with:
cargo run --release --features accelerate --example gpt2 -- "prompt" 40 xla
loaded the checkpoint in 0.3s recorded 5559 nodes and compiled the decode plan in 0.0s prefilled 5 tokens in 0.12s Once upon a time, everyone seemed to be doing it for business, and then it happened. In the decades before the Internet, most businesses were all about their business. The companies who had a business were the generated 40 tokens on the tape engine in 0.7s (18 ms/token)
cargo run --release --features accelerate --example gpt2 -- "Once upon a time" 40 printed into a pipe.The source, examples/gpt2/main.rs
mod model;
mod tokenizer;
mod weights;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::Instant;
use topos::{Bf16, Element, Emittable, Module, Plan, Run, Symbol, Tape, Tensor, checkpoint};
use model::{CONTEXT_LEN, EMBED_DIM, Gpt2, VOCABULARY_LEN, load};
use tokenizer::Tokenizer;
use weights::{Weights, cached_text};
/// The end-of-text token that opens and closes generation.
const END_OF_TEXT: usize = 50256;
/// Which executor and which plan run the generation loop.
enum Engine {
/// The one-token decode plan on topos's own interpreter.
Decode,
/// The full-context plan on topos's own interpreter, kept as the
/// decode loop's comparison baseline.
Full,
/// The full-context plan emitted as StableHLO under a serving XLA
/// process.
Xla,
}
/// The recorded sampling expression's feed and read symbols.
struct Sampler {
stream: Symbol,
extraction: Symbol,
logits: Symbol,
}
/// Records the sampling expression over `model`: the embedded window
/// and the extraction row are per-run inputs, and the logits are the
/// tied head — the embedding table transposed.
fn record<E: Element + From<f32> + 'static>(tape: &Tape<E>, model: &Gpt2<E>) -> Sampler {
let embedded = tape.input(Tensor::filled([CONTEXT_LEN, EMBED_DIM], E::from(0.0)));
let extraction = tape.input(Tensor::selection(vec![0], CONTEXT_LEN, E::from(1.0)));
let last = model.express(embedded).gather(extraction);
let logits = last.matmul(tape.resolve(model.embeddings()).transpose());
Sampler {
stream: embedded.symbol(),
extraction: extraction.symbol(),
logits: logits.symbol(),
}
}
/// One layer's cache symbols: the carried inputs and the updated
/// outputs the run pairs them with.
struct LayerCache {
keys_in: Symbol,
keys_out: Symbol,
values_in: Symbol,
values_out: Symbol,
}
/// The recorded decode step's feed and read symbols.
struct Decoder {
stream: Symbol,
position: Symbol,
mask: Symbol,
logits: Symbol,
caches: Vec<LayerCache>,
}
/// Records the one-token decode step over `model`: the embedded row,
/// the position one-hot (placing the cache appends and gathering the
/// position row), the mask row, and the per-layer caches are per-run
/// inputs. The logits are the tied head spelled `(wte . row^T)^T`, so
/// no run materializes the transposed table.
fn record_decode<E: Element + From<f32> + 'static>(tape: &Tape<E>, model: &Gpt2<E>) -> Decoder {
let zeros = |shape: [usize; 2]| Tensor::filled(shape, E::from(0.0));
let stream = tape.input(zeros([1, EMBED_DIM]));
let position = tape.input(Tensor::selection(vec![0], CONTEXT_LEN, E::from(1.0)));
let mask = tape.input(zeros([1, CONTEXT_LEN]));
let pairs: Vec<_> = (0..model.layers())
.map(|_| {
(
tape.input(zeros([CONTEXT_LEN, EMBED_DIM])),
tape.input(zeros([CONTEXT_LEN, EMBED_DIM])),
)
})
.collect();
let (last, updated) = model.express_decode(tape, stream, &pairs, position, mask);
let logits = tape
.resolve(model.embeddings())
.matmul(last.transpose())
.transpose();
let caches = pairs
.iter()
.zip(&updated)
.map(
|((keys_in, values_in), (keys_out, values_out))| LayerCache {
keys_in: keys_in.symbol(),
keys_out: keys_out.symbol(),
values_in: values_in.symbol(),
values_out: values_out.symbol(),
},
)
.collect();
Decoder {
stream: stream.symbol(),
position: position.symbol(),
mask: mask.symbol(),
logits: logits.symbol(),
caches,
}
}
/// The decode loop's pending feeds: one payload per carried cache
/// input, advanced from each run's declared cache outputs
/// (`notes/carry.md`).
///
/// Feeding and advancing clone the caches; the zero-copy spelling (a
/// consuming read on `Run`, the unfed-only default overlay in
/// `Plan::forward`) was built and graded flat on both axes at this
/// scale — ~57 MB of clones per step ride under the ~500 MB of
/// weight traffic — so the simpler spelling stays, per the numbers.
struct Carry<E> {
entries: Vec<(Symbol, Tensor<E>)>,
}
impl<E: Element + From<f32>> Carry<E> {
/// Returns the empty caches: generation's initial state.
fn new(caches: &[LayerCache]) -> Self {
let zeros = || Tensor::filled([CONTEXT_LEN, EMBED_DIM], E::from(0.0));
let entries = caches
.iter()
.flat_map(|cache| [(cache.keys_in, zeros()), (cache.values_in, zeros())])
.collect();
Self { entries }
}
/// Returns this step's cache feeds.
fn feeds(&self) -> impl Iterator<Item = (Symbol, Tensor<E>)> + '_ {
self.entries
.iter()
.map(|(symbol, payload)| (*symbol, payload.clone()))
}
/// Returns the carry advanced from `run`: each cache input's next
/// payload is its pair's output in the run.
fn advanced(run: &Run<E>, caches: &[LayerCache]) -> Self {
let entries = caches
.iter()
.flat_map(|cache| {
[
(cache.keys_in, run.of(cache.keys_out).clone()),
(cache.values_in, run.of(cache.values_out).clone()),
]
})
.collect();
Self { entries }
}
}
/// The XLA serving process: the emitted plan compiled once, the
/// parameters resident, one execution per written step.
struct XlaServer {
child: Child,
requests: ChildStdin,
responses: ChildStdout,
}
impl XlaServer {
/// Emits the plan, stages `arguments` — the parameter payloads in
/// the emitted argument order — and starts the server.
fn new<E>(plan: &Plan<E>, arguments: &[Tensor<E>]) -> Self
where
E: Element + Emittable + Copy,
f32: From<E>,
{
let directory = weights::cache_directory();
let module_path = directory.join("gpt2-plan.mlir");
let static_path = directory.join("gpt2-static.bin");
let manifest_path = directory.join("gpt2-manifest.json");
std::fs::write(&module_path, plan.emit_stablehlo().expect("the plan emits"))
.expect("the module writes");
let mut staged = Vec::new();
for tensor in arguments {
let axes = tensor.shape().axes().to_vec();
staged.extend((axes.len() as u32).to_le_bytes());
for extent in axes {
staged.extend((extent as u32).to_le_bytes());
}
for element in tensor.to_vec() {
staged.extend(f32::from(element).to_le_bytes());
}
}
std::fs::write(&static_path, staged).expect("the arguments write");
std::fs::write(
&manifest_path,
format!("{{\"dynamic\": [[{CONTEXT_LEN}, {EMBED_DIM}], [1, {CONTEXT_LEN}]]}}"),
)
.expect("the manifest writes");
let python = std::env::var("TOPOS_XLA_PYTHON").unwrap_or_else(|_| "python3".to_string());
let mut command: Vec<String> = python.split_whitespace().map(str::to_string).collect();
command.push("tools/serve-stablehlo-xla.py".to_string());
let mut child = Command::new(&command[0])
.args(&command[1..])
.arg(&module_path)
.arg(&static_path)
.arg(&manifest_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("the serving process starts; is `jax` installed for it?");
let requests = child.stdin.take().expect("the server's input pipes");
let responses = child.stdout.take().expect("the server's output pipes");
Self {
child,
requests,
responses,
}
}
/// Executes one step and returns the logits.
fn step(&mut self, stream: &[f32], extraction: &[f32]) -> Vec<f32> {
let mut request = Vec::with_capacity(4 * (stream.len() + extraction.len()));
for &value in stream.iter().chain(extraction) {
request.extend(value.to_le_bytes());
}
self.requests
.write_all(&request)
.expect("the request writes");
self.requests.flush().expect("the request flushes");
let mut response = vec![0u8; 4 * VOCABULARY_LEN];
self.responses
.read_exact(&mut response)
.expect("the server answers; see its standard error");
response
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes(chunk.try_into().expect("four bytes")))
.collect()
}
}
impl Drop for XlaServer {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Advances `state` and returns the next value uniformly in `[0, 1)`.
fn unit(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let bits = (*state >> 11) as f64;
bits / (1u64 << 53) as f64
}
/// Draws one token from `logits` under temperature and top-k.
fn draw(logits: &[f32], temperature: f64, top: usize, state: &mut u64) -> usize {
let mut ranked: Vec<(usize, f32)> = logits.iter().copied().enumerate().collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
ranked.truncate(top);
let peak = ranked[0].1 as f64;
let weights: Vec<f64> = ranked
.iter()
.map(|&(_, logit)| libm::exp((logit as f64 - peak) / temperature))
.collect();
let total: f64 = weights.iter().sum();
let mut remaining = unit(state) * total;
for (&(id, _), weight) in ranked.iter().zip(&weights) {
if remaining < *weight {
return id;
}
remaining -= weight;
}
ranked[0].0
}
/// Loads the checkpoint into a module tree of element type `E`,
/// compiles the sampling plan, and generates `count` tokens after
/// `prompt` on `engine`, reporting timings as `label`.
fn run<E>(prompt: &str, count: usize, engine: Engine, label: &str)
where
E: Element + Emittable + From<f32> + Copy + 'static,
f32: From<E>,
{
let loading = Instant::now();
let tokenizer = Tokenizer::new(&cached_text("vocab.json"), &cached_text("merges.txt"));
let weights = Weights::load();
// The module tree and the sampling expression record on one tape;
// sealing yields the immutable spec, and the named restore builds
// the parameter state that carries the checkpoint, converting
// elements at the precision boundary.
let tape = Tape::new();
let gpt2 = Gpt2::<E>::new(&tape);
let sampler = record(&tape, &gpt2);
let decoder = record_decode(&tape, &gpt2);
let network = tape.into_network();
let parameters = load(&network.parameters(), &gpt2, &weights);
drop(weights);
println!(
"loaded the checkpoint in {:.1}s",
loading.elapsed().as_secs_f64()
);
let mut window = vec![END_OF_TEXT];
window.extend(tokenizer.encode(prompt));
assert!(
window.len() + count <= CONTEXT_LEN,
"prompt and generation must fit the {CONTEXT_LEN}-token context"
);
assert_eq!(
tokenizer.decode(&window[1..]),
prompt,
"the tokenizer round-trips the prompt"
);
if let Engine::Decode = engine {
let compiling = Instant::now();
let outputs: Vec<Symbol> = decoder
.caches
.iter()
.flat_map(|cache| [cache.keys_out, cache.values_out])
.collect();
let plan: Plan<E> = network.entry([decoder.logits]).observe(outputs).lower();
println!(
"recorded {} nodes and compiled the decode plan in {:.1}s",
network.len(),
compiling.elapsed().as_secs_f64()
);
let table = parameters.of(gpt2.embeddings()).to_vec();
let row = |token: usize| {
Tensor::new(
[1, EMBED_DIM],
table[token * EMBED_DIM..(token + 1) * EMBED_DIM].to_vec(),
)
};
let mask_row = |until: usize| {
let elements: Vec<E> = (0..CONTEXT_LEN)
.map(|at| {
if at <= until {
E::from(0.0)
} else {
E::from(f32::NEG_INFINITY)
}
})
.collect();
Tensor::new([1, CONTEXT_LEN], elements)
};
// One step: feed the carry and the row's transients, read the
// logits, advance the carry from the run's cache outputs.
let step = |carry: Carry<E>, token: usize, at: usize| -> (Vec<f32>, Carry<E>) {
let run = plan.forward(
¶meters,
carry.feeds().chain([
(decoder.stream, row(token)),
(
decoder.position,
Tensor::selection(vec![at], CONTEXT_LEN, E::from(1.0)),
),
(decoder.mask, mask_row(at)),
]),
);
let logits = run.of(decoder.logits).to_vec();
(
logits.iter().map(|&element| f32::from(element)).collect(),
Carry::advanced(&run, &decoder.caches),
)
};
// Prefill is token-by-token through the same plan: the carry
// is the only state the steps share.
let prefilling = Instant::now();
let mut carry = Carry::new(&decoder.caches);
let mut logits = Vec::new();
for (at, &token) in window.iter().enumerate() {
(logits, carry) = step(carry, token, at);
}
println!(
"prefilled {} tokens in {:.2}s",
window.len(),
prefilling.elapsed().as_secs_f64()
);
print!("{prompt}");
let mut state: u64 = 7;
let generation = Instant::now();
for index in 0..count {
let token = draw(&logits, 0.9, 40, &mut state);
if token == END_OF_TEXT {
break;
}
window.push(token);
print!("{}", tokenizer.decode(&[token]));
std::io::stdout().flush().expect("stdout flushes");
if index + 1 < count {
(logits, carry) = step(carry, token, window.len() - 1);
}
}
let elapsed = generation.elapsed().as_secs_f64();
let generated = window.len() - 1 - tokenizer.encode(prompt).len();
println!();
println!(
"generated {generated} tokens on the {label} engine in {elapsed:.1}s ({:.0} ms/token)",
elapsed / generated.max(1) as f64 * 1e3
);
return;
}
let compiling = Instant::now();
let plan: Plan<E> = network.entry([sampler.logits]).lower();
println!(
"recorded {} nodes and compiled the plan in {:.1}s",
network.len(),
compiling.elapsed().as_secs_f64()
);
// The vocabulary lookup is data preparation: the window embeds by
// row copies from the table, and the plan adds the positions.
let table = parameters.of(gpt2.embeddings()).to_vec();
let embedded = |window: &[usize]| {
let mut stream = vec![E::from(0.0); CONTEXT_LEN * EMBED_DIM];
for (row, &token) in window.iter().enumerate() {
stream[row * EMBED_DIM..(row + 1) * EMBED_DIM]
.copy_from_slice(&table[token * EMBED_DIM..(token + 1) * EMBED_DIM]);
}
stream
};
let widened = |elements: &[E]| -> Vec<f32> {
elements.iter().map(|&element| f32::from(element)).collect()
};
let mut server = match engine {
Engine::Decode | Engine::Full => None,
Engine::Xla => {
println!("starting the XLA server (compiling the emitted plan) ...");
// The emitted module's leading arguments are the
// parameters in recording order; the tree records them in
// visit order, so the positional snapshot is exactly the
// argument list.
let arguments = checkpoint::snapshot(¶meters, &gpt2);
let mut server = XlaServer::new(&plan, &arguments);
// One warmup step absorbs the server's compile, keeping
// the per-token figure the steady state.
let extraction = Tensor::selection(vec![0], CONTEXT_LEN, 1.0_f32);
server.step(&widened(&embedded(&window)), &extraction.to_vec());
Some(server)
}
};
print!("{prompt}");
let mut state: u64 = 7;
let generation = Instant::now();
for _ in 0..count {
let stream = embedded(&window);
let extraction = Tensor::selection(vec![window.len() - 1], CONTEXT_LEN, E::from(1.0));
let logits = match &mut server {
Some(server) => server.step(&widened(&stream), &widened(&extraction.to_vec())),
None => {
let run = plan.forward(
¶meters,
[
(
sampler.stream,
Tensor::new([CONTEXT_LEN, EMBED_DIM], stream),
),
(sampler.extraction, extraction),
],
);
widened(&run.of(sampler.logits).to_vec())
}
};
let token = draw(&logits, 0.9, 40, &mut state);
if token == END_OF_TEXT {
break;
}
window.push(token);
print!("{}", tokenizer.decode(&[token]));
std::io::stdout().flush().expect("stdout flushes");
}
let elapsed = generation.elapsed().as_secs_f64();
let generated = window.len() - 1 - tokenizer.encode(prompt).len();
println!();
println!(
"generated {generated} tokens on the {label} engine in {elapsed:.1}s ({:.0} ms/token)",
elapsed / generated.max(1) as f64 * 1e3
);
}
fn main() {
let prompt = std::env::args()
.nth(1)
.unwrap_or_else(|| "The library of this place holds one book".to_string());
let count: usize = std::env::args()
.nth(2)
.map(|argument| argument.parse().expect("a token count"))
.unwrap_or(40);
let engine = std::env::args()
.nth(3)
.unwrap_or_else(|| "tape".to_string());
match engine.as_str() {
"tape" => run::<f32>(&prompt, count, Engine::Decode, "tape"),
"full" => run::<f32>(&prompt, count, Engine::Full, "full"),
"xla" => run::<f32>(&prompt, count, Engine::Xla, "xla"),
"bf16" => run::<Bf16>(&prompt, count, Engine::Decode, "bf16"),
other => panic!("unknown engine `{other}`; use `tape`, `full`, `xla`, or `bf16`"),
}
}llama
Generates text with released Llama-family weights — the whole architecture a module tree recorded on the tape from the existing op surface.
The model lives in model.rs as ordinary [Module]
implementations: pre-norm blocks (structs of bias-free projections
and RmsNorms around a grouped-query attention module) stacked in
a Sequential, with rotary position embeddings as precomputed
cosine and sine leaves and the SwiGLU MLP spelling SiLU from the
op surface — no new opcodes. The architecture is one and a model
is data: a Family descriptor picks TinyLlama 1.1B (the default)
or Llama 2 7B, and the same structs record either. The tree's
visit paths mirror the checkpoints' own tensor names, so loading
is one named_restore over tensors streamed shard by shard. The
embedded token window arrives as a per-run input (the vocabulary
lookup is loop-land data preparation, a row copy), and the untied
language-model head records after the prediction row's one-hot
extraction. One forward-only plan at a fixed context serves every
generation step, so generating never regrows the tape.
The same plan powers two runs: tape (the default) runs it on
topos's own interpreter over f32, and bf16 records the identical
module tree over Tensor<Bf16> — the genericity the module tier
promises, with the matmuls accumulating in f32 by the payload's
contract. At 7B, bf16 is also the practical engine: it keeps the
resident parameters near 13 GB where f32 wants 27.
The checkpoint and tokenizer download and cache on first run. Run
with:
cargo run --release --features accelerate --example llama -- "prompt" 40 bf16 llama2
loaded the checkpoint in 8.6s recorded 16722 nodes and compiled the plan in 0.0s Once upon a time, and what's the most popular toy? What's the most played video game for generations? What generated 24 tokens on the tape engine in 34.9s (1452 ms/token)
cargo run --release --features accelerate --example llama -- "Once upon a time" 24 printed into a pipe.The source, examples/llama/main.rs
mod family;
mod model;
mod tokenizer;
mod weights;
use std::io::Write;
use std::time::Instant;
use topos::{Bf16, Element, Module, Plan, Symbol, Tape, Tensor};
use family::{Family, LLAMA2_7B, TINYLLAMA};
use model::{CONTEXT_LEN, Llama, load};
use tokenizer::Tokenizer;
use weights::{Weights, cached_text};
/// The beginning-of-sequence token that opens generation.
const SEQUENCE_START: usize = 1;
/// The end-of-sequence token that closes generation.
const SEQUENCE_END: usize = 2;
/// The recorded sampling expression's feed and read symbols.
struct Sampler {
stream: Symbol,
extraction: Symbol,
logits: Symbol,
}
/// Records the sampling expression over `model`: the embedded window
/// and the extraction row are per-run inputs, and the logits are the
/// untied head over the extracted row.
fn record<E: Element + From<f32> + 'static>(
tape: &Tape<E>,
family: Family,
model: &Llama<E>,
) -> Sampler {
let embedded = tape.input(Tensor::filled(
[CONTEXT_LEN, family.embed_dim],
E::from(0.0),
));
let extraction = tape.input(Tensor::selection(vec![0], CONTEXT_LEN, E::from(1.0)));
let last = model.express(embedded).gather(extraction);
let logits = model.predict(last);
Sampler {
stream: embedded.symbol(),
extraction: extraction.symbol(),
logits: logits.symbol(),
}
}
/// Advances `state` and returns the next value uniformly in `[0, 1)`.
fn unit(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let bits = (*state >> 11) as f64;
bits / (1u64 << 53) as f64
}
/// Draws one token from `logits` under temperature and top-k.
fn draw(logits: &[f32], temperature: f64, top: usize, state: &mut u64) -> usize {
let mut ranked: Vec<(usize, f32)> = logits.iter().copied().enumerate().collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
ranked.truncate(top);
let peak = ranked[0].1 as f64;
let weights: Vec<f64> = ranked
.iter()
.map(|&(_, logit)| libm::exp((logit as f64 - peak) / temperature))
.collect();
let total: f64 = weights.iter().sum();
let mut remaining = unit(state) * total;
for (&(id, _), weight) in ranked.iter().zip(&weights) {
if remaining < *weight {
return id;
}
remaining -= weight;
}
ranked[0].0
}
/// Loads `family`'s checkpoint into a module tree of element type
/// `E`, compiles the sampling plan, and generates `count` tokens
/// after `prompt`, reporting timings as `label`.
fn run<E>(family: Family, prompt: &str, count: usize, label: &str)
where
E: Element + From<f32> + Copy + 'static,
f32: From<E>,
{
let loading = Instant::now();
let tokenizer = Tokenizer::new(&cached_text(&family, "tokenizer.json"));
let weights = Weights::open(&family);
// The module tree and the sampling expression record on one tape;
// sealing yields the immutable spec, and the named restore builds
// the parameter state that carries the checkpoint, converting
// elements at the precision boundary.
let tape = Tape::new();
let llama = Llama::<E>::new(&tape, family);
let sampler = record(&tape, family, &llama);
let network = tape.into_network();
let parameters = load(&network.parameters(), &llama, &weights);
drop(weights);
println!(
"loaded the checkpoint in {:.1}s",
loading.elapsed().as_secs_f64()
);
let mut window = vec![SEQUENCE_START];
window.extend(tokenizer.encode(prompt));
assert!(
window.len() + count <= CONTEXT_LEN,
"prompt and generation must fit the {CONTEXT_LEN}-token context"
);
assert_eq!(
tokenizer.decode(&window[1..]),
prompt,
"the tokenizer round-trips the prompt"
);
let compiling = Instant::now();
let plan: Plan<E> = network.entry([sampler.logits]).lower();
println!(
"recorded {} nodes and compiled the plan in {:.1}s",
network.len(),
compiling.elapsed().as_secs_f64()
);
// The vocabulary lookup is data preparation: the window embeds by
// row copies from the table; position enters inside the plan
// through the rotary leaves.
let embed_dim = family.embed_dim;
let table = parameters.of(llama.embeddings()).to_vec();
let embedded = |window: &[usize]| {
let mut stream = vec![E::from(0.0); CONTEXT_LEN * embed_dim];
for (row, &token) in window.iter().enumerate() {
stream[row * embed_dim..(row + 1) * embed_dim]
.copy_from_slice(&table[token * embed_dim..(token + 1) * embed_dim]);
}
stream
};
print!("{prompt}");
let mut state: u64 = 7;
let generation = Instant::now();
for _ in 0..count {
let stream = embedded(&window);
let extraction = Tensor::selection(vec![window.len() - 1], CONTEXT_LEN, E::from(1.0));
let run = plan.forward(
¶meters,
[
(
sampler.stream,
Tensor::new([CONTEXT_LEN, embed_dim], stream),
),
(sampler.extraction, extraction),
],
);
let logits: Vec<f32> = run
.of(sampler.logits)
.to_vec()
.into_iter()
.map(f32::from)
.collect();
let token = draw(&logits, 0.9, 40, &mut state);
if token == SEQUENCE_END {
break;
}
window.push(token);
print!("{}", tokenizer.piece(token));
std::io::stdout().flush().expect("stdout flushes");
}
let elapsed = generation.elapsed().as_secs_f64();
let generated = window.len() - 1 - tokenizer.encode(prompt).len();
println!();
println!(
"generated {generated} tokens on the {label} engine in {elapsed:.1}s ({:.0} ms/token)",
elapsed / generated.max(1) as f64 * 1e3
);
}
fn main() {
let prompt = std::env::args()
.nth(1)
.unwrap_or_else(|| "The library of this place holds one book".to_string());
let count: usize = std::env::args()
.nth(2)
.map(|argument| argument.parse().expect("a token count"))
.unwrap_or(40);
let engine = std::env::args()
.nth(3)
.unwrap_or_else(|| "tape".to_string());
let member = std::env::args()
.nth(4)
.unwrap_or_else(|| "tinyllama".to_string());
let family = match member.as_str() {
"tinyllama" => TINYLLAMA,
"llama2" => LLAMA2_7B,
other => panic!("unknown model `{other}`; use `tinyllama` or `llama2`"),
};
match engine.as_str() {
"tape" => run::<f32>(family, &prompt, count, "tape"),
"bf16" => run::<Bf16>(family, &prompt, count, "bf16"),
other => panic!("unknown engine `{other}`; use `tape` or `bf16`"),
}
}
topos