Skip to content

C++ emitter: bulk-bytes path for statically byte-aligned [N]uint8 arrays - #7

Merged
rowan-claude merged 1 commit into
mainfrom
optimize-bulk-bytes
Aug 7, 2026
Merged

C++ emitter: bulk-bytes path for statically byte-aligned [N]uint8 arrays#7
rowan-claude merged 1 commit into
mainfrom
optimize-bulk-bytes

Conversation

@rowan-claude

Copy link
Copy Markdown
Collaborator

The C++ emitter now takes serialize's bulk-bytes path (align + memcpy) for fixed [N]uint8 arrays wherever byte alignment is statically provable, instead of emitting a per-byte write_bits(...,8) / read_bits(8) loop. Glenn's approach, verbatim: "Trick I have used in the past is to align so I can just memcpy in when writing bytes and strings."

The conviction

Serialize PR #27's EPYC profiling put ~58% of WriteTestData and ~57% of ReadTestData self-cycles in the generated 17x per-byte loop over TestData.fixed_bytes. The runtime already had the right primitive — SerializeBytes aligns then memcpys — but the emitter never used it for fixed byte arrays. (Strings and bytes(N) already rode write_bytes/read_bytes; the fixed array was the one construct still going byte-at-a-time.)

The alignment finding, and the wire decision

SerializeBytes aligns to a byte boundary before the bulk copy, so emitting it blindly would CHANGE THE WIRE at any unaligned site. The decision here is wire-identity restricted to where it is provable:

  • SPEC §4.3 pins bit i of the stream to byte i/8, LSB-first — so at a byte boundary, N consecutive unranged 8-bit writes produce exactly the N array bytes in order.
  • SerializeBytes' internal align is ZERO bits when the stream is already aligned (write: zero pad bits emitted; read: zero pad bits consumed, nothing to reject).
  • Therefore, at a statically byte-aligned position, the bulk path is byte-identical to the per-byte loop. Everywhere else the per-byte loop stays. No wire byte moves, anywhere, by construction — and no SPEC change is needed.

The proof is ir.AlignedFixedByteArrays: a conservative bit-position-mod-8 walk over a struct's items. Entry is unknown (a type may embed at any bit offset); align and the string/bytes(N) wires (§4.7: length, align, payload of whole bytes) force known boundaries; fixed-width fields advance the position; branches must agree on exit; nested structs are walked for their exit; counted arrays are position-tracked but not yet marked. Unknown never optimizes — a wrong "unknown" costs speed, never wire bytes.

In the corpus this marks exactly one field: TestData.fixed_bytes [17]uint8, declared immediately after an explicit align. Generated delta (both message modes):

-    for ( int32_t i = 0; i < 17; i++ )
-    {
-        write_bits( stream, value.fixed_bytes[i], 8 );
-    }
+    write_bytes( stream, value.fixed_bytes, 17 ); // byte-aligned [N]uint8 — bulk copy, wire-identical to the per-byte loop

and the matching read_bytes on the read side.

Verification

