Skip to content

js: the write round — flat writes 1.89x, round_trip 1.47x; 461% -> 313% of generated C++ - #237

Merged
gafferongames merged 7 commits into
mainfrom
js-writes-round
Sep 1, 2026
Merged

js: the write round — flat writes 1.89x, round_trip 1.47x; 461% -> 313% of generated C++#237
gafferongames merged 7 commits into
mainfrom
js-writes-round

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Laggard round for generated JavaScript (#173): the flat tier's write side dragged the blended row — reads were already native-class. This is the write-side profile and the levers it justified.

Headline (--only js --quick, both halves in one sitting minutes apart, bench_mixed, family gen, codec flat, checks=contract, corpus 6b213fbfa1a03a99, max rates per §2.2, spreads ≤ 0.9%):

path before max after max
write 1,353,222 2,562,534 1.89x
round_trip 764,875 1,121,647 1.47x

Against the arm64 sitting-2 certification reference at the same corpus_id (cpp round_trip max 3,512,100 — the LOCKed reference, untouched by this round): 459.2% → 313.1% on this pair's own before leg (the ledger's standing 461% reproduced within 0.3%) of generated C++ on the canonical round_trip blend; on write 553% → 292%. Derived read moved 1.76 → 2.00 M msg/s as a side effect of the wide-lane lever — the read side was not traded away. The measurement CSVs live outside bench/results/ (scratch, per the round's rules); the numbers above are their rows verbatim.

1. MEASURE FIRST — where write time actually was

node --prof over the conformant quick leg (BENCH-STANDARD §2.8; node 26.7, M2, production mode):

  • 51% of total ticks inside writeBenchMixedFlatProduction itself — straight-line merge arithmetic, no calls;
  • 27% in V8 C++ runtime calls made from the hot functions (macOS symbolication renders them as one bogus nearest-symbol; the callers and the code say what they are: BigInt traffic) — the issue's prime suspect, confirmed;
  • GC 1.5% — BigInt intermediates.

The per-field merge carried two data-dependent branches (a s < 32 lane split and the 64-bit flush) plus an s > 0 guard; every 128-bit field paid ~7 allocating BigInt steps; every &/>>/Number()/truncating-asUintN BigInt op allocates and calls the runtime.

The micro that ranked the primitives (interleaved arms, node 26, M2): DataView BigInt scratch stores consume a BigInt into plain memory without allocatingsetBigUint64+2×getUint32 runs 674 M ops/s where the & 0xffffffffn / >> 32n / Number() chain runs 29 M. That asymmetry is the whole write-side BigInt story.

2. THE LEVERS, each landed as one commit with its paired numbers

# lever micro leg (write / round_trip)
L1 Single-word 32-bit merge replaces the two-lane 64-bit staging pair: one shift-or, one literal add, one flush branch storing one word 1.44x on a mixed-width group 1.35→1.49 (+10%) / 0.77→0.82
L2/3 Write-side BigInt lane discipline: every wide value reaches the wire through SC.setBigUint64 (ToBigUint64 IS the asUintN(64) wrap; shift-then-wrap equals the asUintN(128) high half, so no reduction ever allocates ahead of the store); flags ≤32 and the >32-storage int32-range truncation (SC.getInt32asIntN(32)) ride the same scratch 128-bit split 5.6x; sub-32 truncation 6.1x 1.49→2.22 (+49%) / 0.82→1.01 (+23%)
L4 Wide (>64-bit) read assembly through the scratch — two number-domain halves, one shift-or, instead of four BigInt(v) constructions chained through three shift-ors 3.2x on the 128-bit assembly — / 1.01→1.06; derived read 1.86→2.07
L5 Chunked write merges — the family's grouping lever (go #198's runs, cs #208's word codec): adjacent ≤32-bit pure-expression fields pack into ONE staged word with literal relative shifts, which are static even where the absolute cursor is dynamic, so the lever reaches the loop bodies (stats: 2 merges/element → 1; entity: ~14 → ~7) 2.22→2.48 (+12%) / 1.06→1.11
L6 String/bytes bodies at 4 bytes per merge with a byte tail — measured first as a hand-patched ceiling on the generated leg, then landed +3.6% ceiling 2.48→2.55 / 1.11→1.12

Refused by measurement

  • Static cursor constant-folding for straight-line runs (the header is mostly aligned 32-bit stores — the classic AOT lever): hand-patched its ideal form into the generated header (13 merges → 11 direct word stores at literal offsets, golden gate still green) and measured 2.48 → 2.47 M msg/s: zero. V8 predicts the dynamic form's branches; the bottleneck is elsewhere. Refused — the emitter complexity would buy nothing.
  • Chunking the flags-through-scratch piece via dedicated temps (Damage breaks an entity chunk): bounded at ~1–2%, not taken.
  • Unrolling small fixed scalar arrays (cs: the codec pass — union batch cores, scoped batches, and the flat word codec (write 1.59x, round_trip 1.60x; 361% -> 219% of generated C++) #208's lever G): not taken, same reasoning as there.

Named follow-on (land-and-expand, not this round)

Read-side window chunking: the same relative-shift argument applies to reads — one 64-bit window load can feed several extractions where today every field reloads two words. The issue rules reads out of this round ("the read side needs nothing"); it is the next pass if the blend is chased further.

3. Runtime-side lever, reported not implemented

serialize.js's own BitWriter.writeBits (src/bitpacker.js) carries the exact two-lane 64-bit staging form this round retired in the flat tier — L1's micro (1.44x on a mixed-width group) prices a single-word 32-bit staging rewrite there. It would move the js bits family rows (the runtime bitpacker), not the flat gen rows, and it touches the four fused copies the file itself warns about plus flush/reset invariants — a serialize.js PR, not a schema one.

4. Discipline

  • Wire held: full nine-language make test green; testdata/wire untouched (the re-pin commit touches SOURCE goldens only); the bench leg's §1.5 golden + 64-variant decode→re-encode gates pass at corpus_id 6b213fbfa1a03a99; degenerate corpus (The bench corpus cannot see fixed scalar arrays, bare float units, or run-cap-split nested structs #203) included, both NODE_ENV modes.
  • Byte-equivalence argument for L1/L5, stated: an LSB-first packer into consecutive little-endian 32-bit words emits the identical byte stream to the 64-bit pair form, and merging a concatenation of fields equals merging them in sequence; the gates then prove it on every pinned instance and variant.
  • Negative control, run rather than assumed: with the chunker's shift composition broken by one bit, test/js produces 100 failure lines and exits non-zero; restored, green, regenerated tree byte-identical.
  • Chunk-boundary law in the emitter: chunks never cross a scope boundary or a statement-form merge (flush at every loop, branch, switch, align, scratch field); a temp-based piece flushes around itself so no piece outlives its inputs — the go: the flat word codec — 712% to 248% of generated C/C++ #198 fallback-class hazard designed out rather than tested away.
  • Coverage of the new paths is pinned: chunked emission appears in every flat module of the conformance corpus; the scratch wide lanes are exercised by bench_mixed (64/48/128-bit fields) and the ludicrous suite (128-bit family), all byte-compared against C++-pinned wire.
  • One commit per lever, paired numbers per commit; refusals measured on the same instrument.
  • Quality gates: gofmt clean, go vet clean, modernize clean, shape-gate clean, generated tree current.
  • bench/LOCK untouched: nothing under internal/codegen/{c,cpp} or generated/{c,cpp,c-ludicrous} moved.

🤖 Generated with Claude Code

Certification pair committed under bench/results/2026-09-01-jswrites-{before,after}-quick-arm64-macbook.csv — spreads 0.22–0.82%, recomputable forever. Adversarially reviewed: differential oracle (five seeds × ~600 rounds × four corpora, checked+production modes, cross read-back) zero divergence; every dropped-reduction equivalence claim verified with failed falsifications; negative control reproduced. Verdict: MERGE.

gafferongames and others added 7 commits September 1, 2026 12:45
… pair

Profile (node --prof, bench_mixed --quick): 51% of ticks inside
writeBenchMixedFlatProduction itself, 27% in V8 runtime calls it makes.
The per-field merge carried two data-dependent branches (the s<32 lane
split and the 64-bit flush) plus an s>0 guard; the single 32-bit staging
word does one shift-or, one literal add, and one flush branch storing one
word. Micro (interleaved, node 26, M2): 1.44x on a mixed-width group of
ten. On the leg (quick, production): write 1.35 -> 1.49 M msg/s (+10%),
round_trip 0.77 -> 0.82 (+6.5%). Wire bytes unchanged: an LSB-first
packer into consecutive little-endian 32-bit words emits the identical
byte stream; golden + 64-variant re-encode gates and test/js (both
modes, degenerate corpus included) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sumes, nothing allocates

The profile's 27% V8-runtime bucket was BigInt: every BigInt '&', '>>',
asUintN-that-truncates and Number() on the write path allocates a BigInt
and calls the runtime, where a DataView setBigUint64 consumes the value
into plain memory without allocating. Micro (node 26, M2): 128-bit split
15.0 -> 84.3 M ops/s via two scratch stores + one shift (5.6x); sub-32
truncation 116.9 -> 718.9 M ops/s via scratch wrap (6.1x); the existing
<=64 scratch form measured already optimal (674 M ops/s) and keeps its
shape minus the redundant asUintN(64) wrap (ToBigUint64 IS that wrap;
for the >64 half, shift-then-wrap equals the asUintN(128) high half).

Reworked: emitWriteWideOffset takes the raw BigInt offset and routes all
four width classes through the scratch; flags <=32 wire mask through the
scratch instead of Number(asUintN); the >32-storage int32-range truncation
becomes SC.getInt32 (ToBigUint64 wrap + signed lane == asIntN(32) for
every BigInt). wideOffsetWidth deleted — nothing reduces ahead of the
store anymore. Checked-mode guards unchanged.

Leg (quick, production): write 1.49 -> 2.22 M msg/s (+49%), round_trip
0.82 -> 1.01 (+23%). Golden + 64-variant gates and test/js{,-ludicrous}
green in both modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s, one shift-or

The read twin of the write-side lane discipline: the 96/128-bit
assembly built its BigInt from four BigInt(v) constructions chained
with three shift-ors (seven allocating steps); it now assembles each
64-bit half in the number domain through SC.setUint32 pairs and joins
two getBigUint64 loads with a single shift-or. Micro: 12.5 -> 40.3
M ops/s on the 128-bit assembly (3.2x, node 26, M2). Leg (quick,
production): round_trip 1.01 -> 1.06 M msg/s, derived read
1.86 -> 2.07; write untouched within spread. Gates green both modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nto one staged word

The family's grouping lever (go #198's runs, cs #208's word codec)
in the form this backend can hold: adjacent <=32-bit fields whose
value expressions are pure (field loads, hoisted element refs,
literals — never a mutable temp) pack into one 'v' with literal
RELATIVE shifts, then merge once. Relative offsets inside a chunk
are static even where the absolute cursor is dynamic, so the lever
reaches the loop bodies carrying most of the corpus wire (the
80-element stat array drops from two merges per element to one; the
entity body from ~14 to ~6). Chunks never cross a scope boundary or
a statement-form merge — wire order is emission order, enforced by
chunkFlush at every loop, branch, switch, align, and scratch-based
field; a temp-based piece (the >32-storage int32-range 'n') flushes
around itself so no piece outlives its inputs.

Leg (quick, production): write 2.22 -> 2.48 M msg/s (+12%),
round_trip 1.06 -> 1.11 (+5%). Wire identical: golden + 64-variant
gates, test/js{,-ludicrous} both modes, degenerate corpus — green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured first as a hand-patched ceiling on the generated leg (+3.6%
write), then landed in the emitter: the byte-copy loop for string(N)/
bytes(N) takes 4 bytes per 32-bit merge with a byte-at-a-time tail.
The counter lives in a brace scope so two byte fields can share a
nesting depth. Leg (quick, production): write 2.48 -> 2.55 M msg/s,
round_trip 1.11 -> 1.12. Gates green both modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Source goldens only — testdata/wire is untouched by this round (the
stop-the-line invariant), and the full nine-language make test is
green against the unchanged wire pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dent: the pair rides with the round)

before 1dba683 (main) / after 3b3e6f6 (the round), one sitting 39s
apart, spreads 0.22-0.82%. The before leg reproduces the standing
ledger row within 0.3% — the pair's validity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gafferongames
gafferongames marked this pull request as ready for review September 1, 2026 03:19
@gafferongames
gafferongames merged commit e291d86 into main Sep 1, 2026
8 checks passed
@gafferongames
gafferongames deleted the js-writes-round branch September 1, 2026 03:22
gafferongames added a commit that referenced this pull request Sep 1, 2026
…k-bytes path; 363% -> 221% of generated C++ (#238)

* dart: chunked write merges — 64-bit lanes, single-merge wide fields

Adjacent constant-width pure-expression fields pack into one staged word
with literal relative shifts (the family lever, #198/#208/#237), capped at
Dart's native 64-bit lane where the js number domain held it to 32. Wide
33..64-bit fields merge once instead of twice — their wire bits are
contiguous, so the bytes are identical by the concatenation argument.

Leg (quick, arm64 M2): write 1.894 -> 2.380 M msg/s (1.26x),
round_trip 0.974 -> 1.088 (1.12x). Golden gate + 64-variant re-encode
green; dart conformance suites green with asserts on.

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

* dart: bulk-bytes write path — the #165 lead

String/bytes bodies and bare [N]uint8 arrays stop paying one merge per
byte: static runs of 8 or fewer bytes queue as chunk pieces (joining
neighboring fields in one merge), longer and dynamic runs take 4 bytes
per merge with a byte tail. No alignment requirement: LSB-first merging
of the concatenation equals merging the bytes in sequence at any bit
position, so the fix reaches the unaligned loadout too.

Micro (AOT, M2): 4-byte grouping 2.33x, per-byte baseline 1.00x.
Leg (quick): write 2.380 -> 2.499 M msg/s (+5.0%), round_trip
1.088 -> 1.104 (+1.4%) — bulk is only 3.65% of bench_mixed's wire by
construction; the lever is for the real packets the issue names.

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

* dart: chunked read windows — one load and one tail branch per run, not per field

The reader kept reloading its 64-bit window at every field. A load now
folds the byte-interior shift in and guarantees 57 valid bits (in the
tail arm, every bounds-proven remaining bit), so consecutive
constant-width fields extract at literal relative shifts from one
window through a compile-time ledger. Wide 33..57-bit fields extract in
one masked read — their groups are contiguous on the wire. Short bare
byte arrays unroll into the same windows (#165's read twin). The ledger
invalidates at every dynamic bitsRead move and scope boundary.

Micro (AOT, M2): chunked window 1.35x over per-field reload.
Leg (quick): round_trip 1.104 -> 1.367 M msg/s (+23.8%), write flat;
derived read 2.01 -> 3.03 M msg/s.

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

* dart: grouped counted loops — k elements share one lane per iteration

A counted array of static-width elements unrolls k = lane/elemBits
elements per iteration (64-bit chunk lane on the write side, 57-bit
window on the read side), with a remainder loop. The chunk accumulator
and window ledger span the unrolled elements, so short elements merge
together and extract from one load — bench_mixed's 80-element stat
array drops from one merge and one window load per element to one per
three. Hoisted element refs get unique names (e0, e0g1, e0g2) so the
unroll shares one scope; correctness rides the same ledger/flush
machinery as straight-line code.

Bounded first by a hand-patched generated leg (+10% round_trip on the
read side alone). Leg (quick): write 2.493 -> 2.856 M msg/s (+14.6%),
round_trip 1.367 -> 1.592 (+16.5%).

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

* dart: re-pin source goldens for the chunked emission

Wire goldens untouched — testdata/wire byte pins hold in every leg; the
re-pin is the SOURCE form only (SPEC §3.1's deliberate-change path).

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

* dart: range-over-int in the emitter's unroll loops (modernize)

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

* bench/results: the dart round's certification pair

before 1,900,191/972,818 (reproducing the standing ledger row within
0.6%), after 2,857,586/1,591,336; spreads 0.07-2.28%; one sitting,
corpus 6b213fbfa1a03a99.

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

---------

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

2 participants