Skip to content

float32 matmul loses precision to float16 when the right operand has a broadcast batch dimension (also corrupts batch-1 SDPA fallback) #3953

Description

@SirsiMaster

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!).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions