Skip to content

Tier 9: stabilisation, and the 1.0 release - #83

Merged
tonytonycoder11 merged 11 commits into
mainfrom
tier-9
Aug 2, 2026
Merged

Tier 9: stabilisation, and the 1.0 release#83
tonytonycoder11 merged 11 commits into
mainfrom
tier-9

Conversation

@tonytonycoder11

Copy link
Copy Markdown
Contributor

Closes #79, #62, #65, #61, #60, #33, #34, #35.

The tier that turns the gate from something that works into something whose
numbers can be argued with.

What the measurements changed

Two of these items were expected to confirm what was already there and did not.

int8-dynamic was measured at 4.1e-2 against a bound of 5e-2. That is not a
margin, it is a coincidence, and a build on other hardware had every chance of
tipping a committed fixture over it. The bound widens rather than narrows.

Narrowing float16 to what the convolutional model alone would justify was
tried, at 5e-3, and it failed the two-layer fixture on the first run. The
two-layer model drifts up to eight times further on every backend and it is the
simpler network: its outputs land near 9.4 while the other ends in a softmax
that pins its own into [0, 1], and a relative error is measured against the
output while the rounding happened on intermediates. The bounds are set by the
model with the large outputs.

tool/measure_tolerances.dart reproduces all of it.

What was covered rather than fixed

Every model committed here returned a single tensor, so the code keeping a
second one in the right order had been read and never executed. testdata/multi_io
runs it through all three engines, because each shim converts its own runtime's
output list into ours. Its two inputs share a shape and so do its two outputs,
which removes the shape check as a safety net and leaves ordering as the only
thing that can be right.

What the issue got wrong

#65 said ONNX Runtime could not reach a weight file beside the graph because
ft_load takes a buffer and external data resolves against a path.
AddExternalInitializersFromFilesInMemory registers the names against the bytes,
so nothing is looked up on disk. An artifact can now be more than one file: the
manifest names the parts, weight_hash covers them, and schema_version rises
to 2 only for a manifest that carries any, because a reader without them would
load 506 kB of structure and answer from a graph with no numbers in it.

VoltaCast through ONNX Runtime is the proof. It loads with its weights in a
separate file and its goldens hold.

The rest

  • The parity matrix runs on a convolutional model with two kinds of
    normalisation instead of two linear layers that had nowhere to disagree.
  • STABILITY.md: four surfaces carry a compatibility promise and only one is a
    Dart API.
  • docs/benchmarks.md: runInto saves four to seven microseconds over run
    and the saving does not scale with the model, which inverts the advice you
    might expect.

Verification

dart analyze --fatal-warnings clean, all six Dart suites and the typed API
example green, 118 Python tests, ruff clean, audit-library.sh at standard.
The three native shims were rebuilt and the on-device suites re-run against
them.

The tag and the pub.dev publish are not in this branch.

The three shims raised ManifestFormatException when the engine failed, whose
remedy is to re-export the document. On a bundle whose manifest parsed and whose
hash matched, that advice rebuilds something already correct and leaves the real
cause unexamined. VoltaCast made it obvious: a provably good bundle failing at
execution, and the message pointing at the export.

RuntimeExecutionException names which of the three engines failed and carries
the shim's status and the runtime's own error code as numbers. The shim already
formatted that code into its message, so it is pulled back out rather than left
inside a string a reader has to search.

The shared client gains the engine's name, because what the three shims share is
the C ABI rather than an implementation, and without it a failure could only say
the native call failed, which is true of all three.

The exhaustive switch over the sealed hierarchy caught the addition, which is
what sealing it is for.

Closes #79
Every model committed here returned a single tensor. The runtime, the gate
and the generator all handle lists throughout, so the code that keeps a
second output in the right order looked finished; it had simply never been
executed. The gate's loop over the outputs had only ever taken one trip,
and the emitter had never written an index other than zero.

testdata/multi_io is the fixture that runs it. Two inputs, two outputs,
through ExecuTorch, LiteRT and ONNX Runtime, because each shim converts
its own runtime's output list into ours and one of them keeping the pair
straight says nothing about the other two.

Both inputs share a shape and so do both outputs. That is the whole design
rather than a convenience: a pair that differed in shape would be caught on
the way past by the shape check, and every assertion would then pass for a
reason that has nothing to do with ordering. What is left to tell these
tensors apart is the name and the numbers, which is what the generated API
and the golden replay actually rely on.

The gate half runs without an engine, so it sits in fluttorch_test where CI
reaches it rather than only on a machine with the native libraries built.
The perturbation there moves the second output alone and asserts the report
names it and not the first.

