Skip to content

Reuse a scratch buffer when reading child output - #283

Open
tas50 wants to merge 2 commits into
chef:mainfrom
tas50:perf/reuse-read-buffers
Open

Reuse a scratch buffer when reading child output#283
tas50 wants to merge 2 commits into
chef:mainfrom
tas50:perf/reuse-read-buffers

Conversation

@tas50

@tas50 tas50 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Description

read_nonblock(READ_SIZE) allocates a fresh 4 KB String on every read. For a command with a lot of output that's nearly all of the garbage ShellOut produces — an 8 MB stdout means ~2000 needless allocations that exist only to be copied into @stdout and thrown away.

IO#read_nonblock takes an optional buffer to read into, so this hands it one and reuses it across reads.

That's only safe because #<< copies the bytes into @stdout/@stderr. A live stream is different — it's documented as anything responding to <<, and it's free to hold onto whatever it's handed. Reusing one buffer there would silently alias every chunk together (an Array used as a live_stream would end up with N references to the same String). So when a live stream is attached, each read still gets its own String, exactly as today.

That split is what the new #drain helper expresses, and it has the side benefit of collapsing the three near-identical read loops into one.

Benchmark

cat of an 8 MB file, 25 runs, ruby 4.0.6, arm64-darwin. objects/run is GC.stat(:total_allocated_objects) across a single run_command.

median min objects/run
before 30.76 ms 16.81 ms 1997
after 16.10 ms 13.42 ms 100

48% faster with 95% fewer allocations. The min column (13.42 ms vs 16.81 ms) is the GC-free floor — the rest of the median gap is GC pressure the allocations were causing.

Full matrix, same machine:

case before (median / objects) after (median / objects)
tiny stdout (echo hi) 6.94 ms / 51 6.23 ms / 50
1 MB stdout 9.52 ms / 288 8.39 ms / 44
8 MB stdout 30.76 ms / 1997 16.10 ms / 100
8 MB stdout + live_stream 30.38 ms / 3957 31.64 ms / 3960
1 MB stderr 22.82 ms / 311 23.17 ms / 48

The live_stream row is deliberately flat — that path runs the same code it does today.

Verification

Output is byte-identical, checked against main rather than assumed:

  • 8 MB of stdout — same SHA256, same size, same encoding (UTF-8)
  • interleaved binary + multibyte stdout and stderr ("\xff\xfe café" / "err\xc3\xa9") — same SHA256, same encoding (ASCII-8BIT) on both streams
  • live_stream receiving 1954 chunks — same joined SHA256, and explicitly asserted the chunks are not aliased to each other
  • pre-exec failure still propagates (Errno::ENOENT), which exercises the process_status pipe's buffer

Plus:

  • bundle exec rspec — 147 examples, 0 failures (unchanged from main)
  • bundle exec cookstyle --chefstyle -c .rubocop.yml — no offenses

Note

The first commit is a spellcheck fix. The spellcheck job only scans files a PR touches, so any change to unix.rb fails on identifiers that have been in the file for years (pgid, sgids, WNOHANG, endgrent, …). It also flagged proccess, which is a genuine typo in a comment, so that's fixed rather than added to the dictionary. The same commit is in #282 — whichever lands first, the other needs a trivial rebase.

tas50 added 2 commits August 27, 2026 20:57
The spellcheck job only scans files a PR touches, so any change to
lib/mixlib/shellout/unix.rb or lib/mixlib/shellout.rb fails on
identifiers that have been in those files for years: cgroupv, ducktype,
endgrent, getgrent, LOGNAME, pgid, secondarygroups, seconderies, sgids
and WNOHANG.

"proccess" was flagged too, but that one is an actual typo in a comment
rather than a word worth teaching the dictionary, so fix it instead.

Signed-off-by: Tim Smith <tsmith84@proton.me>
read_nonblock allocates a fresh READ_SIZE String on every read. For a
command with a lot of output that is nearly all of the garbage ShellOut
produces -- an 8 MB stdout means ~2000 needless 4 KB allocations.

It takes an optional buffer to read into instead, so hand it one and
reuse it. That is only safe because #<< copies the bytes into @stdout /
@stderr; a live stream is free to hold onto whatever it is handed, so
when one is attached each read still gets its own String. That split is
what the new #drain helper expresses, and it also collapses the three
near-identical read loops into one.

    cat an 8 MB file, ruby 4.0.6      median     min   objects/run
    before                            30.76 ms  16.81 ms      1997
    after                             16.10 ms  13.42 ms       100

    with live_stream (unchanged path)
    before                            30.38 ms  19.56 ms      3957
    after                             31.64 ms  17.32 ms       3960

48% faster on the common path with 95% fewer allocations, and no change
for live streams. Output is byte-identical: verified equal SHA256 and
encoding for 8 MB of stdout, and for interleaved binary stdout/stderr.

Signed-off-by: Tim Smith <tsmith84@proton.me>
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