Skip to content

Ship the MLX delegate as a linkable library in the wheel - #22184

Merged
shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:mlx-shared-wheel
Aug 26, 2026
Merged

Ship the MLX delegate as a linkable library in the wheel#22184
shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:mlx-shared-wheel

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

MLX is reachable from Python but not from C++. The macOS wheel carries no MLX
library at all: the delegate is statically fused into the Python extension, so a
C++ application that links the wheel cannot use it.

find_package(executorch) offers no MLX component, and running an MLX program
fails at load:

Backend MLXBackend is not registered.

Build the delegate as a shared library in the wheel, the same way the XNNPACK
delegate already is, and package it beside the runtime. A C++ consumer can then
link executorch::backend_mlx.

The static MLX archive is bundled into the shared library rather than left for
the consumer to supply, since it carries the delegate's kernels. The archive is
kept out of the target's interface deliberately, so an imported target that only
exists while building cannot reach the installed export set.

The Metal kernel archive moves from beside the Python extension to beside the
shared library. MLX finds that archive by resolving the address of its own code
with dladdr() and looking in the resulting directory, so it has to sit next to
whichever image carries MLX. Building the delegate shared moves that code out of
the Python extension entirely, so both the Python path and a C++ consumer now
resolve to the library directory and one copy serves both. The extension shrinks
from 5.7 MB to 0.9 MB as a result.

The copy placed beside the Python extension during a build is kept, because
without the shared option the delegate is static and is absorbed into the
extension, which is then the image that carries MLX.

Nothing changes for a build that is not producing a wheel: without the shared
option the delegate stays a static library linking the static runtime.

Test Plan:
Built the wheel on macOS arm64 with MLX enabled, installed it into a new virtual
environment holding only torch and the wheel, and exercised both paths. The
build-tree copy of the Metal archive was hidden for every run, because MLX falls
back to a compile-time path that still exists on a build machine and would
otherwise mask a failure.

find_package(executorch) now reports executorch::backend_mlx alongside the
other components, where the list previously ended at backend_xnnpack. A C++
program linking it loads an MLX program and produces the expected output, where
the same program previously failed with the error above. The same program on an
XNNPACK model still passes, so the harness is sound.

The Python path still exports, runs and matches eager exactly.

Which directory the Metal archive has to live in was measured rather than
reasoned about, by removing each copy in turn. Only the copy beside the shared
library works; the one beside the Python extension is not reachable from either
path once the delegate is shared. That is why a single copy ships.

Also add the delegate to the wheel's own release checks, which assert both that
every shipped library has an expected name and that each component has exactly
one owning library. The second one matters here because this library bundles a
third-party runtime, which is the case those checks exist to police.

The Metal kernel archive ships beside whichever image carries MLX code, which is the
delegate when it is built shared and the Python extension when it is not. The build
records which case applies, so exactly one copy ships either way.

Document the new component: the C++ page previously said a C++ application on
macOS cannot link MLX at all, which is what this change makes false, and its
component table and probe loop omitted it. The wheel's package config now also
publishes the archive's path, the way the source package already does, so an
application that relocates the delegate can bring the archive with it.

Ran the wheel's own two release checks against that install. Both pass, and the
new ownership rows are doing work rather than skipping: each reports a single
owner for the delegate and for the bundled runtime. Confirmed they can fail by
planting a second copy of the library, which makes them report two definers.

Confirmed a consumer is handed the Metal archive's path by configuring a project
against the installed package, and that the archive is selected for every build
that produces the delegate, shared or static.

Corrected after review, all three verified rather than reasoned about:

The archive was shipping twice in a shared build, because the entry covering the
static case was gated on MLX alone. The build now records which image carries MLX and
exactly one copy ships in every configuration.

The package config located the archive with find_file, which is the pattern the file's
own comment explains it avoids: it applies the consumer's find-root rules, so a
cross-compiling toolchain reroots an absolute path into its sysroot and reports a
complete package as missing. It also cached the result, which goes stale across two
installs in one build tree. Now a plain existence test and an uncached set, which is
what the rest of the file does.

