Skip to content

rust: the self-contained flat word codec — 594% to 444% of generated C/C++ - #183

Merged
gafferongames merged 2 commits into
mainfrom
rust-word-codec
Aug 31, 2026
Merged

rust: the self-contained flat word codec — 594% to 444% of generated C/C++#183
gafferongames merged 2 commits into
mainfrom
rust-word-codec

Conversation

@gafferongames

@gafferongames gafferongames commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Campaign item 1 of the laggard round (#170): the Rust emitter learns the self-contained flat word-codec form. Wire bytes never move; checks=always is preserved exactly; nothing under bench/LOCK's prefixes is touched.

The move

Blended nine-language quick table, one caffeinated sitting, M2, generated subject:

lang family checks before ns/msg after ns/msg before % after %
cpp gen removed 1.72 1.70 100% 100%
c gen removed 1.85 1.86 107% 109%
java gen contract 6.49 6.50 377% 383%
rust gen always 10.22 7.53 594% 444%
cs gen always 13.59 13.53 790% 797%
dart gen contract 16.37 16.09 952% 949%
go gen always 26.93 26.55 1566% 1565%
js gen contract 75.53 74.69 4391% 4402%
elixir gen contract 288.68 287.10 16782% 16922%

Before column: the equalized sitting (bench/results/2026-08-31-equalized-quick-*). After: bench/results/2026-08-31-rustcodec-quick-*. The frozen reference re-ran unchanged — cpp gen within 2.4% write / 0.5% read, c within 0.2% / 0.7% — and every leg I did not touch landed within 2% of its equalized number, so the sitting is comparable and the movement is the emitter's. Rust is now the second-fastest generated leg.

Full rust leg, per shape (family gen, median msgs/s, M2):

shape write before write after read before read after
rigidbody_moving 50.1 50.8 1.01x 42.6 44.5 1.04x
rigidbody_at_rest 81.3 81.7 1.01x 75.1 78.5 1.05x
chat 67.1 68.8 1.03x 102.2 103.3 1.01x
test 224.7 876.4 3.90x 234.5 278.5 1.19x
inputpacket 47.0 70.7 1.51x 40.4 73.0 1.81x
shipcreate 77.3 84.9 1.10x 42.2 91.4 2.17x
probe_header 214.4 249.3 1.16x 153.5 182.8 1.19x
probebits 122.0 134.7 1.10x 134.0 149.7 1.12x
probearray 53.2 62.6 1.18x 63.0 66.1 1.05x
testdata 25.1 26.5 1.06x 21.7 24.3 1.12x
real_packet 8.6 10.0 1.16x 7.3 9.8 1.35x
bench_packet 65.9 70.8 1.07x 76.3 87.6 1.15x
bench_ints 108.2 132.8 1.23x 115.7 146.3 1.26x
bench_bits 117.0 148.4 1.27x 108.1 159.2 1.47x
bench_mixed 96.5 126.1 1.31x 102.1 136.0 1.33x
geomean 1.25x 1.26x

The hand-written rt/bits control rows — the same source before and after — moved 1.014x across ten rows. That is the negative control on the sitting: the generated rows moved because the generated code changed.

What the form is

Every bit offset inside a message is a generation-time constant no matter where the message starts. The old form did not use that: it handed the runtime one field at a time, which folds to nothing unless the spine lands inlined in a caller whose stream was just constructed. Entered with an unknown bit position — the regime every generated Rust call actually runs in — each serialize_bits reloads the packer's scratch, shifts by a runtime amount, tests a runtime carry and stores the state back. An eleven-field write paid eleven memory round trips through the stream.

internal/codegen/rust/flat.go folds the placement itself: field values into locals, OR'd into 32-bit chunk locals at literal shifts, one whole chunk per stream call. Reads fuse their bounds checks the same way — one per chunk instead of one per field — the technique the Java, Dart and JS-flat backends already carry.

let f0: u64 = u64::from(value.sequence as u32);
let f1: u64 = u64::from(value.ack_bits);
...
let mut w0 = (f0 | (f1 << 16)) as u32;
stream.serialize_bits(&mut w0, 32)?;
let mut w1 = ((f1 >> 16) | (f2 << 16) | (f3 << 28)) as u32;
stream.serialize_bits(&mut w1, 32)?;

Runs break at align, string/bytes, arrays, branches, nested struct and union calls, and the fixed-point / compressed-float / 128-bit families, whose value arithmetic lives in the runtime. Those keep the per-field form and start a fresh run after themselves. Runs also close at 256 bits so a hundred-field message does not hold every field value live at once. Chunk widths sum to the run's bit count exactly — the last chunk carries B mod 32 — so the flat form touches precisely the bits the per-field form did.

The public surface is unchanged: same function names, same (&mut WriteStream, &T) -> Result signatures, same types.

Wire and semantics

  • Every wire golden unmoved (git status testdata/wire clean across the change).
  • make test green: the full cross-language conformance suite, test/rust and test/rust-ludicrous included, plus the hostile-stream cases that pin specific error kinds (nonzero reserved → Validation, truncation → the stream's own error, out-of-range count → Error::Stream).
  • corpus_id unchanged in every bench run: 457b96dfc5a0ffcd quick, bc776c004485e408 full.
  • checks=always preserved: the same values are refused, with the same error, on every path. The checks-mode ruling stays the owner's.

Negative control, run and pasted:

$ printf '\xff' | dd of=testdata/wire/bench_mixed.bin bs=1 seek=3 count=1 conv=notrunc
$ cargo run --release -- --quick
WIRE GOLDEN MISMATCH: bench_mixed (21 golden vs 21 actual bytes) — refusing to bench code that does not match the corpus
BENCH FAILED (corpus_id 762ddd88ac9c941f)
$ # restore
$ cargo run --release -- --quick
bench_mixed        write     120.08 M msg/s ...
OK (corpus_id 457b96dfc5a0ffcd)

Two behaviours move, both named rather than smoothed:

  1. Refusal timing on write. A run's refusals all run before the run packs, so a refused write has emitted fewer bits than before. Which values are refused, and with which error, is identical.
  2. Error kind on a doubly-bad read. Fused bounds checks read a whole chunk at a time, so a stream that is both truncated inside a run and carries an out-of-range value before the truncation now surfaces Overflow where the per-field form surfaced the range refusal. Both refuse the packet; the set of accepted streams is unchanged, which is the property that matters at the trust boundary. This is the same consequence the Java/Dart/JS-flat fusion already carries.

Where the remaining 4.4x lives — and it is not the codec

Honest account: 444% is not C-tier, and I can name the whole residue.

The generated Rust spine is never inlined into the timed loop, and the emitter cannot fix that. #[inline(always)] on the spine is honoured — into the Fn::call shim standing between the bench driver and the spine. LLVM then prices that shim against its caller and refuses it. clang honours always_inline unconditionally, so the C and C++ legs never see this regime. Three measurements pin it:

  • Flipping the read spine to #[inline(always)] in this harness moves bench_mixed by less than half a percent (97.16/102.97 vs 97.11/103.12). The attribute never reaches the loop.
  • Extracting the timed loops into their own small functions, and passing closures instead of fn items, changed nothing. It is the cost model, not the call shape.
  • Rebuilding the same binary with RUSTFLAGS="-C llvm-args=--inline-threshold=5000" — a diagnostic, not a shipped flag — moves the generated rows a further 2.28x geomean and the hand-written rt rows 4.54x. bench_mixed goes to 367 M/s write and ~444 M/s read ; rigidbody_moving read 10.0x; probe_header write 1034 M/s. Against generated C++'s 610/547 on the same sitting, that is 60% and ~81% — read approaches within ~1.23x and write is within 1.7x. (CORRECTED at review: this bullet originally claimed 606 M/s read and "read reaches parity" — the 606 was the hand-written rt row misattributed as the generated row; the reviewer's two independent runs measured gen read 444.5/437.8 M/s at 0.13% spread, and this PR's own later "450 M/s flat" sentence agrees. Read does NOT reach parity under the diagnostic.)

So the ceiling this form approaches, once the inlining discipline matches the reference legs, is near-C-tier on reads (~1.2x). The residue is LLVM's inliner cost model at the harness boundary, not language physics — and, per the reviewer's own measurement, not linkage in the LTO sense: plain idiomatic lto = "fat" + codegen-units = 1 (never measured by this PR; the repo has no [profile.release] at all) buys ~7% on read and nothing on write, nowhere near the threshold diagnostic's ~3x. That strengthens the cost-model diagnosis and retires the "linkage asymmetry" framing this paragraph first used. I did not equalize it here: it changes the measured leg's build or driver mid-round for one language only, and that is the owner's call, not a worker's. bench/rust/README.md claimed the driver "inlines like the C++ template reference"; that claim is now corrected to what is measured, with the diagnostic named.

The flat form is what makes the out-of-line regime cheap, which is why it lands regardless of how the inlining item is ruled — and it wins in the inlined regime too on reads (bench_mixed read 450 M/s flat vs 573 M/s per-field is the one row where the per-field form is ahead once inlined; write is 346 vs 300 the other way).

Aside, not shipped, as invited by the brief: stripping only the write-side range refusals — what a contract checks mode would do — buys +13.2% on bench_mixed write (126.1 → 142.7 M/s), reads unaffected. That is roughly 6% blended: 444% → ~420%. It does not change the picture, and the mode stays always.

Coverage gaps, named as follow-ons

  • Compressed floats and fixed-point break every run — which is why rigidbody_* moved only 1.01–1.05x and examples128's generated Rust is byte-identical before and after. Both are pure functions of the value plus generation-time constants; folding their arithmetic into the run is the obvious next pass, gated on a differential against the runtime's own quantization.
  • Arrays break runs. A fixed array of scalars is a static run of N * w bits; unrolling small ones into the enclosing run is a bounded, measurable next step (probearray 1.18x/1.05x is the row that would move).
  • Nested struct and union calls break runs, so a message built from small structs flattens per struct rather than whole.

Land and expand: each is a separate measured pass, not a reason to hold this one.

🤖 Generated with Claude Code

gafferongames and others added 2 commits August 31, 2026 15:07
The Rust emitter's serialize functions asked the runtime to place one field
at a time. That folds to nothing when the spine lands inlined in a caller
whose stream was just built, and costs a memory round trip through the
packer per field when it does not — which is the regime every generated Rust
call runs in today.

The flat form removes the dependence. Every bit offset INSIDE a message is a
generation-time constant regardless of where the message starts, so the
emitter folds the placement itself: field values into locals, OR'd into
32-bit chunks at literal shifts, one whole chunk per stream call. Reads fuse
their bounds checks the same way — one per chunk instead of one per field —
the technique the Java, Dart and JS-flat backends already carry.

Runs break at align, strings, arrays, branches, nested calls and the
fixed-point / compressed-float / 128-bit families, which keep the per-field
form. Chunk widths sum to the run's bit count exactly, so the wire is
byte-identical: every wire golden unmoved, the cross-language conformance
suite green, corpus_id unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full rust leg and the nine-language quick sweep, one caffeinated sitting
on the M2, C/C++ re-run unchanged as the frozen reference (cpp gen within
2.4%, c within 0.7% of the equalized baseline — no alarm).

bench/rust/README.md claimed "the generic driver monomorphizes and inlines
like the C++ template reference". Measurement refutes the second half:
#[inline(always)] on a generated spine is honoured only into the Fn::call
shim between driver and spine, and LLVM prices that shim against its caller
and refuses it. The README now says what is true and names the diagnostic
that measures the cost. inline.go gains the same present-state note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gafferongames

Copy link
Copy Markdown
Contributor Author

Adversarial review verdict: MERGE, with one required correction now applied to the body. The reviewer reproduced everything on a fresh clone: zero test edits behind the behaviour movements (the hostile-stream error-kind pins unchanged and green; SPEC §5 mandates failure, not kind; the Java/Dart fusion precedent verified against those emitters — Rust's fusion is finer than Java's), zero-diff regen by its own rebuild, lock clean off the raw file list, all 30 per-shape cells and the blended table recomputed exactly, negative control 1.0142x over ten rows, and a code audit clearing precedence/shift-width/chunk-reuse/run-symmetry hazards with write_probe_bits hand-verified bit-for-bit.

THE CORRECTION: the diagnostic-build bullet claimed generated read 606 M/s / 111% of generated C++ / read parity. The 606 was the hand-written rt row misattributed; two independent reviewer runs measured gen read ~444 M/s at 0.13% spread (the PR's own later '450 M/s flat' sentence agrees). True figures: 60% write, ~81% read. The cost-model diagnosis survives and is STRENGTHENED — the reviewer also measured plain lto=fat + codegen-units=1 (never measured by the PR; no [profile.release] exists) at ~7% read / nothing write, so the barrier is the inliner cost model, not linkage.

Follow-ons filed on #170: name the blended table's statistic (max) beside the per-shape table's median per the doctrine; extend the fuzz compile leg to rustc (the flat form's compilability now rests on the fixed corpus — a wider class of layout-dependent spellings than the old uniform calls).

@gafferongames
gafferongames merged commit 8228084 into main Aug 31, 2026
7 checks passed
gafferongames added a commit that referenced this pull request Aug 31, 2026
Maximal runs of statically-sized pieces are gathered, field values computed
into locals and OR'd into word-sized chunk locals at literal shifts, and one
whole chunk handed to the stream per call. Reads fuse their bounds checks per
chunk and take ONE sticky-error test per run instead of one per field.

WriteMixedEntity goes from 14 stream calls to 3.

Two deliberate deviations from the Rust template (#183), both Go physics:

  1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one
     call with one bounds check (its two tryWriteBits are inlined into it),
     where two SerializeBits are two calls. Wire-identical: SerializeBits64
     splits low-dword-then-remainder, which IS the 32-bit chunk order.
     Chunks of 32 bits or fewer still go through SerializeBits, which inlines.

  2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a
     too-wide value; the Rust runtime only debug_asserts, so #183 had to
     REFUSE. Masking is what keeps the Go form observably identical.

And one coverage win the Rust template could not have: COMPRESSED FLOATS DO
NOT BREAK A RUN. This emitter already folds quantization into generated
arithmetic ending in a plain bit write, so quantized fields are ordinary
pieces — where Rust's live in the runtime, which is why its compressed-float
shapes barely moved.

Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99).
Only Go outputs regenerated; every other language byte-identical.
Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c
(including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous,
and the fuzz leg.

Measured, M2, bench_mixed, same sitting, on top of lever 1:
  write      1.39 -> 2.08 M msg/s  (1.50x)
  round_trip 0.62 -> 1.01 M msg/s  (1.63x)
Negative control: the hand-written rt rows, same source throughout, held at
1.22/1.20 against a 1.23/1.22 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
Maximal runs of statically-sized pieces are gathered, field values computed
into locals and OR'd into word-sized chunk locals at literal shifts, and one
whole chunk handed to the stream per call. Reads fuse their bounds checks per
chunk and take ONE sticky-error test per run instead of one per field.

WriteMixedEntity goes from 14 stream calls to 3.

Two deliberate deviations from the Rust template (#183), both Go physics:

  1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one
     call with one bounds check (its two tryWriteBits are inlined into it),
     where two SerializeBits are two calls. Wire-identical: SerializeBits64
     splits low-dword-then-remainder, which IS the 32-bit chunk order.
     Chunks of 32 bits or fewer still go through SerializeBits, which inlines.

  2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a
     too-wide value; the Rust runtime only debug_asserts, so #183 had to
     REFUSE. Masking is what keeps the Go form observably identical.

And one coverage win the Rust template could not have: COMPRESSED FLOATS DO
NOT BREAK A RUN. This emitter already folds quantization into generated
arithmetic ending in a plain bit write, so quantized fields are ordinary
pieces — where Rust's live in the runtime, which is why its compressed-float
shapes barely moved.

Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99).
Only Go outputs regenerated; every other language byte-identical.
Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c
(including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous,
and the fuzz leg.

Measured, M2, bench_mixed, same sitting, on top of lever 1:
  write      1.39 -> 2.08 M msg/s  (1.50x)
  round_trip 0.62 -> 1.01 M msg/s  (1.63x)
Negative control: the hand-written rt rows, same source throughout, held at
1.22/1.20 against a 1.23/1.22 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
PR #183's Rust template maps one item to exactly one piece, so its fallback
(re-emit each of a run's pieces) IS re-emitting each of its items. This
emitter's levers made classification 1:N — an unrolled fixed array
contributes one piece per element, a flattened nested struct contributes
pieces naming ITS fields at a different base expression — and the fallback
was inherited verbatim. Three defects followed from that one gap:

  - `type Pair { values [2]float64 }` emitted the element loop TWICE on both
    paths: 32 bytes where every other language writes 16. Go-to-Go round
    trips passed, so nothing went red.
  - a nested struct split by the run cap emitted `value.C` on the OUTER
    type — a compile error, or, under a name collision, the silent
    serialization of the wrong field.
  - a bare `type Vec2 { x float64; y float64 }` carried an unused "math"
    import: needsMath was set during SPECULATIVE classification and never
    cleared when the run fell back.

A run now accumulates whole ITEMS as flatGroups and splits only on item
boundaries, so the fallback re-emits items against the base each item owns.
flatPiece no longer carries an ir.Item at all — a piece is a bit-placement
recipe and nothing else, which makes re-emitting one structurally
impossible rather than merely avoided. needsMath moves into the float
pieces' emit/read closures, which run at emission.

Wire is unmoved and generated/ is zero-diff on the corpus: none of the three
shapes exists in it. That blind spot is issue #203, addressed next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
The PR carried a before/after table and a 0.9977x negative control with NO
committed CSV, and #199 retired the shapes those numbers were taken over,
so they could not be re-run on main. These two files are that evidence,
re-taken on the shape that survives (bench_mixed, family gen) and committed
before AND after, which is #183's precedent.

One sitting, back to back, nothing changed between the halves but
generated/bench/go — main's Go emitter output for the before half, this
branch's for the after. Every other leg is the in-sitting control.

  go        write 1.33 -> 2.71 M msg/s (2.04x), round_trip 0.59 -> 1.44 (2.45x)
  headline  602% -> 251% of generated C++ on run.sh's own statistic (2.40x)
  controls  geomean 1.0050x over 14 rows (cpp, c, rust, cs, js, java, dart;
            min 0.9854, max 1.0376) — unchanged code, and it did not move

The go rows carry 0.24% and 0.31% spread here against 10.44% in the
branch's earlier committed quick CSV, which is why the max and median
statistics now agree (251% and 249%) where that CSV had them 20 points
apart (255% and 275%).

Named rather than smoothed: elixir's after-half rows are REFUSED under
§2.3 (spread 66% and 213%) — a background spike hit the leg that ran last.
It is a control row, not the subject, and it is reported here instead of
being dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
Maximal runs of statically-sized pieces are gathered, field values computed
into locals and OR'd into word-sized chunk locals at literal shifts, and one
whole chunk handed to the stream per call. Reads fuse their bounds checks per
chunk and take ONE sticky-error test per run instead of one per field.

WriteMixedEntity goes from 14 stream calls to 3.

Two deliberate deviations from the Rust template (#183), both Go physics:

  1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one
     call with one bounds check (its two tryWriteBits are inlined into it),
     where two SerializeBits are two calls. Wire-identical: SerializeBits64
     splits low-dword-then-remainder, which IS the 32-bit chunk order.
     Chunks of 32 bits or fewer still go through SerializeBits, which inlines.

  2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a
     too-wide value; the Rust runtime only debug_asserts, so #183 had to
     REFUSE. Masking is what keeps the Go form observably identical.

And one coverage win the Rust template could not have: COMPRESSED FLOATS DO
NOT BREAK A RUN. This emitter already folds quantization into generated
arithmetic ending in a plain bit write, so quantized fields are ordinary
pieces — where Rust's live in the runtime, which is why its compressed-float
shapes barely moved.

Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99).
Only Go outputs regenerated; every other language byte-identical.
Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c
(including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous,
and the fuzz leg.

Measured, M2, bench_mixed, same sitting, on top of lever 1:
  write      1.39 -> 2.08 M msg/s  (1.50x)
  round_trip 0.62 -> 1.01 M msg/s  (1.63x)
Negative control: the hand-written rt rows, same source throughout, held at
1.22/1.20 against a 1.23/1.22 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
PR #183's Rust template maps one item to exactly one piece, so its fallback
(re-emit each of a run's pieces) IS re-emitting each of its items. This
emitter's levers made classification 1:N — an unrolled fixed array
contributes one piece per element, a flattened nested struct contributes
pieces naming ITS fields at a different base expression — and the fallback
was inherited verbatim. Three defects followed from that one gap:

  - `type Pair { values [2]float64 }` emitted the element loop TWICE on both
    paths: 32 bytes where every other language writes 16. Go-to-Go round
    trips passed, so nothing went red.
  - a nested struct split by the run cap emitted `value.C` on the OUTER
    type — a compile error, or, under a name collision, the silent
    serialization of the wrong field.
  - a bare `type Vec2 { x float64; y float64 }` carried an unused "math"
    import: needsMath was set during SPECULATIVE classification and never
    cleared when the run fell back.

A run now accumulates whole ITEMS as flatGroups and splits only on item
boundaries, so the fallback re-emits items against the base each item owns.
flatPiece no longer carries an ir.Item at all — a piece is a bit-placement
recipe and nothing else, which makes re-emitting one structurally
impossible rather than merely avoided. needsMath moves into the float
pieces' emit/read closures, which run at emission.

Wire is unmoved and generated/ is zero-diff on the corpus: none of the three
shapes exists in it. That blind spot is issue #203, addressed next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
The PR carried a before/after table and a 0.9977x negative control with NO
committed CSV, and #199 retired the shapes those numbers were taken over,
so they could not be re-run on main. These two files are that evidence,
re-taken on the shape that survives (bench_mixed, family gen) and committed
before AND after, which is #183's precedent.

One sitting, back to back, nothing changed between the halves but
generated/bench/go — main's Go emitter output for the before half, this
branch's for the after. Every other leg is the in-sitting control.

  go        write 1.33 -> 2.71 M msg/s (2.04x), round_trip 0.59 -> 1.44 (2.45x)
  headline  602% -> 251% of generated C++ on run.sh's own statistic (2.40x)
  controls  geomean 1.0050x over 14 rows (cpp, c, rust, cs, js, java, dart;
            min 0.9854, max 1.0376) — unchanged code, and it did not move

The go rows carry 0.24% and 0.31% spread here against 10.44% in the
branch's earlier committed quick CSV, which is why the max and median
statistics now agree (251% and 249%) where that CSV had them 20 points
apart (255% and 275%).

Named rather than smoothed: elixir's after-half rows are REFUSED under
§2.3 (spread 66% and 213%) — a background spike hit the leg that ran last.
It is a control row, not the subject, and it is reported here instead of
being dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
The PR carried a before/after table and a 0.9977x negative control with NO
committed CSV, and #199 retired the shapes those numbers were taken over,
so they could not be re-run on main. These two files are that evidence,
re-taken on the shape that survives — bench_mixed, family gen — and
committed before AND after, which is #183's precedent.

One sitting, back to back, on the tree as it stands after #204 rewrote the
runners. Nothing changed between the halves but generated/bench/go: main's
Go emitter output for the before half, this branch's for the after.

  go        write 1.35 -> 2.67 M msg/s (1.98x), round_trip 0.60 -> 1.41 (2.36x)
  headline  583% -> 248% of generated C++ on run.sh's own statistic (2.35x)
  controls  geomean 0.9940x over 16 rows — every other leg, unchanged code,
            compiled identically in both halves (min 0.9426, max 1.0339)

corpus_id is 6b213fbfa1a03a99 in every row of both files: the runner hashes
the goldens it loads, and Degenerate.schema's golden is not one of them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
* go lever 1: fold the ranged-integer READ path to generation-time bit counts

The write side already folded its bounds; reads still called the runtime's
SerializeInt/SerializeInt64, which derive the bit count from min/max on EVERY
call (a BitsRequired per field) and carry the bounds as arguments. Both are
compile-time constants of the call site.

Folded, reads drop onto SerializeBits — whose wrapper the Go compiler inlines,
where the ranged entry points are 168/328 cost units against an 80-unit budget
and never inline. The headroom refusal moves into generated code with the same
error, ErrValueOutOfRange, and is elided where the range fills its bit width.

Wire bytes unmoved; corpus_id 6b213fbfa1a03a99 unchanged.
Measured, M2, bench_mixed, same sitting: write 1.37 -> 1.39 M msg/s (+1.5%),
round_trip 0.61 -> 0.62 (+2.6%).

The small size is the finding: the cost is the CALL COUNT, not the call's
arguments. That is what lever 2, the flat word codec, attacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go lever 2: the self-contained flat word codec

Maximal runs of statically-sized pieces are gathered, field values computed
into locals and OR'd into word-sized chunk locals at literal shifts, and one
whole chunk handed to the stream per call. Reads fuse their bounds checks per
chunk and take ONE sticky-error test per run instead of one per field.

WriteMixedEntity goes from 14 stream calls to 3.

Two deliberate deviations from the Rust template (#183), both Go physics:

  1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one
     call with one bounds check (its two tryWriteBits are inlined into it),
     where two SerializeBits are two calls. Wire-identical: SerializeBits64
     splits low-dword-then-remainder, which IS the 32-bit chunk order.
     Chunks of 32 bits or fewer still go through SerializeBits, which inlines.

  2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a
     too-wide value; the Rust runtime only debug_asserts, so #183 had to
     REFUSE. Masking is what keeps the Go form observably identical.

And one coverage win the Rust template could not have: COMPRESSED FLOATS DO
NOT BREAK A RUN. This emitter already folds quantization into generated
arithmetic ending in a plain bit write, so quantized fields are ordinary
pieces — where Rust's live in the runtime, which is why its compressed-float
shapes barely moved.

Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99).
Only Go outputs regenerated; every other language byte-identical.
Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c
(including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous,
and the fuzz leg.

Measured, M2, bench_mixed, same sitting, on top of lever 1:
  write      1.39 -> 2.08 M msg/s  (1.50x)
  round_trip 0.62 -> 1.01 M msg/s  (1.63x)
Negative control: the hand-written rt rows, same source throughout, held at
1.22/1.20 against a 1.23/1.22 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go lever 3: place nested struct fields inline instead of calling per element

With the per-field calls gone, the profile's next item was the CALL boundary
itself: bench_mixed carries up to 80 MixedStat and 8 MixedEntity elements, and
each paid a function call plus its own sticky-error load. WriteMixedStat was
12.4% of samples for 18 bits of work, and at cost 134 against Go's fixed
80-unit budget it will never inline itself.

An array whose element is a struct that flattens whole now has its element
body placed directly in the loop. The element's own Write/Read functions are
still emitted — the public surface is unchanged — they are simply no longer
the path the generated caller takes.

Measured, M2, bench_mixed, same sitting, on top of lever 2:
  write      2.08 -> 2.27 M msg/s  (+9%)
  round_trip 1.01 -> 1.11 M msg/s  (+10%)
Control rows held at 1.17-1.20 write / 1.18-1.21 read against the 1.22/1.20
baseline. Wire unmoved, conformance green.

Cumulative over the three levers: write 1.37 -> 2.27 (1.66x),
round_trip 0.61 -> 1.11 (1.82x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go levers 4 and 5: nested struct FIELDS and small fixed arrays join the run

Two coverage extensions on the same mechanism, both cheap once a run can be
built against an arbitrary base expression:

  4. A nested struct field that flattens whole contributes its fields to the
     PARENT's run, so they pack into the parent's chunks rather than paying a
     call and starting fresh chunks of their own.

  5. A small fixed array of scalars is a static run of bound*width bits like
     any other, so it unrolls into the enclosing run instead of looping one
     stream call per element. Capped at 128 bits so generated code stays
     proportional to the schema, and byte-aligned [N]uint8 keeps the bulk-copy
     path, which is faster than either form.

Measured, M2, bench_mixed, same sitting, on top of lever 3:
  write      2.27 -> 2.32 M msg/s  (+2%)
  round_trip 1.11 -> 1.16 M msg/s  (+4.5%)
Control rows 1.20/1.21 against the 1.22/1.20 baseline. Wire unmoved,
conformance green.

Cumulative over five levers: write 1.37 -> 2.32 (1.69x),
round_trip 0.61 -> 1.16 (1.90x), blended 1.83x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go: flatten only where it REDUCES the stream call count

The full per-shape leg caught two rows going the wrong way: rigidbody_moving
and rigidbody_at_rest both lost 9% on write. Mechanism, found in the emitted
code — RigidBody is all float64, so every piece is exactly one whole chunk.
The run packed into as many chunks as it had fields, removing no call, while
ADDING a materialized local per field and an address-taken chunk local, where
the per-field form had simply passed the struct field's own address.

The policy is now the honest one: flatten when ceil(bits/chunk) is fewer calls
than the run has bit-carrying pieces, and otherwise leave the per-field form
alone. That makes the transform non-regressive on call count by construction
rather than by luck.

bench_mixed is unaffected (write 2.35, round_trip 1.16 — its runs all reduce
calls); the rigidbody shapes return to the per-field form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go lever 6: pack consecutive array elements into shared chunks

The profile after the first five levers put the generated code's own packing
arithmetic at 15% of samples and the runtime's bit placement at 30% — the
per-field calls were gone, but bench_mixed still spent most of its calls on
eighty 18-bit stat elements, each carrying 18 of a possible 64 bits.

An array of a struct that flattens whole is now emitted as a K-at-a-time loop
over a run spanning K elements, followed by a remainder loop. K is chosen as
the element count that minimises chunks-per-element within the run cap and an
eight-element unroll cap; for the 18-bit stat element that is 7, and eighty
elements fall from 80 stream calls to 25.

Measured, M2, bench_mixed, same sitting, on top of lever 5:
  write      2.34 -> 2.66 M msg/s  (+14%)
  round_trip 1.15 -> 1.40 M msg/s  (+21%)

Cumulative over six levers, against current main in one sitting:
  write      1.38 -> 2.66 M msg/s  (1.93x)
  round_trip 0.61 -> 1.40 M msg/s  (2.30x)
  blended    1182 -> 545 ns/msg    (2.17x)

Named behaviour change, the same class the run form already carries: a group's
refusals all run before the group packs, so a refused write has emitted fewer
bits than before. Which values are refused, and with which error, is
unchanged. Wire bytes unmoved, conformance green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go lever 7: raise the run cap to its measured peak, and scope the grouped loop

Two things, found together when the cap was moved.

THE BUG: the grouped array form declares its index outside the loop, so two
grouped arrays in one function collided on i. It only surfaced once the cap
let a second array group, but it was latent from lever 6. The grouped loop now
carries its own block, as the runs already do.

THE CAP: measured on the corpus shape rather than inherited from the Rust
template's 256. bench_mixed write / round_trip, M msg/s, one sitting:
  256  -> 2.66 / 1.40
  384  -> 2.78 / 1.49   <- peak
  512  -> 2.75 / 1.46
  1024 -> 2.62 / 1.43
The curve peaks and falls: past the peak a run holds more field values live
than the register file has room for and they spill, which is the cost the cap
exists to bound. 384 also lets the 135-bit entity element group two at a time.

Cumulative over seven levers, against current main in one sitting:
  write      1.38 -> 2.78 M msg/s  (2.01x)
  round_trip 0.61 -> 1.49 M msg/s  (2.44x)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench: the gocodec quick sweep — go at 255% of generated C/C++

Six legs measured in one sitting (java, dart and elixir report ABSENT: this
fresh clone has no pinned dist/ toolchains and the box has no JRE). The
locked c/cpp reference reproduces at 100/104%, which is the control on the
sitting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go: range over int in the group loop (modernize lint)

Emitter output unchanged — regenerated with no diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go: re-derive the invariant the fallback rests on — groups, not pieces

PR #183's Rust template maps one item to exactly one piece, so its fallback
(re-emit each of a run's pieces) IS re-emitting each of its items. This
emitter's levers made classification 1:N — an unrolled fixed array
contributes one piece per element, a flattened nested struct contributes
pieces naming ITS fields at a different base expression — and the fallback
was inherited verbatim. Three defects followed from that one gap:

  - `type Pair { values [2]float64 }` emitted the element loop TWICE on both
    paths: 32 bytes where every other language writes 16. Go-to-Go round
    trips passed, so nothing went red.
  - a nested struct split by the run cap emitted `value.C` on the OUTER
    type — a compile error, or, under a name collision, the silent
    serialization of the wrong field.
  - a bare `type Vec2 { x float64; y float64 }` carried an unused "math"
    import: needsMath was set during SPECULATIVE classification and never
    cleared when the run fell back.

A run now accumulates whole ITEMS as flatGroups and splits only on item
boundaries, so the fallback re-emits items against the base each item owns.
flatPiece no longer carries an ir.Item at all — a piece is a bit-placement
recipe and nothing else, which makes re-emitting one structurally
impossible rather than merely avoided. needsMath moves into the float
pieces' emit/read closures, which run at emission.

Wire is unmoved and generated/ is zero-diff on the corpus: none of the three
shapes exists in it. That blind spot is issue #203, addressed next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* corpus: the degenerate arrangements, C++-pinned in all nine languages (#203)

Three emitter defects — one of them silent wire corruption — reproduced on
one-line schemas, and the entire conformance corpus caught none of them.
It grew from real game shapes, and a real shape always carries enough
neighbouring fields to mask the arrangements a run/chunk emitter keys on.
The corpus's realism is precisely what made it blind.

examples/Degenerate.schema carries twelve shapes that are deliberately
unrealistic: a fixed scalar array as essentially the whole message
([2]float64, [2]uint64, [2]int64, [1]T, [N]T landing on a chunk boundary,
an array plus a trailing field, two arrays back to back); a bare two-float
unit with no other math consumer in the file; and a nested struct as the
ONLY field, as the FIRST field, and straddling the Go emitter's run cap.

They are pinned as one wire golden, testdata/wire/degenerate.bin, and every
one of the nine legs is held to it: the stream legs (C++, C, Go, Rust, C#,
JS) write the twelve into one stream, and the whole-message legs (Dart,
Java, Elixir) concatenate their twelve buffers. That equality is why every
type in the file is a whole number of bytes wide — the file's header says
so, because it is load bearing.

internal/codegen/golang states the same properties directly against
maxRunBits, so re-tuning that constant cannot quietly retire the coverage.
Verified red first: on the pre-fix emitter all four assertions fail, and
generated/go/Degenerate.go does not compile ("math" imported and not used;
value.C undefined on TrioStraddle).

The new file caught a SECOND emitter defect on sight, the same class as the
Go one: the Dart backend declared all four conversion-scratch views whenever
any was needed, so a file converting only float64 carried an unreferenced
_u32 and `dart analyze` refused it. The views are now emitted per
conversion.

Existing wire goldens are unmoved; the corpus protocol id moves, as adding
a file must, and is re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench/go: state the --cpuprofile flag in the usage line and refuse instead of panicking

The Go leg is not untouched: the flat word codec's conviction — ~86% of
generated-codec time inside the runtime's per-field bit calls — came from
this flag. It stays, because the next round will want it and a profiler
kept in a scratch fork of the leg is a profiler that rots. It is an
iteration instrument: it changes nothing about what is measured, and no
timed row is taken under it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test/c: dirty the degenerate read targets before reading them

gcc's -Wmaybe-uninitialized (CI, -Werror) cannot see that a read function
stores every field, and clang does not raise it, so the new block built
clean here and refused there. The rest of the leg already dirties its read
targets — a read that skips a field must be caught — so this block does the
same, with the same 0xEF fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench: the paired same-sitting go A/B, with its controls, committed

The PR carried a before/after table and a 0.9977x negative control with NO
committed CSV, and #199 retired the shapes those numbers were taken over,
so they could not be re-run on main. These two files are that evidence,
re-taken on the shape that survives — bench_mixed, family gen — and
committed before AND after, which is #183's precedent.

One sitting, back to back, on the tree as it stands after #204 rewrote the
runners. Nothing changed between the halves but generated/bench/go: main's
Go emitter output for the before half, this branch's for the after.

  go        write 1.35 -> 2.67 M msg/s (1.98x), round_trip 0.60 -> 1.41 (2.36x)
  headline  583% -> 248% of generated C++ on run.sh's own statistic (2.35x)
  controls  geomean 0.9940x over 16 rows — every other leg, unchanged code,
            compiled identically in both halves (min 0.9426, max 1.0339)

corpus_id is 6b213fbfa1a03a99 in every row of both files: the runner hashes
the goldens it loads, and Degenerate.schema's golden is not one of them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* go: bind the run once in flush, and say why an oversized group takes the per-field form

Comment and shape only — generated output is byte-identical. flush() built
the run twice, once to ask worthFlattening and once to emit it; the
oversized-group branch had no note saying no classifier produces one today.
The orphaned flatFieldPiece doc above flatArrayElemPiece now sits on the
function it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Aug 31, 2026
The third port of the template Rust (#183) and Go (#198) already carry:
field values into locals, OR'd into 64-bit chunk words at literal shifts,
one packer step per chunk instead of one per field.

WHY IT PAYS HERE IS NOT WHY IT PAID IN GO. #198's finding was that Go's
per-field entry points sit past the compiler's inlining budget, so every
field cost a real call and ~86% of the codec's time was inside them. C# has
no such wall: the JitDisasm audit found ZERO direct calls into serialize.cs
from any generated method — every entry point inlines. What C# pays instead
is what those inlined bodies DO. BitWriter's packer step masks, shifts, ORs
into the scratch word, tests `newScratchBits >= 64`, conditionally stores a
qword and recovers the spill: a data-dependent branch and a conditional
store PER FIELD. MixedEntity paid fourteen of them for 135 bits. It now
pays three.

THREE INVARIANTS, RE-DERIVED RATHER THAN INHERITED — #198's review record
is why they are spelled out in flat.go's header rather than assumed:

  1. 64-bit chunks are wire-identical because serialize.cs's SerializeBits64
     splits low-dword-first-then-remainder, which IS the 32-bit chunk order.
  2. Every piece is masked to its width, because BitWriter.WriteBitsUnchecked
     TRUNCATES a too-wide value rather than refusing it — the flat form must
     truncate identically or a wide value corrupts its neighbours in the
     chunk instead of losing its own high bits. This is the invariant the Go
     port lost by inheriting Rust's fallback; here it is enforced at the one
     place a piece's value expression is built.
  3. Write refusals come first, in declaration order — same values refused,
     same non-latching false. What moves is how many bits reached the stream
     before the refusal, and a refused write leaves a partial message either
     way.

A run breaks at align, strings/bytes, arrays, branches, nested struct and
union calls, and the float, fixed-point, compressed-float and 128-bit
families. A run that would not REDUCE the packer-step count is not
flattened. A run that does not pay falls back ITEM BY ITEM — the item rides
inside every piece for exactly that reason, because the item is the only
unit the per-field path can re-emit.

THE READ HALF IS NOT IN THIS COMMIT, and the blocker is named rather than
worked around: folding a ranged read replaces SerializeInt with a generated
comparison, and serialize.cs's ranged read LATCHES ValueOutOfRange where a
generated guard returns false without latching. cs publishes checks=always
and test/cs pins that latch, so the read fold waits on a public
refusal-latch on ReadStream/ReadBatch — a serialize.cs change, its own PR.
The write path has no such dependence: its folded range refusals already
return false without latching. Measured ceiling for the read half, from a
hand-written flat prototype of the two hot cores: round_trip 1.08 -> 1.55
M msg/s, a 42% cut in read time.

MEASURED, against the previous commit, same box, median of 3 sittings:

  bench_mixed write       2.82 -> 3.30 M msg/s  (+17%)
  bench_mixed round_trip  1.08 -> 1.18 M msg/s  (+9%)

and against main:

  bench_mixed write       2.10 -> 3.30 M msg/s  (+57%)
  bench_mixed round_trip  0.97 -> 1.18 M msg/s  (+22%)

flatMaxRunBits is Go's 384 carried unmeasured: swept at 128/256/512 the C#
numbers do not move, because this corpus's longest run is 135 bits and no
cap in that range binds it. Said out loud in the file rather than presented
as a C# measurement.

Wire bytes unmoved: full `make test` green across nine backends, the
degenerate corpus included, and the bench's golden + per-variant round-trip
gates pass at corpus_id 6b213fbfa1a03a99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 1, 2026
…word codec (write 1.59x, round_trip 1.60x; 361% -> 219% of generated C++) (#208)

* C# emitter: union batch cores, and a scoped batch per composition site

The C# backend was the only one never audited for generation quality, and
the profile of the bench shape found the cost in one place: composition.

MEASURED FIRST (DOTNET_JitDisasm + DOTNET_JitDisasmSummary, FullOpts,
tiering off, on generated/bench/cs/Bench.cs). Everything the runtime offers
already inlines — zero direct calls into serialize.cs from any generated
method. What did not inline was the generated code's own composition: the
entity array (8 elements) and the stat array (80 elements) each reached
their element type through its STREAM entry, and each of those entries
opened a batch and closed it again. 88 BeginBatch captures and 88 End
restores per 438-byte message, plus 88 real calls the JIT will not inline
through, on a message where those two arrays carry 72% of the wire bits.

They reached the stream entry because BenchMixed reaches a union, and the
batch plan excluded any type that reached one — a union's arm dispatch had
no core to compose with. That exclusion was landed as land-simple with the
note "a follow-on if a profile ever convicts the plain path". It has.

TWO CHANGES, one law each.

1. Unions carry batch cores. A union's arm dispatch is a switch over calls,
   and inside a core those calls go core-to-core by ref like any other
   composition — nothing about the inline-only rule ever stood in the way,
   only the missing emitter half. densityUnion weighs the tag plus the
   heaviest arm; the core closure walks arms as well as struct fields.

2. A composition site inside a stream body opens a SCOPED batch — one
   capture and one restore for the whole site, an array's entire loop
   included. Only the composition call rides inside the scope (an array's
   count site stays on the stream ahead of it), so the scope has exactly one
   way to fail and End always runs before the refusal leaves the function.

AND ONE MEASURED REFUSAL, recorded because it is a result. The obvious
third change — let the whole message body run on one batch now that the
union no longer blocks it — is SLOWER, and materially: bench_mixed write
2.10 -> 2.04 M msg/s and round_trip 0.97 -> 0.76 M msg/s against the same
baseline the two changes above improve. The JIT did inline the 12.7 KB
core (IL size 23 at the entry), so this is not the address-exposure law
firing; it is the delegated sites. A body with any bulk, 128-bit or
fixed-point site must Sync the batch down and Recapture it around that
site, and between them it hands the register allocator a ref struct that
has to survive the whole body. batchEntry states it: density is necessary,
b == 0 is the rest.

MEASURED, this branch vs main, same box, bench --quick, median of 3
sittings (iteration instrument, not certification):

  bench_mixed write       2.10 -> 2.82 M msg/s  (+34%)
  bench_mixed round_trip  0.97 -> 1.08 M msg/s  (+11%)

Wire bytes unmoved: full `make test` green across nine backends, the
degenerate corpus included, and the bench's own golden + per-variant
round-trip gates pass at corpus_id 6b213fbfa1a03a99. Checks semantics are
untouched — the same inputs are refused with the same errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* C# emitter: the flat word codec, write half

The third port of the template Rust (#183) and Go (#198) already carry:
field values into locals, OR'd into 64-bit chunk words at literal shifts,
one packer step per chunk instead of one per field.

WHY IT PAYS HERE IS NOT WHY IT PAID IN GO. #198's finding was that Go's
per-field entry points sit past the compiler's inlining budget, so every
field cost a real call and ~86% of the codec's time was inside them. C# has
no such wall: the JitDisasm audit found ZERO direct calls into serialize.cs
from any generated method — every entry point inlines. What C# pays instead
is what those inlined bodies DO. BitWriter's packer step masks, shifts, ORs
into the scratch word, tests `newScratchBits >= 64`, conditionally stores a
qword and recovers the spill: a data-dependent branch and a conditional
store PER FIELD. MixedEntity paid fourteen of them for 135 bits. It now
pays three.

THREE INVARIANTS, RE-DERIVED RATHER THAN INHERITED — #198's review record
is why they are spelled out in flat.go's header rather than assumed:

  1. 64-bit chunks are wire-identical because serialize.cs's SerializeBits64
     splits low-dword-first-then-remainder, which IS the 32-bit chunk order.
  2. Every piece is masked to its width, because BitWriter.WriteBitsUnchecked
     TRUNCATES a too-wide value rather than refusing it — the flat form must
     truncate identically or a wide value corrupts its neighbours in the
     chunk instead of losing its own high bits. This is the invariant the Go
     port lost by inheriting Rust's fallback; here it is enforced at the one
     place a piece's value expression is built.
  3. Write refusals come first, in declaration order — same values refused,
     same non-latching false. What moves is how many bits reached the stream
     before the refusal, and a refused write leaves a partial message either
     way.

A run breaks at align, strings/bytes, arrays, branches, nested struct and
union calls, and the float, fixed-point, compressed-float and 128-bit
families. A run that would not REDUCE the packer-step count is not
flattened. A run that does not pay falls back ITEM BY ITEM — the item rides
inside every piece for exactly that reason, because the item is the only
unit the per-field path can re-emit.

THE READ HALF IS NOT IN THIS COMMIT, and the blocker is named rather than
worked around: folding a ranged read replaces SerializeInt with a generated
comparison, and serialize.cs's ranged read LATCHES ValueOutOfRange where a
generated guard returns false without latching. cs publishes checks=always
and test/cs pins that latch, so the read fold waits on a public
refusal-latch on ReadStream/ReadBatch — a serialize.cs change, its own PR.
The write path has no such dependence: its folded range refusals already
return false without latching. Measured ceiling for the read half, from a
hand-written flat prototype of the two hot cores: round_trip 1.08 -> 1.55
M msg/s, a 42% cut in read time.

MEASURED, against the previous commit, same box, median of 3 sittings:

  bench_mixed write       2.82 -> 3.30 M msg/s  (+17%)
  bench_mixed round_trip  1.08 -> 1.18 M msg/s  (+9%)

and against main:

  bench_mixed write       2.10 -> 3.30 M msg/s  (+57%)
  bench_mixed round_trip  0.97 -> 1.18 M msg/s  (+22%)

flatMaxRunBits is Go's 384 carried unmeasured: swept at 128/256/512 the C#
numbers do not move, because this corpus's longest run is 135 bits and no
cap in that range binds it. Said out loud in the file rather than presented
as a C# measurement.

Wire bytes unmoved: full `make test` green across nine backends, the
degenerate corpus included, and the bench's golden + per-variant round-trip
gates pass at corpus_id 6b213fbfa1a03a99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* C# emitter: the flat word codec, read half

The read half of the same form: chunks read first, ONE sticky-error test for
the whole run, then every value unpacked, validated and stored with literal
shifts and literal masks. Where the per-field form paid a bounds check and an
error test per field, MixedEntity now pays one of each per chunk.

WHAT IT WILL NOT ABSORB, AND WHY. A ranged read whose refusal can actually
fire goes through serialize.cs's SerializeInt/SerializeInt64, which LATCH
SerializeError.ValueOutOfRange; a generated comparison returns false without
latching. cs publishes checks=always and test/cs pins that latch, so a
ranged piece joins a run ONLY where its check is VACUOUS — max - min is
exactly 2^bits - 1, so no value the bits can hold is out of range — or where
this emitter's own read path ALREADY refuses without latching (the
full-range unsigned bits64 path, which has carried a generated refusal all
along). Everything else closes the run and keeps the runtime call. Same
inputs refused, same errors, on every path.

AND THAT RESTRICTION COSTS ALMOST NOTHING — which retires a lever rather
than deferring it. The hand-written prototype that folded EVERY ranged read
of the two hot cores, latch semantics and all, measured round_trip 1.55
M msg/s. This commit, which leaves four of MixedEntity's fourteen fields on
the runtime call because their range checks can fire, measures 1.54. The
public refusal-latch on ReadStream/ReadBatch that the full fold would need
is worth about 1% here, and is NOT worth a serialize.cs API addition on this
evidence. Recorded as ranked-and-refused, not as a follow-on.

TWO CONSEQUENCES OF FUSION, named in the file rather than found later:

  - A stream BOTH truncated inside a run AND carrying an out-of-range value
    before the truncation now surfaces the stream's overflow error where the
    per-field form surfaced the range refusal. Both refuse the packet; the
    set of ACCEPTED streams is unchanged, which is the property that matters
    at the trust boundary. Rust, Go, Java, Dart and JS-flat already carry it.
  - A failed run leaves the destination object untouched where the per-field
    form left the fields before the failure updated. Both are partial states
    a failed read gives no contract over (SPEC §5).

NEGATIVE CONTROL, run rather than assumed: with flatMaskLit forced to emit
no mask — invariant 2 deliberately broken — `make test` produces 65 failure
lines across the backends. The gate that proves this form does see it.

MEASURED, against the previous commit, same box, median of 3 sittings:

  bench_mixed write       3.30 -> 3.30 M msg/s  (unchanged, as expected)
  bench_mixed round_trip  1.18 -> 1.54 M msg/s  (+30%)

and against main:

  bench_mixed write       2.10 -> 3.30 M msg/s  (+57%)
  bench_mixed round_trip  0.97 -> 1.54 M msg/s  (+59%)

Wire bytes unmoved: full `make test` green across nine backends, the
degenerate corpus included, test/cs and test/cs-ludicrous (which pin the
corrupt-wire refusals) among them, and the bench's golden + per-variant
round-trip gates pass at corpus_id 6b213fbfa1a03a99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench/run.sh: the schema commit line carries its branch

Every runtime line in the preamble already records `<sha> (<branch>)`; the
schema commit line recorded a bare sha. Two failures that closes, both of
which this repo has already had: a sweep that silently measured the wrong
tree and a CSV that could not have said so, and a published result whose sha
stopped resolving the moment the branch was rebased. The branch name
survives the rebase.

One line in run.sh (it reuses commit_of, which the runtime lines already
use) and its law-home in BENCH-STANDARD.md §3.5, beside the provenance
clause the same class of defect earned in August.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bench/results: the cs-word-codec before/after pair, both halves, two sittings

Both halves committed, --only cs, taken back to back in the same window,
twice. Preambles carry the schema commit AND its branch on the after half;
the before half runs main's own run.sh, which predates that line, so the
file says so in its own note.

bench_mixed, cs, family gen, checks=always (M msg/s):

  path         before max   after max    before median  after median
  write         2,104,563   3,342,814      2,103,454     3,334,630   1.59x
  round_trip      992,155   1,594,245        990,865     1,586,844   1.60x

Against the quiet-box reference sweep of the same day and the same
corpus_id (bench/results/2026-09-01-arm64-macbook.csv, cpp round_trip
median 3,467,551; cpp write median 7,287,687), on the round_trip statistic
the published ledger reads at:

  cs before   350% of generated C++   (the published ledger row: 361%)
  cs after    219%

and on write, 347% -> 219%.

The `bits` family rows in this pair are NOT attributable to the change and
the files say so where a reader will meet them: BitsBench.cs exercises
serialize.cs's raw BitWriter/BitReader and no generated code at all, yet its
rows still move 11% across the halves at sub-3% spread. That can only be
process-level — JIT method placement and cache state after a differently
shaped bench_mixed pass in the same process. An open question, not a result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* flat.go: the bare-integer width test stops pretending to be a switch

staticcheck QF1002 on CI: a two-arm `switch { case w == 64: default: }` is an
if. Generated output is byte-identical — the regenerated tree is unchanged
and `make test` stays green across nine backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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