Repository navigation
Write into a caller supplied buffer, and stop boxing an enumerator per message - #15
Merged
Merged
Conversation
…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>
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 oneByteWritercursor, 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.
ConvertToByteArrayused to pay for aList<byte>, its backing array, and aToArraycopy.3.
SortedListwas boxing an enumerator per message, in both directions.SortedList<K,V>.GetEnumerator()returnsIEnumerator<KeyValuePair<K,V>>rather than its own struct enumerator, so everyforeachover 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 inPrepare().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:
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
TestSpanWriteApi.cs.GetByteCountexact (not an upper bound) including variable length, empty and null strings; every destination size from 0 to exact-1 refused withbytesWritten = 0; an exactly sized destination accepted; an oversized destination left untouched pastbytesWritten; repeated writes into the same buffer stable; a full round trip through caller owned buffers.Open question for you
I picked
Try...+GetByteCountoverIBufferWriter<byte>because it is the BCL's own idiom and needs no allocation or indirection. If you would rather also expose anIBufferWriter<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