#938

macOS/HVF: anonymous guest RAM is counted twice in phys_footprint after host and guest access

Opendunnryopened 9d ago

I noticed an issue only on MacOS where a VM seemed to be taking up ~2x the max allocated RAM. I had Astra evaluate and this is what it found. I had it create a repro as well (attached)

Summary

On Apple Silicon, guest RAM allocated with ordinary anonymous mmap and then accessed through both host and HVF guest mappings is charged approximately twice in TASK_VM_INFO.phys_footprint. Using hv_vm_allocate for the same memory removes the extra footprint charge.

The standalone C repro below allocates 256 MiB and executes a seven-instruction ARM64 guest. It needs only the Apple SDK and Hypervisor.framework; no guest OS, virtual disk, filesystem image, VMM library, or network is involved.

Connection to libkrun

Source inspection found this allocation path in both:

Both use GuestMemoryMmap::from_ranges for this path. The corresponding vm-memory 0.17.1 and 0.18.0 Unix allocators use MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE. The macOS memory initialization passes these host addresses to the HVF mapping wrapper.

The repro isolates that allocation/mapping sequence. The upstream revisions above were inspected, not built or booted as part of this standalone test.

Environment

  • Apple M5 Max, arm64; 128 GiB host RAM; 16 KiB host pages.
  • macOS 26.6.2 (25G83).
  • Apple clang 21.0.0 (clang-2100.3.34.2), macOS SDK 27.0.
  • Built with a macOS 12.1 deployment target and ad-hoc Hypervisor entitlement.
  • Only this macOS/hardware combination was executed for this repro.

Reproduce

libkrun-hvf-memory-repro.zip

Save the C source and entitlement below as hvf-memory.c and hypervisor.entitlements, then run on Apple Silicon with macOS 12.1 or later:

xcrun clang -std=c11 -Wall -Wextra -Werror -O2 -mmacosx-version-min=12.1 \
  -framework Hypervisor hvf-memory.c -o hvf-memory
codesign --force --sign - --entitlements hypervisor.entitlements hvf-memory
./hvf-memory mmap
./hvf-memory hv

Each run allocates 256 MiB plus one code page, has a 60-second watchdog, verifies host/guest coherence twice, and explicitly destroys/unmaps/deallocates its resources before exit. No administrator privileges are required.

hvf-memory.c — complete standalone source
// SPDX-License-Identifier: Apache-2.0
// Standalone Apple Silicon HVF accounting repro; no guest OS or VMM dependency.
#include <Hypervisor/Hypervisor.h>
#include <Hypervisor/hv_vm_allocate.h>
#include <inttypes.h>
#include <mach/mach.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

#if !defined(__APPLE__) || !defined(__aarch64__)
#error "This repro requires macOS on Apple Silicon"
#endif

#define MIB (1024ULL * 1024ULL)
#define RAM_SIZE (256ULL * MIB)

static uint64_t baseline;

static void check(hv_return_t result, const char *operation) {
    if (result != HV_SUCCESS) {
        fprintf(stderr, "%s failed: 0x%x\n", operation, (unsigned)result);
        exit(1);
    }
}

static uint64_t sample(const char *phase) {
    task_vm_info_data_t info = {0};
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO,
                                    (task_info_t)&info, &count);
    if (result != KERN_SUCCESS) {
        fprintf(stderr, "task_info: %s\n", mach_error_string(result));
        exit(1);
    }
    if (phase != NULL) {
        printf("%-18s %14" PRIu64 " %12.2f %12.2f\n", phase,
               (uint64_t)info.phys_footprint,
               ((double)info.phys_footprint - (double)baseline) / MIB,
               (double)info.resident_size / MIB);
        fflush(stdout);
    }
    return info.phys_footprint;
}

static void run_guest(hv_vcpu_t cpu, hv_vcpu_exit_t *exit_info,
                      hv_ipa_t code_gpa, hv_ipa_t data_gpa, size_t page_size) {
    check(hv_vcpu_set_reg(cpu, HV_REG_PC, code_gpa), "set PC");
    check(hv_vcpu_set_reg(cpu, HV_REG_X0, data_gpa), "set X0");
    check(hv_vcpu_set_reg(cpu, HV_REG_X1, page_size), "set X1");
    check(hv_vcpu_set_reg(cpu, HV_REG_X2, RAM_SIZE / page_size), "set X2");
    check(hv_vcpu_run(cpu), "hv_vcpu_run");
    // HVC #0 from AArch64 has exception class 0x16.
    if (exit_info->reason != HV_EXIT_REASON_EXCEPTION ||
        (exit_info->exception.syndrome >> 26) != 0x16) {
        fprintf(stderr, "Unexpected guest exit: reason=%u syndrome=0x%" PRIx64 "\n",
                (unsigned)exit_info->reason,
                (uint64_t)exit_info->exception.syndrome);
        exit(1);
    }
}

