opus_r29_prompt — Round 29 Track A: gemm_swiglu_gu shared-expert gate+up fusion + route_topk unrolls #

patch: /tmp/opus_r29_prompt/opus_r29_prompt.patch (git -C vendor/strata apply --check OK). It touches metal/kernels/prompt.metal (route_topk, +3/−1 lines) and metal/tests/comb_fold_parity.cpp (new gu + topk modes). window.cpp is unchanged. The fusion doesn't win robustly (details below), so I didn't wire it in. Scratch tree + build: /tmp/opus_r29_prompt/strata, /tmp/opus_r29_prompt/build (run with STRATA_METAL_KERNELS=/tmp/opus_r29_prompt/strata/metal/kernels).

TL;DR #

  • gemm_swiglu_gu (two accumulators, one A tile) is bitwise identical but not faster. On average it ties gate gemm_f16 + gemm_swiglu_up (±2–3%, which is the noise floor here). It wins −3..−5% at T = 192/256, loses +10..+14% at T = 224/288, and loses +1..+3% at 384..1536 in the most recent repeated runs. The kernel is compute-bound on the matrix units, and the gsh round trip (the "503 MB") overlaps with that compute. Two TM×TN accumulators halve occupancy, which eats the saving. This matches Round 11's finding (audits/opus_r11_dense.md: 2-acc fused 1016 µs vs 976 µs unfused). Not shipped.
  • Interleaved single-accumulator variant gemm_swiglu_il (one GEMM over B = gate/up rows interleaved, row 2j = gate j, 2j+1 = up j; each M5 lane's 4 columns = (g,u) of two outputs, half2 store) is also bitwise identical. It is the only shape with a repeatable win: −3.0..−4.4% at T = 1024 (≈ −1.0 ms/chunk, 6 of 6 runs, tiles il128_64_4 / il128_128_8 / il64_256_8). It ties at 2048 (−0.8..+1.2%) and is +1..+3% at 512. Shipping it needs dq(Wshg)/dq(Wshu) writing one interleaved [1280, 2560] buffer (dq_f16's perm = 2 over the pair; window.cpp lines 3674-3676 + dense_mm.metal). That is outside my boundaries, so it stays in the test as a measured follow-up candidate.
  • route_topk: the #pragma clang loop unroll(full) additions are no-ops. With only the pragmas (lines 35, 41, 104, 106, 125) the A/B ratio is 1.000–1.002, so the compiler already fully unrolls these constant-trip loops. The real cost was v[ex / 32] = -INFINITY, a runtime index into float v[16]. Replacing it with an unrolled select v[i] = i == (ex >> 5) ? -INFINITY : v[i] gives −0.7..−1.2% (ratio 0.988–0.993 in 3 of 3 quiet runs, every T and MoCE setting). It is bitwise identical (ids + w, 512 and 256 experts, MoCE 0 / 320335 / −16.3 / −0.05). The absolute gain is tiny: route_topk is ~2.3 ms per 48 at T=2048, so ≈ 20 µs/chunk. That is the only shipping change. I left the pragmas out of the diff because they measured nothing.

Parity (all memcmp == 0, outputs pre-filled 0xCD) #

  • comb_fold_parity 0 gu: T ∈ {1, 7, 161, 256, 512, 1024, 1983, 2048} × 7 gu tiles + window path → 65 PASS
  • comb_fold_parity 0 gu check il64_128_4,il128_64_4,64_64_4 → 33 PASS (interleaved)
  • During exploration, also bitwise: threadgroup-parked silu(g) (one accumulator, 8–32 KB TG) and K-stepped TK=32 multiply_accumulate (two accumulators) at 3 tiles each, at every T.
  • comb_fold_parity 0 topk base_prompt.metal → 25 PASS; route_parity PASS (K=512 and K=256+MoCE)
  • regressions: comb_fold_parity 1024 21 PASS, 0 epi 113 PASS, 256 hcup 16 PASS, 0 ab base_prompt.metal 22 PASS
  • window.cpp variant (gu in the mm_hist wave next to Wsg, swiglu_h only when !swu) compiled clean in the scratch engine build before I reverted it. The full model was never run (rules).

