Skip to content

fix(scanner): pre-size the to_the_end read buffer from a source size … - #478

Merged
Jeffail merged 10 commits into
redpanda-data:mainfrom
rockdatasrl001:pre-size-to-the-end-buffer
Aug 20, 2026
Merged

fix(scanner): pre-size the to_the_end read buffer from a source size …#478
Jeffail merged 10 commits into
redpanda-data:mainfrom
rockdatasrl001:pre-size-to-the-end-buffer

Conversation

@rockdatasrl001

Copy link
Copy Markdown
Contributor

What this does

The to_the_end scanner reads an entire source into a single message with
io.ReadAll, which is given no size hint. For the file input the size is
already known — the input calls Stat() on every file (for the mod time) — but
it was never used.

This change threads an optional SizeHint through scanner.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
(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.ReadAll was rewritten in
Go 1.26 and its own comment describes the strategy:

Build slices of exponentially growing size, then copy into a perfectly-sized
slice at the end.

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.ReadAll nor the append
godoc 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 fed
through a reader that returns realistic short reads (not one giant
bytes.Reader), io.ReadAll vs readAllHinted.

Raw go test -bench output (Go 1.26.6, -benchmem -count=6):

goos: linux
goarch: amd64
pkg: github.com/redpanda-data/benthos/v4/internal/impl/pure
cpu: AMD Ryzen 5 7640U w/ Radeon 760M Graphics
BenchmarkToTheEndReadAll-12          	      20	  52518838 ns/op	1277.81 MB/s	165347692 B/op	      37 allocs/op
BenchmarkToTheEndReadAll-12          	      21	  51125961 ns/op	1312.62 MB/s	165347169 B/op	      36 allocs/op
BenchmarkToTheEndReadAll-12          	      19	  54705585 ns/op	1226.73 MB/s	165347123 B/op	      36 allocs/op
BenchmarkToTheEndReadAll-12          	      24	  48705848 ns/op	1377.84 MB/s	165347139 B/op	      36 allocs/op
BenchmarkToTheEndReadAll-12          	      26	  48951980 ns/op	1370.91 MB/s	165347156 B/op	      36 allocs/op
BenchmarkToTheEndReadAll-12          	      20	  50688896 ns/op	1323.94 MB/s	165347443 B/op	      37 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      43	  27296848 ns/op	2458.48 MB/s	67117074 B/op	       2 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      42	  27096146 ns/op	2476.69 MB/s	67117072 B/op	       2 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      44	  26971602 ns/op	2488.13 MB/s	67117077 B/op	       2 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      43	  27192572 ns/op	2467.91 MB/s	67117074 B/op	       2 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      43	  27140304 ns/op	2472.66 MB/s	67117074 B/op	       2 allocs/op
BenchmarkToTheEndReadAllHinted-12    	      44	  26966928 ns/op	2488.56 MB/s	67117077 B/op	       2 allocs/op

benchstat (n=6), io.ReadAll (base) vs readAllHinted:

            │  io.ReadAll  │        readAllHinted        │
            │     B/op     │     B/op      vs base       │
ToTheEnd-12   157.69Mi ± 0%   64.01Mi ± 0%  -59.41% (p=0.002)

            │  io.ReadAll  │        readAllHinted        │
            │  allocs/op   │  allocs/op    vs base       │
ToTheEnd-12    36.000 ± 3%    2.000 ± 0%   -94.44% (p=0.002)

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/op figures 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 Stat and the read, so the hint
can be wrong in either direction. readAllHinted is written so the bytes returned
are identical to io.ReadAll for every hint value — correct, too small, too
large, zero, or negative:

  • If the source is larger than the hint, the buffer grows exactly as io.ReadAll
    would, paying a copy only for the excess.
  • If it is smaller, the read simply ends early.
  • The capacity is hint+1, not hint: the read loop only calls Read while
    cap > len, so a buffer sized to exactly the content length would hit
    len == cap on the final iteration and grow once more, reintroducing the
    reallocation this avoids.

Tests covering this (scanner_to_the_end_internal_test.go,
scanner_to_the_end_test.go):

  • Differential against io.ReadAll over 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.Reader alone would never exercise the
    refill path).
  • Exact-hint-no-realloc: asserts the returned buffer's capacity is size+1,
    which is the test that catches a regression of the +1 subtlety.
  • End-to-end through the public scanner config path with hints that are
    absent, exact, too small, too large, and negative — content is unchanged in all
    cases.

