Skip to content

Report the MLX backend as unavailable on the iOS simulator - #22336

Merged
shoumikhin merged 3 commits into
pytorch:mainfrom
shoumikhin:mlx-simulator-unavailable
Sep 1, 2026
Merged

Report the MLX backend as unavailable on the iOS simulator#22336
shoumikhin merged 3 commits into
pytorch:mainfrom
shoumikhin:mlx-simulator-unavailable

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Loading a model that delegates to MLX crashes the process on an iOS simulator instead of failing
cleanly. In the examples repository the demo app crashes on its MLX menu entry, and the llama and
image classifier tests crash with it.

The simulator's Metal device is real enough to build and dispatch a compute pipeline, but two things
MLX does during startup do not survive it:

what MLX does what the simulator does
reads device.architecture while constructing its device returns nothing, and MLX reads it with no null check
asks for a shared storage heap Metal trips an assertion and aborts the process

MLX has an opt-out for the heap, but it only fires when the device names itself
Apple Paravirtual device. A simulator reports Apple iOS simulator GPU, so the request goes
through. Neither failure is reachable as an error, so nothing downstream can report it.

is_available() is the interface's own answer to whether a backend can run here, and the runtime
already asks before loading a delegate, so returning false is enough to turn the crash into
Error::NotFound. MLX's own is_available() returns a constant and checks nothing, which is why the
answer has to come from here.

This does not make MLX work on the simulator. It makes the answer honest, so a caller gets an error it
can handle.

Documentation

Four pages disagreed with each other about where MLX runs. The MLX overview listed Mac only, while the
build has shipped an iOS slice and declared iOS 17 for some time, so the page was behind the build.
The backend table and the C++ component table also said macOS only. All three now say iOS and macOS,
with the iOS minimum version the build actually enforces.

The iOS page keeps the MLX framework in its list without an exception, because the framework still
builds, ships and links for the simulator: only the runtime answer changed. The simulator behaviour is
called out in a note beside the existing ones instead, so nobody reads the list and starts removing
the simulator slice from their build.

Test plan

A runtime test that needs no Apple hardware: the executor's stub backend already has a hook to install
a custom availability answer, unused until now. Installing false and loading a delegated method
returns Error::NotFound rather than initializing the backend. That runs on every pull request, on
Linux, and it fails if the check in Method::load is removed.

For the guard itself, the macro decides everything, so it is checked on all three targets it affects:

simulator   TARGET_OS_SIMULATOR 1   is_available() false
device      TARGET_OS_SIMULATOR 0   defers to MLX
macOS       TARGET_OS_SIMULATOR 0   defers to MLX

A real GPU is unaffected. Compiling the same function with the guard disabled returns true on the
simulator again, which is the crashing path.

The two Metal facts above were measured directly, with a standalone Metal probe run in a booted
simulator: the device reports Apple iOS simulator GPU with a null architecture, and the heap request
aborts with MTLStorageModePrivate is required for heaps, signal 6.

Not covered: no model was run through MLX on a physical iPhone or iPad as part of this change. The iOS
device rows in the tables reflect what the build already ships, not a new claim measured here.

Follow-ups, not in this change

  • apple.yml has a path filter that does not list backends/mlx, so the iOS framework job does not
    run for MLX runtime changes. Adding it is one line and belongs in its own change.
  • The build still produces and requires the simulator slice of the MLX Metal kernel library. That is
    deliberate for now: if the simulator gains a usable Metal device the guard goes and the file is
    needed again.

Copilot AI lite review requested due to automatic review settings August 30, 2026 21:22
@pytorch-bot

pytorch-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 3 New Failures, 2 Unrelated Failures

As of commit 9ce78e1 with merge base 2b3a32d (image):

NEW FAILURES - The following jobs have failed:

FLAKY - The following job failed but was likely due to flakiness present on trunk:

BROKEN TRUNK - The following job failed but was 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.

@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 30, 2026

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 shoumikhin added the release notes: apple Changes to the Apple backend delegate label Aug 30, 2026
Copilot AI review requested due to automatic review settings August 31, 2026 17:18

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

Thanks, the comment finding was a real error on my part.

The comment was wrong about MLX and is fixed. You are right that MLX does not require the shared
storage heap: the allocator skips it on a paravirtual device, and the one place that uses it tests
heap_ first and falls back to a plain device buffer. So a heap that never got created would not sink
MLX by itself. What actually happens is that Metal traps inside the heap request, so MLX never
receives a value it could check. Only the architecture read genuinely has no null check. The comment
now separates the two, and says the guard is a build switch rather than a probe, plus what would let
it go.

Both documentation pages are fixed. The iOS page listed the MLX framework with no exception while
saying the frameworks work on devices and simulators, and the MLX overview gave only Mac under target
requirements without mentioning iOS at all. Both now say MLX runs on real devices and Mac but not the
simulator.

Two I looked at and am leaving:

