rMLX ↔ mlx-c FFI bridge: how Rust talks to MLX without any Python at runtime.
mlx-c is Apple's stable C ABI layer over the MLX C++ library. It exposes
a pure-C interface (mlx_array, mlx_closure, mlx_fast_metal_kernel, …)
that avoids the fragile ABI of C++, making it safe to link from Rust via
bindgen-generated bindings.
The rmlx-mlx crate is the only crate in the workspace that touches mlx-c
directly. All other crates (rmlx-models, rmlx-quant, rmlx-loader,
rmlx-server, rmlx-cli) call through the public API of rmlx-mlx and
never see an unsafe block related to mlx-c. rmlx-kv-quant (KV-cache
codecs) and rmlx-models (per-arch kernels) ship MSL source that is
registered and dispatched through rmlx-mlx's metal_kernel module.
The crate does not depend on mlx-rs (the community Rust binding). It
drives mlx-c directly to control every detail of the FFI contract.
mlx-c pins a specific MLX version. The build links libmlxc.dylib and
libmlx.dylib, resolving each prefix in this order:
MLX_C_PREFIX/MLX_PREFIX— explicit override.brew --prefix mlx-c/brew --prefix mlx./opt/homebrew/opt/mlx-c//opt/homebrew/opt/mlx— the conventional Homebrewoptsymlink.
build.rs asserts the dylibs exist and aborts the build with an actionable
message if they are absent.
Resolving to the opt path rather than a Cellar path is deliberate. Both
dylibs' install names are opt paths:
$ otool -D /opt/homebrew/opt/mlx/lib/libmlx.dylib
/opt/homebrew/opt/mlx/lib/libmlx.dylib
so that symlink decides what gets loaded at run time regardless of what the build pointed at. An upgrade repoints it and silently retargets an already-built binary — no rebuild, no relink, no diagnostic. Building against the same path the loader uses is what keeps compile-time and run-time on the same file; a hard-coded Cellar path drifts from it on the next upgrade.
rMLX declares the MLX stack it is validated against in one place:
crates/rmlx-mlx/mlx-pin.txt
mlx 0.31.2
mlx-c 0.6.0_2
Nothing else in the tree declares these versions — build.rs reads that file,
and bumping a line there is the whole change.
They bump together. mlx-c is compiled against a specific mlx, and both
resolve the moving opt symlink at run time, so a mismatched pair aborts at
load:
dyld: Symbol not found: __ZN3mlx4core4fast12metal_kernelE...
Referenced from: .../mlx-c/0.6.0_3/lib/libmlxc.dylib
Expected in: .../mlx/0.31.2/lib/libmlx.dylib
(mlx-c 0.6.0_3 is built against mlx 0.32.0. Same upstream 0.6.0 as _2 — the
Homebrew revision suffix is the only thing that distinguishes them, which is
why the pin carries it.)
Homebrew's mlx 0.32.0 bottle ships zero steel_gemm_fused_nax_* kernels
— the M5 Neural-Accelerator GEMM path. The pinned 0.31.2 bottle ships 145 of
them:
strings "$(brew --prefix mlx)/lib/mlx.metallib" | grep -c steel_gemm_fused_nax
# 0.31.2 -> 288 0.32.0 -> 0(grep -c counts matching lines, not kernels: 145 distinct kernel functions
appear on 288 lines. Only the zero is load-bearing — the build's probe is
boolean and never counts.)
Measured cost: ~3.8× lower GPU matmul throughput, 2.2–3.7× slower prefill on Neural-Accelerator-class hardware. Decode is bandwidth-bound and barely moves (gemma-4-e2b @4k, median of 3, same binary: prefill 6,916 → 15,352 t/s, decode 124.5 → 119.9), so the symptom looks like a model-code defect rather than a toolchain one — it cost one investigation days of misattribution before the metallib was inspected.
This is a Homebrew bottle regression, not an MLX regression: the upstream 0.32.0 PyPI wheel ships the kernels and is fine. Tracked in #216.
The pin buys the NAX GEMM path (steel_gemm_fused_nax_*), which every
model's matmuls go through. NAX attention is a far narrower thing, and the
two are easy to conflate:
| Path | NAX? | Why |
|---|---|---|
Prefill attention, head_dim 64 or 128, Q not f32 |
yes, steel_attention_<dtype>_bq64_… |
MLX's sdpa_full takes the NAX branch unless head_dim == 80 or Q is f32 without TF32 |
Prefill attention, head_dim 256 or 512 |
no | MLX has no fused prefill kernel at either width, NAX or otherwise. See Head-dim dispatch — that gap is about a missing kernel shape, not about NAX |
Decode attention, any head_dim, any codec |
no | bq64 is a query tile of 64, and decode is q_seq = 1. At head_dim ≤ 256 MLX routes q_seq <= 8 to sdpa_vector, which has no NAX variant; at 512 there is no vector kernel either and decode falls to the composite path. Neither reaches NAX |
Our own .metal kernels |
no | all of them are q_seq = 1 decode kernels. NAX's one matmul tile shape has an M floor of 16; at q_seq = 1 the only M available is heads_per_kv (4–8 on our models), so the tile can never be more than half filled |
So the bf16 mirror's decode advantage over a quantized codec is bandwidth and kernel quality, not NAX — no decode path on any codec reaches it, and a hand-written decode kernel could not either. Only prefill is in play, and only for a 64- or 128-wide head.
Measured on this host (M5 Max, mlx 0.31.2), from the per-pipeline binary
archives inside a GPU capture — the whole mlx.metallib is also embedded in a
bundle, so only the small per-pipeline archives are evidence of a created
pipeline:
- Ternary-Bonsai-8B (every layer
head_dim128) creates threesteel_attention_bfloat16_bq64_bk32_bd128_wm4_wn1_mask*pipelines — the causal, aligned-masked and unaligned-masked prefill permutations. NAX is engaged and Q is already bf16, so there is no f32 gate to fix. - gemma-4-e2b creates none, on the same tooling and prompt size. Its sliding
layers are 256-wide and its full-attention layers 512-wide, and MLX ships no
fused attention kernel at either. It does create
steel_gemm_fused_nax_*, i.e. NAX GEMM is live for a model whose attention can never use NAX.
Which widths reach which kernel, and what the fallback costs, is Head-dim dispatch.
build.rs warns — never fails — on two independent things:
| Check | Fires when | Meaning |
|---|---|---|
| Capability | the resolved mlx.metallib contains no steel_gemm_fused_nax |
the real defect; names both versions and the fix command |
| Pin drift | resolved mlx/mlx-c ≠ the pinned pair, but the kernels are present | informational; the pair is unvalidated, and bumping the pin may be due |
Warning rather than failing is deliberate: the pin records what was validated here, not a claim that everything else is broken. A correct non-bottle build of another version must still compile.
The same metallib scan also stamps cargo:rustc-env=RMLX_MLX_NAX=<present|absent|unknown>
(exposed as rmlx_mlx::NAX_CAPABILITY) — not a second detection path, the same
fast_gemm result the two warnings above already computed. rmlx-cli::main()
forwards it into rmlx-metrics's run identity
(rmlx_metrics::identity::set_mlx_nax), so every events row records whether
that run built against a nax-capable MLX. See docs/METRICS_DB.md §3.6 for
the column and why the propagation goes through a runtime setter rather than
a second env!() read (cargo:rustc-env only reaches the compiler
invocation of the crate whose build script set it).
The capability probe is the ground truth; the version pin only proxies for it. The probe is what keeps this from nagging forever once a fixed bottle ships — it simply passes. Neither check can be verified from a version number alone, which is why both exist.
Version identity comes from different places per formula, of necessity: mlx
ships include/mlx/version.h (authoritative, and works on non-Homebrew
layouts), while mlx-c ships no version header at all — its identity is the keg
directory name, which is also the only place the load-bearing revision suffix
appears. A non-keg layout (a wheel, a hand-built tree) yields no version and
the pin stays quiet rather than guessing.
Both kegs must already be in the Cellar (ls /opt/homebrew/Cellar/mlx):
ln -sfn ../Cellar/mlx/0.31.2 /opt/homebrew/opt/mlx && \
ln -sfn ../Cellar/mlx-c/0.6.0_2 /opt/homebrew/opt/mlx-c && \
brew pin mlx mlx-c && \
cargo clean -p rmlx-mlx # required — see belowbrew pin stops a later brew upgrade from repointing the symlinks back.
The cargo clean is required, not hygiene. Cargo re-runs a build script
only when a rerun-if-changed path is newer than the last run, and it stats
through the symlink. Repointing opt/mlx at the older validated keg moves
the observed mtime backwards (0.31.2's metallib predates 0.32.0's), so a
plain rebuild does not re-run build.rs at all: the crate keeps bindings
generated against the wrong headers and a stale baked-in
RMLX_MLX_BUILD_VERSION, while the loader picks up the newly-linked pair. That
combination is the ABI abort this section exists to avoid. Touching
crates/rmlx-mlx/mlx-pin.txt forces the same re-run if you prefer.
brew unpin mlx mlx-c && brew upgrade mlx mlx-c- Verify the capability actually returned — this is the whole point:
Assert non-zero, not a particular count: the number is a
strings "$(brew --prefix mlx)/lib/mlx.metallib" | grep -c steel_gemm_fused_nax # must be non-zero (288 on the 0.31.2 bottle); zero means the bottle is still broken
stringsline count that tracks kernel-name spelling, and the build's probe only asks present/absent. - Bump both lines in
crates/rmlx-mlx/mlx-pin.txtto the new pair (brew list --versions mlx mlx-cgives the keg names, revision suffix included). Editing the pin file is itself a rebuild trigger, which is what makes step 4 mean something. - Rebuild and confirm the build emits no MLX warning — but only after
cargo clean -p rmlx-mlx. Upgrading normally moves mtimes forward, so the re-run usually happens on its own; a downgrade does not, and then "no warning" would merely mean the check never ran. Force it and the step is real. Confirmbuild.rsactually ran withcargo build -p rmlx-mlx -vv(its output appears only on a real run). - Re-verify on a real prefill cell, not just the kernel count:
Expect ~15k t/s prefill. A regression to ~7k means the kernels are present but unused — reopen #216 rather than re-pinning silently.
rmlx baseline --model <gemma-4-e2b> --kv-quant none --max-ctx 8192 --prompt-tokens 4096
Note that fixture lengths are nominal: --prompt-tokens 4096 tokenizes to
~4410 on Gemma, so pass --max-ctx deliberately or the prompt is rejected and
the cell reports prefill_tps=0.
The build-time pin cannot see a symlink that moves after the build. The FFI
layer therefore also compares the version it was compiled against
(RMLX_MLX_BUILD_VERSION, baked by build.rs from the resolved prefix's
version.h) against mlx_version() of the library actually loaded, on the
existing one-shot init, and warns on mismatch. Together the two checks cover
both halves: the pin catches a bad stack at compile time, the skew warning
catches a stack that changed underneath a built binary.
A version match is not a capability match, and neither survives distribution.
RMLX_MLX_NAX describes the machine that built the binary; a Homebrew bottle
or release tarball links libmlx.dylib through the moving opt symlink, so it
runs against the installing user's MLX, which may be a different bottle of the
same version. src/nax.rs therefore repeats the metallib scan at run time, on
the same one-shot init as the skew warning, against the library dyld actually
loaded (found by walking dyld's image list for libmlx.dylib, then reading
its colocated mlx.metallib).
Host-class gated, and that gate is the point. The kernels only exist for
the GPU Neural Accelerator, which arrives with M5 — Apple GPU family 10 in
rmlx_core::apple_gpu. Every earlier generation legitimately ships zero of
them at every MLX version, so a warning there would be noise on the majority
of Macs and would train people to ignore the one host where the absence costs
something. The gate runs ahead of every other step, and when it says "no Neural
Accelerator" neither the dyld image walk nor the metallib open happens.
| Host | Kernels | Result |
|---|---|---|
| M5+ | absent (confirmed) | warn! — names prefill/TTFT, the metallib, and the check command |
| M5+ | present | debug! only |
| M5+ | metallib unreadable / not found | debug! only — "could not look" is not "absent" |
| M1–M4, or chip unidentifiable | either | debug! only; no scan at all |
Measured cost on the release binary (M5 Max, warm page cache, best of 5):
~0.7 ms when the kernels are present — the first match lands a couple of MB
into the 158 MB metallib and ends the scan — and ~49 ms for a full pass over
a metallib of the same size with none. On a pre-M5 host it is ~1.3 µs, which
is the sysctl chip query and nothing else: the dyld image walk (~220 ns) and
the file open are both behind the gate. It runs once per process, on the init
that already precedes a multi-second model load.
The wording is scoped to prefill / TTFT deliberately: NAX is unreachable at decode by construction (see "Where NAX can appear, and where it cannot"), so a warning implying a general slowdown would be wrong.
-
Target guard:
aarch64-apple-darwinonly. The assertion fires at compile time for any other target. -
bindgenruns againstwrapper.h, producing$OUT_DIR/bindings.rs. Onlymlx_*symbols are allowlisted;mlx_dtype_andmlx_device_type_are emitted as Rust enums. -
build.rspost-processesbindings.rsto strip any#![...]inner attributes bindgen 0.71 emits at the file head — these are illegal inside theinclude!()call site insys.rs. -
The resolved MLX / mlx-c pair is checked against
mlx-pin.txt, and the resolvedmlx.metallibis scanned for the fast GEMM kernels. Both warn, neither fails — see "Pinned MLX / mlx-c pair" above. -
Rebuild is triggered on
MLX_C_PREFIX,MLX_PREFIX,wrapper.h,build_support.rs,mlx-pin.txt, or a newer resolvedversion.h/mlx.metallib(each registered only when it exists — cargo treats a missing trigger path as permanently dirty, which would re-run bindgen on every build of a non-keg layout).Repointing the
optsymlink does not reliably re-run these checks. Cargo compares mtimes through the symlink and re-runs only for a newer one, so moving to an older keg — the recovery direction — looks like nothing changed. Usecargo clean -p rmlx-mlxwhen repointing; the runtime version-skew warning below is the backstop for a binary that was never rebuilt. -
rpath entries are emitted so the binary finds both dylibs at runtime without
DYLD_LIBRARY_PATH.
build.rs's pure logic (pin parsing, keg-version and header-version
resolution, the metallib scan) lives in build_support.rs, include!d by both
the build script and tests/mlx_pin.rs. A build script cannot be imported by
the crate it builds, and this logic decides whether a known 3.8× perf cliff is
reported at all — so it is covered by tests cargo test actually runs.
sys.rs wraps the generated bindings.rs inside pub(crate) mod ffi { … }
with blanket lint suppression, then re-exports everything via
pub(crate) use ffi::*. The suppressed lints cover naming convention
violations and dead-code warnings that are inherent in generated C bindings.
No public symbol from sys.rs escapes the crate.
mlx-c delivers runtime errors via a registered C callback rather than return
values alone. lib.rs installs a thread-local handler exactly once per
process via std::sync::Once:
// SAFETY: mlx_set_error_handler is thread-safe. The callback receives
// a valid NUL-terminated *const c_char for the duration of the call.
unsafe extern "C" fn handler(msg: *const c_char, _data: *mut c_void) { … }The handler writes the message into LAST_ERROR: thread_local! { Cell<Option<String>> }.
Cell is used instead of RefCell because the only access pattern is
take() and set(), which need no shared borrows and avoid the runtime
borrow-count overhead of RefCell.
After every mlx-c call, check_status(status, context) retrieves the stored
message and returns Err(Error::Mlx(...)) on any non-zero status code.
check_status must be called immediately on the same thread, before any
other mlx-c call could overwrite the slot.
Every op that requires a stream calls with_stream(device, |s| …), which
borrows the process-global default stream and releases the handle after the
closure returns.
An earlier implementation called mlx_stream_new_device per op, which
spawns a new OS thread inside MLX on each call. A single Gemma4 forward
pass dispatches hundreds of ops per decode step; after 3–6 steps the macOS
thread limit (~2 048) was exhausted and pthread_create returned EAGAIN.
The fix uses mlx_default_gpu_stream_new / mlx_default_cpu_stream_new,
which return a reference-counted handle to the already-running default
stream. Freeing the handle decrements the ref-count; the stream and its
thread are never torn down.
with_stream borrows the process-global default stream, but MLX's
eval materialises arrays through a thread-local map of
{stream_index → CommandEncoder}. The encoder entry for a given stream is
created when mlx::core::new_stream runs on that thread. tokio
blocking-pool worker threads never call new_stream, so an Array::eval()
on such a thread can fail with There is no Stream(gpu, 0) in current thread.
rmlx_mlx::ensure_gpu_default_stream() fixes this: it creates a GPU stream
(registering a fresh CommandEncoder for the calling thread), sets it as the
thread's default, and stores the handle in a thread-local for the thread's
lifetime. It is idempotent and a no-op when the GPU is unavailable, with zero
ML-semantic effect.
A worker thread whose graph includes a CPU-scheduled op — e.g. the K8V8
exit_prefill quantization's scale reduction, which MLX places on the CPU
stream even though the surrounding quantize dispatch runs on the GPU device —
needs a CPU stream of its own, the same way the GPU path did.
How MLX resolves the CPU encoder is version-specific, and the two versions behave oppositely — do not reason from the wrong one:
| 0.31.x (the pinned version) | 0.32.0 | |
|---|---|---|
cpu::get_command_encoder map |
one process-global unordered_map<int, CommandEncoder> |
thread_local, with a process-global fallback |
| Populated | lazily, on first evaluation, with no synchronisation | at stream registration |
| Unregistered stream | silently inserted | throws There is no Stream(cpu, N) in current thread. |
| Cross-thread eval | succeeds | throws |
Default CPU streams are per-thread either way (mlx/stream.cpp:
static thread_local ... default_streams), so on the pinned version every
thread that evaluates mints its own stream index and inserts into that one
shared map. That unsynchronised insert is a genuine upstream defect — the
neighbouring Scheduler::threads_ map in mlx/scheduler.h is mutex-guarded —
and it is what EVAL_LOCK (below) contains. MLX 0.32.0 fixes it by making the
map thread-local; we do not pin that version because its bottle ships no NAX
GEMM kernels.
rmlx_mlx::ensure_cpu_default_stream() is the CPU analog of
ensure_gpu_default_stream(): same mechanism (create a stream, register it as
the calling thread's default, leak the handle in a thread-local for the
thread's lifetime), same idempotency guarantee, zero ML-semantic effect. On
0.31.x it is not load-bearing for a thread that builds and evaluates its own
graph — MLX self-registers there — but it pins the thread's stream identity
explicitly and is what keeps the code correct if the pin moves to 0.32.0.
Contract: every blocking-thread inference entry point calls
ensure_cpu_default_stream() unconditionally (not gated on the resolved
device — a GPU-device forward can still schedule CPU-side ops), before
ensure_gpu_default_stream() when both apply. Covered entries: the text
generate dispatch (arch::generate_greedy), the image generate dispatch
(arch::generate_image and the server's run_qwen3vl_image), the
speculative-decode blocking closure, the
audio-transcription blocking closure (audio.rs Whisper decode, and the CLI
transcribe command), and the embeddings compute closure (embeddings.rs
compute_embeddings). New blocking-pool entry points that materialise CPU or
GPU arrays must follow the same pattern (both guards, CPU first).
Bounded leak (serve only). Both guards deliberately leak their stream
handle on thread exit — freeing it would drop the CommandEncoder entry a
still-running eval might reference. Each leaked handle is also backed by its
own MLX-internal OS thread, so the leak is only safe if the set of distinct
worker threads that ever call these guards is bounded. rmlx serve
(crates/rmlx-cli/src/commands/serve.rs) builds its tokio runtime with a
capped max_blocking_threads and a long thread_keep_alive, so the blocking
pool's worker threads are reused rather than idle-reaped-and-replaced under
sporadic load — bounding the cumulative leak to that cap instead of growing
unbounded over long serve uptime (which would otherwise eventually exhaust the
~2 048 per-process pthread ceiling noted above). One-shot CLI commands
(chat, baseline, info) are unaffected — the leak is bounded by the
process lifetime regardless.
See docs/KV_CACHE.md §5.7.5 and docs/KV_QUANT.md for the exit_prefill
mechanism this guards. Note the guard's scope: it registers the worker's own
stream. On the pinned 0.31.x that is all anyone needs — a cross-thread eval
resolves through the process-global map and succeeds
(cross_thread_eval_resolves_through_the_process_global_encoder_map pins this).
On 0.32.0 a cross-thread eval throws and the guard would not help, because it
registers a different stream than the one the foreign array is bound to.
Several mlx-c functions accept optional mlx_array arguments. When the
argument is absent the correct idiom is a default-constructed handle with
ctx = null. The naive approach allocates a fresh handle per call via
mlx_array_new() and frees it after — one heap allocation and one atomic
ref-count free per op per layer per decode step.
rmlx-mlx keeps one process-global null sentinel in
EMPTY_ARRAY_SENTINEL: OnceLock<Array>. The raw inner handle is retrieved
via null_sentinel() and passed directly to the C function. The sentinel is
never freed and must never be passed to any function that will free it or
materialize it as a real array. This pattern is used for:
weightinrms_normwhenNone(Gemma4v_normlayers).freqsinrope/rope_dynamicwhen base-only computation is wanted.biasesinquantized_matmul,dequantize,gather_qmmwhenNone(mxfp8/mxfp4 modes do not use bias).lhs_indicesingather_qmmwhenNone.maskandsinksinscaled_dot_product_attentionwhenNone.global_scaleindequantizeandquantize(unused for affine/mxfp8).
mlx-c functions that accept a mode string ("affine", "mxfp8",
"causal", etc.) receive a *const c_char. Converting a &str to
CString per call allocates on the heap. The set of legal mode strings is
fixed, so each is cached in a OnceLock<CString> and returned as a
Cow<'static, CStr>. Unknown strings fall through to a dynamic
CString::new for forward-compatibility. Callers use .as_ptr() on the
result.
Array is a newtype around sys::mlx_array, which wraps a C++
std::shared_ptr<mlx::core::array> under the hood. mlx-c reference-counts
arrays; every call to mlx_array_new, mlx_array_new_data,
mlx_array_new_float, or mlx_vector_array_get increments the count.
pub struct Array {
inner: sys::mlx_array,
}Array implements Send + Sync:
// SAFETY: mlx_array wraps a std::shared_ptr<mlx::core::array>. The
// mlx-c docs state that arrays are reference-counted and thread-safe
// to pass (same semantics as shared_ptr).
unsafe impl Send for Array {}
unsafe impl Sync for Array {}Drop calls mlx_array_free, which decrements the ref-count. The handle
is never aliased inside Rust — each Array value owns exactly one handle.
A trace! event that was previously fired on drop was removed: it fired
millions of times per decode session and was the dominant overhead in
RUST_LOG=trace mode, reducing decode throughput from ~35 TPS to ~17 TPS.
| Method | mlx-c call | Ownership |
|---|---|---|
Array::from_bytes(data, shape, dtype) |
mlx_array_new_data |
MLX copies the buffer; data need not outlive the call. |
Array::from_safetensor_view(view) |
mlx_array_new_data |
Same: copy-on-construct from the mmap view. |
scalar_f32(v) |
mlx_array_new_float |
MLX owns the scalar. |
Array::try_clone(&self) |
mlx_array_set |
Increments ref-count; cheap — no data duplication. |
Array::from_bytes validates that data.len() == product(shape) * dtype.itemsize()
before calling into C:
// SAFETY: mlx_array_new_data copies the buffer immediately. The
// returned handle owns its data; `data` need not outlive this call.
let inner = unsafe {
sys::mlx_array_new_data(
data.as_ptr().cast(),
shape.as_ptr(),
shape.len() as i32,
dtype.to_sys(),
)
};MLX is lazy: ops build a compute graph but do not execute immediately. Two methods trigger execution:
Array::eval()— synchronous; wrapsmlx_array_evaland blocks until the result is materialized on the GPU.Array::async_eval()— schedules the array's compute graph on the GPU stream without blocking the caller. Implemented by wrapping the array in anmlx_vector_arrayand callingmlx_async_eval. A subsequentto_bytes()oreval()call will wait if the GPU has not finished. Used to pipeline the next decode step's forward pass on the GPU while the current step's argmax is still being read back to the CPU, mirroring mlx-lm'smx.async_evalpattern ingenerate.py.
Both are serialised process-wide by EVAL_LOCK (crates/rmlx-mlx/src/lib.rs),
via with_eval_lock, which holds the lock across the FFI call and nothing else.
So is Closure::apply — see the reach-set table below for why a closure
application evaluates.
MLX evaluation is not safe to drive from two threads at once on the pinned
0.31.x: the CPU command-encoder table described above is a process-global map
filled without synchronisation, so two concurrent evaluations rehash it under
each other. The process then dies inside MLX with no Rust frame at fault and
nothing to catch — observed as SIGSEGV, as SIGTRAP, and as an infinite spin on
a bucket chain that became circular. libtest names no failing test, because
none failed. cargo test runs one OS thread per test, which is how this
reached make ci as an intermittent crash of a whole test binary.
with_eval_lock takes a closure rather than returning a guard on purpose: with
a guard-returning helper, let _ = acquire(); would drop the guard before the
FFI call and silently restore the crash, and MutexGuard's #[must_use] does
not fire on let _ =.
Three consequences worth knowing:
- Cost is one uncontended mutex acquire + release per evaluation — a CAS
and a release store, tens of nanoseconds, against an FFI call that walks a
graph and dispatches work in microseconds or more. (Order-of-magnitude
reasoning, not a measurement; it has not been isolated on a decode bench.)
Under contention it would be a futex syscall, but rMLX runs inference on one
thread at a time by design and the server holds a 1-permit
gpu_queueandgpu_gateabove that, so contention is not the steady state. async_evalstill pipelines. Only the graph walk and dispatch happen under the lock; the scheduled work completes after it is released.- The lock is not a licence to evaluate concurrently. It makes concurrent callers correct, not parallel — they serialise.
Twenty-five, not the three this workspace happens to call — and they come from two passes, because one of them is structurally blind to a quarter of the problem.
Pass 1 — automated, direct calls (24 symbols). Reverse reachability over
the linked dylibs: otool -tvV on libmlxc.dylib and libmlx.dylib,
transitive callers backwards from both mlx::core::eval_impl and the hazard
symbol itself mlx::core::cpu::get_command_encoder(Stream), intersected with
the exported mlx_* C ABI. Both directions agree.
Pass 2 — by hand, indirect dispatch (1 more). mlx_closure_apply reaches
evaluation through a std::function — a blr on a vtable slot — which reverse
reachability over a disassembly does not traverse. It does not appear in
pass 1's output and never will.
Re-running pass 1 at a pin bump gives 24, with
mlx_closure_applyabsent. That is the automated pass's blind spot, not a stale entry — do not "correct" the gate by deleting the closure guard.
The exact procedure, and the other closure-taking entry points to re-audit at a
pin bump, are recorded in scripts/check_eval_lock.sh.
| Entry point | Count | Why it evaluates | Called here |
|---|---|---|---|
mlx_array_eval |
1 | directly | yes, guarded |
mlx_async_eval |
1 | directly | yes, guarded |
mlx_closure_apply |
1 | indirectly — building the fused Compiled primitive bakes scalar constants into the kernel library name: print_constant → array::item<T>() → array::eval() (mlx/backend/common/compiled.cpp) |
yes, guarded |
mlx_eval |
1 | directly | no |
mlx_array_item_* |
14 | array::item<T>() evaluates before reading (mlx/array.h) |
no |
mlx_array_tostring |
1 | operator<<(ostream&, array) evaluates |
no |
mlx_save, mlx_save_writer, mlx_save_safetensors, mlx_save_safetensors_writer, mlx_save_gguf, mlx_load_gguf |
6 | serialisation materialises before writing | no |
mlx_array_data_* |
— | does not evaluate — plain pointer accessors | yes, unguarded and correct |
The twenty-two uncalled ones are the live risk. They sit in the bindings one
call away and read as innocuous: mlx_array_item_float32 looks like a scalar
accessor, mlx_array_tostring is what an impl Debug for Array reaches for,
and mlx_save_safetensors is the write side of rmlx convert, a named
0.1.0 deliverable. Adding one unguarded reinstates this exact defect, with a
signature — whole test binary dies, no test named — indistinguishable from the
original bug.
mlx_closure_apply is the one that is not obvious in either direction:
applying a closure looks like pure graph construction, and the evaluation is
reached through a std::function vtable, so it is invisible both to a text
scan of call sites and to pass 1 above. It is guarded, and because the Rust
closure body runs inside that call with the lock held, a body that takes
the lock again self-deadlocks on the non-reentrant mutex. Note that ban is
broader than "must not evaluate" — Closure::apply takes the lock, so a body
applying another compiled closure deadlocks without calling eval anywhere,
which is the first shape a "fuse two fused kernels" refactor produces. No body
does either today; RULE 3 of the gate keeps it that way.
| Kind | Catches | Misses | |
|---|---|---|---|
make check-eval-lock |
text gate, deterministic | an unguarded call to any of the 25; a guard dropped from a call site; a closure body that takes the lock again | a disarmed lock — the lexical structure is unchanged, so it stays green |
with_eval_lock_serialises_concurrent_callers |
unit test, deterministic | a lock that does not actually exclude | anything about which calls take the lock |
make eval-lock-stress |
reproducer driver, probabilistic | the real end-to-end crash | ~1 run in 12 per process, so it needs N≥60 to mean anything |
The first two are in make ci and in the hosted source gates job, and are
complementary by construction — each is blind to exactly what the other
catches; that was verified by mutation, not assumed.
make check-eval-lock-fixtures is the recall test for the first: 26 synthetic
scan roots, each asserting an exit code and which rule fired. Asserting the
rule is the point — an earlier corpus checked exit codes only, and because every
must-fail fixture happened to contain no guarded call site, each one tripped
RULE 2's anti-vacuity branch and exited 1 regardless of the rule under test.
Deleting RULE 1 or RULE 3 outright left that corpus green. Every RULE 1 / RULE 3
fixture now ends with a correctly-guarded guarded_anchor so the failure is
attributable. The corpus is itself mutation-tested: 17 mutations of the gate,
17 killed, 0 survivors.
The stress driver is not in make ci, and the reproducer it drives carries
#[ignore]. Its measured 8% failure rate was taken the way the driver runs it —
alone, against a cold encoder map. Inside a normal cargo test the map is
already warm from other tests, so its detection chance there is lower still and
was never measured, while its cost is exact: 412 threads peak against 4 without
it, ~436 still resident at suite end, in a binary whose test order is
nondeterministic. Measured cost plus unmeasured benefit is not a gate.
Never eval() a kernel's inputs before dispatching it. Array::eval()
blocks the calling thread until the GPU has produced the array, so an eval()
inside a per-layer dispatcher runs the forward pass one layer at a time with
nothing queued ahead — the host and the GPU stop overlapping. It produces
byte-identical output, which is why the cost hides: it shows up only as a
decode rate several times below what the kernel can reach. A KV flash-decode
dispatcher paying this on every attention layer measured 2.7× below its own
rate on Ternary-Bonsai-8B once the eval() calls were dropped, with the token
digest unchanged.
Pass lazy arrays to MetalKernel::apply and let MLX schedule the graph. Two
facts make that safe, and both are worth stating because the comment this rule
replaced got each of them wrong:
- Ordering.
MetalKernel::applyenqueues an MLXfast::CustomKernelgraph node; it does not dispatch. MLX runs that node'seval_gpuonly once every input edge is materialised, and applies theensure_row_contiguouscopy (below) inside that sameeval_gpu. A kernel cannot read an uncomputed or strided buffer, so a caller-sideeval()buys no ordering — it only changes when the host blocks. - Layout.
Array::eval()materialises but does not relayout. MLX'sTransposeis a strided view over a shared buffer, so an evaluated transpose is still non-row-contiguous. The layout guarantee for a raw-linear kernel always came fromreshapeplusensure_row_contiguous, never from forcing evaluation.
The CI gate make check-no-kernel-input-eval enforces this across every
custom-Metal-kernel dispatcher and shared dispatcher scaffold in the KV codec
layer (keyed on the file constructing a MetalKernelInvoke, or being a
*_common.rs scaffold — not on a codec name), with an // eval-ok: <reason>
marker for a genuinely load-bearing call such as a host readback before
to_bytes(). One marker exempts one call.
make check-no-kernel-input-eval-fixtures is the gate's own recall test: a
fixture tree per evasion (UFCS Array::eval(&x), an eval moved into a shared
_common.rs helper, a dispatcher relocated into a sub-directory, a marker
leaking onto a following loop) pinned to the exit code the gate must produce.
Array::to_bytes() forces evaluation (eval()) before reading, then calls
mlx_array_data_uint8 to obtain a raw pointer to the materialized buffer and
copies into a Vec<u8>. Because it materializes internally, callers do not
need a preceding eval() / async_eval() for correctness — an upstream
async_eval() stays a pipelining hint, not a correctness prerequisite (the
data pointer is not guaranteed valid until evaluation has actually run). The
pointer is valid only for the lifetime of the Array object; the method
returns Err if the pointer is null.
// SAFETY: ptr is non-null and points to `nbytes` contiguous bytes owned
// by the mlx array. Copied into Vec before returning.
let bytes = unsafe { std::slice::from_raw_parts(ptr, nbytes) }.to_vec();Every op in ops.rs and fast_ops.rs follows the same pattern:
- Call
install_error_handler()(idempotent;Once-guarded). - Allocate an output handle with
mlx_array_new(). - Call the C function inside
with_stream(device, |s| …), writing into the output handle via&raw mut res. - Call
check_status(status, "op_name")to convert non-zero returns toError::Mlx. - Return
Ok(Array { inner: res }).
Standard dense matrix multiplication. Wraps mlx_matmul. Batch-compatible.
Integer-affine or OCP mxfp8 quantized matmul. Wraps mlx_quantized_matmul.
wis bit-packed (U32 for affine/mxfp8).scalescarries per-group scale factors.biasesisNonefor mxfp8/mxfp4; present for integer affine.modemust match the quantization codec:"affine"or"mxfp8". MLX 0.31+ rejects the legacy"default"string.transpose_w = trueis the common case for linear layers (wis[out_features, packed_in_features]; matmul computesx @ w.T).group_sizeandbitsare passed asmlx_optional_intstructs.
Reconstructs a floating-point tensor from a quantized triple (codes, scales, biases). Wraps mlx_dequantize. Used for the on-device embedding lookup
path — avoids the device → host → device round-trip that an eye(seq) @ w
workaround required.
Quantizes a floating-point tensor using MLX's affine integer-affine codec.
Returns the canonical triple (codes_u32, scales, biases) that MLX uses for
affine-quantized tensors. Wraps mlx_quantize, which returns its result as
an mlx_vector_array; the wrapper extracts the three handles and wraps them
in Array.
Batched MoE expert dispatch: computes x[lhs_indices] @ W[rhs_indices].T
with quantized weights. Wraps mlx_gather_qmm. Both lhs_indices and
biases are optional; the null sentinel is used when absent.
slice extracts a sub-tensor via per-axis start:stop:stride ranges.
slice_update returns a copy of self with one slice replaced by update.
Both wrap the corresponding mlx_slice / mlx_slice_update C functions.
slice_update is the preferred write path for pre-allocated KV cache buffers
inside compiled graphs — MLX may reuse the underlying buffer when the
original is no longer live, making it cheaper than concat.
take wraps mlx_take_axis — equivalent to np.take. take_along_axis
wraps mlx_take_along_axis — equivalent to np.take_along_axis.
Wraps mlx_scatter_add_axis. Performs out[indices] += values along axis 0.
Used for sparse accumulation in MoE expert output aggregation.
fast_ops.rs wraps the mlx_fast_* family, which bypass the elementwise
graph and dispatch directly to optimised Metal kernels.
Fused RMSNorm: x / sqrt(mean(x^2) + eps) * weight. Wraps
mlx_fast_rms_norm. weight is None for RMSNormNoScale layers (Gemma4
v_norm); the null sentinel is passed when weight is absent.
Four variants of Rotary Position Embedding, all wrapping mlx_fast_rope or
mlx_fast_rope_dynamic:
| Variant | Offset type | Frequency source |
|---|---|---|
rope |
i32 (captured) |
Base theta (computed by kernel) |
rope_dynamic |
Array (0-D i32 scalar) |
Base theta |
rope_with_freqs |
i32 |
Explicit [dims/2] freq table |
rope_with_freqs_dynamic |
Array (0-D i32 scalar) |
Explicit freq table |
rope_dynamic is required inside mx.compile closures: a captured i32
offset forces a retrace on every step (new unique literal in the graph),
whereas an Array-valued offset flows through the compiled graph as a runtime
operand, enabling a single compiled program across all decode steps.
rope_with_freqs is used for Gemma4 full-attention layers that apply
ProportionalRoPE, where the frequency exponent is divided by the global head
dimension (512) rather than the rotated dimension (128). The base parameter
is ignored when freqs is provided; has_value = false is set on the
mlx_optional_float struct to signal this explicitly.
Wraps mlx_fast_scaled_dot_product_attention. Arguments:
q,k,v:[batch, n_heads, seq_len, head_dim].scale:1/sqrt(head_dim)or1.0(Gemma4 uses1.0).mask_mode:"causal"(kernel handles masking internally, fastest),"additive"(caller supplies additive mask), or""(no mask).mask_arr: ignored whenmask_mode = "causal"; null sentinel whenNone.sinks: always null sentinel (not used at this stage).
It is not unconditionally a FlashAttention kernel. Whether the call reaches
a fused kernel or a composite graph is decided by head_dim, silently.
ScaledDotProductAttention::use_fallback
(mlx/backend/metal/scaled_dot_product_attention.cpp:618-636, v0.31.2) gates
on head_dim and on q_seq:
| Route | head_dim accepted |
Other conditions |
|---|---|---|
sdpa_full (fused steel_attention) |
64, 80, 128 | q_seq > 8 (prefill), and the mask is absent, an array, or causal with q_seq <= kL |
sdpa_vector (fused) |
64, 96, 128, 256 | q_seq <= 8 (decode), q_seq <= kL, q_seq × gqa_factor <= 32 |
| composite graph | any | whatever both gates reject |
The composite route is the unfused lambda at mlx/fast.cpp:717 —
matmul(q, kᵀ) → mask → softmax → matmul, with the
[B, n_heads, L_q, L_k] score tensor materialised.
The shipped kernel inventory agrees with the gate:
LIB="$(brew --prefix mlx)/lib/mlx.metallib"
xcrun metal-nm --defined-only "$LIB" | grep -o 'steel_attention[a-z0-9_]*' | sort -u
# ... _bd64_ / _bd80_ / _bd128_ only — no bd256, no bd512
xcrun metal-nm --defined-only "$LIB" | grep -o 'sdpa_vector[a-z0-9_]*' | sort -u
# ... _64_64 / _96_96 / _128_128 / _256_256 — no 512So above head_dim 128 there is no fused prefill kernel at all, and at
head_dim 512 there is no fused decode kernel either.
Which of our models sit where (per-layer head_dim from each snapshot's
config.json; the gemma-4 split is applied in gemma4/loader.rs, sliding
layers take head_dim, full-attention layers take global_head_dim):
| Family | Windowed / linear layers | Full-attention layers | Fused prefill? |
|---|---|---|---|
Ternary-Bonsai-8B (Qwen3ForCausalLM) |
— (all 36 are full-attention) | 128 | yes |
| gemma-4 e2b / e4b / 26b / 31b | 256, SWA (kL ≤ window) |
512 | no, at either width |
medgemma 1.5 4b (Gemma3…) |
256, SWA (kL ≤ window) |
256 | no |
Qwen3.6-35B-A3B, Bonsai-27B (Qwen3_5…) |
GDN — no SDPA | 256 | no |
Gemma-4 is not a head_dim 256 model end-to-end: only its
window-bounded layers are 256 wide, and the layers whose kL grows with the
prompt are 512 wide. That distinction decides who actually pays.
What the fallback costs. Reproduce with
scripts/sdpa_headdim_bench.py, which
prints the metallib inventory it measured against — re-run it when the pin
moves, because these numbers are only valid for the kernel set above. Below:
mlx 0.31.2, M5 Max, bf16, causal, q_seq = kv_seq = L, best-of-5 after a
pipeline pre-warm, median of two runs. L ≥ 8192 reproduced within 6%; the
L = 2048 row is launch-bound and is not load-bearing.
| q:kv heads | L | head_dim 128 |
256 | 512 | 256 ÷ 128 | peak, 256 |
|---|---|---|---|---|---|---|
| 8:1 | 2 048 | 0.71 ms | 1.27 ms | 1.73 ms | 1.79× | 99 MB |
| 8:1 | 8 192 | 3.03 ms | 17.28 ms | 26.17 ms | 5.70× | 1.25 GB |
| 8:1 | 32 768 | 44.5 ms | 313.0 ms | 489.9 ms | 7.03× | 18.7 GB |
| 32:8 | 2 048 | 1.07 ms | 4.64 ms | 7.00 ms | 4.35× | 390 MB |
| 32:8 | 8 192 | 11.9 ms | 72.0 ms | 107.8 ms | 6.06× | 4.8 GB |
| 32:8 | 32 768 | 200.1 ms | 1 276.1 ms | 2 142.3 ms | 6.38× | 71.7 GB |
A fused kernel present at both widths would land near 2.0×, since doubling
head_dim doubles the FLOPs. Every cell from L = 2048 up at 32:8, and from
L = 8192 up at 8:1, is past 4×. Only the smallest cell is inside the
"costs little" band, so the gap is a real cost, not a curiosity.
Why it is more than 2×. The two paths do not perform the same work, and
the causal section of the harness measures it directly — causal ÷ unmasked
at L = 8192:
head_dim |
8:1 | 32:8 | reading |
|---|---|---|---|
| 128 (fused) | 0.566 | 0.511 | skips fully-masked tiles — does ~half the rectangle |
| 256 (composite) | 1.318 | 1.331 | computes the whole rectangle, then pays to build and apply the mask |
| 512 (composite) | 1.160 | 1.150 | same |
So going 128 → 256 costs 2× for the wider head and another 2× for losing the
causal skip — 4× the arithmetic. Normalising each path by the work it
actually performs (2·H·L²·D fused, 4·H·L²·D composite), the fused path
sustains 44–49 TF/s and the composite 28–32 TF/s. That closes the measurement:
4 × (46.2 / 30.5) = 6.06× against 6.06× measured at 32:8 / 8192, and
4 × (44.0 / 27.6) = 6.38× against 6.38× at 32:8 / 32768. The composite path
is not catastrophically inefficient per FLOP — it is asked to do four times as
many, and it materialises the score tensor to do them.
Quoting a single dense-equivalent 4·H·L²·D rate for both would overstate the
fused path by 2×; the ratios above are convention-free either way.
The 512 column is the control. Both 256 and 512 are unfused, and 512 ÷ 256
lands at 1.36–1.68× — below the 2.0× FLOP ideal, which is what a shared
composite path predicts: its fixed [H, L, L] score cost does not grow with
head_dim. The cliff is at the 128 → 256 boundary, not "wider heads are
slower".
Peak memory is the same story told in bytes: at L = 32768, 32:8, 671 MB at
head_dim 128 against 71.7 GB at 256 — the materialised score tensor is
32 × 32768² × 2 B = 68.7 GB on its own.
Decode is unaffected in the way that matters: at q_seq = 1 the score tensor
is [H, 1, kL], so the composite path has no O(L²) term. In the only decode
shape that stays clear of this host's ~200 µs dispatch floor (32:8,
kL = 32768, three runs) the unfused 512-wide path reads KV at ≈315 GB/s
against the 256-wide vector kernel's ≈355 GB/s — a modest deficit, not a
cliff. Every other decode cell moved by up to 2.2× run to run and cannot
resolve a kernel-level difference; the harness prints them all so that stays
visible rather than being quoted selectively.
What rMLX actually pays. Less than the isolated L = kL numbers above,
for two structural reasons:
- Prefill is chunked per arch (
prefill_chunk.rs: gemma-4 1024, Qwen3.5-MoE 2048), so the score tensor is[H_q, chunk, kL]— linear inkL, not quadratic in the prompt. It is still large in absolute terms: Qwen3.6-35B-A3B (16 q heads, chunk 2048) materialises 2.1 GB per full-attention layer per chunk atkL = 32768, 8.6 GB at 128k. Chunking bounds the growth, it does not remove the tensor. - On gemma-4 the 256-wide layers are sliding-window, so their
kLis capped at 512 / 1024 tokens no matter how long the prompt is.
The families that pay a growing-kL composite cost are Qwen3.5 / Qwen3.6
(10 of 40 layers on Qwen3.6-35B-A3B, 16 of 64 on Bonsai-27B), medgemma
(5 of 34), and gemma-4's global layers — the last at 512, where no upstream
proposal reaches.
Upstream status (ml-explore/mlx, checked 2026-08-16). This belongs upstream, and it is already in flight there; none of it has merged:
| PR | What | State |
|---|---|---|
| #3293 | head_dim=256 in sdpa_full + a bd=256 steel instantiation |
closed, unmerged |
| #3660 | revival of #3293 for 192/256, routed above kL > 16384 |
closed, unmerged |
| #3842 | a NAX bd=256 full-attention path |
open |
| #4185 | force_fused flag, which also restores 192/256 behind it |
open |
#3660 was closed with "we decided to add a force_fused option to the API to
let users make the decision, instead of providing builtin heuristics" — so the
direction upstream is an explicit opt-in, not a wider default. No proposal
covers head_dim 512, so gemma-4's global layers stay composite regardless.
There is nothing to port until a release ships one of these. A hand-written
bd=256 flash kernel here is not on the table: it duplicates upstream work,
and the one attention-kernel class this repo has hand-written — flash-decode
over a quant store — landed at 4–14% of MLX's per-byte throughput
(docs/PERF_BASELINE.md). That is a different kernel, but it is the only
calibration we have for what writing one here costs.
compile.rs wraps the mlx_compile / mlx_closure C API. This is the
Rust equivalent of Python's @mx.compile decorator.
Closure is an owned RAII handle around mlx_closure. It is freed on drop
via mlx_closure_free. The underlying handle is ref-counted by mlx-c.
// SAFETY: mlx_closure is a ref-counted pointer (analogous to Arc<T>).
// mlx-c docs guarantee thread-safe ref-counting.
unsafe impl Send for Closure {}
unsafe impl Sync for Closure {}Closure::from_fn accepts any
Fn(Vec<Array>) -> Result<Vec<Array>> + Send + Sync + 'static and bridges
it to the C callback ABI via a heap-allocated Box<BoxFn> whose raw pointer
is stored as the closure payload:
// SAFETY: payload is a *mut BoxFn cast to *mut c_void, valid for the
// lifetime of the mlx_closure (the dtor frees the Box when the closure
// is dropped). input is a borrowed mlx_vector_array; do NOT free it.
// output is a freshly created mlx_vector_array we must populate.
// Panics must NOT propagate across the FFI boundary.
unsafe extern "C" fn rust_closure_callback(
output: *mut sys::mlx_vector_array,
input: sys::mlx_vector_array,
payload: *mut c_void,
) -> c_int { … }The callback catches panics via std::panic::catch_unwind so they never
propagate across the FFI boundary; a panic is converted to a non-zero return
code and logged via tracing::error!.
Output packing requires creating a new mlx_vector_array handle and
overwriting *output with it. The C++ lambda that calls the Rust callback
creates *output with a null ctx; appending to a null-ctx vector is
undefined. The correct pattern: allocate a new initialized vector, append,
then overwrite *output (a plain struct copy of the ctx pointer). The C++
lambda reads ctx from *output and frees it — no leak occurs.
Both functions consume a Closure, pass its inner handle to mlx_compile,
and return a new compiled Closure. The compiled closure replays the cached
Metal program on every subsequent call without re-tracing the Rust op loop.
compile(shapeless = false): re-traces when input shapes change.compile_shapeless(shapeless = true): reuses one compiled program regardless of shape; the Metal dispatch grid is adjusted at runtime. Use this for ops called with variable sequence lengths (e.g. chunked prefill).
metal_kernel.rs wraps the mlx_fast_metal_kernel API from mlx-c 0.6,
which compiles and dispatches arbitrary MSL (Metal Shading Language) kernels
within the MLX compute graph.
Compiled Metal kernel handle. RAII: freed on drop via
mlx_fast_metal_kernel_free.
// SAFETY: mlx_fast_metal_kernel is a void* handle. The kernel object is
// immutable after `new` (no mutation through &self). MLX's Metal device
// context is process-global; callers must hold the rMLX process-level
// GPU claim before invoking.
unsafe impl Send for MetalKernel {}
unsafe impl Sync for MetalKernel {}MetalKernel::new converts all string arguments to CString, builds
mlx_vector_string handles for input_names and output_names, then calls
mlx_fast_metal_kernel_new. String vectors are freed after the call;
mlx-c copies them internally.
ensure_row_contiguous = true is always set — the safer default for custom
kernels. atomic_outputs = false unless the kernel requires read-modify-write
atomics (e.g. atomic_fetch_or_explicit).
ensure_row_contiguous is what makes it safe for a kernel body to index its
buffers by raw linear offset: MLX copies any input that is not row-contiguous
(a lazy transpose, a strided view) before the dispatch. Callers therefore do
not need to materialise inputs themselves — see "Never eval() a kernel's
inputs" under Evaluation. It does not fix a semantic layout disagreement: an
array whose logical axis order is not the one the kernel indexes is still wrong
after the copy, and that is what the canonical seq-major KV store layout is
for.
MetalKernel::apply takes a MetalKernelInvoke by value (consumed), builds
input/output mlx_vector_array handles, dispatches via
mlx_fast_metal_kernel_apply, extracts output Array handles, and returns
them.
MLX JIT language version. MLX compiles custom kernel bodies at Metal
4.0. Observed, not inferred: rmlx_nax_probe_gpu
(crates/rmlx-mlx/src/metal_kernel_tests.rs, run by make gpu-test) reads
__METAL_VERSION__ from inside a JIT'd body and gets 400, with
__HAVE_TENSOR__ == 1 and a constexpr matmul2d_descriptor(8, 32, 128, …)
instantiating to .m == 8. The third value is the load-bearing one: it proves
the <MetalPerformancePrimitives/…> include path survives MLX's source
wrapping, not merely that the macro is defined.
The consequence: mpp::tensor_ops is reachable from an rMLX kernel body, so
a prefill GEMM with a custom epilogue is a legitimate design option rather than
something gated on an MLX change. mlx-c exposes no compile-options surface (the
kernel config is seven setters in mlx/c/fast.h), so this is observed, never
forced — re-run the probe after an MLX bump rather than assuming it holds.
The compile gate mirrors this: it compiles every body at metal3.0 and
metal4.0. See "MSL gates" below.
Lazy compile. MetalKernel::new only registers the kernel
with MLX; the MSL → Metal pipeline compiles on the first apply()
dispatch, not at new. For KV codecs this means the shader cold-compile lands
inside the first user request unless it is warmed earlier. The KV layer warms
its shader-heavy codecs at model-load time via
rmlx_kv_quant::precompile::precompile_kv_codec_msl (one representative dispatch
per codec kernel during the eager-preload window); see docs/KV_QUANT.md
§ "Metal-vs-CPU hot path + load-time MSL precompile". The gdn_warmup in
rmlx-models::arch::loader is the analogous warm for the GatedDeltaNet compiled
graph.
The source parameter is the body of the kernel function. MLX wraps it
with the Metal function signature and buffer declarations automatically:
- Input buffers:
device const T* <name>for most arrays (auto-typed from array dtype) — but not always. MLX silently switches an input's outer-kernel parameter to theconstantaddress space instead ofdevicewhen the array is small (measured trip point: fewer than 8 elements, for every dtype and shape tried); this is an internal MLX size heuristic, not something a caller can pin per-argument. If the kernel body calls a header/body helper function whose parameter is hard-declareddevice const T*, that call fails the MSL compile the moment a small enough array reaches it (Unable to build metal library from source: no matching function ... cannot pass pointer to address space 'constant' as a pointer to address space 'device') — a first-dispatch failure, not aMetalKernel::new-time one, so it can ship silently until a small enough input shows up in production. Any kernel whose input can legitimately be tiny at a valid call (e.g. a per-tokennormsbuffer at lowkv_seq) must pad that array up past the trip point before dispatch rather than assumedevicebinding. Seermlx_kv_quant::flash_decode_common::pad_norms_to_device_floor(floor 16, 2× the measured 8-element trip point for margin) — the general fix shared byiso_flash_decode_symv_sdpaandrotor_flash_decode_symv_sdpa, documented per-codec indocs/KV_QUANT.md. - Output buffers:
device T* <name>. - Built-ins:
thread_position_in_grid(uint3),threadgroup_position_in_grid(uint3),thread_position_in_threadgroup(uint3). headeris MSL inserted before the kernel body; use it forconstantarray declarations and helper functions.
For KV codecs the source argument is not a Rust literal — it is
include_str! of a .metal file. See ".metal files + include_str!" below.
Builder for a single dispatch. add_input clones the input Array (via
mlx_array_set ref-count increment) into the builder's inputs vec.
add_output_shape declares an output buffer via
mlx_fast_metal_kernel_config_add_output_arg. set_grid and
set_thread_group configure the 3-D Metal dispatch geometry. Template
arguments (set_template_int, set_template_dtype) specialize the MSL
template at JIT compile time.
set_init_value zeroes output buffers before the kernel runs. Required for
kernels that accumulate into outputs via atomic_fetch_or_explicit — without
it, MLX may reuse a Metal buffer from its pool whose previous contents are
non-zero, corrupting the result.
Every MSL kernel body lives in a .metal file, never in a Rust string literal.
Three directories hold them, one per crate that ships MSL:
| Directory | Scope |
|---|---|
crates/rmlx-kv-quant/src/metal/ |
Every KV-cache codec (q8, TurboQuant, PlanarQuant, IsoQuant, RotorQuant, rot-K, TCQ, TurboFlash, fused-QK, sparse-attn phases), dispatched from src/*_msl.rs and src/sparse_attn/*_msl.rs |
crates/rmlx-models/src/metal/ |
Per-arch kernels — weight-side ParoQuant and GatedDeltaNet, dispatched from paroquant_msl.rs / gated_delta_msl.rs. Not KV codecs. |
crates/rmlx-mlx/src/metal/ |
The MLX-JIT language-version probe. Not a production kernel. |
The gates are scoped by directory, not crate: a .metal file is gated by
where it lives, wherever its Rust dispatcher sits. That distinction is the one
that matters — a kernel inside a gated crate but outside its metal/ directory
is not gated.
Each module registers its kernels once as OnceLock<MetalKernel> singletons
on first use, and its MSL body matches the CPU reference path in the
corresponding *quant.rs file.
Bodies are embedded at compile time:
const QUANTIZE_SOURCE: &str = include_str!("metal/q8_quantize.metal");include_str! — never a runtime fs::read. The binary stays single-file with
no runtime data files (CLAUDE.md hard rule 2).
Body / header split. A .metal file holds the kernel body only — MLX
supplies the function signature and buffer declarations at dispatch. The
header argument is separate, and comes from one of two places:
- Static header — a
.metalfile of its own (turboquant_header.metal,turbo_flash_header.metal, …), embedded the same way. - Runtime-generated header — a
build_*_header(..)Rust function that emitsconstant/#definedeclarations whose values are computed (codebooks, rotation constants, quaternions, eps). These stay in Rust: they are derived data, not source text. The kernel is assembled at registration asMetalKernel::new(name, header, include_str!("<body>.metal"), ..).
An #include belongs in the header, never the body: MLX splices the body into
the generated kernel function, so an include there lands at function scope.
Parameterised bodies. Two mechanisms, and the choice is not stylistic:
- One
.metalfile per variant, selected by the builder (planar_fused_qk_b3.metal/_b4.metal;rot_k_fwht_quantize_d{32..512}.metal). Use this when the variants differ in code, not just in a constant. - MLX template arguments —
set_template_int/set_template_dtype, which MLX instantiates per distinct tuple. Use this when the variants differ only by a compile-time constant, asgated_delta_step.metaldoes forDk/Dv/Hk/Hvandparoquant_rotate.metaldoes forROWS_PER_TILE/MAX_KROT/MAX_GROUP_SIZE. It keeps the bound's single source of truth in the Rust const that the validation checks already use.
The body text is never mutated at runtime. A .replace("{PLACEHOLDER}", ..) over
a kernel source is what a template argument is for, and it makes the file
uncompilable by the gate.
Adding a KV codec means adding a .metal decode kernel and a native compile
test; see CLAUDE.md hard rule 10.
Two gates run over the three kernel directories listed above. The list is
single-sourced in scripts/metal_dirs.sh, sourced by both gates and referenced
by the check-metal-format pre-commit hook's trigger pattern; a crate that
starts shipping MSL must be added there, since nothing else discovers it.
| Target | Tool | Checks |
|---|---|---|
make check-metal-compiles |
xcrun -sdk macosx metal (full Xcode, not just the Command Line Tools) |
Every kernel compiles natively at -std=metal3.0 and -std=metal4.0, so an MSL syntax error surfaces at CI instead of on first GPU dispatch. Also fails if a .metal file is missing from its directory's manifest. |
make check-metal-format |
clang-format (on PATH or via xcrun -f clang-format — it is not on PATH by default) |
Every kernel is clang-format clean. MSL is a C++14 dialect; style is pinned by the .clang-format in each kernel directory. |
Two language versions, for two different reasons. metal4.0 is what
production compiles at (see "MLX JIT language version" above). metal3.0 is the
floor, kept so newer syntax cannot creep in unnoticed. The second pass is what
makes a #if __HAVE_TENSOR__ kernel checkable at all: that macro is undefined
below 4.0, so at metal3.0 such a body compiles to an empty translation unit
and the gate goes green having validated nothing. Such a body is therefore never
compiled without the guard — it is checked for real, or reported as SKIP and
counted, never quietly passed.
The capability is probed by asserting the guard and the cooperative-tensor
includes, not by testing that the driver accepts the -std flag. A toolchain
that takes the flag but leaves __HAVE_TENSOR__ undefined would otherwise
compile a guarded body through its #else arm at both passes — the same
vacuous pass, reached another way.
One toolchain policy, not two. "This box cannot do X" gets the same answer
whether X is the Metal compiler itself or the Metal 4 pass: hard failure under
--strict (CI, which must never report green while checking less), and a loud
notice plus a reduced run otherwise. A contributor on an older Xcode keeps a
working make ci; what could not be checked is named on stdout and counted in
the summary line. Splitting that rule would break the dev loop for everyone
whose Xcode predates Metal 4, over one diagnostic kernel that ships nothing.
Manifest coverage is enforced. Every .metal file in a gated directory must
be named by that directory's probes/kernels.manifest, as a body or as a
../-prefixed header. An unlisted body is compiled by nothing, which is the
same vacuous pass in a different disguise, so the gate hard-fails on it.
Where they actually run. Both gates skip when their tool is missing, so a
Command-Line-Tools-only box is not blocked — but a skipping gate protects
nothing, so the skip is local-only. The msl job in
.github/workflows/ci.yml runs both with METAL_STRICT=--strict, which turns
a missing tool into a hard failure — and, for the compile gate, a toolchain that
cannot do the metal4.0 pass; for the format gate, an empty file set, so a
renamed kernel directory cannot silently disable it while the job stays green. The GitHub macOS runner ships full Xcode,
so the compile gate runs for real there; compiling MSL needs the toolchain,
not a GPU, so it works on a runner with no usable Metal device. Install full
Xcode (xcode-select -s /Applications/Xcode.app) to run the compile gate
locally too — on Xcode 16.3+ the compiler is a separate component
(xcodebuild -downloadComponent MetalToolchain).
check-metal-compiles cannot compile a .metal file directly — a body is a
run of statements at file scope, not a translation unit. It assembles a probe
per kernel (stdlib preamble + header + kernel { buffer aliases + defines + body }) and compiles that. Each directory's probes/kernels.manifest supplies,
per body: the header to prepend, the buffer names the body expects, and an
optional fourth field of #define NAME VALUE pairs for the values MLX injects
at dispatch that are neither buffers nor header constants — template dtypes
(OutT, InT, StT), template ints (Dk, ROWS_PER_TILE, …) and scalar 0-D
inputs (T), which the body sees as numeric literals. Buffer types are u
(uint), i (int) and f (float), matching the dtype the dispatch site declares.
Where such a #define duplicates a Rust const, pin it with an equality test as
probe_manifest_defines_match_rust_consts does — a hand-copied bound drifts the
same way a captured header snapshot does.
crates/rmlx-kv-quant/src/metal/probes/README.md documents the layout and how
to refresh the captured header snapshots; the other two directories follow the
same convention and point back at it.
Deliberately not wired: clang-tidy (wants a compilation database and is
noisy on MSL) and MegaLinter (CI-heavy). The two gates above already cover
syntax and style.
Every file in rmlx-mlx carries:
// unsafe_code: mlx-rs FFI bridge — <per-file justification>
#![allow(unsafe_code)]
#![deny(unsafe_op_in_unsafe_fn)]#![deny(unsafe_op_in_unsafe_fn)] is preserved throughout: every unsafe
operation inside an unsafe fn must be individually justified with its own
// SAFETY: comment. This prevents the common anti-pattern of marking an
entire function unsafe and then writing unjustified unsafe code in its
body.
Array::from_bytes / mlx_array_new_data
// SAFETY: mlx_array_new_data copies the buffer immediately. The
// returned handle owns its data; `data` need not outlive this call.
mlx_array_shape / mlx_array_data_uint8 (borrowed pointer)
// SAFETY: ptr is valid for the lifetime of the Array object (mlx-c
// contract). Copied into Vec before returning; no pointer escapes.
null_sentinel / EMPTY_ARRAY_SENTINEL (process-global null handle)
// SAFETY: the null-ctx handle returned here is only valid as a
// "sentinel absent" argument to mlx-c functions that document
// "may be null". Never store or materialize the returned handle
// as a real Array.
with_stream (stream handle borrow)
// SAFETY: f must not store the stream handle past the duration of
// the call. mlx_default_gpu_stream_new returns a ref-counted handle;
// freeing it after the call decrements the count without tearing down
// the stream or its thread.
check_status (thread-local error retrieval)
// SAFETY: must be called immediately after the mlx-c call whose status
// is being checked, on the same thread, before any other mlx-c call
// that could overwrite the thread-local error slot.
rust_closure_callback (FFI → Rust trampoline)
// SAFETY: payload is a *mut BoxFn cast to *mut c_void, valid for the
// lifetime of the mlx_closure (the dtor frees the Box when the closure
// is dropped). input is a borrowed mlx_vector_array; we must NOT free
// it. output is a freshly created mlx_vector_array we must populate.
// Panics must NOT propagate across the FFI boundary.
MetalKernel / Closure (Send + Sync)
// SAFETY: the handle is immutable after construction and is ref-counted
// by mlx-c. MLX's Metal device context is process-global; rMLX enforces
// single-process ownership via the /tmp/rmlx.<port>.claim file.
mlx_array_data_uint8 readback
// SAFETY: ptr is non-null and points to `nbytes` contiguous bytes owned
// by the mlx array. Copied into Vec before returning.
docs/KV_CACHE.md— KV cache design; describes howslice_update,take, and the MSL quant kernels are composed to implement the KV quant families.docs/WEIGHT_QUANTS.md— weight quantization formats and howquantized_matmul/dequantize/gather_qmminteract with them.docs/KV_QUANT.md— KV quantization format details; how the MSL kernels inrmlx-kv-quant/src/*_msl.rsmap to each codec.