Describe the bug
At batch 1, mx.quantized_matmul is slower than the same matmul done densely in
bfloat16 whenever the output dimension is not a multiple of 8. At out_dim = 4
it is 6.8× slower than dense, and 4× slower than the same quantized matmul
at out_dim = 8 — a matrix half the size takes four times as long.
To Reproduce
Each call multiplies a (1, in_dim) bfloat16 vector by an (out_dim, in_dim)
weight matrix, once with the matrix quantized to 4-bit and once with it dense in
bfloat16. Dense bfloat16 is the reference: quantizing should not make a matmul
slower than not quantizing it. Calls are chained so each depends on the previous
(the batch-1 autoregressive regime, where no two matmuls overlap), and timings
are differenced across two chain lengths so the fixed mx.eval round-trip
cancels.
import time
import mlx.core as mx
IN_DIM = 10240
GROUP_SIZE = 64
BITS = 4
def time_per_call(build_chain, short=4, long=36):
def run(chain_length, repeats=50, warmup=15):
chain = build_chain(chain_length)
for _ in range(warmup):
mx.eval(chain())
mx.synchronize()
start = time.perf_counter()
for _ in range(repeats):
mx.eval(chain())
mx.synchronize()
return (time.perf_counter() - start) / repeats * 1e6
return (run(long) - run(short)) / (long - short)
activation = mx.random.normal((1, IN_DIM)).astype(mx.bfloat16)
mx.eval(activation)
def feed_forward(result):
"""Widen one output column back to in_dim so the next call can consume it."""
return mx.broadcast_to(result[:, :1], (1, IN_DIM)).astype(mx.bfloat16)
print(f"{'out_dim':>8} {'out_dim % 8':>12} {'quantized':>11} {'dense bf16':>11} {'ratio':>7}")
for out_dim in (4, 8, 12, 16, 32, 64, 128, 512, 2048):
weight = mx.random.normal((out_dim, IN_DIM)).astype(mx.bfloat16)
packed, scales, biases = mx.quantize(weight, group_size=GROUP_SIZE, bits=BITS)
mx.eval(weight, packed, scales, biases)
def quantized_chain(chain_length):
def call():
value = activation
for _ in range(chain_length):
value = feed_forward(
mx.quantized_matmul(
value, packed, scales, biases,
transpose=True, group_size=GROUP_SIZE, bits=BITS,
)
)
return value
return call
def dense_chain(chain_length):
def call():
value = activation
for _ in range(chain_length):
value = feed_forward(value @ weight.T)
return value
return call
quantized_us = time_per_call(quantized_chain)
dense_us = time_per_call(dense_chain)
print(f"{out_dim:8d} {out_dim % 8:12d} {quantized_us:10.1f}us "
f"{dense_us:10.1f}us {quantized_us / dense_us:6.2f}x")
Output
out_dim out_dim % 8 quantized dense bf16 ratio
4 4 99.0us 14.7us 6.76x
8 0 25.2us 14.2us 1.78x
12 4 31.7us 13.8us 2.30x
16 0 23.1us 14.1us 1.64x
32 0 22.0us 14.2us 1.55x
64 0 22.4us 14.5us 1.55x
128 0 22.1us 14.8us 1.50x
512 0 23.1us 19.0us 1.22x
2048 0 26.1us 50.9us 0.51x
Only at out_dim = 2048 does quantization win. Everywhere below it is a loss,
and below 8 it is a rout.
Two separate effects, both visible in the divisibility pattern
| out_dim |
4 |
8 |
9 |
12 |
16 |
17 |
20 |
24 |
32 |
64 |
| out_dim % 8 |
4 |
0 |
1 |
4 |
0 |
1 |
4 |
0 |
0 |
0 |
| µs |
96.9 |
23.7 |
32.3 |
32.4 |
22.3 |
31.6 |
31.3 |
22.6 |
21.9 |
22.5 |
This matches qmv() in mlx/backend/metal/quantized.cpp (v0.32.2, lines 477-493):
int bn = 8;
...
bool fast = N % bn == 0 && K % qmv_fast_k_alignment(bits) == 0;
...
MTL::Size grid_dims(M, (N + bn - 1) / bn, B);
N % 8 != 0 drops off qmv_fast onto qmv — costs ~1.4× (22 → 32 µs).
N < 8 additionally collapses the grid to a single threadgroup, since the
N dimension is ceil(N / 8) and M = B = 1 at batch 1. One threadgroup of 64
threads then performs the entire K-length reduction. This is the ~4× on top,
and it is why the penalty grows with K: 1.8× at K=1024, 2.5× at K=2560,
4.1× at K=10240 (out_dim 4 vs 8).
Independent of group_size (32/64/128 measured identical). Worse at 8 bits:
out_dim=4 costs 169 µs at bits=8 vs 97 µs at bits=4.
Workaround: padding is exact and recovers the speed
Zero-padding the weight rows to a multiple of 8 and slicing the output is
bit-exact — each output row is an independent dot product along K, so extra
rows cannot perturb the existing ones. max abs diff is exactly 0.0 at every
size measured:
| out_dim |
as-is |
padded to a multiple of 8 |
speedup |
| 1 |
50.2 µs |
24.8 µs |
2.03× |
| 2 |
62.3 µs |
23.3 µs |
2.67× |
| 4 |
96.8 µs |
24.3 µs |
3.99× |
| 6 |
81.1 µs |
23.2 µs |
3.49× |
| 12 |
32.8 µs |
22.1 µs |
1.48× |
| 20 |
32.3 µs |
23.3 µs |
1.39× |
Applied to the 96 layers in the model below, this gave a 1.26× end-to-end
decode speedup with an identical token stream, for +5.4 MB.
Expected behavior
quantized_matmul should not be slower than dense bfloat16 at any shape, and
out_dim = 4 should not cost 4× out_dim = 8 — especially when padding the
weight to 8 fixes it exactly and costs nothing.
Two directions, both maintainer calls:
- Pad at quantization time.
mx.quantize / nn.QuantizedLinear could round
out_features up to a multiple of 8 and slice on output. Contained, and
exact — but it changes stored weight shapes, which has checkpoint-compat
consequences we are not in a position to judge.
- Fix the thread allocation in
qmv_impl. When N < num_simdgroups * results_per_simdgroup, put the idle simdgroups on a slice of K rather than
letting them return, and reduce across them. No stored-shape change.
Happy to send a PR for either if you have a preference — we have the repro and
the hardware, but the design choice (particularly the compat implications of 1)
seemed yours to make rather than ours to assume.
Related
#3553 reported a discontinuity on the M (batch) axis of the same kernel.
This is the N (output) axis — different condition, same kernel family.
Desktop
- OS: macOS 15.6 (Darwin 25.6.0)
- Version: mlx 0.32.2
- Device: Apple M3 Ultra, 96 GB unified (
applegpu_g15d)
Additional context
Found in the decode critical path of Qwen3.8-Flash-Next, which has a
Linear(10240 → 4) gate running 96 times per token; that one call site was 32%
of the decode step. Narrow output projections are not unusual — router gates,
scoring heads and per-stream gating weights all produce them — and nothing in a
profile draws attention to a 20 KB matrix, so models using them may be paying
this silently.
Describe the bug
At batch 1,
mx.quantized_matmulis slower than the same matmul done densely inbfloat16 whenever the output dimension is not a multiple of 8. At
out_dim = 4it is 6.8× slower than dense, and 4× slower than the same quantized matmul
at
out_dim = 8— a matrix half the size takes four times as long.To Reproduce
Each call multiplies a
(1, in_dim)bfloat16 vector by an(out_dim, in_dim)weight matrix, once with the matrix quantized to 4-bit and once with it dense in
bfloat16. Dense bfloat16 is the reference: quantizing should not make a matmul
slower than not quantizing it. Calls are chained so each depends on the previous
(the batch-1 autoregressive regime, where no two matmuls overlap), and timings
are differenced across two chain lengths so the fixed
mx.evalround-tripcancels.
Output
Only at
out_dim = 2048does quantization win. Everywhere below it is a loss,and below 8 it is a rout.
Two separate effects, both visible in the divisibility pattern
This matches
qmv()inmlx/backend/metal/quantized.cpp(v0.32.2, lines 477-493):N % 8 != 0drops offqmv_fastontoqmv— costs ~1.4× (22 → 32 µs).N < 8additionally collapses the grid to a single threadgroup, since theN dimension is
ceil(N / 8)and M = B = 1 at batch 1. One threadgroup of 64threads then performs the entire K-length reduction. This is the ~4× on top,
and it is why the penalty grows with
K: 1.8× at K=1024, 2.5× at K=2560,4.1× at K=10240 (out_dim 4 vs 8).
Independent of
group_size(32/64/128 measured identical). Worse at 8 bits:out_dim=4costs 169 µs atbits=8vs 97 µs atbits=4.Workaround: padding is exact and recovers the speed
Zero-padding the weight rows to a multiple of 8 and slicing the output is
bit-exact — each output row is an independent dot product along K, so extra
rows cannot perturb the existing ones.
max abs diffis exactly0.0at everysize measured:
Applied to the 96 layers in the model below, this gave a 1.26× end-to-end
decode speedup with an identical token stream, for +5.4 MB.
Expected behavior
quantized_matmulshould not be slower than dense bfloat16 at any shape, andout_dim = 4should not cost 4×out_dim = 8— especially when padding theweight to 8 fixes it exactly and costs nothing.
Two directions, both maintainer calls:
mx.quantize/nn.QuantizedLinearcould roundout_featuresup to a multiple of 8 and slice on output. Contained, andexact — but it changes stored weight shapes, which has checkpoint-compat
consequences we are not in a position to judge.
qmv_impl. WhenN < num_simdgroups * results_per_simdgroup, put the idle simdgroups on a slice of K rather thanletting them return, and reduce across them. No stored-shape change.
Happy to send a PR for either if you have a preference — we have the repro and
the hardware, but the design choice (particularly the compat implications of 1)
seemed yours to make rather than ours to assume.
Related
#3553 reported a discontinuity on the M (batch) axis of the same kernel.
This is the N (output) axis — different condition, same kernel family.
Desktop
applegpu_g15d)Additional context
Found in the decode critical path of Qwen3.8-Flash-Next, which has a
Linear(10240 → 4)gate running 96 times per token; that one call site was 32%of the decode step. Narrow output projections are not unusual — router gates,
scoring heads and per-stream gating weights all produce them — and nothing in a
profile draws attention to a 20 KB matrix, so models using them may be paying
this silently.