topos

The guide

Entries and plans

Declare what a run may read. A plan is the schedule that declaration licenses: liveness, skipped nodes, elected patterns, exact and fast.

Say what you want to read#

A sealed spec is a compilation unit, and it may export several functions that share weights: train and sample, full context and decode. An Entry is one such export, declared: the roots a run must compute, the extra interior values it will also observe, the memory posture, and the numerics.

let entry = network.entry([loss]).observe([hidden]).numerics(Numerics::Exact);
let run = entry.interpret(&parameters, []);   // the oracle over just those ancestors
let plan = entry.lower();                     // the compiled schedule

Say it once. Every executor honors that keep-set: the interpreter evaluates exactly the ancestors of the declared results; a plan is a derived schedule of the same closure. A read outside the declaration panics — an interior that liveness happened to retain is still unreadable, because the contract does not depend on the optimizer's choices. Network::forward, the whole-spec oracle, is the debug road for when you really do want every node.

Lowering#

lower derives a Plan: what to run, what to keep, what to free, which patterns to elect. It freezes a prefix of the spec and takes Parameters per call, so it holds no state, survives every training step, and outlives a reopen. describe prints one line per scheduled node with its liveness, then the summary.

   0  Parameter                         [2, 2]       freed after 2
   1  Input                             [1, 2]       freed after 2
   2  MatMul         1, 0               [1, 2]       freed after 3
   3  Tanh           2                  [1, 2]       freed after 4
   4  Mul            3, 3               [1, 2]       freed after 5
   5  Sum            4                  []           kept
plan: forward; 6 of 6 nodes evaluated, 1 readable
live volume: peak 8 elements at node 2, retain-all 13
Plate 1. A forward-only plan over one root. Every intermediate is freed after its last reader, the loss is kept, and the summary counts the peak.
The code that produced it
use topos::{Tape, Tensor};

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().symbol();
let network = tape.into_network();
show::spec(network.entry([loss]).lower().describe())

Observability is a license#

The declaration is what every optimization runs on. The observed must survive any derived artifact; the unobserved is fair game to skip, free, or fuse away. Add one name to the declaration and one line changes.

   0  Parameter                         [2, 2]       freed after 2
   1  Input                             [1, 2]       freed after 2
   2  MatMul         1, 0               [1, 2]       freed after 3
   3  Tanh           2                  [1, 2]       freed after 4
   4  Mul            3, 3               [1, 2]       freed after 5
   5  Sum            4                  []           kept
plan: forward; 6 of 6 nodes evaluated, 1 readable
live volume: peak 8 elements at node 2, retain-all 13
Plate 2. Observability is declared. Lowering the same graph with hidden observed turns one line from freed to kept; nothing else changes.
The code that produced it
use topos::{Tape, Tensor};

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();
let (hidden, loss) = (hidden.symbol(), loss.symbol());
let network = tape.into_network();

let roots_only = network.entry([loss]).lower();
let observed = network.entry([loss]).observe([hidden]).lower();
show::panels([
    ("roots only", show::spec(roots_only.describe())),
    ("observe hidden", show::spec_marked(observed.describe(), [hidden.index()])),
])

Nodes an entry does not reach are not scheduled at all. When the gradient of the walkthrough is recorded with respect to the weights, the rules also produce the gradient with respect to the input — four nodes nobody declared, so a run never computes them and the emitted text never mentions them.

0 Parameter[2, 2]0 Parameter [2, 2]1 Input[1, 2]1 Input [1, 2]2 MatMul[1, 2]2 MatMul [1, 2]3 Tanh[1, 2]3 Tanh [1, 2]4 Mul[1, 2]4 Mul [1, 2]5 Sum[]5 Sum []6 Leaf[]6 Leaf []7 Broadcast[1, 2] shape=[1, 2]7 Broadcast [1, 2] shape=[1, 2]8 Leaf[1, 2]8 Leaf [1, 2]9 Add[1, 2]9 Add [1, 2]10 Mul[1, 2]10 Mul [1, 2]11 Mul[1, 2]11 Mul [1, 2]12 Leaf[1, 2]12 Leaf [1, 2]13 Add[1, 2]13 Add [1, 2]14 Add[1, 2]14 Add [1, 2]15 Leaf[1, 2]15 Leaf [1, 2]16 Mul[1, 2]16 Mul [1, 2]17 Sub[1, 2]17 Sub [1, 2]18 Mul[1, 2]18 Mul [1, 2]19 Leaf[1, 2]19 Leaf [1, 2]20 Add[1, 2]20 Add [1, 2]21 Permute[2, 2] order=[1, 0]21 Permute [2, 2] order=[1, 0]22 MatMul[1, 2]22 MatMul [1, 2]23 Permute[2, 1] order=[1, 0]23 Permute [2, 1] order=[1, 0]24 MatMul[2, 2]24 MatMul [2, 2]25 Leaf[1, 2]25 Leaf [1, 2]26 Add[1, 2]26 Add [1, 2]27 Leaf[2, 2]27 Leaf [2, 2]28 Add[2, 2]28 Add [2, 2]
Plate 3. What the plan skipped, drawn: the rules also produced the gradient with respect to the input, and nobody declared it, so it is never computed.

Live volume#

Every freed after N in a plan is a buffer release, and the summary's peak is the most elements alive at once. Plan::live_series is that accounting as a curve. A training plan's curve climbs while the forward values are still needed by the chain rule and falls as the backward half consumes them; an inference plan over the same graph stays flat and low.

live volume along two plans of one [8, 32, 32, 4] mlp ── loss + gradients ── loss only e 12.0k ┤ ╭╮ ╭─╮ ╭╮ l │ ╭─╯│ │ ╰╮ ╭╯│ ╭╮ ╭╮ e │ ╭─╮ ╭╯ ╰─╯ ╰─╯ ╰╮ ╭╯╰╮╭─╯│ m 8.0k ┤ ╭─╮│ │ ╭╮ ╭─────╮╭─╮ │ │ ╭╯ ╰╯ ╰── e │ ╭──╮╭╮ ─╯╰─╯ ╰╯ ╰──╯ ╰─╯ n 4.0k ┤ ╭╯ ╰╯│ t │ │ ╰╮ s │ ╭───╯ │ 0 ┤ ╯ ╰─── └┬──────┬───────┬───────┬───────┬──────┬───────┬───────┬──────┬─ 0 10 20 30 40 50 60 70 80 scheduled node
Plate 4. Live volume along the walkthrough's training plan: the loss, its gradient, and one observed interior. The curve climbs while the forward values are still needed and falls as the chain rule frees them.
The code that produced it
use malevich::{Line, LineStyle, Plot};
use topos::{Activation, Mlp, Module, Shape, Tape, Tensor, init};

let tape: Tape<f32> = Tape::new();
let mlp = Mlp::new(&tape, &[8, 32, 32, 4], Activation::Tanh, init::xavier(3));
let features: Tensor<f32> = init::normal(5, 1.0)(&Shape::new([64, 8]));
let predicted = mlp.express(tape.input(features));
let loss = (predicted * predicted).sum();
let adjoints = tape.differentiate(loss, mlp.parameters());
let loss = loss.symbol();
let network = tape.into_network();

let training = network.entry(adjoints.roots()).lower();
let inference = network.entry([loss]).lower();
let series = |plan: &topos::Plan<f32>| -> Vec<f64> {
    plan.live_series().iter().map(|&elements| elements as f64).collect()
};
show::plot(
    Plot::new()
        .layer(Line::y(series(&training)).label("loss + gradients").style(LineStyle::Corners))
        .layer(Line::y(series(&inference)).label("loss only").style(LineStyle::Corners))
        .title("live volume along two plans of one [8, 32, 32, 4] mlp")
        .x_label("scheduled node")
        .y_label("elements"),
)

The numbers are the plan's own accounting in elements — constants and placeholders count as zero — not allocator truth. They are what the analysis licenses, which is exactly the number a design decision needs. The notebook card for a plan draws this curve under the schedule.

The backward posture#

