Repository navigation
[Metal] Add staged affine_qmm_t_nax kernels with split-k - #4682
Merged
Merged
Conversation
zcbenz
reviewed
Oct 19, 2026
| // On NAX devices the NAX qmm (splitting K where that helps) also beats | ||
| // qmv / qmv_wide from 8 rows, when the output is wide enough to fill its | ||
| // tiles and narrow enough that a two-way K split fits in 256 threadgroups. | ||
| if (transpose_ && M < vector_limit && M >= 8 && M * N >= 16384 && |
Member
There was a problem hiding this comment.
Will you also add staged implementation for fp_qmm in another PR as followup? If you do it is probably going to have an overlap with #4712
Contributor
Author
There was a problem hiding this comment.
I'll wait for #4712 to land, then benchmark the fp4/fp8 fp_qmm paths at larger M on M5. Based on the remaining bottlenecks, I'll decide whether to add a staged implementation in a follow-up PR.
On NAX devices, transposed affine quantized matmuls with M between 8 and the qmv batch limit, or above it with too few output tiles to fill the GPU, ran qmv_wide, the non-NAX qmm_t_splitk or an unsplit qmm_t_nax. Add a split-K variant of qmm_t_nax (fp32 partials, existing accumulate kernel) and use it there when it splits K. float32 inputs keep their previous routes. Signed-off-by: Yanzhao Wang <yzwang1996@gmail.com>
…weights For half and bfloat16 inputs with N % 64 == 0, affine_qmm_t_nax and affine_qmm_t_nax_splitk use four simdgroups of 16 rows x 64 columns. The weight tile is double-buffered: the next K step's packed words are read into registers before the current step's MMAs and dequantized afterwards, and each simdgroup prefetches its next activation fragments, so one barrier separates K steps. The accumulation order is unchanged, so outputs are bit-identical to the previous schedule at the same split. These kernels launch as flat (128, 1, 1) threadgroups. K is now split at most 4 ways, each partition at least 256 columns deep; which shapes take the NAX route is unchanged. Activations at an offset that is not 8-byte aligned keep the previous schedule (copied first on the split-K route). Signed-off-by: Yanzhao Wang <yzwang1996@gmail.com>
zcbenz
force-pushed
the
qmm-nax-mid-m
branch
2 times, most recently
from
October 20, 2026 09:47
6f3bcd1 to
fcafac9
Compare
zcbenz
approved these changes
Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

This PR adds a split-K NAX route and a 16-row simdgroup schedule for transposed affine
quantized_matmulon M5. For LiquidAI d1-3B with locally converted 4-bit weights, an 85-token forward pass takes 14.5 ms instead of 30.1 ms on upstreammain: 2.07× (95% CI [2.05, 2.09]) on M5 Max (40-core GPU). d1 returns decisions in one forward pass without generating tokens, making short-input latency a useful target. Gains are smaller when longer inputs or larger batches make the matmuls compute-bound.At M = 8 to a few hundred rows, narrow projections (
o_proj,down_proj, GQAk/v_proj) often produce too few output tiles to fill the GPU. They useqmv_wide, the non-NAXqmm_t_splitk, or an underfilledqmm_t_naxlaunch.qmv_widealso re-reads weights for each tile of at most 5 rows.The new
affine_qmm_t_nax_splitkkernel writes fp32 partials and sums them with the existingsteel_gemm_splitk_accumkernel. The route requires a two-way K split that fits within 256 threadgroups. K is split at most 4 ways, with at least 256 columns per partition; K below 512 uses unsplit NAX.For half/bfloat16 inputs with aligned N and 8-byte-aligned activation offsets, batched and unbatched
qmm_t_naxand the new split-K kernel use four 16 x 64 simdgroups per 64 x 64 tile instead of a 2 x 2 grid of 32 x 32. Packed weights, scale/bias and activation fragments for the next K step are prefetched into registers. Weights are then dequantized into the other threadgroup buffer, leaving one barrier between K steps. The schedule uses flat (128, 1, 1) threadgroups.The loader reuses
QuantizedBlockLoader's thread mapping anddequantizefor bits 1–6 and 8 and group sizes 32/64/128. The MMA order is unchanged, so outputs are bit-identical at the same split count.For a single weight matrix, transposed affine mode on NAX, half/bfloat16,
N % 64 == 0andK % 64 == 0, dispatch changes as follows:8 <= M <qmv batch limit,M * N >= 16384, route takenqmv/qmv_wideM >=qmv batch limit, route takenqmm_t_splitk, or an unsplitqmm_t_naxM >=qmv batch limit, route not taken (wide outputs, large M)qmm_t_naxqmm_t_naxwith the new scheduleDispatch is unchanged for float32, unaligned N or K, fp modes and
M < 8. Below the qmv batch limit, shapes withM * N < 16384or K that does not split in two also keep their existing route. Above the limit, shapes still selected forqmm_t_splitkstay there.Float32 retains the old schedule because double buffering would exceed 32 KB of threadgroup memory. Unaligned N and activations at offsets that are not 8-byte aligned also keep the old schedule; the split-K route copies those activations first. Gather kernels and fp modes are unchanged.
Performance
Measured on M5 Max (40-core GPU). Ratios are upstream
maintime divided by this PR's time: paired geometric means with 95% CIs from A/A calibration followed by 6 alternating rounds in the same process. Latencies are the median times for each configuration.I timed one text-only forward pass on each prompt, including the output head on the last row:
main)Operator measurements use bfloat16 activations and 4-bit affine weights, group size 64, in a dependent chain of
quantized_matmulcalls rotating through 8 weight sets.qmv_wideqmv_wideqmv_wideqmv_wideqmm_t_splitkqmm_t_splitkqmm_t_splitkqmm_t_naxqmm_t_naxThe compute-bound case at 4096 x 4096, M = 512 shows no regression: 1.048 [1.033, 1.062]. The 4096 x 14336, M = 8 control keeps
qmv_wide: 1.003 [0.966, 1.042].For the mlx-lm models below, I timed one forward pass over N random tokens with a fresh KV cache, including the output head on all N rows. These use the same paired protocol.
mainCorrectness
Both kernels accumulate in fp32; split-K rounds once after summing the partials. On rerouted shapes, mean error against an fp32 reference stays within 1.1x of the output rounding.
test_qmm_mid_mchecks every split count and both route boundaries, including K that does not split and K below 512. It covers bits 2/3/4/6/8, group sizes 32/64/128, float16/bfloat16 and M up to 257.test_qmm_t_batched_and_stridedchecks 3-D quantized weights, strided activations and odd-offset activations.I also compared the new schedule with the first commit's kernels on 336 float16/bfloat16 cases: bits 1–6 and 8, group sizes 32/64/128 and M from 8 to 300. All were within fp32-reference tolerance, and outputs were bit-identical when the split count was unchanged.
Tests
python -m unittest test_quantized(python/tests), M5 Max,MLX_ENABLE_TF32=0: 55 tests OK (1 skipped). With default TF32, 1270 subtests fail onmainand 1268 fail with this PR. The 1268 shared failures are fp32 cases. The two additional failures onmainare half/bfloat16 cases oftest_qmm_splitk_precision; they fail onmainwith TF32 both on and off.MLX_METAL_JIT=ONas well.Quick reproduction
Run it on a build of the base commit (
06eb7483f03bca49a80e596f4089d02a507a2cf9) and on a build of this PR (6e7ab39b4b1042d53860507e2e00e4854a152658). It prints microseconds per call. The tables above come from same-process paired runs; this script is a quick operator benchmark and does not reproduce their confidence intervals.