While measuring free page reporting (#931, #941) through Colima, I found that krunkit's physical footprint, the number Activity Monitor shows, overstates what a VM costs the host. It isn't a libkrun bug, but it affects how anyone measures libkrun's memory use on macOS, so I'm writing it down here.
What happens: when the VMM writes a page of guest RAM through its own mapping and the guest then uses that page, macOS charges the page to the process twice. The page exists only once.
Reproducer without libkrun (hvf-double.c below, ~80 lines): it maps anonymous memory into a VM with hv_vm_map() and runs a vCPU with a load or store loop over it.
| 512 MiB of guest RAM | phys_footprint |
|---|---|
| the VMM writes every page | +512 MB |
| the guest writes every page | +512 MB |
| the VMM writes every page, then the guest reads it | +1025 MB |
It's the same with MAP_SHARED. With 2 GiB, phys_footprint grows by 4098 MB while system-wide used memory (vm_stat) grows by 1948 MB, so only the accounting is doubled.
With libkrun (krunkit through Colima, #941 with --free-page-reporting replace, 4 vCPU, 2 GiB; guest memory in use counted from /proc/kpageflags):
| guest memory in use: +602 MiB | krunkit phys_footprint |
|---|---|
| written by the guest (tmpfs) | +616 MB |
| read from the disk through virtio-blk | +1180 MB |
Both go away when the guest frees the pages.
What it means in practice:
- Device I/O inflates the footprint: disk reads, received packets, virtio-fs reads, and the kernel and initramfs loaded at boot. On a freshly booted guest here, the footprint is ~210 MB + ~17 MB per vCPU above what the guest can account for, which probably is mostly this.
- Comparing footprints between configurations that do different amounts of I/O is misleading. System-wide
vm_stat(or Memory Pressure) shows the real cost. - The free page reporting numbers in #931/#941 aren't affected: those tests write guest memory from inside the guest.
I'm reporting it to Apple through Feedback Assistant and will add the number here. I don't see a way for libkrun to avoid it, but if someone knows of a mapping that isn't counted twice, I'd be happy to test it. I've also opened a short note for krunkit's docs: libkrun/krunkit#123.
Tested on macOS 26.5.2 (25F84), Apple M5.
hvf-double.c
// Is guest RAM that the VMM writes through its own mapping charged twice once
// the guest touches it? No libkrun: map N MiB of anonymous memory into a VM,
// then per mode:
// vmm-then-guest the VMM writes every page, then a vCPU reads every page
// guest-only a vCPU writes every page
// vmm-only the VMM writes every page, the guest never runs
// and print phys_footprint. A 1x charge is N MiB; 2x means the VMM's pages and
// the guest's are not the same pages any more.
// hvf-double <mode> <mem_mib> [private|shared]
//
// Build: clang -O2 -o hvf-double hvf-double.c -framework Hypervisor
// codesign --entitlements ent.plist -s - hvf-double
// (ent.plist grants com.apple.security.hypervisor)
#include <Hypervisor/Hypervisor.h>
#include <libproc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#define CODE_IPA 0x10000000ULL // one 16 KiB page of guest code
#define RAM_IPA 0x40000000ULL
static long fp(void) {
struct rusage_info_v4 ri;
proc_pid_rusage(getpid(), RUSAGE_INFO_V4, (rusage_info_t *)&ri);
return (long)(ri.ri_phys_footprint >> 20);
}
#define CHECK(x) do { hv_return_t r = (x); if (r != HV_SUCCESS) { fprintf(stderr, "%s: %d\n", #x, r); exit(1); } } while (0)
// Guest code, MMU off: x0 = start, x1 = end, x2 = 1 to store, 0 to load.
// loop: if (x2) str x3,[x0] else ldr x3,[x0]; x0 += 4096; if x0 < x1 loop; hvc #0
static const uint32_t code[] = {
0xb4000062, // cbz x2, +12 -> load
0xf9000003, // str x3, [x0]
0x14000002, // b +8
0xf9400003, // ldr x3, [x0]
0x91400400, // add x0, x0, #4096
0xeb01001f, // cmp x0, x1
0x54ffff43, // b.lo -24 -> loop
0xd4000002, // hvc #0
};
static void run_guest(uint64_t start, uint64_t end, int store) {
hv_vcpu_t v; hv_vcpu_exit_t *e;
CHECK(hv_vcpu_create(&v, &e, NULL));
CHECK(hv_vcpu_set_reg(v, HV_REG_PC, CODE_IPA));
CHECK(hv_vcpu_set_reg(v, HV_REG_CPSR, 0x3c5)); // EL1h, interrupts masked
CHECK(hv_vcpu_set_reg(v, HV_REG_X0, start));
CHECK(hv_vcpu_set_reg(v, HV_REG_X1, end));
CHECK(hv_vcpu_set_reg(v, HV_REG_X2, store));
CHECK(hv_vcpu_set_reg(v, HV_REG_X3, 0x5a5a5a5a5a5a5a5aULL));
for (;;) {
CHECK(hv_vcpu_run(v));
if (e->reason == HV_EXIT_REASON_EXCEPTION && (e->exception.syndrome >> 26) == 0x16) break; // hvc
if (e->reason != HV_EXIT_REASON_EXCEPTION) continue;
fprintf(stderr, "guest exception, syndrome %llx va %llx\n", e->exception.syndrome, e->exception.virtual_address);
exit(1);
}
hv_vcpu_destroy(v);
}
int main(int argc, char **argv) {
const char *mode = argv[1]; size_t mem = (size_t)atol(argv[2]) << 20;
int shared = argc > 3 && !strcmp(argv[3], "shared");
CHECK(hv_vm_create(NULL));
void *cpage = mmap(NULL, 16384, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
memcpy(cpage, code, sizeof code);
CHECK(hv_vm_map(cpage, CODE_IPA, 16384, HV_MEMORY_READ | HV_MEMORY_EXEC));
uint8_t *ram = mmap(NULL, mem, PROT_READ | PROT_WRITE,
(shared ? MAP_SHARED : MAP_PRIVATE) | MAP_ANON | MAP_NORESERVE, -1, 0);
CHECK(hv_vm_map(ram, RAM_IPA, mem, HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC));
long base = fp();
if (!strcmp(mode, "guest-only")) {
run_guest(RAM_IPA, RAM_IPA + mem, 1);
} else {
for (size_t o = 0; o < mem; o += 4096) ram[o] = 0xa5;
if (!strcmp(mode, "vmm-then-guest")) run_guest(RAM_IPA, RAM_IPA + mem, 0);
}
printf("%-15s %-7s %4zu MiB: +%ld MB\n", mode, shared ? "shared" : "private", mem >> 20, fp() - base);
fflush(stdout);
// Stay alive so that the system-wide numbers can be read with the memory held.
if (getenv("HOLD")) pause();
return 0;
}Activity
0No activity yet.