Repository navigation
Conversation
…perts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- No tokens, top-k > 8, or scales in a different dtype than the input: use the generic path. Before: zero tokens failed to build the Metal library, k > 32 exceeded the threadgroup limit, mixed precision returned the input dtype instead of the promoted one. - M5 and later: only below 4 tokens per expert, where MLX still runs gather_qmv. From 4 it runs the NAX kernels, which these kernels were not measured against. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This branch has not been deployed
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.
What
Adds
mlx_lm/models/switch_kernels.py, plus a 5-line hook at the top ofSwitchGLU.__call__. For quantized experts at small batch, it replaces the per-slotgather_qmmcalls:The output has the same per-slot shape as
SwitchGLU, so no model code changes. The only exception is ametal_acttag onSwiGLUand on Gemma-4'sGeGLU.When it applies
All of these must hold; otherwise the existing path runs unchanged:
gather_qmmon M4 Pro;gather_qmmon the NAX kernels, which I could not measure against (see Hardware scope).Gradients: the op is an
mx.custom_functionwhose VJP is thegather_qmmmath, so LoRA training throughSwitchGLUis unaffected (test_gradient_matches_generic_path).Results (M4 Pro 48 GB,
mlx_lm benchmark -p 128 -g 256 -n 3, main vs this branch, alternating, 2 passes)generation tok/s, mean of 2 passes (each
-n 3). Prompt (prefill) speed: 8–20% faster at B=1 (e.g. Qwen3 505 → 602 tok/s), unchanged (±1%) at B=8 and B=32.Hardware scope
applegpu_g16s), macOS 26. Nothing was run on an M5 or M6.gather_qmv/gather_qmm_rhswithout NAX in MLX). Not measured on M1–M3 or on base/Max/Ultra variants; the gain depends on memory bandwidth and GPU core count.Why it should work on M5 / M6 (reasoning from the MLX source, not a measurement):
mx.fast.metal_kernelcode: threadgroups of at most 256 threads and 8×8simdgroup_matrix, which MLX's own GEMM kernels use on every Apple GPU. There is no chip-specific code.SwitchGLUcallsgather_qmmwith M = 1.GatherQMM::eval_gpusends that togather_qmm_rhsonly for sorted indices with B ≥ 16 and B/E ≥ 4, and otherwise togather_qmv, which has no NAX variant. The gain in this range comes from reading and dequantizing each expert once per step instead of once per (token, slot). That is fewer bytes moved, which does not depend on the GPU generation.gather_qmm_rhsruns the NAX kernel on M5 / M6, which I could not measure against. On GPU generation ≥ 17 the kernels are used only below 4 (_nax_gpu(),test_nax_gpu_keeps_gather_qmm_rhs), so that range runs exactly as on main.To be checked on an M5 / M6 (I have no access to one; any of the five models works):
python -m unittest tests.test_switch_kernelspasses (fp32 reference match, gradient, fallbacks).mlx_lm benchmark --model mlx-community/Qwen3-30B-A3B-4bit -p 128 -g 256 -n 3 -b 8and-b 32, main vs this branch. Expected: generation tok/s higher on the branch, prompt tok/s equal. The size of the gain is unknown: bandwidth differs and the dense layers run NAX kernels there.switch_kernels._nax = False(the M4 gate) onmlx-community/LFM2.5-8B-A1B-MLX-4bit -b 32(4 per expert) and on the-b 1prompt speed (8–16 per expert). If the kernels still beat NAX there, the limit can be raised.If 1 or 2 fails, I will restrict the PR to GPU generation ≤ 16.
Scope and alternatives
mlx_lm.serverwith--decode-concurrency,batch_generate). A single stream gains only 3–8%.gather_qmmdoes not see as a unit. A coregather_qmmchange would be the cleaner long-term home; this PR is self-contained, opt-out by format, and easy to remove if that happens. Happy to move it (for example intomlx_lm/kernels/next to the other fused kernels), split it (e.g. the T > 1 path only), or put it behind a flag if you prefer.Accuracy
gather_qmm's (the kernels accumulate in fp32).Tests
tests/test_switch_kernels.py: reference match, gradient, fallbacks, zero tokens, NAX gate.python -m unittest discover tests, 413 tests OK. Formatted with the pinned pre-commit hooks.Methodology and wider measurements (several launches, CIs, bandwidth guard): https://github.com/Tigerly1/moe-lab
🤖 Generated with Claude Code