Skip to content

Shrink ArrayPoolListWriteBuffer by renting the segment table on first spill - #6

Merged
neuecc merged 1 commit into
mainfrom
slim-arraypool-list-write-buffer
Oct 19, 2026
Merged

neuecc merged 1 commit into
mainfrom
slim-arraypool-list-write-buffer

Conversation

@neuecc

@neuecc neuecc commented Oct 19, 2026

Copy link
Copy Markdown
Member

Summary

ArrayPoolListWriteBuffer kept its chain of rented arrays inline: 16 byte[] references and 17 lengths, about 240 bytes of struct. GC references can never be left uninitialized, so every construction zeroed the whole thing even when the message never left the scratch span, and Dispose walked it again. For tiny messages (the byte[] Serialize<T>(T) entry point) that was most of the buffer's lifetime cost.

The chain now lives in a PooledSegment[] (array + finished length pairs) rented from ArrayPool<PooledSegment>.Shared on the first spill. The struct is ~64 bytes (two spans, three ints, a long and one reference), BytesWritten is a running total instead of a loop, and ToArray / WriteTo take a single-copy path when nothing spilled. No heap allocation is introduced: the table is pooled exactly like the byte[] segments.

CompatibleArrayPoolListWriteBuffer and BufferSegments share the same table, which removes the netstandard switch-based inline storage and the length normalization in the compatible variant.

Measurements

net10.0, x64 (i7-13700KF), 1 KB stackalloc scratch, warmup 8 / iteration 15.

Scenario Before After SpanWriteBuffer (floor)
ctor + Dispose 3.62 ns 0.31 ns -
+ write 1 byte 3.27 ns 1.89 ns 0.29 ns
+ ToArray (1 byte) 7.33 ns 4.63 ns 3.04 ns
ToArray (32 bytes, 4 × 8 B) 12.84 ns 9.19 ns -
2 KB (one spill) 124 ns 100 ns -
200 KB (three segments) 53.8 µs 53.1 µs -

Steady-state allocation measured with GC.GetAllocatedBytesForCurrentThread across 200 KB × 1000 (62 Gen2 GCs), 2 KB × 100k and 64 B × 100k: 0 extra bytes on every path.

The remaining gap to SpanWriteBuffer is the struct staying on the stack because GetSpanSlow takes ref this; that is the floor of this shape.

Notes

  • Rent order in GetSpanSlow: the new segment is rented before any field changes, so a failed Rent leaves the buffer untouched.
  • BufferSegments borrows the rented table. The existing contract (valid until the next write or Dispose) is unchanged, but a view used after Dispose now reads a table that may have been re-rented rather than hitting a null array.
  • New tests in ArrayPoolListWriteBufferTest cover scratch-only, single and multiple spills, empty scratch, Dispose reset / reuse and the compatible variant. 143 tests pass.

🤖 Generated with Claude Code

…nted table

The ref struct held 16 byte[] references and 17 lengths inline (~240 bytes),
and because GC references can never be left uninitialized, every construction
zeroed all of it even when the message never left the scratch span. The chain
now lives in a PooledSegment[] rented from ArrayPool<PooledSegment>.Shared on
the first spill, so the struct shrinks to ~64 bytes and messages that fit the
scratch span never touch it. BytesWritten is a running total instead of a loop,
and ToArray/WriteTo take a direct single-copy path when nothing spilled.

Measured on net10.0 (x64, 1 KB scratch): ctor+Dispose 3.6 -> 0.3 ns, one-byte
write 3.3 -> 1.9 ns, one-byte ToArray 7.3 -> 4.6 ns; 200 KB unchanged, 2 KB
spill 124 -> 100 ns. Steady-state allocation stays at zero on every path
(verified with GC.GetAllocatedBytesForCurrentThread across Gen2 collections).

CompatibleArrayPoolListWriteBuffer and BufferSegments share the same table, which
removes the netstandard switch-based inline storage and the length
normalization in the compatible variant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@neuecc
neuecc merged commit df2a17d into main Oct 19, 2026
7 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