fix(scanner): pre-size the to_the_end read buffer from a source size … - #478
Conversation
…hint The `to_the_end` scanner reads a whole source into one message with io.ReadAll, which receives no size hint. In Go 1.26 io.ReadAll builds a list of exponentially growing chunks and then copies them into a single right-sized slice, so at peak the chunks and the final copy are live simultaneously and it holds roughly twice the content size in memory. For the `file` input this is avoidable: it already calls Stat() on every file (for the mod time) and so already knows the exact size, but never used it. This threads an optional SizeHint through SourceDetails so the `file` input can hand the scanner the size it already has, and replaces io.ReadAll in the to_the_end scanner with a capacity-hinted read that allocates the buffer once up front. The hint is an optimisation only and never a correctness input: if the file grows past the hint the buffer still grows exactly as before, and if it shrinks the read ends early, so the bytes returned are identical to io.ReadAll for any hint value. A previously latent defect is fixed as part of the plumbing: codec/scanner.go reconstructed the details object and copied only Name, silently dropping any other field. It now propagates SizeHint too (and nil-guards details). Measured on a 64 MiB in-process benchmark (Go 1.26.6, benchstat n=6): bytes allocated 157.7Mi -> 64.0Mi -59.4% (p=0.002) allocations 36 -> 2 -94.4% (p=0.002) In plain terms: to read a 64 MiB file the scanner previously reserved about 158 MiB of memory -- roughly two and a half times the file -- and did so in 36 separate allocation steps. It now reserves about 64 MiB (the file size itself) in a single step. So peak memory for the read drops from ~2.5x the file size to ~1x. For customers this is the difference that can keep large-file pipelines within a memory limit instead of being OOM-killed; the effect grows with file size (a multi-GB file previously peaked at two to three times its size in RAM). Nothing about the output or configuration changes -- it is purely a reduction in memory used. The p=0.002 figures mean the improvement is statistically solid, not measurement noise. make fmt, make lint and make test all pass.
Follow-ups from review of the size hint work: - readAllHinted delegates to io.ReadAll when there's no usable hint — growing from an empty buffer was measurably worse than stdlib's strategy for every unhinted source (~2.4x the bytes allocated on a 64 MiB read), and unhinted sources are the common case. - Pre-allocation from a hint is capped at 1 GiB, so a wildly wrong size (procfs oddities, a custom filesystem returning garbage) can't turn into an enormous up-front allocation or a makeslice panic (hint+1 overflows at MaxInt64). Content beyond the cap still reads fine. - The decompress scanner strips the hint before handing details to its child, since it describes the compressed stream rather than the one the child actually reads. - codecRPublic passes details through wholesale instead of copying field by field, so future fields can't be silently dropped again. - ScannerSourceDetails getters are safe on a nil receiver now (details are optional everywhere), which lets the scattered nil guards go. - Docs spell out that a zero hint is indistinguishable from unset; the file input's Stat failure log includes the error value; copyright years bumped on touched files. Tests: huge-hint clamp coverage, an unhinted-path benchmark, decompress hint-strip coverage, and nil-receiver getter coverage.
Co-authored-by: Joseph Woodward <joseph.woodward@xeuse.com>
Co-authored-by: Joseph Woodward <joseph.woodward@xeuse.com>
Follow-ups from a second review pass over the branch: - readAllHinted copies the result down when a hint overestimated the content (source truncated between Stat and read, or a misreporting filesystem), so the returned message no longer pins the whole pre-allocated array for its lifetime. An accurate hint leaves cap at len+1 and never pays the copy. - The SizeHint contract docs now state the wrapping-scanner invariant that decompress previously enforced only as inline code: a wrapper that transforms stream length must clear the hint before forwarding details. Setter docs also note they panic on a nil receiver, unlike the getters. - The capture-scanner test scaffolding duplicated between the file input and decompress scanner tests is consolidated into internal/component/scanner/testutil, with the capture callbacks now consistently mutex-guarded.
| // iteration and grow once more, reintroducing the reallocation this avoids. | ||
| func readAllHinted(r io.Reader, hint int64) ([]byte, error) { | ||
| if hint < 0 { | ||
| hint = 0 |
There was a problem hiding this comment.
io.ReadAll has a default buffer of 512 bytes, which means anything without a hint larger than that value would change the current the runtime profile.
Perhaps a better solution would be to use that same 512 bytes as a default size, and only use the hint of larger to better align existing behaviour? ie:
defaultReadAllCap := 512 // matching io.ReadAlls
...
if hint < defaultReadAllCap {
hint = defaultReadAllCap
}
buf := make([]byte, 0, hint+1)There was a problem hiding this comment.
Also, given setting the hint is now exposed as an exported function on the package, we should also guard against hint overflows to ensure it never exceeds the max size, so the whole thing (including the above) looks like this:
func readAllHinted(r io.Reader, hint int64) ([]byte, error) {
if hint < defaultReadAllCap {
hint = defaultReadAllCap
}
if hint > math.MaxInt64-1 {
hint = math.MaxInt64 - 1
}
buf := make([]byte, 0, hint+1)
...It'd be good to get some tests around the behaviour too.
There was a problem hiding this comment.
Thank you, Joseph — both points are well made and have been addressed in 4d7c4ed.
On the 512-byte floor. I concur, and I would add that the consequence is somewhat broader than small files alone. The to_the_end scanner is also exercised by the socket, stdin and http_client inputs, none of which supply a size hint. Absent a floor, such no-hint reads would grow from a single-byte buffer and, for payloads below 512 bytes, incur more allocations than the io.ReadAll they replace — a regression on the very paths the change was not intended to touch. Flooring the hint at 512 (the initial capacity io.ReadAll itself uses) preserves the existing allocation profile on those paths, whilst larger hints continue to pre-size exactly.
On guarding against overflow. Agreed. As SetSizeHint is now exported, the hint must be treated as untrusted input, and the +1 capacity could otherwise overflow to a negative value. I have accordingly clamped it below math.MaxInt64. To keep the behaviour verifiable, I have extracted the floor and clamp into a small hintedCap function and covered it directly with TestHintedCap; the overflow branch is not reachable through an actual read, as the allocation would fail long beforehand. I have also added TestReadAllHintedFloor to confirm that no-hint callers retain the 512-byte buffer.
The benchmark figures are unchanged, and make fmt, make lint and make test all pass. Grateful for the review.
Third pass from review, tightening the memory behaviour of the hinted read and reconciling the contract docs with the in-tree wrappers: - The pre-allocation clamp drops from 1 GiB to 128 MiB: large enough that typical whole-stream messages keep the single-allocation benefit, small enough that a bogus Stat can't spike a constrained deployment before a byte is read. - Growth past the hint (source grew after being measured, or content beyond the clamp) now doubles explicitly instead of relying on append's ~1.25x strategy, keeping cumulative allocation linear in the content size much like io.ReadAll's chunking, with the same +1 trick so content ending exactly at a doubled capacity doesn't force one more doubling just to observe EOF. - The pre-allocation is floored at io.ReadAll's 512-byte starting size, so a tiny stale hint doesn't begin with pathologically small reads. - The copy-down of excess capacity now only runs on success (the caller discards the buffer on error) and only when the waste exceeds 64 KiB, where it's worth the copy. - The file input ignores negative Stat sizes from misbehaving custom filesystems, keeping the documented zero-means-unknown contract. - The SizeHint contract now distinguishes wrappers that materially transform stream length (decompress: must clear the hint) from those trimming a bounded few bytes (skip_bom: may forward it, and now says so at the forwarding site). - The details-capture test scanner owns its synchronised state and returns an accessor, rather than each test hand-rolling globals. - Changelog notes the 128 MiB cap and that deprecated string codec configs are unaffected.
…verflow Addresses review feedback from @josephwoodward. The to_the_end scanner is also driven by inputs that pass no size hint (socket, stdin, http_client). io.ReadAll starts from a 512-byte buffer, so a small no-hint read previously grew up from a single byte and made more allocations than the io.ReadAll it replaced. Floor the hint at 512 (io.ReadAll's initial capacity) so those paths keep io.ReadAll's profile while large hints still pre-size exactly. SetSizeHint is exported, so the hint is untrusted: also clamp it below math.MaxInt64 so the +1 capacity can never overflow to a negative value. The floor/clamp is factored into a small hintedCap function so both behaviours (including the overflow branch, which can't be reached through an actual read) are unit tested. Benchmark is unchanged; make fmt, make lint and make test still pass.
|
Upstream added a 512-byte floor and a MaxInt64-1 overflow guard via a new hintedCap helper (review feedback from josephwoodward), plus copyright bumps on the two new test files. This merge keeps that helper shape and its dedicated tests, adjusted to the stricter semantics on this branch: - hintedCap clamps at the 128 MiB pre-allocation cap, not just below MaxInt64 — an unguarded make() panics for any capacity above the runtime's allocation limit, so the arithmetic guard alone still crashes on e.g. sparse-file stat sizes. - A hint of zero or below delegates to io.ReadAll rather than flooring to 512: the floor fixes sub-512 payloads but large unhinted streams would still pay the grow loop. The floor test is narrowed to small positive hints accordingly.
Both main and this branch added an Unreleased section; the merge keeps main's websocket entry under Added alongside this branch's entry under Fixed.
What this does
The
to_the_endscanner reads an entire source into a single message withio.ReadAll, which is given no size hint. For thefileinput the size isalready known — the input calls
Stat()on every file (for the mod time) — butit was never used.
This change threads an optional
SizeHintthroughscanner.SourceDetailsso thefileinput can hand the scanner the size it already has, and replacesio.ReadAllin theto_the_endscanner with a capacity-hinted read(
readAllHinted) that allocates the read buffer once, up front.The result is a large reduction in memory allocated per read, and far fewer
allocations, when reading a whole file.
Why — the allocation mechanism (Go 1.26)
This repo targets Go 1.26.4 (now 1.26.6 on
main).io.ReadAllwas rewritten inGo 1.26 and its own comment describes the strategy:
It accumulates a list of exponentially growing chunks and then copies them into a
single right-sized slice. The returned slice is exact, but at peak the
intermediate chunks and the final copy are live simultaneously, so it
transiently holds roughly twice the content size in memory. Pre-sizing the buffer
to the known size avoids the intermediate chunks and the final copy entirely.
Note this is not documented behaviour — neither the
io.ReadAllnor theappendgodoc specifies an allocation strategy — so this PR is framed around the
measured allocation reduction, not around Go internals guarantees.
Measurements
Benchmark added in
scanner_to_the_end_internal_test.go: a 64 MiB source fedthrough a reader that returns realistic short reads (not one giant
bytes.Reader),io.ReadAllvsreadAllHinted.Raw
go test -benchoutput (Go 1.26.6,-benchmem -count=6):benchstat(n=6),io.ReadAll(base) vsreadAllHinted:So for a 64 MiB file the read goes from ~158 MiB allocated across 36 allocations
to ~64 MiB (the file size) in 2. The
B/opfigures are deterministic (± 0%),which is why they, rather than timing, are the basis of the claim.
This benchmark measures allocation volume of the read itself, which is the
mechanism being changed. It is an in-process proxy for the peak-RSS behaviour, not
a full-pipeline RSS measurement.
The hint is an optimisation only — never a correctness input
A writer can append to or truncate a file between
Statand the read, so the hintcan be wrong in either direction.
readAllHintedis written so the bytes returnedare identical to
io.ReadAllfor every hint value — correct, too small, toolarge, zero, or negative:
io.ReadAllwould, paying a copy only for the excess.
hint+1, nothint: the read loop only callsReadwhilecap > len, so a buffer sized to exactly the content length would hitlen == capon the final iteration and grow once more, reintroducing thereallocation this avoids.
Tests covering this (
scanner_to_the_end_internal_test.go,scanner_to_the_end_test.go):io.ReadAllover a matrix of content sizes{0,1,511,512,513,4096,1<<20}× hints{0,1,size/2,size-1,size,size+1,size*2,-5},through a short-read reader (a
bytes.Readeralone would never exercise therefill path).
size+1,which is the test that catches a regression of the
+1subtlety.absent, exact, too small, too large, and negative — content is unchanged in all
cases.
Latent defect fixed in passing:
codec/scanner.godropped fieldsTo make the hint reach the scanner from the
fileinput, a bug had to be fixed inpublic/service/codec/scanner.go. That layer sits between the input and thescanner and reconstructs the
ScannerSourceDetails, copying onlyName:Any field added to
SourceDetailsis therefore silently dropped for every inputrouted through this layer, with no compile error and no test failure — which is
exactly what made site 3 easy to miss. It now propagates
SizeHinttoo (andnil-guards
details, which was an unchecked deref).Open question for maintainers: rather than copying fields one by one, would you
prefer this layer pass
detailsthrough wholesale so future fields can't bedropped again? That is a smaller, more robust change; I kept the explicit copy here
to match the existing style, but I'm happy to switch if you'd prefer.
Changed files
internal/component/scanner/interface.go— addSizeHint int64toSourceDetails.public/service/scanner.go— addSetSizeHint/SizeHintaccessors.public/service/codec/scanner.go— propagateSizeHint; nil-guarddetails.internal/impl/io/input_file.go— moveStat()above scanner creation; set thehint from
fInfo.Size(), guarded byIsRegular()(a FIFO, device or directoryhas no meaningful size to pre-allocate against).
internal/impl/pure/scanner_to_the_end.go— capture the hint (nil-safe); replaceio.ReadAllwithreadAllHinted.Checks
make fmt,make lintandmake testall pass (Go 1.26.6).