huggingface/transformers · #48910

[Executorch] Add MLX recipe

metascroy · merged Sep 25, 20264 files · 130 + / 27 −
docs/source/en/exporters.md36 + / 11 −
@@ -76,12 +76,30 @@ pip install transformers "torch==2.12.0" "onnx==1.21.0" "onnxscript==0.7.0" onnx ```  </hfoption>-<hfoption id="ExecuTorch">+<hfoption id="ExecuTorch XNNPACK">  ```bash pip install transformers "torch==2.12.0" "executorch==1.3.1" ``` +</hfoption>+<hfoption id="ExecuTorch MLX">++Requires macOS 14 or later on Apple Silicon. MLX is included in the macOS ARM64 ExecuTorch+nightly wheels; it is not a separate package or extra. The following nightly pair was validated+with the MLX exporter tests. Install it from the standard ExecuTorch nightly registry:++```bash+pip install transformers+pip install \+  "executorch==1.6.0.dev20260924" \+  "torch==2.15.0.dev20260924" \+  --extra-index-url https://download.pytorch.org/whl/nightly/cpu+```++Install `torch` explicitly because nightly ExecuTorch wheels do not declare it as a dependency.+For source builds, see the [MLX installation guide](https://docs.pytorch.org/executorch/main/backends/mlx/mlx-overview.html).+ </hfoption> <hfoption id="OpenVINO"> @@ -145,7 +163,10 @@ outputs = session.run(None, ort_inputs) </hfoption> <hfoption id="ExecuTorch"> -[`~exporters.ExecutorchConfig#backend`] defaults to `xnnpack` which targets the CPU and works on CPU-only installations. `cuda` targets the GPU and requires a CUDA-enabled environment. Requesting it without CUDA raises a `RuntimeError`.+[`~exporters.ExecutorchConfig#backend`] selects the target: `xnnpack` (the default) for CPU,+`mlx` for Apple Silicon GPU, or `cuda` for a CUDA-enabled GPU. Choose the corresponding+[installation option](#installation). MLX execution requires the MLX delegate and its Metal libraries;+requesting CUDA without a CUDA-enabled environment raises a `RuntimeError`.  ```python from transformers import AutoModelForCausalLM, AutoTokenizer@@ -156,7 +177,7 @@ tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B") inputs = tokenizer("Hello, world!", return_tensors="pt")  exporter = ExecutorchExporter()-config = ExecutorchConfig(backend="xnnpack", dynamic=True)+config = ExecutorchConfig(backend="xnnpack", dynamic=True)  # Use "mlx" on Apple Silicon. et_program = exporter.export(model, inputs, config=config)  # save for on-device deployment@@ -277,7 +298,7 @@ seq = torch.export.Dim("seq", min=1, max=2048)  exporter = ExecutorchExporter() config = ExecutorchConfig(-    backend="xnnpack",+    backend="xnnpack",  # Use "mlx" on Apple Silicon.     dynamic_shapes={"input_ids": {0: batch, 1: seq}, "attention_mask": {0: batch, 1: seq}},     # Emit data-dependent shape guards as runtime asserts instead of failing the export when a     # guard wouldn't hold across the explicit symbolic range. Most LLMs need this under fine-grained@@ -388,7 +409,7 @@ text = processor.apply_chat_template(messages, add_generation_prompt=True, token inputs = processor(text=text, images=messages[0]["content"][0]["url"], return_tensors="pt").to(model.device)  exporter = ExecutorchExporter()-config = ExecutorchConfig(backend="xnnpack", dynamic=True)+config = ExecutorchConfig(backend="xnnpack", dynamic=True)  # Use "mlx" on Apple Silicon. components = exporter.export_for_generation(model, inputs, config=config) # components = {"image_encoder": ExecutorchProgramManager, "language_model": ..., "lm_head": ..., "decode": ...} ```@@ -490,7 +511,7 @@ components = exporter.export_for_generation(model, inputs, config=config, multi_ from transformers.exporters import ExecutorchExporter, ExecutorchConfig  exporter = ExecutorchExporter()-config = ExecutorchConfig(backend="xnnpack", dynamic=True)+config = ExecutorchConfig(backend="xnnpack", dynamic=True)  # Use "mlx" on Apple Silicon. components = exporter.export_for_generation(model, inputs, config=config, multi_token_decode=True) # components["decode"] now accepts a variable number of query tokens ```@@ -505,6 +526,10 @@ cache handles where they land internally.  ### Static KV cache +> [!NOTE]+> The ExecuTorch examples in this section, including zero-copy updates and the decode loop,+> are **XNNPACK-only**. The MLX exporter rejects `StaticCache`; use `DynamicCache` with MLX.+ `generate()` grows a `DynamicCache` by default, reallocating as the sequence extends — a moving target for an exported graph. A **static** cache is a fixed-size buffer, allocated once and written in place at the current position each step. Combined with a [multi-token decode](#multi-token-decode) it collapses@@ -542,7 +567,7 @@ components = exporter.export_for_generation( ```  </hfoption>-<hfoption id="ExecuTorch">+<hfoption id="ExecuTorch XNNPACK">  ```python from transformers import GenerationConfig@@ -577,7 +602,7 @@ own arena. What that takes is the only per-backend part left:   each pair to one device buffer, so the cache is read and updated in place across the loop with no host   round-trips. -- **ExecuTorch** — turn off the memory-planning allocations on [`ExecutorchConfig`] so the in-place+- **ExecuTorch (XNNPACK)** — turn off the memory-planning allocations on [`ExecutorchConfig`] so the in-place   write can land in the caller's own tensor (see the reference for what each flag does):    ```python@@ -718,7 +743,7 @@ for position in range(prompt_len, max_cache_len): ```  </hfoption>-<hfoption id="ExecuTorch">+<hfoption id="ExecuTorch XNNPACK">  ExecuTorch's on-device runtime is C++, and the in-place cache update relies on `Method::set_output_data_ptr` — **not surfaced by the Python runtime** (`executorch.runtime.Method`@@ -807,8 +832,8 @@ visible from the public `export` API, but the most common things to know: setting and `eager` also works (slower). Set one of them on the model before calling `export` if it's using something else. - `grouped_mm` traces fine through `DynamoExporter` and is auto-translated for `OnnxExporter`.-For `ExecutorchExporter` with the XNNPACK backend, the exporter swaps MoE experts to-`batched_mm` because XNNPACK has no `_grouped_mm.out` kernel.+For `ExecutorchExporter` with either XNNPACK or MLX, the exporter swaps MoE experts to+`batched_mm` before export.  ## Next steps 
src/transformers/exporters/configs.py1 + / 0 −
@@ -163,6 +163,7 @@ class ExecutorchConfig(DynamoConfig):              - `"xnnpack"` — CPU inference via the XNNPACK library (default; runs anywhere).             - `"cuda"` — GPU inference via the ExecuTorch CUDA backend.+            - `"mlx"` — GPU inference via the ExecuTorch MLX backend on Apple Silicon.         alloc_graph_input (`bool`, *optional*, defaults to `True`):             Whether the memory-planning pass reserves arena memory for graph inputs. When `False`,             the runtime uses the caller-provided input buffers directly instead of copying into the
src/transformers/exporters/exporter_executorch.py45 + / 5 −
@@ -17,8 +17,8 @@ Extends `DynamoExporter` to produce an `ExecutorchProgramManager` for mobile and edge deployment. The export pipeline runs: -1. **Backend preparation** (`_BACKEND_PREPARE`): `prepare_for_xnnpack` / `prepare_for_cuda`-   move the model to the target device/dtype and build the partitioner list.+1. **Backend preparation** (`_BACKEND_PREPARE`): move the model to the target device/dtype+   and build the partitioner list. 2. **Torch patches** (`_PATCHES["executorch"]` via `apply_patches("executorch")`, plus the    backend-specific `_PATCHES[f"executorch.{backend}"]`): reversibly swap `torch` ops the    ExecuTorch backends can't accept (`split_copy`, `avg_pool2d`, …) with decomposed equivalents.@@ -67,6 +67,7 @@     from torch.utils._sympy.numbers import IntInfinity     from torch.utils._sympy.value_ranges import ValueRanges +    from ..cache_utils import EncoderDecoderCache, StaticCache     from ..modeling_utils import PreTrainedModel      # Runtime-assert ops dropped before lowering (see `_drop_runtime_asserts`).@@ -145,7 +146,10 @@ def export(             apply_fx_program_fixes("executorch", exported_program)             apply_fx_node_fixes("executorch", exported_program.graph_module)             edge_program_manager: EdgeProgramManager = to_edge_transform_and_lower(-                exported_program, partitioner=partitioner, compile_config=_get_edge_compile_config()+                exported_program,+                partitioner=partitioner,+                compile_config=_get_edge_compile_config(config.backend),+                transform_passes=_get_transform_passes(config.backend),             )             executorch_programs_manager: ExecutorchProgramManager = edge_program_manager.to_executorch(                 config=_get_backend_config(config)@@ -154,7 +158,16 @@ def export(         return executorch_programs_manager  -def _get_edge_compile_config() -> EdgeCompileConfig:+def _get_transform_passes(backend: str):+    """Return backend-specific graph transforms, or ``None`` for defaults."""+    if backend == "mlx":+        from executorch.backends.mlx.passes import get_default_passes++        return get_default_passes()+    return None+++def _get_edge_compile_config(backend: str) -> EdgeCompileConfig:     """Build the ``EdgeCompileConfig`` used for ``to_edge_transform_and_lower``.      Adds non-core ATen ops to ``_core_aten_ops_exception_list`` so torch.export@@ -164,6 +177,8 @@ def _get_edge_compile_config() -> EdgeCompileConfig:     but aren't in the core ATen opset. The CPU portable kernels handle them at     runtime; XNNPACK leaves them in the non-delegated CPU portion of the graph.     """+    if backend == "mlx":+        return EdgeCompileConfig(_check_ir_validity=False, _skip_dim_order=True)     return EdgeCompileConfig(         _core_aten_ops_exception_list=[             torch.ops.aten._fft_c2c.default,@@ -258,9 +273,33 @@ def prepare_for_cuda(model: PreTrainedModel, sample_inputs: dict[str, Any]):     return model, _make_contiguous(sample_inputs), partitioner  +def prepare_for_mlx(model: PreTrainedModel, sample_inputs: dict[str, Any]):+    """Apple Silicon GPU inference via the ExecuTorch MLX backend."""+    for value in sample_inputs.values():+        caches = [value]+        if isinstance(value, EncoderDecoderCache):+            caches = [value.self_attention_cache, value.cross_attention_cache]+        if any(isinstance(cache, StaticCache) for cache in caches):+            raise ValueError(+                "StaticCache is not supported by the ExecuTorch MLX backend. "+                "Use DynamicCache or set cache_implementation='dynamic' in GenerationConfig."+            )++    from executorch.backends.mlx import MLXPartitioner++    model.requires_grad_(False)+    model = model.to(device="cpu")+    # MLX does not support grouped MoE kernels.+    if isinstance(model, PreTrainedModel) and model._can_set_experts_implementation():+        model.set_experts_implementation("batched_mm")+    partitioner = [MLXPartitioner()]+    return model, _make_contiguous(sample_inputs), partitioner++ _BACKEND_PREPARE = {     "xnnpack": prepare_for_xnnpack,     "cuda": prepare_for_cuda,+    "mlx": prepare_for_mlx, }  @@ -339,7 +378,8 @@ def patch(input, k, dim=None, largest=True, sorted=True):     return patch  -@register_patch("executorch", "torch.detach", "torch.Tensor.detach")+@register_patch("executorch.xnnpack", "torch.detach", "torch.Tensor.detach")+@register_patch("executorch.cuda", "torch.detach", "torch.Tensor.detach") def _patch_detach(_original):     """No-op detach.""" 
tests/exporters/test_export.py48 + / 11 −
@@ -14,6 +14,7 @@  import copy import functools+import importlib.util import inspect import itertools import re@@ -47,6 +48,7 @@     slow,     torch_device, )+from transformers.utils import is_executorch_available   # ──────────────────────────── skip lists ────────────────────────────@@ -579,6 +581,38 @@ )  +_EXECUTORCH_BACKENDS = ("xnnpack",)+if is_executorch_available() and importlib.util.find_spec("executorch.backends.mlx") is not None:+    try:+        from executorch.runtime import Runtime++        if "MLXBackend" in Runtime.get().backend_registry.registered_backend_names:+            _EXECUTORCH_BACKENDS += ("mlx",)+    except ImportError:+        pass  # The Python backend can be installed without the native runtime.++EXECUTORCH_EXPORT_PARAMS = parameterized.expand(+    list(itertools.product(_EXECUTORCH_BACKENDS, _EXPORT_SHAPE_MODES)),+    name_func=lambda f, _, p: f"{f.__name__}_{'dynamic' if p.args[1] else 'static'}_{p.args[0]}",+)+EXECUTORCH_GENERATE_EXPORT_PARAMS = parameterized.expand(+    [+        (backend, dynamic, generation_config)+        for backend, dynamic, generation_config in itertools.product(+            _EXECUTORCH_BACKENDS, _EXPORT_SHAPE_MODES, _EXPORT_GENERATION_CONFIGS+        )+        if not (+            backend == "mlx" and generation_config is not None and generation_config.cache_implementation == "static"+        )+    ],+    name_func=lambda f, _, p: (+        f"{f.__name__}_{'dynamic' if p.args[1] else 'static'}"+        + (f"_{p.args[2].cache_implementation}_cache" if p.args[2] is not None else "")+        + f"_{p.args[0]}"+    ),+)++ def _needs_static_cache(generation_config) -> bool:     """True if `generation_config` requests a cache the model must explicitly support (a static impl).     Such variants only run on models that can (see the `_can_compile_fullgraph` gate in the tests)."""@@ -1081,24 +1115,25 @@ def test_openvino_export(self, dynamic, atol=1e-3, rtol=1e-3):      # ──────────────────── ExecuTorch tests ─────────────────────── -    @DYNAMIC_EXPORT_PARAMS+    @EXECUTORCH_EXPORT_PARAMS     @slow     @require_executorch     @pytest.mark.executorch_export_test     @pytest.mark.timeout(EXPORT_TEST_TIMEOUT)     @require_torch_greater_or_equal(MIN_EXPORT_TORCH_VERSION)     @disable_hub_kernels-    def test_executorch_export(self, dynamic):+    def test_executorch_export(self, backend, dynamic):         """Export each model class to ExecuTorch, run it, and verify output count matches eager."""          self._skip_if_not_exportable()         exporter = ExecutorchExporter()-        config = ExecutorchConfig(dynamic=dynamic)+        config = ExecutorchConfig(backend=backend, dynamic=dynamic)          for model_class in self.all_model_classes:-            if self._should_skip(model_class, dynamic=dynamic, backend="executorch"):+            if any(+                self._should_skip(model_class, dynamic=dynamic, backend=scope) for scope in ("executorch", backend)+            ):                 continue-             # Trace on CPU: XNNPACK targets CPU, and CPU tracing yields device-consistent graphs.             # Tracing on CUDA surfaces per-model device bugs — models create in-`forward` tensors             # (arange/zeros/sinusoids) without `device=`, which default to CPU and then mismatch a@@ -1282,26 +1317,28 @@ def test_openvino_export_generate(self, dynamic, generation_config):      # ──────────────────── ExecuTorch tests ─────────────────────── -    @GENERATE_EXPORT_PARAMS+    @EXECUTORCH_GENERATE_EXPORT_PARAMS     @slow     @require_executorch     @pytest.mark.executorch_export_test     @pytest.mark.timeout(EXPORT_TEST_TIMEOUT)     @require_torch_greater_or_equal(MIN_EXPORT_TORCH_VERSION)     @disable_hub_kernels-    def test_executorch_export_generate(self, dynamic, generation_config):+    def test_executorch_export_generate(self, backend, dynamic, generation_config):         """Export prefill and decode stages to ExecuTorch, run each, and verify output count matches eager."""          self._skip_if_not_exportable()         exporter = ExecutorchExporter()-        config = ExecutorchConfig(dynamic=dynamic)+        config = ExecutorchConfig(backend=backend, dynamic=dynamic)          for model_class in self.all_generative_model_classes:-            if self._should_skip(-                model_class, generate=True, dynamic=dynamic, backend="executorch", generation_config=generation_config+            if any(+                self._should_skip(+                    model_class, generate=True, dynamic=dynamic, backend=scope, generation_config=generation_config+                )+                for scope in ("executorch", backend)             ):                 continue-             components = self._prepare_export_generate_model_and_inputs(                 model_class,                 device="cpu",