Skip to content

tools/infer: a deterministic inference engine, and the gates that check it - #58

Open
zemo-g wants to merge 12 commits into
masterfrom
feat/kv-cache
Open

zemo-g wants to merge 12 commits into
masterfrom
feat/kv-cache

Conversation

@zemo-g

@zemo-g zemo-g commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Twelve commits building a transformer inference engine where the same
request produces the same bytes, on three different Apple GPUs, checked by
tests that demand exact equality rather than closeness. Written in Rail,
running on Metal, no CUDA, no PyTorch, no Python in the inference path.

Full README with the claims and their limits: tools/infer/README.md.

The claims, each with a gate

  1. Incremental decode is bit-identical to a full forward pass.
    kv138_gate.rail requires max |full - incremental| == 0.0 across all
    98,304 hidden values and the full 16,384-wide logit row.
  2. A chunk of K tokens equals K sequential steps, caches included. The
    keystone. ng0_keystone_gate.rail: 48 tokens both ways, independent
    buffer sets, divergent comparisons: 0 (of 34).
  3. Therefore speculative decoding cannot change the output.
    ng0_spec.rail does n-gram draft-and-verify with no draft model and no
    second set of weights: 2.0x fewer forward passes, byte-equal output.
  4. It holds across three chips and three OS versions, M4 Pro / M1 Ultra
    / base M1, each compiling its own dylib and kernels, same hash.

The gates can fail

An outside review found they could not. Max-difference alone is blind: an
all-NaN run leaves every running max at 0.0 because IEEE says every
comparison with NaN is false, and an all-zero run agrees perfectly while
proving nothing. Every parity gate now counts finite nonzero values first
and fails if the tensor is mostly dead. Verified by deleting the weights
file: the gate that used to report max diff 0.0, PASS now reports
hidden 0/98304 live and fails.

The roofline changed the plan

rl0_gate.rail measures the memory ceiling with a coalesced streaming
kernel rather than quoting a spec sheet. Batch-1 decode reads 0.45 GB of
weights per token and should be memory-bound. It reached 6% of measured
bandwidth
. So the gate measured the other term: 288 empty dispatches cost
24-34ms against a 36ms decode step, because every tgl_* call built its own
command buffer, committed, and waited.

tgl_batch_begin/tgl_batch_end encode the step into one command buffer.
Kernels, arguments and order are untouched, so only the moment the CPU stops
to listen changes.

ms/token tok/s % of measured bandwidth
one command buffer per dispatch 36.1 27.6 5.7%
one command buffer per token 7.5 132.9 27.1%

4.8x, decode checksum bit-identical, all three gates unchanged. That is
what the gates are for.

The gate also failed its own repeatability check twice (16.7%, then 20.6%
spread between identical bandwidth runs) and was fixed by improving the
estimator rather than relaxing the threshold: best-of-N, since contention
only ever slows a run, and interleaved comparison batches, since this
machine runs a GPU beacon at ~9fps and sequential batches turn drift in load
into fake disagreement. Spread is now 0.7-2.2%.

The ids become language

tok0.rail is the byte-level BPE the 240M base run was trained with: 173
base bytes, 15,404 merges, vocab 15,577. Encoding is deliberately the slow
obvious algorithm because that is what produced these ids; a faster encoder
giving different ids would feed the model tokens it never saw.

tok0_gate.sh checks it two independent ways, because each is blind where
the other sees. It compares against bpe_replica.py over seven cases picked
for what breaks byte-level BPE, and skipping one merge is caught by four of
seven. But that check cannot see its own input: truncating the merges file
by 100 lines left all seven reporting ok. So it also verifies the merges
against the run's recorded tokenizer.sha256 and pins the alphabet hash,
which that recording never covered even though the alphabet is half the
tokenizer.

Being precise about the size of this

Replayability, not verifiability. Verification costs what generation
cost: a checker needs the weights, matching hardware, and the same time.
That makes it a fraud-proof primitive, not a succinct proof to a stranger.
zkML and TEE attestation give a verifier an asymptotic advantage; this does
not.

Batch-1 is a choice, not a requirement. The keystone gate is in fact a
batch-invariance proof; this engine has not spent it on throughput yet.

Greedy only, and prior art exists and is good (LLM-42 runs the same
argument in the opposite direction). The honest contribution is the
numerical half: making chunk-equals-sequential true bitwise on commodity
hardware.

