Skip to content

elixir #174: the aggressive round — 3743% to 1457% of generated C/C++ - #202

Merged
gafferongames merged 16 commits into
mainfrom
elixir-swizzle
Aug 31, 2026
Merged

elixir #174: the aggressive round — 3743% to 1457% of generated C/C++#202
gafferongames merged 16 commits into
mainfrom
elixir-swizzle

Conversation

@gafferongames

@gafferongames gafferongames commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Issue #174. The Elixir position — thousands of percent of generated C/C++ on
the §2.9 blend — worked with the method the owner set: profile first,
enumerate every lever, rank by win/effort, take them one commit and one
measurement at a time, and record the ones that fail as results.

3743% → 1457% of generated C/C++. Write 2.79x, round_trip 2.51x, both
halves measured in one sitting after the rebase and both committed:
bench/results/2026-08-31-elixirswizzle-{before,after}-quick-arm64-macbook.csv.

lever measured
A one binary append per group, not per field write 1.41x
C one window decode per group, not per field read 1.49x
B one map read per scope, not per field write 1.63x
E the float32 step, and a declaration constant folded ~1.03x
D the static offsets the generator already knew ~0 — at the noise floor
J a scalar array element sized its own read window ~0 on this shape
K one append for a whole clause of elements write 1.17x
L one window decode for a whole clause of elements read 1.14x

The per-lever figures are each commit's own measurement, recorded as the
lever landed. The certification pair below is the committed sweep.

The certification pair, and what the controls actually did

bench/run.sh --quick, both halves three minutes apart on the same fanless
M2 Air, corpus_id 6b213fbfa1a03a99 on every row. BEFORE stamps main at
52691a0; AFTER stamps this branch at fef86ed — the last code commit, so
both stamps resolve on origin and reconstruct the tree that was measured.

language before after control drift
c 98% 99% +0.71%
cpp 100% 100%
java 163% 147% −9.28%
rust 153% 154% +0.33%
go 238% 240% +0.71%
cs 360% 363% +0.69%
dart 367% 371% +1.30%
js 468% 482% +3.05%
elixir 3743% 1457% −61.06%

Elixir's own rates: write 135,696 → 378,044 msg/s, round_trip 95,413 →
239,723 msg/s. Both elixir rows sit far inside §2.3's 15% noise gate
(spreads 0.22% and 0.38%), so neither is a row bench/tools/relative.go
would exclude.

Five of the seven non-cpp controls hold inside 1.3%. Two do not, and
saying otherwise would be false: js +3.05% and java −9.28%, the two JIT
legs, and both had the noisier half in BEFORE (js spread 3.3%, java 1.9%,
against 0.4% and 0.2% after). Read them as JIT variance, not as signal.

The five tight controls all moved the SAME way, +0.3% to +1.3%, which means
cpp was marginally faster in the AFTER sitting. That drift runs against
this headline, not with it: a relatively faster cpp inflates every other
language's percentage, so 1457% is if anything slightly high and the
improvement slightly understated. The result is conservative.

An earlier revision of this PR published 3624% → 1451% from an
uncommitted sweep. That pair is withdrawn. Its elixir round_trip row had
come in at 15.3% spread against the 15.0% spreadNoisy threshold in
bench/tools/relative.go:86 — the repo's own tool would have excluded it,
and because the headline uses the max statistic, a noisier row carries a
luckier max. It was also the most flattering of the three figures then in
hand (1491%, 1456%, 1451%). The pair above replaces it with clean rows and
committed data; it moves the substance not at all.

