#947

macOS: a VM's phys_footprint counts guest pages written by the VMM twice

OpenJartXopened 4d ago

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 RAMphys_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 MiBkrunkit 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

0

No activity yet.