The include. I checked whether dropping it silently disables the guard, since that would be the
serious version of this. It does not: TARGET_OS_SIMULATOR is predefined by the compiler for Apple
targets, so a build with the include removed still resolves it to 1 and the guard stays active. I am
keeping the include because relying on a predefined macro is worse than saying where it comes from,
but the failure mode described is not reachable.

The simulator slice of the Metal kernel library. Fair point that the build still produces it, the
package still ships it, and the release job still requires it, while nothing can now load it. I would
rather not trim that in the same change: if the simulator gains a usable Metal device, the guard goes
and that file is needed again. Worth its own change if you would rather it went now.

@shoumikhin
shoumikhin force-pushed the mlx-simulator-unavailable branch from 500efa7 to 11f9377 Compare August 31, 2026 17:42
Copilot AI review requested due to automatic review settings August 31, 2026 17:42

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 31, 2026 21:54

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

Third pass. All the documentation findings are fixed, and the test gap is closed.

A correction to something I said last round. An earlier review told me MLX null-checks the heap and
falls back, so the heap could not be the cause. I repeated that. It is wrong, and I have now measured
why: MLX's opt-out only fires when the device names itself Apple Paravirtual device, and a simulator
reports Apple iOS simulator GPU, so the request goes through and Metal aborts with
MTLStorageModePrivate is required for heaps. Both clauses of the comment are true as written. Thanks
for catching that the earlier finding was inverted.

The test gap is real and now closed. You are right that nothing would notice if the guard were
deleted: the only job that compiles this file for a simulator has tests off and passes on the base
commit too. The stub backend in the executor tests already had an unused hook for installing an
availability answer. Installing false and loading a delegated method now asserts NotFound. It runs on
every pull request, on Linux, with no Apple hardware, and it fails if the check in Method::load goes.

The iOS page was contradicting itself and that was my fault. It now keeps the plain framework entry,
because the framework really does still build, ship and link for the simulator, with the behaviour in a
note beside the existing ones. The backend table and the C++ component table now say iOS and macOS too,
and the iOS requirement carries the 17.0 the build actually enforces.

The description now covers the documentation changes, says the iOS device rows reflect what the build
already ships rather than something measured here, and records the two Metal facts as probe results.

Two follow-ups I would rather not fold in: adding backends/mlx to the Apple workflow path filter, and
the simulator slice of the kernel library, which is worth keeping while the guard might still be lifted.

Copilot AI review requested due to automatic review settings September 1, 2026 16:52
@shoumikhin
shoumikhin force-pushed the mlx-simulator-unavailable branch from d893b71 to d7eb824 Compare September 1, 2026 16:52

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread docs/source/using-executorch-cpp.md Outdated
Comment on lines +356 to +363
Result<FileDataLoader> loader = FileDataLoader::from(program_path());
ASSERT_EQ(loader.error(), Error::Ok);
Result<Program> program = Program::load(&loader.get());
ASSERT_EQ(program.error(), Error::Ok);
ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes);

Result<Method> method_res = program->load_method("forward", &mmm.get());
EXPECT_EQ(method_res.error(), Error::NotFound);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Added EXPECT_TRUE(program->method_meta("forward")->uses_backend(StubBackend::kName)) before the load, so the test fails loudly if the fixture ever stops delegating to the stub, rather than passing NotFound for the wrong reason. This matches the sibling tests in this file (e.g. GetBackendNamesSuccess) which assert uses_backend the same way.

Comment thread docs/source/backends-overview.md Outdated
| [CUDA](backends/cuda/cuda-overview.md) | Linux/Windows | GPU | NVIDIA GPU acceleration |
| [Core ML](backends/coreml/coreml-overview.md) | iOS, macOS | NPU/GPU/CPU | Apple devices, high performance |
| [MLX](/backends/mlx/mlx-overview.md) | macOS | GPU | Apple Silicon GPU (MLX) |
| [MLX](/backends/mlx/mlx-overview.md) | iOS, macOS | GPU | Apple Silicon GPU (MLX) |

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.

Did you add iOS support?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not new support added by this PR, but it is accurate: MLX is already built, packaged, and published for the iOS device slice. The Apple framework build runs the ios preset (PLATFORM=OS64, iOS 17), a per-platform Metal patch produces the iphoneos metallib, Package.swift ships mlx-ios.metallib, and apple.yml fails publication if that device slice is missing. The runtime has no iOS-device gate: is_available() just defers to mlx::core::metal::is_available(). The one place it genuinely broke was the simulator (no usable Metal device), which is what this PR fixes. The honest caveat is that on-device execution isn't covered by physical-device CI yet; happy to word it as experimental if you'd prefer. I've also removed iOS from the pip CMake-components table in using-executorch-cpp.md, since that table is host/wheel-scoped (Linux/macOS) like the Core ML row.

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.

