Skip to content

tarutil: buffer the tar stream and pool the copy buffer - #1262

Open
Chenyi Wang (chw120) wants to merge 2 commits into
agent-substrate:mainfrom
chw120:tarutil-stream-buffers
Open

tarutil: buffer the tar stream and pool the copy buffer#1262
Chenyi Wang (chw120) wants to merge 2 commits into
agent-substrate:mainfrom
chw120:tarutil-stream-buffers

Conversation

@chw120

Copy link
Copy Markdown
Collaborator

Archiving and restoring a durable dir runs inside the VM's pause window, so its cost is actor downtime. Two mechanical inefficiencies dominated it, both independent of how much data the dir actually holds.

A tar is a sequence of 512-byte blocks, and the stream was written straight to the file, so every header, short file, and padding tail became its own write(2): 120904 of them for a 30k-file tree, against 31 once a 1 MiB buffer sits in between. The reader is buffered for the same reason.

The larger cost was allocation. io.Copy honours a WriterTo on src or a ReaderFrom on dst before it looks at any buffer, and *os.File implements both. Neither fast path can complete here -- the other end is a tar stream, not a file, so sendfile/copy_file_range do not apply -- and the generic fallback each one drops into allocates a fresh 32 KiB buffer. That is one allocation per entry on both the archive and the restore side: 965 MiB of garbage and 287 collections for a 30k-file tree, all of it swept while the actor is frozen. copyPooled masks both interfaces so io.CopyBuffer actually uses the pooled buffer it is handed.

Measured on a 30000-file, 30874624-byte tree (linux/arm64, 9 repeats):

             before              after
Create    274 ms  964.6 MiB   165 ms  28.6 MiB   GC 287 -> 12
Extract   583 ms  979.1 MiB   490 ms  44.7 MiB   GC 279 -> 19

Output is unchanged: the archive hashes identically to the one origin/main produces (sha256 6f524c45...), and so does a re-archive of the extracted tree. The existing round-trip tests for xattrs, device nodes, FIFOs, ownership, and special mode bits all pass.

It's a good idea to open an issue first for discussion.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

Archiving and restoring a durable dir runs inside the VM's pause window, so
its cost is actor downtime. Two mechanical inefficiencies dominated it, both
independent of how much data the dir actually holds.

A tar is a sequence of 512-byte blocks, and the stream was written straight to
the file, so every header, short file, and padding tail became its own
write(2): 120904 of them for a 30k-file tree, against 31 once a 1 MiB buffer
sits in between. The reader is buffered for the same reason.

The larger cost was allocation. io.Copy honours a WriterTo on src or a
ReaderFrom on dst before it looks at any buffer, and *os.File implements both.
Neither fast path can complete here -- the other end is a tar stream, not a
file, so sendfile/copy_file_range do not apply -- and the generic fallback each
one drops into allocates a fresh 32 KiB buffer. That is one allocation per
entry on both the archive and the restore side: 965 MiB of garbage and 287
collections for a 30k-file tree, all of it swept while the actor is frozen.
copyPooled masks both interfaces so io.CopyBuffer actually uses the pooled
buffer it is handed.

Measured on a 30000-file, 30874624-byte tree (linux/arm64, 9 repeats):

                 before              after
    Create    274 ms  964.6 MiB   165 ms  28.6 MiB   GC 287 -> 12
    Extract   583 ms  979.1 MiB   490 ms  44.7 MiB   GC 279 -> 19

Output is unchanged: the archive hashes identically to the one origin/main
produces (sha256 6f524c45...), and so does a re-archive of the extracted tree.
The existing round-trip tests for xattrs, device nodes, FIFOs, ownership, and
special mode bits all pass.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

overall looks like a good improvement, thanks.

Worth noting: if there are any sparse files in the durabledir (from something the workload is doing), we'll wind up densifying them by wrapping tr in readerOnly, we probably don't have a use case yet but we might have to sort that out at some point.

The 1MiB bufio per tar may get expensive if we have a lot of concurrent suspend with durdir in the multi-actor future.

defer f.Close()

tw := tar.NewWriter(f)
bw := bufio.NewWriterSize(f, streamBufSize)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should we be pooling the bufio buffers?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, both come from a pool now.

Review feedback on the previous commit: the copy buffers were pooled but
the bufio stream buffers were not, and a 1 MiB buffer per archive gets
expensive once several actors suspend at once.

Pooling them is the smaller half. They are one per archive rather than one
per file, so unlike copyBufPool they were never much garbage; reusing them
mainly keeps the two allocations off a concurrent checkpoint path. The
buffers are Reset(nil) on the way back so a pooled entry does not pin the
closed *os.File it was last bound to.

The size is the half that matters, and 1 MiB turns out to buy nothing.
Sweeping the buffer over 16 KiB..4 MiB on a 30k-file tree (51901440-byte
archive, 9 repeats, median):

    buffer     writes    reads   create ms   extract ms
    none       116607    75120       251.6         60.6
    16 KiB       3168     3168       209.6         43.2
    64 KiB        792      792       210.7         42.4
    128 KiB       396      396       209.4         42.6
    256 KiB       198      198       206.9         41.8
    1 MiB          50       50       208.7         42.0
    4 MiB          13       13       210.8         42.8

Everything above 16 KiB is flat to within run-to-run noise: once the
syscall count is off the critical path the remaining cost is the walk,
the stats, and the xattrs, none of which care how big the buffer is. So
the size is now chosen on memory alone -- the smallest that still sits
inside the flat region, with margin for a tree whose file sizes differ
from the one measured. 64 KiB holds 16x less per archive in flight than
1 MiB did, for no measurable time.

Output is unchanged: the archive hashes identically with the 1 MiB
buffer, with the 64 KiB buffer, and with no pooling at all.
@chw120

Copy link
Copy Markdown
Collaborator Author

overall looks like a good improvement, thanks.

Worth noting: if there are any sparse files in the durabledir (from something the workload is doing), we'll wind up densifying them by wrapping tr in readerOnly, we probably don't have a use case yet but we might have to sort that out at some point.

Thanks Benjamin Elder (@BenTheElder) for reviewing.

From my learning, archive/tar doesn't write GNU sparse extensions, so there's no way to represent a hole in the archive however the bytes arrive; and neither os.File.WriteTo nor ReadFrom punches holes — their fast paths only engage when both ends are files, which never happens on this path. Sparse files were already densified on main. The archive hashing identically before and after is the direct evidence.

Looks it'd be SEEK_HOLE/SEEK_DATA on create and hole punching on extract, which is separate from this PR.

The 1MiB bufio per tar may get expensive if we have a lot of concurrent suspend with durdir in the multi-actor future.

It turned out 1 MiB wasn't buying anything. Updated to 64 KiB now, 16× less per archive in flight.

One thing that fell out of the smaller size: at 64 KiB the stream buffer is below copyBufPool's 128 KiB, and bufio.Writer hands a write straight to the file when it's larger than the whole buffer. So file contents now bypass the stream buffer instead of being copied through it, and the buffer only batches the headers and padding it was added for. On a large-file tree (40 × 8 MiB) that's 8× more write(2) than 1 MiB but 320 MiB less memcpy, and it measures slightly faster. Both constants now carry a comment noting the sizes are coupled.

@BenTheElder

Copy link
Copy Markdown
Collaborator

thanks, I suppose we may need some kind of IFTT presubmit someday

"golang.org/x/sys/unix"
)

// streamBufSize buffers the tar stream against the underlying file. A tar is a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: document the current state, not the previous state. this is a bad behavior claude has this comment is way longer than useful, and it takes a lot of time to read the code when we do this.

Comment on lines +97 to +105
// copyPooled is io.Copy with a reused buffer.
//
// The interface masking is load-bearing, not decoration. io.CopyBuffer honors
// a WriterTo on src or a ReaderFrom on dst before it ever looks at the supplied
// buffer, and *os.File implements both. Neither fast path can complete here —
// the other end is a tar stream rather than a file, so sendfile/copy_file_range
// do not apply — and the generic fallback each one drops into allocates a
// buffer of its own. Hiding the two methods keeps the copy on the path that
// actually uses the pooled buffer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// copyPooled is io.Copy with a reused buffer.
//
// The interface masking is load-bearing, not decoration. io.CopyBuffer honors
// a WriterTo on src or a ReaderFrom on dst before it ever looks at the supplied
// buffer, and *os.File implements both. Neither fast path can complete here —
// the other end is a tar stream rather than a file, so sendfile/copy_file_range
// do not apply — and the generic fallback each one drops into allocates a
// buffer of its own. Hiding the two methods keeps the copy on the path that
// actually uses the pooled buffer.
// copyPooled masks the fast-path interfaces so io.CopyBuffer uses the pooled buffer.

the implementation shows which methods are masked

