topos

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:

  1. The spec: one line per operation, in the order written.
  2. The derivative: differentiate appends the chain rule to the same list, so a gradient is just more spec.
  3. The plan: a schedule of what to run, what to keep, and what to free, derived from the spec and holding no state.
  4. StableHLO: the plan written out as text that an industrial compiler such as XLA accepts.
  5. 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.
Plate 1. What 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(&parameters, []);
    let engine_gradient = oracle.backward(loss).parameters(&parameters);

    // 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(&parameters, []);
    let fast_run = plan.forward(&parameters, []);
    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(&current, []);
        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(&current, []).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 = -3
Plate 2. What cargo 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(&parameters, []);
    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
                                          step
Plate 3. What cargo 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(&parameters, []);
    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(&parameters, []);
                losses.push(run.of(loss).scalar());
                let gradients = run.backward(loss).parameters(&parameters);
                parameters = parameters.step(&gradients, |parameter, gradient| {
                    parameter.clone() - gradient.clone() * Tensor::from(learning_rate)
                });
            }
            let run = network.forward(&parameters, []);
            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
                                           x
Plate 4. What cargo 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(&parameters, []);
        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(&parameters);
        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(
        &parameters,
        [(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.0
Plate 5. What cargo 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(&parameters, [(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(&parameters);
        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(&parameters, [(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(&parameters, [(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.5
Plate 6. What cargo 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(&parameters, []);
        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(&parameters);
        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(&parameters, []);
    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]
Plate 7. What 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
Plate 8. What 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)
Plate 9. What 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(
            &parameters,
            [(x, Audited(sample_x).into()), (y, Audited(sample_y).into())],
        );
        let gradients = run.backward(loss).parameters(&parameters);
        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 throughput

Without 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
Plate 10. What 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(&parameters, [(features, batch.clone())]);
        let gradients = run.backward(loss).parameters(&parameters);
        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(&parameters, [(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
  adealemiejonahiryte
Plate 11. What cargo 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(
            &parameters,
            [
                (
                    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(&parameters);
        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(&parameters, 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
  keny
Plate 12. What cargo 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(
            &parameters,
            [
                (
                    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(&parameters);
        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(
                &parameters,
                [(
                    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
  keny
Plate 13. What cargo 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(
            &parameters,
            [
                (
                    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(&parameters);
        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(
                &parameters,
                [(
                    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
  keny
Plate 14. What cargo 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(
            &parameters,
            [
                (
                    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(
                &parameters,
                [(
                    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
                                          step
Plate 15. What cargo 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(&parameters, feeds);
            let gradients = run.recorded_gradients(adjoints);
            (run.of(loss).scalar(), gradients)
        } else {
            let run = network.forward(&parameters, feeds);
            (
                run.of(loss).scalar(),
                run.backward(loss).parameters(&parameters),
            )
        };
        losses.push(batch_loss);

        parameters = optimizer.step(&parameters, &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
  keny
Plate 16. What cargo 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(
                    &parameters,
                    [
                        (
                            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(&parameters),
                )
            })
            .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(
                &parameters,
                [(
                    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
  gkinble
Plate 17. What cargo 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(
            &parameters,
            [
                (
                    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(&parameters);
        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(
                &parameters,
                [
                    (
                        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.5
Plate 18. What cargo 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(
            &parameters,
            [
                (
                    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(&parameters);
        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
  groyte
Plate 19. What cargo 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(
            &parameters,
            [
                (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(&parameters);
        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(
                &parameters,
                [(
                    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(&parameters, 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(
        &parameters,
        [(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
                                         step
Plate 22. What cargo 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(
            &parameters,
            [(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(&parameters);
        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(
                &parameters,
                &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(
                &parameters,
                &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
                                         step
Plate 23. What cargo 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(
            &parameters,
            [(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(&parameters);
        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(
                &parameters,
                &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(
                &parameters,
                &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(
            &parameters,
            [(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(&parameters),
        };
        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(
            &parameters,
            [(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(&parameters),
        };
        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)
Plate 26. What 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(
                &parameters,
                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(&parameters, &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(
                    &parameters,
                    [
                        (
                            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)
Plate 27. What 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(
            &parameters,
            [
                (
                    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`"),
    }
}