Repository navigation
Metal backend: free only the destroyed delegate's memory - #24782
abdelaziz-mahdy wants to merge 23 commits into
Conversation
…set view aoti_torch__reinterpret_tensor read views that start partway into a buffer before the GPU had written them. A non-packed view is copied on the CPU, and the wait for pending GPU work was guarded by metal_is_device_pointer(src). src is the offset pointer, and only a buffer's base address is registered, so the wait was skipped whenever the storage offset was non-zero. A packed view gets its own no-copy MTLBuffer over the parent's memory; Metal tracks hazards per buffer object, so nothing ordered work reading it after the pending work that writes the parent. Decide on the wait from the base pointer, and wait before aliasing a packed view. Adds two linear-then-chunk modules that fed the second chunk to another linear and came back as zeros.
…aliasing it aoti_torch__reinterpret_tensor gave every packed view with a storage offset its own no-copy MTLBuffer over the parent's memory. Metal treats the two buffers as unrelated: a write through one followed by a read of the other in the same serial compute encoder sees stale data every time. Inductor does exactly that when it fills the result of a cat by writing through views of it, and the opposite when an op reads a chunk of a buffer another op just wrote, so models built on split/cat (YOLO's C2f blocks) ran but were wrong. Register such a view against the buffer it lives in and bind that buffer at the view's offset, in compute encoders and in blits. This replaces the wait-before-aliasing from the previous commit, which only covered reads. MPSGraphTensorData cannot address into a buffer, so MPSGraph ops still get an alias for an offset view, now with the stream synchronized on both sides of the graph. Adds cat variants of the chunk tests. The last-dim one is skipped: its slices are non-packed views, which reinterpret_tensor materializes into a copy, so writes through them are still lost.
Inductor flattens nested views into a single reinterpret of the base buffer with the combined offset, so this exercises a larger packed offset rather than a view of a view, but it pins that behavior down.
A view registration is counted, but only aoti_torch__reinterpret_tensor with a non-zero offset took a count. aoti_torch_new_tensor_handle on a view, and a reinterpret of a view at offset 0, both make another handle at the same address without one, while aoti_torch_delete_tensor_object gives one back for every handle. Deleting either handle therefore unregistered the view for the other: a kernel writing through the surviving handle fell back to a temporary and the write never reached the buffer, and MPSGraph ops failed to find the tensor's Metal buffer. metal_retain_view takes a count for an address that is a registered view and does nothing otherwise; both paths call it. Tests cover both ways of making the second handle, in both deletion orders. They need the test_metal_memory target, hence the merge of main.
get_mtl_buffer asked the stream to wait after the next graph before it knew the alias MTLBuffer could be created. On failure it throws, and the flag would have made the next, unrelated graph wait. Also reword a comment in materialize_packed that claimed only base addresses are known as device pointers; constant sub-buffers are mapped at interior addresses too.
…iews working Review of 89dbe34 found two problems, and a third came up checking the memory accounting around them. - The wait that an aliasing buffer needs was a flag on the stream, armed by get_mtl_buffer and consumed by whichever graph ran next. Another thread's graph could consume it, and an op failing between get_mtl_buffer and its graph left it armed for an unrelated one. get_mtl_buffer now only reports that it made an alias, the op passes that to its own executeMPSGraph call, and the stream waits before and after encoding that graph inside the same block on its serial queue, so nothing else can come in between. The stream no longer holds any such state. - A packed offset view of CPU memory lost the no-copy buffer it used to get, so MPSGraph ops could not find it and kernels wrote to a temporary. It gets one again. Unlike before, the buffer is counted with the view's handles and released with the last of them, so that it does not outlive the CPU memory under it; a longer view at the same address gets a buffer that covers it. - A view at an offset took a count on its parent allocation that deleting it never gave back, and a view of a view, or a handle copied from a view, took none. The parent was then either never freed, or freed while such a handle still pointed into it. Every handle into memory the runtime owns now holds a count on the allocation it lives in, found through the handle it was made from, and gives it back when deleted. test_metal_memory: 9 new tests. All but AliasedGraphSettlesItsOwnWork fail on 89dbe34; that one covers what the change must keep working.
metal_copy_memory copied with memcpy and only then waited for the GPU, and only when the source was on the device. A copy to the host could read what a pending command buffer had not written yet, and a copy to the device could overwrite memory a pending command buffer was still to read. It now waits before the copy whenever either side is on the device; with nothing pending the wait does nothing. The backend itself waits before copying a model's outputs, so exported models were not affected; direct aoti_torch_copy_ calls were. Two tests, one per direction, fail without the change.
# Conflicts: # backends/apple/metal/runtime/shims/memory.cpp # backends/apple/metal/runtime/test/test_memory.cpp
Deleting the last handle on CPU memory freed it right away, even when a command still queued on the stream reads or writes it through the no-copy buffer of a view of it. That buffer does not own the memory, so keeping the buffer alive does not keep the memory. The stream is now settled before CPU memory is freed; with nothing queued the wait does nothing. Before the ownership change the memory under such a view was never freed at all, which is why this did not show before. materialize_packed likewise waited only when the parent was in a Metal buffer. Graphs and kernels can write CPU memory through those no-copy buffers too, so a non-packed view of it copied what was there before the write. It now always waits before copying. QueuedCpuViewKeepsBackingStorageAlive is the reported case; it and MaterializingCpuMemoryWaitsForGpuWrites fail without the change.
A view of CPU memory got a no-copy buffer of its own. Overlapping views of the same memory were then separate MTLBuffers, which Metal does not order, the problem this PR fixes for views of Metal buffers; and a longer view at the same address replaced the buffer while work was still queued on it. Views of CPU memory are now bound, at an offset, into one no-copy buffer over the region they belong to, as views of a Metal allocation are bound into its buffer. For memory the runtime allocated the region is the whole allocation (its size is now recorded), and its buffer lives until the memory is freed: views are deleted and made again all the time, and a new buffer for the same memory would not be ordered with work queued on the old one. The allocation's own address resolves to that buffer too. Memory the runtime did not allocate can be freed and reused behind its back, so its region is the tensor the view is made from and goes, after a wait, with its last view. A region is only replaced by a longer buffer when a view reaches past an extent that was not known, after a wait. The buffers use the default CPU cache mode. Along the way, from an adversarial review of the whole change: - An empty view at the end of a buffer was registered at the next allocation's address. It now points at its parent's start. - Freeing CPU memory waited for the GPU every time; it now waits only if the memory has a region, the only way the GPU reaches it. - aoti_torch_copy_ into or out of CPU memory with a buffer was queued as a blit and returned before it ran. Copies involving CPU memory now wait and are made on the CPU. - A handle whose view registration failed was left behind; handles are now published only once registration succeeded. - The mark at a view's address is removed with its last handle, so a later allocation there is not taken for a view. A graph fed a CPU view now gets an alias and settles the stream around itself, so the tests for queued work use a kernel, which binds the buffer at an offset without waiting. Each new test fails without its change.
materialize_packed waited for the whole stream before every copy. It now waits only when queued work may write the source, because the source overlaps a Metal buffer or a CPU region with a buffer, or may still use the destination, because it came from the pool and the stream has not waited since its buffer was freed. Pool entries record the stream's completed wait count for that. commit() now keeps the buffer it commits, so that the next commitAndWait waits for it. A wait after a plain commit used to return before the committed work was done. The extent and is_packed_strides now multiply in int64.
With commit-and-continue enabled, flush() released the buffer it committed, so the next commitAndWait did not wait for that work, while the buffer pool counts that wait as settling everything queued before it. flush() now keeps the buffer, as commit() does. With commit-and-continue disabled, it also releases the one it kept before instead of leaking it.
A tensor made from a blob at an offset into a Metal buffer, or into CPU memory the runtime allocated or wraps in a region, was not known to be there: kernels bound it by copy, graphs refused it, and a view of it got a no-copy buffer of its own over bytes another buffer covers. Such a blob is now registered as a view of that memory when it is made, and holds the allocation it lies in, as a view does. A blob that runs past the end of its buffer is an error, and so is a CPU view that would make two regions overlap. Several handles can share an address, e.g. a blob and a view. The address now stays tracked until the last of them is deleted, and each handle gives back only the view registration it took. The buffer pool only hands a freed buffer back to the stream it was freed on: a wait on one stream does not cover work queued on another.
Buffers freed on a stream are only reused on that stream, so once it is gone they only took up the pool until evicted, and a later stream at the same address would have been handed them. The stream now takes them out of the pool when it is destroyed. The test for materializing memory inside a region again covers a blob made before a region covers it, which blob registration no longer covers on its own.
Every shim now checks its arguments at entry and returns an error instead of aborting in make_tensor_ptr or truncating: sizes and strides that do not fit int32 (a dim of size 1 never steps, so its stride may be anything), strides no dense tensor has, counts and offsets that overflow, and views outside the memory they are made from. Views with overlapping dims are materialized like views with holes. Empty tensors can be allocated and copied, and a constant of size 0 (a null data pointer) loads. Blobs are registered against the constant they lie in, never reaching into the next one, and a CPU region is refused where it would take in another tensor's memory or a runtime allocation. CPU regions record when queued work last bound them, and are released or grown with a wait only if work bound them since the stream last waited. Already broken on main, fixed here too: aoti_torch_mps_memcpy and aoti_torch_mps_free wait for queued readers, refuse buffers that are not constants buffers, and clean up the constants' own buffers; cleanup_memory waits before freeing; aoti_torch_copy_ no longer reads past the dims of tensors of different ranks.
Queued work reads the constant at the start of a constants buffer through that buffer itself, which owns its memory and which the command buffer retains until the work is done. Only the constants' own no-copy buffers, which do not own their memory, need aoti_torch_mps_free to wait first.
…ed memory aoti_torch_mps_free now forgets the views in any buffer it frees, not only in constants buffers with nested ones, and retires the handles still in it: deleting one later only drops the handle, so it cannot unregister or uncount a later allocation at its address, and shims refuse it. It refuses memory a tensor owns and a constant's own buffer, which go with the tensor and with the buffer of all constants. The CPU region stamp no longer records a stream, which the one-stream model does not have. topk freed the memory of its values handle on later errors, and SDPA leaked its output handle and buffers; both now delete the handle once it owns the memory. topk refuses a negative k and returns empty tensors for an empty result, which copy_ now copies out whatever strides either side has. topk returns its scratch to the pool instead of going through mps_free.
An empty view is bound at its parent's start, so that one at the end of its buffer is not taken for a view of the next allocation. That also dropped its storage offset: reinterpreting an empty view at offset 8 of an 8-element allocation as 4 elements at offset 0 addressed elements 0-3 instead of being refused past the end. The address an empty view stands for is now recorded (empty_view_address) and views of it, and of its copies, are offset from there and bounds-checked against it.
MetalBackend::destroy ran cleanup_memory(), which deletes every tensor handle and frees the constants of every delegate in the process, so destroying one model left any other loaded model reading freed memory. Each delegate now records the handles made while it loads or runs, and the buffers aoti_torch_mps_malloc makes while it loads (its constants). destroy waits for queued work and frees only those; the full cleanup_memory() still runs when the last delegate is destroyed. A failed init releases what it made so far.
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24782
Note: Links to docs will display an error until the docs builds have been completed. ❌ 3 Awaiting Approval, 88 New Failures, 1 Pending, 1 Unrelated Failure, 9 Unclassified FailuresAs of commit 36a6283 with merge base 5c788db ( AWAITING APPROVAL - The following workflows need approval before CI can run:
NEW FAILURES - The following jobs have failed:
UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:
FLAKY - The following job failed but was likely due to flakiness present on trunk:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
There was a problem hiding this comment.
🟡 Changes recommended
Regression tests do not exercise backend-level multi-delegate lifecycle or initialization-failure cleanup, nor verify that queued work completes before memory is freed, leaving key use-after-free safeguards unprotected.
3 open issues
What changed in this PR
Scopes Metal memory cleanup to the delegate being destroyed, preserving other live models or partitions. The stacked diff also includes #22957’s offset-view binding and memory-safety changes.
Changes:
- Tracks delegate-owned handles and constants, with cleanup on destruction and failed initialization.
- Binds offset views through parent buffers and strengthens synchronization, validation, and error cleanup.
- Adds memory-lifetime regressions and chunk/view model tests.
| File | Description |
|---|---|
backends/apple/metal/tests/test_modules.py |
Adds chunk, concatenation, and nested-view models. |
backends/apple/metal/runtime/test/test_memory.cpp |
Adds ownership, view-lifetime, synchronization, and validation tests. |
backends/apple/metal/runtime/shims/shim_mps.mm |
Tracks delegate constants and safely releases their buffers. |
backends/apple/metal/runtime/shims/memory.h |
Declares delegate ownership and scoped tracking interfaces. |
backends/apple/metal/runtime/shims/memory.cpp |
Implements scoped cleanup, view accounting, and argument validation. |
backends/apple/metal/runtime/shims/et_metal.mm |
Implements shared view bindings and stream-aware buffer reuse. |
backends/apple/metal/runtime/shims/et_metal.h |
Exposes view, buffer, and synchronization helpers. |
backends/apple/metal/runtime/ops/op_topk.mm |
Handles empty results, alias synchronization, and failure cleanup. |
backends/apple/metal/runtime/ops/op_sdpa.mm |
Releases output handles and buffers on failure. |
backends/apple/metal/runtime/ops/op_mm.mm |
Synchronizes graph execution around offset aliases. |
backends/apple/metal/runtime/ops/op_convolution.mm |
Adds alias synchronization and failed-output handle cleanup. |
backends/apple/metal/runtime/ops/op_bmm.mm |
Passes alias synchronization requirements to graph execution. |
backends/apple/metal/runtime/ops/op_addmm.mm |
Passes alias synchronization requirements to graph execution. |
backends/apple/metal/runtime/ops/common.mm |
Creates temporary graph buffers for offset views. |
backends/apple/metal/runtime/ops/common.h |
Updates the graph-buffer helper contract. |
backends/apple/metal/runtime/metal_backend.cpp |
Wires ownership scopes and per-delegate lifecycle cleanup. |
Review effort: Balanced
| executorch::backends::aoti::ScopeGuard release_on_failure([&]() noexcept { | ||
| if (!initialized) { | ||
| metal_release_delegate(handle); | ||
| } |
There was a problem hiding this comment.
I looked for a way to make init fail after it allocates. With a valid program, nothing in init fails once it has allocated; a bad weights blob makes AOTI read out of bounds instead of returning an error. So this would need a fault-injection hook in init. The release the guard runs is the one the shim tests cover. Happy to add a hook if you'd like one.
| if (metal_release_delegate(handle)) { | ||
| cleanup_memory(); | ||
| executorch::backends::aoti::cleanup_tensor_metadata(); |
There was a problem hiding this comment.
Good point, the module tests run one program per process. I added test_destroying_one_program_keeps_the_others_memory: a small runner loads two Metal programs in one process, destroys one, checks it left no handles behind, and runs the other against eager. It fails with the old unconditional cleanup and without the init ownership scope. Dropping the execute scope isn't caught there, because no handle outlives a run unless runtime constant folding is on, which the Metal export doesn't enable; the shim-level test covers that case. (36a6283)
The module tests run one program per process, so nothing exercised a delegate being destroyed while another stays loaded. A small runner now loads two Metal programs, destroys one, checks it left no handles behind and runs the other, whose output is compared against eager. Also corrects the comment on the wait in metal_release_delegate: the frees after it do not depend on it.
There was a problem hiding this comment.
🔵 Needs a closer look
The cross-cutting Metal buffer, no-copy view, and delegate lifetime changes require hardware-backed validation by a Metal maintainer on macOS, which this review environment cannot perform.
2 open issues
1 resolved since last review
Review effort: Balanced

Summary
Stacked on #22957, which lists this as a known limit. Until #22957 lands, the diff includes its commits; this PR's own change is the last commit (
Metal backend: free only the destroyed delegate's memory).MetalBackend::destroyrancleanup_memory(), which deletes every tensor handle and frees the constants of every delegate in the process. With two models loaded, or two Metal partitions in one.pte, destroying one left the other reading freed memory on its next run.Stack
destroyfrees only its own delegate's memoryChanges
initandexecuteset the current delegate for the thread (MetalDelegateScope).tensorsentry, so a run pays no extra allocation.aoti_torch_mps_mallocmakes while it loads (its constants) are recorded against it.destroywaits for queued work, then frees only that delegate's handles and constants. When the last delegate goes it still runs the fullcleanup_memory()andcleanup_tensor_metadata(), as before.initreleases what it made so far.Known limits
init,executeanddestroy(no locks). That is unchanged.init(with a valid program nothing ininitfails once it has allocated, so it would need fault injection), and the untagging of a handle whose delete fails.Test plan
test_memory.cpp: 98 gtests, 4 of them new. Each new check was mutation-tested: 8 mutants, each failing at least one test.test_destroying_one_program_keeps_the_others_memory: a small runner (metal_multi_delegate_runner, built byrun_metal_test.sh --build) loads two Metal programs in one process, destroys one, checks it left no handles behind, and runs the other against eager. It fails with the old unconditional cleanup and without theinitownership scope.run_metal_test.sh --build+python -m unittest backends.apple.metal.tests.test_modules.TestMetalBackendModules: 167 OK (4 skipped).lintrunnerclean.This came out of the review of #22957. I kept it separate so that PR stays focused. Happy to fold it in or change the approach if you'd prefer another way to scope the cleanup.
cc @nil-is-all @metascroy