metal : skip the empty half of the mul_mm_id token tile, load iq2/iq3 codebooks as uint32 - #28301
Draft
masterFoad wants to merge 1 commit into
Draft
metal : skip the empty half of the mul_mm_id token tile, load iq2/iq3 codebooks as uint32#28301masterFoad wants to merge 1 commit into
masterFoad wants to merge 1 commit into
Conversation
…ks as uint32 - kernel_mul_mm_id: split the NR1 = 32 token tile into two 16-row halves and skip the upper half when no routed rows land in it (both tensor and simdgroup paths); zero the B tile padding once instead of restaging it every k step - fix the tB extents to (NK, NR1H) for the [NR1][NK] row-major tile - dequantize.h: read iq2_xxs/iq2_xs/iq3_xxs/iq3_s/iq2_s codebook entries as 1-2 uint32 loads and replace the ksigns_iq2xs table with a popcount identity - test-backend-ops: re-draw the expert ids between perf iterations of test_mul_mat_id so MoE perf numbers are not warm-cache
masterFoad
marked this pull request as draft
September 3, 2026 07:10
|
Hi @masterFoad, thanks for your contribution! Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:
Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below. |
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.
Overview
This makes
kernel_mul_mm_idskip the upper half of the token tile when an expert does not fill it, and reads iq2/iq3 codebook entries as whole words instead of individual bytes.In MoE prefill, each
mul_mm_idthreadgroup multiplies a 32-row weight tile against a 32-row token tile, but an expert often receives far fewer than 32 tokens per ubatch (ne21*ne20/ne02: 8 for a 256-expert top-8 model at-ub 256, and 2 to 4 for a 128-expert top-8 model at n=32..64). The padded rows still incur work.The patch splits the token tile into two
NR1H = 16halves and skips the upper half whennr1 <= NR1H. On the tensor-ops path, this uses onematmul2dcall per half. On the simdgroup path, the two simdgroups responsible for rows 16..31 idle throughsg_active. Padding rows in the B tile are also zeroed once instead of being restaged from a clamped row on every k step.I noticed this while profiling prefill on Tiel-Coder-35B-A3B, where the expert matmul accounted for around half of prefill time while most expert tiles were only partially populated.
The
dequantize_iq2_xxs/iq2_xs/iq3_xxs/iq3_s/iq2_sfunctions now read each codebook entry using one or twouint32loads instead of 4 to 8uint8loads. Theksigns_iq2xslookup is also replaced by the identity encoded by that table,k | (parity(k) << 7). The result is bit-exact. This relies on little-endian layout, which covers Metal targets. It is the same general idea as #27370 for q8_0.This part is independent of the tile change and I can split it into a separate PR if that is easier to review.
Also included: the
tBextents were(NR1, NK)for a[NR1][NK]row-major tile, i.e. reversed. This was harmless whileNR1 == NK == 32, but needed fixing once the tile was split into(NK, NR1H)halves.Tested on my 14inch MacBook Pro M5 24 GB using the tensor-ops path, and on my 16inch MacBook Pro M1 Pro 32 GB with a 16-core GPU using the simdgroup path. On the M1 Pro, Metal reports
tensor API disabled for pre-M5 and pre-A19 devices.On the M5, using the same fixed coding-agent replay as #28086 (Tiel-Coder-35B-A3B IQ3_XXS,
b10488tree), cold 5628-token prefill improved from 455 to 508 tok/s (−10.4% time), warm suffix prefill improved by about 10%, decode by 3 to 4%, and perplexity remained unchanged at 3.3969. Time to first token on that prompt dropped from 12.4 s to 11.1 s. The full 13-turn session improved by about 3% wall-clock because that workload spends roughly 70% of its time in decode.On the M1 Pro,
llama-benchon Qwen3-30B-A3B UD-IQ3_XXS improved pp4096 from 298.5 to 324.1 tok/s at-ub 256(+8.6%), and from 346.8 to 362.2 tok/s at-ub 512(+4.4%). tg64 was unchanged at 45.9 tok/s, and perplexity remained unchanged at 8.4328. The kernel-level gain below is roughly twice the end-to-end gain because the expert matmul accounts for less than half of prefill time on that model.Additional information
Who this helps. The half-tile skip fires for any
(expert, tile)with 16 or fewer routed rows. Average rows per expert areubatch * n_expert_used / n_expert, so 256-expert top-8 models such as Qwen3.6-35B-A3B and the DeepSeek-V3 family are in this regime on every tile at-ub 256. 128-expert top-8 models such as Qwen3-30B-A3B and GLM-4.5-Air hit it on partial last tiles at-ub 512and on most tiles at-ub 256. Routing skew increases the number of underfilled tiles relative to the uniform average. The dequantization change applies to iq2/iq3 models generally, both dense and MoE.Where the gain lands. On the simdgroup path used by pre-M5 Apple GPUs, all four simdgroups run
simdgroup_multiply_accumulateover the full tile regardless ofnr1. Idling the upper pair therefore removes roughly half of the matrix work on a half-empty tile.On the M5 tensor-ops path, the padded rows were already relatively cheap and the kernel is more sensitive to occupancy and A-tile dequantization, which is what the
dequantize.hchange targets. In practice, the simdgroup path benefits across quant types, while the tensor-ops path sees the larger gain on iq2/iq3 and is approximately neutral on q4_0.Upstream
test-backend-ops perfcases, q4_0, M1 Pro 16-core GPU,upstream/master = 7798007, min of 3:n ≤ 8 is effectively unchanged within ±1%. The gain tapers as tiles fill. For the 32-expert top-4 case, n=128 averages 16 rows per expert, exactly the half-tile boundary, which is why it behaves more like the n=256 cases than the n=32..64 cases.
Same cases on the M5, tensor-ops path, single pass:
These q4_0 tensor-ops results are within run-to-run noise on this setup (about ±5% for a single pass). Forcing the simdgroup path on the M5 with
GGML_METAL_TENSOR_DISABLE=1produces the same shape as the M1 Pro results, with slightly larger gains: about −42% at n=32..128.iq3_xxs / iq2_s at the production shape, M5 tensor-ops path,
master = 0eadefe, min of 3 (the 256-expert top-8ffn_*_expsshape used by Tiel, with 8 rows per expert at n=256): iq3_xxs improves by 15.6% (2223.6 → 1877.7 µs), and iq2_s by 10.2% (2115.8 → 1900.8 µs). Smaller batches improve more: iq3_xxs is −21.4% at n=64 and −17.6% at n=128. Upstream has no perf cases for these types, so these measurements use the same case shapes withtype_achanged.Trade-off to be aware of: at large n on the tensor-ops path, the half-tile skip no longer triggers and splitting the operation into two calls adds a small cost for iq2_s: n=1024 is +3.6% and n=2048 is +7.0%. iq3_xxs remains neutral there. Default ubatch sizes are in the regime where this patch wins.
I also tried gating the split so full tiles use a single full-size
matmul2dcall. That avoided the large-n two-call case in isolation, but was about 3% slower in real prefill for the reason below, so I left the current implementation as-is. I can revisit this if the large-n iq2_s regression is considered more important.Rejected alternative: a second full-
NR1matmul2ddescriptor for full tiles, allowing one call instead of two, was neutral in the microbenchmark but about 3% slower in real prefill. Keeping cooperative tensors of both shapes live raises the accumulator count per thread from 16 to 24 floats and appears to reduce occupancy.Benchmark harness: stock
test-backend-ops perfdraws the expert IDs once and then re-times the same expert set, so MoE measurements quickly become warm-cache. This PR adds areinit_perf_iterhook totest_case, called between timed iterations and never inside them, whichtest_mul_mat_iduses to redraw the IDs. This is the rotating-ID change mentioned in #28086. Every kernel number above uses the same rotating-ID behavior on both the master and PR sides. I can split this into a separate PR if that is easier to review.Test methodology
test-backend-ops test -o MUL_MAT_IDand-o MUL_MATagainst the CPU backend: 810/810 and 1221/1221 onmaster = 7798007, tested on the M5 using both the tensor-ops path andGGML_METAL_TENSOR_DISABLE=1, and on the M1 Pro.test-backend-ops perf -b MTL0 -o MUL_MAT_ID -p type_a=q4_0, using the same binary modulo the kernel files, with the rotating-ID harness enabled on both sides. All trees were confirmed withCMAKE_BUILD_TYPE=Release.cache_prompt: false. Same server configuration as metal : fix idle threads in mul_mv_iq3_xxs for ne00 < 1024 #28086.llama-bench -m Qwen3-30B-A3B-UD-IQ3_XXS.gguf -p 4096 -n 64 -ub 256,512 -fa 1, with master and PR built using the same flags.llama-perplexity ... -c 2048 -b 2048 --chunks 4 -ngl 999 -fa on -ctk q8_0 -ctv q8_0, unchanged at 3.3969 on Tiel on the M5 and 8.4328 on Qwen3-30B-A3B UD-IQ3_XXS on the M1 Pro.Related: #25377 (64x8 dense
mul_mmtile for small batches, same diagnosis on the dense kernel), #27370 (packed q8_0 dequant), and #26223 (touches the same kernel's B-tile staging; whichever lands second will need a small merge).Requirements