0120 a stream with no declared length is pooled too - CyrilB1531/lodestar GitHub Wiki
0120 โ A stream with no declared length is pooled too
Status: accepted ยท Date: 2026-09-13 ยท Amends: 0054
Context
0054 rented the payload buffer for a stream that declares its length, and carved out the other case in its consequences:
A stream with no declared length is not pooled. The growable path sizes itself as it reads and owns the buffer it grows, so
RentedPayloadcarries it owning nothing andDisposeis a no-op.
That path is not rare. A GZipStream โ the recipe
0044 hands the caller for compression โ a network
stream and a pipe all take it. It accumulated into a MemoryStream that doubled from nothing, so a
20.6 MB artifact grew through fresh zeroed arrays up to 32 MB and copied everything read so far at
each growth.
Nobody had measured it: no BenchmarkDotNet row reached a stream without a length, and the
nightly's embedding_index_load_gzip row is dominated by the inflate, so the growth hid inside it.
The measurement
PersistenceBenchmarks.EmbeddingIndexLoadGzip, added for this, AMD Ryzen 7 8700G, .NET 10.0.12,
BenchmarkDotNet DefaultJob, before and after interleaved twice
(#716):
| before | after | |
|---|---|---|
| mean | 50.678 / 50.383 ms | 43.078 / 43.048 ms |
| allocated per load | 91 562.54 KB | 16 095.15 KB |
| gen0 / gen1 / gen2 per 1 000 loads | 600 / 500 / 500 | 83 / 0 / 0 |
EmbeddingIndexLoad, which reads a declared-length stream and is untouched, read 4.796 / 4.872 ms
before and 4.887 / 4.747 ms after โ the control crosses in both directions.
It is 0054's mechanism again. The time follows the collections, and the collections follow 91.6 MB of large-object allocation, none of which survives the load.
Decision
A stream with no declared length is read into rented 1 MiB segments, then copied once into one
rented buffer sized to the total, and that buffer is handed to the parser as a RentedPayload
that owns it. A stream that ends inside its first segment is parsed from that segment, with no
second copy.
- Segments, not rented doubling. Doubling through the pool copies everything read so far at every step, and rents a 32 MB bucket on the way to the final one. Segments copy each byte once.
- 1 MiB. Small enough that the pool's power-of-two rounding wastes nothing, large enough that a 20 MB index is a score of reads.
MaxTotalBytesis checked as each read lands, exactly as the growable path checked it, so a hostile stream is refused before it is buffered past the limit rather than after.
Consequences
- 0054's carve-out is withdrawn; the rest of 0054 stands. Its residency argument applies with one addition: the pool now also keeps about the artifact's size in 1 MiB arrays, beside the power-of-two bucket 0054 already accepted.
- The same tail hazard 0054 named applies to the segments, and is guarded the same way: only
the byte count bounds what is parsed.
EmbeddingIndexReadPathTestsloads a short artifact through a non-seekable stream after a long one, and covers a read spanning several segments, one ending exactly on a segment boundary, and one refused mid-read byMaxTotalBytes. - Only
EmbeddingIndex.Loadtakes it. The other loaders callReadAllBytes, which keeps the unpooled growable path because it returns memory the caller keeps. - The asynchronous read is unchanged.
LoadAsyncon a stream with no length still grows aMemoryStream; it is the next place to apply this, and was not measured here. - What would reopen this is 0054's own condition: a caller measured on peak resident memory in a short-lived process rather than on load time.