Skip to content

perf: remove the per-field allocations from both conversion directions - #10

Merged
holonsoftFounder merged 1 commit into
mainfrom
perf/v4-alloc-free-write
Sep 13, 2026
Merged

holonsoftFounder merged 1 commit into
mainfrom
perf/v4-alloc-free-write

Conversation

@holonsoftFounder

Copy link
Copy Markdown
Member

Phase 3. Every allocation that scaled with field count is gone from both directions. new byte[ no longer appears on either path.

Write side

removed was
BitConverter.GetBytes a fresh byte[] per multi-byte field
LINQ .Reverse() an iterator on top of that array, per big-endian field
List<byte> growth started empty, doubled its way up; sized from the protocol now
string encoding a byte[] for the text, a string from char.ToString(), a second byte[] for the fill char — per message

The encoder and encoded fill bytes are resolved once in Prepare(), and text is encoded straight into the list's backing array via CollectionsMarshal.

Read side

removed was
big-endian scratch buffers a byte[] per field, built only to feed BitConverter. BinaryPrimitives reads from a span. Ten sites.
Guid a byte[16], plus a LINQ reverse and another array for big-endian. Now stackalloc, reversed in place
string decoding copied into a temp byte[] before GetString. Now decoded straight from the source span

The big-endian read allocations were invisible to every test — both byte orders were correct, one was just quietly allocating 3x. Only the memory column surfaced it.

One deliberate subtlety

The fill bytes are produced with the real encoder, not (byte) fillChar. For a character above 0x7F the ASCII encoder substitutes '?' (0x3F) while a raw cast emits the low byte — a silent wire-format change for anyone using a non-ASCII fill character. No golden vector covers that case, so it would have shipped unnoticed.

Against the shipped 3.6.1 code (bcea658)

before now time alloc
Read LE 118.09 ns 91.79 ns −22.3% 344 → 104 B
Read BE 146.21 ns 93.33 ns −36.2% 568 → 104 B (−82%)
Read reused 115.58 ns 94.81 ns −18.0% 288 → 48 B (−83%)
Write LE 245.33 ns 134.93 ns −45.0% 888 → 208 B (−77%)
Write BE 376.86 ns 134.06 ns −64.4% 1528 → 208 B (−86%)
Read w/ string 73.96 ns 64.69 ns −12.5% 288 → 208 B
Write w/ string 166.05 ns 144.35 ns −13.1% 384 → 296 B

Byte order is now free in both directions (read 93.3 vs 91.8 ns; write 134.1 vs 134.9 ns).

Golden vectors unmoved throughout — that is the evidence the hand-written endianness arithmetic matches BitConverter byte for byte. 83/83.

🤖 Generated with Claude Code

Write side:
- BitConverter.GetBytes allocated a byte[] for every multi byte field. Append16,
  Append32 and Append64 write the bytes directly in the requested order.
- LINQ Reverse allocated an iterator on top of that array for every big endian
  field, which is why big endian cost almost twice what little endian did.
- the result List<byte> grew from nothing, it is sized from the protocol now.
- a string allocated a byte[] for the encoded text, a string for
  char.ToString() and a second byte[] for the fill character, per message.
  The encoder and the encoded fill bytes are resolved once while preparing, and
  the text is encoded straight into the backing array of the list.

Read side:
- big endian built a scratch byte[] per field just to feed BitConverter.
  BinaryPrimitives reads from a span and needs none. Ten sites.
- a Guid allocated a byte[16], and for big endian a LINQ reverse plus another
  array. It is a stackalloc span reversed in place now.
- a string copied into a temporary byte[] before decoding. It decodes straight
  out of the source array through a span.

No new byte[ remains on either path.

The fill bytes are produced with the real encoder and not as (byte) fillChar.
For a character above 0x7F the ASCII encoder substitutes '?', a raw cast would
emit the low byte, and that would silently change the wire format for anyone
using a non ASCII fill character. No golden vector covers that case.

Against the shipped 3.6.1 code:

  Read  LE      118.09 ->  91.79 ns  -22.3%   344 -> 104 B
  Read  BE      146.21 ->  93.33 ns  -36.2%   568 -> 104 B
  Read  reused  115.58 ->  94.81 ns  -18.0%   288 ->  48 B
  Write LE      245.33 -> 134.93 ns  -45.0%   888 -> 208 B
  Write BE      376.86 -> 134.06 ns  -64.4%  1528 -> 208 B
  Read  string   73.96 ->  64.69 ns  -12.5%   288 -> 208 B
  Write string  166.05 -> 144.35 ns  -13.1%   384 -> 296 B

Byte order is free now in both directions. Golden vectors unmoved throughout,
which is the evidence that the hand written endianness arithmetic matches
BitConverter byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@holonsoftFounder
holonsoftFounder merged commit 489e202 into main Sep 13, 2026
2 checks passed
@holonsoftFounder
holonsoftFounder deleted the perf/v4-alloc-free-write branch September 13, 2026 21:13
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