mlx-metal-gpu-overlay¶
Overlays
Build MLX with Metal GPU support under
Nix on Apple Silicon (aarch64-darwin).
Problem¶
nixpkgs ships MLX CPU-only. Enabling Metal is not just a build flag: MLX
compiles its Metal shaders by shelling out to xcrun -sdk macosx metal /
metallib. xcrun is a thin locator that resolves the active developer
toolchain — and it is invisible inside the Nix build sandbox. So a naive
"flip MLX_BUILD_METAL to ON" build fails the moment CMake tries to invoke the
shader compiler.
Key insight / traps¶
-
xcrundoesn't work in the sandbox — call the compiler directly. The realmetal/metallibbinaries live inside Apple's Metal toolchain cryptex, mounted at/var/run/com.apple.security.cryptexd/mnt/under a directory whose name carries a version suffix (com.apple.MobileAsset.MetalToolchain-<version>). That suffix changes with OS/Xcode updates, so the overlay discovers the path at eval time (builtins.readDir) instead of hardcoding it, and rewrites MLX's build scripts to call those binaries directly, passing-isysroot <MacOSX.sdk>. -
__noChroot = trueis mandatory. The Metal compiler needs the real toolchain and SDK present on disk at build time; they cannot be brought into a sealed sandbox. This requires the daemon to allow it —sandbox = relaxed(orfalse) innix.conf. With full sandboxing enforced the flag is ignored and the build fails. -
Graceful CPU fallback. If the toolchain cryptex is not mounted,
metalToolchainDirevaluates tonull, the Metal patches are skipped, and MLX builds CPU-only. No hard failure. -
Several
xcrunshellouts, not one. The version probe inCMakeLists.txtis made non-fatal (it otherwise aborts configure), a deployment-target guard is removed, and bothkernels/CMakeLists.txtandmake_compiled_preamble.shhave theirxcrun ... metalinvocations rewritten. Missing any one of them reintroduces the failure. -
env -u MACOSX_DEPLOYMENT_TARGET. A strayMACOSX_DEPLOYMENT_TARGETfrom the build environment breaks the directmetalinvocation, so it is unset just for that command. -
The CPU JIT hardcodes
g++. Separately from the Metal path,mlx/backend/cpu/jit_compiler.cppshells out to a bareg++, which isn't on the sandboxPATH. It is rewritten to the Nix stdenvc++, so the CPU path builds hermetically too — this patch applies whether or not the Metal toolchain was found. -
Pre-fetched FetchContent sources. MLX's CMake uses
FetchContentto pullmetal-cppandnanobindfrom the network — which the sandbox blocks. Both are pre-fetched into the store and passed viaFETCHCONTENT_SOURCE_DIR_*.
Usage¶
Add the overlay to an aarch64-darwin configuration and consume
python3Packages.mlx:
Then, e.g. python3.withPackages (ps: [ ps.mlx ]).
Requirements¶
- Apple Silicon running macOS with Xcode installed (provides the MacOSX
SDK at the path in
sdk). - The Metal toolchain component present (mounts the cryptex the overlay discovers). Without it you get a CPU-only build.
- Relaxed sandbox so
__noChrootis honored:
Pinning¶
The MLX version + src hash, and the metal-cpp / nanobind revisions and
hashes, are pinned inline in default.nix. Bump them to the MLX release you
want; update the hash values to match.
Caveats¶
- The
sdkpath and the cryptex mount path are macOS/Xcode conventions; if Apple relocates them in a future release these constants need updating. __noChrootmakes the build impure — its output depends on the toolchain present on the build host. That is inherent to how Apple ships the Metal compiler; there is no fully-hermetic path today.- The CMake
--replace-fail/sedanchors are tied to MLX's source layout at the pinned version. A major MLX refactor may move them.
Source¶
overlays/mlx-metal-gpu-overlay/default.nix
# mlx-metal-gpu-overlay
#
# Build MLX (ml-explore/mlx) with Metal GPU support under Nix on Apple Silicon.
#
# The trap: nixpkgs ships MLX CPU-only because the Metal shader compiler
# (`metal` / `metallib`) is normally invoked through `xcrun`, and `xcrun` is
# invisible inside the Nix build sandbox. This overlay discovers the real
# MetalToolchain cryptex mount at *eval time*, rewrites MLX's build scripts to
# call `metal`/`metallib` directly against that toolchain plus the Xcode SDK,
# and sets `__noChroot = true` because the Metal compiler needs the real
# toolchain and SDK on disk. If the toolchain mount is absent the overlay
# quietly falls back to a plain CPU-only build.
#
# Usage: add to `nixpkgs.overlays` on an aarch64-darwin config, then use
# `python3Packages.mlx`. Requires:
# - Xcode installed (for the MacOSX SDK), and
# - the Metal toolchain present (Xcode component; the cryptex mount below).
# - the sandbox relaxation `__noChroot` honored — set in nix.conf:
# sandbox = relaxed # or false
# Nothing here depends on any particular host, user, or network.
#
# Pin the versions/hashes below to whatever MLX release you want.
final: prev:
let
# Apple's metal-cpp headers. FetchContent inside MLX's CMake wants to grab
# these from the network at build time; we pre-fetch and point at the store.
metal-cpp = prev.fetchzip {
url = "https://developer.apple.com/metal/cpp/files/metal-cpp_26.zip";
hash = "sha256-7n2eI2lw/S+Us6l7YPAATKwcIbRRpaQ8VmES7S8ZjY8=";
};
# nanobind (Python bindings). Same story: pre-fetch so CMake FetchContent
# resolves it from the store instead of hitting the network in-sandbox.
nanobind-src = prev.fetchFromGitHub {
owner = "wjakob";
repo = "nanobind";
rev = "v2.12.0";
hash = "sha256-s9TshE3V50BtrnVv56j4BxZOloNsOVgi0PUT6xyF7yY=";
fetchSubmodules = true;
};
# The Metal toolchain ships as a cryptex ("MobileAsset") that macOS mounts
# under this directory. Its exact subdirectory name carries a version suffix
# that changes across OS/Xcode updates, so we discover it at eval time rather
# than hardcoding it. readDir here is an impure-ish eval read of the live
# filesystem — that is deliberate; it is how we find the toolchain path.
cryptexdMnt = "/var/run/com.apple.security.cryptexd/mnt";
metalToolchainDir =
let
entries = builtins.attrNames (builtins.readDir cryptexdMnt);
toolchains = builtins.filter (
e: prev.lib.hasPrefix "com.apple.MobileAsset.MetalToolchain-" e
) entries;
in
if toolchains != [ ] then
"${cryptexdMnt}/${builtins.head toolchains}/Metal.xctoolchain/usr/bin"
else
# Toolchain not mounted → metalToolchainDir stays null and the patches
# below are skipped, leaving MLX to build CPU-only.
null;
# The Metal compiler needs an -isysroot pointing at the real MacOSX SDK.
sdk = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk";
in
{
python3Packages = prev.python3Packages.overrideScope (
pfinal: pprev: {
mlx =
let
mlxWithMetal = pprev.mlx.overridePythonAttrs (old: {
version = "0.31.1";
src = prev.fetchFromGitHub {
owner = "ml-explore";
repo = "mlx";
tag = "v0.31.1";
hash = "sha256-PiNk/MdMw9Vpat2KuslBTyaFuK+mJ4UvwJqBnysvvUU=";
};
build-system = [
pprev.cmake
pprev.setuptools
pprev.typing-extensions
];
# Flip the nixpkgs default (Metal OFF) to ON, enable the Metal JIT,
# and hand CMake FetchContent our pre-fetched sources so it does no
# network access during the build.
env = old.env // {
CMAKE_ARGS =
builtins.replaceStrings
[ "-DMLX_BUILD_METAL:BOOL=FALSE" ]
[
"-DMLX_BUILD_METAL:BOOL=TRUE -DMLX_METAL_JIT:BOOL=TRUE -DFETCHCONTENT_SOURCE_DIR_METAL_CPP:FILEPATH=${metal-cpp} -DFETCHCONTENT_SOURCE_DIR_NANOBIND:FILEPATH=${nanobind-src}"
]
old.env.CMAKE_ARGS;
};
postPatch =
# (1) CPU JIT compiler hardcodes "g++"; point it at the Nix stdenv
# C++ compiler so the CPU path also builds hermetically.
''
substituteInPlace mlx/backend/cpu/jit_compiler.cpp \
--replace-fail "g++" "${prev.lib.getExe' prev.stdenv.cc "c++"}"
''
# (2) MLX derives its Metal version by shelling out to xcrun; that
# fails in-sandbox and is FATAL. Make it non-fatal and default
# to a sane version so configuration proceeds.
+ ''
substituteInPlace CMakeLists.txt \
--replace-fail \
'OUTPUT_VARIABLE MLX_METAL_VERSION COMMAND_ERROR_IS_FATAL ANY)' \
'OUTPUT_VARIABLE MLX_METAL_VERSION ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT MLX_METAL_VERSION)
set(MLX_METAL_VERSION 400)
endif()'
''
# (3) Drop the deployment-target guard that aborts when
# CMAKE_OSX_DEPLOYMENT_TARGET is unset in the sandbox.
+ ''
sed -i '/if(NOT CMAKE_OSX_DEPLOYMENT_TARGET/,/endif()/d' \
mlx/backend/metal/kernels/CMakeLists.txt
''
# (4) The core fix: replace `xcrun -sdk macosx metal[lib]` shellouts
# with direct calls to the discovered toolchain binaries against
# the Xcode SDK. `env -u MACOSX_DEPLOYMENT_TARGET` avoids a stray
# deployment-target from the sandbox env breaking the compiler.
# Only applied when the toolchain was found.
+ prev.lib.optionalString (metalToolchainDir != null) ''
metal=${metalToolchainDir}/metal
metallib=${metalToolchainDir}/metallib
sdk=${sdk}
sed -i "s|xcrun -sdk macosx metal |env -u MACOSX_DEPLOYMENT_TARGET $metal -isysroot $sdk |g" \
mlx/backend/metal/kernels/CMakeLists.txt
sed -i "s|xcrun -sdk macosx metallib |$metallib |g" \
mlx/backend/metal/kernels/CMakeLists.txt
echo "Patched metal shader compiler: $metal"
''
# (5) Same substitution for the preamble generator script, which
# has its own hardcoded `xcrun ... metal` invocation.
+ prev.lib.optionalString (metalToolchainDir != null) ''
metal=${metalToolchainDir}/metal
sdk=${sdk}
sed -i "s|CCC=\"xcrun -sdk macosx metal -x metal\"|CCC=\"env -u MACOSX_DEPLOYMENT_TARGET $metal -isysroot $sdk -x metal\"|g" \
mlx/backend/metal/make_compiled_preamble.sh
echo "Patched make_compiled_preamble.sh: CCC uses direct metal path"
'';
doCheck = false;
});
in
# __noChroot lets the build reach the real Metal toolchain + SDK on disk.
# Requires `sandbox = relaxed` (or false) in the daemon's nix.conf; the
# daemon ignores __noChroot when full sandboxing is enforced.
mlxWithMetal.overrideAttrs (_: {
__noChroot = true;
});
}
);
}