Skip to content

WriteBytes fuses: one store flushes the scratch, the payload goes in whole, one load reloads the tail - #77

Merged
gafferongames merged 1 commit into
mainfrom
fused-write-bytes
Aug 16, 2026
Merged

WriteBytes fuses: one store flushes the scratch, the payload goes in whole, one load reloads the tail#77
gafferongames merged 1 commit into
mainfrom
fused-write-bytes

Conversation

@rowan-claude

Copy link
Copy Markdown
Contributor

The C++ half of the string-body gap: BitWriter::WriteBytes pushed the block's head and tail through the packer a byte at a time, and that loop priced the function out of generated callers. serialize.c fused its body first (mas-bandwidth/serialize.c#26, merged); this is the reference adopting the same shape in its own idiom, per the Implementation Law's both-sides-were-wrong clause. The wire format is unchanged — proven below, not asserted.

The mechanism

The old body wrote head and tail bytes with WriteBits( data[i], 8 ) — spill test and all, per byte. For a chat-shaped string body the inliner refused the result at cost 345 against threshold 225, so every generated string and byte-array field paid an out-of-line call into the 213-instruction generic.

The fused body:

  • one 8-byte store flushes the partial scratch word (low bytes are the bytes already written, high bytes zero, overwritten by the payload copy),
  • one SERIALIZE_BULK_COPY lands the whole payload at the byte cursor,
  • one 8-byte load reloads the trailing partial word into the scratch, masked to its tail bits, so later writes pack into it exactly as before. The tail load touches only the word the final flush is already obliged to store.

The byte-swapped scratch is what makes the edge words single moves on either endianness; the s390x golden leg pins that.

SERIALIZE_BULK_COPY is the variable-length twin of serialize.c's macro pair: __builtin_memcpy on clang/GCC, plain memcpy elsewhere. It keeps the body's pricing independent of any platform's fortify capture (glibc honors _FORTIFY_SOURCE in C++ too; Darwin's capture turns out to be C-only, so the C++ refusal measured here was entirely the byte loops — stated honestly in the macro comment). The always-inlined packer paths keep their plain memcpy spelling.

Wire unchanged — proof

  • Exhaustive differential against main: every scratch occupancy (0..64 pre-bits) x every payload length (0..40), chained second block, mid-stream bit fields, poisoned buffers — byte-for-byte identical over all 2665 cases, every case round-trips, clean under asan+ubsan and with -DNDEBUG.
  • serialize.c's make diff against this checkout: core / wide / compressed float all IDENTICAL.
  • Full suite (55 tests incl. test_golden_wire_format, golden zero-length/unaligned vectors, test_unaligned_writer): green in Debug, Release, and asan+ubsan builds.
  • tools/conformance: 89 checks against STANDARD.md, 0 failures.
  • The c++03 consumer legs (native and emulated-128): green, no new warnings.

Check model unchanged

Release write stays checkless. The two new asserts (byte alignment — already asserted — plus scratch-tracks-the-cursor) are debug-only invariants; writing after a mid-stream FlushBits was never supported by the old shape either.

Evidence

Chat-shaped repro (two bit fields + string field, 11-byte payload, Apple clang, -O2 -DNDEBUG):

measure before after
inliner verdict on WriteBytes in serialize_string_internal<WriteStream> refused, cost=345 vs threshold=225 inlined, cost=90
write path instructions 314 across three functions (WriteBytes out of line) 40, one fully inlined writer

Per the repo's own caution (#27's isolated win did not survive composition): the composed claim belongs to schema's four-language bench, where this write body is the runtime dependency of the C++ side of the string rows.

🤖 Generated with Claude Code

…whole, one load reloads the tail

WriteBytes pushed the block's head and tail through the packer a byte at
a time -- WriteBits per byte, spill test and all -- and that loop priced
the function out of generated callers: refused at cost 345 against
threshold 225 for a chat-shaped string body, leaving every string field
an out-of-line call into the 213-instruction generic. serialize.c fused
its body first (serialize.c #26, under the Implementation Law's
both-sides-were-wrong clause); this is the reference adopting the same
shape in its own idiom.

The fused body: the partial scratch word goes to the buffer as one
8-byte store (low bytes are the bytes already written, high bytes zero,
overwritten by the payload), the whole payload lands as one
SERIALIZE_BULK_COPY at the byte cursor, and the trailing partial word is
loaded back into the scratch -- masked to its tail bits -- so later
writes pack into it exactly as before. The tail load touches only the
word the final flush is already obliged to store. No head loop, no tail
loop, no per-byte spill tests; the byte-swapped scratch makes the edge
words single moves on either endianness, and the s390x golden leg pins
that.

SERIALIZE_BULK_COPY is new: __builtin_memcpy on clang/GCC, plain memcpy
elsewhere, the variable-length twin of serialize.c's macro pair. It
keeps the body's pricing independent of any platform's fortify capture
(glibc honors _FORTIFY_SOURCE in C++; Darwin's capture is C-only, so
the C++ refusal measured here was entirely the byte loops). The
always-inlined packer paths keep their plain memcpy spelling.

Wire bytes are identical: an exhaustive differential against main --
every scratch occupancy (0..64 pre-bits) x every payload length (0..40),
chained second block, mid-stream bit fields, poisoned buffers -- is
byte-for-byte the same over all 2665 cases, and every case round-trips,
also clean under asan+ubsan and with NDEBUG. Check model unchanged:
release write stays checkless; the two new asserts (scratch tracks the
cursor, byte alignment) are debug-only invariants.

Gates: ctest Debug + Release + asan/ubsan legs, the c++03 consumer legs,
tools/conformance (89 checks), and serialize.c's make diff against this
checkout (core / wide / compressed float all IDENTICAL). Evidence: the
chat-shaped repro at -O2 -DNDEBUG drops from 314 instructions across
three functions (WriteBytes out of line) to 40 in one fully inlined
writer; the inliner accepts at cost 90 where it refused at 345.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gafferongames
gafferongames merged commit dd24915 into main Aug 16, 2026
15 checks passed
@gafferongames
gafferongames deleted the fused-write-bytes branch August 16, 2026 21:36
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