Skip to content

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

Open
Benjamin Elder (BenTheElder) wants to merge 2 commits into
agent-substrate:mainfrom
BenTheElder:atelet-sparse-copy
Open

atelet: preserve holes when copying a local checkpoint#769
Benjamin Elder (BenTheElder) wants to merge 2 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
}
return fmt.Errorf("seeking to data at %d: %w", off, err)
}
holeOff, err := unix.Seek(fd, dataOff, unix.SEEK_HOLE)

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.

If unix.Seek(unix.SEEK_HOLE) returns holeOff <= off (for instance, if concurrent modifications or a non-standard filesystem implementation returns an offset that fails to advance past off), off = holeOff will fail to progress, causing an infinite loop. Additionally, if dataOff >= size is returned, unix.Seek(dataOff, unix.SEEK_HOLE) would query past EOF.

if dataOff >= size {
break
}
holeOff, err := unix.Seek(fd, dataOff, unix.SEEK_HOLE)
if err != nil {
return fmt.Errorf("seeking to hole at %d: %w", dataOff, err)
}
if holeOff <= off {
return fmt.Errorf("seek to hole at %d returned non-advancing offset %d", dataOff, holeOff)
}

// 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
	}
}

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.

@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.

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