Let's word as experimental. I've never run a model with it on iOS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, see #22203

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Marked iOS as experimental: the backends-overview table now reads "iOS (experimental), macOS", and the MLX overview page says the iOS device path is experimental and notes that on-device execution isn't covered by CI yet. (The page already flagged the whole MLX delegate as experimental at the top.) So the docs now claim only what's actually been exercised.

Loading a model that delegates to MLX crashes the process on an iOS simulator instead of failing
cleanly. The demo app in the examples repository crashes on the MLX menu entry, and the llama and
image classifier tests crash with it.

The simulator's Metal device is real enough to build and dispatch a compute pipeline, but two of
the things MLX needs unconditionally are missing from it: `architecture` reports nothing, and a
shared storage heap is refused by an assertion inside Metal. MLX reads the first without a null
check, so it faults during device construction, and if that is bypassed it aborts on the second.
Neither is reachable as an error, so nothing downstream can report it.

`is_available()` is the interface's own answer to whether a backend can run here, and the runtime
already asks before loading a delegate, so returning false is enough to turn the crash into
`Error::NotFound`. MLX's own `is_available()` returns a constant and checks nothing.

Test plan:

The macro decides everything here, so it is checked on all three targets it affects:

    simulator   TARGET_OS_SIMULATOR 1, is_available() false
    device      TARGET_OS_SIMULATOR 0, is_available() defers to MLX
    macOS       TARGET_OS_SIMULATOR 0, is_available() defers to MLX

so a real GPU is unaffected. Compiling the same function with the guard disabled returns true on
the simulator again, which is the crashing path, so the guard is doing the work.

Confirmed separately that a delegate whose backend reports unavailable makes `load_method` return
`Error::NotFound` rather than crash. The include is explicit because an undefined macro is zero in
`#if`, which would make the guard silently do nothing.

This does not make MLX work on the simulator. It makes the answer honest, so a caller gets an
error it can handle.
Review found one clause of the new comment is wrong about MLX. MLX does not require the shared
storage heap: its allocator skips it on a paravirtual device and every use tests for it first, so a
heap it never got would not sink it. The real sequence is that Metal traps inside the heap request,
so MLX never receives a value it could fall back from. Only the architecture read genuinely has no
null check. Left as it was, the next reader goes looking for a missing check in MLX and finds one
that is already there.

The comment also now says this is a build switch rather than a probe, and what would let it go.

Two documentation pages said the opposite of the code. The iOS page lists the prebuilt frameworks as
working on devices and simulators and listed the MLX backend with no exception, and the MLX overview
gave only Mac under target requirements without mentioning iOS at all. Both now say MLX runs on real
devices and Mac but not the simulator, so the first sign of this is not a load error.

Test plan:

Unchanged from the previous revision, since no behaviour changed here. The guard still compiles clean
with -Wall -Wextra for macOS, the iOS simulator and iOS device, and the macro still resolves to 1 only
for the simulator.
Copilot AI review requested due to automatic review settings September 1, 2026 17:35
@shoumikhin
shoumikhin force-pushed the mlx-simulator-unavailable branch from d7eb824 to db3986e Compare September 1, 2026 17:35

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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Nothing would have noticed if this guard were deleted. The only job that compiles this file for a
simulator compiles it with tests off, and it passes on the base commit too, so the guard could go and
every check would stay green.

The executor's stub backend already has a hook to install a custom availability answer, unused until
now. Installing false and loading a delegated method asserts `Error::NotFound` rather than the backend
being initialized anyway. That runs on every pull request, on Linux, with no Apple hardware, and it
fails if the check in `Method::load` goes.

On the documentation, my last change left the iOS page contradicting itself. It said the MLX framework
is for real devices and Mac only, while the same page maps a simulator platform name, force loads the
MLX library for that slice, and asks the reader to ship the simulator kernel file. The framework does
still build, ship and link there; only the runtime answer changed. So the list entry goes back to
plain, and the behaviour is a note beside the existing ones, or a reader starts pulling the simulator
slice out of their build.

Two more pages said macOS only, the backend table and the C++ component table, which are where most
people look first. Both now read iOS and macOS, matching the Core ML row above.

The iOS requirement also carries its version now. The build stops with a fatal error below iOS 17.0
and the Swift package declares iOS 17, so the number belongs on the line. The requirements list reads
as alternatives rather than a set that must all hold.

Test plan:

    unavailable backend  load_method returns Error::NotFound
    check in Method::load removed  the test fails

Guard behaviour unchanged from the previous revision: the macro is 1 only for the simulator, and the
same function with the guard disabled returns true there, which is the crashing path.
@shoumikhin
shoumikhin force-pushed the mlx-simulator-unavailable branch from db3986e to 9ce78e1 Compare September 1, 2026 17:45
Copilot AI review requested due to automatic review settings September 1, 2026 17:45

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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@shoumikhin
shoumikhin merged commit 44dfa52 into pytorch:main Sep 1, 2026
233 of 238 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. release notes: apple Changes to the Apple backend delegate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants