Round 4 Track C: Prefill MoE Plan (mm_clear + mm_hist + mm_scan + mm_place) & Epilogue / Gather Audit #

  • Patch: /tmp/argon_prefill_plan/argon_prefill_plan.patch (verified clean with git -C vendor/strata apply --check)
  • Target Device: Apple M5 (10-core GPU, 24 GB Unified Memory, Metal 4 matmul2d K4 right-input layout)

1. Executive Summary #

We audited and benchmarked the MoE prefill plan pipeline (mm_clear, mm_hist, mm_scan, mm_place, mm_gather) in metal/kernels/moe_mm.metal and all elementwise epilogue / conversion kernels (swiglu_h, act_f16, moe_out, gr_epi_down, gr_norm_h, mm_combine) in Window::prompt_chunk (metal/src/engine/window.cpp & prompt_chain.inc).

Key Findings & Optimizations Implemented #

  1. 100% Elimination of mm_clear via Exact Padding-Row Initialization in mm_scan:

    • mm_hist initializes threadgroup atomic_uint h[1024] to 0 and unconditionally writes every element bcnt[tg.x * a.n_expert + e] (e < a.n_expert), so bcnt never needed external clearing.
    • mm_clear was dispatching (p.maxrows + 255) / 256 threadgroups (36,864 ints for C = 2048) on every layer solely to initialize p.stok to -1 for the padded tail rows [r0 + tot, r0 + rows) of each expert that mm_place does not touch.
    • In mm_scan (1 threadgroup of n_expert = 512 threads, e = tid), each thread e already computes r0, tot, and rows = nt * a.tm. Having thread e write srt_tok[r0 + r] = -1 for r = tot .. rows - 1 (0..15 ints per expert) completely eliminates the mm_clear dispatch across all 48 MoE layers.
  2. Discovery & Fix of the Real Plan Bottleneck: $O(1024^2)$ Serial Threadgroup Scan in mm_place (5.3x–6.0x Plan Speedup):

    • Profiling individual plan kernels revealed that mm_hist (1.1–1.5 us) and mm_scan (2.9–4.8 us) were tiny, whereas mm_place alone took 50.4–81.5 us/layer (91% of total plan time)!
    • Root cause: for (uint j = 0; j < tid; ++j) rank += s_id[j] == e; in mm_place forced threads in the last warp (tid = 992..1023) to execute up to 1,023 serial threadgroup memory loads and comparisons per block.
    • Fix: Replaced the $O(1024)$ serial loop in mm_place with a two-level deterministic 32-lane sub-block nibble-packed counter (p_cnt[4096]) + short4 intra-warp scan (ltid = tid & 31, at most 7 short4 loads per thread). Because topk = 10 per token guarantees at most $\lceil 32/10 ceil = 4 \le 15$ occurrences of any expert within a 32-entry sub-block, 8 sub-block counts pack into a single 32-bit atomic_uint without overflow, and the resulting rank, pos[i], srt_tok[row], and srt_w[row] are 100% bitwise identical to the original loop.
    • Result: mm_place drops from 50.4–81.5 us to 3.9–6.2 us (12.9x faster), and the full MoE plan (mm_hist + mm_scan + mm_place) drops from 46.9–77.2 us/layer to 8.6–13.1 us/layer (5.4x–5.9x faster) in moe_mm_parity --bench.
  3. Why Single-Threadgroup mm_hist + mm_scan Fusion Was Rejected:

    • We also prototyped and measured a single-threadgroup fused mm_hist_scan kernel (1 TG of 1024 threads looping over b = 0 .. n_blocks - 1). Because 1 TG cannot occupy all 10 GPU cores on M5 across n_blocks = 10..20, mm_hist_scan took 8.27–11.59 us vs 5.60 us for separate multi-TG mm_hist + 1-TG mm_scan_pad. Keeping mm_hist multi-TG and fusing padding-clear into mm_scan is strictly faster.
  4. Halving mm_gather Memory Traffic via mm_gather_h (p.mixh FP16 Source + half4 Loads/Stores):

    • In Window::prompt_chunk (line 3332), gemm_hc_up_64_64_4 already outputs both FP32 p.mixed and FP16 p.mixh (mixh[i] = half(mixed[i])).
    • Previously, mm_gather read FP32 p.mixed (4 bytes/elem) and converted it to half again (half(x[...])) for every routed token row (24,576 x 2560 = 251.6 MB read + 125.8 MB write per layer at T = 2048).
    • Added mm_gather_h, which reads directly from FP16 p.mixh using vectorized 64-bit half4 loads and stores (125.8 MB read + 125.8 MB write, bit-for-bit identical to mm_gather since mixh is already half(mixed)).
    • Result: mm_gather time in moe_mm_parity --bench at T = 1024 drops from 762.2 us to 495.2 us (-267.0 us/layer, 1.54x faster) and at T = 2048 drops from 2028.3 us to 1190.5 us (-837.8 us/layer, 1.70x faster, saving 40.2 ms across 48 layers).
  5. Audit of swiglu_h, act_f16, and Other Elementwise Kernels + Barrier Placement:

    • Audited all barriers in Window::prompt_chunk:
      • Removed the redundant mm_clear dispatch and fixed the barrier flag before mm_hist: previously mm_clear had nb_ = true and mm_hist ALSO had nb_ = true, which only worked when stream_ && pred_on && l + 1 < 48 inserted a barrier inside the read-ahead loop. Now nb_ = stream_ && pred_on && l + 1 < 48; before mm_hist, guaranteeing a barrier after route_topk(p.ids) when read-ahead is off or on layer 47, while overlapping mm_hist with the read-ahead route_topk(predicted_ids) when read-ahead is active.
      • Verified swiglu_h (line 3567): the three shared-expert GEMMs (Wshg -> p.gsh, Wshu -> p.ush, Wsg -> p.sgv) run concurrently in one barrier group (nb_ = true on lines 3562, 3564); swiglu_h must wait for p.gsh and p.ush, and gemm(p.hh, p.Wshd, p.ys) must wait for p.hh.
      • Benchmarked swiglu_h (9.6–68.8 us) vs float4-vectorized swiglu_h_v4 (17.2–75.0 us) vs fused gemm_swiglu_f16 (113.6–1294.6 us), and act_f16 vs act_f16_v4. On M5, scalar coalesced 256-thread dispatches saturate the memory controller and hardware coalescer slightly better than float4-strided threads (64.8 us vs 75.0 us for swiglu_h at T=2048; 546.1 us vs 558.5 us for act_f16), and fusing Wshg + Wshu into one double-GEMM kernel hurts occupancy (1042 us -> 1295 us at T=2048). Thus swiglu_h and act_f16 are already optimal as-is.

2. Benchmark Results on Apple M5 (moe_mm_parity --bench --v3 --gu IQ2_XS --dn IQ4_NL) #

2.1 Microbenchmark: Isolated Plan Kernels (48-layer run_graph median per layer) #

T nblk Padded Rows Baseline mm_clear Baseline mm_hist Baseline mm_scan New mm_scan (with pad clear) Baseline mm_place New mm_place (32-sub + short4) Full Plan Baseline Full Plan New Plan Speedup
128 2 7,568 4.15 us 1.56 us 2.90 us 4.02 us 50.46 us 3.92 us 54.93 us 8.95 us 6.14x
512 5 8,352 0.93 us 1.13 us 3.27 us 4.12 us 50.82 us 4.02 us 55.54 us 9.18 us 6.05x
1024 10 14,256 0.88 us 1.10 us 3.81 us 4.69 us 50.97 us 4.14 us 56.29 us 9.63 us 5.85x
2048 20 24,160 0.93 us 1.33 us 4.77 us 5.60 us 81.41 us 6.19 us 88.08 us 13.20 us 6.67x

2.2 End-to-End moe_mm_parity --bench (512 Experts, IQ2_XS Gate/Up 16,32,32,4, IQ4_NL Down 16,64,32,2) #

T Metric Baseline (Before) Optimized (After) Delta / Speedup
128 Plan (hist + scan + place) 46.9 us 8.7 us -38.2 us (5.39x faster)
128 Gather (mm_gather -> mm_gather_h) 236.7 us 235.3 us -1.4 us
128 Total per layer (plan + GEMMs + combine) 13,123.8 us 12,965.0 us -158.8 us/layer (-7.6 ms / 48L)
512 Plan (hist + scan + place) 47.2 us 8.6 us -38.6 us (5.49x faster)
512 Gather (mm_gather -> mm_gather_h) 278.8 us 274.2 us -4.6 us
512 Total per layer (plan + GEMMs + combine) 14,689.1 us 14,603.8 us -85.3 us/layer (-4.1 ms / 48L)
1024 Plan (hist + scan + place) 48.0 us 9.7 us -38.3 us (4.95x faster)
1024 Gather (mm_gather -> mm_gather_h) 762.2 us 495.2 us -267.0 us (1.54x faster)
1024 Total per layer (plan + GEMMs + combine) 22,201.9 us 21,900.3 us -301.6 us/layer (-14.5 ms / 48L)
2048 Plan (hist + scan + place) 77.2 us 13.1 us -64.1 us (5.89x faster)
2048 Gather (mm_gather -> mm_gather_h) 2,028.3 us 1,190.5 us -837.8 us (1.70x faster)
2048 Total per layer (plan + GEMMs + combine) 37,210.6 us 36,216.1 us -994.5 us/layer (-47.7 ms / 48L, +2.7% tok/s)

3. Bitwise & Numerical Parity Verification #

  • Bitwise Plan & Gather Parity (plan_epi_bench):
    • Tested across T = 128, 512, 1024, 2048 with stok and bcnt pre-polluted with 0xDEADBEEF / 777777 and with skipped experts (ids = -1 from MoCE tail skipping):
    • std::memcmp across meta[0..10], boff[0..nblk*NE-1], eoff[0..NE-1], tiles[0..nt-1], stok[0..nr-1], sw[0..nr-1], pos[0..nent-1], and XS[0..nr*N-1]: 100% bitwise identical (0 byte differences).
  • moe_mm_parity (--v3 --gu IQ2_XS --dn IQ4_NL --tile 16,32,32,4 --dtile 64,32,2):
    • T = 128: plan ok; y vs fp16-operand reference rel L2 2.02e-05, vs exact float reference 4.93e-04 PASS
    • T = 256: plan ok; y vs fp16-operand reference rel L2 1.80e-05, vs exact float reference 4.90e-04 PASS (identical 1.80e-05 / 4.90e-04 to baseline)
  • prompt_parity: ALL PASS (route_topk, HC read as GEMMs, swiglu_h, moe_out)
  • dense_mm_parity: ALL PASS across all 13 quantization formats (F32, BF16, Q8_0, Q4_0, Q5_0, IQ4_NL, IQ4_XS, Q2_0, IQ3_S, Q3_K, Q4_K, Q5_K, Q6_K).