Numerics, hardware gating, and writing custom Metal kernels from Python¶
Part 12 · MLX in Python · Reference 02
Version floor. Current stable MLX is 0.32.3 (release, checked 2026-10-07); detailed APIs retain the inspected 0.32.1 source snapshot. MLX itself has a much
lower OS floor than the rest of this series: the macOS wheels require Apple silicon, native Python
≥ 3.10, macOS ≥ 14.0 (✅ docs/src/install.rst). Nothing in this guide requires iOS 27 or
macOS 27. The one place where the OS version becomes load-bearing is the neural-accelerator
(NAX) path, which needs macOS / iOS / tvOS / visionOS 26.2 or later at runtime and a GPU
architecture generation ≥ 17 (≥ 18 on phone-class parts) — see §4. Metal TensorOps, the layer
underneath that path, shipped across the 26 point releases on Apple's own narrated ladder
(26.0 base · 26.1 bfloat · 26.3 cooperative tensors as matmul inputs · 26.4 int4/int8 tensors), while
the shipped Xcode 26.6 SDK headers annotate the relevant symbols as 26.2. Both statements are
true and they are about different things — §4.2 gives the full story. Build from source with
CMake ≥ 3.25, Clang ≥ 15 (C++20), Xcode ≥ 15.0, macOS SDK ≥ 14.0 (✅ docs/src/install.rst).
What this covers¶
Use this reference to choose numeric types and hardware paths, then implement and validate custom Metal kernels through MLX.
What this does not cover¶
Related references: Part 12, Part 11, Part 13.
What you need¶
- An Apple silicon Mac with MLX 0.32.x installed (
pip install mlx). Check you are on a native interpreter:python -c "import platform; print(platform.processor())"must printarm(✅docs/src/install.rsttroubleshooting). - For §4 and §5 to be more than theory, hardware on both sides of the gate — one M5-class
(architecture generation 17) machine and one earlier one. If you only have one, §4.5's probe
script tells you which side you are on, and
MLX_METAL_GPU_ARCHlets you simulate the far side (§4.5). - For §7–§9, nothing beyond MLX itself. Custom Metal kernels are JIT-compiled from a Python string;
you do not need Xcode, a
.metalfile, or a build step. That is the whole point of the API. - Willingness to read a number and ask "on what hardware, at what OS, measured by whom." Every figure in this guide carries that attribution, and several of the most-quoted MLX numbers in circulation do not.
⚠️ Read this before you trust a signature below¶
Pin MLX before validating an NAX kernel. PR #3912 is included in 0.32.3; runtime remediation remains unverified. PR #3924 closed without merge, so verify odd tile shapes numerically. The detailed restrictions and sources are in §4.
Contents¶
- 0. Orientation: the three questions
- 1. The numeric types
- 1.1 The complete dtype table
- 1.2 Defaults, promotion, and the category lattice
- 1.3 float32 — the default, and what it costs
- 1.4 float16 vs bfloat16 — the decision that actually matters
- 1.5 float64: CPU only, and why
- 1.6 The integer family
- 1.7 Complex support: exactly one dtype
- 1.8 fp8 and fp4 are ops and storage formats, not dtypes
- 1.9 Reinterpreting bits:
mx.viewvsastype - 2. Choosing a dtype in practice
- 3. TF32 and the hardware gate — one feature, two halves
- 3.1 Half one:
relaxed_precision = true, hardcoded - 3.2 Half two: the host gates float32 on
MLX_ENABLE_TF32 - 3.3 ⚠️ SILENT FAILURE: precision you did not choose, with no runtime signal
- 3.4 What it measures out at
- 3.5 Three mechanics that cost people days
- 3.6 The blast radius, downstream
- 3.7 What to actually do
- 4. NAX, the M5 neural accelerator, and how to tell whether you are on the fast path
- 4.1 What the hardware is, per Apple
- 4.2 The version story, stated carefully
- 4.3 Three gates, all of which must pass
- 4.4 There is no capability query — here is the heuristic MLX uses
- 4.5 A probe you can run, and an A/B switch
- 4.6 Apple's own numbers, with their baselines
- 4.7 NAX changes the algorithm, not just the kernel
- 5. ⚠️ The silent SDPA fallback
- 5.1 The API, and the four notes that matter
- 5.2 The complete fallback table
- 5.3 What the fallback actually does
- 5.4 What it costs, measured
- 5.5 Detecting the fallback — four techniques
- 5.6 Fixing it: pad the head dimension
- 5.7 Two adjacent SDPA traps
- 6. The rest of
mx.fast, and why fused beats hand-composed - 6.1 The mechanical reason:
mx.compiledoes not fuse reductions - 6.2
rms_normandlayer_norm - 6.3
rope - 6.4 When not to use the fused primitive
- 7.
mx.fast.metal_kernel: the complete API - 7.1 Constructor
- 7.2 The returned callable
- 7.3 How the function signature is generated
- 7.4
gridandthreadgroupare in THREADS - 7.5 Math mode
- 8. A complete worked example
- 9. The advanced options
- 9.1 Strides and non-contiguous inputs
- 9.2 Template parameters
- 9.3
header=for helpers and includes - 9.4 Atomic outputs and
init_value— the VJP pattern - 9.5 Debugging:
verbose=True, Metal logging, GPU capture
Scope: this reference intentionally ends at §9.5; the contents list includes only sections present in this file. API spellings and kernel gates are pinned to the inspected MLX revision.1
0. Orientation: the three questions¶
Every performance or precision question in MLX resolves into three separate questions that people routinely collapse into one. Keeping them apart is most of the skill.
Question 1: what dtype did you ASK for?
mx.float32 / mx.float16 / mx.bfloat16 / mx.int8 / ...
↓ you control this; it is in your source code
Question 2: which KERNEL did MLX pick?
steel GEMM · steel GEMM (NAX) · gemv · gemv_wide · split-K · fused SDPA · unfused SDPA
↓ MLX controls this; it depends on shape, dtype, mask, training-ness, and hardware
Question 3: what PRECISION does that kernel compute at internally?
IEEE fp32 · TF32-class (relaxed_precision) · fp32 accumulate over fp16 operands · ...
↓ the kernel controls this; on the NAX path it is HARDCODED
The dtype you asked for constrains but does not determine the answer to questions 2 and 3. Specifically, on MLX 0.32.x:
- The same dtype and the same op give you different internal precision depending on operand
shape — a matvec (
M == 1orN == 1) does not take the NAX route and stays exactfloat32, while the matmul next to it does not (community-established, mlx#3860; §3.5). - The same code on two Macs gives you different numbers, because the kernel selection depends on the GPU architecture generation (§4).
- The fused attention primitive silently becomes three unfused ops when your head dimension is not in a small allow-list (§5).
None of these produce a warning, an exception, a log line, or a queryable flag. Question 2 and
question 3 are answerable today only by reading MLX's source, running a differential test against a
CPU-stream or float64 reference, or capturing a Metal trace. §4.5 and §5.5 give you the tooling.
The rest of this guide is: §1–§2 answer question 1 properly, §3–§4 answer question 3, §5 answers question 2 for the one op where it hurts most, and §6–§9 are about deliberately taking control of question 2 yourself by writing the kernel.
1. The numeric types¶
1.1 The complete dtype table¶
✅ VERIFIED — from docs/src/python/data_types.rst, transcribed in notes/repos/mlx-core.md
§12 and corroborated by the documentation-site crawl.
mx. name |
Bytes | Description | GPU? |
|---|---|---|---|
bool_ |
1 | Boolean | yes |
uint8 |
1 | 8-bit unsigned integer | yes |
uint16 |
2 | 16-bit unsigned integer | yes |
uint32 |
4 | 32-bit unsigned integer | yes |
uint64 |
8 | 64-bit unsigned integer | yes |
int8 |
1 | 8-bit signed integer | yes |
int16 |
2 | 16-bit signed integer | yes |
int32 |
4 | 32-bit signed integer | yes |
int64 |
8 | 64-bit signed integer | yes |
bfloat16 |
2 | 16-bit brain float — 8 exponent bits, 7 mantissa bits | yes |
float16 |
2 | IEEE half — 5 exponent bits, 10 mantissa bits | yes |
float32 |
4 | IEEE single | yes |
float64 |
8 | IEEE double | ❌ CPU only |
complex64 |
8 | 64-bit complex (two float32s) |
yes |
That is the entire list. There is no float8 dtype, no float4 dtype, no int4 dtype,
no complex128, and no bool packed bit type. §1.8 explains where the low-precision
floating formats actually live, because a lot of MLX material implies dtypes that do not exist.
1.2 Defaults, promotion, and the category lattice¶
✅ VERIFIED — docs/src/python/data_types.rst; mlx/dtype.h:42-49.
Defaults: MLX defaults to float32 for floating-point literals and int32 for integer
literals. This differs from PyTorch (also float32, but int64 for integers) and from NumPy on
64-bit platforms (float64 / int64). If you are porting code, the integer default is the one that
bites — an index array that was int64 in NumPy arrives as int32 in MLX.
The category lattice, used by mx.issubdtype:
generic
├── number
│ ├── integer
│ │ ├── signedinteger (int8, int16, int32, int64)
│ │ └── unsignedinteger (uint8, uint16, uint32, uint64)
│ └── inexact
│ ├── floating (float16, bfloat16, float32, float64)
│ └── complexfloating (complex64)
└── (bool_ is `generic` but not `number`)
✅ VERIFIED — the seven category names are quoted from mlx/dtype.h:42-49:
complexfloating, floating, inexact, signedinteger, unsignedinteger, integer, number, generic.
🟡 RECONSTRUCTED — the tree shape above (which category nests inside which) is inferred from
the names and from standard NumPy semantics. The names are exact; the parent-child edges are not
individually quoted from source. Safe default: use mx.issubdtype(x.dtype, mx.floating) and
mx.issubdtype(x.dtype, mx.integer), which are the two edges everything actually depends on, and
verify with a one-liner on your machine before relying on a subtler one.
Introspection surface, all ✅ VERIFIED as exported symbols
(python/src/mlx.cpp / docs/src/python/data_types.rst):
import mlx.core as mx
mx.Dtype # the dtype type itself
mx.DtypeCategory # the category type
mx.issubdtype(a, b) # dtype-or-category subsumption test
mx.isdtype(x, kind) # Array-API style predicate
mx.can_cast(a, b) # is a -> b a safe cast?
mx.result_type(*xs) # promotion result for a set of dtypes/arrays
mx.finfo(dtype) # machine limits for a float dtype
A worked promotion check you can run:
import mlx.core as mx
print(mx.array([1.0]).dtype) # float32 (float literal default)
print(mx.array([1]).dtype) # int32 (int literal default)
print(mx.result_type(mx.float16, mx.int32)) # dtype promotion for a mixed expression
print(mx.issubdtype(mx.bfloat16, mx.floating))
print(mx.finfo(mx.float16).max, mx.finfo(mx.bfloat16).max)
🔴 GAP — the exact
finfofield set is unverified.mx.finfois confirmed to exist as an exported symbol (docs/src/python/data_types.rst), but its attribute names (.max,.min,.eps,.tiny,.resolution, …) were not read from the binding source this session. The line above assumes.maxby analogy with NumPy. To resolve:print(dir(mx.finfo(mx.float16)))on your machine, or readpython/src/for thefinfobinding. Safe default: print the object and read what it has rather than assuming a NumPy-identical surface.
1.3 float32 — the default, and what it costs¶
float32 is MLX's default float and the right choice for: anything you are treating as ground truth,
numerical code that is not a neural network (linear algebra, signal processing, optimisation),
loss accumulation, and — critically — any reference implementation you are diffing another
implementation against.
Two things about float32 in MLX 0.32 that are not obvious:
- It is not necessarily computed at
float32precision. This is §3, and it is the single most important section of this guide. On architecture-generation-17 hardware (M5 class) with macOS ≥ 26.2, afloat32matmul routes through a kernel withrelaxed_precision = truehardcoded, unless you setMLX_ENABLE_TF32=0before the first matmul. - It is not portable across Apple silicon generations at the bit level. Community-measured
(mlx-lm#1280): the same model, same prompt,
temperature: 0, same seed, samemax_tokensproduced different generated-token counts on an M5 Max versus an M3 Ultra — e.g. 7857 vs 6145 tokens on one AIME25 case, both reaching the correct answer. Cross-device bit reproducibility is not a property MLX offers. Design your tests accordingly (§3.7).
A separate, older instance of the same class: mx.random.normal produces different float32 output
on an M1 Max versus an M3 Ultra / M5, traced to the FMA chain in the Metal erfinv kernel
(community-reported, mlx#3568, referenced from mlx#3702's thread; unverified beyond that reference).
1.4 float16 vs bfloat16 — the decision that actually matters¶
Both are 2 bytes. They differ in how they spend those 16 bits, and this is the whole decision:
| exponent bits | mantissa bits | dynamic range | relative precision | |
|---|---|---|---|---|
float16 |
5 | 10 | narrow (~6e-5 … 65504) | ~11 significant bits |
bfloat16 |
8 | 7 | same as float32 |
~8 significant bits |
✅ VERIFIED — the bit splits are quoted from docs/src/python/data_types.rst, which describes
bfloat16 as "brain float (e8, m7)" and float16 as "IEEE half (e5, m10)".
The practical rule. bfloat16 has float32's exponent field. That means anything that fits
in float32 fits in bfloat16 without overflowing or flushing to zero — you just lose mantissa.
float16 will overflow to inf at 65504 and underflow to zero around 6e-5, which is exactly the
regime that attention logits, un-normalised activations, and gradient accumulation live in.
Consequences:
- For transformer inference and training, prefer
bfloat16. This is why every modern checkpoint ships in it and why MLX's own quantized kernels dequantise the MX/NV floating-point formats intobfloatin threadgroup memory (✅ VERIFIED,fp_quantized_nax.h:198-204: the defaultWtype = bfloat). - For anything where you need the extra ~3 bits of mantissa and you know your dynamic range is
bounded,
float16is genuinely more accurate. Image pixel data in [0, 1], normalised feature maps, and post-softmax probabilities are the classic cases. - Do not use
float16for a running sum over many terms. With 10 mantissa bits, adding a small value to a large accumulator stops changing the accumulator far sooner than you expect. Accumulate infloat32.
A concrete example of MLX making this decision for you: mx.fast.scaled_dot_product_attention
performs the softmax in float32 regardless of the input precision (✅ VERIFIED, quoted verbatim
from docs/src/python/fast.rst: "The softmax operation is performed in float32 regardless of the
input precision."). That is a deliberate defence against exactly the float16 range problem, and it
is one of several reasons the fused primitive is better than your hand-composed version (§6).
A float16-specific hardware trap, community-measured and worth knowing before you assume a
2-byte dtype is uniformly cheap: on M5-class hardware the divergence between batched and
single-sequence attention in float16 / bfloat16 traces to the NAX attention kernel's masked
reduction at 64-aligned head dims — and MLX_ENABLE_TF32=0 does nothing about it, because the
TF32 gate's third clause (dtype != float32) is satisfied regardless. The only lever that moves it
is forcing a different architecture string. Source: mlx#3897 (closed 2026-08-09; 7 comments at
the research snapshot), M5 base
applegpu_g17g, 32 GB, macOS 26.5.2 / build 25F84, reproduced on both mlx 0.31.2 and 0.32.0; M3 Max
clean. Max |Δ logprob| ≈ 0.031–0.039, argmax always matched.
1.5 float64: CPU only, and why¶
✅ VERIFIED, docs/src/python/data_types.rst, verbatim:
"Using float64 arrays on the GPU will result in an exception."
This is one of the very few MLX failures that does throw, which makes it pleasant by comparison with everything else in this guide. Apple GPUs do not implement double precision; MLX does not emulate it.
The practical use for float64 is as a reference. When you need to know whether a float32
result is right, compute the same thing in float64 on the CPU stream and measure the relative
error. That is exactly the methodology behind the numbers in §3.4.
import mlx.core as mx
def fp64_reference_matmul(a32, b32):
"""Ground-truth matmul, CPU stream, float64. Slow; for testing only."""
with mx.stream(mx.cpu):
a64 = a32.astype(mx.float64)
b64 = b32.astype(mx.float64)
return (a64 @ b64)
def rel_frobenius_error(approx, exact64):
with mx.stream(mx.cpu):
d = (approx.astype(mx.float64) - exact64)
return (mx.sqrt((d * d).sum()) / mx.sqrt((exact64 * exact64).sum())).item()
🟡 RECONSTRUCTED — the with mx.stream(mx.cpu): context manager is ✅ VERIFIED as
mx.StreamContext / mx.stream (python/src/stream.cpp), and float64-on-CPU is ✅ VERIFIED. The
composition above is idiomatic but was not copied from a source file; test it on a small case before
trusting it as a harness.
1.6 The integer family¶
Eight integer types (int8…int64, uint8…uint64) plus bool_. Things worth knowing:
Indexing is not bounds-checked. ✅ VERIFIED, docs/src/usage/indexing.rst, verbatim:
"Indexing does not perform bounds checking. Indexing out of bounds is undefined behavior."
with the stated reason: "exceptions cannot propagate from the GPU. Performing bounds checking for
array indices before launching the kernel would be extremely inefficient." An out-of-range int32
index does not raise; it reads whatever memory the offset lands on. This is the integer-type failure
mode you are most likely to hit in practice.
Duplicate-index scatter is nondeterministic. ✅ VERIFIED, docs/src/usage/indexing.rst:
a[[0, 0]] = mx.array([4, 5]) may leave either 4 or 5. Use the .at API instead, which correctly
applies all updates:
a = mx.array([0, 0]); idx = mx.array([0, 1, 0, 1])
a[idx] += 1 # -> array([1, 1], dtype=int32) WRONG if you meant "count"
a = mx.array([0, 0])
a = a.at[idx].add(1) # -> array([2, 2], dtype=int32) correct
✅ VERIFIED verbatim from python/src/array.cpp:430's docstring: "Regular in-place updates map to
assignment. For instance x[idx] += y maps to x[idx] = x[idx] + y. As a result, assigning to the
same index ignores all but one update." The .at methods are add, subtract, multiply, divide,
maximum, minimum.
mx.empty is mx.zeros. ✅ VERIFIED, python/src/ops.cpp Array-API alias block:
mx.empty = mx.zeros, mx.empty_like = mx.zeros_like. Unlike NumPy, MLX's empty allocates
zeroed memory. If you were relying on empty for a cheap uninitialised buffer, you are paying for
a fill. (In a custom Metal kernel, output arrays are a different story — see §9.4.)
Bit manipulation is available: mx.bitwise_and / _or / _xor / _invert, mx.left_shift,
mx.right_shift, with Array-API aliases mx.bitwise_left_shift / mx.bitwise_right_shift
(✅ VERIFIED, docs/src/python/ops.rst + the alias block).
uint32 is the packing type for quantized weights. MLX's affine quantization packs b-bit
values into unsigned 32-bit integers from the low bits upward — for 4-bit, eight elements per
uint32 with the first in bits 0–3 (✅ VERIFIED, python/src/ops.cpp:4649-4660 quantize
docstring). mx.dequantize will reject anything else: "[dequantize] The matrix should be given as a
uint32". If you ever inspect a quantized checkpoint and wonder why the last dimension is 1/8 of what
you expected, this is why.
1.7 Complex support: exactly one dtype¶
complex64 — two float32s, 8 bytes — is the entire complex story. There is no complex128
(which follows from §1.5: no float64 on the GPU).
What works, ✅ VERIFIED from docs/src/python/ops.rst and mlx/compile.cpp:77:
mx.real,mx.imag,mx.conj/mx.conjugate;array.real/array.imag/array.conjas methods.- The whole
mx.fftmodule (fft, ifft, fft2, ifft2, fftn, ifftn, rfft, irfft, rfft2, irfft2, rfftn, irfftn, fftfreq, rfftfreq, fftshift, ifftshift) — this is the main reasoncomplex64exists. Conjugate,RealandImagare inmx.compile's fusable unary primitive list, so complex element-wise chains do fuse.mx.linalg.eig/eigvals(non-symmetric eigendecomposition) are present in 0.32'slinalgsurface, which is newer than many MLX releases.
Two cautions:
⚠️ Complex is excluded from the NAX matmul path outright. The Metal gate reads
!issubdtype(a.dtype(), complexfloating)— ✅ VERIFIED,mlx/backend/metal/matmul.cpp:916-918. So acomplex64matmul on M5 does not get the neural accelerator. On CUDA the situation is the opposite:dtype_to_compute_type()inmlx/backend/cuda/gemms/cublas_gemm.cppselectsCUBLAS_COMPUTE_32F_FAST_TF32forfloat32andcomplex64— so on NVIDIA hardware your complex matmul is TF32-reduced by default and on Apple silicon it is not. Source: mlx#3860, contributor reading source, corroborated by the same file path innotes/repos/mlx-core.md.⚠️ Complex autodiff was recently buggy. Commit
af55406is titled "Fix complex vjps for several unary ops (#3766)". UNVERIFIED which ops and which MLX version — the shallow clone cannot date it, and the PR body was not read. Safe default: if you differentiate through complex unary ops, pin an MLX version, write a finite-difference check, and re-run it after every upgrade.
1.8 fp8 and fp4 are ops and storage formats, not dtypes¶
This trips people up constantly, because MLX's quantization surface mentions e8m0, e4m3 and
e2m1, and it is natural to assume those are dtypes you can create arrays in. They are not.
✅ VERIFIED — the quantization mode table, quoted verbatim from python/src/ops.cpp:4649-4660
(the quantize docstring); * marks the default when unspecified:
| mode | group size | bits | scale type | bias |
|---|---|---|---|---|
affine |
32, 64*, 128 | 2, 3, 4*, 5, 6, 8 | same as input | yes |
mxfp4 |
32* | 4* | e8m0 |
no |
mxfp8 |
32* | 8* | e8m0 |
no |
nvfp4 |
16* | 4* | e4m3 |
no |
Those scale types are encodings inside a uint8 buffer, decoded in software. ✅ VERIFIED from
notes/repos/mlx-tensorops-kernels.md §10: fp8_e8m0, fp8_e4m3 and fp4_e2m1 are plain structs
with hand-written bit manipulation in MLX's own mlx/backend/metal/kernels/fp8.h and fp4.h,
loaded from a uint8_t by reinterpret-cast:
// fp_quantized_nax.h:31-38 — MLX's own software scale decode
template <typename T, int group_size>
static inline T dequantize_scale(uint8_t s) {
if constexpr (group_size == 16) {
return T(*(thread fp8_e4m3*)(&s)); // nv scale
} else {
return T(*(thread fp8_e8m0*)(&s));
}
}
At the pinned MLX revision, these are software codecs rather than MLX dtypes. The negative
header search behind the earlier wording was performed against Xcode 26.6. Xcode 27 now documents
native MTLTensorDataType cases for int2, FP4, FP8, and E8M0 plus auxiliary scale planes; that does
not change how this MLX snapshot implements its own to_fp8, from_fp8, MX, and NV paths.2
The two ops that do exist:
def to_fp8(x: array, *, stream=None) -> array # -> uint8, E4M3 encoded
def from_fp8(x: array, dtype: Dtype = bfloat16, *, stream=None) -> array
✅ VERIFIED — exact nb::sig strings from python/src/ops.cpp. Note the asymmetry: to_fp8
produces a uint8 array (the bit pattern), and from_fp8 defaults its output to bfloat16,
not float32. If you round-trip without passing dtype=, you silently change precision class.
1.9 Reinterpreting bits: mx.view vs astype¶
Two operations that look similar and are completely different:
def astype(dtype, stream=None) -> array # CONVERT the value
def view(a, dtype: Dtype, stream=None) # REINTERPRET the bits
✅ VERIFIED, python/src/ops.cpp, view docstring, verbatim:
"the view op does not imply that the input and output arrays share their underlying data. The view only guarantees that the binary representation of each element (or group of elements) is the same."
So mx.view is a bit-cast, not a NumPy-style memory view — MLX may copy. The output shape
changes along the last axis when item sizes differ. Use it to inspect float bit patterns:
import mlx.core as mx
x = mx.array([1.0, -0.0, float("inf")], dtype=mx.float32)
bits = mx.view(x, mx.uint32)
print([hex(int(b)) for b in bits]) # forces evaluation
Related trap in the same family: slicing copies in MLX, unlike NumPy. b = a[:] then b[2] = 0
leaves a unchanged; but plain aliasing b = a does share (✅ VERIFIED,
docs/src/usage/indexing.rst). And mx.as_strided carries a documented warning that it
"can lead to the resulting array pointing to invalid memory locations which can result into
crashes."
Finally, the NumPy bridge and bfloat16:
⚠️ NumPy has no
bfloat16. Converting abfloat16MLX array withnp.array(a)raisesItem size 2 for PEP 3118 buffer format string does not match the dtype V item size 0.✅ VERIFIED,docs/src/usage/numpy.rst. Cast first:np.array(a.astype(mx.float32)). In the other direction, NumPyfloat64silently becomes MLXfloat32— which is usually what you want, and is occasionally the reason your "reference" implementation is not one.
2. Choosing a dtype in practice¶
A decision table, with the reason attached to each row. Everything here follows from §1 plus the gating in §3–§5.
| You are… | Use | Because |
|---|---|---|
| Running a transformer for inference | bfloat16 |
Matches how checkpoints ship; full float32 exponent range; the fused SDPA kernel and the NAX GEMM both accept it |
| Training / fine-tuning a transformer | bfloat16 activations, float32 optimizer state |
Gradient magnitudes span decades; float16 flushes the small tail to zero |
| Writing a numerical reference to diff against | float32, plus a float64 CPU-stream oracle (§1.5) |
float64 is the only thing on the machine that is unambiguously right |
| Doing signal processing / FFTs | float32 → complex64 |
Only complex dtype; float64 is CPU-only and the FFT kernels are GPU |
| Storing image data in [0, 1] | float16 |
Bounded range, and 10 mantissa bits beats bfloat16's 7 |
| Accumulating a long sum | float32 (or float64 on CPU) |
2-byte accumulators stall |
| Indexing / gather indices | int32 (the default) |
Matches MLX's default; int64 costs bandwidth and buys nothing under 2^31 elements |
| Shipping a memory-constrained model | quantized weights (Part 12's quantization guide) + bfloat16 activations |
The quantized kernels dequantise to bfloat/half anyway |
| Needing bit-identical results across two Macs | you cannot have this | §1.3, §3.4, §4 — kernel selection is hardware-dependent |
Two rules that are worth internalising because they cut across all of the above:
Rule 1 — the dtype of the accumulator is not the dtype of the operands. MLX's kernels
generally accumulate in a wider type than they load. The fused SDPA does its softmax in float32
(✅ VERIFIED, §1.4). MLX's quantized NAX kernels dequantise into bfloat threadgroup memory before
the matmul (✅ VERIFIED, fp_quantized_nax.h:198-204). Asking "what dtype is my model" is not the
same as asking "what precision is my model computed at", and §3 is where that distinction becomes
expensive.
Rule 2 — a dtype change can change which kernel runs. This is not a hypothetical. The NAX gate is literally written as a dtype test:
bool use_nax = metal::is_nax_available() &&
!issubdtype(a.dtype(), complexfloating) &&
(env::enable_tf32() || a.dtype() != float32);
✅ VERIFIED — mlx/backend/metal/matmul.cpp:916-918, quoted verbatim. Casting a matmul's inputs
from float32 to bfloat16 does not just halve the bytes; on gen-17 hardware with
MLX_ENABLE_TF32=0 it moves you from a non-NAX kernel to a NAX kernel. Benchmarks that vary dtype
are therefore also varying algorithm, and you should say so when you publish them.
3. TF32 and the hardware gate — one feature, two halves¶
This is the section to read if you read nothing else.
There is a feature in MLX 0.32 with no name in the API, no runtime query, no log line and no
documentation page, which decides whether your float32 matrix multiplication is computed at
float32 precision. It is implemented in two places that never mention each other: a hardcoded
true inside a Metal kernel, and an environment variable read on the host. Understanding either
half alone produces a wrong mental model. Here are both.
3.1 Half one: relaxed_precision = true, hardcoded¶
MLX's entire contact surface with Apple's Metal Performance Primitives TensorOps is one function,
mma, written twice with mirrored operand shapes. Here is the descriptor it builds, quoted verbatim
from mlx/backend/metal/kernels/steel/gemm/nax.h (the mma function spans lines 387–456; the
descriptor construction is at lines ~401–409):
constexpr auto desc = mpp::tensor_ops::matmul2d_descriptor(
16, // m
32, // n
16, // k
transpose_a, // transpose_left
transpose_b, // transpose_right
true, // relaxed_precision <-- THIS
mpp::tensor_ops::matmul2d_descriptor::mode::multiply_accumulate);
✅ VERIFIED — notes/repos/mlx-tensorops-kernels.md §9, quoting
mlx/backend/metal/kernels/steel/gemm/nax.h. The twin file
mlx/backend/metal/kernels/steel/attn/nax.h is byte-identical (verified by diff), so the
attention path carries the same descriptor.
What does that sixth argument mean? Apple's header says so in its own words. From
MPPTensorOpsMatMul2d.h:98-99 (shipped in the Xcode 26.6 SDK, Build 17F113), with Apple's own typo
preserved:
// false); // relaxed_precision = false, set it to true to allow implementation
// // to sacrifice accurancy for performance.
✅ VERIFIED — quoted from the SDK header at
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/MetalPerformancePrimitives.framework/Headers/MPPTensorOpsMatMul2d.h.
Note the default in Apple's declaration is false (argument 6 of 7, and the matmul2d_descriptor
constructor takes all seven positionally — there are no named parameters). MLX passes true
unconditionally. There is no #if, no template parameter, no runtime branch. Every operation
that reaches MLX's NAX GEMM or NAX attention kernel is computed with the implementation permitted to
sacrifice accuracy for performance.
That is half one. Taken alone it reads as "MLX always uses reduced precision on M5", which is not quite true, because of half two.
3.2 Half two: the host gates float32 on MLX_ENABLE_TF32¶
The host side compensates. mlx/utils.h:195-197, verbatim:
inline bool enable_tf32() {
static bool enable_tf32_ = get_var("MLX_ENABLE_TF32", 1);
return enable_tf32_;
}
✅ VERIFIED. Three things to read out of those three lines:
- The default is
1— on. static— the environment variable is read once, lazily, on the first call. §3.5 is about what that costs you.- It returns a plain
boolwith no way to observe it from Python.
And the gate it feeds, mlx/backend/metal/matmul.cpp:916-918:
bool use_nax = metal::is_nax_available() &&
!issubdtype(a.dtype(), complexfloating) &&
(env::enable_tf32() || a.dtype() != float32);
✅ VERIFIED, with the identical pattern at matmul.cpp:2858-2859 and at
quantized.cpp:787-788, :982-983, :1327-1328 — e.g.:
if (metal::is_nax_available() && transpose && (K % 64 == 0) &&
(env::enable_tf32() || x.dtype() != float32)) {
Read the third clause as: "use NAX unless the input is float32 and the user has disabled TF32."
So the two halves compose into this:
MLX_ENABLE_TF32=1 (default) MLX_ENABLE_TF32=0
float32, gen ≥ 17 NAX kernel, relaxed_precision non-NAX kernel, IEEE fp32
float32, gen < 17 non-NAX kernel, IEEE fp32 non-NAX kernel, IEEE fp32
bf16/fp16, gen ≥ 17 NAX kernel NAX kernel ← flag has NO effect
complex64 never NAX never NAX
The bottom two rows are the ones people get wrong. MLX_ENABLE_TF32=0 does nothing for
bfloat16 or float16 — the gate's third clause is satisfied by dtype != float32 regardless of
the flag, so half-precision work keeps running through the relaxed_precision = true kernel. And on
pre-gen-17 hardware the flag is completely inert, because is_nax_available() short-circuits
the whole conjunction. A contributor reading the source put it exactly this way on mlx#3860:
"on Metal every TF32 gate is
is_nax_available() && (env::enable_tf32() || dtype != float32)— the steel and gather GEMM paths inmatmul.cpp,quantized.cpp, and the SDPA gate — andis_nax_available()requires macOS ≥ 26.2 andarch_gen >= 17(18 on'p'parts). So the flag is inert before gen-17 on Metal. On CUDA it isn't gated at all."
Community-attributed (issue thread, contributor katlun-lgtm, 2026-07, quoted in
notes/repos/issues-mlx-stack.md §3.1), consistent with the source quoted above.
Scope summary to memorise: CUDA → always; Metal → gen-17 (M5 / A19 class) and up, on macOS ≥ 26.2.
3.3 ⚠️ SILENT FAILURE: precision you did not choose, with no runtime signal¶
⚠️ SILENT FAILURE — fp32 storage can conceal reduced matmul precision. On the documented M5/macOS ≥26.2 path, fp32 matmul defaults to relaxed internal precision while retaining fp32 output dtype. The cited measurements show roughly three orders of magnitude worse relative error against fp64; no array flag or warning reports the change. For numerical tests, set
MLX_ENABLE_TF32=0before importing MLX, as its own test harness does. For production speed, measure tolerances on the actual hardware; dtype alone does not establish full fp32 accumulation. Documentation PR #3894 and closure of #3860 concern documentation/policy, not demonstrated removal of relaxed precision; the warning proposal #3883 closed unmerged.
3.4 What it measures out at¶
All of the following are community-measured, from the mlx#3860 thread (closed as completed
2026-08-04, 9 comments), by pierre427 and mabaeyens. Not Apple figures. Attribution is per row.
Metal — plain float32 512×512×512 matmul, GPU versus CPU, expressed as the error exponent:
| Device | Architecture string | Native (default) | MLX_ENABLE_TF32=0 |
Forced MLX_METAL_GPU_ARCH=applegpu_g16s |
|---|---|---|---|---|
| M5 base | applegpu_g17g |
2^-10.4 | 2^-19.8 | 2^-19.8 |
| M5 Max | applegpu_g17s |
2^-10.4 | 2^-20.9 | 2^-20.9 |
| M3 Max | applegpu_g15s |
2^-21.7 | 2^-21.7 (bit-identical, max|Δ| = 0) | — |
Read the M5 rows: the default is losing roughly nine to ten bits of mantissa relative to the same machine with the flag off. The M3 row is the control — the flag is inert there, and the two columns are bit-identical, which is exactly what §3.2's table predicts.
CUDA — relative Frobenius error, 512×512 float32 GEMM against a float64 reference:
| Backend | Relative error |
|---|---|
NumPy float32 |
2.9e-07 |
| MLX CPU stream | 4.1e-07 |
| MLX Metal, M-series pre-gen-17 | fp32-class |
MLX CUDA, default (MLX_ENABLE_TF32=1) |
2.9e-04 |
MLX CUDA + MLX_ENABLE_TF32=0 |
2.1e-07 |
MLX CUDA + NVIDIA_TF32_OVERRIDE=0 |
2.1e-07 |
A ~1000× error increase, on by default, on both backends, with different gating rules on each. Note
the last row: NVIDIA's own override variable also works on the CUDA backend, which is a useful
independent confirmation that MLX is genuinely selecting CUBLAS_COMPUTE_32F_FAST_TF32 and not
doing something of its own.
3.5 Three mechanics that cost people days¶
These are the parts that make bisection hard. All three are community-established on mlx#3860 and consistent with the source quoted in §3.1–§3.2.
(a) It is shape-dependent. Matvec shapes (M == 1 or N == 1) do not take the NAX route and
stay exact float32. So the same dtype, the same operator, the same machine gives different
precision depending on operand shape. The concrete symptom reported: mlx-lm's test_ssm passes
on the gemv-shaped output comparison and fails on the outer-product state comparison, in the same
test.
(b) It is first-use latched. enable_tf32() uses a function-level static, so the environment
variable is read exactly once, on the first call.
# WORKS — set before anything touches a matmul
import os
os.environ["MLX_ENABLE_TF32"] = "0"
import mlx.core as mx
# ...
# SILENTLY DOES NOTHING — the flag was already latched
import mlx.core as mx
_ = mx.random.normal((512, 512)) @ mx.random.normal((512, 512))
mx.eval(_)
os.environ["MLX_ENABLE_TF32"] = "0" # too late; no error, no effect
This is a real diagnostic hazard, not a theoretical one. On mlx#3702 — a report of corrupted audio on an A19 iPhone — MLX collaborator zcbenz wrote:
"They should have the same output whether correct or corrupted, it is possible that
MLX_ENABLE_TF32=0is set too late. But I think the neutral accelerator is not relevant here since with or without it the results are still corrupted. To know what is happening I think the only way is to isolate the op that is outputting wrong results on A19…"
If you are testing whether TF32 explains a discrepancy, set the variable in the shell, or as the
very first lines of the entry-point module, and prove it took effect by measuring §3.4-style error
rather than by trusting os.environ.
(c) It is not limited to things that look like matmuls. From the same thread:
"On gen-17 it also moves attention paths that compose from ordinary GEMMs rather than a fused kernel. We hit this at head_dim 96, which fails
sdpa_full_supported_head_dim({64, 80, 128}) and so never enters the fused path, yet still responds toMLX_ENABLE_TF32."
That sentence is the hinge between §3 and §5: an attention layer whose head dimension is outside the
fused allow-list falls back to matmul → softmax → matmul (§5.3), and those matmuls are then
subject to the TF32 gate. So the two silent failures in this guide compose.
3.6 The blast radius, downstream¶
Community-measured consequences, each attributed:
- Signal processing, CUDA sm_120 (mlx#3860): a fitting workload flipped near-tie
argmaxresults on 1.4–2.5 % of spectra, costing roughly 9 dB PSNR — "while every op-level parity test passed." That last clause is the lesson: op-level tolerance tests did not catch a decision-boundary failure.
mlx-lm/tests/test_generate.py(mlx#3860, mlx#3897): 8 of 28 tests fail on gen-17 —test_batch_matches_single,test_batch_sliding_window,test_batch_continued_generation*,test_stream_generate_input_embeddings*— all of them batch-versus-single equivalence assertions on models pinned toset_dtype(mx.float32). All pass with the flag off.mlx-lm/tests/test_models.py::test_ssmfails out of the box on any M5 with mlx ≥ 0.32.- mlx-lm PR #1595 pins
MLX_ENABLE_TF32=0intests/test_models.py— but not intest_generate.py. - mlx-swift-lm #357 (closed as completed 2026-07-31; re-checked 2026-08-01) — "[BUG] tests
fail due to TF32", the Swift-side manifestation of the same thing. The closing diagnosis is
narrower than the original title: maintainers agreed that the failure is in the test oracle,
not the cache or chunking implementation. Float16 execution plus different-shaped matmuls moves
logits by roughly
2e-2; the proposed repair is deterministic initialization and a direct max-absolute-difference check while retaining the exact cache-offset assertions. Closure does not retract the TF32 numerical warning above. (Previously miscited here asmlx-swift#357, which is an unrelated merged PR; the TF32 issue lives inml-explore/mlx-swift-lm.)
And the methodological quote from mlx#3897 that deserves to be pinned above every benchmark you write:
"Your per-seed table shows the medians were hiding a 27-of-32 disagreement with a ten-seed tail up to 2^-13 … The median was the wrong statistic and I should not have leaned on it for a claim about a mechanism."
3.7 What to actually do¶
A short, opinionated policy:
- In tests:
MLX_ENABLE_TF32=0, set beforeimport mlx. Copy MLX's own harness. If you cannot control import order (pytest plugins, notebooks), set it inconftest.pyat module scope or in the shell, and assert it with a §4.5-style probe. - In production: leave it on, and stop asserting bit equality. A strict
rtol=1e-5batch-equivalence assertion cannot hold on gen-17, in any dtype (community conclusion,
mlx#3897). Decide what your product actually needs — usually "the argmax is stable and the
loss curve matches within noise", not "the bits match."
3. Never compare numbers across two different Macs and call the difference a regression without
first pinning MLX_ENABLE_TF32 on both and re-measuring.
4. If you need reference precision for one specific op, put that op on the CPU stream. MLX's
unified memory makes this cheap to try: with mx.stream(mx.cpu): .... The CPU stream measured
4.1e-07 relative error in §3.4's table — fp32-class, as expected.
5. Log the environment. A single line in your run banner — MLX version, architecture string,
MLX_ENABLE_TF32 as your process saw it — turns a week of bisection into a diff. §4.5's
mx.device_info() probe supplies the version and architecture fields.
4. NAX, the M5 neural accelerator, and how to tell whether you are on the fast path¶
4.1 What the hardware is, per Apple¶
Everything in this subsection is Apple-published, from Tech Talk 111432, "Accelerate your
machine learning workloads with the M5 and A19 GPUs", presented by Zak, manager of Apple's GPU
Driver Performance team. ⚠️ It is a Tech Talk, not a WWDC26 session — it is the M5 launch talk,
predates WWDC26, and is not listed in developer.apple.com/wwdc26/guides/machine-learning/, which is
why searching WWDC26 for it fails. It is what WWDC26 session 330 means by "the M5 machine learning
talk".
"Neural accelerators are dedicated hardware in M5 purpose built for matrix multiplication. They're built into each shader core right alongside the other GPU pipelines such as ALU, raytracing, and so on."
Two consequences Apple draws from that placement, both of which are real architectural arguments and both of which matter for how you write code:
- "This physical locality enables fast, seamless interoperation with code running on other GPU pipelines." — you can interleave matrix work and ALU work in one kernel with no handoff. This is the argument for doing matmul inside your shader instead of calling out to a framework.
- "Neural accelerator capacity scales directly with core count. So workloads that use them efficiently will scale well as you move up the M5 family."
Apple's own framework-stack picture, top to bottom, is worth reproducing because it tells you where MLX sits:
| Tier | Contents (verbatim) |
|---|---|
| Domain frameworks | MetalFX — "You get the benefit of neural accelerators automatically" |
| Host-side frameworks | Metal Performance Shaders, MPSGraph, Core ML |
| Training / research | MLX, llama.cpp, PyTorch — "already leverage neural accelerators under the hood" |
| Lowest level | Metal Performance Primitives and TensorOps — "direct access to neural accelerators from your metal shaders" |
So: using MLX at all puts you on the third tier, where neural accelerators are used on your behalf. §3 is the fine print on that sentence, and §7–§9 are about deliberately dropping to the fourth.
4.2 The version story, stated carefully¶
This is a place where a single confident number would be wrong, so here is the whole thing.
Apple's narrated feature ladder for Metal TensorOps, verbatim from Tech Talk 111432:
"We introduced TensorOps at [WWDC] 25 in the combined metal for machine learning and graphics session. … Since we introduced TensorOps, we've continued expanding the API in iOS and Mac OS 26. In 26.1, we added bfloat tensor support… In 26.3, we added support for cooperative tensors as inputs to matmul… And in 26.4, we added four bit and eight bit integer tensors, so quantized models can fully leverage neural accelerators."
| Version | Feature added |
|---|---|
| 26.0 (WWDC25 session 262) | TensorOps introduced |
| 26.1 | bfloat tensor support |
| 26.2 | (never mentioned in the talk) |
| 26.3 | cooperative tensors as inputs to matmul → custom in-kernel dequantisation |
| 26.4 | 4-bit and 8-bit integer tensors |
The shipped SDK header, meanwhile, says 26.2. ✅ VERIFIED, MPPTensorOpsAvailability.h:10:
#define __TENSOR_OPS_SUPPORT_DEPLOYMENT_TARGET_26_2 ((__ENVIRONMENT_OS_VERSION_MIN_REQUIRED__) >= 260200)
And MLX gates on 26.2 in two independent places — its CMake build gate and its runtime
__builtin_available check (§4.3), both ✅ VERIFIED.
How to hold all three at once:
- ✅ "Not 27" is settled decisively. Every capability described in the M5 talk lands in a 26 point release. Nothing about TensorOps or neural accelerators is gated on iOS 27 / macOS 27. Anything you read that says otherwise is wrong.
- ⚠️ Do not write a blanket "26.2". Apple's narrated ladder skips 26.2 entirely. The most defensible sentence is: TensorOps ships across the macOS/iOS 26 point releases — base at 26.0, bfloat at 26.1, cooperative-tensor matmul inputs at 26.3, int4/int8 tensors at 26.4 — and the shipped Xcode 26.6 SDK annotates the relevant symbols with a 26.2 deployment-target macro. Both facts are true; they are about different things (a feature ladder versus a symbol availability macro).
- ✅ For MLX specifically, 26.2 is the number that matters, because 26.2 is what MLX's own build and runtime gates test. That is the practical floor for the NAX path.
One more Apple claim worth carrying, because it removes an objection people raise:
"The API is portable. The same code runs across Apple's entire GPU family from M1 to M5. On older GPUs without neural accelerators, TensorOps falls back to optimized shader implementations."
TensorOps is not M5-only; it is a portable API with a hardware fast path. MLX, however, does not rely on that fallback — it ships a complete non-NAX twin of every NAX kernel and selects between them on the host (§4.7).
4.3 Three gates, all of which must pass¶
MLX gates the NAX path in three independent places.
Gate 1 — Apple's compile-time availability macro. Both public MPP headers are hard-gated on two
compiler feature macros, MPPTensorOpsMatMul2d.h:328:
✅ VERIFIED. If __HAVE_TENSOR__ is undefined the entire header expands to nothing — no error,
just an empty namespace and a confusing "no member named matmul2d" much later. Related macros seen
in the headers: __HAVE_BFLOAT__, __HAVE_INT4B_FORMAT_TYPE__, __HAVE_EXECUTION_UNIT__.
Gate 2 — MLX's CMake gate, mlx/backend/metal/kernels/CMakeLists.txt:158-182, ✅ VERIFIED
verbatim:
if(MLX_METAL_VERSION GREATER_EQUAL 400
AND MACOS_SDK_VERSION VERSION_GREATER_EQUAL 26.2
AND CMAKE_OSX_DEPLOYMENT_TARGET VERSION_GREATER_EQUAL 26.2)
build_kernel(steel/gemm/kernels/steel_gemm_fused_nax ${STEEL_NAX_HEADERS})
build_kernel(steel/gemm/kernels/steel_gemm_gather_nax ${STEEL_NAX_HEADERS})
build_kernel(steel/gemm/kernels/steel_gemm_splitk_nax ${STEEL_NAX_HEADERS})
build_kernel(steel/gemm/kernels/steel_gemm_segmented_nax ${STEEL_NAX_HEADERS})
build_kernel(quantized_nax quantized_nax.h ${STEEL_NAX_HEADERS})
build_kernel(fp_quantized_nax fp4.h fp8.h fp_quantized_nax.h ${STEEL_NAX_HEADERS})
build_kernel(steel/attn/kernels/steel_attention_nax ${STEEL_NAX_ATTN_HEADERS})
else()
message(WARNING "NAX kernels require Metal 4, macOS SDK >= 26.2, and "
"MACOSX_DEPLOYMENT_TARGET >= 26.2 (...). Building without NAX kernels.")
target_compile_definitions(mlx PRIVATE MLX_METAL_NO_NAX)
endif()
That list is also the complete inventory of what NAX covers: fused GEMM, gather GEMM, split-K GEMM, segmented GEMM, affine-quantized matmul, floating-point-quantized matmul, and attention.
⚠️ SILENT FAILURE — building MLX from source silently loses every NAX kernel. The deployment-target condition is the practical trap: a default macOS build often targets something older than 26.2, the
else()branch fires,MLX_METAL_NO_NAXis defined, and you get a CMake warning and a fully working MLX that is simply never fast on M5. Nothing at runtime tells you. This has cost enough people enough time that there are two merged upstream PRs about it — #3622 "NAX requires settingMACOSX_DEPLOYMENT_TARGET=26.2" and #3824 "Warn at configure time when NAX kernels are disabled". Safe default: if you build MLX yourself and care about M5 performance, setCMAKE_OSX_DEPLOYMENT_TARGET=26.2explicitly and read the configure output. If you install from PyPI wheels this does not apply to you.
Gate 3 — MLX's runtime check, mlx/backend/metal/device.cpp:944-963, ✅ VERIFIED verbatim:
bool is_nax_available() {
#ifdef MLX_METAL_NO_NAX
return false;
#else
auto _check_nax = []() {
bool can_use_nax = false;
if (__builtin_available(macOS 26.2, iOS 26.2, tvOS 26.2, visionOS 26.2, *)) {
can_use_nax = true;
}
auto& d = metal::device(mlx::core::Device::gpu);
auto arch = d.get_architecture().back();
auto gen = d.get_architecture_gen();
can_use_nax &= gen >= (arch == 'p' ? 18 : 17);
return can_use_nax;
};
static bool is_nax_available_ = _check_nax();
return is_nax_available_;
#endif
}
Two runtime conditions: OS ≥ 26.2, and GPU architecture generation ≥ 17 — or ≥ 18 if the
architecture-string suffix is 'p'. Note the static again: like enable_tf32(), this is computed
once per process.
4.4 There is no capability query — here is the heuristic MLX uses¶
This is the part readers hunt for and do not find, so it is worth stating plainly:
The M5 neural accelerator has no API. There is no
MTLDevice.supportsFamily(...)value for it, no feature flag, nomx.*predicate. MLX infers its presence from the GPU architecture generation number alone, exactly as in the code above. ✅ VERIFIED —notes/repos/mlx-tensorops-kernels.md§11.3 and §12 row 32; corroborated by the absence of any such symbol in the MPP headers.
The architecture string is a name like applegpu_g17s. MLX parses it as: two digits = generation,
final character = device class. MLX's own class table, mlx/backend/metal/device.cpp:592-627:
| Suffix | MLX's internal class | max_ops_per_buffer |
max_mb_per_buffer |
|---|---|---|---|
p |
phone | 20 | 40 |
g |
base / pro | 40 | 40 |
s |
max | 50 | 50 |
d |
ultra | 50 | 50 |
| other | default (medium) | 40 | 40 |
✅ VERIFIED as MLX's internal buffer-sizing table. But be careful with the marketing-name column. A community thread on mlx#3885 catalogued observed values and reached a different mapping:
| Architecture string | Device (community-observed) |
|---|---|
applegpu_g13s |
M1 Max |
applegpu_g15s |
M3 Max |
applegpu_g16s |
M4 Max and M4 Pro |
applegpu_g16g |
M4 iPad Pro |
applegpu_g17g |
M5 base |
applegpu_g17s |
M5 Max |
with the explicit conclusion that the suffix cannot distinguish Pro from Max (g16s covers both
M4 Pro and M4 Max), and that 'g' covers base chips and iPads/iPhones.
⚠️ The two tables disagree about what
'g'and's'mean in marketing terms. MLX's source comment saysg= "base / pro" ands= "max"; the community observation saysg16sis both M4 Pro and M4 Max. Both cannot be right. The MLX table is ✅ VERIFIED as what MLX uses for buffer sizing; the marketing mapping is 🟡 community-observed. Do not use the suffix to identify a product. Use it for exactly what MLX uses it for: the'p'test inis_nax_available(), and nothing else.
For A19 / phone-class parts, note the +1: NAX requires gen ≥ 18 when the suffix is 'p'.
MLX PR #3083 is titled as enabling the NAX matmul path for gen >= 18 phone architectures
(referenced from mlx#3702's thread; UNVERIFIED beyond that reference).
4.5 A probe you can run, and an A/B switch¶
Python does not expose is_nax_available() or get_architecture_gen(). What it does expose is
the architecture string, via mx.device_info():
import mlx.core as mx
info = mx.device_info()
print(info) # dict; keys include 'device_name', 'architecture', ...
print(info["architecture"]) # e.g. 'applegpu_g17s'
✅ VERIFIED that mx.device_info(d=None) exists and that its documented keys include
device_name (str), architecture (str) and total_memory / memory_size (size_t) — from the
comment on mlx/device.h and the binding in python/src/device.cpp. On Metal it additionally
exposes max_recommended_working_set_size, and mx.device_info()["resource_limit"] is used
elsewhere in the community to read MLX's live-buffer cap.
⚠️ Do not use
mx.metal.device_info(). The wholemx.metal.*memory/device family is deprecated and prints a deprecation notice to stderr on first call (✅ VERIFIED,python/src/metal.cpp:20-26); it forwards to the top-levelmx.*equivalents.mx.metal.is_available,start_captureandstop_captureare the survivors.
Reimplementing MLX's gate in Python:
import mlx.core as mx
def nax_likely_available() -> bool:
"""Mirror of MLX's C++ is_nax_available() gate, minus the OS check.
RECONSTRUCTED: MLX does not expose is_nax_available() to Python. This
reproduces the architecture-generation half of the C++ logic at
mlx/backend/metal/device.cpp:944-963. It CANNOT see the __builtin_available
OS >= 26.2 check, nor whether MLX was built with MLX_METAL_NO_NAX.
Treat a True as "the hardware qualifies", not as "NAX is running".
"""
if not mx.metal.is_available():
return False
arch = mx.device_info()["architecture"] # e.g. 'applegpu_g17s'
suffix = arch[-1]
digits = "".join(ch for ch in arch if ch.isdigit())
if len(digits) < 2:
return False
gen = int(digits[-2:])
return gen >= (18 if suffix == "p" else 17)
🟡 RECONSTRUCTED. The gate logic and the 'p'/18 special case are ✅ VERIFIED from
device.cpp:944-963. The string parsing — taking the last two digits of the architecture name as
the generation — is inferred from MLX's own description of arch_gen_ being "parsed from the two
digits before the [class] suffix" (device.cpp:592-627). It has not been executed against a real
applegpu_* string in this session. Safe default: print the raw string as well as the verdict,
and never branch on this in shipping code — branch on measured behaviour instead.
The A/B switch. MLX_METAL_GPU_ARCH overrides the architecture string MLX reports
(✅ VERIFIED, notes/repos/mlx-core.md §19 environment table). Community researchers used exactly
this to isolate NAX effects, forcing an M5 to present as applegpu_g16s — a generation-16 string —
which fails the gen >= 17 test and takes the whole non-NAX path:
# Force the non-NAX path on an M5 without touching your code
MLX_METAL_GPU_ARCH=applegpu_g16s python my_script.py
# Compare with the fp32-precision lever, which only moves float32
MLX_ENABLE_TF32=0 python my_script.py
This pair is the single most useful diagnostic in this guide. Their difference is diagnostic:
- If
MLX_ENABLE_TF32=0fixes it → it is the fp32 TF32 path (§3). - If only
MLX_METAL_GPU_ARCH=applegpu_g16sfixes it → it is a NAX kernel behaviour affectingbfloat16/float16, which the TF32 flag cannot reach (§3.2's bottom rows). This is exactly how
mlx#3897 separated its two mechanisms.
- If neither fixes it → it is not NAX. On mlx#3702, neither did, and even forcing
can_use_nax = false in the backend left the output corrupted — which is what redirected that
investigation toward a shape-dependent GEMM dispatch bug instead.
4.6 Apple's own numbers, with their baselines¶
Apple-published, Tech Talk 111432. ⚠️ The baselines differ per claim, and a number without its baseline is meaningless — carry them.
| Claim | Baseline | Verbatim |
|---|---|---|
| Image generation ~4× | vs M4 | "on the new iPad Pro with M5, AI image generation, apps like Draw Things can produce images up to four times faster over M4" |
| Video enhancement 7.7× | vs M1 | "on the new 14 inch MacBook Pro with M5, AI video enhancement and Topaz video is up to 7.7 times faster than on M1" |
| Time to first token up to 4× | context is M4→M5 | "Time to first token is up to four times faster and token generation is up to 25% faster" |
| Matmul 4–8× | unstated | "Matrix multiplication operations, often called gems, are up to 4 to 8 times faster, depending on precision" |
The attribution of where the gain comes from is the genuinely useful part:
"the time to first token performance in the prefill phase is up to four times faster, thanks to the neural accelerators, and in the decode phase, the increased memory bandwidth and larger GPU caches in M5 speed up token generation by up to 25%."
That maps onto the compute-bound / bandwidth-bound split Apple sets up earlier in the talk: prefill is large-matrix, compute-bound, and scales with math rates; decode is tall-skinny, bandwidth-bound, and scales with how fast you move data. The neural accelerator helps prefill. It does not help decode. If your workload is decode-dominated, gen-17 buys you the memory subsystem, not the matrix hardware — and §5's attention discussion becomes the thing that matters.
The headline benchmark, and the most citable number in the talk. Workload: one 4K × 4K matrix multiplication, three implementations, same hardware:
| Variant | Implementation | Wall time (Metal System Trace) | Neural-accelerator utilisation |
|---|---|---|---|
| v1 | classic SIMD-group matrix API | "over two seconds" | 0 % |
| v2 | TensorOps | "over just a half second" | "well above 50 %" |
| v3 | TensorOps + Morton-ordered threadgroup dispatch | "around a third of a second" | "close to 100 %" |
"It's the same 4K by 4K matrix multiplication running on the exact same hardware. The difference is almost seven times faster execution just by understanding how to use and feed neural accelerators efficiently."
⚠️ The 0 % figure is the headline. Apple's gloss: "All of this compute work is happening on the
ALU, which means the dedicated matrix hardware is sitting completely idle." That converts
"you should migrate off simdgroup_matrix" from advice into arithmetic — and it pairs with Apple's
direct instruction in the same talk: "if you're already writing your own custom kernels in metal
using SIMD Group matrix API, you should move your workloads over to adopt TensorOps instead."
Note also the diagnosis of v2, which is why v3 exists: "the utilization percentage tells us that the neural accelerators could be doing more. They're waiting for data." v2 was data-starved, not compute-limited. Traversal order fixed the feeding, not the math. If you take one design lesson from this section into §10, it is that one.
⚠️ Every figure in this subsection is Apple-published, hardware and OS unstated beyond "M5", from a launch talk. No independent reproduction of the three-variant benchmark exists in our corpus.
4.7 NAX changes the algorithm, not just the kernel¶
A structural point that surprises people: NAX is not a drop-in accelerator. MLX ships a complete
non-NAX twin of every NAX kernel and picks between them on the host —
steel_matmul_regular_axpby_nax versus steel_matmul_regular_axpby, steel_gemm_splitk_axpby_nax,
gather_mm_rhs_nax, qmm_nax, gather_qmm_nax, gather_qmm_rhs_nax, and a segmented path that
merely appends "nax_" to the kernel name (✅ VERIFIED, matmul.cpp:984-985, :2883).
But look at this inversion, matmul.cpp:924:
✅ VERIFIED. The old split-K path is taken only when NAX is absent; NAX has its own split-K at
matmul.cpp:947. So enabling NAX does not merely swap the inner loop — it changes which algorithm
is selected. Two consequences:
- Numerical differences between a gen-16 and a gen-17 machine are not only "relaxed precision"; they
can also be "a different reduction order in a different split-K decomposition". Do not assume
MLX_ENABLE_TF32=0restores gen-16 behaviour exactly. (In §3.4's Metal table it did restore the error magnitude, but that is not the same as bit equality across machines.) - The non-NAX kernels use
simdgroup_matrix(<metal_simdgroup_matrix>) — which is precisely the API Apple's 4K×4K benchmark measured at 0 % neural-accelerator utilisation. On a gen-17 machine with NAX disabled you are running the v1-class path.
The threshold in that line is itself architecture-dependent, matmul.cpp:919-920:
char devc = d.get_architecture().back();
int min_tmn_threshold = (devc == 's' || devc == 'd') ? 2048 : 1024;
✅ VERIFIED. Another reminder that MLX's dispatch reads the architecture string in several places for several unrelated reasons.
5. ⚠️ The silent SDPA fallback¶
If §3 is the numerics footgun, this is the performance footgun — and it is the canonical MLX one. A
single call, mx.fast.scaled_dot_product_attention, either runs a hand-written fused kernel or
silently decomposes into three ordinary ops that allocate an enormous transient. Same API, same
result, order-of-magnitude different memory and time.
5.1 The API, and the four notes that matter¶
✅ VERIFIED — signature from python/src/fast.cpp, prose from docs/src/python/fast.rst:
def scaled_dot_product_attention(
q: array, k: array, v: array, *,
scale: float,
mask: Union[None, str, array] = None,
sinks: Optional[array] = None,
stream=None,
) -> array
with the C++ form (mlx/fast.h) carrying the mask as two separate parameters:
array scaled_dot_product_attention(
const array& queries, const array& keys, const array& values,
const float scale,
const std::string& mask_mode = "",
std::optional<array> mask_arr = {},
const std::optional<array>& sinks = {},
StreamOrDevice s = {});
Shapes: q is [B, N_q, T_q, D], k and v are [B, N_kv, T_kv, D].
Four documented notes, each of which is a mistake people make:
- The softmax is always
float32. Verbatim: "The softmax operation is performed infloat32regardless of the input precision." You do not need to upcast for numerical safety, and doing so costs you (§5.2: the dtype is not itself a gate, but the extra copy is real). - Do not pre-tile
kandvfor GQA/MQA. Verbatim: "For Grouped Query Attention and Multi-Query Attention, thekandvinputs should not be pre-tiled to matchq." If youmx.repeatyour KV heads up toN_qbefore calling — as most hand-rolled attention implementations do — you have quadrupled your KV bandwidth and changed the shape enough to affect kernel selection.n_headsmust be a multiple ofn_kv_heads. maskaccepts exactly one string:"causal". Anything else raises"[scaled_dot_product_attention] invalid mask option '<x>'. Must be 'causal', or an array."✅ VERIFIED. Array masks may be boolean or additive, at most 4 dimensions, broadcast-compatible with[B, N, T_q, T_kv]; an additive mask's dtype must promote toresult_type(q, k, v)."causal"uses lower-right alignment — "the last query aligns with the last key." If you are used to upper-left alignment this silently changes which positions are masked whenT_q != T_kv, which is every decode step with a KV cache.
sinks is an optional attention-sink array (shape-validated). ⚠️ Community-reported interaction:
gpt-oss with a quantized KV cache produces a silent client timeout because attention sinks are
unsupported on that path (notes/repos/issues-mlx-stack.md §12 item 20; mlx-lm, community-reported).
5.2 The complete fallback table¶
✅ VERIFIED — ScaledDotProductAttention::use_fallback in
mlx/backend/metal/scaled_dot_product_attention.cpp:593-644, read on disk. Independently
corroborated by mlx#3885, which quotes the routing at L621–633 of commit 0c537a41, and which notes
that the same head-dimension sets appear as far back as v0.29.3 (L395–400).
MLX takes the unfused path when ANY of these is true:
| Condition | Meaning |
|---|---|
is_training |
you are inside gradient tracing |
output_logsumexp |
logsumexp output was requested |
s.device == Device::cpu |
you are on the CPU stream |
| head dims unsupported | see the two tables below |
Vector kernel — the decode path, T_q <= 8:
| Requirement | Value |
|---|---|
D_q == D_v and in |
{64, 96, 128, 256} |
| …or the asymmetric combo | (D_q=192, D_v=128) |
| also | T_q <= T_kv |
| also | T_q * gqa_factor <= 32 |
Full kernel — the prefill path, T_q > 8:
| Requirement | Value |
|---|---|
D_q == D_v and in |
{64, 80, 128} |
| mask | absent, an array, or "causal" with T_q <= T_kv |
Read those two allow-lists side by side and note what is not in them.
- No 512 in either. Gemma 4's global layers are
head_dim = 512— "at prefill no layer of this model fuses at all" (mlx#3885). - No 192 or 256 in the full/prefill kernel — so a
d = 256model fuses at decode and falls back at prefill. Tracked in mlx#3658 and PRs #3293 / #3660. - No 96 in the full kernel, though 96 is in the vector kernel. Same model, fused at decode, unfused at prefill.
- No 72, which is Qwen3-VL's vision tower (1152 / 16 heads).
- No 80 in the vector kernel, though 80 is in the full kernel. The two lists are not nested.
⚠️ The
is_trainingrow is the one that surprises MLX users most. The comment in MLX's source is explicit: "It's faster for training on Metal to use the unfused SDPA for both forward and backward." ✅ VERIFIED. So the fused SDPA kernel is inference-only on Metal. Every attention benchmark you have seen quoted for MLX training is measuring the unfused path, by design. If you are profiling a fine-tune and wondering why attention looks slow relative to inference numbers, this is why, and it is not a bug.
5.3 What the fallback actually does¶
It composes matmul → softmax → matmul from ordinary ops — and, critically, materialises the full
score tensor:
which for float32 accumulation is approximately
per full-attention layer, per prefill chunk (community-derived, mlx#3658). That is the entire cost model, and it explains every number in §5.4. The fused kernel never forms this tensor; the unfused path forms all of it, at once, in GPU memory.
Two consequences that look like completely unrelated bugs:
- Out-of-memory during long-context prefill. At
kv_len = 130K, "even a 32-token chunk allocates ~665 MB for scores alone" (mlx#3658). A memory guard that responds by shrinking the chunk size makes it worse per token, not better. - A "long-context decode collapse" that is really an allocator problem — see §5.7.
And the honest statement from the issue thread about visibility:
"today the only ways to notice the composite path are a Metal System Trace or reading
use_fallback."
An MLX_FAST_LOG_FALLBACK=1 environment variable and a queryable
mx.fast.sdpa_is_fused(q_shape, k_shape, ...) predicate were both requested and have not landed
(mlx#3885). Until one does, §5.5 is what you have.
⚠️ SILENT FAILURE — fused attention silently becomes unfused.
What happens.
mx.fast.scaled_dot_product_attentionreturns the mathematically correct answer viamatmul → softmax → matmul, materialising a[B, n_kv, n_rep, qL, kL]score tensor.What you see. Nothing. No warning, no exception, no attribute on the output. The only symptoms are throughput and peak memory.
How you find out. Your model is unexpectedly slow at prefill; or peak memory scales with
qL × kLinstead of with the KV cache; or you OOM at a context length that arithmetic said should fit; or the model runs fine athead_dim=128and badly athead_dim=96.Safe default. Assert your head dimension against the allow-lists in §5.2 at model-load time and either pad (§5.6) or log loudly. Do not wait for a profiler.
5.4 What it costs, measured¶
All community-measured, from the issue threads, with hardware stated per block. None of these are Apple figures.
Decode, head_dim = 512 versus a fused shape. M4 Max 128 GB, bfloat16, qL = 1, additive
mask (mlx#3885):
| Shape | seqK = 1024 | seqK = 4096 |
|---|---|---|
d=512, 32 q / 4 kv (fallback) |
168 µs | 260 µs |
d=256, 32 q / 8 kv (same KV bytes, vector kernel) |
146 µs | 184 µs |
At decode, the penalty is modest — 15 % at 1K, 41 % at 4K. Prefill is where it becomes structural.
Same issue: at L = 4096, a single d = 512 global layer runs 84 ms and materialises ~1 GB of
transient scores.
Prefill peak memory, fused versus unfused. M4 Max 36 GB, n_q = 16 / n_kv = 8 GQA, d = 256,
float16, measured on a rebased PR #3293 branch (mlx#3658). "Peak" includes inputs:
| qL | kL | inputs | fused peak | unfused peak | max abs err |
|---|---|---|---|---|---|
| 1024 | 32768 | 264 M | 272 M | 1,824 M | 0.0 |
| 2048 | 65536 | 528 M | 544 M | 5,696 M | 0.0 |
| 2048 | 131072 | 1,040 M | 1,056 M | 11,328 M | 0.0 |
Note the max abs err column: 0.0. The fallback is not less accurate. It is a 10× memory
multiplier that returns identical numbers, which is precisely why nobody notices it.
And an honesty note that the thread itself makes, which you should carry when you argue for
fusing: above the kL > 16384 routing threshold the fused kernel measured 0.74–0.79× of unfused on
M4 Max — i.e. slower when the transient happens to fit. The value of fusing here is memory, not
speed. Do not promise both.
Real-world scenario table. Mac Studio 36 GB, Gemma 4 26B-A4B 3-bit (head_dim = 256, 30 layers,
only 5 full-attention) — mlx#3658:
| Scenario | Result |
|---|---|
| Cold prefill ~130K tokens, 1024-token chunks | 226 s — completes, transient-bound |
| Default 2048-token chunks at long context | Metal OOM abort (kIOGPUCommandBufferCallbackErrorOutOfMemory) |
| Multi-turn past ~133K with a memory ceiling active | guard shrinks chunks to 32–512 tokens → a single turn's prefill takes 26+ minutes |
⚠️ A correction from that same thread, worth repeating because the wrong version circulates: be
very careful attributing Gemma 4's attention behaviour to head_dim = 256. The original poster
issued a correction that Gemma 4's large-context global layers are head_dim = 512, n_kv = 2, so
the d=256 work does not activate for them. And a clean python -m mlx_lm.benchmark A/B showed no
change for Gemma 4 26B 8-bit at 32K/64K (prompt tok/s 896.2 → 892.6; peak 31.078 GB both). MLX
maintainer zcbenz asked for exactly that clean A/B:
"Would it be possible to measure with the mlx-lm instead of oMLX? We are not familiar with oMLX's code so it is hard for us to check if this is something that we could fix without sacrificing the performance."
That is the right standard for any performance claim you bring to MLX: reproduce it in mlx-lm or
in plain mlx, not in a downstream runtime.
5.5 Detecting the fallback — four techniques¶
There is no API. Here are four things that work today, cheapest first.
Technique 1 — a static shape assertion. Encode §5.2's tables and check at model construction. This costs nothing and catches the common case:
import mlx.core as mx
SDPA_VECTOR_HEAD_DIMS = {64, 96, 128, 256} # T_q <= 8 (decode)
SDPA_FULL_HEAD_DIMS = {64, 80, 128} # T_q > 8 (prefill)
SDPA_VECTOR_ASYMMETRIC = {(192, 128)} # (D_q, D_v)
def sdpa_fusion_report(d_q: int, d_v: int, n_q: int, n_kv: int) -> dict:
"""Static check of MLX 0.32.x's Metal SDPA head-dim gates.
VERIFIED against mlx/backend/metal/scaled_dot_product_attention.cpp:593-644.
Does NOT model: is_training, output_logsumexp, CPU stream, mask-shape gates,
or the T_q*gqa_factor <= 32 vector-kernel limit (which is call-site dependent).
"""
gqa = n_q // n_kv if n_kv else 0
vector_ok = (d_q == d_v and d_q in SDPA_VECTOR_HEAD_DIMS) or (d_q, d_v) in SDPA_VECTOR_ASYMMETRIC
full_ok = (d_q == d_v and d_q in SDPA_FULL_HEAD_DIMS)
return {
"head_dims": (d_q, d_v),
"gqa_factor": gqa,
"decode_fused_possible": vector_ok,
"prefill_fused": full_ok,
"note": "decode also requires T_q <= T_kv and T_q * gqa_factor <= 32",
}
# Qwen3-VL vision tower: 1152 / 16 heads
print(sdpa_fusion_report(72, 72, 16, 16))
# {'head_dims': (72, 72), ..., 'decode_fused_possible': False, 'prefill_fused': False, ...}
🟡 RECONSTRUCTED as a helper; the sets it encodes are ✅ VERIFIED. It deliberately does not try
to model is_training or mask shapes, because those depend on the call site and a helper that
pretends to know them would be worse than one that says it does not.
Technique 2 — the peak-memory differential. The fallback materialises the score tensor, so it shows up in memory accounting. Reset the peak, run one attention call, read the peak, and compare against the analytic score-tensor size:
import mlx.core as mx
def probe_sdpa_transient(B, n_q, n_kv, T_q, T_kv, D, dtype=mx.bfloat16):
q = mx.random.normal((B, n_q, T_q, D)).astype(dtype)
k = mx.random.normal((B, n_kv, T_kv, D)).astype(dtype)
v = mx.random.normal((B, n_kv, T_kv, D)).astype(dtype)
scale = D ** -0.5
mx.eval(q, k, v)
mx.clear_cache()
mx.reset_peak_memory()
base = mx.get_active_memory()
out = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale, mask="causal")
mx.eval(out)
peak_delta = mx.get_peak_memory() - base
scores_bytes = B * n_q * T_q * T_kv * 4 # fp32 scores, the fallback's tell
return {
"peak_delta_MB": peak_delta / 1e6,
"predicted_scores_MB": scores_bytes / 1e6,
"verdict": "LIKELY UNFUSED" if peak_delta > 0.5 * scores_bytes else "likely fused",
}
✅ The memory APIs are VERIFIED (mx.get_peak_memory, mx.reset_peak_memory,
mx.get_active_memory, mx.clear_cache, mx.get_cache_memory — mlx/memory.h /
python/src/memory.cpp). 🟡 The heuristic is reconstructed.
⚠️ Two caveats on the memory probe, both community-established (mlx#3896). First,
mx.get_peak_memory()is a high-water mark of active memory only and excludes the buffer pool. One reporter (M5 Max 128 GB, mlx 0.32.0, Darwin 25.4.0) sawget_peak_memory()report ~46 GB while the OS reported ~110 GB. For absolute footprint, usemx.get_active_memory() + mx.get_cache_memory()— that sum matched the OS footprint to 0.2 % in their churn test, and it is what MLX's own limit enforcement uses. Second,mx.clear_cache()genuinely returns memory, butphys_footprinttrails the call by a few seconds — do not sample immediately and conclude there is a leak. Closure context (mlx#3896 closed 2026-08-08; checked 2026-08-23): the maintainer confirmed the scoping when closing —get_peak_memory()is meant for measuring a single model's inference/training run with near-100% cache hits, andactive + cacheis his own recommendation for the real footprint. A short single-workload probe like this one sits inside that intended scope; a long-lived or multi-model process does not.
Technique 3 — the A/B against a known-fused shape. Pad your head dim up to the nearest allowed value (§5.6) and measure. If padding up — strictly more arithmetic — makes it faster, you were on the fallback. This is the technique that requires no tooling and produces an unarguable result.
Technique 4 — Metal System Trace or a GPU capture. The authoritative answer, and the one the issue thread names. Apple's recipe from Tech Talk 111432: build (⌘B) → Instruments (⌘I) → Metal System Trace template → select the performance limiters counter set → record; then expand the M5 Metal Device events track and use the track filter to pin the neural accelerator utilization counter. For MLX specifically:
mx.metal.start_capture("mlx_trace.gputrace") # path must NOT already exist
for _ in range(10):
mx.eval(my_attention_step())
mx.metal.stop_capture()
✅ VERIFIED, docs/src/dev/metal_debugger.rst. In the capture, the fused path is one kernel with
sdpa in its name; the fallback is a recognisable matmul/softmax/matmul triple with a large
intermediate. Apple's practical tip from 111432 is worth copying: capture a single K-loop
iteration, which "keeps the capture small while preserving the performance characteristics we care
about."
5.6 Fixing it: pad the head dimension¶
The community fix is zero-padding the head dimension up to the nearest supported value, and it is exact:
"the same trick as
gemma4EnsureFusedSDPAin this repo (and mlx-vlm'sensure_fused_sdpa). Padding is exact: the padded dims contribute nothing to the dot products andscaleis passed explicitly."
Community-attributed (mlx-swift-lm thread on the Qwen3-VL vision tower, notes/repos/issues-mlx-stack.md
§9; the same technique appears in mlx_vlm as ensure_fused_sdpa). The argument is simple: Q·K over
zero-padded channels adds zero to every dot product, and because scale is an explicit parameter
rather than derived from D, padding does not perturb it.
import mlx.core as mx
def pad_head_dim(x, target_d: int):
"""Zero-pad the last (head) dimension up to target_d. Exact for SDPA."""
d = x.shape[-1]
if d == target_d:
return x
if d > target_d:
raise ValueError(f"cannot pad {d} down to {target_d}")
pad = [(0, 0)] * (x.ndim - 1) + [(0, target_d - d)]
return mx.pad(x, pad)
def fused_sdpa(q, k, v, *, scale, mask=None, target_d=None):
"""SDPA with optional head-dim padding so the fused Metal kernel dispatches.
scale is passed through UNCHANGED — it must stay 1/sqrt(original_d).
"""
d = q.shape[-1]
if target_d is None:
return mx.fast.scaled_dot_product_attention(q, k, v, scale=scale, mask=mask)
q = pad_head_dim(q, target_d)
k = pad_head_dim(k, target_d)
v = pad_head_dim(v, target_d)
out = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale, mask=mask)
return out[..., :d] # trim V's padded channels back off
# Qwen3-VL vision tower: 72 -> 80 puts you in the full/prefill allow-list {64, 80, 128}
# out = fused_sdpa(q, k, v, scale=72 ** -0.5, target_d=80)
🟡 RECONSTRUCTED. The technique is ✅ community-verified with a stated correctness argument, and 72→80 is the exact case reported. The code above is written for this guide, not copied from a shipping repo. Two things to be careful about, both of which the snippet handles explicitly:
scalemust remain1/sqrt(original_d). If you recompute it from the padded shape you have changed the model. This is the mistake to look for in a code review.- Pad
vtoo, then trim the output.v's padded channels produce zero output channels, so slicing them off is exact — but if you pad onlyqandk,D_q != D_vand you land in the asymmetric case, which is only supported for exactly(192, 128). - Pick your target from the right list. 96 is in the vector set but not the full set. If your
workload is prefill-dominant, target
{64, 80, 128}; if decode-dominant,{64, 96, 128, 256}. There is no single value that is optimal for both unless it is 64 or 128.
Padding costs bandwidth proportional to the padding ratio. 72→80 is +11 %; 96→128 is +33 %. Measure.
5.7 Two adjacent SDPA traps¶
Trap A — MLX_SDPA_BLOCKS must be a multiple of 32.
MLX_SDPA_BLOCKS is a new-in-0.32.0 override for the Metal SDPA block size (✅ VERIFIED,
mlx/backend/metal/scaled_dot_product_attention.cpp:477). It was added validated only for > 0,
but pass 2 in sdpa_vector.h iterates blocks / BN with BN = 32 and integer division:
⚠️ SILENT FAILURE — a non-multiple-of-32
MLX_SDPA_BLOCKSsilently corrupts attention. Quoting the PR: "Any other value silently corrupts the attention output on every decode step — no error, no clamp." Fixed by mlx PR #3875 (MERGED 2026-07-22), which rounds the override up to the next multiple of 32. On mlx ≤ 0.32.0, if you set this variable, use a multiple of 32. The built-in choices are 32–1024, all multiples of 32. Safe default: do not set it at all — one sweep on M1 Ultra (mlx#3837) found MLX's default selection was already optimal for that shape.
Trap B — the L = 8 → L = 12 routing cliff.
Community-measured on M5 Max, GQA 32 q / 8 kv, head_dim = 128, float16, no mask (mlx#3826):
| S (KV length) | L=8 | L=12 | L=16 | L=32 | L=48 | L=64 |
|---|---|---|---|---|---|---|
| 16384 | 0.813 ms | 1.906 ms | 1.903 ms | 1.904 ms | 1.904 ms | 1.588 ms |
| 32768 | 1.319 ms | 3.633 ms | 3.646 ms | 3.643 ms | 3.630 ms | 2.992 ms |
L = 8 → 12 is a 2.34× (16K) / 2.75× (32K) jump for 1.5× the work, then a flat plateau from
L=12 to L=48, recovering at L ≥ 64. The mechanism: the vector kernel's small-L path caps at
T_q ≤ 8, so at 12 everything falls to a path tuned for much larger L.
This lands squarely on speculative decoding — MTP, prompt-lookup and draft-model verify steps
live at L ≈ 4–16, and batched short continuations at 12–48. A surprising corollary from the same
thread: "the decomposed quantized attention (qmm → softmax → qmm) beats fp16 SDPA at L=16
at these S (0.35–0.48× its latency)" — i.e. in that specific window, the "slow" decomposed path
wins. Measure your actual L.
6. The rest of mx.fast, and why fused beats hand-composed¶
mx.fast is a small module with an outsized effect. ✅ VERIFIED — the complete surface, from
docs/src/python/fast.rst:
mx.fast.rms_norm
mx.fast.layer_norm
mx.fast.rope
mx.fast.scaled_dot_product_attention # §5
mx.fast.metal_kernel # §7-§9
mx.fast.cuda_kernel
mx.fast.precompiled_cuda_kernel
The first four are hand-written fused kernels for the four operations that dominate transformer runtime. The last three are the escape hatch. This section is about the first three; §5 covered attention.
6.1 The mechanical reason: mx.compile does not fuse reductions¶
The obvious question is "why can't I just write RMS norm out of primitives and let mx.compile fuse
it?" The answer is in MLX's compiler, and it is decisive.
✅ VERIFIED — mlx/compile.cpp:77. Only these primitives are fusable:
- unary:
Abs, ArcCos, ArcCosh, ArcSin, ArcSinh, ArcTan, ArcTanh, AsType, Ceil, Cos, Conjugate, Cosh, Remainder, Erf, ErfInv, Exp, Floor, Log, Log1p, LogicalNot, Negative, Round, Sigmoid, Sign, Sin, Sinh, Square, Sqrt, Tan, Tanh, Expm1, Real, Imag, BitwiseInvert - binary:
Add, Divide, Equal, Greater, GreaterEqual, Less, LessEqual, LogicalNot, LogicalAnd, LogicalOr, LogAddExp, Maximum, Minimum, Multiply, NotEqual, Power, Subtract, BitwiseBinary, ArcTan2 - ternary:
Select - broadcast:
Broadcast
And, in the same file: is_reduction = Reduce, ArgReduce — explicitly not fused into element-wise
kernels. Matmuls and gathers are likewise absent from the fusable set.
Now look at what RMS norm needs:
x -> square -> MEAN OVER LAST AXIS -> add eps -> rsqrt -> broadcast-multiply x -> multiply weight
^^^^ a reduction
The reduction sits in the middle. mx.compile can fuse the element-wise work before it and the
element-wise work after it, but it cannot fuse across it. So the compiled version is at least:
one element-wise kernel, one reduction kernel, one more element-wise kernel — three kernel
launches and two round-trips of the full activation tensor through memory. The fused
mx.fast.rms_norm is one launch that reads x once, reduces in registers/threadgroup memory, and
writes once.
The same argument applies to layer_norm (two reductions: mean and variance) and, for different
reasons, to rope (a gather-like index pattern plus trig) and SDPA (two matmuls around a softmax
reduction — §5.3 is literally the cost of not fusing it).
This is the general rule and it is worth carrying beyond MLX: mx.compile is an element-wise
fuser. Anything whose shape requires a reduction, a matmul or a gather in the middle is a candidate
for a hand-written kernel — either one MLX already ships (mx.fast.*) or one you write (§7).
MLX ships benchmarks for exactly these, which is where you should start if you want your own numbers:
benchmarks/python/rms_norm_bench.py, layer_norm_bench.py, rope_bench.py, sdpa_bench.py,
sdpa_vector_bench.py (✅ VERIFIED, notes/repos/mlx-core.md §25). The canonical harness in
benchmarks/python/time_utils.py — note the warm-up and that mx.eval is inside the loop:
import time
import mlx.core as mx
def time_fn(fn, *args, **kwargs):
for _ in range(5): # warm up: JIT + kernel cache
mx.eval(fn(*args, **kwargs))
num_iters = 100
tic = time.perf_counter()
for _ in range(num_iters):
x = mx.eval(fn(*args, **kwargs))
toc = time.perf_counter()
print(f"{1e3 * (toc - tic) / num_iters:.5f} msec")
✅ VERIFIED verbatim from benchmarks/python/time_utils.py. Timing MLX without a warm-up loop and
without mx.eval inside the timed region measures graph construction, not computation — MLX is
lazy, and nothing runs until you evaluate.
6.2 rms_norm and layer_norm¶
✅ VERIFIED — signatures from python/src/fast.cpp; prose from docs/src/python/fast.rst:
def rms_norm(x: array, weight: Optional[array], eps: float, *, stream=None) -> array
def layer_norm(x: array, weight: Optional[array], bias: Optional[array], eps: float,
*, stream=None) -> array
with the C++ forms (mlx/fast.h) matching, taking std::optional<array>.
Documented semantics:
- Normalisation is with respect to the last axis of
x. Not an arbitrary axis, not a set of axes. If your normalisation axis is not last, you must move it. weightmust be one-dimensional with the same size as the last axis.weight=Nonemeans no scaling happens — a genuinely useful case, because it gives you a bare normalisation without allocating an all-ones weight.layer_norm'sbiasis likewise optional.
Practical shape:
import mlx.core as mx
B, T, D = 2, 512, 4096
x = mx.random.normal((B, T, D)).astype(mx.bfloat16)
w = mx.ones((D,), dtype=mx.bfloat16)
y = mx.fast.rms_norm(x, w, 1e-5) # fused, one kernel
y_none = mx.fast.rms_norm(x, None, 1e-5) # normalise only
# LayerNorm with weight and bias
b = mx.zeros((D,), dtype=mx.bfloat16)
z = mx.fast.layer_norm(x, w, b, 1e-5)
🟡 The mlx.nn layers. nn.RMSNorm, nn.LayerNorm and nn.GroupNorm are exported by
mlx.nn (✅ VERIFIED from python/mlx/nn/layers/__init__.py). That nn.RMSNorm / nn.LayerNorm
delegate to mx.fast.* is 🟡 RECONSTRUCTED — the delegation is confirmed in our notes only for
nn.RoPE, whose __call__(x, offset=0) explicitly delegates to mx.fast.rope (✅ VERIFIED,
python/mlx/nn/layers/positional_encoding.py). To resolve: read
python/mlx/nn/layers/normalization.py. Safe default: if you care, call mx.fast.rms_norm
directly — the fused path is then not in question.
🔴 GAP — custom VJPs for
rms_norm/layer_normare unverified.mlx/fast_primitives.hexists in the tree, and the file map listsfast.cppas containingrms_norm, layer_norm, rope, SDPA, metal_kernel, cuda_kernel, but whether these primitives implement hand-written backward passes (as opposed to being differentiated through their decomposition) was not read this session. What is known is the analogous SDPA behaviour: on Metal, SDPA deliberately falls back to the unfused path for both forward and backward during training (§5.2). To resolve: readmlx/fast.cppandmlx/fast_primitives.hforvjp/jvpoverrides. Safe default: do not assume training-time speedups frommx.fast.*without measuring; assume inference-time speedups are real.
6.3 rope¶
✅ VERIFIED — python/src/fast.cpp:
def rope(a: array, dims: int, *, traditional: bool, base: Optional[float], scale: float,
offset: Union[int, array], freqs: Optional[array] = None, stream=None) -> array
Note that traditional, base, scale and offset are keyword-only and have no published
defaults — you must pass all four. The C++ side has two overloads, one taking an int offset and
one taking an array offset for per-batch positions (mlx/fast.h).
The documented rules, each of which is a real constraint:
- Input must be at least 3-D, shape
(B, *, T, D). - "Exactly one of
baseandfreqsmust beNone." Verbatim. Pass abasefor standard RoPE; passfreqsandbase=Nonefor a custom frequency schedule (YaRN, NTK-aware scaling, partial RoPE tables). Passing both, or neither, is an error. offsetmay be anint, or anarray— scalar, or a vector ofBper-example offsets. That array form is what makes batched generation with ragged KV caches expressible in one call instead of a Python loop.traditional=Truerotates consecutive dimensions; the default (False) uses the split-halves convention that most Hugging Face checkpoints use. Getting this backwards produces a model that generates fluent nonsense — no error, degraded output. Check it against the reference implementation for your checkpoint.dimsmay be smaller thanD; the remainder is left unchanged. This is how partial RoPE (e.g. 25 % rotary, as in some Gemma 4 layers) is expressed.
import mlx.core as mx
B, H, T, D = 1, 32, 128, 128
x = mx.random.normal((B, H, T, D)).astype(mx.bfloat16)
# Standard RoPE at position offset 0
y = mx.fast.rope(x, dims=D, traditional=False, base=10000.0, scale=1.0, offset=0)
# Decode step: one new token at position 512
x1 = mx.random.normal((B, H, 1, D)).astype(mx.bfloat16)
y1 = mx.fast.rope(x1, dims=D, traditional=False, base=10000.0, scale=1.0, offset=512)
# Partial RoPE: rotate only the first 32 dims, leave the rest alone
y2 = mx.fast.rope(x, dims=32, traditional=False, base=10000.0, scale=1.0, offset=0)
# Custom frequencies: base MUST be None
freqs = mx.arange(D // 2).astype(mx.float32) # placeholder schedule
y3 = mx.fast.rope(x, dims=D, traditional=False, base=None, scale=1.0, offset=0, freqs=freqs)
The mlx.nn wrapper, ✅ VERIFIED from python/mlx/nn/layers/positional_encoding.py:
nn.RoPE(dims, traditional=False, base=10000, scale=1.0) with __call__(x, offset: int = 0)
delegating to mx.fast.rope. Note that the layer's __call__ takes only an int offset, so the
per-batch-offset array form requires calling mx.fast.rope directly.
6.4 When not to use the fused primitive¶
Three honest cases:
- When you need something the primitive does not express. A norm over a non-last axis; a mask
shape SDPA rejects; a rotary scheme that is not
base-or-freqs. Composing ops is correct and slow; writing a kernel (§7) is fast and yours to maintain. Do not contort your model into the primitive's shape and then wonder why the numbers moved. - When you are training on Metal and the primitive falls back anyway. For SDPA this is documented and unconditional (§5.2). Measure before assuming a fused call is buying you anything in a training loop.
- When the fused kernel is the thing that is wrong. On M5, the NAX attention kernel is
implicated in
bfloat16/float16batch-versus-single divergence (mlx#3897, §1.4) and the affine-quantized gather path had a silent-corruption bug (§13's checklist). Being able to switch to the composed path — by disabling NAX withMLX_METAL_GPU_ARCH, or by writing the composition out explicitly — is a debugging capability, not just a fallback.
7. mx.fast.metal_kernel: the complete API¶
This is the part of MLX that has no equivalent in most array frameworks: you write Metal Shading
Language in a Python string, and MLX generates the function signature, JIT-compiles it, and wires it
into the lazy graph as a first-class op. No Xcode project, no .metal file, no build step, no
ctypes. It is the fastest path from "the fused kernel I need does not exist" to "the fused kernel I
need exists."
7.1 Constructor¶
✅ VERIFIED — python/src/fast.cpp:391-398, corroborated by the autosummary block on
docs/src/python/fast.html:
mx.fast.metal_kernel(
name: str,
input_names: List[str],
output_names: List[str],
source: str,
header: str = "",
ensure_row_contiguous: bool = True,
atomic_outputs: bool = False,
compile_options: Optional[dict] = None, # {"math_mode": "safe"|"relaxed"|"fast"}
)
Parameter by parameter, with the documentation's own words where they are load-bearing:
| Parameter | Meaning |
|---|---|
name |
Name for the kernel. Becomes part of the generated Metal function name (§7.3). |
input_names |
"The parameter names of the inputs in the function signature." These are the identifiers you use in source. |
output_names |
"The parameter names of the outputs in the function signature." Likewise. |
source |
"Source code. This is the body of a function in Metal, the function signature will be automatically generated." |
header |
"Header source code to include before the main function. Useful for helper functions or includes that should live outside of the main function body." |
ensure_row_contiguous |
"Whether to ensure the inputs are row contiguous before the kernel runs." Default True — this can copy (§9.1). |
atomic_outputs |
"Whether to use atomic outputs in the function signature e.g. device atomic<float>." Default False (§9.4). |
compile_options |
Currently only {"math_mode": ...} (§7.5). |
The single most important operational note, ✅ VERIFIED verbatim from
docs/src/dev/custom_metal_kernels.rst:
"Every time you make a kernel, a new Metal library is created and possibly JIT compiled. To reduce the overhead from that, build the kernel once with
fast.metal_kernel()and then use it many times."
Construct at module scope or in a cached factory. Constructing inside your forward pass compiles a
new Metal library per call. (MLX has been optimising the cheaper half of this: commit 7c92ce1,
"[Metal] Avoid regex in custom kernel name generation (#3869)" — but the library creation itself
is the expensive part and hoisting is the fix.)
import mlx.core as mx
# RIGHT: build once, at import time.
_EXP_KERNEL = mx.fast.metal_kernel(
name="myexp",
input_names=["inp"],
output_names=["out"],
source="""
uint elem = thread_position_in_grid.x;
T tmp = inp[elem];
out[elem] = metal::exp(tmp);
""",
)
def exp_elementwise(a):
return _EXP_KERNEL(
inputs=[a],
template=[("T", mx.float32)],
grid=(a.size, 1, 1),
threadgroup=(256, 1, 1),
output_shapes=[a.shape],
output_dtypes=[a.dtype],
)[0]
# WRONG: recompiles a Metal library on every call.
def exp_elementwise_slow(a):
k = mx.fast.metal_kernel(name="myexp", input_names=["inp"], output_names=["out"], source="...")
...
7.2 The returned callable¶
mx.fast.metal_kernel(...) returns a callable object, not an array. Calling it runs the kernel
and returns a list of arrays, one per output_names entry.
✅ VERIFIED — from python/src/fast.cpp (the binding source), read in
notes/repos/mlx-core.md §9.4:
def __call__(self, *,
inputs: List[Union[scalar, array]],
output_shapes: List[Sequence[int]],
output_dtypes: List[Dtype],
grid: tuple[int, int, int],
threadgroup: tuple[int, int, int],
template: Optional[List[Tuple[str, Union[bool, int, Dtype]]]] = None,
init_value: Optional[float] = None,
verbose: bool = False,
stream: Union[None, Stream, Device] = None)
A source conflict, resolved. The MLX documentation site never publishes this signature; the crawl's own note says so and marks the kwarg list as "inferred from every example on the site", with
stream=explicitly listed as unknown. The binding source above is the stronger evidence and it confirmsstream=exists. Where the docs and the source disagree, the source wins. If you need to be certain on your install:help(mx.fast.metal_kernel(...))orinspect.signatureon the returned object.
Notes on the call arguments:
- All keyword-only. There are no positional forms.
inputsmay contain scalars as well as arrays. A Pythonint/floatpassed here becomes a scalar kernel argument rather than a buffer. (🟡 The scalar handling is stated in the binding's type annotationUnion[scalar, array]; no worked example of the scalar form exists in our corpus. Safe default: if in doubt, bake constants intotemplate=asint/booltemplate parameters, which is documented, or pass a 1-element array.)output_shapesandoutput_dtypesare mandatory and parallel tooutput_names. MLX does not infer output shape from your kernel; you declare it.templateis how you get atemplate <typename T>on the generated function (§9.2).init_valuepre-fills every output element before the kernel runs (§9.4).verbose=Trueprints the full generated Metal source. Use it the first time you write any kernel (§9.5).stream=places the launch on a specific stream or device, like every other MLX op.
7.3 How the function signature is generated¶
You write the body. MLX writes the signature. The rules are worth memorising because a mismatch between what you assume and what MLX generates is the most common compile failure.
✅ VERIFIED — verbatim from docs/src/dev/custom_metal_kernels.rst:
The full function signature will be generated using:
- The shapes/dtypes of
inputs— …ais anmx.arrayof typemx.float16and we pass it with the keyinpso we will addconst device float16_t* inpto the signature.inp_shape,inp_stridesandinp_ndimare also added for convenience if they are present insource.- The list of
output_dtypes— …outis anmx.arrayof typemx.float16so we adddevice float16_t* out.- Template parameters passed using
template— …template=[("T", mx.float32)]adds a template oftemplate <typename T>to the function and instantiates the template. Template parameters can bemx.core.Dtype,intorbool.- Metal attributes used in
sourcesuch as[[thread_position_in_grid]]— These will be added as function arguments. All the attributes defined in Table 5.8 of the Metal Shading Language Specification are supported.
Four consequences:
- Input and output identifiers come from
input_names/output_names. In the example,inpandoutare not magic — they are the strings you passed. - Shape/stride helpers appear only if you mention them. Writing
inp_stridesanywhere insourcecausesinp_shape,inp_stridesandinp_ndimto be added to the signature. Not mentioning them keeps the signature lean. This is a textual trigger, so a mention inside a comment also counts. - Metal attribute variables appear only if you mention them.
thread_position_in_grid,threads_per_threadgroup,thread_index_in_simdgroup,threads_per_simdgroup,threadgroup_position_in_gridand the rest of MSL Table 5.8 are injected on demand, by name. - You do not write
[[kernel]], the parameter list, or the braces. Only statements.
Here is what MLX actually generates for the myexp example — ✅ VERIFIED verbatim from the docs:
template <typename T>
[[kernel]] void custom_kernel_myexp_float_float16_t_float16_t(
const device float16_t* inp [[buffer(0)]],
device float16_t* out [[buffer(1)]],
uint3 thread_position_in_grid [[thread_position_in_grid]]) {
uint elem = thread_position_in_grid.x;
T tmp = inp[elem];
out[elem] = metal::exp(tmp);
}
template [[host_name("custom_kernel_myexp_float_float16_t_float16_t")]] [[kernel]]
decltype(custom_kernel_myexp_float_float16_t_float16_t<float>)
custom_kernel_myexp_float_float16_t_float16_t<float>;
Read the generated name: custom_kernel_<name>_<template args>_<input dtypes>_<output dtypes>. Note
that the input array is float16_t while the template parameter T is float — that is the
example deliberately computing in float over half storage, which is exactly the accumulator-width
control §2's Rule 1 talked about. The buffer type comes from the array you pass; T comes from
template=; they are independent.
Two more automatic conveniences, ✅ VERIFIED from the same page:
mlx/backend/metal/kernels/utils.his automatically included, soelem_to_locandceildivare available with no work from you.- "Output arrays are always row contiguous." You index outputs with the raw linear index. Only inputs can be strided (§9.1).
7.4 grid and threadgroup are in THREADS¶
The single most common mistake for anyone arriving from CUDA. ✅ VERIFIED verbatim:
Note:
gridandthreadgroupare parameters to the MetaldispatchThreadsfunction. This means we will launchmx.prod(grid)threads, subdivided intothreadgroupsize threadgroups. For optimal performance, each thread group dimension should be less than or equal to the corresponding grid dimension.
So for an element-wise kernel over a:
In CUDA you would write blocks = ceil(n / 256). Here you write n. Getting this wrong by writing
the CUDA form launches 256× too few threads and quietly computes only the first n/256 elements —
the rest of the output holds whatever init_value left there, or uninitialised memory.
Note the API-compatibility decision MLX made for the CUDA backend: mx.fast.cuda_kernel and
mx.fast.precompiled_cuda_kernel also take the grid in threads, explicitly "For compatibility
with metal_kernel() the grid is in threads and not in threadblocks." (✅ VERIFIED). So a kernel
written for one backend ports without recomputing the launch geometry.
🔴 GAP — the maximum threadgroup size is not a constant you can assume. MLX does not surface a
per-pipeline maxTotalThreadsPerThreadgroup query from Python. Community measurement on mlx#3885
found that on M1 Max (applegpu_g13s) the Metal compiler register-limits some d=512 SDPA
pipelines to 832 threads/threadgroup while every other head dim gets 1024 — and that the 1-pass
kernel dispatched a flat 1024 with no size check at all, so "the launch is invalid, the GPU drops
it, and the kernel returns all zeros." To resolve: probe maxTotalThreadsPerThreadgroup on the
built pipeline (which requires the Metal API, not MLX's Python surface).
Safe default: use 256. It is what every MLX example uses, it is comfortably under every
register-limited ceiling observed, and if you go higher, verify your output is not all zeros before
believing your speedup.
7.5 Math mode¶
compile_options={"math_mode": ...} is a newer option — added by commit 51bef6f,
"Add math mode option for custom Metal kernels (#3728)", which closes issue #3592. ✅ VERIFIED.
Default is
"safe". Verbatim from the docs: "By defaultfast.metal_kernel()compiles kernels withcompile_options={"math_mode": "safe"}so special values follow IEEE behavior, for exampleexp(-inf) == 0. This is important for kernels such as masked softmax where causal or sliding-window masks depend on exponentiating-inf."
The three values are "safe", "relaxed", "fast". Error strings, ✅ VERIFIED:
"[metal_kernel] Expected math_mode to be 'safe', 'relaxed', or 'fast'.""[metal_kernel] Unknown compile option \`."`
Both surface as ValueError in Python (test test_custom_metal_kernel_math_mode in
python/tests/test_fast.py). Internally parsed by parse_metal_math_mode →
mx::MathMode::{Safe, Relaxed, Fast} (python/src/fast.cpp:78-88).
⚠️ SILENT FAILURE —
math_mode"relaxed"/"fast" breaks masked softmax. If you write a masked attention or masked reduction kernel and setmath_modeto anything but"safe",exp(-inf)is no longer guaranteed to be0. Masked positions then contribute a non-zero (or NaN) weight to your softmax denominator. The kernel compiles, runs, and returns plausible numbers with wrong attention. This is not hypothetical — it is precisely the case Apple's own MLX documentation calls out as the reason"safe"is the default. Safe default: leavemath_modealone. Only opt intorelaxed/fastfor a kernel you have proven has no special-value dependence, and diff it against the"safe"build before shipping.
kernel = mx.fast.metal_kernel(
name="my_kernel",
input_names=["x"],
output_names=["y"],
source=source,
compile_options={"math_mode": "relaxed"}, # only if you have proven you can
)
🔴 GAP — whether compile_options will accept other keys. Today math_mode is the only
recognised key and anything else raises. Issue #3592, which #3728 closed, also asked for
-fmetal-math-mode, integer template parameters, and Metal 4 Tensor types — only the math
mode landed. §11 draws the consequence.
8. A complete worked example¶
Three examples, in increasing order of realism. The first two are ✅ VERIFIED verbatim from MLX's own documentation and are the ones to copy. The third is written for this guide and marked accordingly.
8.1 The minimal kernel, end to end¶
✅ VERIFIED verbatim — docs/src/dev/custom_metal_kernels.rst:15-43, complete and runnable:
import mlx.core as mx
source = """
uint elem = thread_position_in_grid.x;
T tmp = inp[elem];
out[elem] = metal::exp(tmp);
"""
kernel = mx.fast.metal_kernel(
name="myexp",
input_names=["inp"],
output_names=["out"],
source=source,
)
def exp_elementwise(a: mx.array):
outputs = kernel(
inputs=[a],
template=[("T", mx.float32)],
grid=(a.size, 1, 1),
threadgroup=(256, 1, 1),
output_shapes=[a.shape],
output_dtypes=[a.dtype],
)
return outputs[0]
a = mx.random.normal(shape=(4, 16)).astype(mx.float16)
b = exp_elementwise(a)
assert mx.allclose(b, mx.exp(a))
Twelve lines of Python and three lines of Metal, and you have a new op that participates in MLX's lazy graph like any other. Note the shape of the pattern, which every kernel in this section repeats:
sourceis a body — statements only.- The kernel object is built once, at module scope.
- The call site supplies
inputs,template,grid,threadgroup,output_shapes,output_dtypes, and unpacksoutputs[0]. - There is an assertion against the reference implementation. Keep that habit. Custom kernels do not throw when they are wrong.
8.2 The realistic one: fused bilinear grid_sample¶
This is MLX's own worked example, and it is worth studying because it shows what "fusing" actually buys. The reference implementation in ordinary MLX ops does four separate gathers, four boolean mask computations, four masked multiplies and a four-term weighted sum — every one of which materialises a full-size intermediate.
✅ VERIFIED verbatim — docs/src/dev/custom_metal_kernels.rst. Reference implementation first:
import mlx.core as mx
import numpy as np
def grid_sample_ref(x, grid):
N, H_in, W_in, _ = x.shape
ix = ((grid[..., 0] + 1) * W_in - 1) / 2
iy = ((grid[..., 1] + 1) * H_in - 1) / 2
ix_nw = mx.floor(ix).astype(mx.int32)
iy_nw = mx.floor(iy).astype(mx.int32)
ix_ne = ix_nw + 1
iy_ne = iy_nw
ix_sw = ix_nw
iy_sw = iy_nw + 1
ix_se = ix_nw + 1
iy_se = iy_nw + 1
nw = (ix_se - ix) * (iy_se - iy)
ne = (ix - ix_sw) * (iy_sw - iy)
sw = (ix_ne - ix) * (iy - iy_ne)
se = (ix - ix_nw) * (iy - iy_nw)
I_nw = x[mx.arange(N)[:, None, None], iy_nw, ix_nw, :]
I_ne = x[mx.arange(N)[:, None, None], iy_ne, ix_ne, :]
I_sw = x[mx.arange(N)[:, None, None], iy_sw, ix_sw, :]
I_se = x[mx.arange(N)[:, None, None], iy_se, ix_se, :]
mask_nw = (iy_nw >= 0) & (iy_nw <= H_in - 1) & (ix_nw >= 0) & (ix_nw <= W_in - 1)
mask_ne = (iy_ne >= 0) & (iy_ne <= H_in - 1) & (ix_ne >= 0) & (ix_ne <= W_in - 1)
mask_sw = (iy_sw >= 0) & (iy_sw <= H_in - 1) & (ix_sw >= 0) & (ix_sw <= W_in - 1)
mask_se = (iy_se >= 0) & (iy_se <= H_in - 1) & (ix_se >= 0) & (ix_se <= W_in - 1)
I_nw *= mask_nw[..., None]
I_ne *= mask_ne[..., None]
I_sw *= mask_sw[..., None]
I_se *= mask_se[..., None]
output = nw[..., None] * I_nw + ne[..., None] * I_ne + sw[..., None] * I_sw + se[..., None] * I_se
return output
And the fused kernel that replaces all of it:
source = """
uint elem = thread_position_in_grid.x;
int H = x_shape[1];
int W = x_shape[2];
int C = x_shape[3];
int gH = grid_shape[1];
int gW = grid_shape[2];
int w_stride = C;
int h_stride = W * w_stride;
int b_stride = H * h_stride;
uint grid_idx = elem / C * 2;
float ix = ((grid[grid_idx] + 1) * W - 1) / 2;
float iy = ((grid[grid_idx + 1] + 1) * H - 1) / 2;
int ix_nw = floor(ix);
int iy_nw = floor(iy);
int ix_ne = ix_nw + 1;
int iy_ne = iy_nw;
int ix_sw = ix_nw;
int iy_sw = iy_nw + 1;
int ix_se = ix_nw + 1;
int iy_se = iy_nw + 1;
T nw = (ix_se - ix) * (iy_se - iy);
T ne = (ix - ix_sw) * (iy_sw - iy);
T sw = (ix_ne - ix) * (iy - iy_ne);
T se = (ix - ix_nw) * (iy - iy_nw);
int batch_idx = elem / C / gH / gW * b_stride;
int channel_idx = elem % C;
int base_idx = batch_idx + channel_idx;
T I_nw = x[base_idx + iy_nw * h_stride + ix_nw * w_stride];
T I_ne = x[base_idx + iy_ne * h_stride + ix_ne * w_stride];
T I_sw = x[base_idx + iy_sw * h_stride + ix_sw * w_stride];
T I_se = x[base_idx + iy_se * h_stride + ix_se * w_stride];
I_nw = iy_nw >= 0 && iy_nw <= H - 1 && ix_nw >= 0 && ix_nw <= W - 1 ? I_nw : 0;
I_ne = iy_ne >= 0 && iy_ne <= H - 1 && ix_ne >= 0 && ix_ne <= W - 1 ? I_ne : 0;
I_sw = iy_sw >= 0 && iy_sw <= H - 1 && ix_sw >= 0 && ix_sw <= W - 1 ? I_sw : 0;
I_se = iy_se >= 0 && iy_se <= H - 1 && ix_se >= 0 && ix_se <= W - 1 ? I_se : 0;
out[elem] = nw * I_nw + ne * I_ne + sw * I_sw + se * I_se;
"""
kernel = mx.fast.metal_kernel(
name="grid_sample",
input_names=["x", "grid"],
output_names=["out"],
source=source,
)
@mx.custom_function
def grid_sample(x, grid):
assert x.ndim == 4, "`x` must be 4D."
assert grid.ndim == 4, "`grid` must be 4D."
B, _, _, C = x.shape
_, gN, gM, D = grid.shape
out_shape = (B, gN, gM, C)
assert D == 2, "Last dim of `grid` must be size 2."
outputs = kernel(
inputs=[x, grid],
template=[("T", x.dtype)],
output_shapes=[out_shape],
output_dtypes=[x.dtype],
grid=(np.prod(out_shape), 1, 1),
threadgroup=(256, 1, 1),
)
return outputs[0]
MLX-project-published measurement, quoted verbatim from the same page: for
x.shape = (8, 1024, 1024, 64) and grid.shape = (8, 256, 256, 2), on an M1 Max:
55.7ms -> 6.7ms => 8x speed up
⚠️ Attribution: this is a number published by the MLX project in its own documentation. Hardware: M1 Max. OS, MLX version and date: unstated. No independent reproduction exists in our corpus.
Four techniques in that listing worth naming explicitly, because you will reuse all four:
x_shape[i]andgrid_shape[i]are used to derive strides in-kernel. Mentioning them insourceis what causes MLX to inject them (§7.3).template=[("T", x.dtype)]propagates the caller's dtype into the kernel, so one kernel object servesfloat32,float16andbfloat16— MLX instantiates per dtype and caches.- Bounds checks are
? :selects, not branches around the load. The loads happen unconditionally and out-of-bounds contributions are zeroed afterwards. That is deliberate: it is the same pattern MLX's own quantized kernels use, and it avoids divergence. It also means the loads themselves must be in-bounds as addresses, which the surrounding math guarantees here. @mx.custom_functionwraps the whole thing, which is what lets you attach a VJP (§9.4) and makes the op differentiable.
8.3 A fused row normaliser, written from scratch¶
This one is written for this guide. It is the shape of kernel you will most often want: a
row-wise reduction followed by a row-wise scale — precisely the pattern mx.compile cannot fuse
(§6.1).
Design: one simdgroup (32 threads) per row. Each lane strides across the row accumulating squares,
simd_sum reduces across the lane, every lane recomputes the same scale, and a second strided loop
writes the output. eps arrives as a one-element array because template parameters may only be
Dtype, int or bool — not float (§7.3).
🟡 RECONSTRUCTED — this listing was written for this guide and has NOT been executed. Every Metal construct in it is attested in MLX's own documented examples (
thread_position_in_grid,thread_index_in_simdgroup,threads_per_simdgroup,simd_sum,metal::rsqrt,x_shape/x_ndiminjection) and every Python argument is ✅ VERIFIED in §7. But the composition is mine. The assertion at the bottom is not decoration — run it before you trust this. If it fails, the most likely culprits are the lane/row decomposition and thethreadgroupsize relative tothreads_per_simdgroup.
import mlx.core as mx
_ROWNORM_SRC = """
// One simdgroup per row. grid.x = n_rows * threads_per_simdgroup.
uint gid = thread_position_in_grid.x;
uint lane = thread_index_in_simdgroup;
uint simd = threads_per_simdgroup;
uint row = gid / simd;
int D = x_shape[x_ndim - 1];
// Pass 1: sum of squares across the row, strided by simd width.
float acc = 0.0f;
for (int i = int(lane); i < D; i += int(simd)) {
float v = float(x[row * uint(D) + uint(i)]);
acc += v * v;
}
float ss = simd_sum(acc);
// Every lane computes the same scale; no broadcast needed.
float scale = metal::rsqrt(ss / float(D) + float(eps[0]));
// Pass 2: scale and apply the per-feature weight.
for (int i = int(lane); i < D; i += int(simd)) {
uint idx = row * uint(D) + uint(i);
out[idx] = T(float(x[idx]) * scale * float(w[i]));
}
"""
_ROWNORM = mx.fast.metal_kernel(
name="rownorm",
input_names=["x", "w", "eps"],
output_names=["out"],
source=_ROWNORM_SRC,
# default ensure_row_contiguous=True: inputs are made contiguous for us,
# so raw linear indexing above is valid.
# default math_mode="safe": keep it (§7.5).
)
SIMD_WIDTH = 32 # see the GAP note below
def rownorm(x: mx.array, w: mx.array, eps: float = 1e-5) -> mx.array:
"""RMS-style row normalisation, fused into one kernel launch."""
assert x.ndim >= 2, "x must be at least 2D"
assert w.ndim == 1 and w.shape[0] == x.shape[-1], "w must match the last axis of x"
n_rows = x.size // x.shape[-1]
eps_arr = mx.array([eps], dtype=mx.float32)
(out,) = _ROWNORM(
inputs=[x, w, eps_arr],
template=[("T", x.dtype)],
grid=(n_rows * SIMD_WIDTH, 1, 1),
threadgroup=(256, 1, 1),
output_shapes=[x.shape],
output_dtypes=[x.dtype],
)
return out
# ---- the assertion that makes this trustworthy -----------------------------
if __name__ == "__main__":
mx.random.seed(0)
B, T, D = 2, 17, 512 # deliberately not a round row count
x = mx.random.normal((B, T, D)).astype(mx.float32)
w = mx.random.normal((D,)).astype(mx.float32)
got = rownorm(x, w, 1e-5)
ref = mx.fast.rms_norm(x, w, 1e-5) # MLX's own fused kernel as ground truth
mx.eval(got, ref)
err = (mx.abs(got - ref).max()).item()
print("max abs err vs mx.fast.rms_norm:", err)
assert err < 1e-4, f"kernel disagrees with mx.fast.rms_norm by {err}"
print("OK")
Three things to notice about that script, all of which generalise:
- The reference is
mx.fast.rms_norm, not a hand-composed formula. Diffing a new kernel against a different implementation of the same maths is a much stronger test than diffing it against your own arithmetic, because it catches your misunderstanding of the maths as well as your bug. D = 512andT = 17. Pick a row count that is not a multiple of your threadgroup size and a row width that is a multiple of the simd width; then re-run with the opposite. Boundary handling is where custom kernels break, and it breaks silently.floataccumulation over aT-typed load.accisfloatregardless ofT. This is §2's Rule 1 applied deliberately: you control the accumulator width in a custom kernel, and for a sum-of-squares over 512 terms inbfloat16you very much want to.
🔴 GAP —
SIMD_WIDTH = 32is a hardcoded host-side assumption. The kernel reads the true value fromthreads_per_simdgroup, but the host needs the same number to size the grid and has no way to ask MLX for it. 32 is the value used throughout MLX's own kernels and its documented VJP example (simdgroup_size = 32appears verbatim ingrid_sample_vjp), and it is correct for every Apple GPU in the corpus — but it is an assumption, not a query. To resolve: readMTLComputePipelineState.threadExecutionWidth(Metal API, not reachable from MLX's Python surface). Safe default: keep 32, and add an in-kernel guard that writes a sentinel ifthreads_per_simdgroup != 32so the assumption fails loudly rather than silently.
9. The advanced options¶
9.1 Strides and non-contiguous inputs¶
By default, ensure_row_contiguous=True. ✅ VERIFIED verbatim:
fast.metal_kernel()supports an argumentensure_row_contiguouswhich isTrueby default. This will copy the array inputs if needed before the kernel is launched to ensure that the memory layout is row contiguous.
That default is a convenience with a cost: a transposed or sliced input is silently copied on every call. For a large activation tensor in a hot loop, that copy can dominate your kernel.
To avoid it, set ensure_row_contiguous=False and index through the injected stride metadata using
elem_to_loc, which comes from mlx/backend/metal/kernels/utils.h and is automatically included:
✅ VERIFIED verbatim — docs/src/dev/custom_metal_kernels.rst:
source = """
uint elem = thread_position_in_grid.x;
// Utils from `mlx/backend/metal/kernels/utils.h` are automatically included
uint loc = elem_to_loc(elem, inp_shape, inp_strides, inp_ndim);
T tmp = inp[loc];
// Output arrays are always row contiguous
out[elem] = metal::exp(tmp);
"""
kernel = mx.fast.metal_kernel(
name="myexp_strided",
input_names=["inp"],
output_names=["out"],
source=source,
ensure_row_contiguous=False,
)
def exp_elementwise(a: mx.array):
outputs = kernel(
inputs=[a],
template=[("T", mx.float32)],
grid=(a.size, 1, 1),
threadgroup=(256, 1, 1),
output_shapes=[a.shape],
output_dtypes=[a.dtype],
)
return outputs[0]
a = mx.random.normal(shape=(4, 16)).astype(mx.float16)
# make non-contiguous
a = a[::2]
b = exp_elementwise(a)
assert mx.allclose(b, mx.exp(a))
⚠️ SILENT FAILURE —
ensure_row_contiguous=Falsewithoutelem_to_loc. Turning the flag off does not change your indexing for you. If you setensure_row_contiguous=Falseand keep indexing with the raw linearelem, MLX hands you the array's actual buffer, strides and all, and your kernel reads the wrong elements. It compiles. It runs. It returns an array of the right shape with wrong contents. For a strided view whose strides happen to be contiguous, it even returns the right contents — until the day a caller passes a transposed tensor. Safe default: leaveensure_row_contiguous=Trueuntil you have measured that the copy matters, and when you turn it off, change every input index toelem_to_locin the same commit.
Two asymmetries to remember:
- Only inputs can be strided. "Output arrays are always row contiguous." ✅ VERIFIED. Index outputs linearly, always.
mx.fast.precompiled_cuda_kerneldefaultsensure_row_contiguoustoFalse— the opposite ofmetal_kernelandcuda_kernel(✅ VERIFIED). If you port a kernel between them, check this.
9.2 Template parameters¶
template=[(name, value), ...] where values may be mx.core.Dtype, int, or bool
(✅ VERIFIED). This is how you get compile-time specialisation:
The Dtype case is the one MLX's own examples show, and the generated code for it is quoted in
§7.3: template <typename T> plus an explicit instantiation, with the instantiated name baked into
the host_name.
🟡 RECONSTRUCTED — how
intandbooltemplate parameters render. The docs state plainly that those types are accepted, but no example on the site or in our corpus shows the generated code for a non-Dtypetemplate parameter. The natural rendering istemplate <typename T, int BLOCK, bool USE_MASK>, but that is inference. Relevant context: issue #3592 asked for "integer template params" among other things and was closed by the math-mode PR #3728, which suggests the integer case is newer than the dtype case. To resolve: pass one and call withverbose=True(§9.5) — MLX prints the generated source, and that settles it in one run. Safe default: start withverbose=Trueon any kernel using a non-Dtypetemplate parameter, read the generated declaration, and only then remove it.
Each distinct template instantiation is a distinct compiled kernel. If your BLOCK value varies per
call you are compiling per call, which is the §7.1 problem in a different disguise. Quantise your
template values to a small set.
9.3 header= for helpers and includes¶
The header string is emitted before the generated function, so it is where helper functions,
using declarations and #includes go. ✅ VERIFIED verbatim example:
header = """
template <typename T>
T do_exp(T x) { return metal::precise::exp(x); }
"""
kernel = mx.fast.metal_kernel(
name="myexp_precise",
input_names=["inp"],
output_names=["out"],
source="""
uint elem = thread_position_in_grid.x;
out[elem] = do_exp(inp[elem]);
""",
header=header,
)
Note metal::precise::exp in that snippet — Metal's precise:: namespace is the per-call-site
alternative to changing math_mode globally, and it is a much narrower instrument. If one function
in your kernel needs IEEE behaviour and the rest does not, prefer precise:: over relaxing the whole
compilation unit.
header is also the only place a #include can go, which is what §11 is about.
9.4 Atomic outputs and init_value — the VJP pattern¶
Scatter-style kernels — anything where multiple threads accumulate into the same output element — need two options together. ✅ VERIFIED verbatim from the docs:
init_value=0— Initialize all of the kernel's outputs to this value before it runs. This allows us to update only part of the output arrays with the kernel.atomic_outputs=True— Designate all of the kernel outputs asatomicin the function signature. This means we can use Metal'satomicfeatures to simultaneously update thex_gradandgrid_gradarrays from multiple threadgroups. See section 6.15 of the Metal Shading Language Specification for more details.⚠️ SILENT FAILURE — outputs are not zeroed for you.
init_valueis optional and MLX documents forprecompiled_cuda_kernelthat "By default, output arrays are uninitialized." 🟡 The same is strongly implied formetal_kernel—init_valueexists precisely so that you can "update only part of the output arrays" — but it is stated explicitly only for the CUDA variant. Either way, the failure mode is the same and it is nasty: if your kernel does not write every output element, the unwritten elements hold whatever the recycled Metal buffer last contained. A community regression note on a related MLX bug puts it perfectly: "unwritten rows hold whatever the recycledMTLBufferlast contained, which is sometimes coincidentally plausible. A regression test should poison the output buffer (or compare two runs) rather than trust one lucky read." Safe default: passinit_valuewhenever your kernel does not provably write every element, and test by running the same kernel twice on different inputs in the same process.
MLX's own VJP example is the canonical demonstration. ✅ VERIFIED verbatim — note simd_sum before
the atomic, C_padded to keep simdgroups from overlapping, and the thread_index_in_simdgroup == 0
guard:
source = """
uint elem = thread_position_in_grid.x;
int H = x_shape[1];
int W = x_shape[2];
int C = x_shape[3];
// Pad C to the nearest larger simdgroup size multiple
int C_padded = ceildiv(C, threads_per_simdgroup) * threads_per_simdgroup;
int gH = grid_shape[1];
int gW = grid_shape[2];
int w_stride = C;
int h_stride = W * w_stride;
int b_stride = H * h_stride;
uint grid_idx = elem / C_padded * 2;
float ix = ((grid[grid_idx] + 1) * W - 1) / 2;
float iy = ((grid[grid_idx + 1] + 1) * H - 1) / 2;
int ix_nw = floor(ix);
int iy_nw = floor(iy);
int ix_ne = ix_nw + 1;
int iy_ne = iy_nw;
int ix_sw = ix_nw;
int iy_sw = iy_nw + 1;
int ix_se = ix_nw + 1;
int iy_se = iy_nw + 1;
T nw = (ix_se - ix) * (iy_se - iy);
T ne = (ix - ix_sw) * (iy_sw - iy);
T sw = (ix_ne - ix) * (iy - iy_ne);
T se = (ix - ix_nw) * (iy - iy_nw);
int batch_idx = elem / C_padded / gH / gW * b_stride;
int channel_idx = elem % C_padded;
int base_idx = batch_idx + channel_idx;
T gix = T(0);
T giy = T(0);
if (channel_idx < C) {
int cot_index = elem / C_padded * C + channel_idx;
T cot = cotangent[cot_index];
if (iy_nw >= 0 && iy_nw <= H - 1 && ix_nw >= 0 && ix_nw <= W - 1) {
int offset = base_idx + iy_nw * h_stride + ix_nw * w_stride;
atomic_fetch_add_explicit(&x_grad[offset], nw * cot, memory_order_relaxed);
T I_nw = x[offset];
gix -= I_nw * (iy_se - iy) * cot;
giy -= I_nw * (ix_se - ix) * cot;
}
if (iy_ne >= 0 && iy_ne <= H - 1 && ix_ne >= 0 && ix_ne <= W - 1) {
int offset = base_idx + iy_ne * h_stride + ix_ne * w_stride;
atomic_fetch_add_explicit(&x_grad[offset], ne * cot, memory_order_relaxed);
T I_ne = x[offset];
gix += I_ne * (iy_sw - iy) * cot;
giy -= I_ne * (ix - ix_sw) * cot;
}
if (iy_sw >= 0 && iy_sw <= H - 1 && ix_sw >= 0 && ix_sw <= W - 1) {
int offset = base_idx + iy_sw * h_stride + ix_sw * w_stride;
atomic_fetch_add_explicit(&x_grad[offset], sw * cot, memory_order_relaxed);
T I_sw = x[offset];
gix -= I_sw * (iy - iy_ne) * cot;
giy += I_sw * (ix_ne - ix) * cot;
}
if (iy_se >= 0 && iy_se <= H - 1 && ix_se >= 0 && ix_se <= W - 1) {
int offset = base_idx + iy_se * h_stride + ix_se * w_stride;
atomic_fetch_add_explicit(&x_grad[offset], se * cot, memory_order_relaxed);
T I_se = x[offset];
gix += I_se * (iy - iy_nw) * cot;
giy += I_se * (ix - ix_nw) * cot;
}
}
T gix_mult = W / 2;
T giy_mult = H / 2;
// Reduce across each simdgroup first.
// This is much faster than relying purely on atomics.
gix = simd_sum(gix);
giy = simd_sum(giy);
if (thread_index_in_simdgroup == 0) {
atomic_fetch_add_explicit(&grid_grad[grid_idx], gix * gix_mult, memory_order_relaxed);
atomic_fetch_add_explicit(&grid_grad[grid_idx + 1], giy * giy_mult, memory_order_relaxed);
}
"""
kernel = mx.fast.metal_kernel(
name="grid_sample_grad",
input_names=["x", "grid", "cotangent"],
output_names=["x_grad", "grid_grad"],
source=source,
atomic_outputs=True,
)
@grid_sample.vjp
def grid_sample_vjp(primals, cotangent, _):
x, grid = primals
B, _, _, C = x.shape
_, gN, gM, D = grid.shape
assert D == 2, "Last dim of `grid` must be size 2."
# pad the output channels to simd group size
# so that our `simd_sum`s don't overlap.
simdgroup_size = 32
C_padded = (C + simdgroup_size - 1) // simdgroup_size * simdgroup_size
grid_size = B * gN * gM * C_padded
outputs = kernel(
inputs=[x, grid, cotangent],
template=[("T", x.dtype)],
output_shapes=[x.shape, grid.shape],
output_dtypes=[x.dtype, x.dtype],
grid=(grid_size, 1, 1),
threadgroup=(256, 1, 1),
init_value=0,
)
return outputs[0], outputs[1]
MLX-project-published measurement, same conditions as §8.2 (M1 Max, x.shape=(8,1024,1024,64),
grid.shape=(8,256,256,2), other details unstated):
676.4ms -> 16.7ms => 40x speed up
The 40× on the backward versus 8× on the forward is not an accident, and the reason is stated in the
listing's own comment: simd_sum first, atomics second. Reducing within the simdgroup turns 32
atomic contentions into one. The docs say it outright — "This is much faster than relying purely on
atomics." If you write a scatter kernel and it is slow, this is the first thing to check.
Also note the interaction between atomic_outputs=True and mx.custom_function from §8.2: the
forward is registered with @mx.custom_function and the backward is attached with @grid_sample.vjp,
taking (primals, cotangent, output). The whole thing then composes with mx.grad,
mx.value_and_grad and mx.vmap like any built-in op.
⚠️ The
custom_functionpurity rule applies. Verbatim frommx.custom_function's docstring: "Allcustom_functioninstances behave as pure functions. Namely, any variables captured will be treated as constants and no gradients will be computed with respect to the captured arrays." ✅ VERIFIED. If your kernel's weight tensor is captured from an enclosing scope rather than passed as an argument,mx.gradreturns zero for it — silently.
9.5 Debugging: verbose=True, Metal logging, GPU capture¶
Step 1 — read the generated source. verbose=True on the call prints the complete generated
Metal function, signature and all. ✅ VERIFIED. Do this the first time you write any kernel; it
answers every "did MLX inject x_strides?" and "what did my template render as?" question instantly.
outputs = kernel(inputs=[a], template=[("T", mx.float32)],
grid=(a.size, 1, 1), threadgroup=(256, 1, 1),
output_shapes=[a.shape], output_dtypes=[a.dtype],
verbose=True)
(The docs page contains a typo here, calling it ast.metal_kernel.__call__(); it means
mx.fast.metal_kernel.)
Step 2 — log from inside the kernel. MLX supports Metal shader logging, which requires Metal
3.2 (macOS 15+ / iOS 18+) and a debug build. ✅ VERIFIED, docs/src/dev/metal_logging.rst:
#include "mlx/backend/metal/kernels/logging.h"
constant mlx::os_log logger("mlx", "my_kernel");
kernel void my_kernel(/* ... */) {
logger.log_debug("unexpected state: idx=%u", idx);
}
🟡 That listing is the documented pattern for MLX's own .metal sources. Whether the same
#include works from a mx.fast.metal_kernel header= string is UNVERIFIED — see §11 for the
general form of that question.
Step 3 — capture a GPU trace. ✅ VERIFIED, docs/src/dev/metal_debugger.rst:
mx.metal.start_capture("mlx_trace.gputrace") # the path must NOT already exist
for _ in range(10):
mx.eval(mx.add(a, b))
mx.metal.stop_capture()
Or drive it from Xcode: cmake .. -DMLX_METAL_DEBUG=ON -G Xcode && open mlx.xcodeproj, then run the
metal_capture scheme.
Step 4 — read the counters. Apple's Metal debugger surfaces, per Tech Talk 111432: a cost graph
view inline with your Metal source, runtime statistics (register usage, divergence,
instruction-type breakdown) and per-shader performance counters. The instruction-mix breakdown is
the verification technique for §4: Apple's own example notes that in the simdgroup_matrix variant
"the majority of our instruction types are math" while in the TensorOps variant "almost all of the
instructions are being executed by neural accelerators." That is how you prove, rather than assume,
which hardware your kernel is running on.
-
Source snapshot:
ml-explore/mlxat973e27f, including the Metal kernels and Python bindings this reference documents. ↩ -
Apple's current
MTLTensorDataTypedocumentation lists Int2, UInt2, Float4E2M1, Float8E4M3, Float8E5M2, and Float8UE8M0. Apple documents scale planes throughMTLTensorAuxiliaryPlaneDescriptorandMTLTensorDescriptor.auxiliaryPlanes. ↩