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:
Store the repro.m and workaround.m files from below
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;
}
0
0
38