Skip to content

Encode a string into the frame itself instead of into a scratch buffer - #17

Merged
holonsoftFounder merged 1 commit into
mainfrom
perf/v4-string-encode-direct
Sep 13, 2026
Merged

holonsoftFounder merged 1 commit into
mainfrom
perf/v4-string-encode-direct

Conversation

@holonsoftFounder

Copy link
Copy Markdown
Member

What

The last per message allocation. A string field was encoded into a List<byte> scratch buffer which was then copied into the frame, and for a sequence protocol the pre pass that assigns the length fields encoded every string a second time only to throw the result away.

The encoder now writes into the span the frame itself occupies, and the pre pass only measures.

One case still needs a buffer of its own: a value longer than its fixed length field has to be cut at the byte level, and an encoder cannot write into a span that is too small for its output. That one is rented from the pool, and only values that overflow their field ever reach it.

Numbers

A/B of the same tree with and without the change, back to back:

Method List scratch into the frame time alloc
Write with a string 114.74 ns 44.08 ns −61.6% 152 → 64 B
Read with a string 30.48 ns 28.12 ns −7.7% 160 B, unchanged

The other twelve rows are the control: the change cannot reach a protocol without strings, and all of them stay within the run to run spread with byte identical allocation.

This also clears the one row that got ~6% slower in #15; it is now well below where it started.

The 160 bytes left on the string read are the POCO and the decoded string itself, which is what the caller asked for, so there is nothing left to remove there.

Safety

130/130 green, golden vectors unmoved. The behaviour this touches is exactly what TestFixedLengthStringWriting pinned in #16 first: padding, byte level truncation including a split surrogate pair, odd declared lengths in both branches, empty and null values, and agreement between both write overloads. That was written before this change for precisely this reason.

Where v4 ends up

Against the shipped 3.6.1 code:

Method 3.6.1 4.0 time alloc
Read LE 118.09 ns 58.65 ns −50% 344 → 56 B
Read BE 146.21 ns 58.19 ns −60% 568 → 56 B
Read into a reused instance 115.58 ns 28.92 ns −75% 288 → 0 B
Write LE 245.33 ns 55.76 ns −77% 888 → 64 B
Write BE 376.86 ns 56.06 ns −85% 1528 → 64 B
Read with a string 73.96 ns 28.12 ns −62% 288 → 160 B
Write with a string 166.05 ns 44.08 ns −74% 384 → 64 B

At 100.000 messages per second the write path used to produce about 89 MB/s of garbage in little endian and 153 MB/s in big endian. It now produces 6.4 MB/s, or none at all into a reused buffer.

🤖 Generated with Claude Code

… buffer

The last per message allocation. A string field was encoded into a List<byte>
and the buffer was then copied into the frame, and for a sequence protocol the
pre pass that assigns the length fields encoded every string a second time only
to throw the result away.

The encoder now writes into the span the frame itself occupies, and the pre pass
only measures. One case still needs a buffer of its own: a value longer than its
fixed length field has to be cut at the byte level, and an encoder cannot write
into a span that is too small for its output. That one is rented from the pool
and only values that overflow their field ever reach it.

A/B of the same tree with and without the change, back to back:

    Write string   114.74 ns -> 44.08 ns   -61.6%   152 -> 64 B
    Read  string    30.48 ns -> 28.12 ns    -7.7%   160 B, unchanged

The other twelve rows are the control: the change cannot reach a protocol
without strings and all of them stay within the run to run spread with byte
identical allocation.

The 160 bytes left on the string read are the POCO and the decoded string
itself, which is what the caller asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@holonsoftFounder
holonsoftFounder merged commit ede12fe into main Sep 13, 2026
2 checks passed
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