Round 10 Track C Audit: mm_gather_h 128-Bit uint4 Vectorization + mm_scan/mm_place Plan Optimization #

  • Agent: argon-mm-gather-and-plan (opencode-go/gemini-4-argon-contributor)
  • Scratch workspace: /tmp/argon_r10_gather/strata
  • Unified patch: /tmp/argon_r10_gather/argon_r10_gather.patch (verified clean with git -C vendor/strata apply --check)
  • Files touched in patch: metal/kernels/moe_mm.metal, metal/tests/moe_mm_parity.cpp (metal/src/engine/window.cpp line 3540 g.tg[0] = 256 kept unchanged; compatible with prompt_chain.inc and prompt_chain.cpp too).

1. mm_gather_h Investigation (half4 vs uint4, Threadgroup Sizing, Unrolling & Multi-Row-per-TG) #

1.1 Roofline & Geometry Analysis on Apple M5 (24 GB Unified Memory, 153.6 GB/s Peak DRAM Bandwidth) #

For N_EMBD = 2560 (5,120 bytes per row = 640 half4s = 320 uint4s):

  • At T = 512 (512 experts, TM = 16): tot_rows = 8,432 -> XS write footprint is 43.17 MB per layer (2.07 GB across 48 layers) plus gathered reads from xh (2.62 MB).
  • At T = 1024: tot_rows = 14,352 -> XS write footprint is 73.48 MB per layer (3.53 GB across 48 layers) plus gathered reads from xh (5.24 MB).
  • At T = 2048: tot_rows = 24,576 -> XS write footprint is 125.83 MB per layer (6.04 GB across 48 layers) plus scattered reads from xh (10.49 MB). Because 125.83 MB exceeds the M5 System Level Cache (SLC), every layer's XS write and subsequent read stream through DRAM at ~130–153 GB/s (85–99% of hardware DRAM peak).

1.2 Paired 48-Layer Sweep Results (mm_gather_h Variants) #

Measured via paired alternating 48-dispatch command buffers (7–9 pairs after GPU warmup) on Apple M5:

Variant Vector Type tg[0] Grid (T=2048) T=512 (48L ms / us/L) T=1024 (48L ms / us/L) T=2048 (48L ms / us/L) XS memcmp
Baseline (half4, fixed stride 256) half4 (64-bit) 256 24,576 13.153 ms (274.0 us) 23.455 ms (488.6 us) 55.543 ms (1157.1 us) Ref
half4_tg32 (nth stride) half4 (64-bit) 32 24,576 13.555 ms (282.4 us) 24.314 ms (506.5 us) 56.517 ms (1177.4 us) PASS (0)
half4_tg64 (nth stride) half4 (64-bit) 64 24,576 13.446 ms (280.1 us) 23.932 ms (498.6 us) 56.172 ms (1170.3 us) PASS (0)
half4_tg128 (nth stride) half4 (64-bit) 128 24,576 13.307 ms (277.2 us) 23.611 ms (491.9 us) 55.931 ms (1165.2 us) PASS (0)
uint4_tg32 (nth stride) uint4 (128-bit) 32 24,576 13.265 ms (276.4 us) 23.372 ms (486.9 us) 56.978 ms (1187.1 us) PASS (0)
uint4_tg64 (nth stride, 5 iters) uint4 (128-bit) 64 24,576 13.080 ms (272.5 us) 23.038 ms (480.0 us) 56.063 ms (1168.0 us) PASS (0)
uint4_u64_tg64 (unrolled 5x uint4) uint4 (128-bit) 64 24,576 13.212 ms (275.2 us) 23.061 ms (480.4 us) 56.177 ms (1170.3 us) PASS (0)
uint4_tg128 (nth stride) uint4 (128-bit) 128 24,576 13.022 ms (271.3 us) 22.979 ms (478.7 us) 55.820 ms (1162.9 us) PASS (0)
uint4_tg256 (nth stride, selected) uint4 (128-bit) 256 24,576 12.992 ms (270.7 us) 22.955 ms (478.2 us) 55.508 ms (1156.4 us) PASS (0)
mr64_R2_tg128 (2 rows/TG, 64 thr/row) uint4 (128-bit) 128 12,288 13.104 ms (273.0 us) 23.156 ms (482.4 us) 56.628 ms (1179.8 us) PASS (0)
mr64_R4_tg256 (4 rows/TG, 64 thr/row) uint4 (128-bit) 256 6,144 13.080 ms (272.5 us) 23.167 ms (482.6 us) 56.563 ms (1178.4 us) PASS (0)
mr32_R4_tg128 (4 rows/TG, 32 thr/row) uint4 (128-bit) 128 6,144 13.189 ms (274.8 us) 23.393 ms (487.4 us) 57.298 ms (1193.7 us) PASS (0)

