clinuxmacosprocfsmach

Why is virtual_size on macOS so different from VmSize on Linux?


I've read that virtual_size on macOS (from MACH_TASK_BASIC_INFO) is roughly equivalent to VmSize on Linux (as reported by /proc/self/status). But there is over a 1000-fold difference between them.

Consider this sample program:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#ifdef __APPLE__
#include <mach/mach.h>
#include <mach/task_info.h>
#include <mach/mach_error.h>
#endif

#define MEGABYTE 1048576.0

#ifdef __linux__
typedef struct {
    size_t virtual_size;
    size_t resident_size;
    size_t resident_size_max;
} memory_stats_t;
#endif

int main(void) {
#if defined(__linux__)
    memory_stats_t stats = {};
    FILE* file = fopen("/proc/self/status", "r");
    if (file == NULL) return perror("fopen"), EXIT_FAILURE;
    char line[256];
    while (fgets(line, sizeof(line), file)) {
        if (sscanf(line, "VmSize: %lu kB", &stats.virtual_size) == 1) {
            stats.virtual_size *= 1024;
        } else if (sscanf(line, "VmRSS: %lu kB", &stats.resident_size) == 1) {
            stats.resident_size *= 1024;
        } else if (sscanf(line, "VmHWM: %lu kB", &stats.resident_size_max) == 1) {
            stats.resident_size_max *= 1024;
        }
    }
    fclose(file);
#elif defined(__APPLE__)
    task_t task = MACH_PORT_NULL;
    kern_return_t kr = task_for_pid(mach_task_self(), getpid(), &task);
    if (kr != KERN_SUCCESS) {
        return fprintf(stderr, "task_for_pid error: %s\n", mach_error_string(kr)), EXIT_FAILURE;
    }
    mach_task_basic_info_data_t stats;
    mach_msg_type_number_t task_info_count = MACH_TASK_BASIC_INFO_COUNT;
    kr = task_info(task, MACH_TASK_BASIC_INFO, (task_info_t)&stats, &task_info_count);
    if (kr != KERN_SUCCESS) {
        return fprintf(stderr, "task_info error: %s\n", mach_error_string(kr)), EXIT_FAILURE;
    }
#else
#   error "Unsupported OS"
#endif

    // Print memory usage information
    printf("=== Current process memory usage [PID: %d]\n", getpid());
    printf("Virtual:       %.3f MB\n", stats.virtual_size / MEGABYTE);
    printf("Resident:      %.3f MB\n", stats.resident_size / MEGABYTE);
    printf("Resident-Peak: %.3f MB\n", stats.resident_size_max / MEGABYTE);
    return EXIT_SUCCESS;
}

I compile and run it with gcc -o memusage memusage.c && ./memusage. (Of course, on macOS gcc is really Apple Clang, but the same command line works.)

Here is the output I get on Linux:

=== Current process memory usage [PID: 1403418]
Virtual:       2.594 MB
Resident:      1.125 MB
Resident-Peak: 1.125 MB

That seems plausible memory usage for such a simple program.

Comparatively, here is what macOS reports:

=== Current process memory usage [PID: 68603]
Virtual:       4169.246 MB
Resident:      0.699 MB
Resident-Peak: 0.699 MB

The resident memory usage, while lower on macOS, is also entirely plausible.

But the virtual memory usage on macOS is reported as over 4GB. How is such a trivial program using over 4GB of virtual memory?

It seems obvious that mach_task_basic_info_data_t.virtual_size on macOS must be defined or measured very differently from VmSize on Linux. But can anyone explain what the difference specifically is? And is there any API on macOS which will give the virtual memory usage of a process using a roughly similar definition to that of Linux? I'm not expecting something exactly the same, but a thousand-fold difference is a long way away from a "roughly similar definition".


