Repository navigation
MLX: lower index_copy into a mutated user input functionally - #23508
IlyasMoutawwakil wants to merge 16 commits into
Conversation
A mutated user input is not the delegate's to own across calls, so only buffers the method mutates take the in-place INDEX_COPY pattern; index_copy into a user input lowers to a functional IndexCopyNode and the method writes the result back. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/23508
Note: Links to docs will display an error until the docs builds have been completed.
|
|
Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
This PR needs a
|
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The targeted lowering change is consistent with MLX slot ownership and is covered across both tested axes.
Review effort: Balanced
Findings: None
What changed in this PR
This PR makes MLX lower index_copy on mutated user inputs functionally while preserving in-place lowering for mutable buffers.
Changes:
- Restricts the in-place pattern to mutable buffers.
- Adds functional
aten.index_copy.defaultlowering. - Tests mutated inputs along axes 0 and 1.
| File | Description |
|---|---|
backends/mlx/patterns.py |
Limits in-place index copy to buffers. |
backends/mlx/ops.py |
Adds functional index-copy lowering. |
backends/mlx/test/test_ops.py |
Adds mutation and output regression coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Hi @IlyasMoutawwakil, could you fix the failing MLX CI test and lintrunner? Do ping us when ready for review. |
The method writes the mutated input back with a portable copy_ outside the delegate, and op_test_runner links no portable kernels, so the op test could not load. Cover it in test_partitioner instead, which runs through the Python runtime, and format ops.py for ufmt. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The generic handler silently wraps negative indices although ATen rejects them.
1 open finding
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
The method writes an input mutated inside a delegate back with copy_(input, output) once the delegate returns, so a static KV cache passed in as an input is copied in full on every call. With run_reinplace_pass, a backend that sets BackendDetails.writes_mutated_inputs_in_place now gets that output planned as the input itself: reinplace_delegate_input_mutations gives the delegate output the input's TensorSpec and drops the copy, so the method passes one EValue as both and returns the input. MLX opts in. When an input is also an output, MLXBackend::execute releases the state's reference to it once the graph is built, so MLX can donate the wrapped input buffer to the update, waits for every output before writing any, and skips the copy when the result already lives in the output's memory. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ATen's index_copy raises on a negative index, but exec_index_copy wrapped it to dst_dim + idx. A program exported with valid example indices could then silently write the wrong row when given a negative index at runtime instead of failing the way eager PyTorch does. The runtime now throws for any index outside [0, size) with a message mirroring ATen's, and a partitioner test checks that executing with a negative index raises. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces cross-cutting tensor aliasing and asynchronous GPU buffer-donation behavior requiring final human validation.
2 open findings
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Cover the two orderings in-place writes must respect. In the pass, a read of the input between the delegate and its write-back keeps the copy_ and leaves the specs separate, since writing in place would move the mutation ahead of that read. In MLX, a value computed from the input before index_copy_ must still see the old contents across repeated in-place calls, which relies on MLXBackend waiting for every output before writing into the input. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An in-place op on a view of the input is planned onto the view's spec, not the input's, so matching specs only against the input missed it, and a read of its result between the delegate and the write-back would see the update early. Each alias's users are now matched against that alias's own spec, so every node sharing the input's storage is found transitively. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@IlyasMoutawwakil seems the branch went out-of-date over the weekend. Could you update & give us a ping? |
|
@IlyasMoutawwakil did you run a model through this change? What was the perf like? I think there is a correctness issue that can show up. The new aliased-output handling can overwrite a mutated input’s storage before copying another output that still references its pre-mutation contents, causing that output to return updated data. Waiting for all MLX outputs to finish does not prevent this sequential write-back hazard; output sources overlapping mutation destinations must be preserved before those destinations are overwritten. Consider adding a regression test like this: |
|
|
||
| """ | ||
|
|
||
| # Whether the runtime backend computes its outputs correctly when the method |
There was a problem hiding this comment.
Could we express this as a partitioner annotation on the specific input mutation, analogous to how we tag mutated buffers, rather than a backend-wide writes_mutated_inputs_in_place flag? The annotation would declare that this partition supports aliasing the mutation output with its corresponding input; EXIR would still perform its reinplacement safety checks, establish the alias, and remove the external write-back only when safe, retaining the existing path otherwise.
Unlike buffer delegation, this would not transfer ownership: the user input remains an external argument. This gives us per-mutation opt-in, with the annotation preserved through lowering, instead of requiring every partition produced by a backend to support the behavior.
Maybe @JacobSzwejbka has thoughts here on how we should handle user input mutation inside delegates as well.
There was a problem hiding this comment.
makes sense, a per-mutation annotation is more precise than a backend-wide flag and keeps the safety checks in EXIR. I'd like to hear what @JacobSzwejbka thinks about delegates mutating user inputs in general before reworking it, then I'll update the PR to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
MLXBackend writes outputs one after another. An output written into an input's memory then also changes any output still reading that memory and written after it, e.g. a clone of the input taken before the update, which MLX leaves as the input's own buffer. Waiting for every output does not prevent that. When an input is written in place, copy any output whose data overlaps memory written at or before it out of the way before writing any output. test_snapshot_before_index_copy_with_reinplace covers the snapshot from the review, and a view of it, which the delegate returns after the update and so is the order that read updated data. Authored with Claude Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
|
@nil-is-all merged main. the workflows are waiting for approval though, could someone approve them? the MLX job is the one that actually runs the new tests on apple silicon. @metascroy good catch on the write back order, fixed in 64e84e0. when an input is written in place, MLXBackend now snapshots any output whose data overlaps memory written at or before it, before writing anything back, so an output that's still the input's own buffer gets read before the update lands. added test_snapshot_before_index_copy_with_reinplace with your SnapshotBeforeMutation module plus a variant that returns on perf, I haven't run a full model through it on apple silicon yet so no numbers so far. the change just drops the copy back into the mutated input, so the gain should scale with whatever is written in place (the kv cache in practice). |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 34m 8s —— View job PR Review: #23508
Request Changes: with Full reviewSummaryThe PR adds
Code Quality
Testing
API Design
Backward Compatibility
Performance
RecommendationRequest Changes What blocks merging:
The contract for |
…cument the opt-in - `_index_copy_handler` leaves a 0-d index or a 0-d destination to portable, since the runtime rejects both, and checks the dim with `require_static_int`. - The in-place rewrite also skips a delegate that takes the mutated input through an alias as well. - The read-before and snapshot tests assert the write-back `copy_` is gone, so they fail if the pass doesn't run. - `writes_mutated_inputs_in_place` and `run_reinplace_pass` say what opting in requires and does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. Addressed in the latest commit:
Forward compatibility with older MLX runtimes is tied to @metascroy's suggestion to express this as a per-mutation partitioner annotation instead of a backend-wide flag: deciding the alias at partition time is what lets the payload record it and bump its version. I'm waiting on @JacobSzwejbka's view on delegates mutating user inputs before reworking it that way, so I've left the flag as is for now.
|
- An unplanned, page-aligned cache (alloc_graph_input=False), which MLX can wrap without a copy, so the update must land in the caller's own tensor. - Two caches mutated by one delegate, both aliased to their inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI on f4c81be, for the record:
These pass on main and don't touch the changed code. I merged main to pick up the current CI setup. |
… pybindings (#23183) ## Summary Adds `ExecuTorchMethod.set_output(tensor, index)`, exposed as `executorch.runtime.Method.set_output`. It binds an output that is not memory planned to caller storage: the method writes that output straight into `tensor` on every later call, with no copy, and returns it as that tensor (viewed at the written shape when a dynamic output is smaller), uncloned, since binding is the opt-in. Later calls overwrite it. `set_output` rejects, with `ValueError`, a tensor that doesn't match the output's dtype and device, isn't contiguous or is too small for the output's largest shape, or shares memory with another bound output or an input; and an output that is memory planned, returned at more than one index, not contiguous, or also an input. Storage the output used before binding is kept alive, so tensors already returned with `clone_outputs=False` stay valid. The ATen bindings don't support it: they swap the data pointer of the output's storage in place, so earlier outputs would read the bound tensor, or freed memory after a rebind. An output that is also an input is found by checking that both are the same value in the method (`&get_output(i) == &get_input(j)`), not by comparing data pointers, which could match the wrong tensor. The check runs once, after the first successful `set_inputs()`, not in the constructor: `get_input()` marks an input as set, which would disable `execute()`'s "Input N has not been set" check. So binding such an output before the first call can't be rejected up front: the first call removes that binding and raises, and later calls work normally. Unplanned inputs are handled as on main: a mutation is written into the caller's tensor, and an output that is that input is a clone by default and main's detached alias of the caller's storage with `clone_outputs=False` (#23089). Only `PyMethod` changes. The MLX `index_copy` changes from the first version of this PR are in #23508. ## Test plan New tests in `runtime/test/test_runtime.py` cover binding, rebinding, and dynamic outputs smaller than the bound tensor; rejection of bad dtype, size, layout, planned, duplicated, input-aliasing and memory-sharing bindings; recovery from a binding made before the first call; `execute()` without inputs after a binding; mutated unplanned inputs (updated in place, returned as a copy by default and sharing the caller's storage with `clone_outputs=False`) and planned ones (not updated); an uncloned output fed back in as the next input; and a failed `set_inputs()`. pytest runtime/test/test_runtime.py extension/pybindings/test This PR was authored with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani @nil-is-all @metascroy --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>



Summary
A model that mutates one of its inputs, like a static KV cache passed in by the caller and updated with
index_copy_, previously failed to lower on MLX: theINDEX_COPYpattern pointed the update at the input's slot after the builder had already given it an output slot. This PR lowers such updates, and lets MLX write them straight into the input instead of copying the cache back on every call.Lowering:
index_copyinto anything other than a mutated buffer now goes through a functionalaten.index_copy.defaulthandler that emits anIndexCopyNode. Like ATen,IndexCopyNodenow rejects negative indices instead of wrapping them.In place: once a delegate returns, the method writes a mutated input back with
copy_(input, output), which copies the whole cache on every call.BackendDetailsgets awrites_mutated_inputs_in_placeopt-in. Withrun_reinplace_pass=True,reinplace_delegate_input_mutationsgives such a delegate output the input'sTensorSpecand drops thecopy_, so the method passes one EValue as both input and output and returns the input. MLX opts in.MLXBackend::executedetects an input that is also an output, releases the state's reference to it once the graph is built so MLX can donate the wrapped input buffer to the update, waits for every output before writing any, and skips the copy when the result is already in the output's memory. Backends that don't opt in are unchanged.Combined with #23183, which passes an unplanned caller tensor to the method without copying and returns it uncloned, a caller-owned static cache is then updated in place in one MLX graph, with no write-back.
Suggested review order:
exir/backend/backend_details.pyandreinplace_delegate_input_mutationsinexir/passes/reinplace.py(with its call into_executorch), thenbackends/mlx/runtime/MLXBackend.cpp, then the MLX lowering inbackends/mlx/ops.py.Test plan
test_delegate_writes_mutated_input_in_placeinexir/tests/test_reinplace_pass.pylowers a cache update to a demo backend that opts in and to one that doesn't. It checks that only the opted-in delegate gets the input's spec for its output and loses thecopy_.TestMLXPartitionerMutatedUserInputinbackends/mlx/test/test_partitioner.pycoversindex_copy_into an input along both axes, rejection of a negative index, and the in-place path withrun_reinplace_pass=Trueover several calls: nocopy_in the program, the method output is the input value, and the values match eager. These run through the Python runtime rather thanop_test_runner, because without the in-place path the write-backcopy_needs portable kernels thatop_test_runnerdoesn't link.The MLX runtime part of these tests needs Apple silicon and runs in
test-mlx.This PR was authored with Claude Code.
🤖 Generated with Claude Code
cc @nil-is-all @metascroy