1.3 Key Findings for mm_gather_h #

  1. 128-bit uint4 copies beat 64-bit half4 copies across all threadgroup sizes (32, 64, 128, 256) at T=512 (-0.16 ms / -1.2%) and T=1024 (-0.50 ms / -2.1%), and tie/edge out baseline at T=2048 (55.508 ms vs 55.543 ms), because 128-bit loads/stores halve the number of loop iterations (320 uint4s vs 640 half4s per row).
  2. Why tg[0] = 256 with uint4 beats tg[0] = 64 at T = 2048 (55.51 ms vs 56.06 ms):
    • With uint4 and tg[0] = 256, iteration 0 issues 256 * 16B = 4,096B (32 cache lines of 128B) of the 5,120B row concurrently across 8 simdgroups, and iteration 1 issues the remaining 64 * 16B = 1,024B (8 cache lines) on the first 2 simdgroups (tid < 64). Because 64 is an exact multiple of the 32-wide SIMD width, simdgroups 2..7 exit cleanly at the SIMD level without intra-warp divergence, while launching 8 simdgroups per row maximizes memory-level parallelism (MLP) when xh (10.49 MB at T=2048) spills out of L1/L2 into SLC/DRAM.
    • Making mm_gather_h take ushort nth [[threads_per_threadgroup]] (with scalar uint tg [[threadgroup_position_in_grid]] per MSL scalar/vector attribute rules) supports any tg[0] = 32, 64, 128, 256 while keeping g.tg[0] = 256 in window.cpp, prompt_chain.inc, and prompt_chain.cpp.

2. Target 2 Investigation: mm_scan and mm_place Plan Kernels #

We also inspected and benchmarked mm_hist, mm_scan, and mm_place in metal/kernels/moe_mm.metal:

  1. mm_scan barrier + dead-slot elimination:
    • Previously, mm_scan zeroed s_rows[0..31] and s_tiles[0..31], executed an extra threadgroup_barrier(mem_flags::mem_threadgroup), and then looped for (int i = 0; i < 32; ++i) after the prefix sum barrier because 512 threads only populate 16 of the 32 simdgroup slots.
    • By taking ushort nsg [[simdgroups_per_threadgroup]] and looping for (uint i = 0; i < nsg; ++i), we eliminate both the initial zero-fill and the first threadgroup_barrier (cutting mm_scan's barriers in half from 2 to 1).
  2. mm_place threadgroup-memory & barrier reduction (s_id[1024] -> register simd_broadcast):
    • Previously, mm_place allocated threadgroup short s_id[1024] (2 KiB), wrote s_id[tid] = short(e) after zeroing p_cnt, and after the second barrier did a threadgroup-memory short4 scan over the caller's 32-lane sub-block.
    • Because each 32-lane sub-block (sub = tid >> 5) is an exact 32-lane Metal simdgroup, each lane can compute its intra-simdgroup rank directly in registers via simd_broadcast(e, l) while waiting for the p_cnt atomic updates, eliminating threadgroup short s_id[1024] completely.

2.1 Paired 48-Layer Plan Timing Results (mm_hist + mm_scan + mm_place) #

Kernel T=512 Before -> After (48L) T=1024 Before -> After (48L) T=2048 Before -> After (48L) Bitwise memcmp (bcnt, boff, eoff, tiles, meta, stok, sw, pos)
mm_hist 0.111 ms -> 0.109 ms 0.049 ms -> 0.048 ms 0.060 ms -> 0.052 ms PASS (0)
mm_scan 0.354 ms -> 0.322 ms (-9.0%) 0.214 ms -> 0.200 ms (-6.5%) 0.265 ms -> 0.250 ms (-5.7%) PASS (0)
mm_place 0.309 ms -> 0.281 ms (-9.1%) 0.192 ms -> 0.165 ms (-14.1%) 0.290 ms -> 0.271 ms (-6.6%) PASS (0)
Full Plan (48L) 1.663 ms -> 1.160 ms 0.445 ms -> 0.408 ms (-8.3%) 0.611 ms -> 0.573 ms (-6.2%) PASS (0)

3. Verification (memcmp == 0 & moe_mm_parity) #

  • Added fast (1.40s total runtime) --gather-bench mode to metal/tests/moe_mm_parity.cpp (using #ifdef MM_GATHER_ONLY to skip compiling the heavy GEMM template matrix when testing/benchmarking plan + gather):
    • Verifies 100% bitwise identity (memcmp == 0) on XS (across all tg[0] in {32, 64, 128, 256} against mm_gather) and exact plan invariants across T = 1, 7, 64, 256, 512, 1024, 2048.
    • Benchmarks 48-layer command buffers for both plan and mm_gather_h across T = 512, 1024, 2048.
  • Full end-to-end moe_mm_parity --v3 --tile 16,32,32,4 --dtile 64,32,2 verified PASS (rel L2 1.11e-05 at T=1, 3.20e-06 at T=7, 2.65e-06 at T=64).