make SERIALIZE=../serialize test — all green:

  • schema_test OK — includes the wire goldens byte-for-byte (testdata/wire/*.bin untouched by this PR; git diff testdata/wire is empty) and the wire oracle (generated bytes == hand-written classic serialize bytes)
  • schema_test_variant OK, schema_test_random OK (2000 round-trip iterations)
  • go / rust / cs conformance runners OK (cross-language wire agreement over the same goldens)
  • go test ./... OK — source goldens re-pinned for the two Wire.h files (source pins may move; wire pins may not, and did not)
  • New: internal/ir/align_test.go — an 18-type expectation table for the analysis: marked after align / after string / after bytes / whole-byte gaps / agreeing branches / aligned nested exits / byte-wide count prefixes; NOT marked at unknown entry, off-boundary, ranged elements, counted arrays, disagreeing branches, off-boundary or entry-dependent nested exits. Verified the test fails when an expectation is flipped.

The bench runner's own gate also passed: it refuses to produce numbers unless the binary's output matches the pinned wire goldens — the new binary benches.

Measurement (M2, tonight — paired, median-of-7, interleaved runs x3)

Apple M2, Release (-O3 -DNDEBUG -DSERIALIZE_RELEASE, apple clang 21), serialize @ 6ad407d, bench-harness v2 merged locally for measurement only (not in this diff). Predictions banked before running: write ~2.0x, read ~2.1x, everything else flat.

bench path base (msgs/s, 3 runs) new (msgs/s, 3 runs) delta
testdata write 16.96M / 17.37M / 17.43M 17.90M / 18.35M / 18.36M +5.3..5.6%
testdata read 15.12M / 15.47M / 14.53M 25.70M / 26.26M / 26.21M +70..80%
chat write / read 109.1M / 137.4M 107.3M / 136.0M -1.7% / -1.0% (noise)
all other rows within +/-5%

message_batch moved -7%/+11% in one pair but swings +/-20% between runs of the SAME binary (spreads up to 16.6%) and its generated code is byte-identical in both builds (Messages.h unchanged; the batch carries no TestData) — code-layout noise, not a regression.

Refutation, plain: the write prediction was wrong. Read delivered ~1.7-1.8x; write only +5.6%. Root cause, from the runtime source: at serialize 6ad407d, WriteBytes at a byte-but-not-word boundary still pushes up to 15 of the 17 bytes through per-byte head/tail WriteBits — the emitter's loop moved into the runtime's loop. Re-paired against serialize PR #27's optimize-bytes (packed head/tail, 14ea61a): write +14.4% (17.81M -> 20.37M), read +71.5%. So this PR plus serialize PR #27 is the write story so far, and the remaining gap says the EPYC's ~58%-of-write-cycles share does not transfer to the M2 write path as-is. The EPYC pairing is deliberately not run tonight (another session owns that box); worth a quiet-hours profile there before concluding more.

Judgment calls

  • Fixed [N]uint8 only. int8 and bits(8) would also be byte-exact on the wire, but their storage/cast paths differ and nothing profiles them; counted [<= N]uint8 is position-tracked but not marked (the write side would need the count prefix emitted before the bulk call — mechanical, unconvicted). All noted in the analysis comments for when a profile convicts them.
  • The analysis lives in internal/ir (target-independent wire math, the same charter as MaxBits) so the go/rust/cs backends — whose emitters still write these per-byte loops — can adopt the same proof later.
  • No SPEC change. Nothing here alters or reinterprets the wire; §4.3/§4.7 already contain everything the proof needs.

CI note

GitHub Actions is in a major outage today; verification above is local (M2), commands as quoted. The suite should re-run in CI when Actions recovers.

Profile conviction (serialize PR #27 EPYC perf): ~58%/~57% of Write/
ReadTestData self-cycles sat in the generated 17x per-byte
write_bits(...,8)/read_bits(8) loop over fixed_bytes. The runtime already
has the right primitive - SerializeBytes aligns then memcpys - but the
emitter never used it for fixed byte arrays.

The wire decision: serialize_bytes' internal align is ZERO bits when the
stream is already byte-aligned, and SPEC 4.3 pins bit i of the stream to
byte i/8 - so at a byte boundary, N consecutive unranged 8-bit writes are
byte-identical to the bulk copy. The emitter may therefore switch ONLY
where alignment is statically provable; everywhere else the per-byte loop
stays, wire-identical by construction. No golden wire byte moves.

ir.AlignedFixedByteArrays is the proof: a conservative bit-position-mod-8
walk over a struct's items (entry unknown - a type embeds at any offset;
align and string/bytes wires force known boundaries; branches must agree;
counted arrays tracked but not yet marked). The cpp backend consults it in
emitWriteField/emitReadField for fixed [N]uint8 arrays and emits
write_bytes/read_bytes instead of the loop.

Corpus effect: exactly TestData.fixed_bytes (declared after an explicit
align). Source goldens re-pinned for that; wire goldens untouched -
verified by the full suite: schema_test (wire goldens + wire oracle),
variant, random round-trip (2000 iterations), and the go/rust/cs
conformance gates all green with SERIALIZE=../serialize.

Glenn's approach, verbatim: 'Trick I have used in the past is to align so
I can just memcpy in when writing bytes and strings.'

Co-Authored-By: Claude Opus 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