topos

Start here

Getting started

Install, the first tape, a gradient, a training step, and the readings of one small graph.

Install#

cargo add topos

Rust 1.88 or newer. The default build has #![forbid(unsafe_code)] and a handful of small, boring dependencies: cow_vec, libm, smallvec, static_assertions, thiserror. Everything the crate exists to do — the tape, the derivative rules, the tensor math, the kernels, the optimizers — is its own code. The backends (accelerate, simd, metal, cuda) and the notebook cards (evcxr) are opt-in features, named later on this page.

Two rustdoc maps cover the surface, and nothing moves between them: topos::model to record, train, and checkpoint; topos::compiler to inspect, lower, emit, and extend. use topos::Tape works either way.

The first tape#

A tape is a notebook of operations. Every expression over a Value appends one line: its opcode, the operands it reads, and its shape, checked at that line.

use topos::Tape;

let tape: Tape<f64> = Tape::new();
let w = tape.parameter(0.0);
let x = tape.input(0.0);
let y = tape.input(0.0);
let error = w * x - y;
let loss = error * error;
println!("{}", tape.describe());
   0  Parameter                         []
   1  Input                             []
   2  Input                             []
   3  Mul            0, 1               []
   4  Sub            3, 2               []
   5  Mul            4, 4               []
tape: 6 nodes, 1 parameter, 2 inputs
Plate 1. The README's graph as the spec: one line per recorded operation, with its index, its opcode, the indices of the operands it reads, and its shape. Hover a line to see what it reads and who reads it.
The code that produced it
use topos::Tape;

let tape: Tape<f64> = Tape::new();
let w = tape.parameter(0.0);
let x = tape.input(0.0);
let y = tape.input(0.0);
let error = w * x - y;
let _loss = error * error;
show::spec(tape.describe())

Read the columns left to right: the node's index, its opcode, the indices it reads, its shape. A rank-0 shape prints as []: a scalar is a tensor with no axes, and the graph is always tensors. error is node 4, and node 5 reads it twice — the square is Mul 4, 4. The three sources have no operands: a parameter is trainable, an input is fed per run and carries a default, and a leaf (not used here) is a constant.

0 Parameter[]0 Parameter []1 Input[]1 Input []2 Input[]2 Input []3 Mul[]3 Mul []4 Sub[]4 Sub []5 Mul[]5 Mul []
Plate 2. The same six lines as a picture. error is read twice, by both sides of the product, and the picture shows it.
The code that produced it
use topos::Tape;

let tape: Tape<f64> = Tape::new();
let w = tape.parameter(0.0);
let x = tape.input(0.0);
let y = tape.input(0.0);
let error = w * x - y;
let _loss = error * error;
show::graph(Graph::from_nodes(tape.nodes()))

A gradient#

Tape::record is the shorthand for the block above: the closure records, its return value names what leaves the tape, and the seal happens on the way out. What comes back is a Network — the sealed spec — and the detached names, called symbols.

use topos::{Detach, Tape};

let (network, [w, x, y, loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.5_f64);
    let x = tape.input(2.0);
    let y = tape.input(4.0);
    let error = w * x - y;
    [w, x, y, error * error].detach()
});
let run = network.forward(&network.parameters(), []);
let gradients = run.backward(loss);
println!("{}", gradients.of(w));

forward evaluates every node and hands back a Run that owns the values. backward is the engine reverse scan: from one rank-0 target, back through every node, applying each opcode's derivative rule and summing the contributions of a value with several readers. It answers a Field — one payload per node — read through symbols.

loss = (w x - y)^2           = 9
d loss / d w = 2 (w x - y) x = -12
d loss / d x = 2 (w x - y) w = -3
d loss / d y = -2 (w x - y)  = 6
Plate 3. The engine reverse scan, on one sample: three gradients from one backward, each the chain rule the textbook writes.
The code that produced it
use topos::{Detach, Tape};

let (network, [w, x, y, loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.5_f64);
    let x = tape.input(2.0);
    let y = tape.input(4.0);
    let error = w * x - y;
    [w, x, y, error * error].detach()
});
let run = network.forward(&network.parameters(), []);
let gradients = run.backward(loss);
show::lines([
    format!("loss = (w x - y)^2           = {}", run.of(loss)),
    format!("d loss / d w = 2 (w x - y) x = {}", gradients.of(w)),
    format!("d loss / d x = 2 (w x - y) w = {}", gradients.of(x)),
    format!("d loss / d y = -2 (w x - y)  = {}", gradients.of(y)),
])

A training step#

Training never touches the network. network.parameters() materializes the recorded initial payloads as a Parameters table; step is a pure data transform of that table, with the gradients projected onto the same slots. The loop below is the crate's README, and the whole model.

use topos::{Detach, Tape, Tensor};

let (network, [w, x, y, loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.0_f64);
    let x = tape.input(0.0);
    let y = tape.input(0.0);
    let error = w * x - y;
    [w, x, y, error * error].detach()
});
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, sample_x.into()), (y, sample_y.into())]);
    let gradients = run.backward(loss).parameters(&parameters);
    parameters = parameters.step(&gradients, |w, g| {
        w.clone() - g.clone() * Tensor::from(0.02)
    });
}
assert!((parameters.of(w).scalar() - 2.0).abs() < 1e-6);

The second argument of forward is the feeds: this run's values for the inputs, overlaying the recorded defaults without touching the spec. Any number of threads can forward one shared network on different feeds, or different parameters, at once.