What lever D does and does not retire (issue #174)

The issue proposed one binary construction per word with pre-swizzled
segments. That is two claims, and only one of them is dead.

  • The arithmetic half — literal segment widths let ERTS pack better — is
    retired.
    Lever D built it exactly as specified and measured ~0. The micro
    isolating it settles why: twelve appends of one 32-bit segment cost 103.6
    ns, and the same twelve appends with a DYNAMIC segment width cost 103.7 —
    identical. The literal widths were never the term that mattered.
  • The construction half — several registers riding ONE construction rather
    than one append each — is live, and it is lever M.
    Its micro is direct
    evidence for the issue's own mechanism: one append of twelve segments is
    73.3 ns against 103.6 for twelve appends of one, 1.41x. M is
    prototyped, byte-identical in output, and measured at 1.14x on the stats
    loop.

So #174's hypothesis is not retired. What the round established is that
append COUNT dominates — the same insight the hypothesis was reaching for by
a different route. (This matches the correction already posted on #174.)

The floor, with its mechanism

At 1457% the port is 14.6x the fastest language — inside the five-to-fifteen
band #174 asked to have published rather than smoothed, and still ~3x short
of the generated-JS tier. The shipped stats clause, decomposed a layer at a
time: append 7.96 of 13.19 ns/stat, merge 2.03, contract checks 0.76, and
2.44 ns/stat just walking a list of maps and reading two fields before any
bit is packed. The BEAM's 60-bit fixnum caps a scratch register at 59 bits,
which is why the write group budget is 52 and the read window 49, and which
caps how much of the append cost ANY grouping can remove.

Named and not built: lever M (above), the only remaining pure-BEAM move,
~12% on the blend; and a NIF backend, which is the owner's decision and
not an optimization.

New: the corpus gap this round exposed

Degenerate.schema (#203) has a standing property — every type in it is a
whole number of bytes. That is load bearing for what it catches and it is
also a ceiling: no clause boundary inside it ever lands mid-byte, so this
round's grouped write clause (52-bit budget) and grouped read clause (49-bit
window) always pick the same element count there. The disagreement was
untested by construction.

Two new corpus units close it, adopted from an adversarial review of this PR:

  • Clauses.schema — element widths where the two sides choose different
    k. At 13 bits the write clause takes four elements (52, the whole budget)
    and the read clause three (39, inside the window); at 17 it is three
    against two; at 26, two against one. Counts run 0, below a clause, exactly
    a clause, one past it, and the bound. Plus a fixed mid-byte array, grouping
    across a nested struct boundary, a union of empty arms behind a tag, and
    string/bytes at zero, partial and full length behind a 5-bit lead so
    the align inside them is a real barrier.
  • Joins.schema — the same aimed at the static-offset state machine:
    arms that agree and disagree on width, a branch with no else, a branch
    inside a branch, an align that regains staticness on one path only, an
    array that gives it up on one path only, unions of unequal arms at mid-byte
    offsets, and a long static run after an align.

Because these shapes are NOT byte-aligned, one shared stream would not equal
a concatenation of the shapes written alone — and the Elixir emitter returns
each message as its own binary from bit zero, so it cannot write a shared
stream at all. Each shape is therefore written to its own stream and flushed,
and the golden is those concatenated. All nine legs byte-compare against
the C++ pin
, and each shape is held to its own declared bit width so a
doubled or dropped clause is named rather than smeared into one total.

Constraints held

  • No existing wire golden moved. git diff origin/main -- testdata/wire/
    shows only the two NEW corpus goldens, clauses.bin and joins.bin.
    make update-goldens rewrote every wire golden under
    SCHEMA_UPDATE_WIRE_GOLDENS=1 and every pre-existing one came back
    byte-identical.
  • corpus_id 6b213fbfa1a03a99 unchanged; bench/ otherwise untouched.
  • Adding units to examples/ moves the example unit's protocol id in
    testdata/golden/id.txt, exactly as go: the flat word codec — 712% to 248% of generated C/C++ #198 did when it added
    Degenerate.schema.
  • Rebased onto main. go: the flat word codec — 712% to 248% of generated C/C++ #198 pinned testdata/golden/elixir/Degenerate.ex with
    the pre-round emitter, so merging unrebased would have turned main red on
    TestGoldenSourceElixir; that source golden is re-pinned here.
  • Behaviour and raise text identical; nine-backend make test green;
    regeneration zero-diff (make generated-current); shape-gate clean with no
    new ledger entries; mix format, modernize, gofmt and go vet clean.
  • serialize.elixir is untouched and could not be otherwise — generated
    Elixir has no runtime dependency at all, so the whole seam the owner's
    grant opened is on the emitter side of it.

🤖 Generated with Claude Code

@gafferongames

Copy link
Copy Markdown
Contributor Author

The profile, and the ranked lever list — posted before the work (#174's method)

1. Where the time actually goes

Instrument: wall-clock A/B on the canonical shape, bench_mixed (438 bytes;
8 entities x 135 bits, 80 stats x 18 bits, 4 loadout bytes), rotating the
same 64 pre-decoded instances the bench rotates, 100k ops per measurement,
M2 Air, OTP 29.0.5 / Elixir 1.20.4. Decomposition by shortening the lists in
the instance, which is honest because the writer's list length is data.

write        7.344 us/op
read         3.561 us/op        (round_trip 10.748 us/op)

write splits            us      per element     share
  stats, 80 elems      2.632    32.9 ns          36%
  entities, 8 elems    2.970   371.5 ns          40%
  header+footer+loadout 1.742                    24%

The write path is twice the read path. eprof agrees on the ordering
(stats 38.7%, entities 23.7% of write) and is not quoted for magnitudes —
162000 traced calls into the stats loop inflate the very function the wall
clock independently indicts. BEAM profiling distorts; the A/B is the ruling.

The mechanism, named. mergeW flushes every whole byte after EVERY
field, so 325 bs_append BIF calls ride one message (131 static field
sites + 8x14 + 80x2 + 4x1 in the loops). The read side's rd/3 opens a
fresh match context per FIELD — 14 of them per entity, 2 per stat.

Not a routing question. Generated Elixir has no runtime dependency;
serialize.elixir is not on this path at all. Every lever below is emitter-only.

2. The levers, measured in isolation before ranking

Micro-benchmarks reproducing the shipped loop shapes exactly (the shipped
shape reproduces to 32.8 vs the in-situ 32.9 ns/stat, so the model is faithful).
These live in scratch, not in the repo — the shape gate (#200) is right that
hand-coded shapes do not belong in bench/.

write, per stat (18 bits, 2 fields) ns
shipped: 1 append per field 32.8
grouped: 1 append per element 28.0
grouped + one map read per element 20.2
unrolled x4 (72 bits = 9 bytes), 2 appends / 4 elements 14.3
no append at all (arithmetic control) 12.6
write, per entity (135 bits, 14 fields) ns
shipped shape 348
4 grouped appends + one map read 101
read, per stat ns
shipped: one rd per field 27.6
one rd per element + shift/mask 14.8
one 56-bit-window rd per 2 elements 7.7
one 72-bit-window rd per 2 elements 29.4
progressive <<..., rest::binary>> match, 4-element period 13.2
one rd per element, no struct built (control) 11.2

Two results that decide the design:

  • The 72-bit window is SLOWER than the shipped 40-bit one (29.4 vs 27.6).
    A 72-bit window boxes: the BEAM's fixnum is 60-bit signed, so any
    intermediate at or above 2^59 costs a heap bignum. The whole design is
    fenced by that boundary — this is why the group budget below is 52 bits
    (7 leftover + 52 = 59) and not "a 64-bit word".
  • Progressive rest::binary matching (13.2) barely beats a grouped rd
    (14.8) and loses to a wider grouped rd (7.7)
    , while costing 8-phase
    clause specialization to generate. The match-context-reuse idiom is not
    worth its generator complexity here. Recorded as a negative result.

3. Ranked by win / effort

# lever est. win effort take
A Grouped write appends. mergeW stops flushing per field: accumulate into scratch with the generator's statically-known offsets and flush once per group of <= 52 bits, with explicit flushes at every barrier (function tail, loop-helper call and loop tail, align, string bytes, branch and union arm joins). 325 appends/msg -> ~90. write ~1.7x low — one choke point (mergeW) plus barrier calls yes, first
B One map read per element. Emit %{f1: v1, ...} = e once instead of N e.field accesses; each . is its own get_map_elements with its own raise branch, one destructure is one instruction. write ~1.3x on top of A low-medium — field-path rewriting in the emitter yes
C Grouped reads through a wider window. One rd per group of <= 49 bits into a group register, fields extracted by static shift/mask; a second helper rdw opens a 56-bit window (49 = 56 - the 7-bit worst-case shift) while rd keeps the cheap 40-bit window for narrow groups. read ~2x low-medium — one choke point (readR) plus group resets at the same barriers yes
D Static-offset specialization. Where the bit offset is statically known (everything before the first counted array), the flush size is a literal and flush/scratch_bits bookkeeping disappears entirely. write ~1.1x low, once A exists yes if A lands clean
E fr/1 halves its allocations. The float32 rounding helper builds two 4-byte binaries where one suffices (match the same binary twice, once as int and once as float). 18 calls/msg. write ~1.02x trivial yes
F Unroll loops to the byte-alignment period (4 stats = 9 bytes; 8 entities = 135 bytes), so a whole period is emitted with byte-literal flushes. Measured 14.3 vs 20.2 ns/stat. write ~1.15x high — period clauses plus a remainder path per element type no — named, not built
G Phase-specialized loop clauses (8 clauses per element type, one per entry bit-phase), which would make every offset static inside a loop. unknown, <= F high — 8x generated code per array element type no — named, not built
H iodata instead of binary append. Measured 23.2 ns/stat against the shipped 28.0 for the same append count — but grouped appends (A) already reach 20.2, and iodata then owes a final iodata_to_binary pass. negative once A lands low no — measured and rejected
I NIF backend (#174's reserved second tier). Out of scope for this round by the issue's own sequencing: it goes on the table after the pure-BEAM floor is published, and it is the owner's decision, not an optimization. large large no

Expected combined: write ~2.2x, read ~2.1x, blended round_trip ~2.1x.
That is 3699% -> roughly 1700%. It does not reach the generated-JS tier (478%),
and the reason will be stated with its mechanism rather than smoothed.

@gafferongames
gafferongames force-pushed the elixir-swizzle branch 4 times, most recently from 2257b81 to 11021b8 Compare August 31, 2026 14:16
@gafferongames

Copy link
Copy Markdown
Contributor Author

The round's result: 3624% → 1451% of generated C/C++, and the floor named

Four more levers since the ranked list above — D and J from it, K and L
re-ranked onto it on evidence. Every one measured, one commit each, and the
two that did not pay are recorded as results rather than quietly kept.

The certification numbers

bench/run.sh --quick, both legs in ONE sitting, same box (M2 Air, OTP
29.0.5 / Elixir 1.20.4), corpus_id 6b213fbfa1a03a99 on every row. The
published blend is the §2.9 round_trip. The eight non-Elixir rows are the
in-sitting control: none of them moves more than 2%, so the Elixir move is
the change and not the weather.

language before after
c 97% 99%
cpp 100% 100%
java 144% 148%
rust 151% 153%
cs 355% 361%
dart 359% 368%
js 465% 469%
go 572% 589%
elixir 3624% 1451%

Elixir's own rates: write 133,697 → 365,823 msg/s (2.74x), round_trip
93,764 → 234,112 msg/s (2.50x). The sweep's elixir round_trip row
tripped §2.3's noise gate at 15.3% spread, so the leg was re-run twice
alone: 1491% (spread 1.8%) and 1456% (spread 1.0%). 1451% is the sweep's
own figure and the one quoted; the re-runs bracket it.

Whole round, against the pre-round baseline, on the scratch A/B (the
committed variant corpus, 64 rotating instances, 150k ops, 4 runs x 4
interleaved passes, best per pass, median of passes):

before after A/B/C/E after D/J/K/L round
write 7.431 3.203 2.602 2.86x
read 3.529 2.155 1.779 1.98x
round_trip 12.696 5.374 4.354 2.92x

The four levers

D — the static offsets the generator already knew. A write surface
starts at a known empty scratch and stays known until the message's own data
decides a length, so through that region every shift is a literal, every
flush is a literal-width binary segment, the scratch_bits variable is not
maintained at all, and the tail is data or <<data::binary, scratch>>
outright. Staticness is given up at a loop call and at a branch whose arms
disagree, and regained at every align.

MEASURED: at or below the noise floor. ~2% on write, and the honest
reading is zero.
head 3.122 vs D 3.136 by median over five passes, 3.095
vs 3.038 by best. The isolated micro says why, and the two agree: the whole
pre-lever-D bookkeeping shape costs 113.8 ns per twelve 32-bit fields and
the post-lever-D shape 103.6 — 9% of a region that is 24% of the write. 2%
is exactly what 9% of 24% predicts. The measurement did not disappoint the
mechanism; the mechanism was small.
The lever stays because it also made
K possible to reason about, and because the code it deletes is real, but it
is recorded as ~0, not as what it looked like it should be worth.

J — a scalar array element sized its own read window. The run fuser is
reached through emitReadItems, and a scalar array element is not: it went
straight to the scalar read with the run unknown, so a one-byte element
opened the 56-bit window to take eight bits — and took that window's longer
:binary.decode_unsigned tail fallback with it, two bytes sooner than the
40-bit window would. MEASURED: no change on this shape (read 2.054 vs
2.069, inside the noise). The loadout array is four elements of a 438-byte
message; the defect is real and the shape it appears in here is too small to
show it. Recorded as correct-and-unmeasurable, kept because a schema with a
large scalar array pays it and this one does not.

K — one append for a whole clause of elements. This is where the write
time was, and the ranked list had it at "no — named, not built" on a cost
estimate that turned out to be wrong. The micro that re-ranked it: twelve
appends of one 32-bit segment cost 103.6 ns; the same 48 bytes in three
appends of four segments cost 79.0, and in one append of twelve, 73.3. The
same twelve appends with a DYNAMIC segment width cost 103.7 — identical.
The append is the expense and the arithmetic around it is not.

The list clause now takes k elements, k being the most whole elements whose
widths fit the 52-bit group budget, capped at four; the single-element
clause behind it is the remainder, so no list length has to divide anything.
No byte-alignment period and no phase specialization are needed — which is
what made F look expensive and is why the estimate was wrong. Stats: 80
appends → 40. Loadout: 4 → 1.

MEASURED: write 3.097 → 2.65 us/op, 1.17x. round_trip 1.11x.

L — one window decode for a whole clause of elements. K's other half.
A read clause decodes k elements under one window, k bounded by the wide
window's 49 usable bits. Stats: 80 decodes → 40. Loadout: 4 → 1.

MEASURED: read 2.089 → 1.838 us/op, 1.14x. round_trip 1.07x.

Both needed the element emitters to take the variable they bind (so a clause
can hold e1 and e2), and the raise TEXT is held across it: the display
map resolves every slot back to e, so a message still reads e.delta is above the wire maximum.

The floor, with its mechanism

At 1451% the port is 14.5x the fastest language — inside the five-to-
fifteen band #174 asked to have published rather than smoothed, and still
3.1x short of the generated-JS tier (469%). Here is what holds it there,
measured rather than asserted. The shipped stats clause, decomposed by
removing one layer at a time from the real shape:

ns/stat what the layer costs
shipped: destructure + checks + merge + append 13.19
no append 5.23 the append: 7.96
no merge either 3.20 the merge: 2.03
destructure only 2.44 the contract checks: 0.76
bare list traversal 0.72 the two field reads: 1.72

Two things follow, and they are the floor.

1. The append still dominates, and the fixnum boundary caps how much of it
can be removed.
The BEAM's fixnum is 60-bit signed, so a scratch register
cannot carry more than 59 bits without heap-allocating a bignum — measured
back in lever A, a boxed window is slower than the flush it saves. That is
why the group budget is 52 and the read window 49, why a stats clause takes
two elements and not four, and why no amount of further grouping can put a
whole 438-byte message in one construction. The cap is the BEAM's integer
representation, not the design.

2. Under the serialization there is a per-element cost no codec can
remove.
Walking a list of 80 maps and reading two fields from each costs
2.44 ns/stat before a single bit is packed — 19% of the shipped clause —
and the contract checks the SPEC requires add 0.76 more. C does the same
work in registers with branch-predicted compares.

Ranked next, named and not built

M — one binary construction per barrier, not one per flush. The one
lever that can still move the append cost, and the closest thing to #174's
original hypothesis: a barrier emits ONE construction carrying several
scratch registers as several segments, instead of one construction per
flush. A register still cannot exceed 59 bits, but a construction can
carry as many registers as it likes, so four stats can ride one append as
two segments. Prototyped and measured: 14.62 → 12.61 ns/stat, 1.14x on
the stats loop, and the bytes come out identical to the shipped shape

the mechanism is proven, only the emitter work is left. Its bigger prize is
the entity loop, which flushes three times per element for 40% of the write.
Estimated ~20% on write, ~12% on the blend; 1451% → roughly 1300%.

N — a NIF backend. #174's reserved second tier, and after this round it
is the only thing that reaches the JS tier. It is the owner's decision and
not an optimization: per-platform native compilation, and a NIF fault takes
the VM down.

Ruled out and measured, unchanged from the list above: F is superseded by K
(and was mis-ranked, recorded), G is not needed once K showed phase
specialization was never the requirement, H stays rejected.

Constraints held

Wire bytes never moved: no file under testdata/wire is touched by any
commit on this branch, corpus_id 6b213fbfa1a03a99 on every bench row
before and after, the full per-variant decode/re-encode gate green in all
nine legs. Behaviour identical: the same inputs refused, the same raise
text. Every generated module compiles without a warning,
mix format --check-formatted is clean, and the nine-backend make test
chain is green. serialize.elixir is untouched and could not be otherwise:
generated Elixir has no runtime dependency at all, so the whole seam the
owner's grant opened is on this side of it.

The micro-benchmarks quoted here live in scratch, not in the repo — #200's
shape gate is right that hand-coded shapes do not belong in bench/.

🤖 Generated with Claude Code

@gafferongames gafferongames changed the title elixir #174: the aggressive round — grouped appends, grouped reads, one map read per element elixir #174: the aggressive round — 3624% to 1451% of generated C/C++ Aug 31, 2026
@gafferongames
gafferongames marked this pull request as ready for review August 31, 2026 14:27
Wall-clock decomposition of generated Elixir on the canonical shape
(bench_mixed, 438 bytes: 8 entities x 135 bits, 80 stats x 18 bits,
4 loadout bytes), M2 Air, OTP 29.0.5 / Elixir 1.20.4, 100k ops per
measurement, the same instances the bench rotates:

  write        7.344 us/op
  read         3.561 us/op   (round_trip 10.748)

  write splits:
    stats loop, 80 elements     2.632 us    32.9 ns/stat  (36%)
    entities, 8 elements        2.970 us   371.5 ns/ent   (40%)
    header + footer + loadout   1.742 us                  (24%)

The write path is 2x the read path, and 325 binary appends per message
is what it is made of: the emitter's mergeW flushes every whole byte
after EVERY field, so one bs_append BIF call rides each of the 131
static field sites plus 8x14 + 80x2 + 4x1 loop sites.

eprof agrees on the ordering (stats 38.7%, entities 23.7% of write) and
is not quoted for magnitudes: 162000 traced calls into the stats loop
inflate exactly the function the wall clock says is hot.

Nothing measured here lands in serialize.elixir. The generated Elixir
codec has no runtime dependency at all, so every lever below is a
schema-level emitter change and #170's routing question does not arise.
The emitter knew every field width statically and threw the knowledge
away: mergeW flushed the scratch's whole bytes after EVERY field, so a
438-byte bench_mixed message cost 325 bs_append BIF calls.

mergeW now carries a GROUP. It merges into the scratch and flushes only
when the next field would pass the budget, and flushW closes the group at
every barrier that observes data or scratch_bits — the write function's
tail, an align, the bytes of a string, a loop helper's call and its own
element tail, and the joins of a branch or a union case. flushW is a
no-op when no group is open, which is what makes a barrier free where one
is already closed.

The budget is 52 bits and the number is not a taste. The BEAM's fixnum is
60-bit signed, so an intermediate at or above 2^59 costs a heap bignum; a
flush leaves at most 7 bits behind, so 7 + 52 = 59 is the whole envelope.
Measured on the read side of the same shape, a 72-bit window is SLOWER
than the shipped 40-bit one (29.4 vs 27.6 ns/element) — boxing costs more
than the flush it would save, so the group stops at the boundary.

MEASURED, wall clock, canonical shape, 100k ops (before -> after):

  write            7.344 -> 5.208 us/op     1.41x
    stats loop      32.9 -> 22.3 ns/stat
    entities       371.5 -> 223.2 ns/entity
  round_trip      10.748 -> 8.886 us/op
  read             3.561 -> 3.561 us/op     (untouched, as expected)

  bench --quick: write 0.13 -> 0.18 M msg/s, round_trip 0.09 -> 0.12

Static append sites in generated/bench/elixir: 131 -> 65; per entity
14 -> 3, per stat 2 -> 1; ~325 -> ~128 appends per message.

Wire bytes unmoved: corpus_id 6b213fbfa1a03a99, the full variant
round-trip gate green, test/elixir and test/elixir-ludicrous OK,
mix format --check-formatted clean.
rd/3 opened a fresh match context for EVERY field: 14 per entity, 2 per
stat. The generator knows every width statically, so readR now reads a
GROUP into rv once and cuts each field out with a static shift and mask.
The fused static run's own length sizes the group, so a short run keeps
the cheap 40-bit window and a long one takes rdw's 56-bit window.

Two windows, and the second one's width is the same fixnum argument the
write group's budget rests on. A 56-bit window less the 7-bit worst-case
offset is a 49-bit group and stays under 2^59; a 64-bit window would box.
Measured on this shape a 72-bit window is SLOWER than the shipped 40-bit
one it would replace — 29.4 vs 27.6 ns/element — so 49 is the ceiling and
not an arbitrary stopping point.

Reading a group wider than the fields it feeds is safe by construction:
rd and rdw never raise (the tail falls back to the bytes that exist), and
bits past the bounds-checked run are discarded, never observed. Every
bounds check, range check, constant check and refusal is where it was.

rdBreak closes the group at every barrier — the read surface, a loop
helper's entry and every call to one, an align, the bytes of a string,
and the arms of a branch or a union case.

MEASURED, wall clock, canonical shape, 100k ops (after lever A -> after C):

  read             3.561 -> 2.384 us/op     1.49x
    stats           27.6 ->  ~15  ns/stat   (rd calls 2/elem -> 1)
    entities                               (rdw calls 14/elem -> 4)
  round_trip       8.886 -> 8.126 us/op
  write            5.208 -> 5.208 us/op     (untouched, as expected)

  bench --quick: round_trip 0.12 -> 0.13 M msg/s

Wire bytes unmoved: corpus_id 6b213fbfa1a03a99, the full variant
round-trip gate green, test/elixir and test/elixir-ludicrous OK,
mix format --check-formatted clean.
Elixir's `.` on a struct is a get_map_elements of its own with its own
raise branch. The writer spent one per field: 14 for an entity, 2 for a
stat, ~30 at the top level. A scope now reads its fields ONCE —
`%{stat_id: e_stat_id, delta: e_delta} = e` — and every reference to
those fields is a local from there on.

Scope, precisely: a scope binds the fields it reads UNCONDITIONALLY at
its own level — its field items and the conditions of its branches. A
branch arm's fields are NOT bound at the enclosing level; the arm is its
own scope and binds them when it is taken, so a value the wire never asks
for is still never demanded of the caller. Below two fields the pattern
would not pay for itself and none is emitted.

Refusals are unchanged. A struct always carries every key, so the bound
locals are exactly as unconditional as the dotted accesses they replace,
and every range, count, mask and length check is where it was. The one
delta, named: a MAP missing a key now raises MatchError where it raised
KeyError — a raise either way, on exactly the same inputs, never a wrong
answer. The raise TEXT is unchanged: g.dsp resolves a local back to the
dotted access it stands for, so the message still reads "e.delta is above
the wire maximum" and never names a local the caller never wrote.

MEASURED, wall clock, canonical shape, 100k ops (after lever C -> after B):

  write            5.208 -> 3.202 us/op     1.63x
    stats loop      23.4 ->  15.4 ns/stat
    entities       212.6 ->  96.4 ns/entity
  round_trip       8.126 -> 5.466 us/op
  read             2.384 -> 2.276 us/op     (the read side binds locals already)

  bench --quick: write 0.18 -> 0.31 M msg/s, round_trip 0.13 -> 0.18

Cumulative over A + C + B, against the round's baseline:

  write            7.344 -> 3.202 us/op     2.29x
  read             3.561 -> 2.276 us/op     1.56x
  round_trip      10.748 -> 5.466 us/op     1.97x

Wire bytes unmoved: corpus_id 6b213fbfa1a03a99, the full variant
round-trip gate green, test/elixir and test/elixir-ludicrous OK,
mix format --check-formatted clean.
Three changes to the compressed-float helpers, all of them removing work
that was provably redundant:

fr/1 lets the REFUSAL be the test. A float segment does not match a
non-finite pattern, so the finite path is one construction and one match
and never touches the exponent field; the second clause reads the sign of
exactly the patterns the first refused. NaN still maps by sign, as it did.

cf_quantize takes miv32 — the float32 of the step count — as a
generation-time literal instead of computing fr(miv * 1.0) on every call.
It is a declaration constant; folding it removes one of the six float32
steps. cf_decode takes it directly in place of the integer count, which
was only ever used to compute the same rounding.

trunc(Float.floor(x)) becomes floor(x): one BIF returning an integer
instead of a float floor and a truncation. The argument is finite by
construction (normalized is clamped to [0, 1] and miv32 is finite), so
the paths differ only in the class of an unreachable raise.

MEASURED, wall clock, canonical shape, 100k ops, and reported honestly:

  write            3.202 -> 3.18 us/op   AT OR BELOW the noise floor
  read             2.276 -> 2.19 us/op   ~3%
  round_trip       5.466 -> 5.30 us/op

The isolated micro said more (cf_quantize 203.6 -> 135.2 ns/call), and
the micro was wrong to say it: it passed fr as a closure, so every fr
call it removed was an inflated indirect call rather than the direct
local call the generated module makes. The in-situ number is the ruling.
The lever stays because it costs nothing and removes real work; it is
recorded as small, not as what the micro promised.

Wire bytes unmoved: corpus_id 6b213fbfa1a03a99, the full variant
round-trip gate green, test/elixir and test/elixir-ludicrous OK,
mix format --check-formatted clean.
Deliberate emitter change, and only the SOURCE goldens move: no file under
testdata/wire is touched by this commit or by any of the four levers, and
the full `make test` chain — every language's conformance suite, the wire
goldens, the fuzzers, the format refuser — is green.
The group model carried its widths statically and then computed with them
at runtime anyway: every merge shifted by the scratch_bits VARIABLE and
added to it, and every flush divided that variable by eight to size a
binary segment whose width was therefore dynamic.

A write function starts at a known empty scratch and stays known until the
message's own data decides a length. Through that whole region the emitter
now tracks the offset itself, so:

  - a merge is one statement, `scratch = scratch ||| v <<< 16`, with a
    literal shift and no scratch_bits arithmetic at all; the first merge of
    an empty group is a bare bind, `scratch = v`
  - a flush is `data = <<data::binary, scratch::little-size(4)-unit(8)>>`
    with a LITERAL segment width — the form the BEAM's binary construction
    is built for — plus a literal shift, in place of four statements
  - an align that lands on a boundary emits nothing, and one that does not
    appends the residual byte unconditionally instead of testing for it
  - the function's tail is `data` or `<<data::binary, scratch>>` outright,
    never `if scratch_bits != 0`
  - scratch_bits is not even bound where the surface never needs it

Staticness is a property the emitter EARNS and gives up honestly. It is
given up where a loop helper is called (how many elements rode is the
message's business) and where a branch's arms end on offsets that disagree;
in that case each arm publishes the offset it reached and the emitter goes
back to maintaining the variable. It is REGAINED at every align, which
lands the position on a byte whatever the data did. Where every arm of a
branch or a union case does agree, the offset stays static past the join
and scratch_bits does not ride the join's tuple at all.

Behaviour is unchanged: the same statements in the same order over the same
values, with the arithmetic the generator can do moved to generation time.
Every range, count, mask and length check is where it was, and every raise
carries the text it carried.

Wire bytes unmoved: no file under testdata/wire is touched, corpus_id
6b213fbfa1a03a99, the full variant round-trip gate green, test/elixir and
test/elixir-ludicrous OK, mix format --check-formatted clean, and the whole
nine-backend `make test` chain green. Only the four SOURCE goldens move,
re-pinned here with the emitter that moved them.

MEASURED: in the sitting's A/B, recorded on the PR with the round's table.
Lever C sizes each window decode by the fused static run it sits in, and
the run fuser is reached through emitReadItems. A scalar array element is
not: readHelper hands it straight to the scalar read, so the run was zero —
unknown — and readR fell back to its widest window. A one-byte element was
opening the 56-bit window to take eight bits.

That is not merely a wider mask. rdw needs seven bytes ahead of the
position to match its window and rd needs five, so the wide window drops
into the :binary.decode_unsigned tail fallback two bytes sooner — and a
scalar array is very often the LAST thing in a message, which is exactly
where that boundary lies.

A scalar element's own width is the run, so readHelper sets it. The loadout
loop now reads `rd(data, bits_read, 8)` where it read `rdw(data, bits_read,
49)`. Nothing else moves: a struct or union element still goes through
emitReadItems and fuses its own runs, and readR's choice of window was
already free to be any width that covers the field.

Wire bytes unmoved: no file under testdata/wire is touched, corpus_id
6b213fbfa1a03a99, the full variant round-trip gate green, test/elixir and
test/elixir-ludicrous OK, mix format --check-formatted clean, the whole
nine-backend `make test` chain green, and the two source goldens the
emitter moved re-pinned here.

MEASURED: in the sitting's A/B, recorded on the PR with the round's table.
…each

The write loop appended once per ELEMENT: 80 bs_append calls for the stats
array of one bench_mixed message, 4 for the loadout. Lever A closed the
group across the fields of an element; it could not close it across the
call boundary between elements, because a helper is entered at an offset
the caller's element count decides.

Measured in isolation, that boundary is where the write path's cost is.
Twelve appends of one 32-bit segment each cost 103.6 ns; the same 48 bytes
in three appends of four segments cost 79.0, and in one append of twelve,
73.3. The same twelve appends with a DYNAMIC segment width cost 103.7 —
identical. The append is the expense; the arithmetic around it is not.

So a clause takes SEVERAL elements off the list. k is chosen by the group
budget the fixnum boundary already fixed — the most whole elements whose
widths fit in 52 bits, capped at four so one array field cannot cost
unbounded generated code — and the k element bodies merge into one group
and flush once. The single-element clause behind the wide one is the
remainder, so a list length never has to divide anything, and an element
whose width the wire decides keeps one clause per element as before.

  stats  (18 bits): 2 elements per clause, 80 appends -> 40
  loadout (8 bits): 4 elements per clause,  4 appends ->  1

Nothing about an element's emission changes. The clause names its slots e1
and e2 where it used to have just e, and the raise TEXT is held: the
display map resolves every slot back to "e", so a message still reads
"e.delta is above the wire maximum" and never names a slot the caller never
wrote. Every range, count, mask and length check is where it was.

MEASURED, wall clock, the canonical shape from the committed variant
corpus, 64 rotating instances, 150k ops, 4 runs x 4 interleaved passes,
best per pass, median of passes:

  write            3.097 -> 2.65 us/op      1.17x
  round_trip       5.268 -> 4.74 us/op      1.11x
  read             2.132 -> 2.09 us/op      (untouched, as expected)

Wire bytes unmoved: no file under testdata/wire is touched, corpus_id
6b213fbfa1a03a99, the full variant round-trip gate green, test/elixir and
test/elixir-ludicrous OK, every generated module compiles without a
warning, mix format --check-formatted clean, the nine-backend `make test`
chain green, and the source goldens the emitter moved re-pinned here.
The read loop's other half of lever K. Lever C reads a GROUP into one
window and cuts each field out of it with a static shift and mask, but a
loop helper is a function boundary, so a group could never span two
elements: 80 window decodes for the stats array, 4 for the loadout.

A read clause now decodes k elements under ONE window. k is chosen by the
wide window's usable width — the most whole elements whose widths fit in
49 bits, which is the same fixnum boundary the write budget rests on,
capped at four — and the clause carries a guard on the remaining count,
with the single-element clause behind it as the remainder.

Two things had to give way for a window to span elements. The element
emitters now take the variable they bind, so a clause can hold e1 and e2
without either shadowing the other. And the run fuser no longer overwrites
an OUTER run: a named element's own scope would otherwise size the window
to one element and undo the clause. Where no outer run is open the fuser
behaves exactly as it did.

  stats  (18 bits): 2 elements per rdw window of 36 bits, 80 decodes -> 40
  loadout (8 bits): 4 elements per rd window of 32 bits,   4 decodes ->  1

Reading a window wider than one element is safe on exactly the grounds
lever C established: rd and rdw never raise, the tail falls back to the
bytes that exist, and bits past the bounds-checked run are discarded and
never observed. The bounds check itself is unchanged — the call site
proved count * elem for a counted array, and an unbounded run still checks
its own span before it reads it.

MEASURED, wall clock, the canonical shape from the committed variant
corpus, 64 rotating instances, 150k ops, 4 runs x 4 interleaved passes,
best per pass, median of passes, against lever K:

  read             2.089 -> 1.838 us/op     1.14x
  round_trip       4.736 -> 4.409 us/op     1.07x
  write            2.703 -> 2.691 us/op     (untouched, as expected)

Wire bytes unmoved: no file under testdata/wire is touched, corpus_id
6b213fbfa1a03a99, the full variant round-trip gate green, test/elixir and
test/elixir-ludicrous OK, every generated module compiles without a
warning, mix format --check-formatted clean, the nine-backend `make test`
chain green, and the source goldens the emitter moved re-pinned here.
Four findings, all of them this branch's own, none of them a behaviour
change: the two scope-binding map copies lever B introduced become
maps.Copy, and the two unroll caps levers K and L introduced become min.
The generator emits identical bytes for every schema in the tree.

CI's lint job runs modernize at @latest, so a check that ships tomorrow
lands on the next branch the same way; this is the branch paying for its
own four.
#198 landed `examples/Degenerate.schema` and pinned its Elixir source
golden with the pre-round emitter, so this branch merging as-is turned
main red: `TestGoldenSourceElixir` refused
`testdata/golden/elixir/Degenerate.ex`.

The re-pin is a SOURCE re-pin only. `make update-goldens` rewrote every
wire golden under `SCHEMA_UPDATE_WIRE_GOLDENS=1` and every one came back
byte-identical, `testdata/wire/` has no diff, and the nine-backend chain
byte-compares `degenerate.bin` against the C++ pin in all nine legs and
is green. The new text is the round's own shapes: one map destructure
per scope (B) and literal-width flushes with `scratch_bits` gone (D).
… cannot reach

Degenerate.schema's standing property is that every type in it is a whole
number of bytes. That is load bearing for what it catches and it is also a
ceiling: no clause boundary inside it ever lands mid-byte, so an emitter
that groups array elements picks the same group size on the write and the
read side of every type in the file. The Elixir round's write clause has a
52-bit budget and its read clause a 49-bit window, so on Degenerate the two
always agree and the disagreement is untested.

Clauses.schema picks element widths where they do not. At 13 bits the write
clause takes four elements (52, the whole budget) and the read clause three
(39, inside the window); at 17 it is three against two; at 26, two against
one. Counts run 0, below a clause, exactly a clause, one past it, and the
bound, so the remainder path is entered from every phase. It also carries a
fixed mid-byte array, grouping across a nested struct boundary, a union of
empty arms behind a tag, and string/bytes at zero, partial and full length
behind a 5-bit lead so the align inside them is a real barrier.

Joins.schema does the same to the static-offset state machine: arms that
agree and disagree on width, a branch with no else, a branch inside a
branch, an align that regains staticness on one path only, an array that
gives it up on one path only, unions of unequal arms at mid-byte offsets,
and a long static run after an align.

Unlike Degenerate these shapes are NOT byte-aligned, so one shared stream
would not equal a concatenation of the shapes written alone — and the Elixir
emitter returns each message as its own binary from bit zero, so it cannot
write a shared stream at all. Every shape is therefore written to its own
stream and flushed, and the golden is those concatenated. Every leg can
reproduce that, and each shape's bytes stay individually attributable.

C++ pins, Go and Elixir byte-compare; the remaining six legs follow. Adding
units to the corpus moves the example unit's protocol id, as #198 did when
it added Degenerate. No existing wire golden moved.
All nine legs now hold the two units to the C++ pin. The corpus README
gains a row for each, stating what each reaches that Degenerate cannot.
The published figures were unverifiable: `git log origin/main..HEAD --
bench/results/` was empty, so no re-runner could check them. #198, #199
and #204 all committed their before/after data this week; this matches
that.

Both halves are a fresh paired A/B taken in one sitting on the M2 Air
after the rebase, three minutes apart, corpus_id 6b213fbfa1a03a99 on
every row. The BEFORE half stamps main at 52691a0 and the AFTER half
elixir-swizzle at fef86ed — the last CODE commit on the branch, so both
stamps resolve on origin and reconstruct the tree that was measured.

Elixir, bench_mixed, family gen:
  write      135,696 -> 378,044 msg/s  (2.79x; spread 2.08% -> 0.22%)
  round_trip  95,413 -> 239,723 msg/s  (2.51x; spread 0.46% -> 0.38%)

As a share of generated C++ (§2.9, max rates): 3743% -> 1457%. Both
elixir rows are far inside §2.3's 15% noise gate, so neither is a row
bench/tools/relative.go would exclude.
@gafferongames gafferongames changed the title elixir #174: the aggressive round — 3624% to 1451% of generated C/C++ elixir #174: the aggressive round — 3743% to 1457% of generated C/C++ Aug 31, 2026
@gafferongames

Copy link
Copy Markdown
Contributor Author

The adversarial review's five, answered — and its corpora adopted

The review returned BLOCK on five items, none touching the wire, which it
verified clean by its own commands. All five are fixed; its two schemas are
now part of the corpus.

1 — REBASE (hard blocker). Fixed. The branch's merge-base was aa6c62e
(#200) but main is 52691a0 (#198), which added examples/Degenerate.schema
and pinned testdata/golden/elixir/Degenerate.ex with the OLD emitter.
Merging as-is turned main red on TestGoldenSourceElixir, and it did:
reproduced before the fix, exactly as described. Rebased, regenerated,
re-pinned. The re-pin is a SOURCE re-pin only, and here is the proof rather
than the assertion: make update-goldens rewrote every wire golden under
SCHEMA_UPDATE_WIRE_GOLDENS=1 and every one came back byte-identical,
git diff origin/main -- testdata/wire/ shows nothing but the two new corpus
goldens, and all nine legs byte-compare degenerate.bin against the C++ pin
green.

2 — COMMIT THE CSVs. Fixed, by re-measuring. The original sweep's CSVs
were never written anywhere that survived, so there was nothing to commit —
which is precisely the finding. Both halves were therefore re-run as a fresh
paired A/B in one sitting after the rebase and committed. The stamps resolve
on origin: BEFORE 52691a0 (main), AFTER fef86ed (the last code commit on
this branch — commits after it add only this data and prose, so the stamp
reconstructs the measured tree). No dead SHA, and no need for the
"measurement predates the rebase" caveat.

3 — THE HEADLINE. Fixed, and the review was right about the mechanism.
1451% was the row that tripped §2.3's noise gate at 15.3% spread against the
15.0% spreadNoisy in bench/tools/relative.go:86 — the repo's own tool
would have excluded it — and because the headline uses the max statistic, a
noisier row carries a luckier max. It was also the most flattering of the
three figures then in hand. Rather than pick among them, the pair is
re-measured and committed: 3743% → 1457%, elixir spreads 0.22% and 0.38%,
nowhere near the gate. That lands on the review's own clean re-run (1456%).
The substance is unaffected.

4 — "none of them moves more than 2%". Corrected, and it was false in my
data too.
Five of seven non-cpp controls hold inside 1.3%; two do not
js +3.05% and java −9.28%, the two JIT legs, both with the noisier half in
BEFORE. The claim is gone and the real per-language figures are in the body.

On direction: the five tight controls all moved the same way (+0.3% to
+1.3%), so cpp was marginally faster in the AFTER sitting. That inflates
every other language's percentage, which means it runs against this
headline — 1457% is if anything slightly high and the improvement slightly
understated. Conservative, as the review argued.

5 — THE #174 FRAMING. Fixed. The body now says what the correction on
#174 says: the issue's hypothesis is not retired. Its ARITHMETIC half is
(lever D, ~0, with the literal-vs-dynamic-width micro at constant append
count proving why). Its CONSTRUCTION half is lever M — live, prototyped,
byte-identical output, 1.14x on the stats loop, and its micro (1 append of 12
segments 73.3 ns vs 12 appends of 1 at 103.6, 1.41x) is direct evidence
for exactly the mechanism #174 proposed.

The corpora, promoted

The review's Adv.schema and Adv2.schema were still on disk. They are
adopted as examples/Clauses.schema and examples/Joins.schema,
byte-compared by all nine legs against C++-pinned goldens, in the same shape
as Degenerate.schema.

The gap is real and structural, and the generated code confirms it directly.
For W13 ([..12]uint16 | max = 8191) the emitter produces a write clause
of four elements
([e1, e2, e3, e4 | rest], 52 bits — the whole budget)
and a read clause of three (rdw(data, bits_read, 39)). The two sides
choose different k, and both boundaries land mid-byte — which
Degenerate.schema cannot reach, because its standing property is that every
type in it is a whole number of bytes.

One correction to the review's framing, found while adopting them: the
single-stream design it used for the reference dump is the only one that
works. These shapes are not byte-aligned, so a shared stream does not equal a
concatenation — and the Elixir emitter returns each message as its own binary
from bit zero, so it has no way to write a shared stream at all. Each shape
is therefore written alone and flushed, and the golden is those concatenated.
As a cross-check, the pinned clauses.bin and joins.bin are byte-identical
to the review's own independent C++ dumps
(343 and 247 bytes), and all 90
shape widths in the harness were derived by hand and matched it.

Verification

make test green, nine backends. make generated-current — regeneration
zero-diff. Nothing under testdata/wire moved except clauses.bin and
joins.bin. corpus_id 6b213fbfa1a03a99 unchanged and bench/ otherwise
untouched. Shape gate clean, 19 exemptions, ledger unchanged from main.
mix format --check-formatted, modernize, gofmt, go vet all clean.
Adding units to examples/ moves the unit's protocol id in
testdata/golden/id.txt, exactly as #198 did when it added
Degenerate.schema.

🤖 Generated with Claude Code

@gafferongames
gafferongames merged commit d00cf49 into main Aug 31, 2026
8 checks passed
rowan-claude added a commit that referenced this pull request Aug 31, 2026
Main at d00cf49, all of the night's emitter work landed (#198 go, #202
elixir), box idle, nine legs, corpus_id 6b213fbfa1a03a99.

  c 98 / cpp 100 / rust 154 / java 157 / go 239 / cs 361 / dart 381 /
  js 486 / elixir 1498

This is ledger point one for #194: the first sitting where the canonical
shape, the data-driven harness, and the night's optimizations are all on
main together, measured with nothing else running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 1, 2026
* elixir: lever M — one binary construction per barrier, several registers as several segments

Lever A closed the merge group across the fields of a scope and K closed it
across the elements of a clause, but neither could close it across the
GROUP's own budget: a barrier-free run wider than 52 bits had to empty the
scratch to keep merging, and every emptying was its own bs_append. A
bench_mixed header is 82 static bytes and paid ten of them; an entity
element paid three.

The emptying and the append were never the same thing. A full group now
SPILLS — the scratch's whole bytes are bound to a fresh register and the
scratch keeps merging — and the append is deferred to the barrier, where
every register that spilled since the last one rides ONE binary construction
as its own segment. A spill is a bind and a shift where it was an append and
a shift, so the statement count is what it was and only the BIF calls move.

This is issue #174's own mechanism, and its micro (recorded on #202) is the
direct evidence: one append of twelve segments costs 73.3 ns where twelve
appends of one segment cost 103.6, 1.41x, and the same twelve appends with a
DYNAMIC segment width cost 103.7 — identical. The append is the expense. So
a dynamic-offset region (a loop helper's body, where scratch_bits is the
wire's) defers exactly the same way, each spill naming the width it computed.

The write clause's k follows from it. What a clause shares is one APPEND,
and the append is now the barrier's rather than the group's, so the 52-bit
budget no longer bounds how many elements a clause takes — only the unroll
cap of four does:

  stats  (18 bits): 2 elements per clause -> 4;  40 appends -> 20
  loadout (8 bits): 4 per clause, already the cap;      1 append
  entities (135 bits/element):     3 appends per element -> 1
  bench_mixed's header:                       10 appends -> 1

Static append sites in generated/bench/elixir: 79 -> 44.

The fixnum envelope is untouched, and that is the constraint this whole
round rests on. mergeW's guard is unchanged, so a group is still at most 52
bits and the scratch still at most 7 + 52 = 59; a register is filled by
exactly the spill that used to append it, so it holds what the scratch held
and nothing is ever grown. The registers stay live to the barrier instead of
dying at the append — the deepest run in the tree spends nine of the BEAM's
1024.

Bytes and behaviour are unchanged. The segment takes the low whole bytes, so
the residual riding above them is simply not in the segment and needs no
masking; the statements are the same ones in the same order over the same
values; every range, count, mask and length check is where it was; and the
raise TEXT is held by the display map exactly as under K — a widened clause
names slots e1..e4 and a message still reads "e.delta is above the wire
maximum". The 34 raise sites the diff ADDS are the new slots, and it removes
none.

The barrier's construction is emitted in mix format's own shape for a long
bitstring — broken after the `=`, `<<` at ind+2, continuations at ind+4,
each segment measured with the comma or the closing `>>` that follows it —
so `mix format --check-formatted` passes on the emitted text.

Wire bytes unmoved: no file under testdata/wire is touched, `make
update-goldens` rewrote every wire golden and every one came back
byte-identical, corpus_id 6b213fbfa1a03a99, the full variant round-trip gate
green, test/elixir and test/elixir-ludicrous OK — including Clauses.schema
and Joins.schema, whose mid-byte clause boundaries are exactly where a
grouped write can disagree with a grouped read. Every generated module
compiles under --warnings-as-errors, the nine-backend `make test` chain is
green, regeneration is zero-diff, shape-gate is clean with no new ledger
entries, and mix format, modernize, gofmt and go vet are clean. The seven
SOURCE goldens the emitter moved are re-pinned here.

MEASURED: in the sitting's A/B, recorded on the PR with the round's table.

* bench: commit both halves of the sitting lever M was measured in

A paired same-sitting --quick A/B on the fanless M2 Air, the two halves two
minutes thirty-nine seconds apart, corpus_id 6b213fbfa1a03a99 on every row.
BEFORE stamps main at d398ec4, AFTER stamps this branch at f96d749, so both
resolve on origin and reconstruct the tree that was measured.

  elixir, bench_mixed, family gen, max rates:
    write       383,329 -> 434,318 msg/s   1.133x  (spread 0.20% -> 0.25%)
    round_trip  248,616 -> 254,142 msg/s   1.022x  (spread 0.55% -> 0.50%)

  as a share of generated C++ (§2.9): 1413% -> 1409%

And the controls say to read the second row as nothing. The AFTER half was
globally the faster of the two — EVERY one of the eight non-elixir legs came
in up, +1.4% to +4.1%, cpp round_trip itself +1.9%. Elixir's write at +13.3%
is well clear of that; its round_trip at +2.2% is inside it, and net of cpp
the blend moves 0.3%.

That is not evidence the lever does nothing to round_trip. It is evidence
this instrument cannot resolve the question: one sweep is ONE sample per
arm, and elixir's round_trip rate varies far more BETWEEN runs in a sitting
than within one — across nine interleaved passes of the same main-branch
code it ran 219,874 to 247,320, a 12.5% range, against the 0.55% spread
inside a single run. A 6% effect does not survive that.

So the lever's own figure comes from the interleaved instrument levers K and
L used, which puts both arms in the same window and rotates their order.
It is recorded on the PR and reproduced in the lever's commit message; this
pair is committed for the cross-language context and for the honest record
of what the sweep did and did not show.

The sitting is labelled SHARED in both preambles, which it was: a sibling
session's C# round was live on the same box.

* elixir emitter: segW does not survive a join — the guard the #207 review asked for

The join reset cleared pendW/scZero/sbKnown/sbVal/scBound/sbBound but not
segW, relying on every arm ending in a flush. A future arm that legitimately
does not barrier would split the failure mode: registers spilled before the
join re-emit at the next barrier (silently — the wire-corruption class),
registers spilled inside an arm fail to compile. One line closes the class.
segN stays monotonic on purpose: resetting it renumbers registers after
every join, churning generated text for no safety. Regeneration zero-diff.

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