Skip to content

Write into a caller supplied buffer, and stop boxing an enumerator per message - #15

Merged
holonsoftFounder merged 2 commits into
mainfrom
feat/v4-span-write
Sep 13, 2026
Merged

holonsoftFounder merged 2 commits into
mainfrom
feat/v4-span-write

Conversation

@holonsoftFounder

Copy link
Copy Markdown
Member

What

Three changes, and the one that mattered most was not the one this set out to make.

1. A caller supplied buffer. TryConvertToByteArray(T, Span<byte>, out int) writes into a buffer the caller owns; GetByteCount(T) says exactly how big it has to be. Both entry points share one ByteWriter cursor, so there is exactly one copy of the field writing logic and no way for the overloads to drift apart.

2. The result array is allocated once, at its exact size. ConvertToByteArray used to pay for a List<byte>, its backing array, and a ToArray copy.

3. SortedList was boxing an enumerator per message, in both directions. SortedList<K,V>.GetEnumerator() returns IEnumerator<KeyValuePair<K,V>> rather than its own struct enumerator, so every foreach over the field list put a boxed enumerator on the heap, once per converted message, on the read side as well as the write side. That is 48 bytes per conversion and it is far older than this renovation. The field entries are now also kept as plain arrays, built once in Prepare().

A rejected approach, for the record

A pooled (ArrayPool) write buffer was implemented and measured first. It cut allocation by 46% but cost between 3% and 23% in time: renting and returning costs more than the single allocation it saves on a frame this small. It survives only as a fallback that is never taken in practice, for the case that the computed size and the writer ever disagree, because silently returning a half written or zero padded frame is the worst failure mode available here.

Numbers

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

Method before after time alloc
Read from buffer, copy first 97.74 ns 65.31 ns −33% 168 → 120 B
Read from buffer, span 64.72 ns 33.29 ns −49% 104 → 56 B
Read from buffer, span reuse 57.51 ns 28.97 ns −50% 48 → 0 B
Read LE 93.06 ns 59.33 ns −36% 104 → 56 B
Read BE 92.07 ns 58.95 ns −36% 104 → 56 B
Read reused 58.56 ns 29.21 ns −50% 48 → 0 B
Write LE 105.36 ns 59.75 ns −43% 208 → 64 B
Write BE 107.72 ns 59.85 ns −44% 208 → 64 B
Read DateTime/Guid/decimal LE 57.32 ns 37.06 ns −35% 112 → 64 B
Read DateTime/Guid/decimal BE 58.22 ns 38.50 ns −34% 112 → 64 B
Write DateTime/Guid/decimal LE 60.29 ns 41.47 ns −31% 264 → 72 B
Write DateTime/Guid/decimal BE 59.72 ns 44.29 ns −26% 264 → 72 B
Write into a reused buffer — 58.64 ns — 0 B
Read with string 42.87 ns 28.73 ns −33% 208 → 160 B
Write with string 112.88 ns 119.55 ns +5.9% 296 → 152 B

There is no control row this time, every path was touched. The string write is the one row that moved the wrong way, and it is also the one row that still allocates a List<byte> scratch buffer per call. Encoding straight into the destination is the next step and should take it to 64 bytes as well.

Reading into a reused instance and writing into a reused buffer now allocate nothing at all.

Safety

  • 116/116 tests green, 14 of them new in TestSpanWriteApi.cs.
  • The golden vectors did not move, so the rewritten writer is byte identical to the one that shipped.
  • New tests cover: identical bytes from both write overloads for every protocol shape; GetByteCount exact (not an upper bound) including variable length, empty and null strings; every destination size from 0 to exact-1 refused with bytesWritten = 0; an exactly sized destination accepted; an oversized destination left untouched past bytesWritten; repeated writes into the same buffer stable; a full round trip through caller owned buffers.

Open question for you

I picked Try... + GetByteCount over IBufferWriter<byte> because it is the BCL's own idiom and needs no allocation or indirection. If you would rather also expose an IBufferWriter<byte> overload for pipeline consumers, it is a thin wrapper over the same cursor and can be added without touching the field writing code.

🤖 Generated with Claude Code

christian.vogt@hiquaro.com and others added 2 commits September 14, 2026 00:39
…tor per message

Three changes, and the one that mattered most was not the one this set out to
make.

TryConvertToByteArray(T, Span<byte>, out int) writes into a buffer the caller
owns and GetByteCount(T) says exactly how big it has to be. The Try pattern was
chosen over IBufferWriter<byte>: it is what the BCL uses for this, and
IBufferWriter earns its indirection only when the sink spans several segments.
Both entry points share one ByteWriter cursor, so there is one copy of the field
writing logic and no way for the overloads to drift apart.

ConvertToByteArray now allocates the result once at its exact size and writes
straight into it, instead of a List<byte> plus its backing array plus a ToArray
copy. A pooled buffer was tried first and rejected on measurement: it cut
allocation by 46% but cost 3% to 23% in time, because renting and returning
costs more than the single allocation it saves on a frame this small. It
survives only as a fallback that is never taken in practice, for the case that
the computed size and the writer ever disagree, because silently returning a
half written frame is the worst failure mode available here.

SortedList<K,V>.GetEnumerator() returns IEnumerator<KeyValuePair<K,V>> rather
than its own struct enumerator, so every foreach over the field list put a boxed
enumerator on the heap, once per converted message, on the read side as well as
the write side. 48 bytes per conversion, and far older than this renovation. The
field entries are now also kept as plain arrays built once in Prepare().

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

    Read  LE            93.06 ns ->  59.33 ns  -36%   104 ->  56 B
    Read  BE            92.07 ns ->  58.95 ns  -36%   104 ->  56 B
    Read  reused        58.56 ns ->  29.21 ns  -50%    48 ->   0 B
    Write LE           105.36 ns ->  59.75 ns  -43%   208 ->  64 B
    Write BE           107.72 ns ->  59.85 ns  -44%   208 ->  64 B
    Read  DT/Guid/dec   57.32 ns ->  37.06 ns  -35%   112 ->  64 B
    Write DT/Guid/dec   60.29 ns ->  41.47 ns  -31%   264 ->  72 B
    Write own buffer            -    58.64 ns     -           0 B
    Read  string        42.87 ns ->  28.73 ns  -33%   208 -> 160 B
    Write string       112.88 ns -> 119.55 ns  +5.9%  296 -> 152 B

There is no control row this time, every path was touched. The string write is
the one row that moved the wrong way, and it is also the one row that still
allocates a List<byte> scratch buffer per call. Encoding straight into the
destination is the next step.

Reading into a reused instance and writing into a reused buffer now allocate
nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… back

The growing fallback was never taken. Instrumenting it and running the whole
suite proved that no protocol shape in the tests reaches it, which also means
nothing verified it, and it quietly re ran the entire write, including the part
that assigns the length fields back onto the caller's POCO. Its own comment
claimed it prevented a malformed frame while its implementation returned
whatever length the writer happened to produce, with no check at all.

The size mismatch is now checked on every call and throws. It can only mean a
bug in this library, and a loud failure beats a frame that is half written or
zero padded and still looks well formed.

With the fallback gone the writer never has to grow, so ByteWriter loses the
pool, the growth path and Dispose, and is now a plain cursor over a fixed span.
Overflow also drops the buffer, which makes every later write a no operation
without costing the hot path a second branch, so the documented guarantee that
nothing meaningful is written after an overflow is now what the code actually
does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@holonsoftFounder
holonsoftFounder merged commit a748578 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