Skip to content

perf: Apple7/8 MoE gate and up in one decode pass - #45

Merged
paperniuk merged 1 commit into
paperniuk:apple7-m1-1.5from
ghisguth:apple7-m1-moe-gate-up
Nov 5, 2026
Merged

paperniuk merged 1 commit into
paperniuk:apple7-m1-1.5from
ghisguth:apple7-m1-moe-gate-up

Conversation

@ghisguth

@ghisguth ghisguth commented Nov 3, 2026 •

Copy link
Copy Markdown

@paperniuk this one is for MoE decode on M1/M2. I profiled Flash-Next Q2_0 decode on my M2 Max and the routed experts
are about a third of a one-lane cycle, ~40% at eight verify rows. On Apple7/8 the MMA expert tile runs gate and up
as two passes, the up pass reading the gate back. This runs both in one pass, like the staged tile does on Apple9+.
Outputs are bitwise the same. Flash-Next Q2_0 decode cycle is 2.4–4.5% faster, the Coder and IQ3_XXS
checkpoints a bit less, and the expert kernel is faster for every GGUF format I tried, MLX 4-bit too. As before, I
used Claude Code to author it and tested it by hand.

What changed

  • runtime/ops/MoE.cpp: oneGateUpPass is also true for the Apple7/8 MMA tile in decode. Prefill keeps two passes
    (I didn't measure it). The rest is the existing one-pass path: bindings, 32-column grid, _mixed tables.
  • kernels/shared/moe_gguf_mma.metal: moe_expert_gguf_mma_m8_gate_up[_mixed][_s2]. A gate simdgroup and an up
    simdgroup on the same 32 columns, each projection on the tile its own pass takes (the column tile for a Q2_0 tile
    of one or two rows, the MMA tile otherwise). The gate parks bf16(gate) in its own stage, one barrier, then the up
    writes bf16(up) * silu(gate) like the up pass. No new threadgroup memory (6 KB, 7 KB for _mixed).
  • Where gate and up both take the column tile, one lane runs its column's gate and up chains interleaved
    (column_dot_pair in gguf_column_tile.h), so their FMAs overlap and the activation loads serve both. Each chain
    keeps column_dot's order. The even threadgroup's two simdgroups take the odd one's columns, so the pairs run on as
    many simdgroups as before.
  • gguf_moe_test.mm: expects one pass on the MMA tile in decode. The existing check then splits the one-pass dispatch
    back into the _a / _g passes and compares the output bit for bit on every run, every format the MMA tile takes.

What I tried first and dropped: the staged one-pass kernel on Apple7/8 is 8–14% slower in the cycle (its multi-row
tiles go to the MPP matmul). Gate then up serially in each simdgroup made the Coder up to 1.4% slower.

Results (M2 Max, 38-core GPU)

Expert kernel alone, gguf-moe-benchmark (one MoE layer, random weights in each model's shape and format, no model
loaded), ms per layer, Δ two passes → one pass (%), A B B A, 1 = a verify of one row,
r / p = 8–32 rows with random / pooled routes:

Shape, gate/up format 1 8 r / p 16 r / p 32 r / p
35B-A3B, Q4_K −27.2 −9.5 / −6.6 −5.0 / −9.4 −1.9 / −2.7
35B-A3B, Q5_K −27.6 −8.7 / −6.6 −4.7 / −9.8 −2.3 / −3.1
35B-A3B, Q6_K −27.6 −7.9 / −6.3 −3.6 / −8.1 −1.2 / −2.0
35B-A3B, IQ4_XS −30.7 −8.5 / −3.4 −4.6 / −7.8 −0.7 / −1.6
35B-A3B, IQ4_NL −33.3 −11.2 / −4.7 −5.4 / −9.8 −1.3 / −2.2
35B-A3B, Q8_0 −33.5 −10.2 / −0.9 −5.0 / −6.9 −1.5 / −3.7
35B-A3B, MLX 4-bit −29.7 −8.5 / −6.6 −4.1 / −8.8 −1.8 / −2.6
Flint, Q4_K −29.8 −11.0 / −20.3 −2.1 / −2.5 −3.2 / −4.7
Flint, MLX 4-bit −30.3 −11.2 / −16.7 −1.7 / +0.2 −2.9 / −4.4
Flash-Next, Q2_0 −24.0 −15.8 / −1.5 −11.8 / −7.2 −11.3 / −7.0

The only plus (Flint MLX 4-bit, 16 pooled) is inside that cell's run-to-run noise (0.3%).

decode-profile, Flash-Next, 2048-token prompt, int8 KV, fused GPU ms per cycle at 1 / 2 / 3 / 4 lanes, A B B A,
eight verify rows per lane (Q2_0 four runs per side, the others two):

Model Before After Δ (%)
Q2_0 60.05 / 90.71 / 116.72 / 144.03 57.38 / 86.88 / 113.38 / 140.55 −4.5 / −4.2 / −2.9 / −2.4
Coder (IQ1_M) 70.87 / 103.11 / 134.06 / 161.91 68.72 / 101.38 / 133.50 / 160.82 −3.0 / −1.7 / −0.4 / −0.7
UD-IQ3_XXS 69.90 / 104.38 / 133.28 / 162.24 68.50 / 102.91 / 132.18 / 161.30 −2.0 / −1.4 / −0.8 / −0.6
Q2_0, 32K prompt 61.57 / 94.49 / 121.76 / 149.73 59.14 / 90.57 / 117.86 / 146.64 −3.9 / −4.1 / −3.2 / −2.1

With the verify budget's own rows (noisier, the rows differ between runs):

Model Before After Δ (%)
Q2_0 53.59 / 78.90 / 98.27 / 118.93 53.05 / 76.65 / 95.17 / 115.41 −1.0 / −2.9 / −3.2 / −3.0
Coder (IQ1_M) 60.75 / 87.32 / 109.68 / 131.35 60.36 / 84.91 / 110.28 / 131.06 −0.6 / −2.8 / +0.6 / −0.2
UD-IQ3_XXS 58.41 / 86.83 / 110.75 / 132.74 59.22 / 85.99 / 110.14 / 131.94 +1.4 / −1.0 / −0.5 / −0.6
Q2_0, 32K prompt 54.80 / 82.38 / 101.97 / 124.77 54.57 / 78.78 / 100.67 / 123.45 −0.4 / −4.4 / −1.3 / −1.1

The IQ3_XXS one-lane +1.4% is not noise in these runs, both runs per side agree within 1%. With the budget the two
builds verify different rows. A second A B B A gave +1.0% again. With a debug build that logs the rows, the same
kernel at the same rows is 2.5% faster at one lane, and at a fixed eight rows (table above) it is 2.0% faster. So I think
it is the rows the budget picks, but I can't show the rows of those exact runs.

Server, Flash-Next Q2_0 with my usual flags, median decode tok/s of 5 greedy 400-token requests per run, two runs
each: 55.83 / 55.50 before and 57.64 / 56.66 after (+2.6%). Per request in cycles per second, which
doesn't depend on how many draft tokens get accepted, 18.5 → 18.9 (+2.4%), faster on every request.

Long agent chat replay in one of my tests (78 turns up to 125K context, each sent twice), server log, base → this
PR. Cycles per second is the decode speed without the effect of how many draft tokens get accepted:

Model Context Tok/cycle Cycles/s Δ cycles/s (%)
Q2_0 32K 3.25 → 3.25 16.97 → 17.55 +3.4
Q2_0 64K 4.31 → 4.36 16.22 → 16.83 +3.8
Q2_0 96K 4.37 → 4.51 16.00 → 16.59 +3.7
Q2_0 128K 5.06 → 5.05 15.56 → 16.18 +4.0
Coder 32K 3.23 → 3.21 15.21 → 15.28 +0.4
Coder 64K 4.32 → 4.30 14.33 → 14.65 +2.2
Coder 96K 4.62 → 4.71 14.04 → 14.30 +1.9
Coder 128K 5.08 → 5.06 13.57 → 13.90 +2.5

Prefill is the same (paired requests +0.2% and −0.1%). IQ3_XXS didn't fit 156K of context at my memory setting, so it
has no replay numbers.

Testing

  • gguf-moe and gguf-moe-e512 pass on the M2 Max with and without MTL_SHADER_VALIDATION. I also checked that
    they catch a broken mixed tile (gate and up on different tiles, which a shared expert with Q2_0 gate and another up
    format hits): a build that zeroes those tiles fails both tests, in both directions.
  • linear-plan passes with and without validation.
  • make check passes on the M2 Max up to the app tests, all Metal tests under validation included (the MoE ones
    don't time out anymore). swift test for the app hung downloading Sparkle here, so I stopped it. It doesn't touch
    this code.
  • Not tested on M1. The kernel uses only what the existing MMA expert kernels use.

Apple7/8's MMA expert tile ran a decode step's gate and up projections as two
passes, the up pass reading the gate's bf16 results back. One pass now runs
both (moe_expert_gguf_mma_m8_gate_up[_mixed], the staged tile's bindings and
grid): a gate and an up simdgroup on the same 32 columns, each projection on
the tile its own pass takes, the gate parked in its stage. Where both take the
column tile (Q2_0 tiles of one or two live rows), one lane runs a column's gate
and up chains interleaved (column_dot_pair), so their FMAs overlap and each
activation load serves both.
Each chain keeps its order, so the intermediate is bitwise the two passes';
gguf-moe checks every MMA format against them. Prefill keeps two passes.

M2 Max 38 cores, against 1.5.0-m1 (A B B A):
gguf-moe-benchmark, ms a layer, two passes -> one, 1-row verify / 8-32 rows:
  Q2_0 (Flash-Next shape) -24.0% / -1.5..-15.8%; Qwen3.6-35B-A3B shape with
  Q4_K/Q5_K/Q6_K/IQ4_XS/IQ4_NL/Q8_0/MLX 4-bit gate/up -27..-34% / -0.7..-11.2%;
  Flint (two shared slices) Q4_K and MLX 4-bit -30% / +0.2..-20% (+0.2% inside
  its 0.3% spread).
decode-profile, Qwen3.8-Flash-Next 2K, int8 KV, eight verify rows a lane,
fused ms B1/B2/B3/B4:
  Q2_0     60.05/90.71/116.72/144.03 -> 57.38/86.88/113.38/140.55 (-4.5/-4.2/-2.9/-2.4%)
  Coder    70.87/103.11/134.06/161.91 -> 68.72/101.38/133.50/160.82 (-3.0/-1.7/-0.4/-0.7%)
  IQ3_XXS  69.90/104.38/133.28/162.24 -> 68.50/102.91/132.18/161.30 (-2.0/-1.4/-0.8/-0.6%)
  Q2_0 32K 61.57/94.49/121.76/149.73 -> 59.14/90.57/117.86/146.64 (-3.9/-4.1/-3.2/-2.1%)
Server, Flash-Next Q2_0, 5 greedy 400-token requests: 18.5 -> 18.9 cycles/s.
Agent chat replay (78 turns, sent twice, 32-128K context), cycles/s: Q2_0
+3.4..+4.0%, Coder +0.4..+2.5%; prefill unchanged.
@abssoul

abssoul commented Nov 4, 2026

Copy link
Copy Markdown

Ran it on my M1 Max (the #29/#38/#40 machine). Same recipe: the ISTA-DASLab
Q2_0 pack, decode-profile, 2048 prompt tokens, --cycles 12 --ple-memory 2G
(a 64 GB Mac needs the PLE flag), before = the stock 1.5.0-m1 metallib, after
= your branch, both built the same way.

The end-to-end medians of 3 interleaved runs each sit inside this machine's
run-to-run noise (±8%: B1 68.15 → 67.93 ms, B2 115.55 → 103.32, B3 148.60 →
141.90, B4 176.89 → 177.24), so the cycle numbers alone don't separate a
2–4% effect here. The kernel-level numbers do, in the B1 profile:

  • before: moe_expert_gguf_mma_m8_a 96 calls 17.006 ms + m8_g 48 calls
    9.514 ms = 26.52 ms of expert work;
  • after: m8_gate_up_mixed 34 calls 11.864 ms + m8_gate_up 14 calls
    4.689 ms + m8_a 48 calls 8.050 ms = 24.60 ms.

−7.2% on the experts, ≈ −2.8% of the whole B1 cycle — right in your 2.4–4.5%
band. The fused kernels show up in the profile as expected.

@ghisguth

ghisguth commented Nov 4, 2026

Copy link
Copy Markdown
Author

@abssoul thank you for running it on your M1 Max! Good to see the fused kernels win there too, even if the cycle is inside the noise.

±8% run to run is a lot. On my M2 Max I get ~1% with the display off, caffeinate -i -s and nothing else running, maybe it helps on yours too.

If you have time, #46 is another small one for the same model. The cycle effect is ~1%, so on your machine it would only show in the profile too: hyper_connection_norm_staged (after) vs hyper_connection_norm_quarters (before) at B1-B3. B4 keeps the old kernel on M1 Max. I measured where the new kernel stops winning only on my M2 Max, so M1 numbers would help there.

@paperniuk
paperniuk merged commit a1a065d into paperniuk:apple7-m1-1.5 Nov 5, 2026
@paperniuk

Copy link
Copy Markdown
Owner

Thanks, merged. On my M1 Max together with #46, Flash-Next Q2_0 through the server, four runs each in ABBA order: 1 lane 73.9 to 75.8 tok/s, 2 lanes 93.1 to 94.4, prefill unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants