Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
57c6196
Merge branch 'main' into mlx-index-copy-input
IlyasMoutawwakil Nov 2, 2026
24d820e
Replace the delegate output's spec everywhere it is referenced
IlyasMoutawwakil Nov 2, 2026
f7b5334
Rebase views of the replaced spec in place
IlyasMoutawwakil Nov 2, 2026
961cf20
Read constant destinations and strided indices in MLX IndexCopyNode
IlyasMoutawwakil Nov 2, 2026
7235b99
Mark in-place input mutations per input instead of per backend
IlyasMoutawwakil Nov 3, 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.
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source tensor can also be scalar so either the interpreter needs to support scalar tensors or we have to reject them here.

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: 9 additions & 1 deletion backends/mlx/partitioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
Partitioner,
PartitionResult,
)
from executorch.exir.backend.utils import tag_constant_data, tag_mutated_buffer
from executorch.exir.backend.utils import (
tag_constant_data,
tag_in_place_input_mutations,
tag_mutated_buffer,
)
from executorch.exir.pass_manager import ExportedProgramPassManager
from torch.export.exported_program import ExportedProgram
from torch.fx.passes.infra.partitioner import Partition
Expand Down Expand Up @@ -379,6 +383,10 @@ def partition(self, edge_program: ExportedProgram) -> PartitionResult:
# Tag mutated buffers so they are included in the partition
# This ensures the partitioned subgraph has proper mutation tracking
tag_mutated_buffer(edge_program)
# 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.
tag_in_place_input_mutations(edge_program)

return PartitionResult(
tagged_exported_program=edge_program,
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
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
54 changes: 31 additions & 23 deletions backends/mlx/runtime/MLXInterpreter.h
Original file line number Diff line number Diff line change
Expand Up @@ -1308,7 +1308,11 @@ inline std::tuple<int, int, int, int> next_contiguous_run(
// Optimizes into slice_update calls for contiguous runs
inline void
exec_index_copy(const IndexCopyNode& n, ExecutionState& st, StreamOrDevice s) {
array& dst = st.tensor_ref(n.dst);
// When out == dst, use direct assignment to preserve MLX buffer donation.
// Only then is dst written, so a functional update may read a constant.
const bool in_place = (n.out.idx == n.dst.idx);
const array& dst =
in_place ? st.tensor_ref(n.dst) : st.const_tensor_ref(n.dst);
const array& upd = st.const_tensor_ref(n.update);
const array& indices = st.const_tensor_ref(n.indices);
if (indices.ndim() != 1) {
Expand All @@ -1319,26 +1323,31 @@ 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.
// Indices computed in the graph can be a strided view, so they are made
// contiguous before their data is read.
const array idx_arr = contiguous(
indices.dtype() == ::mlx::core::int32
? astype(indices, ::mlx::core::int64, s)
: indices,
false,
s);
eval(idx_arr); // Ensure indices are materialized before accessing data
Comment on lines +1330 to +1336

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a GPU sync to every IndexCopyNode, including the existing mutable-buffer KV cache path. contiguous() always creates a new unscheduled array, even when its input is already contiguous and materialized, so eval(idx_arr) commits a command buffer and waits once per layer per step. Previously eval(indices) was free for a method input like the cache position, since it's already materialized. The int32 astype also adds a kernel.

exec_update_and_attend already avoids this; see

// Read positions host-side so the cache stays pure graph + integer
// bookkeeping. Every layer of a step reads the same position
// tensor, so evaluating it in place costs one sync for the first layer and
// nothing for the rest.
auto pos = st.const_tensor_ref(n.position);
eval(pos);
// The entries are read in order off the buffer, which a strided view would
// walk with the wrong stride.
if (!pos.flags().row_contiguous) {
throw std::runtime_error("update_and_attend: position must be contiguous");
}
const int length = static_cast<int>(pos.size());
std::vector<int32_t> positions(static_cast<size_t>(length));
switch (pos.dtype()) {
case ::mlx::core::int32:
std::copy(
pos.data<int32_t>(), pos.data<int32_t>() + length, positions.begin());
break;
case ::mlx::core::int64:
std::transform(
pos.data<int64_t>(),
pos.data<int64_t>() + length,
positions.begin(),
[](int64_t p) { return static_cast<int32_t>(p); });
break;
default:
throw std::runtime_error(
std::string("update_and_attend: position must be int32 or int64, ") +
"got " + ExecutionState::dtype_str(pos.dtype()));
}
: eval the tensor as-is, check flags().row_contiguous, and read int32/int64 on the host with a switch instead of astype.

Could you factor that into a shared helper both ops use? Something like:

// Reads a 1-D int32/int64 tensor on the host. Free for a materialized input;
// only indices computed in the graph cost a sync.
inline std::vector<int64_t>
read_host_indices(array a, const char* op, StreamOrDevice s) {
  eval(a);
  if (!a.flags().row_contiguous) {
    a = contiguous(a, false, s);
    eval(a);
  }
  // switch on a.dtype(): copy int32 / int64 out, throw otherwise
}

The contiguous fallback keeps strided indices working; only indices computed in the graph can be strided, and those already need a sync.

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 All @@ -1348,11 +1357,9 @@ exec_index_copy(const IndexCopyNode& n, ExecutionState& st, StreamOrDevice s) {
idx_vec[i] = static_cast<int>(idx);
}

// When out == dst, use direct assignment to preserve MLX buffer donation.
// TODO: I'm not sure if this is needed as a special case since the standard
// st.set_tensor does a std::move. Keeping for now, but should investigate and
// possibly remove in future.
const bool in_place = (n.out.idx == n.dst.idx);
// TODO: I'm not sure the in-place case is needed as a special case since the
// standard st.set_tensor does a std::move. Keeping for now, but should
// investigate and possibly remove in future.

if (idx_vec.empty()) {
if (!in_place) {
Expand Down Expand Up @@ -1402,8 +1409,9 @@ exec_index_copy(const IndexCopyNode& n, ExecutionState& st, StreamOrDevice s) {
: slice(upd, to_shape(upd_vstart), to_shape(upd_vstop), s);

if (in_place) {
dst = slice_update(
dst, upd_slice, to_shape(dst_vstart), to_shape(dst_vstop), s);
array& target = st.tensor_ref(n.dst);
target = slice_update(
target, upd_slice, to_shape(dst_vstart), to_shape(dst_vstop), s);
} else {
result = slice_update(
result, upd_slice, to_shape(dst_vstart), to_shape(dst_vstop), s);
Expand Down
Loading
Loading