Small-L fused expert gather for fine-grained MoE decode — measured expert-overlap data on GLM-5.2 (34% adjacent-token, depth-graded to 52%) #3801
Replies: 9 comments
-
|
This is really well characterized — the depth-graded overlap curve (34% mean rising to ~52% in the deep layers) is the part that makes it actionable, since it says a partial implementation fused on just the last ~30 layers captures most of the recoverable bytes. One framing that might help scope the kernel: the small-L union path is essentially a tiny grouped/segmented GEMM — sort the I've done some quantized-matmul kernel work in mlx core (the mxfp4 matvec correctness fix, #3804), so I'd be happy to help prototype the small-L union gather on the Two questions to make that useful:
Happy to share the reproducible small-rig harness the other direction too. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks — these are exactly the right two questions, and answering them properly made us re-measure. Full disclosure up front: the re-measurement moves the target, and I'd rather hand you that before you write Metal than after. Q1: the ~25% was pure expert-byte arithmetic — and it no longer holds on current mlxThe 25% figure was derived purely from the byte overlap: 13.3 unique / 16 slots → 1.66× "floor" applied to the whole forward, vs the 2.2× measured. It did not net out attention, router, shared/dense MLP. Your instinct to pin that down was right. So today we isolated the actual production path: one real GLM-5.2 MoE layer's routed bank (mxfp4, 256×3×6144×2048, ~20 MB/expert), loaded into mlx-lm's
Two conclusions we did not expect when we opened this thread:
This also answers your ragged-vs-padded question at the byte level: with the existing path within ~10% of floor, padding strategy can't move much. The residual worth chasing is absolute small-row bandwidth: L=1 streams 160 MB in 0.55 ms ≈ 290 GB/s effective (~36% of the machine's peak), improving to ~430 GB/s at L=2–6. A kernel that lifts small-row gather-matvec bandwidth helps every decode, spec or not — that's closer to #3757 ( Where the multi-row cost actually lives now, per production traces (oMLX Lightning MTP on this box): a verify cycle at M≈2–3 still costs ~100 ms vs ~40–50 ms single-row — the residual ~2× is in the DSA attention/indexer path and per-cycle sync, not expert bytes (cf. jundot/omlx#2160, where verify rows landing on the prefill indexer kernel cost 5×). The ecosystem gap we described is real, but it has moved out of the expert gather. Q2: measured answer — use Qwen3.6-35B-A3B, and here is its overlap curveNeither OLMoE (top-8/64) nor Qwen3-30B (8/128) matches GLM's granularity, but Qwen3.6-35B-A3B does exactly: top-8 of 256 routed experts (tiny experts, 2048×512), and it fits your 64 GB (mxfp8 ≈ 37 GB, 4-bit ≈ 19 GB comfortable). We ran the same router instrumentation on it today — 256-token greedy decode, one prose + one code prompt, top-k recorded at all 40 MoE layers: Mean adjacent overlap 34.9%, 13.2 unique experts per L=2 pair — statistically identical to GLM-5.2 (34.1%, 13.3). The routing granularity (8/256) appears to determine the overlap statistics almost entirely. Shape differs mildly: GLM ramps monotonically with depth (21%→45–52%), Qwen plateaus at 35–40% from mid-depth — but for kernel iteration the group-size distribution is what matters, and it matches. One caveat so the proxy doesn't mislead: on 35B-A3B the routed experts are only ~30% of active bytes per token (vs ~75% on GLM-5.2), so end-to-end speedups won't transfer — but per-switch-layer timings and overlap/raggedness stats will, which is what you need to iterate a kernel. Happy to share both scripts (the 3-line router recorder + the bank microbench, they're short) and to run any kernel candidate against real GLM-5.2 banks on the M3 Ultra with quick turnaround. And if after the numbers above you'd rather aim at absolute small-row gather bandwidth than union dedup — we'd gladly bench that too; it's the bigger prize on this hardware. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks for re-measuring instead of defending the headline — that's the useful kind of answer, and it lines up with something I found from the dispatch side, so let me close the loop from both ends. Your "L=2 is already within ~10% of the union-bytes floor" is exactly what the dispatch path predicts. The fused dedup kernel already exists — Independent check on the traffic ceiling: I ran the same router instrumentation on Qwen3-30B-A3B (8/128, what I could run at the time) over 256 greedy-decode tokens — recoverable L=2 18.8%, L=4 35.4%, L=8 51.6% (bytes, so it's the union-floor ceiling your wall-time sits ~10% under). One divergence worth flagging: Qwen3-30B's overlap is flat across depth (~18–20% in every layer group), not depth-graded like GLM's climb to ~52% — so "fuse only the deep layers" is model-specific. Your Qwen3.6-35B-A3B (8/256) is the cleaner proxy and I'll switch to it. So — sold on the pivot. Absolute small-row gather-matvec bandwidth (your L=1 ≈ 290 GB/s, ~36% of peak) is the bigger prize and squarely the lane I want to be in — closer to #3757 Two things before I start:
|
Beta Was this translation helpful? Give feedback.
-
|
Great find on Both questions answered with measurements: Q1: it's occupancy, by a wide marginDecomposition on the real GLM-5.2 layer-40 gate bank (256×2048×6144, mxfp4 gs32), M3 Ultra, one experiment per hypothesis: (a) k-scaling — occupancy (single position, k unique experts in one
Time is nearly flat from k=1 to k=8 — at decode size the GPU is row-starved, not bandwidth-bound. Two corroborating points: a contiguous plain-qmv on 2048 rows (6.7 MB ≈ 8 µs of streaming at peak) still takes 0.24 ms, i.e. a ~30× latency/occupancy floor over the byte time; and gathering 8× the same expert (working set ÷8) costs the same as 8 distinct ones — time doesn't follow bytes at this scale. (b) gather vs contiguous — indirection: same bytes, same 16384 rows, only the indirection differs: 0.274 ms gathered vs 0.238 ms contiguous → ~14% penalty. Real but secondary. (c) dequant mode: identical shapes/indices, random weights: affine gs32 208 GB/s, mxfp4 195 GB/s (~6% slower), and mxfp8 (2× bytes) reaches 278 GB/s effective in only 1.36× the time — more bytes per row actually helps the utilization, which again points at occupancy rather than dequant throughput. So the ranking is: occupancy ≫ gather indirection (~14%) > mxfp4 dequant (~6%). For the kernel that means the win is in getting more useful rows in flight per dispatch at M=1/k=8 — fusing the three GLU matrices into one dispatch (3× rows), processing several experts per threadgroup, and shaving the fixed dispatch floor — rather than in the memory pattern per se. One measurement caveat: these are Q2: #3757 does not cover the M=1 decode shape yetPer the PR description, current ScriptsAll three are here, standalone, with a README: https://github.com/anicaise-ai/moe-small-l-bench Standing offer unchanged: send kernel candidates (branch or patch) and we'll run them against the real GLM-5.2 banks on the M3 Ultra, usually same-day. |
Beta Was this translation helpful? Give feedback.
-
|
That decomposition settles it — flat time from k=1 to 8 with a 30× floor on even the contiguous case is occupancy, full stop, and it changes what's worth building. Thanks for re-measuring twice now; this thread keeps getting more useful. Reading the dispatch code against your numbers makes the mechanism concrete: gather_qmv launches So the candidate I'll prototype is split-K for gather_qmv: grid Plan: metal_kernel prototype against a synthetic 256×2048×6144 mxfp4 bank on my M3 Max first, using your decomposition harness as the baseline so the numbers are directly comparable. If it clears ~1.5× on the M=1/k=8 shape I'll port it into quantized.cpp behind an occupancy guard and take you up on the GLM-5.2 validation offer. Will report back either way — including if the prototype embarrasses the theory. |
Beta Was this translation helpful? Give feedback.
-
|
Promised to report back even if the prototype embarrassed the theory — it did, and the failure is more informative than a win would have been. I built the split-K gather-qmv as a metal_kernel prototype (affine-4bit stand-in, correctness at 5e-6 against the reference) and swept it properly: scalar and uint4-vectorized variants, 1–8 rows per threadgroup, split factors 1–16, up to 65k threadgroups on the k=8 decode shape. Everything lands within ±20% of the stock gather_qmm call (0.455 ms, ~166 GB/s on my M3 Max). More threadgroups actually made it slightly worse. So threadgroup-count occupancy is not the limiter, at least on this part. What made your numbers click for me afterward: take the marginal rate of your own k-scaling instead of the averages — (k=256 − k=128) works out to ~8.9 µs per expert ≈ 750 GB/s, and (k=128 − k=32) ≈ 655 GB/s. Streaming beyond the first ~0.3 ms runs near peak. The "19% of peak at k=8" is a fixed per-dispatch latency/ramp floor, and no grid shaping inside the dispatch can remove a fixed floor — which is exactly what my flat sweep measured from the other side. The good news is your suggestion list already contained the thing that does work: amortization. My M3 Max kscan has k=8 at 0.583 ms and k=16 at 0.831 ms — 2× the bytes in one dispatch for 1.43× the time. That makes gate/up stacking (one [2N, K] bank per expert, same x, same indices) worth ~1.4× on that pair with zero Metal — it's a SwitchGLU-level change. Down-proj stays separate for the data dependency, so a MoE layer goes from 3 dispatches to 2, with the big one better amortized. Next step on my side: patch stacked gate_up into mlx-lm's SwitchGLU and measure end-to-end decode on Qwen3-30B-A3B (fits my 64 GB). If the ~1.4×-on-the-pair holds end-to-end it should be worth roughly 15–20% of MoE-projection time on your GLM-5.2 numbers too — and that one's easy for you to validate since it's a Python-level patch, no kernel build. |
Beta Was this translation helpful? Give feedback.
-
|
Follow-through on the stacking idea, and it closes the loop on this whole line of inquiry — mostly by deflating it, including my own last theory. Three measurements:
So the honest summary for anyone landing on this thread: at batch-1 decode, current mlx gather_qmm/qmv is already within ~1.3× of its practical in-graph envelope on these shapes, the L=2 verify cost is mostly genuine unique bytes, and the recoverable prize for any small-L restructuring is far smaller than the numbers at the top of this discussion suggested. What's left — pushing the 4-bit gather inner loop from ~60% toward 80%+ of peak — is a qmv streaming-efficiency question that doesn't depend on L or expert overlap at all, and probably belongs to the maintainers' qmv tuning rather than a new kernel. Your instinct to re-measure twice was the right call both times; happy to have the negative results on the record next to the data. If you want the SwitchGLU stacking patch anyway (it's harmless, bitwise-identical, and one dispatch fewer), it's ~20 lines — but I wouldn't claim perf for it. |
Beta Was this translation helpful? Give feedback.
-
|
Confirming your results on the 80-core M3 Ultra, on the real GLM-5.2 mxfp4 banks — and closing this out from our side. First, a correction we owe the thread: the ~0.3 ms/dispatch "floor" entered this discussion through our eval-per-call decomposition, and your in-graph probe shows it was a timing artifact, not a hardware property. Worth stating explicitly since we propagated it. Replication of your probe #2 on production weights (
split/stacked ratio = 0.984× / 0.997× — same conclusion as your 1.024× on the M3 Max: within noise of 1.0, stacking buys nothing once you're inside one command buffer. And the eval-per-call floor reproduces here too (~0.33 ms on an 80-core, vs your 0.455 ms on the Max) while the same dispatch costs 105 µs in-graph — a ~3.1× measurement artifact, scale-invariant across both parts. So the full picture holds on production weights: batch-1 decode |
Beta Was this translation helpful? Give feedback.
-
|
Agreed on all of it — and thanks for replicating the in-graph probe on production weights; that's what makes this a trustworthy negative rather than just my M3 Max noise. Good outcome even as a "don't build it": ~62%-of-peak-in-real-graphs is the number to walk away with, and it kills a kernel that'd have been a lot of Metal for nothing. If anyone picks up the generic qmv gap, I'll ping this thread. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Context
On fine-grained MoE models (GLM-5.2: top-8 of 256 routed experts, 78 layers), decode-time forwards at small sequence lengths (L=2–4) cost nearly L× the L=1 forward on Apple Silicon, because each position routes to a mostly-disjoint expert set and routed-expert weights dominate memory traffic. This makes speculative decoding (including GLM-5.2's own MTP head, which Zhipu designed for it) structurally unprofitable at batch 1 — the verify forward eats the speedup.
Measured on M3 Ultra 512 GB, GLM-5.2 mxfp4 (via mlx-lm 0.31.3 + the community
glm_moe_dsaport):The data: adjacent-token expert overlap is substantial and depth-graded
We instrumented the router on real greedy decode traffic (256 tokens × 75 MoE layers). Adjacent tokens share far more experts than independent streams would (~3 % expected at random):
Two implications:
gather_qmm-based switch layers).With verify at its 1.6× floor, MTP@90 % accept turns into a ~+10–15 % net decode win on these models — and CUDA stacks (sglang/vllm grouped-GEMM) already realize MTP gains on the same architecture, so this is an ecosystem gap rather than a conceptual wall.
Ask
Is a small-L (2–8) union-of-experts fused path in
gather_qmm/ the quantized switch-layer path something the team would consider? Happy to:Beta Was this translation helpful? Give feedback.
All reactions