Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5654233
Lower index_copy into a mutated user input functionally in MLX
IlyasMoutawwakil Oct 6, 2026
70c4e3b
Test index_copy into a mutated input through the Python runtime
IlyasMoutawwakil Oct 8, 2026
ac4df62
Merge remote-tracking branch 'upstream/main' into mlx-index-copy-input
IlyasMoutawwakil Oct 8, 2026
78f44d4
Let MLX write a mutated user input in place
IlyasMoutawwakil Oct 8, 2026
5ff5435
Reject negative indices in MLX IndexCopyNode
IlyasMoutawwakil Oct 8, 2026
ed87ac7
Merge branch 'main' into mlx-index-copy-input
IlyasMoutawwakil Oct 9, 2026
40ee446
Test reads of a mutated input around its in-place write
IlyasMoutawwakil Oct 9, 2026
f06ccfb
Keep the write-back when an alias of the input is read before it
IlyasMoutawwakil Oct 9, 2026
4a1ff49
Accept int32 indices in MLX IndexCopyNode
IlyasMoutawwakil Oct 9, 2026
9803564
Follow aliases of aliases when guarding in-place delegate writes
IlyasMoutawwakil Oct 9, 2026
d0878d7
Merge branch 'main' into mlx-index-copy-input
IlyasMoutawwakil Oct 12, 2026
03fc902
Merge branch 'main' into mlx-index-copy-input
IlyasMoutawwakil Oct 14, 2026
64e84e0
Read outputs that alias an in-place input before writing any output
IlyasMoutawwakil Oct 14, 2026
ec2a265
Keep 0-d index_copy off MLX, guard aliases passed to the delegate, do…
IlyasMoutawwakil Oct 27, 2026
f4c81be
Test MLX writing an unplanned input and two inputs in place
IlyasMoutawwakil Oct 27, 2026
f628e6c
Merge remote-tracking branch 'upstream/main' into HEAD
IlyasMoutawwakil Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions backends/mlx/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
GreaterEqualNode,
GreaterNode,
IdCopyNode,
IndexCopyNode,
IntOrVid,
IntOrVidOrTid,
ItemIntNode,
Expand Down Expand Up @@ -2406,6 +2407,38 @@ def _roll_handler(P: MLXProgramBuilder, n: Node) -> Slot:
return out


@REGISTRY.register(target=[torch.ops.aten.index_copy.default])
Comment thread
IlyasMoutawwakil marked this conversation as resolved.
def _index_copy_handler(P: MLXProgramBuilder, n: Node) -> Slot:
"""index_copy into a new tensor, leaving `dst` as it was.

The INDEX_COPY pattern writes in place when `dst` is a buffer the method
mutates; anything else, a mutated user input included, lands here and the
caller writes the result back.
"""
args = P.args(n)
require_args(args, 4, 4, "aten.index_copy.default")
require_kwargs(P.kwargs(n), set(), "aten.index_copy.default")
dst, axis, indices, update = args
require_static_int(axis, "dim", "aten.index_copy.default")
# The runtime takes a 1-D index into a tensor with at least one dim; ATen also
# allows 0-d ones, which stay off the delegate.
if n.args[0].meta["val"].dim() == 0 or n.args[2].meta["val"].dim() != 1:
raise NotImplementedError(
"aten.index_copy.default needs a 1-D index into a tensor of rank >= 1"
)
out = P.make_or_get_slot(n)
P.emit(
IndexCopyNode(
dst=P.slot_to_tid(dst),
update=P.slot_to_tid(update),
indices=P.slot_to_tid(indices),
out=P.slot_to_tid(out),
axis=axis,
)
)
return out


@REGISTRY.register(target=[torch.ops.aten.index.Tensor])
def _index_handler(P: MLXProgramBuilder, n: Node) -> Slot:
args = P.args(n)
Expand Down
10 changes: 6 additions & 4 deletions backends/mlx/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def _int4_qparams_lower(qdata_node: Node, scale_node: Node, group_size: int) ->
class IndexCopyHandler(PatternHandler):
"""
Pattern for index-based updates on mutable buffers.

Only a buffer the method mutates is written in place: the delegate owns it
across calls. A mutated user input is not the delegate's to keep, so its
index_copy is left to the functional handler and written back by the caller.
"""

def __init__(
Expand All @@ -148,10 +152,8 @@ def maybe_create( # noqa: C901
if not match_target(index_copy_node, torch.ops.aten.index_copy.default):
return None

# index_copy should write to a mutable input/buffer to be an index update.
if (index_copy_node.name not in ep.graph_signature.buffers_to_mutate) and (
index_copy_node.name not in ep.graph_signature.user_inputs_to_mutate
):
# index_copy should write to a mutable buffer to be an index update.
if index_copy_node.name not in ep.graph_signature.buffers_to_mutate:
return None

# index_copy(dst, axis, indices, update)
Expand Down
5 changes: 5 additions & 0 deletions backends/mlx/preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class MLXBackend(BackendDetails):
EXPECTED_MAGIC: ClassVar[bytes] = MAGIC
EXPECTED_LENGTH: ClassVar[int] = HEADER_LENGTH

# MLXBackend::execute releases an input that is also an output before
# evaluating, so MLX can update it in its own memory, and only writes
# outputs once everything reading inputs is done.
writes_mutated_inputs_in_place: ClassVar[bool] = True

@staticmethod
def preprocess(
edge_program: ExportedProgram,
Expand Down
84 changes: 78 additions & 6 deletions backends/mlx/runtime/MLXBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@

#include <executorch/backends/mlx/runtime/backend_options.h>

#include <algorithm>
#include <cstdint>
#include <cstring>
#include <limits>
#include <memory>
#include <mutex>
#include <vector>

#include <TargetConditionals.h>

Expand Down Expand Up @@ -128,9 +131,21 @@ array prepare_output(
return result;
}

// Wait for a prepared output array and copy its data to an ET tensor.
// Whether [a, a + a_nbytes) and [b, b + b_nbytes) share any byte.
bool overlaps(const void* a, size_t a_nbytes, const void* b, size_t b_nbytes) {
const auto a_begin = reinterpret_cast<uintptr_t>(a);
const auto b_begin = reinterpret_cast<uintptr_t>(b);
return a_nbytes > 0 && b_nbytes > 0 && a_begin < b_begin + b_nbytes &&
b_begin < a_begin + a_nbytes;
}

// Wait for a prepared output array and copy its data to an ET tensor, or copy
// `snapshot` instead when it holds the array's data taken earlier.
// The array must have been submitted via async_eval before calling this.
void write_output(array& arr, ETTensor& out) {
void write_output(
array& arr,
ETTensor& out,
const std::vector<uint8_t>& snapshot = {}) {
arr.wait();

// Resize output tensor if shape doesn't match (dynamic shapes)
Expand Down Expand Up @@ -171,12 +186,16 @@ void write_output(array& arr, ETTensor& out) {
" bytes, output has " + std::to_string(out_nbytes) + " bytes");
}

const void* src = arr.data<void>();
const void* src = snapshot.empty() ? arr.data<void>() : snapshot.data();
if (!src) {
throw std::runtime_error(
"write_output: arr.data<void>() is null after wait()");
}
std::memcpy(out.mutable_data_ptr(), src, out_nbytes);
// An output that is also an input can be computed into the input's own
// memory when MLX donates its buffer, and is then already in place.
if (src != out.const_data_ptr()) {
std::memcpy(out.mutable_data_ptr(), src, out_nbytes);
}
}

} // namespace
Expand Down Expand Up @@ -472,6 +491,7 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
};

std::vector<OutputInfo> tensor_output_info;
std::vector<Tid> inputs_written_in_place;
size_t arg_idx = 0;

auto* h = static_cast<MLXHandle*>(handle);
Expand Down Expand Up @@ -504,6 +524,13 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
")");
}
if (slot.slot_type == SlotType::TensorSlot) {
// The method passes an input it mutates in place as the output
// that carries the mutation too, as one and the same EValue.
EValue* arg = args[arg_idx];
if (std::find(args.begin() + n_inputs, args.end(), arg) !=
args.end()) {
inputs_written_in_place.push_back(Tid{slot.idx});
}
const ETTensor& tensor = args[arg_idx++]->toTensor();
Tid tid{slot.idx};
std::optional<TensorMeta> expected_meta = std::nullopt;
Expand Down Expand Up @@ -572,6 +599,12 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {
}
}

// With nothing but the graph left holding an input that is written in
// place, MLX can donate the input's buffer to its update.
for (Tid tid : inputs_written_in_place) {
h->state.release_tensor(tid);
}

// Submit all output work to GPU asynchronously
// async_eval encodes Metal commands and returns immediately.
// The GPU will signal events on completion.
Expand All @@ -581,11 +614,50 @@ class MLXBackend final : public ::executorch::runtime::BackendInterface {

} // Lock released — GPU is still executing

