Residency set memory not freed if process performs no GPU operation

Feedback report: FB23959296

If a process creates a residency set, calls requestResidency, endResidency, and then releases the residency set without ever having done any GPU operations, the memory from the residency set is not freed.

Workaround: if the application runs any GPU operation (even an operation not involving the residency set) at any point in its lifecycle (before/while/after creating/releasing the residency set), the memory is freed properly.

This was observed in the context of an application that makes an AI model resident in GPU-accessible memory. If the user unloads the model without running any prompts, the memory is not freed. The model occupies ~16GB of RAM so a lot of memory is being leaked.

Reproduction:

  1. Store the repro.m and workaround.m files from below
  2. Run the following commands (repro.m demonstrates the bug; workaround.m demonstrates the workaround):
$ clang -framework Foundation -framework Metal -o repro repro.m
$ ./repro 
Footprint at start:                 0.00 GB
Footprint after buffer allocation:  4.30 GB
Footprint 5s after teardown:        4.30 GB
$ clang -framework Foundation -framework Metal -o workaround workaround.m
$ ./workaround 
Footprint at start:                 0.00 GB
Footprint after buffer allocation:  4.37 GB
Footprint 5s after teardown:        0.01 GB

Expected behavior:

Footprint 5s after teardown should be ~0 GB, i.e., the memory is freed.

Observed behavior:

Footprint 5s after teardown is 4.30 GB, i.e., the memory is not freed.

Versions:

XCode: 26.6 (17F113)
Clang: 21.0.0 (clang-2100.1.1.101, arm64-apple-darwin25.5.0)
macOS: 26.5.2 (25F84)

Files:

repro.m:

// Build:  clang -framework Foundation -framework Metal -o repro repro.m

#import <Metal/Metal.h>
#include <mach/mach.h>

// Returns the physical memory footprint of the process.
static double footprint_gb(void) {
    task_vm_info_data_t info;
    mach_msg_type_number_t n = TASK_VM_INFO_COUNT;
    task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &n);
    return (double)info.phys_footprint / 1e9;
}

int main(int argc, char ** argv) {
    printf("Footprint at start:                %5.2f GB\n", footprint_gb());

    @autoreleasepool {
        id<MTLDevice> dev = MTLCreateSystemDefaultDevice();

        // Allocate ~4GB of memory.
        const size_t size = 4ULL << 30;
        id<MTLBuffer> buf = [dev newBufferWithLength:size options:MTLResourceStorageModeShared];
        memset(buf.contents, 0xab, size); // fault the pages in

        printf("Footprint after buffer allocation: %5.2f GB\n", footprint_gb());

        MTLResidencySetDescriptor * desc = [[MTLResidencySetDescriptor alloc] init];
        id<MTLResidencySet> rset = [dev newResidencySetWithDescriptor:desc error:nil];
        [desc release];
        [rset addAllocation:buf];
        [rset commit];
        [rset requestResidency];

        [rset endResidency];
        [rset removeAllAllocations];
        [rset commit];
        [rset release];
        [buf release];

        [dev release];
    }

    sleep(5);
    printf("Footprint 5s after teardown:       %5.2f GB\n", footprint_gb());

    return 0;
}

workaround.m:

// Build:  clang -framework Foundation -framework Metal -o workaround workaround.m

#import <Metal/Metal.h>
#include <mach/mach.h>

// Returns the physical memory footprint of the process.
static double footprint_gb(void) {
    task_vm_info_data_t info;
    mach_msg_type_number_t n = TASK_VM_INFO_COUNT;
    task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &n);
    return (double)info.phys_footprint / 1e9;
}

// Performing any work on the GPU ensures the memory from the residency set will be released.
static void do_dummy_work(id<MTLDevice> dev, id<MTLCommandQueue> queue) {
    @autoreleasepool {
        id<MTLBuffer> tmp = [dev newBufferWithLength:1 options:MTLResourceStorageModeShared];
        id<MTLCommandBuffer> cb = [queue commandBuffer];
        id<MTLBlitCommandEncoder> enc = [cb blitCommandEncoder];
        [enc fillBuffer:tmp range:NSMakeRange(0, 1) value:0];
        [enc endEncoding];
        [cb commit];
        [tmp release];
    }
}

int main(int argc, char ** argv) {
    printf("Footprint at start:                %5.2f GB\n", footprint_gb());

    @autoreleasepool {
        id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
        id<MTLCommandQueue> queue = [dev newCommandQueue];

        // Workaround that ensures the memory will be released.
        // It also works if we call this after the residency set release or at any point in between.
        do_dummy_work(dev, queue);

        // Allocate ~4GB of memory.
        const size_t size = 4ULL << 30;
        id<MTLBuffer> buf = [dev newBufferWithLength:size options:MTLResourceStorageModeShared];
        memset(buf.contents, 0xab, size); // fault the pages in

        printf("Footprint after buffer allocation: %5.2f GB\n", footprint_gb());

        MTLResidencySetDescriptor * desc = [[MTLResidencySetDescriptor alloc] init];
        id<MTLResidencySet> rset = [dev newResidencySetWithDescriptor:desc error:nil];
        [desc release];
        [rset addAllocation:buf];
        [rset commit];
        [rset requestResidency];

        [rset endResidency];
        [rset removeAllAllocations];
        [rset commit];
        [rset release];
        [buf release];

        [queue release];
        [dev release];
    }

    sleep(5);
    printf("Footprint 5s after teardown:       %5.2f GB\n", footprint_gb());

    return 0;
}
Residency set memory not freed if process performs no GPU operation
 
 
Q