Repository navigation
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The VMM memory boundary changes substantially, and OCI loading maps oversized blobs before enforcing resource limits.
1 open finding
What changed in this PR
Adds layered incremental snapshots that share immutable memory, persist OCI data and page-table layers, and optimize restore mappings.
Changes:
- Adds shared snapshot layers with incremental capture and installation.
- Introduces snapshot ABI 6 and OCI config v4.
- Optimizes page-table walks, KVM invalidation, tests, and benchmarks.
| File | Description |
|---|---|
src/tests/rust_guests/simpleguest/src/main.rs |
Adds zero-init mapping test support. |
src/hyperlight_host/tests/snapshot_goldens/goldens_version.rs |
Bumps golden snapshots to v6. |
src/hyperlight_host/src/sandbox/uninitialized.rs |
Documents capture behavior and uses GPA spans. |
src/hyperlight_host/src/sandbox/trace/mem_profile.rs |
Reads layered memory by GPA. |
src/hyperlight_host/src/sandbox/snapshot/tripwires.rs |
Pins ABI 6 media types. |
src/hyperlight_host/src/sandbox/snapshot/memory/mod.rs |
Defines layered snapshot memory. |
src/hyperlight_host/src/sandbox/snapshot/memory/backing.rs |
Maps and accesses snapshot layers. |
src/hyperlight_host/src/sandbox/snapshot/file/mod.rs |
Saves and loads layered OCI snapshots. |
src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs |
Adds config v4 and new layer media types. |
src/hyperlight_host/src/sandbox/snapshot/file/config.rs |
Defines the layered snapshot schema. |
src/hyperlight_host/src/sandbox/snapshot/file_tests.rs |
Tests layered persistence and validation. |
src/hyperlight_host/src/sandbox/mod.rs |
Exports SnapshotMemoryReader. |
src/hyperlight_host/src/sandbox/initialized.rs |
Installs captures and updates snapshot APIs. |
src/hyperlight_host/src/mem/virtq/tests.rs |
Updates scratch transport tests. |
src/hyperlight_host/src/mem/shared_mem.rs |
Adds immutable memory and range mappings. |
src/hyperlight_host/src/mem/layout.rs |
Removes flat snapshot layout state. |
src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs |
Disables the slot-wide KVM flush quirk. |
src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs |
Uses range-based snapshot mappings. |
src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs |
Adopts layered VM memory. |
src/hyperlight_host/src/hypervisor/hyperlight_vm/test_support.rs |
Updates mapping-state fault tests. |
src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs |
Manages multiple snapshot mappings. |
src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs |
Adopts layered VM memory. |
src/hyperlight_host/src/hypervisor/gdb/mod.rs |
Resolves layered debug memory. |
src/hyperlight_host/build.rs |
Removes obsolete memory-read configuration. |
src/hyperlight_host/benches/benchmarks.rs |
Adds incremental snapshot benchmarks. |
src/hyperlight_common/src/vmem.rs |
Coalesces adjacent mappings. |
src/hyperlight_common/src/arch/amd64/vmem.rs |
Applies mapping coalescing on AMD64. |
src/hyperlight_common/src/arch/aarch64/vmem.rs |
Applies mapping coalescing on AArch64. |
proposals/0003-incremental-snapshots/README.md |
Updates the snapshot design. |
docs/snapshot-versioning.md |
Documents ABI 6 versioning. |
docs/snapshot-oci-format.md |
Documents layered OCI storage. |
docs/paging-development-notes.md |
Describes incremental capture behavior. |
CHANGELOG.md |
Records breaking API and format changes. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let mut snap_files = data_descs | ||
| .iter() | ||
| .chain([pt_desc]) | ||
| .map(|desc| open_snapshot_blob(&blobs_dir, desc)) | ||
| .collect::<crate::Result<Vec<_>>>()?; |
This comment has been minimized.
This comment has been minimized.
8c92fa0 to
67c5cc8
Compare
call_and_create times a guest call plus snapshot creation, so the call pays for memory remapping done by the previous snapshot. restore_alternate restores two snapshots in turn, so each restore targets memory that is not current. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
67c5cc8 to
7ca7b5e
Compare
This comment has been minimized.
This comment has been minimized.
Share immutable data blobs between related snapshots. Each blob holds guest data followed by the page tables of the snapshot that created it. Persist layered snapshots as OCI image layouts. Capture copies only pages that changed since the snapshot the sandbox was created from or last restored to. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
Snapshot capture switches the running sandbox to the captured snapshot. The next capture reuses its layers and copies only pages changed after it. Scratch pages used before the capture become free again. They are zeroed so guest allocations return zeroed pages, as after restore. Capture removes map_region and map_file_cow regions. Pages of these regions that the guest has not mapped are lost. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
walk_va_spaces merges mappings that have contiguous physical and virtual addresses and the same kind. Snapshot capture classifies and rebuilds each merged mapping once. Criterion snapshots/create: * MSHV: medium 1.18 ms to 950 us (-20%), large 3.56 ms to 2.85 ms (-20%) * KVM: medium 1.34 ms to 1.04 ms (-22%), large 3.59 ms to 2.91 ms (-19%) Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
A page-table walk reads every entry through GuestPhysicalMemoryView, which resolves the backing and copies 8 bytes through a locked volatile copy. The reader keeps a copy of the last table page it read, so each table costs one copy. Snapshot capture and crash dumps use the reader. Criterion snapshots/create: * MSHV: default 344 us to 248 us (-28%), medium 950 us to 594 us (-37%) * KVM: default 499 us to 379 us (-24%), medium 1.04 ms to 746 us (-28%) Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
By default, deleting a memslot flushes the guest mappings of every memslot. KVM keeps that flush as a workaround for VMs with assigned GPUs. Hyperlight assigns no devices and disables the quirk when the kernel supports it (Linux 6.12 and later). Deleting a memslot then flushes only that memslot's mappings. Snapshot capture deletes a memslot when a layer drops out of the snapshot. Restore to another snapshot and region unmapping also delete memslots. Criterion on KVM (Linux 6.17): * snapshots/call_and_create: 24% to 58% faster * snapshots/restore_alternate: 32% to 52% faster Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
7ca7b5e to
67561dc
Compare
Benchmark ResultsMeasured commit: kvm / amd (Linux) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
kvm / intel (Linux) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
mshv3 / amd (Linux) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
mshv3 / intel (Linux) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
hyperv-ws2025 / amd (Windows) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
hyperv-ws2025 / intel (Windows) (➖ stable)No benchmark improved or regressed. Benchmark Resultsfunction_call_codec
payload_allocation
sandboxes
slot_pool
snapshot_files
virtq_readonly
virtq_readwrite
Reported by |
| unsafe { | ||
| internal_walk_va_spaces(&op, roots.iter().cloned(), true, address, len) | ||
| .map(|(id, mappings)| (id, mappings.collect::<Vec<_>>())) | ||
| .map(|(id, mappings)| { |
There was a problem hiding this comment.
It's hard to understand what happens here, but IIUC this is just coalescing regions? If so, I think we could use: coalesce from itertools: https://docs.rs/itertools/latest/itertools/trait.Itertools.html#method.coalesce.
coalesce() expects Ok(merged) or Err((previous, next)), so we could have for example:
impl SpaceAwareMapping {
pub(crate) fn try_merge(mut self, other: Self) -> Result<Self, (Self, Self)>{}
}This would then read like this:
map(|(id, mappings)| (id, mappings.coalesce(SpaceAwareMapping::try_merge).collect()))It looks like coalescing when walking is quite common and that could be reused in the guest code as well, see :
hyperlight/src/hyperlight_guest/src/transport/backing.rs
Lines 131 to 154 in 67561dc
| self.vm.unmap_memory((self.snapshot_slot, &old_rgn))?; | ||
| let target_mappings = snapshot.mappings(); | ||
|
|
||
| let mut index = self.snapshot_mappings.len(); |
There was a problem hiding this comment.
| let mut index = self.snapshot_mappings.len(); | |
| for index in (0..self.snapshot_mappings.len()).rev() { | |
| let (slot, curr) = &self.snapshot_mappings[index]; | |
| if target_mappings.contains(curr) { | |
| continue; | |
| } | |
| self.vm.unmap_memory((*slot, curr))?; | |
| let (slot, _) = self.snapshot_mappings.remove(index); | |
| self.freed_slots.push(slot); | |
| } |
I think is the same and reads slightly better, but feel free to ignore.
| Ok(page_gpa) | ||
| } | ||
|
|
||
| pub(crate) fn read(&mut self, gva: u64, destination: &mut [u8]) -> Result<()> { |
There was a problem hiding this comment.
I would call this read_exact or read_exact_at to be somewhat compatible with std. This would make it clear that this "reads the exact number of bytes required to fill buf".
| self.shared_mem = hsnapshot; | ||
| Some(gsnapshot) | ||
| }; | ||
| let gsnapshot = self.replace_snapshot_memory(snapshot)?; |
| .checked_add(chunk_len) | ||
| .ok_or_else(|| new_error!("crash-dump GVA range overflows"))?; | ||
|
|
||
| if !try_coalesce_region(&mut regions, virt_base, virt_end, host_region.start, flags) |
There was a problem hiding this comment.
I think this could also use the same coalescing pattern:
resolved_regions
.coalesce(CrashDumpRegion::try_merge)
.collect()|
|
||
| /// Copies guest data and page tables into one blob. The data is padded to a | ||
| /// host page. | ||
| #[cfg(test)] |
There was a problem hiding this comment.
Should we keep all those test functions in mod tests and make mod pub(crate)?
| layout, | ||
| memory, | ||
| root, | ||
| failure: Cell::new(None), |
There was a problem hiding this comment.
Shouldn't TableReadOps have a fallible variant of read_entry? I guess we could then avoid this dance with finish() and caching failures?
| if mapping.kind == MappingKind::Unmapped { | ||
| return Ok(false); | ||
| } | ||
| if mapping.len == 0 |
There was a problem hiding this comment.
Are those checks common enough to have them in Mapping constructor? I think, in general, we should think about making all sort of mapping and pages operations more centralized in the common crate.

Summary
Snapshots share immutable memory layers. A capture copies only the pages changed since its parent snapshot, and
Sandbox::snapshotswitches the sandbox to the capture so the next one is incremental too. This makessnapshot()much faster for larger sandboxes. It also improves memory density: each snapshot holds only its changed pages and shares the rest with its parent, wheremainholds a full copy of guest memory per snapshot. See HIP 0003.Deviations from the merged HIP 0003 draft (the HIP is updated):
main. A restore copies the newest page tables into scratch.Sandbox::snapshotinstalls the capture, because otherwise every later capture copies all pages changed since the last restore again. Installing removesmap_regionandmap_file_cowregions, because the snapshot holds their mapped pages and later blobs may reuse their addresses.Changes
Sandbox::snapshotinstalls the capture into the running sandbox. Scratch pages it frees are zeroed.KVM_X86_QUIRK_SLOT_ZAP_ALLis disabled where supported, so deleting a memory slot flushes only that slot.snapshots/call_and_createandsnapshots/restore_alternatecover the cost moved into the next call and restores to a non-current snapshot.Breaking changes
Sandbox::snapshotremovesmap_regionandmap_file_cowregions. Pages of these regions the guest has not mapped are lost.PtRootFinderreceives aSnapshotMemoryReaderand returnsResult<Vec<u64>>.Commits
call_and_createandrestore_alternatesnapshot benchmarks. Lands first so the benchmarks run before and after.walk_va_spaces.PageTableReader.KVM_X86_QUIRK_SLOT_ZAP_ALL.Benchmarks
Compared to
main.restore,restore_alternateandcall_and_createcompare to the first commit, which adds the last two.Highlights
snapshots/create/defaultsnapshots/create/largesnapshots/call_and_create/mediumsnapshots/restore_alternate/largesnapshots/restore/largeGuest calls show no consistent change.
Regressions
snapshot_files/load_snapshot_unverified/defaultsnapshot_files/load_snapshot/defaultsnapshot_files/save_snapshot/defaultsnapshot_files/cold_start_via_snapshot/defaultload_snapshotsandboxes/sandbox_from_snapshot/smalldefault(+11%)Kernels before 6.12 cannot disable
KVM_X86_QUIRK_SLOT_ZAP_ALL, so each memory slot change flushes all guest mappings. WHP and aarch64 were not benchmarked.A saved snapshot stores each layer in full. Base layer pages that a newer layer replaced stay in the file, which adds 88 KiB for
default.Full results
All benchmarks on both hosts (click to expand)
Before → after (change). Negative is faster. Bold is a regression. ~ is no consistent change. A change needs agreement between a 0.5 s and a 3 s run, or a 3 s run alone.
snapshots
call_and_createcall_and_createcall_and_createcall_and_createcreatecreatecreatecreaterestorerestorerestorerestorerestore_alternaterestore_alternaterestore_alternaterestore_alternateguest_calls
callcallcallcallcall_with_host_functioncall_with_host_functioncall_with_host_functioncall_with_host_functioncall_with_restorecall_with_restorecall_with_restorecall_with_restoredifferent_threadinterrupt_latencyguest_functions_with_large_parameters
guest_call_with_large_parameterssample_workloads
24K_in_8K_out_c24K_in_8K_out_rustsandboxes
create_initializedcreate_initializedcreate_initializedcreate_initializedcreate_initialized_and_dropcreate_initialized_and_dropcreate_initialized_and_dropcreate_initialized_and_dropsandbox_from_snapshotsandbox_from_snapshotsandbox_from_snapshotsandbox_from_snapshotsnapshot_files
cold_start_via_evolvecold_start_via_evolvecold_start_via_evolvecold_start_via_evolvecold_start_via_snapshotcold_start_via_snapshotcold_start_via_snapshotcold_start_via_snapshotcold_start_via_snapshot_unverifiedcold_start_via_snapshot_unverifiedcold_start_via_snapshot_unverifiedcold_start_via_snapshot_unverifiedload_snapshotload_snapshotload_snapshotload_snapshotload_snapshot_unverifiedload_snapshot_unverifiedload_snapshot_unverifiedload_snapshot_unverifiedsave_snapshotsave_snapshotsave_snapshotsave_snapshot