You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Headless/no-GPU environments crash on MLX import (NSRangeException) instead of failing gracefully #3148
In environments where no Metal device is visible (headless/sandboxed/virtualized/macOS automation sessions),
MLX initialization aborts the process with an uncaught Objective-C exception instead of returning
a recoverable Python error.
This makes downstream tooling fail hard, including quality/lint/test pipelines that do not need GPU execution.
Reproduction
Run in a session with no visible Metal device.
Execute:
python -c "import mlx.core as mx; print(mx.default_device())"
(Equivalent failure also occurs during import mlx via dependency probes.)
Actual behavior
Process exits with signal/abort (-6) and an uncaught exception similar to:
NSRangeException: -[__NSArray0 objectAtIndex:]: index 0 beyond bounds for empty array
Expected behavior
No hard abort.
Either:
raise a clear Python exception (e.g., RuntimeError: No Metal device available), or
allow a documented CPU/no-op mode for import-time checks.
Error path should be machine-detectable so CI tools can handle it gracefully.
Why this matters
Hard aborts break non-inference workflows (lint/type/test/packaging checks) when MLX is installed
but GPU is unavailable. A recoverable error would allow callers to skip MLX-dependent runtime tests
without crashing the whole process.
Suggested fix
Guard the zero-device path before indexing into device arrays. load_device() in mlx/backend/metal/device.cpp is the primary fix site (empty-device guard + graceful error path).
Convert this failure path to a typed Python exception instead of process termination.
Optionally provide an env flag to skip Metal probing at import time in CI/headless contexts.
The main code path is:
mlx/backend/metal/device.cppload_device()
It currently does devices->object(0) without checking if CopyAllDevices() returned an empty list, which matches the NSRangeException we're seeing.
mlx/device.cpp default_device_ is initialized at static init time via metal::is_available(), so any hard failure in Metal probing can crash import-time flows instead of surfacing a recoverable error.
We should guard the device loading and throw a more sensible error when no device is present. CC @JakeHillion
Regarding actually running MLX with Metal enabled on a Mac where the GPU is not accessible that is trickier.
The simplest thing is to build the CPU-only version and run that. Use Cmake arg MLX_BUILD_METAL=OFF. If building from source is not desirable we can investigate options to make the GPU availability check more dynamic
Tools like PyInstaller expect to be able to import all the Python when bundling. If you do this in a sandboxed environment it fails, and using the CPU version of MLX wouldn't solve that. It does seem the best solution here is to not poke at hardware at import time, and instead rely on initialising anything depending on the GPU at first use.
OK. Can we at least implement what @awni suggests (guard the device loading and throw a more sensible error when no device is present)? I suggest that rather than implementing some arbitrary default behaviour, an exception should be thrown and the calling code should decide whether to go with the cpu only or to abort or ...?
Activity
awni commentedon Feb 20, 2026
We should guard the device loading and throw a more sensible error when no device is present. CC @JakeHillion
Regarding actually running MLX with Metal enabled on a Mac where the GPU is not accessible that is trickier.
The simplest thing is to build the CPU-only version and run that. Use Cmake arg
MLX_BUILD_METAL=OFF. If building from source is not desirable we can investigate options to make the GPU availability check more dynamicJakeHillion commentedon Feb 20, 2026
Tools like PyInstaller expect to be able to import all the Python when bundling. If you do this in a sandboxed environment it fails, and using the CPU version of MLX wouldn't solve that. It does seem the best solution here is to not poke at hardware at import time, and instead rely on initialising anything depending on the GPU at first use.
jrp2014 commentedon Feb 23, 2026
OK. Can we at least implement what @awni suggests (guard the device loading and throw a more sensible error when no device is present)? I suggest that rather than implementing some arbitrary default behaviour, an exception should be thrown and the calling code should decide whether to go with the cpu only or to abort or ...?
dm3rdnv commentedon Apr 19, 2026
Handle headless Metal device init gracefully
Handle headless Metal device init gracefully
Handle headless Metal device init gracefully
Handle headless Metal device init gracefully
Handle headless Metal device init gracefully
Handle headless Metal device init gracefully
ci: run the CPU gate on Linux with mlx-cpu
ci: gate PRs on the CPU test suite
test: skip MLX tests when no Metal device is available (CI)
test: skip MLX tests when no Metal device is available (CI)
Add examples, benchmarks, and an MLX-free path for the protocol layer