VoltaCast already covered two inputs and is left alone: its model directory
is vendored verbatim from upstream, and a second head there would make that
sentence false.
Above a size torch.onnx decides on its own, the weights leave the graph and
land beside it. The export refused that, for two stated reasons: the weight
hash covered the artifact, so a bundle could pass every check while carrying
none of the numbers, and the runtime takes bytes and had no way to reach a
file next to them.

The first reason is answered here. A manifest can name the parts an artifact
references and cannot be loaded without, and weight_hash is computed over the
artifact and every part together, each contributing its name and its length
as well as its bytes. Concatenation alone would be ambiguous, and a hash
whose meaning depends on where you cut it commits to nothing.

With no parts the digest is the artifact's and nothing else, byte for byte
what every manifest already declares, so this re-exports nothing.

The schema version rises to 2, and only for a manifest that actually carries
parts. Every field added to this schema so far has been additive, meaning a
reader that did not know it carried on doing what it did before. This one is
not: the decoder ignores keys it does not recognise, by design, so a reader
without parts would load the structure alone and answer nonsense from a graph
with no numbers in it. The version is the only thing that can stop it.

The second reason, the loader, is the next commit.
The Dart side of what the exporter started writing. A manifest can carry
parts, verifyArtifact takes them, and the digest is computed over the
artifact and each part with its name and its length framed in, matching
bundle_digest byte for byte.

VoltaCast through torch.onnx is the export that produces one, and it is
committed: 506 kB of graph and 3.4 MB of weights in a file beside it. That
makes the cross-language check a real one rather than a fixture written to
agree with itself. If either side ever frames a length differently, the
bundle stops loading, and the test says which side moved.

currentSchemaVersion rises to 2 because this build can now read parts. An
export without them still declares 1, so nothing that already shipped stops
being loadable, and a build from before this refuses a bundle it would
otherwise load the structure of and answer from.

Three failures rather than one, because they have three fixes. A part that
did not arrive is a deployment that shipped the artifact alone.  A part whose
length is short is a copy that got cut. A part whose hash moved is a stale
export. Folding them together would send every one of them to re-export a
model that was fine.

The loader is still to come: nothing yet hands these bytes to an engine.
ft_load_parts takes the artifact and the files it references, and the header
declares it once for all three bindings because they implement one seam and
a second signature would be a second seam.

ONNX Runtime resolves external data against the directory a model was read
from, and a buffer has no directory. AddExternalInitializersFromFilesInMemory
registers the names against the bytes instead, so nothing is looked up on
disk and the session never needs a path. That is what the issue thought was
closed, and it is open.

The other two refuse. A .pte and a .tflite each carry their own weights, so
a bundle arriving at either with parts was exported for a different engine,
and the refusal says which. ExecuTorch does have a shape for this in its .ptd
data file and nothing here writes one yet; when something does, this is where
it gets answered rather than where it gets discovered.

Refusing is the point. Loading the graph and leaving the weights behind gives
a session that parses, declares every shape the manifest promised, runs, and
answers from nothing.

ft_load stays, delegating with no parts, so a caller that does not know which
shape it holds does not have to ask.
The last stretch of it: the Dart side hands the parts to the binding, the
gate reads them from beside the manifest, and the model that made this
necessary runs.

VoltaCast through ONNX Runtime is 506 kB of graph and 3.4 MB of weights in
a file next to it. It loads, and its goldens hold against references captured
from the source model before any of this was split apart. That last clause is
the one that matters: loading was never the hard part, because a graph without
its weights loads too, declares the same shapes, and answers.

The FFI keeps one loader rather than two. ft_load_parts delegates when there
are none, so binding both would have been two paths to drift apart, and the
one that stayed would have been the one nothing exercised.

A binding that cannot resolve external data names that as what it lacks, not
"this load". Two absences that read the same in a log are two absences nobody
can tell apart at three in the morning.
Every bound in the gate was a starting point, written before any model had
been measured and documented as such. tool/measure_tolerances.dart now
produces the numbers, and every entry cites what it was measured on.

The matrix runs on a model that can go wrong. Two linear layers, 4 to 8 to 3,
has nowhere for two delegates to disagree, so the old matrix came out ordered
the way arithmetic says it should and proved the measurement worked rather
than that it was worth taking. testdata/matrix has convolutions, a foldable
BatchNorm2d, a GroupNorm that reduces at run time and a softmax, exported
once per backend and once per recipe.

The measurement said something I did not expect. The two-layer model drifts
further than the convolutional one on every backend, by up to eight times,
and it is the simpler network: its outputs land near 9.4 while the other ends
in a softmax that pins its own into [0, 1], and a relative error measured
against a small output reads smaller for the same rounding.