Verification

190/190 tests, tokenizer gate 9/9, all three parity gates pass, leak guard
clean. Start with kv0_gate.rail and rsqrt_probe.rail, which need no
weights and no downloads.

zemo-g and others added 12 commits August 29, 2026 13:45
…ntical

Generation re-ran the whole prefix for every token (gen_loop's O(n^2));
stdlib/infer_kv.rail adds the KV-cache step: one new row per block,
K and V for every earlier position from a preallocated cache, O(n).

The contract is bit-parity, not approximation: every loop matches the
accumulation order of the tensor.rail CPU loops, causality plus the
mask's exact-zero underflow does the rest. tools/infer/kv0_gate.rail
enforces it -- two model configs, both paths interleaved, every step's
FULL probability row compared: max |p_full - p_kv| must be exactly 0.0.

The gate earned its keep on the first run: 5.1e-9 divergence, root
cause tensor_transpose GPU-dispatching K through f32 on the reference
side only. Both paths now force the CPU loops (the documented .no_gpu
reference config), and the gate's header records the catch.

No arena_reset anywhere near the loops (2026-04-30 corruption). Suite
189/189; compiler untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
A pure-Rail HTTP server (socket.rail, fleet_agent_v3 pattern) serving
greedy generation through the KV0 incremental path. The scheduler is the
accept loop: batch-1, serial FIFO, by construction. Every /generate
appends a hash-chained ledger record and reports ISL/OSL/TTFT/TPS in the
response; /ledger/verify recomputes every record hash and walks the
chain -- check-me, not trust-me. Model is a plug: --prefix loads any v3
checkpoint; without one a deterministic demo model exercises the full
contract (no v3 checkpoints survive on disk -- flywheel-era weights are
gone).

s0_gate.sh boots it and tests the contract: identical request twice must
return identical bytes; a tampered ledger must fail verification; a
garbage request must not kill the server. All eight checks green.

Two stdlib-level catches on the way:
- recv_http_request returned at the header terminator, so any client
  that writes headers and body separately (curl does) lost its POST
  body; it now reads Content-Length bytes of body, as its own comment
  had promised.
- "\r" in Rail source is a literal backslash-r (trap #8); the CR strip
  in request parsing stripped nothing until synthesized via
  char_from_int 13.

Suite 189/189; fleet_agent_v3 still compiles against the socket change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
POST /generate_stream sends each token the moment it exists --
close-delimited, no chunked encoder needed, curl -N watches it happen.
Streaming is transport, never content: the gate proves
sha256(streamed bytes) == the output_sha256 that /generate reports for
the same request, the stream is byte-deterministic across calls, and
first-byte time is a fraction of total time (it is actually live). The
ledger records streamed generations identically.

Two catches:
- the single-char strings id_char returns hit the runtime-string ->
  foreign char* marshaling trap in send(): right length, header byte
  0x09 on the wire instead of the character. Ledger hashes exposed it
  instantly (collected text hashed correctly while the wire bytes
  didn't). Cat-wrap before send is the workaround, documented inline.
- rail_native run execs the server as a child, so a gate that kills
  only the launcher pid leaves an orphan holding the port and every
  later run talks to stale code. The gate now pkills by binary path,
  before and after.

Suite 189/189.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
A request that sends "cache": "reuse" opts into the single resident
slot; everything else always runs cold. The declaration rides in the
ledger canon -- cache behavior is part of the attested transcript, never
an ambient optimization -- and the response reports hit/cold/off.

Reuse is common-prefix, both directions: cache rows at positions beyond
the current one are ignored by the causal attention loops (j <= pos), so
a cache longer than the prompt (a re-ask) or diverging later (an edit)
still safely reuses everything up to the divergence. The slot records
prompt + gen[:-1] -- the final generated token's K/V row is never
written, so it is never claimed.

Gate: first declared run cold, second a hit, and hit bytes == cold
bytes == undeclared bytes -- the cache may never change the output.
Fourteen checks green; suite 189/189.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
Two additions that turn the serving contract into fleet mechanisms:

- agree_check.sh sends the identical request to two serve_kv endpoints
  and compares output_sha256 -- any disagreement between machines
  running the same model is an alarm for the fault class no health
  check sees (silent corruption, weight drift, diverged binaries, a
  changed libm). Proven live before landing: Mini (M4 Pro, macOS 26.3)
  and Studio (M1 Ultra, 26.4.1) AGREE byte-for-byte through the entire
  serving pipeline.

- /ledger/head answers the chain head Ed25519-signed (pure-Rail
  ed25519, seed = sha256 of a fleet-style key file). The head commits
  to every record beneath it, so the signature covers the entire
  serving history. The server runs ed25519_verify on its own signature
  before answering -- verify:1 is checked, not asserted. Without a key
  it answers signed:false, honestly.

Gate is now fifteen checks; suite 189/189.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
Ports the attested float layer into this repo's dylib and lands the
engine plus its gates, so the determinism claims can be checked by
anyone with a Mac rather than taken on faith. That felt like the
minimum bar for a determinism result.

What is here: incremental decode bit-identical to a full forward pass
on a real 138M model (max diff exactly 0.0 over 98304 hidden values and
the full 16384-logit row); a chunk of K tokens bit-identical to K
sequential steps with all 32 K/V caches included; and n-gram
speculative decoding proven to change the schedule and never the
output, 2.0x fewer forward passes on repetitive input with byte-equal
tokens. Verified on M4 Pro (macOS 26.3), M1 Ultra (26.4.1) and base M1
(26.5.1), each compiling its own dylib and kernels.

The README states the size of the claim honestly: this is
replayability, not verifiability, because a checker spends what the
producer spent; batch-1 is a choice rather than a requirement, and the
keystone gate is really a batch-invariance proof that has not been
spent on throughput; the guarantee is greedy-only; and LLM-42 got to
the same observation from the opposite direction. The narrow
contribution is the numerical half: making chunk-equals-sequential true
bitwise, which is what production engines lack.

rsqrt_probe.rail documents the one real numerics finding: Metal's rsqrt
differs from 1/sqrt on 1161 of 4096 values by about an ulp, though both
agree across all three machines. LayerNorm now uses IEEE-exact
1.0f/sqrt anyway.

Suite 189/189 with the ported dylib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
… tree

An outside review took the repo apart. The worst finding was mine from
yesterday: the parity gates could pass on a dead engine. Max-difference
alone cannot fail on all-NaN (IEEE says every comparison with NaN is
false, so the running max stays 0.0) nor on all-zeros (two dead paths
agree perfectly). A determinism artifact whose gates do not gate is
worse than no artifact.

Fixed by counting finite nonzero values before comparing, and proven by
falsification: with the weights file moved away, the gate that used to
report `max diff 0.0, argmax 0 == 0, PASS` now reports `hidden
0/98304 live` and fails. ng0_spec compared min(n0,n1) tokens, so a
speculative path emitting fewer tokens passed on the prefix; it now
requires equal length.

Also from the review: the serving loop forked a Perl interpreter three
times per request inside a loop whose whole claim is being pure Rail,
and it meant ttft_ms partly measured Perl's startup. tgl_now_ms was in
the dylib the entire time. s0_gate still 15/15 with the native timer.

Honesty repairs, all earned: the speculation table's cycle6 row accepts
nothing, runs the same code path as plain greedy, and is now labelled
the control it always was. The README described five of ten files and
omitted the server, its gate, the agreement checker, and the one gate
that needs no weights at all; kv0_gate is now the documented starting
point. The rsqrt section claimed the portability question was never a
problem without mentioning the apparent divergence that motivated the
probe, which turned out to be a stale file and a hash check that
compared the wrong file. That story is now told, because it is the
argument for binding kernel-source, OS, and Metal-version hashes into a
receipt, which does not exist yet.

Repo-level: docs/STATUS.md had drifted on three of three numbers, so
`tools/verify/check.sh` (the one command VERIFY.md asks a stranger to
run) failed on HEAD. Regenerated. Test counts said 187 in the README
and 182 in VERIFY.md against a real gate of 189. The license was stated
three different ways: LICENSE grants production use except competitive
hosting and converts to MIT 2030-03-14, while README said "free for
non-production use" and CONTRIBUTING said Apache 2.0 on a different
date. Both now match LICENSE, and the BSL choice is finally explained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
The one command VERIFY.md asks a stranger to run has been failing on a
correct tree. gen_status stamps every run with the wall clock, and the
check then did `git diff --exit-code`, so the file differed the instant
it regenerated no matter what the numbers said. A permanent red line on
the repo's own self-audit, which is the worst possible place for one.

Now diffs everything except the "Generated by ... on DATE" line, so it
fails on a drifted FACT and passes on a fresh timestamp. 5/5 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
Every gate in tools/infer proves something about integers. That makes the
determinism claims checkable and the engine's output unreadable: nobody
can see language in a list of ids. This is the bridge, using the real
tokenizer the 240M base run was trained with (173 base bytes plus 15,404
merges, vocab 15,577).

Encoding is the slow obvious algorithm on purpose. Every merge is applied
in learn order as one left-to-right non-overlapping pass, because that is
what stdlib/bpe.rail did when this vocabulary was trained. A faster
encoder producing different ids would be worse than no encoder: the model
would receive tokens it never saw, output would become noise, and every
number downstream would look fine while measuring nothing.

Two independent checks, because each is blind where the other sees.

tok0_gate.sh compares Rail's ids against bpe_replica.py, the Python
implementation the corpus pipeline used, over seven cases picked for what
breaks byte-level BPE: multi-byte UTF-8, digits, deep runs, empty input.
Proven able to fail by skipping a single merge in the Rail encoder, which
four of seven cases catch.

That differential check is blind to the input, though. Both sides read
whatever is in TOKDIR, so truncating the merges file by 100 lines left all
seven cases reporting ok. Hashes catch what differential tests cannot, so
the gate now verifies the merges against the run's recorded
tokenizer.sha256. It also pins the alphabet hash, which that recording
never covered: the alphabet is half the tokenizer, and changing one byte
of it shifts every id while the recorded hash stays valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
…mits

rl0_gate.rail asks where decode actually spends time, and answers it with
two measurements instead of one. The engine's throughput alone says
nothing; it only means something next to what this machine can actually
do, so the gate measures the memory ceiling with a coalesced streaming
kernel rather than quoting a spec sheet. A spec figure describes ideal
conditions no real kernel meets and flatters every engine equally.

The answer was not the expected one. Batch-1 decode reads 0.45 GB of
weights per token and should be memory-bound, but the engine reached only
about 6% of measured bandwidth. So the gate measures the other term: 288
EMPTY dispatches cost 24-34 ms while a real decode step cost 36 ms. Eight
to nine tenths of every token was the submission boundary. Every tgl_*
call built its own command buffer, encoded one dispatch, committed, and
waitUntilCompleted, and decode issues 18 dispatches per block across 16
blocks. The engine was not reading memory. It was waiting.

That verdict overrides the plan it was written to inform: quantization
cuts bytes read per token, and bytes read per token was never the
constraint.

tgl_batch_begin/tgl_batch_end encode into one shared command buffer and
commit once. The kernels, arguments and order are untouched, so only the
moment the CPU stops to listen changes. 27.6 -> 131 tok/s, a 4.75x
speedup, with the decode checksum bit-identical and all three determinism
gates unchanged: kv138 still hashes 092af18f2b576bd5 with max diff 0,
keystone still 0 divergent of 34, speculation still matches greedy token
for token.

Two things the gate caught in itself along the way. It failed its own
repeatability check at 16.7% spread between identical bandwidth runs, and
again at 20.6%. Both were fixed by making the estimator honest rather than
the threshold loose: best-of-N, since contention can only ever slow a run,
and the two batches interleaved rather than sequential, since this machine
runs a GPU beacon at ~9fps and sequential batches turn drift in machine
load into fake disagreement. Spread is now 0.7-2.2%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
The gate was diagnosing and grading the unbatched path, which is the one
nobody should run now that batching exists. A gate that keeps measuring
the old strategy keeps recommending the old fix long after it landed, so
the verdict and the RESULT line now follow the batched numbers: 27% of
measured bandwidth at 133 tok/s. The unbatched figure is still printed,
as the before to the after.

README gains the roofline result and the tokenizer, including what each
one is not: the tokenizer reproduces the 240M run's vocabulary exactly,
but the weights these gates run on are a 138M initialisation, so text in
and ids out both work while language out would need that checkpoint
ported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
The pre-push leak guard caught it, correctly. tok0.rail defaulted --tokdir
to one machine's home directory, which in a public repo is both a leak and
a claim that a private training artifact ships here. It does not. The path
now comes from tok0_gate.sh, where a shell can expand $HOME; Rail's
shell() inherits no environment, so it cannot do this itself. Running
without --tokdir prints what to pass instead of failing on a missing file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
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.

1 participant