for (auto& info : tensor_output_info) {
// Per tensor output, a copy of its data when it must not be read from
// the array at the time it is written.
std::vector<std::vector<uint8_t>> snapshots(tensor_output_info.size());
if (!inputs_written_in_place.empty()) {
// An output written into an input's memory must not land while work
// still reading that input is in flight.
for (auto& arr : prepared_outputs) {
arr.wait();
}
// Outputs are then written one after another, so one written into an
// input's memory would also change 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. Copy such
// outputs out before writing any.
for (size_t i = 0; i < tensor_output_info.size(); ++i) {
const array& arr =
prepared_outputs[tensor_output_info[i].prepared_idx];
// Outputs written up to and including this one.
for (size_t w = 0; w <= i; ++w) {
const auto& written = tensor_output_info[w];
const array& written_arr = prepared_outputs[written.prepared_idx];
const void* dst =
args[written.arg_idx]->toTensor().const_data_ptr();
if (written_arr.data<void>() != dst &&
overlaps(
arr.data<void>(),
arr.nbytes(),
dst,
written_arr.nbytes())) {
const auto* src = static_cast<const uint8_t*>(arr.data<void>());
snapshots[i].assign(src, src + arr.nbytes());
break;
}
}
}
}

for (size_t i = 0; i < tensor_output_info.size(); ++i) {
const auto& info = tensor_output_info[i];
ETTensor& out_tensor = args[info.arg_idx]->toTensor();

// write_output waits on arr to be ready
write_output(prepared_outputs[info.prepared_idx], out_tensor);
write_output(
prepared_outputs[info.prepared_idx], out_tensor, snapshots[i]);
}

h->state.reset(); // Release temp GPU buffers back to MLX cache
Expand Down
4 changes: 4 additions & 0 deletions backends/mlx/runtime/MLXExecutor.h
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ struct ExecutionState {
return idx;
}

void release_tensor(Tid id) {
tensors[tensor_index(id)] = std::nullopt;
}

void reset() {
// Clear per-execution tensors (inputs, outputs, temps)
// Constants and mutable buffers are not in this vector
Expand Down
30 changes: 15 additions & 15 deletions backends/mlx/runtime/MLXInterpreter.h
Original file line number Diff line number Diff line change
Expand Up @@ -1317,26 +1317,26 @@ exec_index_copy(const IndexCopyNode& n, ExecutionState& st, StreamOrDevice s) {
const size_t uaxis = static_cast<size_t>(axis);
const int dst_dim = static_cast<int>(dst.shape()[uaxis]);

// Get indices as a vector of ints, handling negative indices
// Note: PyTorch uses int64 for indices, so we read as int64_t
eval(indices); // Ensure indices are materialized before accessing data
if (indices.dtype() != ::mlx::core::int64) {
// Like ATen, negative indices are rejected rather than wrapped. Export
// accepts int32 indices too, so they are widened rather than rejected.
const array idx_arr = indices.dtype() == ::mlx::core::int32
? astype(indices, ::mlx::core::int64, s)
: indices;
eval(idx_arr); // Ensure indices are materialized before accessing data
if (idx_arr.dtype() != ::mlx::core::int64) {
throw std::invalid_argument(
std::string("IndexCopyNode: expected int64 indices, got ") +
std::string("IndexCopyNode: expected int64 or int32 indices, got ") +
ExecutionState::dtype_str(indices.dtype()));
}
std::vector<int32_t> idx_vec(indices.size());
auto idx_data = indices.data<int64_t>();
for (size_t i = 0; i < indices.size(); ++i) {
int64_t idx = idx_data[i];
if (idx < 0) {
idx += dst_dim;
}
std::vector<int32_t> idx_vec(idx_arr.size());
auto idx_data = idx_arr.data<int64_t>();
for (size_t i = 0; i < idx_arr.size(); ++i) {
const int64_t idx = idx_data[i];
if (idx < 0 || idx >= dst_dim) {
throw std::out_of_range(
"IndexCopyNode: index " + std::to_string(idx_data[i]) +
" out of range for axis " + std::to_string(axis) + " with size " +
std::to_string(dst_dim));
"index_copy: index " + std::to_string(idx) +
" is out of bounds for dimension " + std::to_string(axis) +
" with size " + std::to_string(dst_dim));
}
if (idx > std::numeric_limits<int32_t>::max()) {
throw std::out_of_range(
Expand Down
Loading
Loading