From 1d21e9c659501fe67989c6469bfa2777463ef752 Mon Sep 17 00:00:00 2001 From: iximeow Date: Thu, 6 Aug 2026 02:59:30 +0000 Subject: [PATCH] Eagerly populate guest physmem PTEs I noticed today that VM_MEMMAP_F_WIRED is present and does this, but we weren't passing it in VM_MMAP_MEMSEG. By passing this, we proactively set up guest PTEs via `vmspace_populate()` after unconditionally setting up the upper levels of guest physical memory with `vmspace_map()`. As a result, on the guest's first access to a page of memory, it no longer takes an NPF exit for bhyve to handle via VM_EXITCODE_PAGING. This comes at the cost of a bit more time spent setting up the VM's mappings when Propolis is getting going. When guest memory comes from the reservoir, I think the extra time setting up PTEs proactively is the *only* cost here. When it does not come from the reservoir, I don't fully understand the interactions. Since a transient allocation is fundamentally an addition and later subtraction from the reservoir, guests backed by transient allocations should have memory just as pinned as the reservoir-ful. So .. does that mean illumos should operate as if VM_MEMMAP_F_WIRED is passed since b57f5d3 added the reservoir in the first place? At least in our case, it seems that assuming a guest will actually use its available memory, this is a clear win. A guest NPF involves at least a page table walk to get to the PTE which must be populated, plus additional work in the exit, return, etc. Conservatively, one could assume a guest NPF must be at least four pointer chases. Simply walking the page table in entry order as we do in `vmspace_populate()` is approximately `pages` writes, where every 512 entries we go back up the tree 1-3 levels. So lazily touching all memory is something like 4x more total time (and visibly slower to the guest!) versus just doing it up front when we start the VM. The new scripts/lifecycle-times.d bears this out: vm_timing_eager says vm_mmap_memseg for a 16,000MiB (mind the weird number) VM takes a total of about 620ms now, where it took about 24.2ms before. Lazily initializing PTEs though means that when that guest actually uses 8GiB of memory, we spend another 1115ms in vmc_fault setting up mappings. Since that's about half of guest memory, touching the other 8GiB would be at least another second, so the numbers are close to what I'd have predicted.. Said differently, the guest workload I alluded to (a memset() benchmark) now looks pretty much identical now on a Linux host versus a Linux guest in propolis-standalone - it was more than twice as slow for the first pass over memory before. This *maybe* saves ~50 milliseconds in getting the same Ubuntu guest to running systemd. On the whole I expect this adds on the order of 50ms-per-GiB to time between an instance start being requested and being at a running userland in a VM. But probably *saves* that much time when a guest is asked to do useful work? For the largest VMs, that's almost an extra minute.. --- lib/propolis/src/vmm/hdl.rs | 12 +++++- scripts/lifecycle-times.d | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 scripts/lifecycle-times.d diff --git a/lib/propolis/src/vmm/hdl.rs b/lib/propolis/src/vmm/hdl.rs index f34939bf7..dc43de1d5 100644 --- a/lib/propolis/src/vmm/hdl.rs +++ b/lib/propolis/src/vmm/hdl.rs @@ -79,6 +79,7 @@ pub(crate) fn create_vm(name: &str, opts: CreateOpts) -> Result { inner, destroyed: AtomicBool::new(false), name: name.to_string(), + opts, #[cfg(test)] is_test_hdl: false, }) @@ -109,6 +110,7 @@ pub struct VmmHdl { pub(super) inner: bhyve_api::VmmFd, destroyed: AtomicBool, name: String, + opts: CreateOpts, #[cfg(test)] /// Track if this VmmHdl belongs to a wholly fictitious Instance/Machine. @@ -203,13 +205,21 @@ impl VmmHdl { ) -> Result<()> { assert!(segoff <= i64::MAX as usize); + let mut flags = 0; + if self.opts.use_reservoir { + // If the reservoir is in use, guest memory is already not eligible + // to be paged; map it as wired so we proactively set up mappings + // now rather than forcing guest NPFs to establish mappings. + flags |= bhyve_api::VM_MEMMAP_F_WIRED; + } + let mut map = bhyve_api::vm_memmap { gpa: gpa as u64, segid, segoff: segoff as i64, len, prot: i32::from(prot.bits()), - flags: 0, + flags, }; unsafe { self.ioctl(bhyve_api::VM_MMAP_MEMSEG, &mut map) } } diff --git a/scripts/lifecycle-times.d b/scripts/lifecycle-times.d new file mode 100644 index 000000000..e3ba26fd4 --- /dev/null +++ b/scripts/lifecycle-times.d @@ -0,0 +1,77 @@ +#!/usr/sbin/dtrace -s + +/* + * Time bhyve operations related to VM setup and teardown. + * + * Usage: ./lifecycle-times.d + * + * This does not take a particular target Propolis PID; I found it more useful + * to measure all 0-2 propolis-{server,standalone} on my test system. If you + * wanted to measure these times on a sled, the target propolis-server will be + * started by sled-agent and we can't predict the PID anyway! + * + * This could reasonably measure *propolis*-side time for VM lifecycle + * operations, in the future! + */ + +BEGIN { + NEW_FAULTS = 0; +} + +fbt::vm_mmap_memseg:entry { + self->gpa = arg1; + self->len = arg4; + self->prot = arg5; + self->flags = arg6; + self->mapstart = timestamp; +} + +fbt::vm_mmap_memseg:return { + self->maptime = timestamp - self->mapstart; + printf("mapped %12x bytes as %x at gpa %012p in %dus\n", + self->len, + self->prot, + self->gpa, + self->maptime / 1000); + + self->gpa = 0; + self->len = 0; + self->prot = 0; + self->flags = 0; + self->mapstart = 0; +} + +/* + * vmc_fault() is used in service of establishing the PTEs for a guest's + * physical memory, if we've lazily mapped guest physical memory. If guest + * physical memory is "wired", you won't see this happen at all; in that case + * the NPT's PTEs were eagerly populated before the VM was even running (and + * `vm_mmap_memseg` was somewhat slower as a result). + */ +fbt::vmc_fault:entry { + self->faultstart = timestamp; +} + +fbt::vmc_fault:return { + self->faulttime = timestamp - self->faultstart; + + @fault_hist["ns"] = quantize(self->faulttime); + @fault_total["ns"] = sum(self->faulttime); + + NEW_FAULTS += 1; +} + +fbt::vm_cleanup:entry { + self->cleanupstart = timestamp; +} + +fbt::vm_cleanup:return { + self->cleanuptime = timestamp - self->cleanupstart; + printf("vm_cleanup took %dus\n", self->cleanuptime / 1000); +} + +tick-1s / NEW_FAULTS / { + printa(@fault_hist); + printa("total time in vmc_fault: %@dns\n", @fault_total); + NEW_FAULTS = 0; +}