Timings: 48 shared experts, ms per chunk, median of 9 alternating; Δ vs gate + gemm_swiglu_up (the window at T > 160) #

GPU shared with the live :8080 server, so absolute numbers drift up to 2×. Runs whose baseline inflated are discarded or marked.

T gate+swiglu_up best 2-acc gu (Δ, repeated runs) best interleaved il (Δ)
192 4.6–5.0 64_64_4: −3.1, −3.3, −4.8, −4.8 il64_128_4: −8.7, −7.3 (one run each way)
224 — 32_64_2: +1.3 median; 64_64_4: +9.9 —
256 5.9–6.4 64_64_4: −3.3, −5.5, −2.9, −5.0 il128_64_4: −8.3 median (noisy: −18.7..−2.5)
288 — 64_64_4: +13.9 median —
320 — 64_64_4: +2.8 median il32_64_2: +2.2
384 8.7–10.0 64_128_4: −1.4, −1.0, then +3.0, +3.0, +1.5, +3.4 —
512 11.6–12.9 64_128_4: −2.1, −1.6, then +2.5, +4.4, +2.6, +3.7 il128_64_4: +1.2, +1.8, +2.2
1024 24.2–27 64_128_4: ±1.5 (8 runs, median ~0) il128_64_4: −3.8, −4.4, −3.7, −5.2, −3.0, −3.0
1536 37–41 128_128_8 / 64_128_4: −1..+2 —
2048 50–58 64_128_4: −2.1, −1.7, −0.4, −2.4, +1.4, +1.1 il128_64_4: +0.5, +0.3, −0.8

Other shapes swept (all slower or tied): 32_64_2 / 32_128_4 / 64_64_4 at T ≥ 512 (+14..+97%), 128_128_4 (+20..+120%), 64_128_8 (+19..+48% at ≥ 512); TG-parked silu(g) (64_128_4 +0.7..+9%, smaller tiles same as 2-acc); K-stepped TK=32 (+9..+13% at 1024/2048), TK=64 at 64_128_4 (+20..+26%). At T ≤ 160 the overlapped gate ∥ up + swiglu_h beats every fused form (+11..+48%), so swu's 160 threshold stands.

Why the "503 MB" doesn't show up #

gsh is T·640·4 B written and read once per layer. At T=1024 that is 5.2 MB ≈ 40 µs of DRAM per layer, but it runs concurrently with a ~500 µs compute-bound GEMM. Removing a round trip only pays when the extra accumulator costs nothing. The single-accumulator interleave keeps occupancy, which is why only il shows a (small) T=1024 win. The "mixh read twice from DRAM" claim doesn't hold either: each A row-block is already re-read by NFF/TN = 5–10 N-tiles, so A is cache-resident either way.

Test usage #

  • comb_fold_parity T gu [bench [tile,...]]: tiles TM_TN_SG (two-accumulator gemm_swiglu_gu) or ilTM_TN_SG (interleaved). T 0 = the parity sweep. bench rows: gate ∥ up + swiglu_h, gate + gemm_swiglu_up, then each tile. Without bench, pass any word in place of bench to give a tile list (e.g. 0 gu check il128_64_4).
  • comb_fold_parity T topk baseline-prompt.metal: route_topk bitwise vs a baseline copy (512 and 256 experts, 4 MoCE settings), plus paired ×48 times at 512 experts. T 0 = 512, 1024, 2048.
  • Both fused kernels live in the test (kSwigluFused), not in prompt.metal: zero runtime-library cost, no dead code in the engine's kernels.

Follow-up (needs files outside this track) #

Interleaved shared gate/up: dq both into one [2·NFF, N] f16 buffer with rows interleaved (dense_mm.metal dq_f16 needs a row offset, or the two GGUF tensors dequantised as one perm = 2 pair), then dispatch gemm_swiglu_il (il128_64_4) at T ≥ ~768 in the mm_hist wave. Expected ≈ −1 ms/chunk at T=1024, ~0 at 2048. Worth it only if the 1024-row streaming chunk is the common case.