Latent defect fixed in passing: codec/scanner.go dropped fields

To make the hint reach the scanner from the file input, a bug had to be fixed in
public/service/codec/scanner.go. That layer sits between the input and the
scanner and reconstructs the ScannerSourceDetails, copying only Name:

sDetails := service.NewScannerSourceDetails()
sDetails.SetName(details.Name())

Any field added to SourceDetails is therefore silently dropped for every input
routed through this layer, with no compile error and no test failure — which is
exactly what made site 3 easy to miss. It now propagates SizeHint too (and
nil-guards details, which was an unchecked deref).

Open question for maintainers: rather than copying fields one by one, would you
prefer this layer pass details through wholesale so future fields can't be
dropped 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 — add SizeHint int64 to SourceDetails.
  • public/service/scanner.go — add SetSizeHint/SizeHint accessors.
  • public/service/codec/scanner.go — propagate SizeHint; nil-guard details.
  • internal/impl/io/input_file.go — move Stat() above scanner creation; set the
    hint from fInfo.Size(), guarded by IsRegular() (a FIFO, device or directory
    has no meaningful size to pre-allocate against).
  • internal/impl/pure/scanner_to_the_end.go — capture the hint (nil-safe); replace
    io.ReadAll with readAllHinted.
  • Tests + benchmark added; CHANGELOG updated.

Checks

make fmt, make lint and make test all pass (Go 1.26.6).

…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.
@CLAassistant

CLAassistant commented Aug 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread internal/impl/io/input_file_size_test.go Outdated
Comment thread internal/impl/pure/scanner_to_the_end_internal_test.go Outdated
Jeffail and others added 4 commits August 19, 2026 17:13
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

@josephwoodward josephwoodward Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Jeffail and others added 2 commits August 19, 2026 19:50
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.
@squiidz

squiidz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor
  1. Unclamped upfront allocation can panic the pipeline — scanner_to_the_end.go:93
    readAllHinted does make([]byte, 0, hint) with no ceiling. A sparse file (truncate -s 8E f) passes the IsRegular() guard and Stats at 2^63-1, so make panics makeslice: cap out of range inside NextBatch — reproduced by actually running it. Same panic for any file ≥ ~2GiB on 32-bit builds, and the exported SetSizeHint accepts arbitrary values from plugins. io.ReadAll could never crash this way since it allocated proportionally to bytes read. Fix: clamp the pre-allocation to a sane ceiling and let growth handle the rest.

  2. Every non-file source regresses — scanner_to_the_end.go:92
    Sockets, stdin, and http_client pass no hint, and readAllHinted(r, 0) uses the old append-grow loop — but go.mod requires Go 1.26, whose io.ReadAll uses a chunk list with one final right-sized copy. Measured on a 64MiB stream: 393.5MB total allocations vs 165.3MB (2.4x), plus 17.5% permanently retained over-capacity. The code comment claiming parity with io.ReadAll's allocation profile is factually wrong for the required Go version. One-line fix: if hint <= 0 { return io.ReadAll(r) }.

  3. Oversized hints pin memory for the message's lifetime — scanner_to_the_end.go:104
    The buffer is never right-sized on return. If a file Stats at 2GB but is truncated to 4KB before reading (copytruncate rotation), the message carries a 2GB backing array through the whole pipeline until GC. Deterministic whenever hint > content — the PR's own test passes hint = size*100. A glob over rotated files reproduces exactly the memory blow-up the PR claims to fix. Fix: copy to an exact-size slice when cap meaningfully exceeds len.

  4. Nil-guard asymmetry in the public codec API — public/service/codec/scanner.go:84
    The PR nil-guards details in codecRPublic.Create (line 122) but the sibling codecRInternal.Create still calls details.Name() unconditionally. Which impl runs depends on whether the end user's YAML uses scanner: or the deprecated codec: field — so Create(rdr, ack, nil) works or panics based on config the caller can't see. Mirror the guard at line 84.

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.
@Jeffail
Jeffail merged commit 95d2a32 into redpanda-data:main Aug 20, 2026
3 of 4 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.

5 participants