From e98d2696aa39c0c0a5c8a0e50f76f19be8144be9 Mon Sep 17 00:00:00 2001 From: Chenyi Wang Date: Wed, 26 Aug 2026 22:09:06 -0700 Subject: [PATCH 1/3] tarutil: buffer the tar stream and pool the copy buffer 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. --- cmd/ateom-microvm/internal/tarutil/tarutil.go | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/cmd/ateom-microvm/internal/tarutil/tarutil.go b/cmd/ateom-microvm/internal/tarutil/tarutil.go index 3d9f0eb5ec..3a8884f981 100644 --- a/cmd/ateom-microvm/internal/tarutil/tarutil.go +++ b/cmd/ateom-microvm/internal/tarutil/tarutil.go @@ -35,6 +35,7 @@ package tarutil import ( "archive/tar" + "bufio" "context" "errors" "fmt" @@ -45,10 +46,44 @@ import ( "path/filepath" "sort" "strings" + "sync" "golang.org/x/sys/unix" ) +// 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 31 once buffered. +const streamBufSize = 1 << 20 + +// 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. +var copyBufPool = sync.Pool{New: func() any { + b := make([]byte, 128<<10) + return &b +}} + +// 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. +func copyPooled(dst io.Writer, src io.Reader) (int64, error) { + bp := copyBufPool.Get().(*[]byte) + defer copyBufPool.Put(bp) + return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, *bp) +} + +type writerOnly struct{ io.Writer } +type readerOnly struct{ io.Reader } + // Create writes a tar archive of srcDir's contents to tarPath. Entry names are // relative to srcDir, so extracting into another directory reproduces the tree. // srcDir itself is not an entry. @@ -75,13 +110,19 @@ func CreateFiltered(ctx context.Context, tarPath, srcDir string, skip SkipFunc) } defer f.Close() - tw := tar.NewWriter(f) + bw := bufio.NewWriterSize(f, streamBufSize) + tw := tar.NewWriter(bw) if err := writeTree(ctx, tw, srcDir, skip); err != nil { return err } if err := tw.Close(); err != nil { return fmt.Errorf("closing tar %q: %w", tarPath, err) } + // The buffer has to reach the file before the sync below, or the sync + // durably persists a truncated archive. + if err := bw.Flush(); err != nil { + return fmt.Errorf("flushing tar %q: %w", tarPath, err) + } // Durable-dir tars are handed to atelet for upload as soon as we return, so // flush to disk rather than trusting the page cache to outlive us. if err := f.Sync(); err != nil { @@ -211,7 +252,7 @@ func copyFileInto(tw *tar.Writer, path string) error { return fmt.Errorf("opening %q: %w", path, err) } defer in.Close() - if _, err := io.Copy(tw, in); err != nil { + if _, err := copyPooled(tw, in); err != nil { return fmt.Errorf("archiving contents of %q: %w", path, err) } return nil @@ -243,7 +284,10 @@ func Extract(tarPath, dstDir string) error { // are applied after every child exists (see restoreDirMeta). dirs := map[string]*tar.Header{} - tr := tar.NewReader(f) + // Buffered for the same reason the writer is: an archive of many small + // entries is mostly 512-byte headers, and each one would otherwise be a + // read(2) of its own. + tr := tar.NewReader(bufio.NewReaderSize(f, streamBufSize)) for { hdr, err := tr.Next() if errors.Is(err, io.EOF) { @@ -286,7 +330,7 @@ func extractEntry(root *os.Root, tr *tar.Reader, hdr *tar.Header, name string, d if err != nil { return fmt.Errorf("creating file %q: %w", name, err) } - _, copyErr := io.Copy(out, tr) + _, copyErr := copyPooled(out, tr) closeErr := out.Close() if copyErr != nil { return fmt.Errorf("writing contents of %q: %w", name, copyErr) From 5afe8e9a989ecd7dddc9bda0d27dd9573dd168f2 Mon Sep 17 00:00:00 2001 From: Chenyi Wang Date: Fri, 28 Aug 2026 11:09:25 -0700 Subject: [PATCH 2/3] tarutil: pool the stream buffers and size them at 64 KiB 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. --- cmd/ateom-microvm/internal/tarutil/tarutil.go | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/cmd/ateom-microvm/internal/tarutil/tarutil.go b/cmd/ateom-microvm/internal/tarutil/tarutil.go index 3a8884f981..62aea43b6c 100644 --- a/cmd/ateom-microvm/internal/tarutil/tarutil.go +++ b/cmd/ateom-microvm/internal/tarutil/tarutil.go @@ -54,13 +54,41 @@ import ( // 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 31 once buffered. -const streamBufSize = 1 << 20 +// 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 + +// 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) }} +) // 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 @@ -110,7 +138,12 @@ func CreateFiltered(ctx context.Context, tarPath, srcDir string, skip SkipFunc) } defer f.Close() - bw := bufio.NewWriterSize(f, streamBufSize) + bw := tarWriterPool.Get().(*bufio.Writer) + bw.Reset(f) + defer func() { + bw.Reset(nil) + tarWriterPool.Put(bw) + }() tw := tar.NewWriter(bw) if err := writeTree(ctx, tw, srcDir, skip); err != nil { return err @@ -287,7 +320,13 @@ func Extract(tarPath, dstDir string) error { // Buffered for the same reason the writer is: an archive of many small // entries is mostly 512-byte headers, and each one would otherwise be a // read(2) of its own. - tr := tar.NewReader(bufio.NewReaderSize(f, streamBufSize)) + br := tarReaderPool.Get().(*bufio.Reader) + br.Reset(f) + defer func() { + br.Reset(nil) + tarReaderPool.Put(br) + }() + tr := tar.NewReader(br) for { hdr, err := tr.Next() if errors.Is(err, io.EOF) { From 39f27eb3ad929463d6c331c834635a7959446237 Mon Sep 17 00:00:00 2001 From: Chenyi Wang Date: Sun, 30 Aug 2026 23:54:56 -0700 Subject: [PATCH 3/3] tarutil: trim the buffer comments and assert the size relation The comments on the stream and copy buffers described how the code got here rather than what it does, and the requirement that file contents stay larger than the stream buffer was only stated in prose. Name the copy buffer size and let a constant conversion enforce the relation, so shrinking it below streamBufSize fails to compile instead of silently routing every content byte back through a memcpy. --- cmd/ateom-microvm/internal/tarutil/tarutil.go | 58 +++++-------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/cmd/ateom-microvm/internal/tarutil/tarutil.go b/cmd/ateom-microvm/internal/tarutil/tarutil.go index 62aea43b6c..0a89ad746b 100644 --- a/cmd/ateom-microvm/internal/tarutil/tarutil.go +++ b/cmd/ateom-microvm/internal/tarutil/tarutil.go @@ -51,58 +51,29 @@ import ( "golang.org/x/sys/unix" ) -// 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. +// 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 -// 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. +// copyBufSize is the scratch buffer io.CopyBuffer streams file contents +// through, in place of the fresh buffer io.Copy allocates per call. +const copyBufSize = 128 << 10 + +// File contents must bypass the stream buffer. +const _ = uint(copyBufSize - streamBufSize - 1) + +// 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) }} ) -// 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) + b := make([]byte, copyBufSize) return &b }} -// 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. func copyPooled(dst io.Writer, src io.Reader) (int64, error) { bp := copyBufPool.Get().(*[]byte) defer copyBufPool.Put(bp) @@ -317,9 +288,8 @@ func Extract(tarPath, dstDir string) error { // are applied after every child exists (see restoreDirMeta). dirs := map[string]*tar.Header{} - // Buffered for the same reason the writer is: an archive of many small - // entries is mostly 512-byte headers, and each one would otherwise be a - // read(2) of its own. + // Buffered like the writer: most of an archive is 512-byte headers, each + // of which would otherwise be a read(2) of its own. br := tarReaderPool.Get().(*bufio.Reader) br.Reset(f) defer func() {