opus_r34_gr — Round 34 Track A: multi-row / coalesced gr_down (ntok 2..8) + Window::gr_read dispatch #

Patch: /tmp/opus_r34_gr/opus_r34_gr.patch (git -C vendor/strata apply --check OK; touches metal/kernels/gr.metal, metal/src/engine/window.cpp (Window::gr_read dispatch line only; gr_read_m unchanged) and metal/tests/fuse_parity.cpp). Scratch tree /tmp/opus_r34_gr/strata, baseline kernels /tmp/opus_r34_gr/base_kernels, full logs /tmp/opus_r34_gr/final_bench.txt (+ final_bench_run1.txt), every experimental variant kept in /tmp/opus_r34_gr/experiments_gr.metal / experiments_fuse_parity.cpp. Device: Apple M5 10-core GPU, live server sharing the GPU (a few late-run samples were trashed by it; medians of 16 paired ratios used throughout).

tl;dr #

The big-idea candidates (j-outer token loop, NR = 4, larger RS, threadgroup-staged coalesced xn) are all bitwise PASS but slower at every ntok. The only win is small: keep the packed Q8_0 NR = 2 token loop rolled at every ntok for KS = 4, and let gr_read switch to _r2 from ntok 4 instead of 5.

Shipped (bitwise PASS ntok 1..8 in --gr-unroll, default fuse_parity ALL PASS) #

  1. gr.metal: grd_acc<F, NR, ROLL>; gr_down_f sets ROLL = F == Q8_0 && NR == 2 && KS == 4 (the 4_4_r2 / q8f_4_4_r2 down rows). Same fmas, same order, just no unroll(full) over tokens. KS = 8 (8_8_r2, the MTP head) measured +2..6% slower rolled, so it keeps the unrolled loop (now ±0.2%).
  2. window.cpp Window::gr_read: r2 = ... ntok > (m5_gr ? 3 : 4). 4_4_r2 is bitwise 4_4 (same KS), so the switch is free for parity. gr_read_m (KS = 8) stays at > 4: 8_8 beats 8_8_r2 at ntok 4 (45.5 vs 46.7 µs).
  3. fuse_parity --gr-unroll: benches for the dispatch choice (4_4 vs 4_4_r2, q8f_4_4 vs q8f_4_4_r2, 8_8 vs 8_8_r2).

Final paired timings (final_bench.txt, median of 16 pair ratios) #

kernel (base → new) ntok base µs new µs median Δ
4_4_r2 → 4_4_r2 (rolled) 4 41.34 39.55 -4.4%
4_4_r2 → 4_4_r2 5 49.50 48.18 -2.7%
4_4_r2 → 4_4_r2 6 58.43 57.44 -1.7%
4_4_r2 → 4_4_r2 7 / 8 68.14 / 79.77 68.14 / 79.79 0.0% (already rolled)
q8f_4_4_r2 → same 4 / 5 / 6 41.24 / 49.51 / 58.96 39.96 / 48.48 / 57.69 -3.0 / -2.1 / -1.9%
8_8_r2, 4_4, q8f_4_4, 8_8 1..8 ±0.2% (unchanged code)
engine ntok 4: 4_4 → 4_4_r2 (rolled) 4 40.64 39.63 -2.5% (run 1: -0.8%)
engine ntok 3: 4_4 → 4_4_r2 3 31.67 33.03 +4.3% → stays 4_4
engine ntok 2: 4_4 → 4_4_r2 2 23.84 23.82 -0.3% (noise) → stays 4_4
engine ntok 5..8: old r2 → rolled r2 5/6 49.50/58.43 48.18/57.44 -2.7/-1.7%

Net at the 3-draft verify (ntok 4, 97 calls): ~0.3-1.0 µs/call → ~0.03-0.1 ms per verify step. Small, but free and bitwise.

Measured and rejected (all vs base gr_down_q8_4_4 NR = 1 at the same ntok, median Δ; all bitwise PASS unless noted) #

variant 2 3 4 5 6 7 8
4_4_r2 (old, unrolled ≤6) -0.9 +4.4 +1.8 -11.0 -22.4 -30.0 -33.5
4_4_j (NR 1, j-outer) +1.5 +0.5 +25.9 +5.5 +6.9 +5.2 +8.9
4_4_r2j (NR 2, j-outer, task idea #1) +10.5 +8.5 +13.4 +56.0 +78.7 +32.6 +52.8
4_4_r4 (NR 4 packed, 81 TGs) +32.6 +21.2 +17.5 +28.6 +28.7 +9.9 +0.0
4_4_r4j +16.6 +20.3 +42.5 +40.9 +49.8 +34.4 +37.8
8_4 (RS 2) +2.1 +2.3 +2.2 -0.3 -3.7 -0.1 -0.6
8_4_r2 +2.4 +8.6 +5.7 -7.9 -21.7 -30.1 -34.2
8_4_r4j +22.6 +26.0 +50.0 +46.6 +48.0 +38.2 +36.0
16_4 (RS 4) +8.1 +5.8 +3.8 -1.0 -10.7 -12.9 -15.6
16_4_r2 +20.4 +21.0 0.0 -15.8 -30.3
32_4 (RS 8) +17.4 +13.3 +7.7 -8.3
4_4_u2 (unroll_count(2) tokens) +9.5 +9.7 +0.2
4_4_r2u2 +16.2 +27.2 -14.1
4_4_st (per-simdgroup threadgroup-staged, coalesced xn) +96.3 +49.9 +32.4
4_4_r2st (same, NR 2) +30.5 -0.9 -9.0
probe 4_4_pc (coalesced xn, WRONG math) -1.9 -15.1 -0.1 -11.2 -22.0 -29.9 -22.9
probe 4_4_r2pc (coalesced, WRONG math) +4.3 -2.5 -7.5 -14.4 -23.9 -34.3 -36.2

Notes:

  • j-outer (task idea #1) is bitwise identical but 5-80% slower. Holding s[NR][ntok][4] live across the j loop costs more registers than the 2-float4-per-row it saves; the CSE-hoist theory doesn't pay off in practice (the compiler already widens the packed char4 per use well enough — rolling the token loop is what helps).
  • NR = 4 / more RS: fewer, fatter simdgroups lose at ntok ≤ 6 (latency/occupancy: 81 TGs × 4 SGs isn't enough in flight). Bigger threadgroups (16_4, 32_4) only help where NR = 1 is already much worse than _r2.
  • Coalesced xn: the wrong-math probe bounds the gain: ≤ 0% at ntok 4 for NR 1 (confirms r33), -7.5% for NR 2 at ntok 4, -2..-3% vs _r2 at 7-8. A real transpose through threadgroup memory (8 coalesced loads → padded 9-float4/unit tile → simdgroup_barrier → 8 tile reads per token) costs far more than that (+30..+96%), so staging is a dead end here. Cross-simdgroup cooperative staging needs RS > 1, which is itself slower (above), plus two threadgroup barriers per token.

Where ntok 4 is stuck #

gr_down_q8_4_4 at ntok 4 is ~40 µs vs a ~23 µs DRAM floor. Coalescing doesn't move it (probe), halving xn traffic (NR 2) moves it ~2.5% only when rolled, and more/fatter simdgroups lose: it looks latency-bound per simdgroup (3 serial u-iterations of DRAM weight load → L2 xn loads, with lanes 16-31 idle on the third). The bitwise constraint pins the unit→lane map (each lane's acc order and the simd_sum tree), so the remaining lever is a structurally different grid (e.g. split-K across threadgroups with an ordered second-pass reduce) — out of this file/line scope.