Documented the new component and the published path in the config's own header block,
and gave the delegate the same runtime-file caveat the OpenVINO one already has.

Copilot AI lite review requested due to automatic review settings August 26, 2026 19:15
@pytorch-bot

pytorch-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22184

Note: Links to docs will display an error until the docs builds have been completed.

⏳ 102 Pending, 1 Unrelated Failure

As of commit 0411ced with merge base 04b3446 (image):

BROKEN TRUNK - The following job failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 26, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

Copilot AI review requested due to automatic review settings August 26, 2026 19:53

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 26, 2026 20:16

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Updated. The blocker and all four majors are fixed; the minor was a wrong comment
and is reworded. I reproduced each finding before changing anything.

The blocker is real and I confirmed it against a real wheel. The release check
asserts every shipped library name is in a hardcoded allowlist, and the new one was
not. Extracting the allowlist from the PR tree and matching it against the wheel I
built:

allowlist entries: 11   mlx present? False
UNKNOWN (would fail the assertion): ['libexecutorch_backend_mlx.dylib']

Added the name. The check now passes on that wheel, and still rejects
libexecutorch.dylib.old.dylib, so the fix did not weaken it.

No ownership rows. Added two, modelled on the XNNPACK pair, one for the
delegate and one for the bundled MLX runtime. The symbols are real ones taken from
the built library, verified visible to the exact nm flags the checker uses and
defined by exactly one shipped image. Ran the check against the real install: both
rows report a single owner rather than skipping. Then planted a second copy of the
library to confirm they can fail:

expected at most one library to define
executorch::backends::mlx::mutable_state_note_handle, found 2

Marked not-required, like the optimized-kernels row, because the delegate only
exists on an Apple Silicon macOS wheel whose build found the Metal compiler.

Metallib shipped nowhere with MLX on and shared off. Correct, and the
configuration is reachable, since set_overridable_option does not override a
value the caller set. In that state the delegate is static and absorbed into the
Python extension, so the extension is the image needing the archive. The entry
beside the extension is restored and gated on MLX alone, so every build that
produces the delegate ships the archive. Verified across all four flag
combinations.

I did not use a negated flag for this. is_enabled has no negation, so a
NOT_-prefixed name would read as an undefined variable and silently evaluate
false. Two entries, each gated on what actually exists, is the honest form and
matches how libextension_cuda already ships under two spellings.

Docs said the component cannot exist. Fixed all three surfaces: the paragraph
now names only Core ML as extension-only, and the component table and probe loop
both list backend_mlx.

No metallib path for a wheel consumer. The source config published
MLX_METALLIB_PATH and the wheel config had zero mentions. Mirrored it. Verified
by configuring a project against the installed package:

-- MLX_METALLIB_PATH = .../site-packages/executorch/lib/mlx.metallib

The -force_load comment was wrong on mechanism. Agreed. The helper links
PRIVATE, which does not leak into the export set, so the reason I gave did not
support the conclusion. The comment now says what is actually true: the archive is
kept out of the interface so an imported target cannot reach the export set. I kept
the explicit option rather than the helper, since the helper's extra plain link
would put mlx in the interface, which your own measurement shows breaks a
consumer through PUBLIC/INTERFACE.

On R2, the split cache registry: agreed it is a latent trap for a source install
that opts into the shared build, and agreed it is not reachable in what this ships,
since the wheel does not ship extension_llm_cache at all. Worth a follow-up
rather than widening this change.

Copilot AI review requested due to automatic review settings August 26, 2026 20:41

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

C4 is now fixed too, and thank you for catching that it was only half done.

I had corrected the commit message but left the wrong reason sitting in the code
comment, which is the copy that actually matters to the next reader. The comment no
longer claims the helper would put the imported target into the export set. It now
says the archive is deliberately kept out of this target's link interface, because
the imported target exists only while building and naming it anywhere a consumer can
see would leave the installed export set referring to a target that is not there.

That is the mechanism your measurement established: PUBLIC and INTERFACE leak
and then break a consumer with ld: library 'mlx' not found, while PRIVATE does
not. Keeping the explicit option rather than the helper is still the choice, since
the helper's extra plain link is what would put the name in the interface.

MLX is reachable from Python but not from C++. The macOS wheel carries no MLX
library at all: the delegate is statically fused into the Python extension, so a
C++ application that links the wheel cannot use it.

`find_package(executorch)` offers no MLX component, and running an MLX program
fails at load:

    Backend MLXBackend is not registered.

Build the delegate as a shared library in the wheel, the same way the XNNPACK
delegate already is, and package it beside the runtime. A C++ consumer can then
link `executorch::backend_mlx`.

The static MLX archive is bundled into the shared library rather than left for
the consumer to supply, since it carries the delegate's kernels. The archive is
kept out of the target's interface deliberately, so an imported target that only
exists while building cannot reach the installed export set.

The Metal kernel archive moves from beside the Python extension to beside the
shared library. MLX finds that archive by resolving the address of its own code
with `dladdr()` and looking in the resulting directory, so it has to sit next to
whichever image carries MLX. Building the delegate shared moves that code out of
the Python extension entirely, so both the Python path and a C++ consumer now
resolve to the library directory and one copy serves both. The extension shrinks
from 5.7 MB to 0.9 MB as a result.

The copy placed beside the Python extension during a build is kept, because
without the shared option the delegate is static and is absorbed into the
extension, which is then the image that carries MLX.

Nothing changes for a build that is not producing a wheel: without the shared
option the delegate stays a static library linking the static runtime.

Test Plan:
Built the wheel on macOS arm64 with MLX enabled, installed it into a new virtual
environment holding only torch and the wheel, and exercised both paths. The
build-tree copy of the Metal archive was hidden for every run, because MLX falls
back to a compile-time path that still exists on a build machine and would
otherwise mask a failure.

`find_package(executorch)` now reports `executorch::backend_mlx` alongside the
other components, where the list previously ended at `backend_xnnpack`. A C++
program linking it loads an MLX program and produces the expected output, where
the same program previously failed with the error above. The same program on an
XNNPACK model still passes, so the harness is sound.

The Python path still exports, runs and matches eager exactly.

Which directory the Metal archive has to live in was measured rather than
reasoned about, by removing each copy in turn. Only the copy beside the shared
library works; the one beside the Python extension is not reachable from either
path once the delegate is shared. That is why a single copy ships.

Also add the delegate to the wheel's own release checks, which assert both that
every shipped library has an expected name and that each component has exactly
one owning library. The second one matters here because this library bundles a
third-party runtime, which is the case those checks exist to police.

The Metal kernel archive ships beside whichever image carries MLX code, which is the
delegate when it is built shared and the Python extension when it is not. The build
records which case applies, so exactly one copy ships either way.

Document the new component: the C++ page previously said a C++ application on
macOS cannot link MLX at all, which is what this change makes false, and its
component table and probe loop omitted it. The wheel's package config now also
publishes the archive's path, the way the source package already does, so an
application that relocates the delegate can bring the archive with it.

Ran the wheel's own two release checks against that install. Both pass, and the
new ownership rows are doing work rather than skipping: each reports a single
owner for the delegate and for the bundled runtime. Confirmed they can fail by
planting a second copy of the library, which makes them report two definers.

Confirmed a consumer is handed the Metal archive's path by configuring a project
against the installed package, and that the archive is selected for every build
that produces the delegate, shared or static.

Corrected after review, all three verified rather than reasoned about:

The archive was shipping twice in a shared build, because the entry covering the
static case was gated on MLX alone. The build now records which image carries MLX and
exactly one copy ships in every configuration.

The package config located the archive with find_file, which is the pattern the file's
own comment explains it avoids: it applies the consumer's find-root rules, so a
cross-compiling toolchain reroots an absolute path into its sysroot and reports a
complete package as missing. It also cached the result, which goes stale across two
installs in one build tree. Now a plain existence test and an uncached set, which is
what the rest of the file does.

Documented the new component and the published path in the config's own header block,
and gave the delegate the same runtime-file caveat the OpenVINO one already has.
Copilot AI review requested due to automatic review settings August 26, 2026 22:34

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 13s —— View job


Code Review: Ship the MLX delegate as a linkable library in the wheel

I read the full diff (6 files) against the XNNPACK precedent and the CMake helpers. This is a careful, well-reasoned change and the commit message / discussion already anticipate most of the sharp edges. Findings below, none blocking.

  • Read the diff and helpers
  • Compare against the XNNPACK shared-library pattern
  • Check metallib double-ship logic, the package config, and the release checks

What's solid

  • The SHARED/STATIC split, OUTPUT_NAME, executorch_target_soname_policy, and executorch_target_shipped_runtime_path mirror backends/xnnpack/CMakeLists.txt:114-140 exactly, so the delegate lands in lib/ with the right rpath and soname.
  • The two mlx.metallib BuiltFile entries are correctly mutually exclusive: entry 1 (lib/) is gated on EXECUTORCH_BUILD_SHARED + EXECUTORCH_BUILD_MLX; entry 2 (pybindings/) on EXECUTORCH_MLX_METALLIB_IN_PYBINDINGS, which is ON only in the static case. Exactly one ships in every combination. This resolves the double-ship the discussion flagged.
  • Using a plain set(MLX_METALLIB_PATH ...) guarded by EXISTS instead of find_file is the right call — it avoids the consumer's find-root reroot and the stale cache across two installs, consistent with the rest of executorch-wheel-config.cmake.
  • Release-check additions are meaningful: distinct rows for the delegate and the bundled MLX runtime, False (not-required) because the delegate only exists on an Apple-Silicon Metal-capable wheel, plus the allowlist entry. Matches the XNNPACK ownership pair.

Points worth a second look

  1. executorch_shared linked PRIVATE here vs PUBLIC in XNNPACK. backends/xnnpack/CMakeLists.txt:135 uses target_link_libraries(xnnpack_backend PUBLIC executorch_shared), while this PR uses PRIVATE. PRIVATE is arguably cleaner (it keeps the runtime out of the delegate's INTERFACE_LINK_LIBRARIES), and it's fine at runtime because the .dylib records its own LC_LOAD_DYLIB on libexecutorch.dylib. The only consequence: a consumer linking executorch::backend_mlx alone does not transitively pull in executorch::runtime — they must list it explicitly. That's what the docs' probe loop already does, so no action needed, but the divergence from XNNPACK is intentional and worth a one-line note so a future reader doesn't "fix" it to PUBLIC (which your own measurement showed leaks mlx and breaks consumers).

  2. mlx is referenced twice in the shared branch — once as $<BUILD_INTERFACE:mlx> in target_link_libraries and again via LINKER:-force_load,$<TARGET_FILE:mlx>. This is harmless on ld64 (force_load pulls every object; the plain reference then finds nothing new — no duplicate-symbol risk) and the plain link is what guarantees build ordering. The comment explains it, so this is just confirming it's deliberate, not a bug.

  3. extension_llm_cache absorbed into the shared delegate. It's linked PRIVATE and, per the discussion, isn't shipped anywhere else in the wheel, so there's no second copy of the off-graph KV-cache registry. The ownership checks would catch a regression here. Agreed this is a follow-up concern (source-tree opt-in to shared extension_llm_cache), not something this PR needs to solve.

  4. Minor / cosmetic: LINKER:-force_load,$<TARGET_FILE:mlx> will tokenize on spaces under the SHELL: prefix, so a build directory with spaces in its path would break the flag. Build trees virtually never contain spaces, so this is a theoretical nit only.

Verification I could not perform

I can't build the macOS/Apple-Silicon wheel in this environment, so I'm trusting the author's reported runs (find_package now lists backend_mlx, both release checks pass and fail-on-plant, the metallib resolves for a consumer, extension shrinks 5.7 MB → 0.9 MB). The logic all lines up with the established XNNPACK path.

Overall: LGTM. The one thing I'd add is a short comment at backends/mlx/CMakeLists.txt explaining the deliberate PRIVATE choice for executorch_shared vs XNNPACK's PUBLIC, so the divergence reads as intentional.
• branch mlx-shared-wheel

@shoumikhin
shoumikhin merged commit f788639 into pytorch:main Aug 26, 2026
395 of 399 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/mlx CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants