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 needsdq(Wshg)/dq(Wshu)writing one interleaved[1280, 2560]buffer (dq_f16'sperm = 2over 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 wasv[ex / 32] = -INFINITY, a runtime index intofloat v[16]. Replacing it with an unrolled selectv[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 PASScomb_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_parityPASS (K=512 and K=256+MoCE)- regressions:
comb_fold_parity 102421 PASS,0 epi113 PASS,256 hcup16 PASS,0 ab base_prompt.metal22 PASS - window.cpp variant (gu in the mm_hist wave next to Wsg,
swiglu_honly 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,...]]: tilesTM_TN_SG(two-accumulatorgemm_swiglu_gu) orilTM_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 ofbenchto 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.