int main(int argc, char **argv) {
    if (argc != 2 || (strcmp(argv[1], "mmap") && strcmp(argv[1], "hv"))) {
        fprintf(stderr, "Usage: %s mmap|hv\n", argv[0]);
        return 2;
    }
    alarm(60); // Bound the process lifetime even if guest execution misbehaves.
    const bool use_hv = strcmp(argv[1], "hv") == 0;
    const size_t page = (size_t)getpagesize();
    const size_t size = (size_t)RAM_SIZE + page; // One extra page holds guest code.
    const hv_ipa_t gpa = 0x40000000;
    void *allocation = NULL;

    printf("\nallocator=%s data_mib=256 code_bytes=%zu\n", argv[1], page);
    printf("%-18s %14s %12s %12s\n", "phase", "footprint_B", "delta_MiB", "resident_MiB");
    baseline = sample(NULL);
    sample("baseline");
    if (use_hv) {
        check(hv_vm_allocate(&allocation, size, HV_ALLOCATE_DEFAULT), "hv_vm_allocate");
    } else {
        allocation = mmap(NULL, size, PROT_READ | PROT_WRITE,
                          MAP_PRIVATE | MAP_ANON | MAP_NORESERVE, -1, 0);
        if (allocation == MAP_FAILED) {
            perror("mmap");
            return 1;
        }
    }

    const uint32_t code[] = {
        0xf9400003, // ldr x3, [x0]
        0x91000463, // add x3, x3, #1
        0xf9000003, // str x3, [x0]
        0x8b010000, // add x0, x0, x1
        0xf1000442, // subs x2, x2, #1
        0x54ffff61, // b.ne back to ldr
        0xd4000002, // hvc #0
    };
    memcpy(allocation, code, sizeof(code));
    uint8_t *data = (uint8_t *)allocation + page;
    for (size_t offset = 0; offset < RAM_SIZE; offset += page)
        *(volatile uint64_t *)(data + offset) = 41;
    sample("host-touched");

    check(hv_vm_create(NULL), "hv_vm_create");
    check(hv_vm_map(allocation, gpa, size,
                    HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC), "hv_vm_map");
    sample("mapped-only");
    hv_vcpu_t cpu;
    hv_vcpu_exit_t *exit_info;
    check(hv_vcpu_create(&cpu, &exit_info, NULL), "hv_vcpu_create");
    check(hv_vcpu_set_reg(cpu, HV_REG_CPSR, 0x3c5), "set CPSR");
    run_guest(cpu, exit_info, gpa, gpa + page, page);
    sample("guest-touched");

    // Confirm coherence, then repeat access to the same pages in both directions.
    for (size_t offset = 0; offset < RAM_SIZE; offset += page) {
        if (*(volatile uint64_t *)(data + offset) != 42) {
            fprintf(stderr, "Guest/host mismatch at byte %zu\n", offset);
            return 1;
        }
        *(volatile uint64_t *)(data + offset) = 43;
    }
    run_guest(cpu, exit_info, gpa, gpa + page, page);
    for (size_t offset = 0; offset < RAM_SIZE; offset += page) {
        if (*(volatile uint64_t *)(data + offset) != 44) {
            fprintf(stderr, "Second guest/host mismatch at byte %zu\n", offset);
            return 1;
        }
    }
    sample("guest-retouched");
    check(hv_vcpu_destroy(cpu), "hv_vcpu_destroy");
    check(hv_vm_unmap(gpa, size), "hv_vm_unmap");
    sample("guest-unmapped");
    check(hv_vm_destroy(), "hv_vm_destroy");
    if (use_hv) {
        check(hv_vm_deallocate(allocation, size), "hv_vm_deallocate");
    } else if (munmap(allocation, size) != 0) {
        perror("munmap");
        return 1;
    }
    sample("released");
    puts("Guest/host coherence and cleanup: PASS");
    return 0;
}
hypervisor.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.hypervisor</key>
    <true/>
</dict>
</plist>

Observed result

Footprint increase over the pre-allocation baseline, in MiB:

PhaseAnonymous mmaphv_vm_allocate
Host touches every page256.17256.17
Guest mapping registered, vCPU not run256.27256.27
vCPU touches every page512.58256.56
Same pages accessed a second time512.58256.56
Guest mapping removed; host allocation retained256.38256.38
Host allocation released0.190.19

Both runs report Guest/host coherence and cleanup: PASS. The total phys_footprint after guest access was 539,034,488 bytes with mmap and 270,582,648 bytes with the HVF allocator. Small overheads vary between runs.

The additional charge appears when the vCPU actually accesses the pages; calling hv_vm_map alone is insufficient. Removing the guest mapping reverses that additional charge while the host allocation remains valid. Repeated access does not add a third charge.

The raw resident_size counter was approximately 518 MiB in both modes. That counter can count aliases and should not be used alone to assess this fix or infer a second physical copy of guest RAM. This report concerns inflated process footprint, not evidence that the guest can address more RAM than configured.

Expected behavior / possible fix

Guest RAM should contribute approximately its resident allocation size to process footprint, plus normal VMM overhead, after both host and guest access.

Apple's SDK header for hv_vm_allocate states: “This API enables accurate memory accounting of the allocations it creates”. Changing the repro's allocator/deallocator pair to hv_vm_allocate/hv_vm_deallocate is sufficient to remove the duplicate charge.

Please consider using that allocation path for anonymous macOS guest RAM. Integration needs to retain ownership until all guest-memory users and HVF mappings are finished, use the matching deallocator, and preserve accounting through free-page reporting/remapping. File-backed DAX/device windows need their existing separate treatment. This repro establishes the allocator behavior; it is not a proposed wholesale replacement of libkrun's memory-management code.

Activity

0

No activity yet.