Repository navigation
perf(moe): attribute the MoE decode gap at the M = 1 expert gather #1859
Description
Problem / Background
Two MoE families reach gather_qmm with M = 1 on every decode token and never reach the fused decode-MoE kernel. That much is a code fact, verified below against 60341873. Whether it is the dominant term in a measured decode gap is not established, and this issue asks for a profiled attribution before any change is written.
The shape. SwitchGLU::forward (src/models/switch_layers.rs:939-963) expands x with two expand_dims at switch_layers.rs:946-947, producing [n, 1, 1, K], so gather_qmm sees M = 1 and B = n * top_k. The GB10 attribution record for #1799 states this and names its consequence on CUDA. docs/benchmark_results/laguna-dflash-verify-cost-gb10-2026-09-11.md:96: "SwitchGLU::forward expands x to [n, 1, 1, K], so gather_qmm sees M = 1, B = 8n and the routed experts take qmm_sm80 at every width including classic". qmm_sm80 is the prefill GEMM; docs/benchmark_results/qmm-sm121-tile-tuning-gb10-2026-07-10.md:24 records that single-sequence decode is supposed to take qmv. So on CUDA the routed experts are already known to run a tiled GEMM at one row per (token, expert) pair during classic decode, on a different backend and a different checkpoint from the observation below.
What is specific to these two families is that neither has the fused decode-MoE kernel as an escape hatch. Eighteen families wire SwitchGLU::forward_fused_kernel (grep -rln forward_fused_kernel src/models/, excluding the definition and a test file); deepseek_v4 and inkling are not among them.
deepseek_v4does not useSwitchGLUat all. It carries its ownLimitedSwitchGlu::forward(src/models/deepseek_v4_moe.rs:242-252) with the same twoexpand_dimsatdeepseek_v4_moe.rs:243-244, and it passessorted: falseunconditionally. The module doc is explicit atdeepseek_v4_moe.rs:49-50: "The fused MoE decode kernel (MLXCEL_FUSED_MOE) is deliberately not wired: it has no clamp stage."inklingcallsSwitchGLU::forward_with_expert_scales(src/models/inkling/mlp.rs:217-222). That method has no fused-kernel branch at all: it delegates toSwitchGLU::forwardonly when both sidecars areNone(switch_layers.rs:982-984) and otherwise runs the unsorted gather (switch_layers.rs:986-989), documented as deliberate atswitch_layers.rs:970-972.
Sorting does not rescue decode either. SwitchGLU::forward sorts only when n_tokens * top_k >= 64 (switch_layers.rs:940-944), which one decode token never reaches, and both forward_with_expert_scales and LimitedSwitchGlu::forward are unsorted by construction.
Corroborating observation (Metal, one session, unprofiled)
An Apple Silicon session on a Mac Studio M3 Ultra benchmarked deepseek-v4-flash-4bit, inkling-small-mlx-4bit and inkling-small-nvfp4 against mlx-vlm on 2026-09-08 and reported it today. Matched shape on both sides: 512-token prompt, 128 generated, warmup pass, EOS suppressed, no chat template. mlxcel decode landed at 16 to 23 percent of the mlx-vlm baseline while prefill was 55 to 82 percent, consistent across both architectures and across text and image modalities. Decode being the affected phase while prefill is much less so points away from the GEMMs and toward the per-token expert gather, which is why this is recorded rather than dropped. It is a number plus an inference, not a mechanism, so it supports the section above rather than standing on its own.
That session reports both checkpoints as 256-expert top-6. Neither checkpoint is present on the GB10 host (models/mlx holds 190 checkpoints, none matching), so the expert counts and routing widths are unverified here and should be read from each config.json at re-measurement time.
Limits, which are severe
- Metal only, one run per cell, not profiled, no kernel-level attribution.
- The two Inkling rows are not a quantization comparison.
inkling-small-mlx-4bitcarriesrecipe: experts_onlywhile the NVFP4 build quantizes a different scope, so they are one checkpoint under two non-comparable recipes and must not be presented as 4-bit against NVFP4. - Measured on mlxcel
b8fdddfa, which is 124 commits behind60341873, against MLX pin9a795735. Main is now on81ba1c6a, and the Metal quantized kernels changed in between.8ad62d4b(fix(mlx): bump MLX pin to 81ba1c6a so mxfp8 block scales round up #1772) rewrote 150 lines ofsrc/lib/mlx-cpp/patches/mlx/backend/metal/quantized.cpp, including the tile choice, which went fromint bm = 64;toint bm = (transpose && M <= 32) ? 32 : 64;under the upstream comment "Use smaller bm when one block covers all of M". Decode isM = 1, so that switch fires on exactly the shape this issue is about and did not exist when the measurement was taken. Eight diff lines in the same file touchgather_qmm:gather_qmm_t_naxandgather_qmm_n_naxgained aglobal_scaleparameter and a_hgskernel-name suffix. Do not write this off as "Metal was untouched". The mlxcel-side commits in the window are largely CUDA and ROCm, but the pin bump reaches every backend, andsrc/models/switch_layers.rsitself moved 231 lines (Laguna NVFP4 and Kimi K3 SiTU, from feat(models): add the Laguna family with compressed-tensors NVFP4 experts #1739 and feat(models): add the Kimi K3 text backbone #1741). - The one thing that did not move: the
x_expexpansion and thedo_sortthreshold are byte-identical acrossb8fdddfa..60341873, and thedeepseek_v4andinklingcall sites are unchanged. The hypothesis is still live on main; the kernels underneath it are not the ones that were measured. That is a reason to re-measure first, not a reason to discount the lead.
Prior art, so it is not re-derived
- perf(models): general MoE decode gap vs mlx-lm (dots.llm1 0.51x, qwen3_moe 0.69x, nemotron MoE) #268 (closed,
docs/benchmark_results/moe-decode-gap-investigation.md) is the general MoE decode gap against mlx-lm on Metal: worst case dots.llm1 at 0.51x, qwen3_moe 0.69x, nemotron-h 0.60x. It concluded the residual is diffuse per-op execution overhead with no single fusable lever, and it named a fused decode-MoE kernel as the highest-potential path. Every family in that table has that kernel today. 0.16 to 0.23x sits far outside its range, which is consistent with these two families lacking the escape hatch the others received, and is why this is not simply a duplicate of perf(models): general MoE decode gap vs mlx-lm (dots.llm1 0.51x, qwen3_moe 0.69x, nemotron MoE) #268. - perf(moe): batched decode scales 1.55x against 3.25x on dense #1616 (closed) found MoE batched decode scaling to 1.55x against 3.25x on dense, traced it to the fused kernel being a B=1-only path, and already analysed the
do_sortthreshold and the one-row-per-(token, k) expert traffic. Its rejected alternative (forcingsupports_batching()false for MoE) should not be relitigated. - perf(speculative): attribute the Laguna DFlash verify round's fixed and per-row cost on GB10 #1799 is the source of the
M = 1, B = 8nfinding quoted above. perf(cuda): evaluate raising the MLX graph ops budget on GB10 (sm_121) #1798 is the adjacent GB10 investigation from the same sweep, on the MLX graph ops budget; it is useful for the host, method and noise-floor conventions used there, but it is not where the gather-shape finding came from.
Proposed Solution
Measure before changing anything, in this order.
- Re-measure on current main, on both backends. Repeat the reporting session's matched shape on
60341873or later on Metal, and add a CUDA arm on GB10, which requires fetching both checkpoints. Report n of at least 3 with per-arm min-to-max ranges and treat overlapping ranges as no effect, following the convention indocs/benchmark_results/cuda-graph-budget-gb10-2026-09-12.md. Include one family that does have the fused kernel as a control, so a general regression is distinguishable from a family-specific one. - Attribute the gap by kernel, not by inference. Confirm which kernel the routed experts actually take at decode on each backend (
MLXCEL_TRACE_ARCHprints the quantized-matmul path on CUDA, perdocs/environment-variables.md:504), and split the per-token cost into router, expert gather and combine with themlxcel-gpu-profilinghooks already used indocs/benchmark_results/moe-decode-gap-investigation.md. The deliverable is a committed record underdocs/benchmark_results/naming the kernels with their per-token launch counts and milliseconds. - Only then decide what to build. Candidates, in the order perf(models): general MoE decode gap vs mlx-lm (dots.llm1 0.51x, qwen3_moe 0.69x, nemotron MoE) #268 and perf(moe): batched decode scales 1.55x against 3.25x on dense #1616 already ranked them: give these two families a fused path (which for
deepseek_v4means a clamp stage the current kernel lacks, perdeepseek_v4_moe.rs:49-50), deduplicate expert planes across the gathered rows, or re-tune thedo_sortthreshold. None should start before step 2 is committed.
Named risk. The obvious wrong turn is accepting the M = 1 hypothesis because it is tidy and fits the numbers. The #1798 and #1799 investigations both opened with a plausible mechanism that the source then contradicted: #1799's own record corrects two of its issue's premises at laguna-dflash-verify-cost-gb10-2026-09-11.md:96, including a supposed fp_qmv to qmm_sm80 family switch on the dense projections that turned out not to exist on that pairing because those projections are plain bf16. This issue requires a profiled attribution; a mechanism that merely fits is not an answer.
Scope
In scope: a committed benchmark and profile record under docs/benchmark_results/; the kernel-level attribution on Metal and on CUDA; the decision about what, if anything, to build, written down with its evidence.
Out of scope: any edit to src/models/switch_layers.rs, src/models/deepseek_v4_moe.rs or src/models/inkling/mlp.rs before step 2 is committed; batched decode, which #1616 owns; the bench_mlxlm.py memory budget, filed separately.
Acceptance Criteria
- A record under
docs/benchmark_results/reports decode and prefill against the mlx-vlm baseline for both families on current main, on Metal and on CUDA, with host, MLX pin, tree commit, n and per-arm ranges. - The record names the kernel the routed experts take at decode on each backend, with per-token launch counts and milliseconds, and gives the router / gather / combine split.
- The record includes a fused-kernel family as a control, so a family-specific gap is distinguishable from a general one.
- The record states explicitly whether the
M = 1gather shape is or is not the dominant term, naming the measurement that settles it. - Any resulting code change is reached by the real decode path used by
mlxcel-serverandmlxcel generate, not by a bench-only path, and either preserves greedy temp-0 token identity or documents exactly where it does not. - A measured negative closes this issue, provided the measurement meets the criteria above.
Verification
# Neither checkpoint is on the GB10 host; fetch before the CUDA arm.
ls models/mlx | grep -iE "deepseek-v4|inkling"
make release-cuda
# Metal: cargo build --release --features metal,accelerate --bin mlxcel-bench-decode
# Paired arms, same shape on both sides, n >= 3 after a discarded warm-up.
./target/release/mlxcel-bench-decode --model models/mlx/<ckpt> --max-tokens 128 --warmup-tokens 20
MODELS_DIR=models/mlx ./scripts/bench_mlxlm.py models/mlx/<ckpt>
# Which quantized-matmul path the experts actually take (CUDA).
MLXCEL_TRACE_ARCH=1 ./target/release/mlxcel-bench-decode --model models/mlx/<ckpt> --max-tokens 4
# Control: a family that does have the fused kernel, with it forced off.
MLXCEL_FUSED_MOE=0 ./target/release/mlxcel-bench-decode --model models/mlx/qwen3-30b-a3b-4bit --max-tokens 128
Activity
inureyes commentedon Sep 12, 2026
Two facts confirmed, and a count that resolves to two different right answers
The reporting session read the checkpoints and the tree on its own host, so two items this issue attributed or estimated can now be stated outright.
The expert geometry is confirmed, not attributed. All three checkpoints are 256 routed experts with top-6 routing:
deepseek-v4-flash-4bit(43 layers, hidden 4096),inkling-small-mlx-4bit(42 layers, hidden 4096),inkling-small-nvfp4(42 layers, hidden 4096). One trap worth recording for whoever re-measures: DeepSeek carriesn_routed_expertsandnum_experts_per_tokat the top level ofconfig.json, while both Inkling builds carry them undertext_config. Reading for the wrong key concludes the field is missing.The fused-kernel exclusion is confirmed independently.
grep -q forward_fused_kernelis false for bothsrc/models/deepseek_v4_moe.rsandsrc/models/inkling/mlp.rson both hosts, anddeepseek_v4_moe.rs:50states the reason in the source: the fused MoE decode kernel is "deliberately not wired: it has no clamp stage." So this is a deliberate exclusion with a stated cause rather than an oversight, which is what makes it a better account of the measured gap than theM = 1shape alone.On the family count, both numbers are correct and the disagreement is itself evidence. This issue said 18 wired families; the reporting session counted 17 and listed them. Reconciled: 20 files under
src/models/referenceforward_fused_kernel, minusswitch_layers.rs(which holds the sharedpub fnat:1026) andminimax_tests.rs(a test) leaves 18 on current main. The reporting session's 17 is correct for the tree it measured on,b8fdddfa. The one file it lacks issrc/models/laguna_layers.rs:572, which arrived with5a5e73b4(#1739, Laguna NVFP4 experts) on 2026-09-10, after that baseline.git cat-file -e b8fdddfa:src/models/laguna_layers.rsfails, confirming it was absent.So the wired count is 18 on main, 17 at the measurement baseline, and the difference is exactly one family added in the window. That is a small but concrete instance of the staleness this issue already asks re-measurement to clear, and neither of the two families in question is wired in either count, which is the part that decides the mechanism.
Note that
qwen3_moe.rs:286also defines its ownpub fn forward_fused_kernelrather than only calling the shared one, so a count keyed on the definition site alone would also mislead.