MLX: release a temp slot once its chain is done with it - #23109
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/23109
Note: Links to docs will display an error until the docs builds have been completed. ⏳ No Failures, 64 PendingAs of commit 2bcb0d6 with merge base fa61c2b ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
77b197c to
585f206
Compare
585f206 to
8b77188
Compare
|
@msluszniak I'm wondering if these kinds of MLX memory controls would be better to expose than this eval setting we've added: https://ml-explore.github.io/mlx/build/html/python/memory_management.html What are your thoughts? Have you tried using these to see if they help on iOS? We could expose them via backend options like we do with clear cache. |
|
@claude review this code? |
|
Claude finished @metascroy's task in 2m 31s —— View job Review: MLX temp-slot release
Verdict: I think the approach is correct and the change is well scoped. I found no correctness bugs. My main suggestion is to compute the liveness table once at load, not on every Correctness: checks that passed
Main suggestion: build
|
8b77188 to
efded24
Compare
From what I'm ready these options are valid for memory that is already considered freed. Applied claude review. |
|
A few follow-ups:
Separately, could you benchmark temp release with MLX’s native memory/cache limits against this custom eval threshold stuff. I want to make sure we aren't adding infra that could be done by just adding more control that MLX already offers. |
`ExecutionState::tensors` is never pruned, so a chain ends holding every intermediate it produced. Whether that matters is decided by the AOT memory plan: whisper's reuses a handful of slots, but a gemma4-e2b prefill plan assigns roughly one slot per instruction, so its 3243 instructions finish with 1918 live tensors. Their sizes scale with sequence length, so on a phone a long enough prompt is an OOM kill. Measured on an iPhone 16, peak phys_footprint over a 2392-character prompt drops from 3401 MB (killed) to 1379 MB. Only temps are dropped, and only those no other chain names: a branch or scan body is run through run_chain of its own, so a temp it writes for its caller, or a carry it only reads, would otherwise die at its last use inside the body. A chain holding a SCAN or IF opts out entirely, since for_each_tid does not walk nested chains. The tables are built once in ExecutionState::bind rather than per run_chain call, which would otherwise walk every chain on each execute() and on each scan iteration.
run_chain released last-use temps before charging the instruction, so a shrinking op (FULL -> SUM) was charged for its scalar output instead of the input it had just consumed, and could miss a barrier. Order is now: account, release, then check the threshold. Covered by a FULL -> SUM regression test. compute_temp_last_use also finds cross-chain temps in one pass instead of walking every other chain per chain.
efded24 to
75e79e4
Compare
Out of curiosity, was this on a recent gemma4 export (within last month)? MLX now aggressively reuses temp slots during export, whereas before it didn't. |
|
Addressed in 75e79e4 (also rebased on main, which fixes the mypy failure):
Benchmark: M3 Pro, Qwen3-0.6B int4 exported from this branch, 2047-token prefill, temp release on, medians of 4 interleaved rounds x 5 runs. Peak is MLX peak over post-load active memory.
Outputs identical; 1-token decode is 6.3 ms in every arm except So |
No, it's older than that, so I can check the export with the latest changes that might have an impact. |
|
@claude review this code |
|
Claude finished @metascroy's task in 2m 50s —— View job Review: MLX temp-slot release (after 75e79e4)
Verdict: I found no correctness bugs. The three follow-ups from the last round are all addressed:
What's left below is mostly polish, plus one optional suggestion on the data structure. Checks that passed
Suggestions
On the benchmarkYour numbers suggest I have not built or run the tests. They need Apple Silicon with Metal, and this runner is Linux. |
|
@claude review this code |
|
Claude finished @metascroy's task in 3m 11s —— View job Review: MLX temp-slot release (after 75e79e4)
Verdict: I found no correctness bugs. The three follow-ups from the last round are all fixed:
The main remaining issue is how many models actually benefit from the release. Correctness: checks that passed
Main issue: IF/SCAN opt-out skips the models that need this mostThe main chain opts out entirely if it contains any IF or SCAN. At export, IF is emitted whenever the condition is a runtime value. That covers:
These are the long-prefill models where retained temps hurt most. On such a model, the gemma4 number would silently fall back to the stock behavior. I'd suggest a fix with the same safety properties. When building a parent chain's table, treat every tid named anywhere inside an IF's or SCAN's nested chains (walked recursively, as
A slot is then shared only between the parent and its own nested chains, not across unrelated chains. The IF test would need its expectation flipped: Smaller points
On the benchmark / MLX memory controlsThese numbers point one way: Two caveats before swapping:
It would help to repeat the benchmark on a model where release actually changes the live-slot count. Qwen3-0.6B's plan already reuses slots, so release has no effect on it. A fresh gemma4 export would do, if the newer slot reuse still leaves a large live set. I have not built or run the tests. They need Apple Silicon (Metal), and this runner is Linux. |
metascroy
left a comment
There was a problem hiding this comment.
LGTM! Just running tests before merging.
|
This PR looks good to improve memory on previously exported pte files, but I still wonder if it still does much with the latest export side changes. On the new eval_memory_threshold option: do you think it's worth keeping around if we add an option for set_memory_limit? |
|
Yeah, my primarily target was 1.4.1 version I'm still using so the fact that the problem is resolved on newer version changes that a bit. We maybe drop this part to be honest and I will keep it till bump to ET 1.5.1 locally. Regarding set_memory_limit, i think set_memory_limit would be sufficient if added, at least benchmarks suggest so. The only thing that I'm unsure is that set_memory_limit is process-global and needs an absolute value, so might not be flexible with scenarios with multiple models. |
|
|
ExecutionState::tensorsis never pruned, so a chain ends holding every intermediate it produced. Whether that costs anything depends on the AOT memory plan: whisper's reuses a handful of slots, but a gemma4-e2b prefill plan assigns roughly one slot per instruction, so its 3243 instructions finish with 1918 live tensors whose sizes scale with sequence length.Measured on an iPhone 16, gemma4-e2b int4, 2392-character prompt:
A 52-character prompt completes on both. Reported downstream as software-mansion/react-native-executorch#1489.
Only temps are eligible: ids run
Constant -> Input -> Output -> MutableBuffer -> Temp, so>= mutable_buffer_endis neither a method output nor a caller-visible buffer. A chain containing SCAN or IF opts out, sincefor_each_tiddoes not descend into nested chains.Independent of #22932's eval threshold: with the threshold off the intermediates stay lazy and the growth reappears in the final
async_eval; with it on they are materialized and retained. This release is what makes the bounded path bounded.cc @nil-is-all @metascroy