Comment on lines +85 to +95
// copyBufPool holds the scratch buffers used to stream file contents. io.Copy
// allocates a fresh 32 KiB buffer per call (see copyPooled), and a durable dir
// holds tens of thousands of files, so the garbage — 965 MiB for a 30k-file
// tree — and the collections it forces are paid inside the VM's pause window.
//
// The size is not free to change: it must stay above streamBufSize, or content
// stops bypassing the stream buffer (see the note there).
var copyBufPool = sync.Pool{New: func() any {
b := make([]byte, 128<<10)
return &b
}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// copyBufPool holds the scratch buffers used to stream file contents. io.Copy
// allocates a fresh 32 KiB buffer per call (see copyPooled), and a durable dir
// holds tens of thousands of files, so the garbage — 965 MiB for a 30k-file
// tree — and the collections it forces are paid inside the VM's pause window.
//
// The size is not free to change: it must stay above streamBufSize, or content
// stops bypassing the stream buffer (see the note there).
var copyBufPool = sync.Pool{New: func() any {
b := make([]byte, 128<<10)
return &b
}}
const copyBufSize = 128 << 10
// File contents must bypass the stream buffer.
const _ = uint(copyBufSize - streamBufSize - 1)
var copyBufPool = sync.Pool{New: func() any {
b := make([]byte, copyBufSize)
return &b
}}

The compile-time check captures the size constraint; the variable name already explains the pool.

Comment on lines +74 to +83
// tarWriterPool and tarReaderPool hold the stream buffers above. These are one
// per archive rather than one per file, so on their own they save far less
// garbage than copyBufPool does; pooling them keeps a worker that checkpoints
// several actors at once reusing a handful of buffers instead of allocating a
// fresh one per suspend. A buffer must be Reset(nil) before it goes back, or
// the pooled entry pins the closed *os.File it was last bound to.
var (
tarWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, streamBufSize) }}
tarReaderPool = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, streamBufSize) }}
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// tarWriterPool and tarReaderPool hold the stream buffers above. These are one
// per archive rather than one per file, so on their own they save far less
// garbage than copyBufPool does; pooling them keeps a worker that checkpoints
// several actors at once reusing a handful of buffers instead of allocating a
// fresh one per suspend. A buffer must be Reset(nil) before it goes back, or
// the pooled entry pins the closed *os.File it was last bound to.
var (
tarWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, streamBufSize) }}
tarReaderPool = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, streamBufSize) }}
)
// Stream buffers must be Reset(nil) before pooling to avoid retaining files.
var (
tarWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, streamBufSize) }}
tarReaderPool = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, streamBufSize) }}
)

Comment on lines +54 to +72
// streamBufSize buffers the tar stream against the underlying file. A tar is a
// sequence of 512-byte blocks, so an unbuffered stream turns every header,
// every short file, and every padding tail into its own write(2): archiving
// 30k small files issued 120904 of them, against one per 64 KiB of archive
// once buffered.
//
// 64 KiB rather than something larger: the win is in getting the syscall count
// off the critical path, and that is spent well before this size. Sweeping
// 16 KiB..4 MiB over a 30k-file tree is flat to within run-to-run noise, so a
// bigger buffer buys nothing measurable and only adds memory — which is held
// per archive in flight, and a worker may checkpoint several actors at once.
//
// Staying under copyBufPool's buffer is deliberate, not incidental. bufio hands
// a write straight to the file when it is larger than the whole buffer, so file
// contents — which arrive in copyBufPool-sized chunks — bypass this buffer
// instead of being copied through it, and only the headers and padding it
// exists for are batched. Raising this above 128 KiB, or shrinking copyBufPool
// below it, silently puts every content byte back through a memcpy.
const streamBufSize = 64 << 10

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// streamBufSize buffers the tar stream against the underlying file. A tar is a
// sequence of 512-byte blocks, so an unbuffered stream turns every header,
// every short file, and every padding tail into its own write(2): archiving
// 30k small files issued 120904 of them, against one per 64 KiB of archive
// once buffered.
//
// 64 KiB rather than something larger: the win is in getting the syscall count
// off the critical path, and that is spent well before this size. Sweeping
// 16 KiB..4 MiB over a 30k-file tree is flat to within run-to-run noise, so a
// bigger buffer buys nothing measurable and only adds memory — which is held
// per archive in flight, and a worker may checkpoint several actors at once.
//
// Staying under copyBufPool's buffer is deliberate, not incidental. bufio hands
// a write straight to the file when it is larger than the whole buffer, so file
// contents — which arrive in copyBufPool-sized chunks — bypass this buffer
// instead of being copied through it, and only the headers and padding it
// exists for are batched. Raising this above 128 KiB, or shrinking copyBufPool
// below it, silently puts every content byte back through a memcpy.
const streamBufSize = 64 << 10
// streamBufSize batches tar headers and padding into fewer file operations.
// It must be smaller than copyBufSize so file contents bypass the buffer.
const streamBufSize = 64 << 10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants