Summary
mx.matmul on float32 inputs silently loses precision to float16 when the right operand has a broadcast (stride-0) batch dimension. Multiplying by an exact 1.0 changes the values:
import mlx.core as mx
mx.random.seed(9)
v = mx.random.normal((1, 1, 1, 512)).astype(mx.float32)
ones = mx.ones((1, 16, 1, 1)).astype(mx.float32)
out = ones @ v # K = 1: out must equal v exactly
print(mx.abs(out - v).max().item()) # 1.9e-03 (should be 0.0)
Every output element lands on the fp16 grid. Two confirmations:
# fp16 ulp at 1.0 is 2^-10
b = mx.full((1,1,1,512), 1 + 2**-10).astype(mx.float32) # fp16-representable
print(mx.abs(ones @ b - (1 + 2**-10)).max().item()) # 0.0
b = mx.full((1,1,1,512), 1 + 2**-12).astype(mx.float32) # NOT representable
print(mx.abs(ones @ b - (1 + 2**-12)).max().item()) # 2.441406e-04 == 2^-12 exactly
Shape dependence
| shape |
broadcast? |
max err |
[1,1] @ [1,512] |
– |
0.0 |
[1,1,1,1] @ [1,1,1,512] |
no |
0.0 |
[1,16,1,1] @ [1,1,1,512] |
yes |
1.9e-03 |
[1,16,1,1] @ [1,16,1,512] (materialized) |
no |
0.0 |
[2,16,1,1] @ [1,1,1,512] |
yes |
1.9e-03 |
Materializing the broadcast (mx.contiguous(mx.broadcast_to(...))) restores exactness, so the loss is specific to the stride-0 batched path.
Real-world impact: fast.scaled_dot_product_attention at batch 1
For head dims outside the fused kernels' support list, SDPA runs the unfused fallback, whose softmax @ values matmul hits this path via the GQA expand_dims broadcast. At L=1 the output must equal V exactly (softmax of a single score is 1); it does not:
mx.random.seed(9)
q = mx.random.normal((1, 16, 1, 512)).astype(mx.float32)
k = mx.random.normal((1, 1, 1, 512)).astype(mx.float32)
v = mx.random.normal((1, 1, 1, 512)).astype(mx.float32)
out = mx.fast.scaled_dot_product_attention(q, k, v, scale=1.0, mask="causal")
print(mx.abs(out - v).max().item()) # ~1.9e-03; 20/20 random seeds deviate
At B >= 2 the same call is exact (different matmul dispatch). Batch 1 is the single-user interactive case, so every batch-1 decode step through the fallback carries fp16-scale noise on f32 activations — we found it because two independent attention implementations disagreed only at batch 1, and the L=1 ground-truth case (out must equal V, no arithmetic involved) showed which side was wrong.
Environment
- MLX 0.32.0 (pip), Python 3.14
- Apple M5 Max, macOS 26 (Darwin 25.5.0)
Happy to test patches. Related report from the same project: #3949 (rope batch rows, now fixed in 0.32.0 as far as we can measure — thank you!).
Summary
mx.matmulon float32 inputs silently loses precision to float16 when the right operand has a broadcast (stride-0) batch dimension. Multiplying by an exact1.0changes the values:Every output element lands on the fp16 grid. Two confirmations:
Shape dependence
[1,1] @ [1,512][1,1,1,1] @ [1,1,1,512][1,16,1,1] @ [1,1,1,512][1,16,1,1] @ [1,16,1,512](materialized)[2,16,1,1] @ [1,1,1,512]Materializing the broadcast (
mx.contiguous(mx.broadcast_to(...))) restores exactness, so the loss is specific to the stride-0 batched path.Real-world impact:
fast.scaled_dot_product_attentionat batch 1For head dims outside the fused kernels' support list, SDPA runs the unfused fallback, whose
softmax @ valuesmatmul hits this path via the GQAexpand_dimsbroadcast. At L=1 the output must equal V exactly (softmax of a single score is 1); it does not:At B >= 2 the same call is exact (different matmul dispatch). Batch 1 is the single-user interactive case, so every batch-1 decode step through the fallback carries fp16-scale noise on f32 activations — we found it because two independent attention implementations disagreed only at batch 1, and the L=1 ground-truth case (out must equal V, no arithmetic involved) showed which side was wrong.
Environment
Happy to test patches. Related report from the same project: #3949 (rope batch rows, now fixed in 0.32.0 as far as we can measure — thank you!).