WEP: Loam — Lowered Operators, Ahead-of-time Modules
Context
Wado has no tensor story. The closest thing in the tree is
example/microgpt.wado, a scalar autograd graph where every arithmetic
operation allocates a Value node. It is a faithful port and a useful GC
benchmark. It is not a foundation for machine learning work.
What Loam is for is inference on the device: a browser or a phone, on WebGPU. Those places share two constraints that a datacenter never has. Every byte of the module is downloaded before the first token, so the module must carry only what the model uses. And the process starts cold, so there is no warmup to amortize a JIT against. An engine that ships an interpreter and the graph as data pays both; a module compiled from the graph ahead of time pays neither, and that is the shape Loam takes.
Compiling ahead of time buys more than size, and this is the design's center. An engine that runs a graph finds a mismatch when it runs. Loam turns the graph into typed Wado before anything runs, so most of what could go wrong stops the build instead:
- Every extent is settled at build time. The generator evaluates the shape computation the graph carries, and an extent condition it cannot discharge stops the build. So does a node in a form no kernel computes: a build error, never a wrong number.
- Axes are types. Each axis the graph mentions becomes a marker type, and each kernel's signature carries the axes it reads, so the ordinary Wado compiler checks every generated call. A caller whose tensor names the wrong axes does not compile.
- A checkpoint is checked against the graph twice: its header at build time, and the bytes the loader is given at load time. A disagreement names the tensor and the axis.
What is left for run time is only what a value decides: the checkpoint's bytes
and the extents of the tensors forward receives. Both are checked where they
arrive, before any kernel reads them, and a disagreement names the tensor and
the axis. load returns it as a LoadError. forward stops with it, since an
input that disagrees is a bug in the caller. A runtime cannot offer this,
however good its kernels are. Its graph is data, and data is checked when it is
read. That is why Loam builds again what inference runtimes already provide.
Four findings from the 2026 literature drive this proposal.
docs/research-ml-inference-webgpu.md records the survey they come from.
Compile-time shape checking has become implementable. Dependent and refinement types both demand proofs to decide type equality, which is why neither was adopted in practice. Staging replaces the proofs with assertions evaluated as compile-time computations, and the ECOOP 2026 work validated it on ported ocaml-torch programs.
Axis notation has converged on einx, whose bracket marks the axes an operation actually sees. Static checking of it exists only as external stubs, because Python cannot say in a type what an operation does to a shape. That is a constraint of the host language rather than of the idea, and it is the reason this design has no notation layer at all.
Quantization formats are a moving target parameterized by block size, scale representation, and rotation, not a fixed list of names.
Fusion's objective function is not what it was. On WebGPU at batch size 1, dispatch count dominates and kernel quality does not. That objective belongs to the GPU work, and naming it here is what bounds the claims a CPU MVP can make.
Gale is the architectural precedent: a static compiled fast path with escalation to a runtime tier. This WEP adopts five properties of it:
- The escalation boundary is a decidability result. Gale escalates because the lookahead language of a recursive ambiguous prefix is non-regular, so subset construction provably does not converge.
- The escalation sites are enumerable and named. Each site Gale's
simulator decides is pinned by a fixture and visible through
gale dump --atn. - Cost routes as much as correctness does. Sites that belong on the simulator for correctness stay static where measurement showed the simulator too slow, and the divergence is pinned rather than hidden.
- An unused mechanism costs nothing. A grammar needing no simulator carries none and is byte-for-byte unaffected.
- The runtime is inlined into the generated file, which is what removes version drift between generator and runtime.
Version drift is a problem the field has not solved. ONNX opset drift and checkpoint incompatibility are both drift between a model artifact and the code that runs it, which is what the last property removes.
Kiln makes the staging available without a language change. Wado has neither const generics nor general compile-time evaluation, so an extent cannot be carried as an integer in type position. A Kiln generator, however, runs arbitrary Wado at compile time and emits Wado source, which is staging. Gale already works this way. Loam is therefore a package, not a language feature.
Decision
Name
Loam — Lowered Operators, Ahead-of-time Modules.
A model that submits to fixed shapes at build time gets fused kernels, and gets its shape errors at build time instead of wrong numbers at run time. The constraint is the mechanism, not a limit on it.
Loam is the earth a foundry mould is made of, and a mould holds its shape before any metal is poured. Kiln fires such a mould, and Loam is a Kiln generator. The name is a short natural noun, as Gale, Kiln, Jade, and Marl are.
A Kiln Generator, Not a Library
Loam is invoked as a Kiln generator over a model graph and its checkpoint:
use { Weights, forward } from "./model.onnxtext" with {
generator: {
module: "wado-lang:loam@0.1",
inputs: ["./model.safetensors"],
},
};
A library would place shape checking at run time, where every other framework already has it. Only a generator can place it early enough for a shape mismatch to stop the build.
The generator reads the graph and the header of the checkpoint named in
inputs, and checks one against the other.
Three properties follow from the choice, and each answers a standing problem in the field:
- A Kiln generator runs in a full Wasm sandbox. A build-time code generator that
reads untrusted model files is an obvious attack surface, and both
build.rsandsetup.pyexecute with the developer's privileges. Loam cannot. - The generated module carries its own kernel runtime, inlined as Gale's is.
There is no separate runtime package to version-match, so opset drift has no
place to occur. Two generated modules therefore declare distinct
Tensortypes and distinct axis markers, and that is deliberate. An axis is named by its graph's layout, so two graphs have no reason to share one. A value passing from one module to the other crosses as its data and extents, and the receivingforwardchecks them at its boundary as it checks any input. - The generated module is ordinary Wado. It is readable, debuggable, and optimized by the same pipeline as any other Wado source.
Loam reads its graph with a Gale-built parser, so its own source imports a
module Kiln produces. The reader names ../grammar/Onnx.g4 at its use site, and
Kiln builds the parser when it builds the generator. Kiln's WEP covers that
under §"A generator may use a generator".
The Graph Is ONNX
A forward pass is read rather than written. Loam takes a model graph in a language the field already has, and the first one is ONNX, with StableHLO to follow as a second front end.
The operators mean what ONNX says they mean: broadcasting, each operator's definition, and the opset version that fixes it. Loam declares the opset range it reads and reinvents none of it. This is where most of the robustness comes from. A graph language of Loam's own would have had semantics of Loam's own, and every rule of them would have been Loam's to specify, to test, and to get wrong. ONNX's rules come with a reference implementation and a conformance suite, and that suite is the oracle the kernels are tested against.
A graph arrives in either of ONNX's two forms, and the generator picks by
extension. .onnx is the protobuf form, which is what an exporter writes and
what the backend test data ships; package-loam/src/onnx_proto.wado reads it
through the types Grog generates from ONNX's own onnx.proto. .onnxtext is
the textual format onnx.parser reads and onnx.printer writes, named as
onnx-mlir names it, and a Gale grammar parses it.
A node is read as its opset defines it, and the readers restate it in the
current definition. That covers Concat before opset 4, whose axis defaulted
to 1, and every operator that only moved an attribute to an operand. Two older
meanings have no current spelling, and each lowers to a kernel of its own.
Softmax before opset 13 normalized every axis from axis on as one lane,
which softmax_flattened computes. Arithmetic before opset 7 with broadcast
aligned B at A's axis rather than at A's end. Stage 0 reads B followed by A's
remaining axes at extent 1, and zip_at computes it. onnxruntime runs no opset
that old, so its oracle case runs a graph that unsqueezes B instead.
Both produce the Graph in package-loam/src/graph.wado, so nothing after the
reader knows which form it came from. A fixture written here takes the text
form, since a textual graph is a few kilobytes and shows a diff, as
package-loam/example/mlp.onnxtext does. The .onnx files under
package-loam/tests/onnx/ are committed too, and they are copies of ONNX's own
test data rather than anything edited here. The two are not equivalent
encodings. Protobuf is ONNX's canonical form, so where they disagree it is the
one that wins.
An inline value in the text form is read as onnx.parser reads it, into the
protobuf field it would fill. A float16 or bfloat16 element is therefore an
integer, the bit pattern int32_data holds, and 15360 is 1.0. A float may
end at its point (1.) but not start there (.5). It may also be a word:
nan, inf or infinity in any case, with an optional minus. The word stays
an identifier in the grammar, so a tensor named inf still parses.
The graph names its weights, and either the graph or a checkpoint supplies
them. A .onnx may carry its initializers' data itself, and that is enough to
run. Otherwise an initializer's name is the key into the checkpoint, so the
graph is the from literal and the checkpoint an inputs entry, and a sharded
checkpoint is several entries.
An ONNX shape is positional, with a dim_param where a dimension is symbolic.
That is the extent half of an axis and none of the identity half, so the axis
schema of the next section names the axes of the graph's inputs and
initializers, and the shape algebra carries those names and extents through
every operator by its ONNX signature. An intermediate value is never named by
hand. A dim_param is a symbolic extent; a dim_value is a static one.
A dim_param may spell arithmetic, as an exporter writes
past_sequence_length + 1 for the attention mask of a decoder with a past. One
whose words are names and integers, separated by +, - and * with spaces
around them, reads as that polynomial, with * binding first. Any other
dim_param is one opaque name, so seq-len names one symbol. forward binds
a name from an input axis that is the name alone. A name no input carries
alone is read back from the first input axis whose length is linear in it,
once the other names that length reads are bound. A spelling over a name no
input axis yields this way is read as one opaque name after all, so
batch * seq on an input alone names one symbol. The check that every input
axis is as long as its spelling then catches a length that no whole name
spells, and a name read back as negative stops the run.
A graph also declares the shapes of its own outputs, and those are merged
against what the nodes produced on ONNX's terms (mergeInDimensionInfo): two
static extents that differ are the conflict, while a dim_param names the axis
in the author's words and a ? names nothing, so neither can disagree with the
schema's own axis name. A rank that differs is a conflict.
The invocation may fix what the graph leaves open, before stage 0 reads it.
dims gives a dim_param a static extent, as onnxruntime's free dimension
overrides do. outputs names the graph outputs forward returns, and the graph
is cut down to the nodes, inputs and initializers they read, so a decoder's
present.* caches leave with the nodes only they need. A name in either option
that the graph does not declare stops the build. dims fixes a name inside a
spelled dim_param too. Fixing some of its names and not the others stops the
build, and so does fixing them to a negative extent.
Without outputs, forward returns every output the graph declares. A single
output is returned as its tensor. Several are returned as an Outputs struct,
one field per output in the order the graph declares them, so a decoder hands
back its present.* keys and values beside its logits.
A graph computes much of its own shape. An exporter writes x.view(x.size(0), -1) as Shape, Gather, Unsqueeze and Concat feeding a Reshape, and
attention's scale as a Cast and a Pow of an extent. Stage 0 evaluates every
node whose operands the build knows, by the operator's ONNX definition. The
build knows an int64, int32 or bool initializer, a Constant, the extents
Shape reads, a symbolic one as its polynomial, and whatever stage 0 has
already evaluated.
Such a node emits no code. Stage 0 evaluates nothing over a float initializer:
a weight belongs to the checkpoint, and the build may not have it.
A folded tensor that a kernel reads or the graph returns becomes a Tensor
literal, as a transformer's position ids do once Range has folded. A
one-element float is the exception where an elementwise operator can take it: a
binary elementwise operand whose partner is a tensor, or a Gemm bias. There it
folds into the operator's Expr as a constant and no tensor is allocated.
Stage 0 also tracks each tensor's element type. An int64 tensor, such as a
token id or an attention mask, stays i64 through the kernels that move
elements without computing on them: the regroupings, Transpose and
Identity. Gather reads it as indices, and Cast converts it. Every other
kernel computes in f32 and stops the build on any other operand.
This settles who Loam is for. A graph reaching it has already run once, in the framework that exported it, so its shapes are consistent by construction. What the build catches is the seam that framework never saw: a checkpoint whose extents disagree with the graph, and a caller whose tensors disagree with the boundary.
The Graph Is the Schema, the Checkpoint Is Data
The graph is the schema, and the generated code is a function of it. A checkpoint carries data, behind a header of its own: a safetensors header is a length-prefixed JSON blob at the front of the file carrying tensor names, positional shapes, dtypes, and offsets. The build checks that header against the graph and reads nothing further down the file.
Kiln's probe is what makes that pay. A generator reports how many leading bytes determine its output, and the build keys on that range; the mechanism and the two invariants a probe owes are in the Kiln WEP. Loam's probe reads a safetensors header, which satisfies both. The header length is in the first eight bytes, so the extent is determined by bytes inside it.
Without it the build would hash whole checkpoints on every incremental build, proving that bytes the output never depended on did not change.
The graph file is probed too, and its extent is the whole file, since every byte
of a textual graph is schema. An extent is a prefix, so a format that
interleaves its schema with its data has none, and this is what a protobuf
.onnx does: graph and initializers in one message, interleaved, so no prefix
excludes the weights. A model whose weights are large must therefore keep them
in external data files, which is
onnx.save_model(..., save_as_external_data=True) and is what every model over
2 GB already does, since protobuf caps a message there. A stated input
requirement is what keeps the extent a length instead of a range set; the
alternative was generalizing the extent so one input could name several disjoint
ranges, which complicates the key, the record, and every generator that never
needed it.
Loam is the reason Kiln's probe receives the invocation's options:
inline_weights makes the output depend on the whole file rather than on the
header, so a probe blind to it would either report the whole file every time,
losing what the extent was for, or report the header and let a payload-only
change hit the cache and emit a module carrying the old weights.
Per-input probing is what a real checkpoint needs. Large models ship sharded —
model-00001-of-00003.safetensors beside an index — so several huge inputs
arrive at one invocation, each with a header of its own.
The generated module contains the typed graph, the kernels, and a loader that checks the incoming payloads against the shapes the header declared. A checkpoint that does not match its header reports which axis disagreed, at load time, rather than producing wrong numbers.
The loader is Weights::load(checkpoint: &ByteList) -> Result<Weights, LoadError>.
It takes the checkpoint's bytes rather than a path, so the generated module
declares no effect: a command reads the file through core:fs, and a page
fetches it. The build checks the header of the checkpoint the invocation names,
and the loader checks the one it is given, since the two need not be the same
file. An initializer the graph carries data for is embedded, and the checkpoint
supplies the rest.
A checkpoint is keyed by the graph's initializer names, and a published one
rarely is. GPT-2's drops the transformer. prefix its exported graph carries,
and ties the output projection to the embedding where the graph holds the
transpose as a tensor of its own. Mapping one onto the other is the architecture
tables' work. Until then, package-loam/tools/onnx_split.wado splits an .onnx
into the two files Loam reads: a .onnxtext whose float initializers carry no
values, and a .safetensors holding them under the graph's names. The build
then reads a textual graph, which a diff can show.
A small model may set inline_weights to have the payload emitted as a byte
string literal read by List::from_le_bytes, which makes the component
self-contained. The compiler folds that call into a data segment, so the Wasm
grows by the payload alone and loading it runs no decode loop. This is for test
fixtures and small models, not the normal path. A .onnx carrying its own
weights takes the same path, and costs the cache nothing, since its extent is
the whole file already.
The literal was chosen over the alternatives by measurement. A float literal
per element costs every compiler phase a node, about 11 s and 460 MB per MiB.
A decode at startup, from protobuf, base64 in __DATA__ or zlib, costs 8 to
40 ms per MiB and grows the Wasm or the startup. The folded byte literal
compiles at under 1 s per MiB and loads in one array.new_data.
Axis Identity Is a Type, Extents Are Values
An axis has a name rather than a position. Transposition is then a change of description rather than a movement of data, and an operation states which axes it consumes.
An axis is a marker type, and a tensor carries its axis list as one type
parameter: Tensor<f32, [Batch, Seq]>. Naming the same axis variable twice in
a signature makes the two positions agree, so a contraction checks itself:
fn matmul<M, K, N>(a: &Tensor<f32, [M, K]>, b: &Tensor<f32, [K, N]>) -> Tensor<f32, [M, N]>
example/tensor_axes.wado is the working form, with a forward pass built on it.
Rank-generic operations take the axis list as a plain type parameter, and an
operation that changes the rank destructures it with a pack, so a reduction is
<..Rest, Last>(t: &Tensor<f32, [..Rest, Last]>) -> Tensor<f32, [..Rest]>.
This needs no language change. It was the open question when this WEP was written, because Wado has no const generics, and it is closed: an extent cannot be carried in a type, but an axis identity can, and that is the half that catches the mistakes people actually make.
Two axes an operation meets are one axis only where they carry one name.
Lengths that agree do not make two names one: Seq and Total are two axes
even at an input where they are one length, and an operation meeting them stops
the build. An axis of 1 is the exception, since it holds one element and
broadcasting stretches it to any axis, so it may carry a name of its own. The
rule holds wherever an operator meets axes: a contraction, a broadcast, the
axes a Concat does not join, and a Conv's channels. A grouped Conv meets
two names by length alone, because a group's share of the input channels is an
axis of its own.
Loam first took two names whose lengths agreed as one axis, under the first
name. A mistake in a layout then built a module that computed, with generated
types stating an axis identity the graph never had. No graph Loam runs relied
on it.
Extents therefore stay runtime values, and stage 0 is what checks them. A static
extent is a dim_value the graph states, and a checkpoint's header is checked
against it. A dynamic extent, such as sequence length or batch size, is a named
symbol that stage 0 carries through the shape algebra without knowing its value.
Stage 0 counts two tensors carrying one symbol as the same length. The caller
owes that for the inputs, and forward checks it before any kernel runs.
An extent in general is a polynomial over those symbols with integer
coefficients. A merge multiplies its parts, and an exporter's shape arithmetic
adds and subtracts them: a causal mask is sliced at the key length less the
query length, and a decoder with a past extends the sequence by it. So the
integers stage 0 folds hold polynomials too. Shape of a tensor with a
symbolic extent yields one, Gather, Concat and the arithmetic operators
carry it, and a Reshape target built from it regroups by proving the element
counts equal for every value of the symbols. Where a value depends on what a
symbol is, as a Range over the sequence does, the node becomes a kernel call,
and forward binds each symbol from the first input axis carrying it.
Some shape arithmetic leaves the polynomials. ONNX clamps a Slice bound to
its axis and reads a negative one from the end. It counts the steps of a Slice
or a Range rounding up, and never below none. A Reshape's -1 rounds a
quotient down, and an integer Div truncates one toward zero. An int32 keeps
the low 32 bits of its value, and a half rounds to the nearest it holds. Where the build can decide such an operation for every input, it
takes the answer. A bound past any length a list holds is one such case, and it
is how an exporter writes "to the end". Elsewhere the result is a derived
symbol, such as min(N, 8) or ceil(N / 2): a value forward computes from
the input symbols before any kernel runs. Stage 0 carries a derived symbol as it
carries an input symbol, within the range its operation leaves. The same
operation on equal operands derives the same symbol.
Errors split across the two layers. The generated boundary carries these types, so a caller passing the wrong tensor gets a type error from the ordinary compiler. A disagreeing extent inside the graph, or between the graph and its checkpoint, is discharged as compile-time computation in the generator, which is the ECOOP 2026 formulation.
An extent condition that stage 0 cannot discharge is a compile error, with one
exception: an inequality over the input symbols. The caller owes it, as it owes
the inputs' agreement on a symbol. forward checks it before any kernel runs,
and refuses the inputs naming the node that relies on it. Stage 0 first tries
to prove the inequality from what every symbol is. An input symbol is a length
between 0 and i32::MAX, and a derived one lies within what its operation
leaves. One it shows to hold for every input is not owed, and one it shows false
for every input stays a compile error. Anything else stage 0 cannot discharge has no
runtime tier to defer to, because the MVP has no tier below the static one.
The rule first had no exception. Every condition stage 0 could not discharge was a compile error, "the MVP's boundary, stated rather than worked around". That could not hold once the sequence length became a symbol: GPT-2's causal mask slice needs the length within the context, and only the run knows the length. A condition on the inputs is one the inputs can be checked against, so it became the one exception.
Two lengths of one axis are owed the same way. Where an operation meets two
axes of one name whose lengths are both symbolic, and stage 0 can show them
neither equal nor unequal, the inputs owe the equality as two inequalities. A
merged decoder reaches this. Its mask carries a dim_param of its own, and the
keys it masks carry the past's length plus the sequence's. A number meeting a
symbol stays a compile error, since dims can bind the symbol at build time.
A derived symbol is the common source of one. GPT-2 slices its causal mask to
sequence_length from a buffer as long as its context, so the mask's extent is
min(sequence_length, 1024). The scores it masks carry sequence_length
itself, and the two must be one length. Where a proof needs two extents equal,
or one extent to be 1, and they are not, stage 0 reads each derived symbol in
their difference as the operand it takes unchanged. A clamp reads as its bound,
a count clamped at none as its span, an index counted from the end as counted
from the start, and a wrap as the value it wraps. The reading holds only where the operation does
take that operand, so the inputs owe it. GPT-2's mask owes
sequence_length <= 1024. A rounded quotient takes no operand unchanged, so
only its operand is read.
A broadcast reads the same way. Where a 1 meets a derived symbol that reads as 1, the result keeps the 1, and the inputs owe the reading. A decoder with a past reaches this: one position's causal mask is sliced to a derived length, which reads as that 1.
Derived symbols replaced a stricter rule. A clamp leaves no polynomial, so Loam
first read every bound as unclamped, and the inputs owed that it lay within the
axis. A wrap was read the same way, and the inputs owed that the value fit
int32. That refused inputs ONNX accepts, such as a Slice to N over an axis
of 8 at N = 10, and it had no answer where the step was not 1.
package-loam/conformance/semantics_test.wado holds Loam to onnxruntime's
answers at two lengths.
More conditions are owed where only the run knows a length:
- A
Reshapetarget extent owes that it is not 0, which ONNX reads as the input's extent at that position, unless it is that extent or reads as it. - A
Reshapetarget's-1owes that the other extents do not multiply to 0, since ONNX then finds no extent for it. Where the build cannot divide them out of the count, the-1is the quotient rounded down, and it owes that they divide the count. - A step a
Rangecounts by owes that it is not 0. - A window owes that the padded input holds the kernel's reach.
- A
Splitowes that each part lies within the axis, and that the parts sum to it. - An output the graph declares a length for, where the nodes produced a polynomial, owes the equality as two inequalities. The declaration constrains the inputs, and only the inputs the run is given can say whether they meet it.
An int64 that some input would carry past i64 is refused rather than owed.
forward computes every owed slack in i64 as well, so the check itself would
wrap.
The generated module writes each axis as a marker type and the tensors in terms
of them, so a caller says Tensor<f32, [Batch, In]> where it means the input.
A marker whose axis every input and output fixes to one length carries it as
EXTENT. A caller writes Seq::EXTENT rather than the number, so it cannot
disagree with the graph, and a graph that stops fixing the axis no longer
compiles the caller.
It emits no per-tensor alias. Wado's type is a newtype, so an alias would need
an as at every construction to restate what the axis list already says, and it
would make two tensors of one shape distinct for no reason.
The Axis Schema Is an Invocation Option
Neither the graph nor the checkpoint says which axis is the batch. An ONNX shape and a safetensors header both give a tensor's rank and its lengths and nothing about what they mean. Axis names are a property of the architecture rather than of either file: two Llama checkpoints of different sizes share their axis names and differ only in their extents.
So the architecture is named where the graph is named, in the invocation's options:
use { Weights, forward } from "./model.onnxtext" with {
generator: {
module: "wado-lang:loam@0.1",
inputs: ["./model.safetensors"],
options: {
architecture: "llama",
layout: [{ pattern: "*.q_proj.weight", axes: ["Hidden", "Hidden"] }],
},
},
};
layout is what the generator reads. architecture is not read at all: it
names the built-in table a tensor falls back to where no pattern reaches it,
and roadmap step 17 writes those tables.
Options rather than a second file, because Kiln already rejected a schema a
reader has to go elsewhere to find (§"A manifest-side invocation registry").
Options are typed by the generator's own Options struct, so the use site gets
validation and completion that a sidecar document could not have, and they are
canonically encoded into the cache key, so reformatting one does not regenerate.
Kiln's probe receives them too, which inline_weights needs.
Naming every tensor is not an option at either place. A thirty-layer
transformer has hundreds, so the schema is a name and a set of patterns:
architecture selects a table Loam ships, and layout holds patterns consulted
in order ahead of it. A pattern is a shell wildcard over the tensor's name, and
the first rule that matches wins, so one pattern overrides a table entry without
restating the rest of it.
The schema names the graph's inputs and its initializers. Every other value
takes its axes from the operator that produced it, and an ONNX dim_param or
? on a named axis becomes a symbolic extent standing for that axis. An axis an
operator creates is the exception, and the next section names it.
A tensor that neither reaches is a build error naming it. Loam does not fall back to positional axes, for the same reason stage 0 does not fall back to a runtime check.
The tables age, because model formats move. That is safe rather than merely accepted: a table is read at build time and its answer is baked into the generated module, so a stale one stops the build with the error above instead of producing a module that computes the wrong thing.
architecture is an option rather than something read from the file.
safetensors has only a free-form __metadata__ map, which carries no dependable
architecture. A format that does state one, as GGUF's general.architecture
does, can have it read and checked against the option.
A Regrouped Axis Is Named by Its Parts
Flatten, Reshape and Unsqueeze produce axes that no input carries. Most of
them are regroupings, and a regrouping names its result from its parts.
Merging adjacent axes produces one axis whose identity is the ordered list of
its parts, Merged<[C, H, W]>, and whose extent is their product. Splitting
it again yields the parts back. A Reshape in general takes its input apart
into those parts and gives each output axis the consecutive run of them whose
extents multiply to its own, so a merge followed by a different split is still
named.
layout writes a merged axis as einops does, "(C H W)". A weight that reads a
flattened activation, as the first dense layer after a convolution does, names
the axis it contracts that way, and the contraction checks as any other does.
An extent-1 axis holds no elements to regroup, so a merge leaves it out of its
parts, and one that no output axis takes is dropped. [N, C, 1, 1] flattens to
[N, C], not to a merge of C with two unit axes.
An axis that no regrouping determines is new: the split of an axis that was
never merged, the extent-1 axis Unsqueeze inserts, the axis Concat joins
along, the axes of a folded
tensor, a one-element one included, and every spatial axis a window produces, since
Conv and MaxPool place their output on a grid of their own. The schema
names those. A window whose output keeps its input's extent may take the
input's name, and that is the layout's choice, not the operator's. A layout
pattern reaches a node's output by its tensor name, and it is consulted only for
an axis the operator left undetermined. One that no pattern reaches stops the
build naming the tensor. A generated name would be the positional fallback the
previous section refuses.
Unsqueeze and Squeeze carry every other axis by its position, so an inserted
axis never takes the name of an extent-1 axis beside it. Concat sums its
operands' extents on the axis it joins, and they must agree on every other axis,
by name and by extent. A join of more than two operands is a chain of pairwise
concat calls from the left, and each partial result names the joined axis as
the output does.
Two things follow. An extent is a product of symbols as well as a symbol, since
the parts of a merge may be dynamic. And Reshape reads its target shape at
build time, so the generator reads an integer tensor, which the graph carries as
a Constant or an initializer.
A Branch Is a Block of forward
An exporter can write a decoder as one graph that branches on whether it is
given a past. ONNX's If reads a bool of one element and runs one of two
subgraphs, each reading the tensors of the graph around it. Loam emits it as one
if over that element, with a block of forward for each branch. Both branches
compile, and the run takes one.
This is what lets one module hold one set of weights and still read a prompt in
one pass. The prompt runs through the branch without a past, all its positions
at once, and each step after it through the branch with one. Feeding the prompt
one position at a time through the decoder with a past, starting from an empty
past, also needs one graph and matches onnxruntime. It was rejected because a
prompt of n tokens then costs n dispatches where one would do, and a design
that loses performance on WebGPU is not taken.
Stage 0 checks each branch in a copy of the scope around it. What a branch owes
is checked at the top of its block rather than of forward. A condition one
branch relies on would otherwise refuse inputs the other branch accepts: the
branch without a past owes that the mask is as long as the sequence, and the
branch with one that it is as long as the past and the sequence together.
The node's outputs take the axes both branches give them. An axis the two name apart takes the name the output's layout gives, and each block relabels its value to it. A length the two branches give differently becomes a symbol of its own, which only the run knows. The two branches must agree on each output's rank and element type, and the build stops where they do not.
A constant of the graph around the branches takes the shape that graph's layout
gives it and is spelled once, ahead of the if, so a mask as large as the
context is not written into the module twice. A branch that reads it under
other axes relabels it.
The two branches may name their tensors alike, and the merged GPT-2 does. A
layout rule may therefore name the graph it reaches, by the name the graph
declares. A rule without one reaches every graph.
A merged decoder names its key axis and its query axis alike, as the decoder
without a past does. Without a past, the keys are the sequence, so the two are
one axis there. Telling them apart would need layout to rename an axis that
an operator carries through, an exception to the rule that two names never
meet silently. The types then do not tell a key from a query in either branch.
The run still checks that their lengths agree.
The dtype Is a Storage-Only Primitive
A checkpoint's elements are mostly half precision, and most LLM checkpoints on
the Hub are bf16. Wado computes in neither, and Loam never asks it to: the
host's job is to move the bytes. So f16 and bf16 are primitives that carry
bits and no arithmetic. Adding them is a compiler change rather than a Loam one,
specified in
WEP: Half-Precision Primitives.
Loam holds such a tensor and never computes in it. A graph whose boundary is
float16 or bfloat16 gets a Weights and a forward typed in that
primitive, and forward widens each half operand into f32 on the way in and
narrows the result on the way out. The kernels see one element type, so a
kernel signature states its axes and no dtype.
A dtype becomes a Wado primitive only where a tensor of it can cross the
generated module's boundary, as an input embedding or an output logit does. A
weight-only format never does. int4 is two elements to a byte with a scale per
block, and f8 is never shipped without its scales either; WGSL has no type for
either and unpacks both from u32 with shifts. They stay bytes in the
checkpoint and a tag inside the generator, and never reach Wado's type system.
The Signature Is the Notation
einx exists because Python cannot say in a type what an operation does to a shape, so it says it in a string. Wado can say it in a type. The runtime declares each kernel, and the generator the model's boundary, as a generic signature that carries every part of the notation:
fn matmul<..Bs, M, K, N>(
a: &Tensor<f32, [..Bs, M, K]>,
b: &Tensor<f32, [..Bs, K, N]>,
) -> Tensor<f32, [..Bs, M, N]>
A repeated variable is einx's bracket: K appears in both operands and not in
the result, which is what contraction means. The unrepeated scalars are the
vectorized axes, the pack is einx's leading ellipsis, and the return type is its
arrow. One signature covers rank 2, 3 and 4, because the pack also matches
empty.
So there is no notation layer. A tagged template could not have been one
anyway: ReflectTemplate exposes the hole types and the literal text as a
value, with no associated type derived from the literal, and einx's entire
content is in that literal. A tag could only return a tensor whose axes had
been erased, discarding what the type layer just won.
A parameter list may declare several packs, each settled by the argument that
carries it. So an operation whose input and output axis lists are independent
names both, which is what a rank-changing operator such as Reshape needs.
A contraction names its axis where the axis sits. Two packs in one tuple settle
neither on their own, so [..Pre, K, ..Post] needs its ends settled by something
else, and a generated call spells them in a turbofish. The generator writes both
sides of that call, so spelling them costs it nothing.
Whether a middle-axis contraction is then transposed into the trailing position is the kernel's decision, taken for locality rather than forced by the signature. A rank-2 weight broadcasts across batch axes by putting a pack on one side only.
The Backend Is a Trait
A Backend trait, monomorphized at each use, selects where kernels run. Cpu
is the MVP's implementation, and a first WebGPU one follows it.
The backend owns the tensor's representation, as a generic associated type.
Each kernel is a method, and its signature is the free function's with
Tensor read as Self::Tensor, so the notation survives the seam:
trait Backend with _ {
type Tensor<E: Element, Axes>;
fn matmul<..Bs, M, K, N>(
&mut self,
a: &Self::Tensor<f32, [..Bs, M, K]>,
b: &Self::Tensor<f32, [..Bs, K, N]>,
) -> Self::Tensor<f32, [..Bs, M, N]>;
}
impl Backend for Cpu {
type Tensor<E: Element, Axes> = Tensor<E, Axes>;
...
}
The parameter is generic because half the kernels are: gather, concat and
the regroupings move elements of any type without computing on them. One
associated type per element type would split each of them into one method per
type. Element bounds it to the types the kernels compute in, f32, i64 and
bool, and converts one to another as ONNX's Cast does, so cast is one
kernel generic over both ends.
The rest of the seam follows from what a GPU backend needs:
- The backend is a value.
forward<Bk: Backend>takesbackend: &mut Bkahead of its inputs, since a GPU backend holds a device and a queue. - The boundary stays
Tensor, data on the host.forwarduploads each input and downloads each output, which is nothing on the CPU, and widens or narrows a half there as it does now. Weights are uploaded per call until step 12 holds them in the backend's form. - An elementwise operator is passed as data, never as a closure. An
Expris a list of steps over the kernel's operands (operand(0),operand(1)), the constants folded into it (constant(0.5_f32)), and the unary and binary operators ONNX names, each reading the steps before it. A closure cannot become a shader, and fusion composes expressions. The CPU backend evaluates one a step at a time over whole operands, each step a loop with its operator fixed before it starts: read step by step per element, anExprran four to five times slower than the closure it replaced. A broadcast operand is stretched to the output first. - The trait declares
with _. A GPU backend callswasi:webgpu, and the CPU backend instantiatesforwardwith no effect. - The shape checks stay on the host and outside the trait, ahead of the
uploads, reading the inputs as given. An
Ifdownloads its condition, which on a GPU is a synchronization. BackendandCpuare inlined with the runtime, so each generated module declares its own, as it declares its ownTensor. A test backend,package-loam/conformance/opaque_backend.wado, holds each tensor behind a handle with no field aTensorhas. The operator and semantics cases run through it, so aforwardreaching past the seam does not compile there.
The First WebGPU Backend
WebGpuBackend (package-loam/src/runtime/webgpu.wado) is the seam's second
implementation. A generated module inlines it after the tensor runtime where the
generator's webgpu option is true, and forward runs on it unchanged. It is
the first cut of step 14 and checks the seam: nothing in forward or the
emitter changed for it.
- A tensor is
Placed: a hostTensor, or f32 elements in a device buffer. WGSL stores noi64and nobool, so only an f32 result stays on the device. uploadkeeps the tensor on the host, and each kernel that reads an f32 operand on the device writes it there. So an index or a mask never crosses.- An elementwise kernel runs a shader generated from its
Expr, one per distinct expression and compiled once. It takes the same-extents case and each operand broadcast over the output's leading axes. A batched product runs one tiled shader,matmulandmatmul_sharedalike. - Every other kernel runs on
Cpu, downloading its device operands first. with _on an impl method is an effect parameter nothing resolves. So each method spells thewasi:webgpuresources it touches.
package-loam/conformance/webgpu.wado runs every shader path against Cpu.
wado-run-webgpu's tests run it, since wado test reaches no device.
package-loam/example/webgpu/mlp.wado times a three-layer MLP at batch 256
under wado run-webgpu, both backends on one host:
| Device | Speed over Cpu, second call |
Largest deviation |
|---|---|---|
| GeForce RTX 4060 Ti | 19x | 1.9e-6 |
Most of the WebGPU time is the weights crossing on every call, which step 12 removes.
An effect was considered and set aside. Wado's effects lower through stack switching, whose per-operation cost is unmeasured here, and a tensor dispatch is a hot path. A trait's cost is known to be zero.
The CPU backend is for correctness and for the seam, not for speed. Wasm SIMD
loads from linear memory and a Wado List<T> is a WasmGC array with no address,
so core:simd offers no load: a vector is built by splat, by tuple coercion,
or by reinterpreting another vector. The pattern that does pay is core:prng's,
where vectors stay in the dataflow and never come from a list, which suits a
tile held across a long computation and little else. The performance story
belongs to the GPU backend, and a place with no GPU, such as an edge worker, is
not a target until the CPU path has a vector load.
The MVP Is Tier 1 Only
Gale's two tiers become three here. This WEP builds the first:
| Tier | Decided at | Covers |
|---|---|---|
| Static (tier 1) | build time | Static shapes, weights, fused blocks |
| Bucketed (tier 2) | first use, cached | Residual sequence and batch extents |
| Dynamic (tier 3) | per token | MoE routing, speculation, early exit |
Tiers 2 and 3 are deliberately absent, not undiscovered.
Tier 2 is a shape-class cache rather than a simulator. Generating a kernel is expensive where Gale's ATN step is cheap, and the generation must finish before the dispatch it serves can run. Tier 3 is the genuine analogue of Gale's ATN: data-dependent control flow that no bucketing covers.
The Generator Reports Every Fusion Decision
gale dump is what makes Gale's escalation auditable, and its absence is why
torch.compile's graph breaks arrive as a mystery. Loam's generator reports,
per operation, what it fused, what it did not, and why.
The MVP has one tier and reports anyway. A declined fusion carries the reason it was declined, and those reasons are what will route the later tiers.
Every Name Is Minted From One Set
Every identifier the generated module declares is minted from one set. The
module binds the kernels it inlines, forward, Weights and the weights
parameter before it names anything the graph brings, and the runtime's share is
read out of the runtime source, so a declaration added there is reserved
without anything else being edited.
The two kinds of name the graph brings are answered differently. An ONNX tensor
name is any string and comes out of a model file, so weights, map and
Batch are all names a graph may carry, and one that is taken gets a trailing
_. An axis name is the schema's own word, so an axis named after a kernel or a
marker stops the build naming it: renaming it silently would rewrite what the
author wrote, and they are there to change it.
The language's own share of that set is package-wadopoet's is_reserved: every
keyword the parser does not take as a name, and every name the prelude puts in
scope. A name keyword such as type or from stays free. The predicate is
generated from what wado syntax --format json reports, so a keyword added to
the compiler is reserved here without anything else being edited. Gale reserves
the same set for a grammar's rule names.
What the CPU MVP Cannot Validate
The MVP validates the shape algebra, the generator architecture, and the backend seam. It does not validate the fusion objective.
On CPU the objective is memory traffic. On WebGPU at batch size 1 it is dispatch count. They are different objectives, and a fusion policy tuned against the first is not evidence about the second. The WebGPU backend will have to re-derive its policy from measurement rather than inherit this one.
The CPU arm cannot even reach its own objective's ceiling, for the reason in "The Backend Is a Trait": no vector load from a WasmGC array. A number this MVP produces is a correctness result and a floor, never a claim about the design.
The Tensor Kernels Stay in Loam
The kernels in package-loam/src/runtime/tensor.wado stay in Loam until Loam's
own use cases settle them. An ndarray API has to serve several backends
(scalar, SIMD, WebGPU), and Rust keeps that layer out of std, split across
crates. Moving any of it into the standard library comes after.
Roadmap
- [x] The kernels. Each operator lowers to a call into the inlined
runtime (
package-loam/src/runtime/tensor.wado), and the generated module carries aWeightsstruct for the graph's initializers and aforwardthat runs the nodes in order. Each kernel carries the axis notation in its own signature.matmul<..Bs, M, K, N>contractsK, andzip_suffix<..Pre, ..Suf>broadcasts a trailing axis list, so a generated call is checked by the ordinary compiler rather than by Loam.package-loam/conformance/checks them against ONNX's own outputs: graphs from the backend test data compile through Loam and reproduce the tensors shipped beside them. The models are committed underpackage-loam/tests/onnx/. - [x] The half-precision element types.
float16andbfloat16map to the primitives WEP: Half-Precision Primitives adds.Weightsandforwardstate the graph's own element type, andwiden/narrowconvert at the boundary over theHeldtrait the two halves implement.package-loam/example/half.wadocompiles such a graph through Kiln and prints its tensor. - [x] A model's own weights. Where a graph carries every initializer's data,
the generated module adds
Weights::embedded(), which returns them. Each payload is a byte literal read byList::from_le_bytes, which the compiler folds into a data segment, so an ONNX file alone is enough to run inference. The conformance graphs with initializers run fromWeights::embedded(). - [x] Minimal inference on ONNX alone. MNIST-12 from the ONNX Model Zoo runs
from its
.onnxfile and reproduces the logits shipped beside it, which tookConv,MaxPool,Reshapeand a stretched bias. The model lives inpackage-loam/tests/models/with its provenance, apart from ONNX's own test data.package-loam/example/mnist.wadoclassifies digits drawn in its source. - [x] A small transformer over static shapes. A GPT-2 decoder as an exporter
writes it, with its batch and sequence length fixed at build time. Most
of such a graph computes shapes rather than values, so this is where
stage 0 evaluates that computation. A tiny GPT-2 with random weights runs
from its
.onnxand reproduces the logits onnxruntime computes for it. Itslayoutnames the weights and every axis a regrouping leaves new, such as the heads. The model lives inpackage-loam/tests/models/tiny-gpt2/with the scripts that wrote its oracle data.package-loam/example/gpt2.wadotakes text in and gives text out. Its tokenizer, GPT-2's byte-level BPE, gives the ids Hugging Face's tokenizer gives, and greedy decoding picks the tokens onnxruntime picks. - [x] A symbolic sequence length. The same model with its sequence length left to the run. Finished when one generated module reproduces onnxruntime's logits at two sequence lengths. The tiny GPT-2 does at four tokens and at seven, and both GPT-2 examples feed the tokens so far rather than a padded context.
- [x] The checkpoint reader. safetensors in the generator, its header checked
against the graph. safetensors comes first because its header is already
JSON, with no quantization to model. The generated loader checks each
payload against the shape its header declared. Finished when a real
checkpoint's shapes check against its graph and its payloads load, and a
deliberately mismatched header or payload names the axis. The real
checkpoint is GPT-2's 124M-parameter export, split by
onnx_split.package-loam/example/gpt2-124m/downloads it and continues a prompt with the tokens onnxruntime picks. It builds from the graph and the checkpoint's header, both committed, sowado testcompiles it without the download. - [x] Incremental decoding.
forwardreturns every output the graph names, so a decoder hands back the attention keys and values it computed. GPT-2'sdecoder_with_past_model.onnxtakes them back aspast_key_values, so a step after the prompt computes one position against a past that grows, which needs step 6. Finished when GPT-2 continues a prompt with the tokens onnxruntime picks, computing one position per step after the prompt. The tiny GPT-2's decoder with a past lives inpackage-loam/tests/models/tiny-gpt2/with-past/, andpackage-loam/conformance/tiny_gpt2_past_test.wadoreproduces onnxruntime's logits for the prompt and for two steps after it.package-loam/example/gpt2.wadoreads the prompt once and then one token per step. - [x] One graph for both. An exporter merges the two decoders into one graph
that branches with an
Ifon whether a past is given, so one generated module holds one set of weights and still reads a prompt in one pass. Finished when GPT-2 continues a prompt with the tokens onnxruntime picks through one module. The tiny GPT-2's merged decoder lives inpackage-loam/tests/models/tiny-gpt2/merged/, andpackage-loam/conformance/tiny_gpt2_merged_test.wadoreproduces onnxruntime's logits for the prompt and two steps after it.package-loam/example/gpt2.wadoruns on it. - [ ] The tokenizer. Text reaches a model through a tokenizer Loam ships, rather than through code beside each example. Its design is a WEP of its own. Finished when both GPT-2 examples encode and decode through it with the ids Hugging Face's tokenizer gives.
- [x] The backend seam. The
Backendtrait with its CPU implementation. Finished when the MLP runs through the trait and matches the reference within tolerance, and a test backend whose tensor is an opaque handle runs it too, which no generated read of.datasurvives. The conformance graphs run throughCpu, and the MLP, anIfand every operator and semantics case run through the handle backend inpackage-loam/conformance/opaque_backend.wado, the MLP and theIfto the CPU's output exactly. - [ ] Prepared weights.
Weightsholds each weight as the backend computes it, converted once whereloadorembeddedbuilds it, rather than widened on every call toforward. The form belongs to the backend, so this follows step 11. It is whatf16andbf16entered the language for: a half-precision model is downloaded and stored at half its size. Finished when a half-precision model runsforwardin a loop that converts no weight, and matches its output converted per call. - [ ] Fusion and its report. The generator fuses operators into one kernel
where the CPU objective favours it, and reports each decision, following
what
gale dumpestablished. Finished when the step-4 model runs with its elementwise chains fused and matches the unfused output, and every operation reports a decision and a reason. - [ ] The WebGPU backend. A second
Backendoverwasi:webgpu, with the fusion objective re-derived for dispatch count. Abenchmark/row against onnxruntime waits for it, since a CPU number is only a floor. Finished when GPT-2 (124M) runs through it underwado run-webgpuand picks the tokens onnxruntime picks. The first cut is in (see The First WebGPU Backend): the products and the elementwise kernels run on the device, and every other kernel on the CPU. - [ ] The probe. Loam reports the header as a checkpoint's extent, so the build keys on the header rather than hashing and reading the whole file (see The Graph Is the Schema). Closing it takes the Kiln work its WEP records. Finished when a build naming GPT-2 (124M)'s whole checkpoint reads its header and none of its payloads, so the example no longer needs a header file of its own.
- [ ] Sharded checkpoints. A model published in shards, with the index that
names each tensor's file, arrives as several
inputsentries. Finished when a sharded checkpoint checks and loads as its single-file form does, and a tensor the index places in a shard that is absent names the shard. - [ ] The architecture tables.
architectureselects one Loam ships, consulted after thelayoutpatterns. Finished when a Llama checkpoint needs nolayoutentry to name its axes. - [ ] The StableHLO front end. Finished when the MLP exported from JAX checks and runs through the same pipeline as its ONNX form.
- [ ] int4.
DequantizeLinearwith ablock_sizefused into theMatMulit feeds, which is what makes a model small enough to download. Finished when an int4 checkpoint from GPTQ or AWQ produces the f16 checkpoint's output within tolerance.
Known gaps
The operator set is MatMul, the binary and unary elementwise operators,
Transpose, a Gemm lowered to those kernels rather than to one of its own,
Reshape, Flatten, Unsqueeze and Squeeze, Split, Concat, MaxPool and Conv
over any number of spatial axes, Softmax, ReduceMean and ReduceSum,
Where, Gather, Slice, Range, ConstantOfShape, Cast, Identity and
If.
Where no generic signature relates a kernel's operands to its result, the kernel
takes the axis lists as type parameters and trusts them, as reshape does. The
generated call spells the lists stage 0 computed. A permutation of three or more
axes is one such kernel, permute, since each of the n! orders is its own
type. So is matmul_broadcast, which serves every product whose operands drop
a rank-1 operand's axis or stretch a batch axis of 1. So is lift, which reads
an elementwise operand under the axes it meets where the layout names an axis
of 1 apart from the one it stretches to.
Stage 0 evaluates Shape, and no kernel computes it, so it stops the build
where the build does not know its operand. Concat has a kernel, and stage 0
evaluates one whose operands the build knows. Slice, Range and
ConstantOfShape have kernels, but read their bounds or their shape at build
time, so one the run computes stops the build as well. So does a Range whose
delta has a sign the build cannot tell, since that decides which way it counts.
Every other operator stops the build naming itself.
Stage 0 bounds each term of a condition apart, takes a bound past 2^64 as none, and stops comparing through derived symbols after a fixed number of failures. So a condition that holds for every input may still be owed, and one false for every input owed rather than refused.
A derived symbol is read as its operand only where two extents must be equal or
one must be 1, a Reshape's element count and the sum of a Split's parts
among them. Only the difference of the two extents is read, so a derived term
both share owes nothing.
An integer Div truncates toward zero, and stage 0 computes it over numbers.
Over a symbol, it stops the build unless the dividend is never negative and the
divisor is a positive number. There truncating rounds down, and a derived symbol
rounds down.
A value forward computes from the symbols, an extent or an owed slack, must
fit i64 with every symbol as large as a length can be, or the build stops. That
bound takes each symbol apart from the others, so it also stops a length the
inputs bound together. [N, M, 3] flattened is 3 * N * M elements, which no
input carrying N and M on one tensor takes past a list, and still stops the
build.
layout can name a merged axis by its parts, as "(C H W)". That gives the
axis an extent but not its parts, so regrouping such an axis stops the build
naming the part.
A dim_param spells arithmetic only in the whitespace-separated form above.
past_sequence_length+1 is one opaque name, and so is any spelling with
parentheses or division.
A model exported as two graphs carries its weights twice. GPT-2's decoder and
its decoder with a past each name every weight, so each generated module's
Weights holds a copy, and a program running both holds two. The merged export
is one graph and holds them once.
A node other than Split and If produces one output. TopK and the recurrent
operators name several, and stage 0 gives only those two a shape per output.
A branch that declares inputs or initializers of its own stops the build. So does an attribute holding a list of graphs.
An If output whose branches give it two lengths has a length nothing binds.
A node after the If that reads that length stops the build.
A constant that only branch-scoped layout rules name is spelled in each branch that reads it.
A condition the build knows still compiles both branches.
Every arithmetic kernel computes in f32. A boundary of float16 or bfloat16
is held in that primitive and widened. One of int64 is held in i64, and one
of bool in bool. Every
other element type stops the build naming it. Arithmetic in double and the
integer types is a second instantiation of the same kernels. Wado has a
primitive for nearly every ONNX element type, so what a step adds is kernels
that compute in one, never the mapping.
The conformance suite reaches the graphs ONNX commits, not the per-operator
cases. onnx/backend/test/data/node is generated rather than checked in, so
what package-loam/conformance/ runs against is pytorch-operator/ and
pytorch-converted/: whole small graphs with their inputs and outputs beside
them.
Two suites take their expectations from onnxruntime instead.
package-loam/conformance/operators_test.wado covers every operator Loam
lowers, over static shapes, with the attributes and edge values where a reading
can go wrong. semantics_test.wado covers shape arithmetic over a symbolic
length, at two lengths. A script beside each, operators.mjs or
semantics.mjs, writes one small model per case and what onnxruntime computes
from it. The test builds each model as the generator would and runs the result
under core:eval, so a refused build, a trap and a wrong answer each show as a
difference from onnxruntime. A case Loam does not match yet is a #[TODO] test,
so the run lists where the two part.
Those models are copied into package-loam/tests/onnx/ rather than read out of
vendor/onnx. A submodule is absent wherever it is not initialized, and a file
an import names has to be there for the module to compile, so reading one turns
a test into a build failure. The copies keep the upstream layout, and the
directory's README.md carries the provenance.
An operator reads only the attributes its shape rule, fold or kernel consults,
and is checked as if the rest were absent. The readers carry every attribute as
the text it was written as, and whether it was written as strings, since a
string may read as 3. An attribute of the wrong kind stops the build. A tensor
attribute is carried when its elements are float, float16, bfloat16, int32,
int64 or bool. A half is widened to f32, which holds each of its values exactly.
It keeps the type it is stored as, so the printer writes it back as bit
patterns. A Constant or ConstantOfShape whose value holds any other type
stops the build. A double would narrow to f32, which is the element-type gap
above.
An initializer naming its own data file stops the build, naming the tensor. The
textual format lets one carry a ["location": ...] list, the protobuf form an
external_data entry, and Loam takes its weights from the
checkpoint the invocation names, so the two say different things about where
one tensor's bytes are.
The WebGPU backend runs a shader for the products and the elementwise kernels
alone. A softmax, a reduction, a regrouping, a gather and a convolution run on
the CPU, and each downloads its operand. So does an elementwise kernel whose
operand stretches an axis of 1, the opset-6 zip_at, and a product with more
batch elements or tiles than one dispatch dimension holds. A model whose graph
alternates the two crosses the boundary at every such node.
A host operand crosses once per kernel that reads it, not once per forward:
a weight two products read is written to the device twice. The host tensor is
a value, so the backend has no identity to cache the buffer under.
A shader may assume no NaN reaches it, as WGSL allows. Where the CPU keeps a
NaN, Relu among them, the device may give a number.
A buffer crosses the component boundary as a list<u8>, which the guest
converts from or to f32 one element at a time, so crossing costs more than the
device's own copy. Under wado run-webgpu, whose wasmtime is a generation
behind wado run's, a loop allocating per element (map and collect) runs
twenty times slower than under wado run. The backend writes its own loops for
that reason.
Every WebGpuBackend method spells the same ten wasi:webgpu resources, since
an impl method's with _ resolves to nothing at a caller.
Tier 2, the bucketed JIT. Residual sequence and batch extents need a shape-class cache populated on first use. Closing it needs a cache key design, a warmup story, and an answer to generation latency becoming user-visible. At build time no user waits on the generator; at first use one does.
Tier 3, dynamic dispatch. MoE routing, speculative decoding, and early exit are data-dependent and no bucketing covers them. Closing it needs the escalation boundary drawn as a decidability question, as Gale's is, rather than as a heuristic.
Quantization beyond int4. Block formats are parameterized by block size, scale representation, and rotation, so the type that carries one must be parameterized the same way rather than named after a format; f8 belongs here, since it is never shipped without its scales. GGUF as an input format arrives with this, not before it, and its metadata is a binary key-value block rather than JSON, so its probe has to parse one to report an extent.
ONNX local functions. The textual format lets a model carry its own function
definitions and call them as operators, and the grammar covers neither, nor the
@name attribute reference only a function body can carry, so a model holding
one does not parse. What they admit is an operator set a model brings with it
rather than one Loam knows, which the shape algebra has no account of.
Training. Loam is an inference design. An autograd layer over the same shape algebra is plausible and unowned.
Whether the backend should be an effect after all. The trait is chosen because its cost is known to be zero and the effect's is unmeasured. A second backend makes the measurement possible.
