Skip to content

elixir round two: single-match-context reads, cf decode tables, arithmetic float32 rounding (1364% -> ~1301%) - #240

Merged
gafferongames merged 16 commits into
mainfrom
elixir-round2
Sep 1, 2026
Merged

elixir round two: single-match-context reads, cf decode tables, arithmetic float32 rounding (1364% -> ~1301%)#240
gafferongames merged 16 commits into
mainfrom
elixir-round2

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

elixir round two: the read side's turn — single-match-context loops, cf decode tables, arithmetic float32 rounding

Round two on the board home #174, resumed across a process death (the branch
carries the whole round; ROUND-LOG.md in the repo root is the milestone
ledger a future resume reads).

Read this first: the instrument was recalibrated after the adversarial
review, and it moved the claims.
An A/A null says the paired instrument
resolves read to ±0.3% and round_trip to ±1.2%, but the write path only to
±5%. Every read and round_trip number below clears its null by a wide margin
and stands. Every write-TIME number this round produced is inside the null
and has been withdrawn as unresolved.
The write path's one surviving claim
is allocation, which reproduces to 0.001%. Details in "The instrument"
below — that section is as much the deliverable as the code.

The fresh profile decided the round

eprof + tprof on the post-M floor, write and round_trip separately
(harness share measured at <2% on both paths — no harness-parity exclusion
applies; codec time is 98.4% of write, 99.0% of rt):

  • round_trip: r_bench_mixed_stats 29.6%, w_bench_mixed_stats 11.6%,
    fr 10.1%, write own 7.7%, read own 7.5%, w_entities 6.9%, cf_quantize 6.2%,
    r_entities 5.0%, cf_decode 4.5%, Enum.reverse 3.7%
  • write: w_stats 26.7%, write own 21.8%, w_entities 16.0%,
    cf_quantize 14.8%, fr 13.0%

The instrument, and what it can and cannot resolve

The paired instrument timed arm A then arm B every round, in that fixed
order, with no rotation. It now alternates arm order by round parity.

The A/A null — both arms the same module, a pure rename, wire gate 64/64 —
is the negative control that says whether a ratio means anything. Run on all
three paths at 400k iters × 7 rounds, with rotation in place:

path A/A null (B/A across invocations) band verdict
read 0.998, 1.003 ±0.3% trustworthy
round_trip 1.012, 0.995 ±1.2% trustworthy well above that
write 1.045, 0.959, 1.057 ±5% not trustworthy at lever scale

Rotation did not rescue the write path, and the reason matters: the bias is
not a slot effect. Within one invocation, every round of one arm is faster
than every round of the other, and which arm that is flips between
invocations — a whole-VM layout/GC-regime effect that the max statistic
cannot average out. The write null band is the size of every write lever
this round decided.

Allocation is the axis that survives. Across every null run the two arms'
words reclaimed agree to under 0.001% on write (794,760,324 vs 794,763,984)
and under 0.02% on read and rt. Word counts are deterministic here; times on
the write path are not.

Also standing from earlier in the round: a pair run that measures write then
rt in ONE VM poisons the rt comparison (heap state from the write loop: rt
read 4500–4800ns vs 3826 clean, matching certification's 262k msg/s). Lever
decisions use single-path pair runs.

Levers landed

Each prototyped on a hand-modified bench file with the paired instrument
before emitter work; wire gate 64/64 byte-identical at every step.

  1. fr, arithmetic float32 rounding (2e71846, inherited from the dead
    worker and re-adjudicated)
    : Veltkamp/Dekker (C = 2^29+1) on the normal
    range, construct/match kept bit-for-bit outside it. KEPT, on
    allocation
    : write-loop GC words reclaimed 1021.8M → 601.3M, −41%
    the JIT keeps the splitting temporaries unboxed, so the arithmetic path
    allocates one float where construct/match allocated a binary plus a float.
    Supporting: micros cf_decode 89.2→62.3ns (1.43x), cf_quantize 108.9→83.4
    (1.31x).
    Withdrawn: the full-bench write timing (1.048x, faster in 6/7
    invocations) is inside the ±5% write null and is unresolved by the
    instrument
    — neither evidence for the lever nor against it. rt and read
    were reported neutral and remain so, now with nulls that give "neutral" a
    meaning.

  2. Single-match-context array reads (lever B): for static elements ≤ 20
    bits, an aligning entry + body-recursive clause consume the stream through
    ONE live match context (+bin_opt_info: "match context reused" at every
    site) — m elements per iteration with m·eb ≡ 0 (mod 8) keep the carry
    width constant, fields cut out of chained fixnum registers (≤ 47 bits),
    and the list builds in order on the unwind, so the fast portion never pays
    Enum.reverse. Entry, tail, and truncated buffers fall back to the scalar
    clauses — same values, same refusals. Paired: read 1.111x, rt 1.079x
    best-vs-best
    (prototype 1.113x/1.046x), write neutral. Read clears its
    null by ~40x, rt by ~6x; the adversarial reviewer reproduced both
    independently.

  3. cf decode tables (lever C): a compressed-float read whose quantum
    count is ≤ 1024 decodes through elem/2 on a module-attribute tuple
    computed at generated-module compile time by literally the cf_decode
    chain (full fr, fr_slow, nonfinite mapping) — equality by construction,
    verified === over the whole index domain. Paired: read 1.142x, rt
    1.070x
    , write untouched. cf_decode stays the shipped path past 1024
    (RealWorld's 2000/20000-quantum fields).

Refusals, with numbers

  • Hypothesis A, iolist emission: writers building [data | <<segs>>]
    with one flatten. Re-measured with the rotating instrument at 400k × 7:
    write 1.134x SLOWER (2596.3→2943.7ns), rt 1.076x slower, write-loop
    words 794.6M → 1019.4M (1.28x the allocation). The refusal stands on
    the timing — 13.4% is well outside the ±5% band, and the direction
    reproduced in every arm order tried. Correction: the first write-up said
    words 267M → 582M (2.2x); that figure does not reproduce. Direction
    unchanged, magnitude overstated.
    Mechanism: bs_append's in-place writable-binary growth already amortizes
    the copy; the iolist pays a heap binary + cons per barrier AND the flatten.
    Lever M's multi-segment barrier append stands.
  • Write unroll 4→8 (1.4% against), combined range check (0.6%
    against), cf_quantize per-declaration specialization (0.2%): all three
    stand refused, and all three are write-path deltas deep inside the ±5%
    null — none was ever resolved by the instrument either. The null makes
    these more secure, not less: each was refused on a number pointing
    slightly against the candidate, and a bias that can manufacture a few
    percent in either direction cannot manufacture evidence for refusing
    the worst it can do is hide a small win, and a win this instrument cannot
    see is not worth the complexity. Each also has a mechanism behind it (the
    fixnum cap bounds wider grouping, elixir #174: the aggressive round — 3743% to 1457% of generated C/C++ #202's law reconfirmed; predicted
    branches are free; literal loading is free), which is what the decision
    actually rests on.
  • Direct float segments in barrier appends: refused by reasoning — the
    {:nonfinite, bits} write form makes every float segment conditional,
    breaking the one-construction-per-barrier shape.

Certification pair (quick legs, one sitting, --out scratch)

  • before (clean worktree at origin/main b63c22e): write 427,700 max /
    426,651 median msg/s, round_trip 253,090 max / 242,109 median (warm
    sitting; spreads 2.3% / 4.7%)
  • after (branch head): write 449,568 max / 449,244 median, round_trip
    275,088 max / 274,908 median (spreads 0.1%)
  • in-sitting round_trip: 1.087x on the §2.9 max statistic (median
    1.135x — the before-leg rt max is a single cool run among throttled ones).
    This is the gain that stands, corroborated by the paired instrument's rt
    measurements against a ±1.2% null and by the reviewer's independent re-run.
  • Withdrawn: the in-sitting write figure (1.051x max, 1.053x median). The
    certification runner is not the pair instrument, but the before leg's own
    write spread was 2.3% and the pair instrument's write band is ±5%; nothing
    measured this round can separate a 5% write move from noise. No
    write-time improvement is claimed.
    The branch's write-path claim is
    allocation: −41% words on the fr lever.
  • against the standing sitting3 ledger point (elixir rt max 262,385, cpp
    rt max 3,579,797 → 1364%): after rt max 275,088 projects elixir to
    ~1301% of generated C++. That projection crosses sittings — a full
    certification sitting is the honest re-pin, and this PR deliberately does
    not write bench/results/. (The same sitting3 row's rt median is
    262,329, which is the figure the ROUND-LOG's RESUME line quotes; median and
    max off one row, not a disagreement.)
  • CSV paths (session scratch): prof/cert-before-quick.csv,
    prof/cert-after-quick.csv

Review repairs in this branch

  • cf_decode/4 was emitted dead. needCf gated cf_quantize/4 and
    cf_decode/4 together and the write path set it, so a module whose every
    compressed-float read decoded through a table still carried cf_decode/4 —
    and the Elixir compiler said so ("function cf_decode/4 is unused",
    Bench.ex:3509). Split into needCfQ/needCfD, both backed by fr/1 +
    fr_slow/1. Bench.ex is the only module affected; 16 lines removed, wire
    bytes untouched.
  • The instrument's missing rotation — fixed, null published above, and
    every write-time claim in the PR body and ROUND-LOG restated.
  • The iolist allocation figure — re-measured and corrected.
  • Two emitter invariants written down: g.feed carries no save/restore
    the way segW carries its reset at a join, and the reason is an exclusion —
    plainFastElement admits only scalars and structs of scalars, so nothing
    that can re-enter read emission runs while a feed is live; widening that
    gate means giving feed the save/restore first. And the fast clause's body
    recursion holds ceil(n/m) live frames where the scalar loop held one,
    bounded by ArrayBound — harmless at these scales, but a new memory
    characteristic.

Wire held

  • 64-variant gate byte-identical through every prototype and both landed
    emitter forms; make test green (nine legs); regeneration deterministic;
    goldens re-pinned (text only — wire bytes untouched); mix format clean;
    gofmt/vet/modernize clean; shape-gate and generated-current green
  • zero warnings from every generated Elixir module, each required
    standalone across the whole generated tree, not just the legs make test
    compiles

🤖 Generated with Claude Code

gafferongames and others added 14 commits September 1, 2026 20:03
The compressed-float helpers spent the profile's largest untouched share
constructing and matching a 4-byte binary per rounding step - 10 heap
words and ~16ns per call, 15 calls per bench_mixed write and 27 per
round_trip. For a double in the float32 normal range that rounds finite
(magnitude in [2^-126, 2^128 - 2^103)), y = value * C; y - (y - value)
with C = 2^29 + 1 is the same round-to-nearest-even 24-bit result in
three flops with no allocation beyond the result. Outside the guard -
subnormal grid, overflow to the infinity atoms, signed zero - fr_slow
keeps the construct/match semantics bit-for-bit.

Equivalence: 0 mismatches under === across 30M random finite doubles,
12M tie-targeted cases (midpoints between adjacent float32s and their
ulp neighbors), and dense sweeps of both guard corners, the inf tie
point 2^128 - 2^103 and the normal floor 2^-126, plus signed-zero
neighborhoods. Paired interleaved micros: the 3x-quantize region 338.8
-> 237.5 ns (1.43x), one cf_decode 97.8 -> 65.9 ns (1.48x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/rt)

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…46x)

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

The scalar read loop re-enters the binary through rd/rdw on every window -
a fresh bs_start_match + byte skip + extract per call, ~29.6% of round_trip
on bench_mixed (the stats loop alone). When the element width is static and
at most 20 bits, the loop now consumes the stream through ONE live match
context: an aligning entry splits bits_read into byte position and phase,
and a body-recursive clause matches whole-byte segments per iteration
(m elements, m*eb = 0 mod 8, so the carry width stays constant), cuts
fields out of chained fixnum registers (never past 59 bits), and builds
the element list in order on the unwind - the fast portion pays no
Enum.reverse. Entry, tail, and truncated buffers fall back to the scalar
clauses: same values, same refusals. +bin_opt_info confirms 'match context
reused' at every fast-loop site.

Paired interleaved on bench_mixed (fr-lever head as baseline): read
1746.8 -> 1571.7 ns/msg (1.111x), round_trip 4173.0 -> 3867.8 ns best
(1.079x), write neutral. Wire gate: 64/64 variants byte-identical; elixir
and elixir-ludicrous test legs green; goldens re-pinned (text only).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The read side of a compressed float is a total function of a range-checked
small integer: for declarations with at most 1024 quanta, replace the
four-step float32 chain (cf_decode + 4 fr calls, ~65ns) with elem/2 on a
module-attribute tuple (~8ns). The table is computed when the generated
module compiles, by literally the arithmetic cf_decode runs - fr's fast
path, fr_slow's construct/match refusal, the non-finite mapping - so
equality with the shipped chain is by construction; verified === over the
full index domain and byte-identical on the 64-variant wire gate.

Paired interleaved on bench_mixed: read 1652.6 -> 1446.6 ns/msg (1.142x),
round_trip 1.070x, write untouched. cf_decode remains the shipped path for
larger declarations (RealWorld's 20000- and 2000-quantum fields).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
needCf gated cf_quantize/4 and cf_decode/4 together, and the write path set
it — so a module whose every compressed-float read decodes through a
compile-time table still carried cf_decode/4, dead, and the Elixir compiler
said so ("function cf_decode/4 is unused" at Bench.ex:3509).

Split the need into halves: needCfQ for cf_clamp01/1 + cf_quantize/5 from
the write path, needCfD for cf_decode/4 from a read that falls past
cfTableMax. Either half emits fr/1 + fr_slow/1, which back both.

Bench.ex is the only module affected; every generated Elixir module now
compiles with zero warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… frame cost

g.feed carries no save/restore, unlike segW's reset at a join, and the reason
is an exclusion rather than luck: plainFastElement admits only scalars and
structs of scalars, so nothing that can re-enter read emission runs while a
feed is live. State the law at the feed field and at the walker that enforces
it — the two places a change would widen the gate.

The fast clause's body recursion also holds ceil(n/m) live frames where the
scalar loop held one; ArrayBound bounds it, but it is a new memory
characteristic and belongs in the emitter comment.

Comments only — generated output is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ithdrawn

The pair instrument timed arm A then arm B every round with no rotation. It
now alternates by round parity — and the A/A null run behind that fix says
rotation is not enough on the write path: read is trustworthy to +/-0.3%, rt
to +/-1.2%, and write only to +/-5%, with the bias falling on a whole
invocation rather than a slot and flipping arm between invocations.

Every write-time claim in this round is inside that band. They are withdrawn
as UNRESOLVED BY THE INSTRUMENT — the fr lever's 1.048x and the certification
pair's 1.051x both. The fr lever stays KEPT on allocation instead: write words
reclaimed 1021.8M -> 601.3M, -41%, on an axis the null reproduces to 0.001%.
The read (1.11-1.14x) and rt (1.07-1.09x) gains stand; they clear their own
nulls by ~40x and ~6x.

The refusals stand unchanged, and the null makes them more secure: each was
refused on a number pointing against the candidate, and the bias can hide a
small win but cannot manufacture one to refuse.

Also re-measured the iolist hypothesis with the fixed instrument at 400k x 7:
words 794.6M -> 1019.4M is 1.28x, not the 2.2x first written down. The
refusal stands on the timing (1.134x slower), which clears the band.

And annotated the sitting-3 median (262329) / max (262385) pair that read as
a contradiction between the RESUME and certification lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gafferongames
gafferongames marked this pull request as ready for review September 1, 2026 12:13
@gafferongames

Copy link
Copy Markdown
Contributor Author

Adversarially reviewed across two passes. The levers' proofs held to an unusual bar (~98M bit-exact fr comparisons including the exhaustive 67.2M tie sweep; 8.5M decode differentials with unaligned phases instrumented; whole-domain cf-table equality; five negative controls caught). The review's own A/A null found the paired instrument's write-path bias (±5%, whole-VM layout — rotation added, bias persists, so ALL write-time claims are withdrawn; fr is kept on the allocation axis, −41% write words, where the null shows 5ppm agreement). Read 1.11–1.14x and rt 1.07–1.09x clear their nulls by ~40x and ~6x. The iolist refusal reproduced in both arm orders and its allocation figure was re-measured and corrected. The unused cf_decode/4 emission is fixed with zero warnings tree-wide. Verdict: MERGE.

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