Entry::backward() declares runs that answer Run::backward: buffers retain what the engine reverse scan reads. It is a memory posture — the retain-all one, which the graded consumers preferred over freeing or rematerializing mid-run — not a second compiler. The column then says what the analysis could release, and the summary reports the retain-all total a run actually holds beside the release floor.

   0  Parameter                         [2, 2]       retained
   1  Input                             [1, 2]       retained
   2  MatMul         1, 0               [1, 2]       releasable after 3
   3  Tanh           2                  [1, 2]       retained
   4  Mul            3, 3               [1, 2]       releasable after 5
   5  Sum            4                  []           kept
plan: retain; 6 of 6 nodes evaluated, 1 readable
live volume: retain-all 13, release floor 11 at node 5

d loss / d weights through the plan's run: [[0.6397, 0.75025934], [1.2794, 1.5005187]]
Plate 5. The engine-backward posture: buffers are retained so Run::backward can still scan them. The column says what the analysis *could* release, and the summary reports the retain-all total a run actually holds.
The code that produced it
use topos::{Tape, Tensor};

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().symbol();
let weights = weights.symbol();
let network = tape.into_network();

// `backward()` is a memory posture: runs keep what the engine scan
// will reread, so `Run::backward` still works on a plan's run.
let plan = network.entry([loss]).backward().lower();
let run = plan.forward(&network.parameters(), []);
let gradients = run.backward(loss);
let mut text = plan.describe();
text.push_str(&format!("\nd loss / d weights through the plan's run: {}\n", gradients.of(weights)));
show::spec(text)

For compiled training, prefer recording the derivative and compiling a forward-only plan over adjoints.roots(): fusion and liveness then apply to the chain rule itself. backward's place is the oracle, and quick procedural use.

Patterns are offers#

The catalog matches structures over the frozen columns — a windowed product, a window reduction, the two batch-norm formulas — and a plan elects the matches it can use. A match never rewrites the tape: at home it calls a fused kernel, and a region nobody elects just runs the original operations. A value you declared readable inside a match blocks it, unless the match names that value as a result and writes it back — which is how observing batch-norm's statistics survives the fusion.

   0  Input                             [1, 1, 6, 6] freed after 9
   1  Parameter                         [2, 1, 3, 3] freed after 7
   2  Parameter                         [2]          freed after 10
   3  Unfold         0  axis=2 size=3 step=1 dilation=1 [1, 1, 4, 3, 6] fused
   4  Unfold         3  axis=4 size=3 step=1 dilation=1 [1, 1, 4, 3, 4, 3] fused
   5  Permute        4  order=[0, 2, 4, 1, 3, 5] [1, 4, 4, 1, 3, 3] fused
   6  Reshape        5  shape=[16, 9]   [16, 9]      fused
   7  Permute        1  order=[1, 2, 3, 0] [1, 3, 3, 2] freed after 8
   8  Reshape        7  shape=[9, 2]    [9, 2]       freed after 9
   9  MatMul         6, 8               [16, 2]      freed after 11
  10  BroadcastAlong 2  axis=0 extent=16 [16, 2]     freed after 11
  11  Add            9, 10              [16, 2]      freed after 12
  12  Reshape        11  shape=[1, 4, 4, 2] [1, 4, 4, 2] freed after 13
  13  Permute        12  order=[0, 3, 1, 2] [1, 2, 4, 4] freed after 15
  14  Leaf                              [1, 2, 4, 4] freed after 15
  15  Maximum        13, 14             [1, 2, 4, 4] freed after 27
  16  Unfold         15  axis=2 size=2 step=2 dilation=1 [1, 2, 2, 2, 4] fused
  17  Unfold         16  axis=4 size=2 step=2 dilation=1 [1, 2, 2, 2, 2, 2] fused
  18  Permute        17  order=[0, 1, 2, 4, 3, 5] [1, 2, 2, 2, 2, 2] fused
  19  Reshape        18  shape=[1, 2, 2, 2, 4] [1, 2, 2, 2, 4] fused
  20  Narrow         19  axis=4 start=0 len=1 [1, 2, 2, 2, 1] fused
  21  Narrow         19  axis=4 start=1 len=1 [1, 2, 2, 2, 1] fused
  22  Maximum        20, 21             [1, 2, 2, 2, 1] fused
  23  Narrow         19  axis=4 start=2 len=1 [1, 2, 2, 2, 1] fused
  24  Maximum        22, 23             [1, 2, 2, 2, 1] fused
  25  Narrow         19  axis=4 start=3 len=1 [1, 2, 2, 2, 1] fused
  26  Maximum        24, 25             [1, 2, 2, 2, 1] fused
  27  Reshape        26  shape=[1, 2, 2, 2] [1, 2, 2, 2] freed after 28
  28  Sum            27                 []           kept
plan: forward; 29 of 29 nodes evaluated, 1 readable
fused 2 groups, 15 nodes replaced
live volume: peak 96 elements at node 11, retain-all 325
Plate 6. A convolution and a max-pool, planned. The catalog found the windowed product and the window reduction it knows, elected them, and the interiors of each match print as fused.
The code that produced it
use topos::{Shape, Tape, Tensor, conv2d, init, max_pool};

let tape: Tape<f32> = Tape::new();
let image = tape.input(init::normal(1, 1.0)(&Shape::new([1, 1, 6, 6])));
let filters = tape.parameter(init::normal(2, 0.3)(&Shape::new([2, 1, 3, 3])));
let bias = tape.parameter(Tensor::filled([2], 0.0_f32));
let convolved = conv2d(image, filters, bias, 1, 0).relu();
let pooled = max_pool(convolved, 2, 2);
let loss = pooled.sum().symbol();
let network = tape.into_network();

let plan = network.entry([loss]).lower();
let mut elected: Vec<String> = plan
    .patterns()
    .iter()
    .map(|pattern| {
        format!(
            "{:?} rooted at node {}, covering {} nodes, served as {:?}",
            pattern.kind(),
            pattern.root().index(),
            pattern.nodes().len(),
            pattern.formula()
        )
    })
    .collect();
elected.insert(0, format!("{} patterns elected:", elected.len()));
show::panels([
    ("plan", show::spec(plan.describe())),
    ("elected", show::text(elected.join("\n"))),
])

Plan::patterns is the elections as data. The same match has a second job abroad: when the plan is emitted, it becomes the other compiler's named op. Fusion raises tells that story; emission shows the text.

Exact and Fast#

Results are a function of the payloads, the compiled features, the numerics posture, and the machine. Network::forward is exact by construction: its bits are the same in every build, on every platform. A plan under Numerics::Exact computes those same bits in the same process, whatever backend features are compiled in. Under Fast — an entry's default — a backend may serve large products with sums in its own order; that is a labeled choice on the entry, never a silent effect of a feature flag.

interpreter  15.916065
exact plan   15.916065
fast plan    15.916065

exact == interpreter: asserted, in every build
fast == exact: yes, no backend is on in this build
Plate 7. Three losses from one graph: the interpreter, an Exact plan, and the default Fast plan. The first two are asserted equal in every build; the third differs only where a backend served and reordered a sum.
The code that produced it
use topos::{Numerics, Shape, Tape, Tensor, init};

let tape: Tape<f32> = Tape::new();
let weights = tape.parameter(init::xavier(3)(&Shape::new([64, 64])));
let batch = tape.input(init::normal(5, 1.0)(&Shape::new([32, 64])));
let loss = batch.matmul(weights).tanh().sum().symbol();
let network = tape.into_network();
let parameters = network.parameters();

let interpreter = network.forward(&parameters, []);
let exact = network.entry([loss]).numerics(Numerics::Exact).lower().forward(&parameters, []);
let fast = network.entry([loss]).lower().forward(&parameters, []);
let bits = |tensor: &Tensor<f32>| tensor.scalar().to_bits();
assert_eq!(bits(interpreter.of(loss)), bits(exact.of(loss)));
show::lines([
    format!("interpreter  {}", interpreter.of(loss)),
    format!("exact plan   {}", exact.of(loss)),
    format!("fast plan    {}", fast.of(loss)),
    String::new(),
    "exact == interpreter: asserted, in every build".to_string(),
    format!(
        "fast == exact: {}",
        if bits(fast.of(loss)) == bits(exact.of(loss)) { "yes, no backend is on in this build" } else { "no: a backend summed in its own order" }
    ),
])

Which backend actually served is run-time data: wrap any region in Backend::tallied and read the per-formula tally. Acceleration has the ladder and the numbers.

Where next#