Skip to content

atelet: preserve holes when copying a local checkpoint - #769

Merged
Dmitry Berkovich (dberkov) merged 3 commits into
agent-substrate:mainfrom
BenTheElder:atelet-sparse-copy
Aug 8, 2026
Merged

atelet: preserve holes when copying a local checkpoint#769
Dmitry Berkovich (dberkov) merged 3 commits into
agent-substrate:mainfrom
BenTheElder:atelet-sparse-copy

Conversation

@BenTheElder

Copy link
Copy Markdown
Collaborator

copyFile used a plain io.Copy, which reads a hole as zeroes and writes it back as data. The biggest thing it copies is a guest memory image, which is mostly unallocated: staging a local checkpoint for a restore inflated a 164MiB snapshot into its full 2GiB logical size. (Note: That itself is a bug, see #679)

That costs disk and I/O on every local checkpoint restore, and it compounds, because cloud-hypervisor then loads the whole image and the next checkpoint it writes is dense in turn. Five pause cycles on one actor left 8.2GB of local checkpoints where a few hundred MiB would do.

Copy only the populated extents, located with SEEK_DATA and SEEK_HOLE, and fall back to the dense copy when the filesystem cannot report holes.

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

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

copyFile used a plain io.Copy, which reads a hole as zeroes and writes it
back as data. The biggest thing it copies is a guest memory image, which is
mostly unallocated: staging a local checkpoint for a restore inflated a
164MiB snapshot into its full 2GiB logical size.

That costs disk and I/O on every local checkpoint restore, and it compounds,
because cloud-hypervisor then loads the whole image and the next checkpoint
it writes is dense in turn. Five pause cycles on one actor left 8.2GB of
local checkpoints where a few hundred MiB would do.

Copy only the populated extents, located with SEEK_DATA and SEEK_HOLE, and
fall back to the dense copy when the filesystem cannot report holes.
The dense io.Copy this replaced could hand the whole file to
copy_file_range, since os.File implements ReadFrom. Copying extents through
a userspace buffer gave that up, which would make a fully populated
checkpoint — a guest that really did touch all its RAM — slower to stage
than before.

Copy each extent with copy_file_range where the destination exposes a
descriptor, falling back to the buffered copy when the kernel or filesystem
refuses (EXDEV across filesystems, ENOSYS on old kernels) or when the
destination is not a file.
@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Dmitry Berkovich (@dberkov) this would've cost a lot for local snapshots with uVM specifically, not sure what you were testing.

Comment thread cmd/atelet/main.go
// It reports errKernelCopyUnsupported when the kernel or filesystem cannot do the
// copy — most commonly EXDEV, when source and destination are on different
// filesystems — so the caller can fall back to a userspace copy.
func kernelCopyRange(srcFd, dstFd int, off, length int64) (int64, error) {

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.

maxKernelCopy is set to 1 << 30 (1 GiB). Performing a 1 GiB copy_file_range syscall can take hundreds of milliseconds to seconds. In Go 1.14+, the Go runtime sends SIGURG signals for asynchronous goroutine preemption. If a signal is caught while copy_file_range is in progress before any bytes are transferred, unix.CopyFileRange returns unix.EINTR.

Currently, unix.EINTR is not handled in kernelCopyRange nor caught as unsupported in copySparse. As a result, copySparse returns copying ...: interrupted system call, aborting the entire checkpoint restore on a transient signal.

func kernelCopyRange(srcFd, dstFd int, off, length int64) (int64, error) {
	if length > maxKernelCopy {
		length = maxKernelCopy
	}
	roff, woff := off, off
	for {
		n, err := unix.CopyFileRange(srcFd, &roff, dstFd, &woff, int(length), 0)
		if errors.Is(err, unix.EINTR) {
			continue
		}
		if err != nil {
			switch {
			case errors.Is(err, unix.ENOSYS),
				errors.Is(err, unix.EXDEV),
				errors.Is(err, unix.EOPNOTSUPP),
				errors.Is(err, unix.EPERM),
				errors.Is(err, unix.EINVAL),
				errors.Is(err, unix.EBADF):
				return 0, errKernelCopyUnsupported
			}
			return 0, err
		}
		if n == 0 {
			return 0, errKernelCopyUnsupported
		}
		return int64(n), nil
	}
}

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.

fixed

Comment thread cmd/atelet/main_test.go

// allocatedBytes reports how much disk a file actually occupies, which is less than its
// size when it has holes.
func allocatedBytes(t *testing.T, path string) int64 {

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:
copyrange_other.go was introduced so non-Linux platforms (e.g. macOS / Windows) can build cmd/atelet during local development. However, main_test.go lacks build tags, and syscall.Stat / syscall.Stat_t are not available on Windows (GOOS=windows), breaking go test ./cmd/atelet on Windows environments.

Suggested Fix: Move allocatedBytes and sparse assertions into main_linux_test.go (with //go:build linux), or wrap allocatedBytes with OS-specific helpers.

@BenTheElder Benjamin Elder (BenTheElder) Aug 8, 2026

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.

cmd/atelet doesn't build for windows as-is before this PR, I'm not sure if we care about windows.

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.

I'd go so far as to say we actively don't care about windows, except perhaps client tools/libraries. In-cluster tools it is useful to support macOS developers where mostly things are the same anyhow, but windows is so different and I doubt we will target windows workloads.

@dberkov

Copy link
Copy Markdown
Collaborator

Is new behavior applied for both gVisor & microVMs ? I think gVisor already does all the optimization and the new code might make gVisor work slower.

Two robustness gaps from review:

The extent loop trusted SEEK_HOLE to advance. A filesystem reporting a hole
at or before where the iteration started would spin forever, and data
reported past the size being copied would seek beyond EOF. Bail out on the
latter and fail loudly on the former rather than hang.

copy_file_range is interruptible and the Go runtime signals goroutines to
preempt them, so EINTR is expected on a copy this large. It was treated as
fatal, which would abort a restore on a transient signal; retry instead.
@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Is new behavior applied for both gVisor & microVMs ? I think gVisor already does all the optimization and the new code might make gVisor work slower.

That's a fair concern, but the old routine wasn't actually faster, it was a naive io.Copy loop which is generic around readers/writers. This is an optimized file copy routine. It micro-benchmarks faster even for dense files.

@dberkov
Dmitry Berkovich (dberkov) merged commit 285232a into agent-substrate:main Aug 8, 2026
11 checks passed
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.

2 participants