Repository navigation
Pick BM from rows per expert in gather_qmm_rhs_nax - #4023
Conversation
zcbenz
left a comment
There was a problem hiding this comment.
Thanks for the PR, I can verify that this is bringing a very nice improvement.
Running gather_qmm_bench.py:
| main branch | this branch |
|---|---|
| 2.74436 msec | 2.22729 msec |
Can you please check whether existing tests actually cover all the kernel instantiations added in this PR?
|
Thank you for testing it out, and glad to see those perf improvements on your machine as well! Great question - I wish I asked myself that before submitting the PR. Method: added temp logging to print every I've pushed an update (fd63970) addressing a coverage regression: on main, the fp-mode rows in The same commit also expands coverage of the kernels this PR adds: existing tests dispatched 4 of the 63 reachable new BM=32 kernels (affine float16 gs64 4-bit, plus the three fp modes in bfloat16). To your original question, there are two gaps (pre-existing):
|
The kernel re-runs its whole K loop once per distinct expert inside a
BM-row block and keeps only that expert's rows, so useful arithmetic is
about min(1, rows_per_expert / BM). Bandwidth is set by the number of
experts a block touches rather than by BM, so a shorter block removes
arithmetic without adding traffic. MoE prefill commonly presents short
runs (rows_per_expert = tokens * top_k / num_experts; a 256-expert
top-8 model prefilling 512-token chunks gives 16), where the fixed
BM=64 discards three quarters of its arithmetic.
Dispatch BM=32 when the average run is shorter than 64 rows and add the
matching instantiations for the affine and fp modes. Runs of 64 and
longer keep BM=64 and the kernel they use today.
Measured on a base M5 (10-core GPU), both arms built from one tree and
run in the same session, 4-bit gs64, E=256 K=2048 N=512, chained
up/down projection pairs, percent of the measured 15.4 TFLOPS fp16
peak:
rows/expert main patch
4 5.3% 7.7% 1.45x
8 10.5% 15.3% 1.46x
16 20.8% 29.5% 1.42x
32 41.5% 53.1% 1.28x
64 75.1% 76.6% unchanged path
128 84.3% 84.4% unchanged path
The last two rows take the same path in both builds and bound the
process-to-process noise at about 2%. Adds a short-run case to
gather_qmm_bench.py: 9.56 ms on main, 7.45 ms here, with its
equivalent_matmul control at 1.814 and 1.818 ms.
BM=16 (WM=1/WN=2) was also built and measured: parity at runs of 8 and
below, behind at 16, so nothing dispatches it. The extra instantiations
grow the metallib by 3.9%. Follow-up to ml-explore#3925.
The sorted-indices tests only dispatched four of the kernels this branch adds: affine float16 gs64 b4 and the three fp modes in bfloat16. Picking BM from rows per expert also moved the fp-mode test rows (64 tokens, 4 experts) from the BM=64 kernels onto BM=32, leaving the BM=64 fp kernels untested. Add a test that runs every mode, group size and bit width at float16 and bfloat16 in three regimes: 16 rows per expert (BM=32), 64 rows per expert (BM=64), and a 78-row batch that is not a multiple of the block height. Verified by logging kernel dispatch: 42 distinct BM=32 and 42 distinct BM=64 gather_qmm_rhs_nax kernels run under this test, which is every instantiation reachable inside the test harness. The float32 variants cannot be reached there because mlx_tests.py pins MLX_ENABLE_TF32=0; checked manually with TF32 enabled, they dispatch and match gather_mm bit for bit.
mlx_tests.py pins MLX_ENABLE_TF32=0 and the flag is cached on its first read, so the float32 variants of these kernels cannot run anywhere inside the harness process. A subprocess with TF32 enabled reaches them: verified by dispatch logging that float32 inputs then select the nax kernels at both block heights, matching gather_mm bit for bit.
367c7eb to
d556f89
Compare
Following up on #3925, where you invited a PR that tunes the block sizes.
affine_gather_qmm_rhs_naxwalks the sorted index array and re-runs its entire K loop once per distinct expert inside a BM-row block, computing all BM rows on each pass and keeping only that expert's rows throughstore_slice. Useful arithmetic is therefore aboutmin(1, rows_per_expert / BM). The run length is a caller-side quantity: for MoE prefill it istokens * top_k / num_experts. A 256-expert top-8 model prefilling 512-token chunks presents runs of 16 rows against a block of 64, so three quarters of the arithmetic is computed and discarded.Bandwidth is set by the number of experts a block touches rather than by BM. Each expert's BN by K slab is read once per block it appears in, and a 16-row run sits inside one block whether that block is 32 rows or 64. A shorter block removes arithmetic and adds no traffic.
The change. Dispatch BM=32 when the average run is shorter than 64 rows, and add the matching instantiations for the affine and fp modes. Runs of 64 rows and longer keep BM=64 and the kernel they use today.
Measurement. Both arms were built from this tree one after the other and measured in the same session by one script: chained up and down projection pairs, 4-bit gs64, E=256, K=2048, N=512, median of five dependent strips after a warmup pass. Percentages are of this device's measured fp16 tensor-core peak of 15.4 TFLOPS, on a base M5 with a 10-core GPU. Within-arm sigma stayed at or below 0.9%.
The last two rows take the unchanged path in both builds, so they act as a control: they agree within 2%, which is the process-to-process floor I see on this machine. Read the rows above against that floor rather than against the within-arm sigma. The curve also tracks
min(1, run / BM)across the whole range, which is the evidence that the block height drives the gap.The benchmark case added here, which is the same shape at 16 rows per expert, reads 9.56 ms on main and 7.45 ms with the patch. Its
equivalent_matmulcontrol reads 1.814 ms and 1.818 ms across the two builds.End to end. On a 4-bit Qwen3.6-35B-A3B under mlx-lm at 32k context with 512-token prefill chunks, warm, time to first token went from 62.8 s to 58.9 s, and prefill from 523.6 to 557.9 tokens/s. Decode was unchanged, which is the expected null because single-token decode does not take this path. That measurement predates this patch and used an earlier build where an environment variable chose BM instead of the heuristic. The dispatch decision was the same one, but I have not re-run it against this branch.
Costs and things I left out. The extra instantiations grow the metallib from 164,587,560 to 171,001,000 bytes, or 3.9%. If that is too much, dropping the fp-mode instantiations and dispatching only the affine path would cut it. I also built and measured BM=16 with WM=1 and WN=2: it reached parity with BM=32 at runs of 8 and below and fell behind at 16, so nothing dispatches it.
All of the above comes from one machine, and the crossover between block sizes may well sit elsewhere on other devices. I am happy to re-run the sweep with different thresholds, or to narrow the condition, if you have a shape in mind.
python -m pytest python/tests/test_quantized.pypasses: 33 tests, 3014 subtests.