pool io.Copy buffers in ioCopier to reduce GC pressure#49
pool io.Copy buffers in ioCopier to reduce GC pressure#49
Conversation
Replace the cureent io.Copy with the io.CopyBuffer with pooled buffer
There was a problem hiding this comment.
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.Poolthat hands out reusable 32KB copy buffers. - Switch
ioCopierfromio.Copytoio.CopyBufferusing 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
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. |
|
Maybe my analysis is not right but this is what I have seen while looking at the profile:
io.Copy(*io.PipeWriter, *io.PipeReader) → 32KB BUFFER ALLOCATEDThis is the Go in-memory pipe ( function.go creates r, w := io.Pipe()for every When stages are chained:
So I think the 255MB of |
|
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 |
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>
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+
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.Copyallocates a fresh 32KB buffer on every call when neither the reader nor writer implements ReadFrom/WriterTo, which is the common case forioCopiersince it typically copies between pipe file descriptors.In high-throughput services this makes
io.copyBufferone 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.Poolof *[]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.CopyBufferhas identical semantics toio.Copywhen a buffer is provided.