From 3546acda15bb8548f11b2270fa4265f19c40101e Mon Sep 17 00:00:00 2001 From: Jorge Prendes Date: Wed, 2 Sep 2026 10:43:45 +0100 Subject: [PATCH] perf(snapshot): write the guest memory blob sparsely `Snapshot::save` wrote the guest memory image with `write_all`, which allocates blocks for zeros like any other data: the kernel cannot know the caller would accept a hole. A guest memory image is mostly pages the guest never touched, so saving a snapshot wrote hundreds of MB to reach a final artifact of a few tens of MB. Skip all-zero blocks instead, seeking over them and extending the file with `set_len`, so the untouched pages become holes. Runs of adjacent non-zero blocks are coalesced into one write, so a blob with no zeros costs a single `write_all` as before. This is invisible to readers. The digest is computed from the in-RAM buffer before the write, and reads of a hole return zeros, so a sparse blob is byte-for-byte identical to a dense one and hashes the same. The format is unchanged and existing snapshots still load. Unix filesystems make a file sparse implicitly. NTFS does not, and zero-fills any range skipped by a seek unless the file is explicitly marked sparse, so request that first with FSCTL_SET_SPARSE. The request is best-effort: a filesystem without sparse support (FAT32, exFAT, HFS+) rejects it, and the write is then simply dense, which is still correct. Only the large snapshot blob takes this path; the manifest and config blobs are small enough that scanning them for zeros would not pay. Measured on btrfs with a 559 MB image whose pages are ~96% zero: 3.55s and 586 MB on disk, down to 1.70s and 23 MB. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jorge Prendes --- CHANGELOG.md | 7 + src/hyperlight_host/Cargo.toml | 8 +- .../src/sandbox/snapshot/file/fsutil.rs | 282 +++++++++++++++++- 3 files changed, 291 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e41c95a1..12c6f13e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added ### Changed +* `Snapshot::save` now writes the guest memory blob sparsely, skipping all-zero + blocks instead of writing them. A guest memory image is mostly untouched + pages, so this cuts the bytes actually written by roughly the proportion of + the guest's memory it never touched. The saved layout is byte-for-byte + identical and its digest is unchanged, so this is transparent to readers and + to previously saved snapshots. Filesystems that do not support sparse files + store the blob as before. ### Removed diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 3735febc00..f85c358b65 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -73,7 +73,13 @@ windows = { version = "0.62", features = [ "Win32_System_JobObjects", "Win32_System_SystemServices", ] } -windows-sys = { version = "0.61", features = ["Win32"] } +windows-sys = { version = "0.61", features = [ + "Win32", + # FSCTL_SET_SPARSE and DeviceIoControl, used to mark the snapshot + # blob sparse. Not implied by "Win32". + "Win32_System_IO", + "Win32_System_Ioctl", +] } windows-result = "0.4" rust-embed = { version = "8.11.0", features = ["debug-embed", "include-exclude", "interpolate-folder-path"] } windows-version = "0.1" diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs index abd74e87ed..d6aeacd025 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/fsutil.rs @@ -1,27 +1,141 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use std::io::{Read, Write}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::Path; use tempfile::NamedTempFile; use super::digest::{Digest256, verify_blob_file}; +/// Granularity at which [`write_sparse`] looks for all-zero regions. +/// A hole can only exist on a filesystem-block boundary, and 4 KiB is +/// the block size of every filesystem we target, so scanning finer +/// would cost comparisons that can never turn into a hole. +const SPARSE_BLOCK: usize = 4096; + +/// Ask the filesystem to treat `file` as sparse, so that ranges left +/// unwritten by [`write_sparse`] become holes. +/// +/// Unix filesystems make a file sparse implicitly, so there is +/// nothing to request. NTFS does not: unless a file is explicitly +/// marked sparse, it zero-fills any range skipped by a seek, and the +/// file ends up fully allocated. Marking it costs one ioctl on an +/// empty file. +/// +/// Best-effort by design. A filesystem that does not support sparse +/// files (FAT32, exFAT) rejects the request, which is not an error: +/// the write that follows is still correct, just dense. +#[cfg(not(windows))] +fn try_set_sparse(_file: &std::fs::File) {} + +#[cfg(windows)] +fn try_set_sparse(file: &std::fs::File) { + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::System::IO::DeviceIoControl; + use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; + + let mut returned: u32 = 0; + // SAFETY: the handle is valid and owned by `file` for the + // duration of the call. FSCTL_SET_SPARSE takes no input or output + // buffer, so all buffer pointers are null with zero lengths, and + // `returned` is a valid writable u32. The return value is + // deliberately ignored: see the doc comment. + unsafe { + DeviceIoControl( + file.as_raw_handle() as _, + FSCTL_SET_SPARSE, + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + &mut returned, + std::ptr::null_mut(), + ); + } +} + +/// Write `bytes` to `file`, seeking over all-zero blocks instead of +/// writing them. +/// +/// `write_all` allocates blocks for zeros like any other data: the +/// kernel cannot know the caller would accept a hole. Seeking past a +/// range and extending the file with `set_len` leaves that range +/// unwritten, and a filesystem that supports sparse files reports it +/// as a hole. +/// +/// The result is byte-for-byte identical to a dense write — reads of +/// a hole return zeros — so this is invisible to anything that reads +/// the file, including digest verification. Only the blocks the file +/// occupies on disk differ. On a filesystem without sparse support +/// the range is zero-filled instead, which is still correct. +/// +/// Runs of adjacent non-zero blocks are written in a single call, so +/// a blob with no zeros costs exactly one `write_all` plus one seek. +fn write_sparse(file: &mut std::fs::File, bytes: &[u8]) -> std::io::Result<()> { + // Must happen while the file is still empty, and before any seek + // past a gap, or NTFS zero-fills the skipped ranges instead of + // leaving holes. + try_set_sparse(file); + let zero = [0u8; SPARSE_BLOCK]; + let len = bytes.len(); + let mut pos = 0; + while pos < len { + let end = (pos + SPARSE_BLOCK).min(len); + // Skipped: leave a hole and move on. + if bytes[pos..end] == zero[..end - pos] { + pos = end; + continue; + } + // Extend over adjacent non-zero blocks so the whole run goes + // out in one write rather than one write per block. + let start = pos; + pos = end; + while pos < len { + let block_end = (pos + SPARSE_BLOCK).min(len); + if bytes[pos..block_end] == zero[..block_end - pos] { + break; + } + pos = block_end; + } + file.seek(SeekFrom::Start(start as u64))?; + file.write_all(&bytes[start..pos])?; + } + // The file must reach its full length even when it ends in zeros, + // which are skipped above and so never extend it. + file.set_len(len as u64)?; + Ok(()) +} + /// Replace `target` atomically: a reader either sees the old /// contents or the full new contents, never a partial write. A /// failure before commit leaves `target` untouched and removes the /// staging file. pub(super) fn replace_file_atomic(target: &Path, bytes: &[u8]) -> crate::Result<()> { + replace_file_atomic_inner(target, bytes, false) +} + +/// [`replace_file_atomic`], but skipping all-zero blocks so the file +/// lands sparse. See [`write_sparse`] for why the contents are +/// unchanged. +pub(super) fn replace_file_atomic_sparse(target: &Path, bytes: &[u8]) -> crate::Result<()> { + replace_file_atomic_inner(target, bytes, true) +} + +fn replace_file_atomic_inner(target: &Path, bytes: &[u8], sparse: bool) -> crate::Result<()> { let parent = target.parent().ok_or_else(|| { crate::new_error!("atomic write: target {:?} has no parent directory", target) })?; let mut tmp = NamedTempFile::new_in(parent).map_err(|e| { crate::new_error!("atomic write: failed to create tmp in {:?}: {}", parent, e) })?; - tmp.write_all(bytes).map_err(|e| { - crate::new_error!("atomic write: failed to write tmp {:?}: {}", tmp.path(), e) - })?; + if sparse { + write_sparse(tmp.as_file_mut(), bytes) + } else { + tmp.write_all(bytes) + } + .map_err(|e| crate::new_error!("atomic write: failed to write tmp {:?}: {}", tmp.path(), e))?; tmp.as_file_mut().sync_all().map_err(|e| { crate::new_error!("atomic write: failed to sync tmp {:?}: {}", tmp.path(), e) })?; @@ -48,6 +162,10 @@ pub(super) fn put_blob(blobs_dir: &Path, digest: &Digest256, bytes: &[u8]) -> cr /// Intended for the large snapshot blob, where the cost of one full /// re-hash of the existing file is far less than the cost of an /// unconditional rewrite. +/// +/// Written sparsely: a guest memory image is mostly untouched pages, +/// so writing its zeros would dominate the cost of saving a snapshot +/// while adding nothing a reader can observe. pub(super) fn put_blob_if_absent( blobs_dir: &Path, digest: &Digest256, @@ -62,7 +180,7 @@ pub(super) fn put_blob_if_absent( { return Ok(()); } - replace_file_atomic(&target, bytes) + replace_file_atomic_sparse(&target, bytes) } /// Reject a path that is a symbolic link. @@ -151,6 +269,160 @@ pub(super) fn read_bounded(path: &Path, max_size: u64) -> crate::Result> Ok(buf) } +#[cfg(test)] +mod sparse_tests { + use super::*; + + /// Every pattern must round-trip byte-for-byte. The interesting + /// cases are the ones where a hole meets data: leading and + /// trailing zeros, a gap in the middle, and a final block shorter + /// than `SPARSE_BLOCK` (so the tail is compared against a partial + /// zero block, not a whole one). + fn patterns() -> Vec<(&'static str, Vec)> { + let b = SPARSE_BLOCK; + vec![ + ("empty", vec![]), + ("all zero, one block", vec![0u8; b]), + ("all zero, many blocks", vec![0u8; b * 4]), + ("all data", vec![0xABu8; b * 4]), + ("leading zeros", [vec![0u8; b * 2], vec![7u8; b]].concat()), + ("trailing zeros", [vec![7u8; b], vec![0u8; b * 2]].concat()), + ( + "hole in the middle", + [vec![7u8; b], vec![0u8; b * 2], vec![9u8; b]].concat(), + ), + ("unaligned tail of data", vec![3u8; b * 2 + 17]), + ("unaligned tail of zeros", { + let mut v = vec![3u8; b]; + v.extend(std::iter::repeat_n(0u8, 17)); + v + }), + ("single non-zero byte in a block", { + let mut v = vec![0u8; b * 3]; + v[b + 5] = 1; + v + }), + ("shorter than one block", vec![1u8, 0, 2]), + ] + } + + #[test] + fn sparse_write_round_trips_every_pattern() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in patterns() { + let path = dir.path().join("blob"); + replace_file_atomic_sparse(&path, &bytes).unwrap(); + let read_back = std::fs::read(&path).unwrap(); + assert_eq!(read_back, bytes, "content mismatch for {name}"); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + bytes.len() as u64, + "length mismatch for {name}" + ); + std::fs::remove_file(&path).unwrap(); + } + } + + /// A sparse write must be indistinguishable from a dense one to + /// anything that reads the file. This is what lets the snapshot + /// blob go out sparse without touching the digest it is addressed + /// by, or the format version. + #[test] + fn sparse_and_dense_writes_produce_identical_files() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in patterns() { + let dense = dir.path().join("dense"); + let sparse = dir.path().join("sparse"); + replace_file_atomic(&dense, &bytes).unwrap(); + replace_file_atomic_sparse(&sparse, &bytes).unwrap(); + assert_eq!( + std::fs::read(&dense).unwrap(), + std::fs::read(&sparse).unwrap(), + "dense and sparse differ for {name}" + ); + } + } + + /// The blob must still hash to the digest it is stored under, and + /// the reuse path must accept a sparse file it wrote itself — + /// otherwise every save would rewrite the blob. + #[test] + fn put_blob_if_absent_writes_a_verifiable_blob_and_reuses_it() { + let dir = tempfile::tempdir().unwrap(); + let mut bytes = vec![0u8; SPARSE_BLOCK * 8]; + bytes[SPARSE_BLOCK * 3..SPARSE_BLOCK * 4].fill(0x5A); + let digest = Digest256::from_bytes(&bytes); + + put_blob_if_absent(dir.path(), &digest, &bytes).unwrap(); + let target = dir.path().join(&digest.hex); + assert_eq!(std::fs::read(&target).unwrap(), bytes); + + let mut file = std::fs::File::open(&target).unwrap(); + verify_blob_file("test", &mut file, &digest.hex).unwrap(); + + // Second call must take the reuse path and leave the file be. + let before = std::fs::metadata(&target).unwrap().modified().unwrap(); + put_blob_if_absent(dir.path(), &digest, &bytes).unwrap(); + let after = std::fs::metadata(&target).unwrap().modified().unwrap(); + assert_eq!(before, after, "blob was rewritten instead of reused"); + } +} + +#[cfg(all(test, unix))] +mod sparse_unix_tests { + use std::os::unix::fs::MetadataExt; + + use super::*; + + /// The point of the change: a mostly-zero blob, like a guest + /// memory image, must occupy far fewer blocks on disk than its + /// length. + /// + /// Sparseness is a filesystem capability, so the test first + /// probes whether the temp filesystem supports holes at all and + /// skips if not (HFS+, FAT). The probe keeps the assertion sharp + /// where holes *are* supported, rather than weakening it + /// everywhere to accommodate filesystems where they are not. + #[test] + fn sparse_write_allocates_fewer_blocks_than_dense() { + let dir = tempfile::tempdir().unwrap(); + + // Probe: a file with a large unwritten gap should occupy far + // fewer blocks than its length if this filesystem has holes. + let probe_path = dir.path().join("probe"); + { + let mut probe = std::fs::File::create(&probe_path).unwrap(); + probe.seek(SeekFrom::Start(8 * 1024 * 1024)).unwrap(); + probe.write_all(b"x").unwrap(); + probe.sync_all().unwrap(); + } + let probe_meta = std::fs::metadata(&probe_path).unwrap(); + let supports_holes = + probe_meta.blocks() > 0 && probe_meta.blocks() * 512 < probe_meta.len() / 2; + if !supports_holes { + eprintln!("filesystem does not support sparse files; skipping"); + return; + } + + // 8 MiB, of which one 64 KiB run is non-zero. + let mut bytes = vec![0u8; 8 * 1024 * 1024]; + bytes[1024 * 1024..1024 * 1024 + 64 * 1024].fill(0xC3); + + let dense = dir.path().join("dense"); + let sparse = dir.path().join("sparse"); + replace_file_atomic(&dense, &bytes).unwrap(); + replace_file_atomic_sparse(&sparse, &bytes).unwrap(); + + let dense_blocks = std::fs::metadata(&dense).unwrap().blocks(); + let sparse_blocks = std::fs::metadata(&sparse).unwrap().blocks(); + + assert!( + sparse_blocks * 4 < dense_blocks, + "expected a large saving, got sparse={sparse_blocks} dense={dense_blocks} blocks" + ); + } +} + #[cfg(all(test, unix))] mod tests { use std::ffi::CString;