|
30 | 30 | ) |
31 | 31 | from executorch.backends.mlx.builder.op_registry import REGISTRY |
32 | 32 | 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 |
34 | 34 | from executorch.backends.mlx.serialization.mlx_graph_schema import ( |
35 | 35 | AbsNode, |
36 | 36 | AddIntNode, |
|
164 | 164 | # The corresponding edge ops are automatically registered |
165 | 165 | # For ops that are not in aten (e.g., dim order ops), directly register on exir_ops |
166 | 166 | from executorch.exir.dialects._ops import ops as exir_ops |
| 167 | +from executorch.exir.passes.reinplace import _derive_edge_inplace_overload |
167 | 168 | from torch.fx.node import Node |
168 | 169 |
|
169 | 170 | _LEAKY_RELU_DEFAULT_NEGATIVE_SLOPE = 0.01 |
@@ -428,6 +429,217 @@ def handler(P: MLXProgramBuilder, n: Node) -> Slot: |
428 | 429 | REGISTRY.register(target=[_target])(_make_unary_handler(_node_cls, _op_name)) |
429 | 430 |
|
430 | 431 |
|
| 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 | + |
431 | 643 | # --------------------------------------------------------------------------- |
432 | 644 | # Numerical checks |
433 | 645 | # --------------------------------------------------------------------------- |
@@ -4514,3 +4726,36 @@ def emit_reverse(in_slot, out_slot): |
4514 | 4726 | ) |
4515 | 4727 |
|
4516 | 4728 | 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