Skip to content

pool io.Copy buffers in ioCopier to reduce GC pressure#49

Open
migue wants to merge 7 commits intomainfrom
git-systems/pooled-copies
Open

pool io.Copy buffers in ioCopier to reduce GC pressure#49
migue wants to merge 7 commits intomainfrom
git-systems/pooled-copies

Conversation

@migue
Copy link
Copy Markdown
Collaborator

@migue migue commented Apr 7, 2026

While doing some perfomance analysis in one of our internal services I found that pipelines using the library showed relatively big allocation rates (12.6 MB/s).

io.Copy allocates a fresh 32KB buffer on every call when neither the reader nor writer implements ReadFrom/WriterTo, which is the common case for ioCopier since it typically copies between pipe file descriptors.

In high-throughput services this makes io.copyBuffer one of the top allocation sources. A 20-second heap profile showed 255MB allocated from io.copyBuffer, contributing ~12.6 MB/s of allocation pressure and driving GC worker saturation to ~70% of available procs per cycle.

The change introduced here adds a sync.Pool of *[]byte (32KB, matching io.Copy's default) and use io.CopyBuffer with the pooled buffer. The buffer is returned to the pool after each copy completes.

This is a safe, drop-in change: io.CopyBuffer has identical semantics to io.Copy when a buffer is provided.

Replace the cureent io.Copy with the io.CopyBuffer with pooled buffer
@migue migue requested a review from a team as a code owner April 7, 2026 13:32
Copilot AI review requested due to automatic review settings April 7, 2026 13:32
@migue migue self-assigned this Apr 7, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces allocation/GC pressure in high-throughput pipelines by reusing a shared 32KB buffer for ioCopier copy operations, avoiding io.Copy’s per-call buffer allocation in the common pipe FD case.

Changes:

  • Introduce a package-level sync.Pool that hands out reusable 32KB copy buffers.
  • Switch ioCopier from io.Copy to io.CopyBuffer using the pooled buffer.
Show a summary per file
File Description
pipe/iocopier.go Adds a pooled 32KB buffer and uses io.CopyBuffer to reduce allocations during copying.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 1/1 changed files
  • Comments generated: 0

carlosmn
carlosmn previously approved these changes Apr 7, 2026
@carlosmn
Copy link
Copy Markdown

carlosmn commented Apr 7, 2026

when neither the reader nor writer implements ReadFrom/WriterTo, which is the common case for ioCopier since it typically copies between pipe file descriptors.

This is surprising. I thought I had checked some time back that you do end up with one of these for file descriptors. Or maybe we erase the type enough that the runtime can't tell.

@migue
Copy link
Copy Markdown
Collaborator Author

migue commented Apr 8, 2026

Maybe my analysis is not right but this is what I have seen while looking at the profile:

*os.File has both interfaces: no type erasure is happening. The 32KB buffer allocation only occurs in one specific combination:

   io.Copy(*io.PipeWriter, *io.PipeReader) → 32KB BUFFER ALLOCATED

This is the Go in-memory pipe (io.Pipe()), not the OS pipe. And this is what go-pipe uses to wire Go function stages together:

function.go creates r, w := io.Pipe()

for every pipe.Function(...).

When stages are chained:

  • Command → Command: OS fd → OS fd = zero-copy ✓
  • Command → Function: OS fd is *os.File → WriteTo kicks in ✓
  • Function → anything: Output is *io.PipeReader → no WriterTo → buffer allocated ✗

So I think the 255MB of io.copyBuffer allocations come specifically from *io.PipeReader ↔ *io.PipeWriter transfers between Go function stages. I think the pooled buffer fix is still correct for these cases (the reason I opened this PR), but OS file descriptor copies were already zero-copy.

@carlosmn
Copy link
Copy Markdown

carlosmn commented Apr 8, 2026

Ok, it makes more sense given that the in-memory "pipe" is not a pipe or a file descriptor. It seems a bit surprising that you wouldn't implement ReadFrom for this buffer but oh well.

znull and others added 4 commits April 8, 2026 13:42
On Go 1.26+, *os.File implements WriterTo, which causes
io.CopyBuffer to bypass the provided pool buffer entirely.
Instead, File.WriteTo → genericWriteTo → io.Copy allocates
a fresh 32KB buffer on every call.

This test detects the bypass by counting allocations during
ioCopier copy operations with an *os.File source (which is
the common case when the last pipeline stage is a commandStage).

The test currently FAILS, demonstrating the problem.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Go 1.26+, *os.File implements WriterTo. When ioCopier's reader is an
*os.File (the common case for commandStage), io.CopyBuffer detects
WriterTo and calls File.WriteTo instead of using the provided pool
buffer. File.WriteTo's sendfile path fails (the dest is not a network
connection), so it falls back to genericWriteTo → io.Copy, which
allocates a fresh 32KB buffer on every call — defeating the sync.Pool
entirely.

Fix: wrap the reader in readerOnly{} to strip all interfaces except
Read, forcing io.CopyBuffer to use the pool buffer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When using WithStdout, the writer is wrapped in nopWriteCloser,
which hides the ReaderFrom interface of the underlying writer.
This prevents io.CopyBuffer from dispatching to ReadFrom for
potential zero-copy paths (e.g., when the destination is a
network connection or has a custom ReadFrom implementation).

This test currently FAILS, demonstrating the problem.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When the pipeline's stdout is set via WithStdout, the writer is
wrapped in nopWriteCloser to add a no-op Close method. This
wrapper hides the ReaderFrom interface of the underlying writer,
preventing io.CopyBuffer from dispatching to it.

Fix: unwrap nopWriteCloser in ioCopier and call ReadFrom directly
when available. This enables zero-copy when the destination has a
meaningful ReadFrom (e.g., network connections, custom writers).
For the pipe-to-pipe *os.File case, File.ReadFrom's zero-copy
paths don't yet support pipe sources, so a follow-up commit adds
direct splice(2) for that case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
znull
znull previously approved these changes Apr 8, 2026
@znull
Copy link
Copy Markdown
Contributor

znull commented Apr 8, 2026

@migue @carlosmn This PR sent me down a rabbit hole, and I ended up with #50

znull and others added 2 commits April 8, 2026 15:27
Check error returns from pw.Write, c.Start, c.Wait. Remove
redundant embedded field selectors (w.Buffer.String → w.String).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Enable sendfile for network destinations; fix pool bypass on Go 1.26+
@znull znull dismissed stale reviews from carlosmn and themself via 3f226f1 April 9, 2026 07:51
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.

4 participants