So the bounds are set by the model with the large outputs. Narrowing float16
to what testdata/matrix alone justifies was tried, at 5e-3, and it failed
testdata/coreml on the first run. A bound derived from the better-behaved of
two committed fixtures is not a measured bound, it is a measurement of which
fixture was picked.

int8-dynamic widens rather than narrows. It measures 4.1e-2 against a bound
of 5e-2, and 1.2 times is a coincidence rather than a margin.

startingPointFor becomes boundFor. The old name was honest about what those
numbers were and would have stopped being honest here.
Four surfaces here carry a compatibility promise and only one of them is a
Dart API, which is why SemVer on the package version answers none of them on
its own.

The manifest is a document two implementations parse in two languages, and
schema_version rather than the package version decides whether an older
reader may proceed. The C header is an ABI three bindings implement and a
consumer may link from a prebuilt library, where struct field order is part
of the contract in a way that does not fail to compile. The generated Dart is
code somebody else commits, so a change to what the emitter writes shows up
as a diff in their repository with nothing of theirs changed. And the
tolerances decide whether a build is green, so narrowing one turns a passing
build red without a line of caller code moving.

The numbers a model produces are not covered, and that is the answer most
likely to be wanted. The arithmetic belongs to the delegate that runs it.

Two minor releases before a deprecated API is removed, because that is
ExecuTorch's policy and a binding cannot outlive a symbol the engine below it
has removed. Promising longer would be promising something this project does
not control.
Three costs, because three different claims get made about this library and
each has its own way of being wrong. Codegen is paid once per manifest at
build time, load once per model per process, and run on every inference.

Codegen is about a millisecond and flat across models that differ by three
orders of magnitude in weight size, which is expected rather than impressive:
the emitter reads specs and writes text and never touches the artifact. What
it supports is only that the emitter is not why a build is slow.

Load tracks artifact size at roughly 6.5 ms per megabyte. VoltaCast costs
22 ms, once per process rather than once per screen, which is worth knowing
before deciding whether to load a model at startup or on first use.

The run numbers invert the advice you might expect. runInto saves four to
seven microseconds over run, and the saving does not scale with the model, so
it is three times faster on the two-layer model and under two per cent on the
convolutional one. Reach for it on something small called at frame rate.

Resident memory was measured and is not published. Dart exposes no allocation
count, and the RSS delta over a thousand runs came back negative as often as
positive: it records when the collector ran, not what the loop allocated. A
column of noise in a published table is worse than a missing one, because a
reader cannot tell which they are looking at.

The README status block said the tolerances were unmeasured and the bindings
executed nothing. Both had stopped being true.
The API is frozen and STABILITY.md says what that covers. Four surfaces here
carry a compatibility promise and only one of them is a Dart API, so each has
its own rule: the manifest is a document two implementations parse, the C
header is an ABI three bindings implement, the generated Dart is code
somebody else commits, and the tolerances decide whether a build is green.

The tolerances are measured against two models rather than started from, and
what they measured changed the answer. The two-layer model drifts up to eight
times further than the convolutional one and it is the simpler network, so
the bounds are set by the model with the large outputs rather than by the
better-behaved fixture.

The rest of the tier: an artifact can be more than one file and the hash
still covers the numbers, two inputs and two outputs are kept apart by every
engine, the parity matrix runs on a model that can go wrong, and what a run
costs is published with the tool that reproduces it.

One release rather than two. The board carried the work at 0.9.0 and the
release item at 1.0.0, and the tier ships as a single tag.
@tonytonycoder11
tonytonycoder11 requested a review from a team as a code owner August 2, 2026 13:35
boundFor left a caller behind in the Flutter spike, which is the one place
plain Dart tooling cannot parse, so the local analyze that covers packages/
could not see it. CI could. The rename was scoped to packages/ and the repo
is wider than that.

The stale references were not only the identifier. docs/tolerance.md still
carried a section headed "Starting points, not measurements" pointing at
issue 60 as open, and docs/ci-parity-gate.md still told a reader that every
number in the table should be replaced by one they measured themselves.
Both had become false in the commit that measured them, and a document
promising the opposite of what the code does is worse than one that says
nothing.

What replaces them says which two models the bounds came from and why they
disagree, because that difference is the useful part: the simpler model
drifts further, and a reader who does not know that will measure the wrong
one and trust the answer.

The exception is named rather than glossed. int4-weight-only is still a
starting point because nothing here exports int4.
@tonytonycoder11
tonytonycoder11 merged commit 1c23ced into main Aug 2, 2026
4 checks passed
@tonytonycoder11
tonytonycoder11 deleted the tier-9 branch August 2, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Report a native execution failure as one, not as a bad manifest

1 participant