Skip to content

Commit 0f3303f

Browse files
authored
[MLX] enable reinplacement for unary/binary ops (#20557)
This adds ExecuTorch's reinplace pass to the MLX backends default pass, and targets all unary/binary ops.
1 parent 035b45a commit 0f3303f

8 files changed

Lines changed: 802 additions & 5 deletions

File tree

backends/mlx/builder/program_builder.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def to_int_or_vid_or_tid(self, v: Union[int, Slot]) -> IntOrVidOrTid:
322322
return IntOrVidOrTid.from_vid(self.slot_to_vid(v))
323323
return IntOrVidOrTid.from_literal(int(v))
324324

325-
def _mark_read(self, node: Node):
325+
def _mark_read(self, node: Node, consumer: Optional[Node] = None):
326326
assert self.node_info[node].handled, f"Node {node} is not handled"
327327
assert (
328328
self.node_info[node].remaining_reads > 0
@@ -335,9 +335,24 @@ def _mark_read(self, node: Node):
335335
return
336336
if not isinstance(slot, tuple):
337337
slot = (slot,)
338+
# When the consuming node reuses one of this node's slots in place as
339+
# its own output (out == in, e.g. an in-place unary like exp_), the
340+
# slot's lifetime transfers to the consumer: it must NOT be reclaimed
341+
# here, or a later allocation could grab the same id while the
342+
# consumer (which shares it) is still live. The consumer frees it when
343+
# its own reads finish. Slot equality is identity-based.
344+
aliased: set = set()
345+
if consumer is not None:
346+
consumer_slot = self.slot_manager.get_slot(consumer)
347+
if consumer_slot is not None:
348+
if not isinstance(consumer_slot, tuple):
349+
consumer_slot = (consumer_slot,)
350+
aliased = set(consumer_slot)
338351
for s in slot:
339352
if s.id_space != IdSpace.Temp:
340353
continue
354+
if s in aliased:
355+
continue
341356
if s.id_type == IdType.Tensor:
342357
self.slot_manager.tid_managers[IdSpace.Temp].return_id(s.idx)
343358
else:
@@ -359,7 +374,7 @@ def mark_read(n: Node):
359374
for a in flat_args:
360375
if isinstance(a, Node):
361376
if a not in seen:
362-
self._mark_read(a)
377+
self._mark_read(a, consumer=n)
363378
seen.add(a)
364379

365380
if isinstance(handler, PatternHandler):

backends/mlx/ops.py

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
)
3131
from executorch.backends.mlx.builder.op_registry import REGISTRY
3232
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
33-
from executorch.backends.mlx.builder.slot_manager import IdType, Slot
33+
from executorch.backends.mlx.builder.slot_manager import IdSpace, IdType, Slot
3434
from executorch.backends.mlx.serialization.mlx_graph_schema import (
3535
AbsNode,
3636
AddIntNode,
@@ -164,6 +164,7 @@
164164
# The corresponding edge ops are automatically registered
165165
# For ops that are not in aten (e.g., dim order ops), directly register on exir_ops
166166
from executorch.exir.dialects._ops import ops as exir_ops
167+
from executorch.exir.passes.reinplace import _derive_edge_inplace_overload
167168
from torch.fx.node import Node
168169

169170
_LEAKY_RELU_DEFAULT_NEGATIVE_SLOPE = 0.01
@@ -428,6 +429,217 @@ def handler(P: MLXProgramBuilder, n: Node) -> Slot:
428429
REGISTRY.register(target=[_target])(_make_unary_handler(_node_cls, _op_name))
429430

430431

432+
def _make_inplace_unary_handler(node_cls: Any, op_name: str):
433+
"""Create a handler for an in-place unary op (e.g. aten.exp_).
434+
435+
These nodes are produced by the MLX reinplace pass (see passes.py), which
436+
only rewrites a functional op to its in-place form when the input is a dead,
437+
single-use temp. We bind the node's output slot to that input slot and emit
438+
with out_tid == in_tid so MLX donates the input buffer at eval time (same
439+
mechanism as SLICE_UPDATE/INDEX_COPY). If the input is not a reusable temp
440+
(defensive — should not happen given the reinplace safety analysis), fall
441+
back to allocating a fresh output slot.
442+
"""
443+
444+
def handler(P: MLXProgramBuilder, n: Node) -> Slot:
445+
args = P.args(n)
446+
require_args(args, 1, 1, op_name)
447+
require_kwargs(P.kwargs(n), set(), op_name)
448+
x = args[0]
449+
input_node = n.args[0]
450+
# Only alias when n produces a fresh temp (no pre-assigned slot). Graph
451+
# outputs / mutable buffers already own an Output/MutableBuffer slot
452+
# (from _make_io_slots) and must keep it, so fall back to functional for
453+
# those — donation on a terminal output is worthless anyway (it's copied
454+
# out). Also require the input to be a dead, single-use temp.
455+
if (
456+
P.slot_manager.get_slot(n) is None
457+
and isinstance(x, Slot)
458+
and x.id_space == IdSpace.Temp
459+
and isinstance(input_node, Node)
460+
and len(input_node.users) == 1
461+
):
462+
# Reuse the dead input temp's slot as the output (out == in). The
463+
# builder's slot-lifetime transfer (program_builder._mark_read) keeps
464+
# this slot alive until n's own users are done.
465+
P.set_slot(n, x)
466+
P.emit(node_cls(x=P.slot_to_tid(x), out=P.slot_to_tid(x)))
467+
return x
468+
out = P.make_or_get_slot(n)
469+
P.emit(node_cls(x=P.slot_to_tid(x), out=P.slot_to_tid(out)))
470+
return out
471+
472+
handler.__name__ = f"_{op_name.replace('.', '_')}_handler"
473+
handler.__doc__ = f"Handle {op_name} (in-place table-driven unary op)."
474+
return handler
475+
476+
477+
# Register in-place variants (e.g. aten.exp_) for every unary op MLX handles that
478+
# has an aten in-place overload. REINPLACEABLE_UNARY_BASE_NAMES is the source of
479+
# truth consumed by passes.py to build the reinplace pass's op set, so MLX has
480+
# full control over exactly which ops get reinplaced (handlers exist for all of
481+
# them, and nothing else — e.g. index_put is never included).
482+
REINPLACEABLE_UNARY_BASE_NAMES: List[str] = []
483+
for _target, _node_cls, _op_name in _UNARY_OPS:
484+
_base = _op_name.split(".")[-1]
485+
_ip_packet = getattr(torch.ops.aten, _base + "_", None)
486+
_ip_op = getattr(_ip_packet, "default", None) if _ip_packet is not None else None
487+
if _ip_op is None:
488+
continue
489+
REGISTRY.register(target=[_ip_op])(
490+
_make_inplace_unary_handler(_node_cls, _op_name + "_")
491+
)
492+
REINPLACEABLE_UNARY_BASE_NAMES.append(_base)
493+
494+
495+
def _inplace_alias_slot(P: MLXProgramBuilder, n: Node, a) -> Optional[Slot]:
496+
"""Return ``a``'s slot if it is safe to reuse it as ``n``'s output (out == in).
497+
498+
The MLX reinplace pass only emits an in-place op when the mutated operand is
499+
full-size and dtype-matching (the shape/dtype guard lives there, where it is
500+
dynamic-shape/SymInt-safe). So this handler-side check just confirms ``a`` is
501+
a reusable temp: ``n`` has no pre-assigned slot (not a graph output / mutable
502+
buffer) and ``a`` is a single-use ``Temp`` tensor. (Reusing the slot is
503+
runtime-correct regardless of shape — it is functional slot reuse; MLX only
504+
donates the buffer when sizes are compatible.) Returns None otherwise.
505+
"""
506+
if P.slot_manager.get_slot(n) is not None:
507+
return None
508+
a_node = n.args[0] if n.args else None
509+
if not (
510+
isinstance(a, Slot)
511+
and a.id_space == IdSpace.Temp
512+
and isinstance(a_node, Node)
513+
and len(a_node.users) == 1
514+
):
515+
return None
516+
return a
517+
518+
519+
def _make_inplace_binary_handler(node_cls: Any, op_name: str):
520+
"""In-place binary handler (mul_/div_, no alpha): alias out == arg0 when safe.
521+
522+
Produced by the MLX reinplace pass, which already guarantees arg0 is a
523+
full-size, dtype-matching, single-use dead temp; the alias check here is
524+
defensive and also handles the graph-output fallback.
525+
"""
526+
527+
def handler(P: MLXProgramBuilder, n: Node) -> Slot:
528+
args = P.args(n)
529+
require_args(args, 2, 2, op_name)
530+
require_kwargs(P.kwargs(n), set(), op_name)
531+
a, b = args[0], args[1]
532+
alias = _inplace_alias_slot(P, n, a)
533+
out = alias if alias is not None else P.make_or_get_slot(n)
534+
P.emit(node_cls(a=P.slot_to_tid(a), b=P.slot_to_tid(b), out=P.slot_to_tid(out)))
535+
if alias is not None:
536+
P.set_slot(n, alias)
537+
return out
538+
539+
handler.__name__ = f"_{op_name.replace('.', '_')}_handler"
540+
handler.__doc__ = f"Handle {op_name} (in-place table-driven binary op)."
541+
return handler
542+
543+
544+
def _make_inplace_addsub_handler(node_cls: Any, op_name: str):
545+
"""In-place add_/sub_ handler: handles the alpha kwarg and aliases out == arg0.
546+
547+
``alpha`` only scales the *other* operand (arg1), so it never blocks aliasing
548+
arg0 (self); when ``alpha != 1`` we emit ``other * alpha`` into a temp first.
549+
"""
550+
551+
def handler(P: MLXProgramBuilder, n: Node) -> Slot:
552+
args = P.args(n)
553+
require_args(args, 2, 2, op_name)
554+
require_kwargs(P.kwargs(n), {"alpha"}, op_name)
555+
a, b = args[0], args[1]
556+
alpha = P.kwargs(n).get("alpha", 1)
557+
if alpha != 1:
558+
input_meta = n.args[0].meta.get("val")
559+
dtype = input_meta.dtype if input_meta is not None else torch.float32
560+
alpha_slot = emit_lifted_constant(P, alpha, dtype)
561+
_, tmp = P.make_tmp_slot()
562+
P.emit(
563+
MultiplyNode(
564+
a=P.slot_to_tid(b),
565+
b=P.slot_to_tid(alpha_slot),
566+
out=P.slot_to_tid(tmp),
567+
)
568+
)
569+
b = tmp
570+
alias = _inplace_alias_slot(P, n, a)
571+
out = alias if alias is not None else P.make_or_get_slot(n)
572+
P.emit(node_cls(a=P.slot_to_tid(a), b=P.slot_to_tid(b), out=P.slot_to_tid(out)))
573+
if alias is not None:
574+
P.set_slot(n, alias)
575+
return out
576+
577+
handler.__name__ = f"_{op_name.replace('.', '_')}_handler"
578+
handler.__doc__ = f"Handle {op_name} (in-place add/sub op)."
579+
return handler
580+
581+
582+
# In-place binary handlers + the (base, overload) source of truth consumed by
583+
# passes.py to build the binary reinplace op set. Restricted to dtype-preserving
584+
# arithmetic Tensor overloads; the reinplace pass additionally guards that arg0
585+
# is full-size (no broadcast) before producing these in-place ops.
586+
REINPLACEABLE_BINARY_BASE_OVERLOADS: List[Tuple[str, str]] = []
587+
for _ip_target, _ip_node_cls, _ip_name, _is_addsub in (
588+
(torch.ops.aten.add_.Tensor, AddNode, "aten.add_", True),
589+
(torch.ops.aten.sub_.Tensor, SubtractNode, "aten.sub_", True),
590+
(torch.ops.aten.mul_.Tensor, MultiplyNode, "aten.mul_", False),
591+
(torch.ops.aten.div_.Tensor, DivideNode, "aten.div_", False),
592+
):
593+
_factory = (
594+
_make_inplace_addsub_handler if _is_addsub else _make_inplace_binary_handler
595+
)
596+
REGISTRY.register(target=[_ip_target])(_factory(_ip_node_cls, _ip_name))
597+
REINPLACEABLE_BINARY_BASE_OVERLOADS.append((_ip_name.split(".")[-1][:-1], "Tensor"))
598+
599+
600+
def _make_inplace_passthrough_handler(functional_handler):
601+
"""In-place handler that aliases out == self, then delegates to the op's
602+
existing functional handler.
603+
604+
These functional handlers (clamp, pow, gelu, relu, leaky_relu, hardtanh)
605+
obtain their output slot via ``P.make_or_get_slot(n)`` and write it with the
606+
last op they emit. By pre-binding ``n``'s slot to the dead ``self`` temp
607+
before delegating, that final write becomes in-place (out == in) and MLX can
608+
donate the buffer. When ``self`` is not a reusable temp (e.g. a graph
609+
output), no pre-bind happens and the functional handler runs unchanged.
610+
611+
The mutated ``self`` is always positional arg 0 for these ops, and every
612+
op emitted before the output writer only *reads* ``self``, so the in-place
613+
write (last) is safe. This "all reads of ``self`` happen before the final
614+
write to out == self" ordering is a contract on each delegated functional
615+
handler; the assertion below catches the easy-to-spot violation where a
616+
handler stops using ``n``'s slot as its output, but a handler that reads
617+
``self`` *after* writing out would still silently corrupt — keep that
618+
invariant in mind when editing clamp/pow/gelu/relu/leaky_relu/hardtanh.
619+
"""
620+
621+
def handler(P: MLXProgramBuilder, n: Node) -> Slot:
622+
args = P.args(n)
623+
self_slot = args[0] if args else None
624+
alias = _inplace_alias_slot(P, n, self_slot)
625+
if alias is not None:
626+
P.set_slot(n, alias)
627+
result = functional_handler(P, n)
628+
# When we pre-bind out == self, the delegated handler must treat that
629+
# slot as its output (write it last). Confirm it actually returned the
630+
# aliased slot; otherwise the in-place aliasing silently did nothing.
631+
assert alias is None or result is alias, (
632+
f"{getattr(functional_handler, '__name__', functional_handler)} did "
633+
f"not use the aliased out==self slot as its output for {n}; in-place "
634+
f"passthrough requires the delegated handler to write n's slot."
635+
)
636+
return result
637+
638+
handler.__name__ = "_inplace_passthrough_handler"
639+
handler.__doc__ = "In-place passthrough (aliases out==self, delegates)."
640+
return handler
641+
642+
431643
# ---------------------------------------------------------------------------
432644
# Numerical checks
433645
# ---------------------------------------------------------------------------
@@ -4514,3 +4726,36 @@ def emit_reverse(in_slot, out_slot):
45144726
)
45154727

45164728
return output_slots
4729+
4730+
4731+
# ---------------------------------------------------------------------------
4732+
# In-place variants for ops with bespoke functional handlers (clamp, pow,
4733+
# activations). Each reuses its functional handler via a passthrough that
4734+
# aliases out == self when self is a dead temp (see
4735+
# _make_inplace_passthrough_handler). Registered last, after every functional
4736+
# handler above is defined. REINPLACEABLE_EXTRA_BASE_OVERLOADS feeds passes.py.
4737+
# ---------------------------------------------------------------------------
4738+
REINPLACEABLE_EXTRA_BASE_OVERLOADS: List[Tuple[str, str]] = []
4739+
for _base, _overload in (
4740+
("clamp", "default"),
4741+
("clamp", "Tensor"),
4742+
("gelu", "default"),
4743+
("relu", "default"),
4744+
("leaky_relu", "default"),
4745+
("hardtanh", "default"),
4746+
("pow", "Tensor_Scalar"),
4747+
("pow", "Tensor_Tensor"),
4748+
):
4749+
_func_aten = getattr(getattr(torch.ops.aten, _base), _overload, None)
4750+
if _func_aten is None:
4751+
continue
4752+
_func_handler = REGISTRY._handlers.get(_func_aten)
4753+
if _func_handler is None:
4754+
continue
4755+
_ip_edge = _derive_edge_inplace_overload(_func_aten)
4756+
if _ip_edge is None:
4757+
continue
4758+
REGISTRY.register(target=[_ip_edge._op])(
4759+
_make_inplace_passthrough_handler(_func_handler)
4760+
)
4761+
REINPLACEABLE_EXTRA_BASE_OVERLOADS.append((_base, _overload))

0 commit comments

Comments
 (0)