a hundred steps toward y = 2x ── w = 2.00000 s 1 ┤ q │ u │ a 10⁻⁴ ┤ r │ e 10⁻⁸ ┤ d │ 10⁻¹² ┤ e │ r 10⁻¹⁶ ┤ r │ └┬───────────┬────────────┬───────────┬────────────┬───────────┬ 0 20 40 60 80 100 step
Plate 4. A hundred steps of the README loop. The graph never changed; the table of parameters did, and w walked to two.
The code that produced it
use malevich::{Line, Plot};
use topos::{Detach, Tape, Tensor};

let (network, [w, x, y, loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.0_f64);
    let x = tape.input(0.0);
    let y = tape.input(0.0);
    let error = w * x - y;
    [w, x, y, error * error].detach()
});
let mut parameters = network.parameters();

let samples = [(1.0, 2.0), (2.0, 4.0), (3.0, 6.0)];
let mut losses = Vec::new();
for step in 0..100 {
    let (sample_x, sample_y) = samples[step % samples.len()];
    let run = network.forward(&parameters, [(x, sample_x.into()), (y, sample_y.into())]);
    losses.push(run.of(loss).scalar());
    let gradients = run.backward(loss).parameters(&parameters);
    parameters = parameters.step(&gradients, |w, g| {
        w.clone() - g.clone() * Tensor::from(0.02)
    });
}
let learned = parameters.of(w).scalar();
show::plot(
    Plot::new()
        .layer(Line::y(losses).label(format!("w = {learned:.5}")))
        .title("a hundred steps toward y = 2x")
        .x_label("step")
        .y_label("squared error")
        .log_y(),
)

The other readings#

Everything after the seal is a different way of reading the same six lines. An entry declares what a run may read — here just the loss — and lowering it gives a plan: the same operations, scheduled, with what happens to each value once computed.

let plan = network.entry([loss]).lower();
println!("{}", plan.describe());
   0  Parameter                         []           freed after 3
   1  Input                             []           freed after 3
   2  Input                             []           freed after 4
   3  Mul            0, 1               []           freed after 4
   4  Sub            3, 2               []           freed after 5
   5  Mul            4, 4               []           kept
plan: forward; 6 of 6 nodes evaluated, 1 readable
live volume: peak 4 elements at node 3, retain-all 6
Plate 5. The same graph, scheduled for one root. Every value is freed after its last reader; only the loss is kept.
The code that produced it
use topos::{Detach, Tape};

let (network, [loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.0_f64);
    let x = tape.input(0.0);
    let y = tape.input(0.0);
    let error = w * x - y;
    [error * error].detach()
});
let plan = network.entry([loss]).lower();
show::spec(plan.describe())

And the plan writes itself as text for an industrial compiler. Nothing XLA-shaped lives in the crate; the text is the boundary.

println!("{}", plan.emit_stablehlo().expect("every operation lowers"));
module @topos {
  func.func @main(%arg0: tensor<f64>, %arg1: tensor<f64>, %arg2: tensor<f64>) -> (tensor<f64>) {
    %v3 = stablehlo.multiply %arg0, %arg1 : tensor<f64>
    %v4 = stablehlo.subtract %v3, %arg2 : tensor<f64>
    %v5 = stablehlo.multiply %v4, %v4 : tensor<f64>
    return %v5 : tensor<f64>
  }
}
Plate 6. The README's graph as a StableHLO module: a function of the parameter and the inputs, returning the loss. Emission is describe in another syntax.
The code that produced it
use topos::{Detach, Tape};

let (network, [loss]) = Tape::record(|tape| {
    let w = tape.parameter(0.0_f64);
    let x = tape.input(0.0);
    let y = tape.input(0.0);
    let error = w * x - y;
    [error * error].detach()
});
let plan = network.entry([loss]).lower();
show::hlo(plan.emit_stablehlo().expect("every operation lowers"))

Both readings, and four more, are walked in order on the next page over a graph with a matrix product in it. Or skip ahead and type your own.

What is in the box#

you want toreach for
record a graphTape, Value, Tape::record, Detach
name things after the sealSymbol, Value::symbol, Tape::resolve
run itNetwork::forward (the exact oracle), BoundEntry::interpret
a gradientRun::backward (engine scan), Tape::differentiate (as more spec), Tape::vjp (any seed)
trainParameters::step, Run::recorded_gradients, Sgd, Adam, AdamW
layersLinear, Mlp, Conv2d, max_pool, BatchNorm, LayerNorm, RmsNorm, Dropout, Embedding, scaled_dot_product, cross_entropy, init
compileNetwork::entry → Entry → lower → Plan, Numerics::{Fast, Exact}
emitPlan::emit_stablehlo
inspectdescribe on tape, network, and plan; Network::nodes, Node, Opcode
bring a numberElement = Differentiable + Elementary; Bf16 is built in

The features:

featureadds
simdportable CPU microkernels for dense products, every platform — acceleration
accelerateApple's AMX/SME through BLAS, vForce maps, vDSP batch-norm (macOS)
metalthe crate's own GPU kernels for large f32 products and maps (macOS)
cudacuBLAS through dlopen, so a missing toolkit does not fail the build (Linux)
evcxrrich cards in an Evcxr notebook — notebooks

Features change speed, not which programs you can write. Whatever a backend declines, the interpreter runs.

Where next#

  • The stack, read six ways — one graph with a matrix product, every reading in order.
  • Recording — shapes at record time, values and symbols, sealing and reopening.
  • The gallery — every example with its output, from a scalar chain to a transformer.
  • The playground — your graph, in the browser, read live.