Solution

  • I worked it out. I remembered that macOS maps a "shared region" into the address space of every process. Linux has no real equivalent. Okay, technically Linux does (vDSO, vvar and vsyscall), but they are negligible in size (kilobytes). By contrast, the macOS shared region contains gigabytes of data. The main thing it contains is the dyld shared cache, containing all shared libraries shipped with the OS. (Maybe some other stuff too–the shared region seems to contain more mappings than just that??) XNU also maps the commpage into each process, which is more directly equivalent to the vDSO/etc in Linux, but likewise is small enough you can ignore it.

    So, the definition of "virtual size" is basically the same between Linux and macOS – sum of the sizes of all virtual memory mappings in the process. The difference is macOS is mapping an extra ~4GB of data into every process.

    So if you want a virtual size measurement on macOS, more directly equivalent to Linux, you can consider the virtual size excluding mappings in the shared region. I don't know if there is any API to directly give you this figure, but you can calculate it by adding up the sizes of memory regions. See the below example code:

    #include <stdbool.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    
    #include <libproc.h>
    #include <mach/mach.h>
    #include <mach/mach_vm.h>
    #include <mach/shared_region.h>
    
    #define MEGABYTE 1048576.0
    
    void handle_error(const char* doing, kern_return_t kr) {
        if (kr == KERN_SUCCESS) return;
        fprintf(stderr,"FATAL ERROR: %s: (%d) %s\n", doing, kr, mach_error_string(kr));
        exit(EXIT_FAILURE);
    }
    
    void print_memusage_basic_info(void) {
        task_t task = MACH_PORT_NULL;
        handle_error("task_for_pid",task_for_pid(mach_task_self(), getpid(), &task));
        mach_task_basic_info_data_t stats;
        mach_msg_type_number_t task_info_count = MACH_TASK_BASIC_INFO_COUNT;
        handle_error("task_info",task_info(task, MACH_TASK_BASIC_INFO, (task_info_t)&stats, &task_info_count));
        printf("=== Current process [PID: %d] memory usage: per task_info\n", getpid());
        printf("Virtual:       %.3f MB\n", stats.virtual_size / MEGABYTE);
        printf("Resident:      %.3f MB\n", stats.resident_size / MEGABYTE);
        printf("Resident-Peak: %.3f MB\n", stats.resident_size_max / MEGABYTE);
    }
    
    bool in_shared_region(mach_vm_address_t address) {
        return address >= SHARED_REGION_BASE && address < (SHARED_REGION_BASE + SHARED_REGION_SIZE);
    }
    
    void print_memusage_regions(void) {
        vm_map_t task = mach_task_self();
        mach_vm_address_t address = 0;
        size_t sum = 0, srsum = 0;
        while (true) {
            mach_vm_size_t vmsize = 0;
            vm_region_basic_info_data_64_t info;
            mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64;
            mach_port_t object_name;
            kern_return_t kr = mach_vm_region(mach_task_self(), &address, &vmsize, VM_REGION_BASIC_INFO_64,
                                              (vm_region_info_t)&info, &info_count, &object_name);
            if (kr == KERN_SUCCESS) {
                if (in_shared_region(address)) {
                    srsum += vmsize;
                }
                address += vmsize;
                sum += vmsize;
            } else if (kr == KERN_INVALID_ADDRESS) {
                break;
            } else handle_error("mach_vm_region",kr);
        }
        printf("=== Current process [PID: %d] memory usage: region-based\n", getpid());
        printf("Virtual:       %.3f MB\n", sum / MEGABYTE);
        printf("Shared Region: %.3f MB\n", srsum / MEGABYTE);
        printf("Non-Shared:    %.3f MB\n", (sum-srsum) / MEGABYTE);
    }
    
    int main(void) {
        print_memusage_basic_info();
        printf("\n");
        print_memusage_regions();
        return EXIT_SUCCESS;
    }
    

    On my system it prints:

    === Current process [PID: 77524] memory usage: per task_info
    Virtual:       4168.246 MB
    Resident:      0.695 MB
    Resident-Peak: 0.695 MB
    
    === Current process [PID: 77524] memory usage: region-based
    Virtual:       4168.246 MB
    Shared Region: 4158.000 MB
    Non-Shared:    10.246 MB
    

    So we can observe:

    1. Adding up virtual memory region sizes (via mach_vm_region) produces an identical figure to what MACH_TASK_BASIC_INFO reports
    2. Excluding mappings in the shared region gives us a virtual memory usage figure much more comparable VmSize on Linux, and more believable for such a trivial program.

    Note you can't just subtract SHARED_REGION_SIZE from virtual_size, since much of the shared region is unmapped. On my (old) x86-64 Mac, SHARED_REGION_SIZE is 32GB, but only slightly over 4GB of that is actually mapped. You need to iterate over the regions, and sum up those belonging to the shared region separately.

    I believe shared region mappings are static - so what you could do, is calculate the total shared region virtual memory size at process startup, and thereafter subtract that from the virtual_size. A single call to task_info is going to be much faster than looping through multiple calls to mach_vm_region.

    Arguably the shared region shouldn't really be considered as part of your own application's virtual memory consumption, since it is memory owned by the operating system and shared by all processes. So subtracting the total mapped shared region memory from the virtual_size does make logical sense.