tarutil: buffer the tar stream and pool the copy buffer - #1262
tarutil: buffer the tar stream and pool the copy buffer#1262Chenyi Wang (chw120) wants to merge 2 commits into
Conversation
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.
fb8d00c to
e98d269
Compare
Benjamin Elder (BenTheElder)
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
should we be pooling the bufio buffers?
There was a problem hiding this comment.
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.
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.
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. |
|
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 |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
| // 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
| // 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 | ||
| }} |
There was a problem hiding this comment.
| // 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.
| // 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) }} | ||
| ) |
There was a problem hiding this comment.
| // 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) }} | |
| ) |
| // 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 |
There was a problem hiding this comment.
| // 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 |
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):
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.