elixir round two: single-match-context reads, cf decode tables, arithmetic float32 rounding (1364% -> ~1301%) - #240
Conversation
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>
|
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. |
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):
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%
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:
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.
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.
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 everysite) — 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.
cf decode tables (lever C): a compressed-float read whose quantum
count is ≤ 1024 decodes through
elem/2on a module-attribute tuplecomputed 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, rt1.070x, write untouched. cf_decode stays the shipped path past 1024
(RealWorld's 2000/20000-quantum fields).
Refusals, with numbers
[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.
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.
{:nonfinite, bits}write form makes every float segment conditional,breaking the one-construction-per-barrier shape.
Certification pair (quick legs, one sitting, --out scratch)
426,651 median msg/s, round_trip 253,090 max / 242,109 median (warm
sitting; spreads 2.3% / 4.7%)
275,088 max / 274,908 median (spreads 0.1%)
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.
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.
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.)
prof/cert-after-quick.csv
Review repairs in this branch
needCfgated cf_quantize/4 andcf_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.
every write-time claim in the PR body and ROUND-LOG restated.
g.feedcarries no save/restorethe way segW carries its reset at a join, and the reason is an exclusion —
plainFastElementadmits only scalars and structs of scalars, so nothingthat 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
emitter forms;
make testgreen (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
standalone across the whole generated tree, not just the legs
make testcompiles
🤖 Generated with Claude Code