opus_gdn_qsa — round 3 track A: gemv_cat for QSA attn_k + attn_v (+ MTP k/v), GDN decode/prefill kernels #
patch: /tmp/opus_gdn_qsa/opus_gdn_qsa.patch (639 lines; git -C vendor/strata apply --check passes on the current tree).
scratch tree: /tmp/opus_gdn_qsa/strata (baseline commit = the live working tree at start), A/B harnesses in /tmp/opus_gdn_qsa/ab/.
nothing touched :8080, vendor/strata/, or ran a model benchmark. the bench numbers below came from a box that also had the live server on it,
so trust the interleaved A/B harness numbers (base and new kernels alternated in one process) over the standalone --bench runs.
1. gemv_cat: attn_k + attn_v in all 12 QSA layers, MTP k_proj + v_proj #
what the GGUFs actually hold (blk.{3,7,..,47}, 2560 -> 512) #
| model | attn_k / attn_v types |
|---|---|
| K512-iq2_xs-metal | 3: Q6_K/IQ4_XS, 7: Q6_K/Q6_K, 11: IQ4_XS/IQ4_XS, 15: IQ3_S/IQ4_XS, 19,27,35,43: IQ4_XS/IQ4_XS, 23,31,39: IQ4_XS/Q6_K, 47: Q6_K/Q6_K |
| K256-q2_0-healed / K256-metal | Q6_K/Q6_K except 7: IQ4_XS/Q6_K, 11 & 19: Q6_K/Q5_K |
| mtp-q2_0.gguf | k_proj/v_proj BF16 (Q8_0 in mq8_ with STRATA_METAL_MTP_Q8) |
so a same-format-only gemv_cat would have fused only 7/12 K512 layers. gemv_tile returns 32_1_4_1 (KS=1) at 2560->512 for
all of Q6_K / IQ4_XS / IQ3_S / Q5_K at every ntok (the m5 special cases only fire at n_out == 640). MTP's own tile logic gives BF16 32_1_4_1,
Q8_0 32_1_4_4 at one row and 16_1_4_1 above.
design #
gemv.metal: gemv's body moved verbatim intogemv_rows<F,LPR,NR,SG,KS>();gemvis now load_tab + barrier + gemv_rows.gemv_cat<F,F2,LPR,NR,SG,KS>splits the grid instead of the rows: threadgroups[0, groups)run gemv_rows on W (format F), the rest on W2 (format F2, its own prepared-x planexp2and row bytes). every threadgroup runs exactly gemv's code, so it's bitwise for any KS (including the KS=4 Q8_0 MTP tile), and mixed formats are fine because the branch is uniform per threadgroup. the old row-interleaved same-format-only kernel is gone; alpha+beta now goes through the same kernel.- instances: BF16/F32 same-format at the 4 old tiles; {Q6_K, IQ4_XS, IQ3_S, Q5_K}² at 32_1_4_1; Q2_0 pair; Q8_0 at 32_1_4_4 / 16_1_4_1 / 32_1_4_1.
window.cpp:gemv_cat()takes the pair's formats (instance table mirrors the macros), requiresgemv_tile(ta) == gemv_tile(tb), and optionally an explicit tile + weight buffers (for MTP'smq8_/Wm_). QSA layer: attn_k+attn_v → one dispatch, fallback to the two gemvs.mtp_encode:gm's tile code pulled out intomtile(), k_proj+v_proj → gemv_cat when both tiles agree.token_chain.cpp(CHAIN_ALBE path) updated to the new kernel name/args (built, not run: it needs the model).
a compile-time trap i hit #
static inline on the new template helper made the runtime MSL compile of the gemv library +0.65 s (2.40 → 3.05 s, 4 reps each, stable);
plain inline brings it back to 2.43 s. kept inline. (the cat instances themselves cost ~nothing.)
verification #
fuse_parity(extended): BF16+BF16 48, F32+F32 48, Q6_K+Q6_K, IQ4_XS+IQ4_XS, Q6_K+IQ4_XS, IQ4_XS+Q6_K, IQ3_S+IQ4_XS, Q6_K+Q5_K, Q2_0+Q2_0, BF16+BF16 512, Q8_0+Q8_0 (KS=4 at ntok 1) — ntok 1..8 each, bitwise PASS. the mixed cases read different x planes per matrix; i mutated the kernel to read W2 throughxpand the 4 mixed cases FAIL, so the test catches that bug.- gemv refactor A/B (base vs new gemv.metal, 7 tiles incl. KS 2/4/8, ntok 1/2/4/8): bitwise identical, timing within ±1%.
gemv_parity,pair_parity: ALL PASS. all 28 instances the host can pick resolve; the 4 engine kernel libraries compile together (no name clashes);strataandstrata_window_clibuild and link in a scratch engine build (/tmp/opus_gdn_qsa/build-eng).
expected gain: honestly about zero GPU time #
in a concurrent encoder the two gemvs already overlap (nb_ = true), and gemv_cat measured the same (12 distinct layers, run_graph):
| pair, ntok | 2 gemvs concurrent | gemv_cat |
|---|---|---|
| IQ4_XS+IQ4_XS 1/2/4 | 16.10 / 22.62 / 32.70 us | 16.11 / 22.58 / 32.59 us |
| Q6_K+IQ4_XS 1/2/4 | 17.54 / 22.73 / 34.79 us | 17.72 / 22.83 / 35.03 us |
| Q8_0+Q8_0 1/2/4 | 20.25 / 21.05 / 25.95 us | 20.37 / 21.31 / 26.77 us |
| BF16 48+48 (alpha/beta, already fused upstream) 1/2/4 | 4.25 / 4.28 / 5.42 us | 4.22 / 4.35 / 5.09 us |
what it buys: 12 fewer dispatches per decode window (+1 per MTP encode): host encode time and command-processor work, which these GPU-time numbers can't see. it only pays off end to end if the window is encode- or dispatch-bound; measure it on the live server before believing it. STRATA_METAL_GEMV_CAT=0 still turns every cat off.
2. gdn_step (kernels/gdn.metal): bitwise, faster on multi-token windows #
changes (all exact: every value comes from the same expression in the same order):
- z[t] and the next token's conv input are issued early (z at the top of the token, qkv[t+1] during token t, qkv[0] next to the state loads); gamma[j] is loaded once.
- the gate and beta are computed on two otherwise idle threads (384, 416) in parallel, instead of serially on tid 0, which also does a conv.
- kv and o partials use separate buffers (red / red2), and the q/k-norm and output-norm partials use separate ones too (part / part2), which drops the
WAR barrier between them. the plane-mode per-16 sums come from
simd_shuffle_downwithin the simdgroup (the same even/odd column order), which drops theysbuffer and the end-of-token barrier. - k and q rows reach the FMA loops through
simd_broadcastfrom one lane each, not 64 broadcast threadgroup loads a thread per token (the biggest single piece: per-token cost 1.5 → 1.33 us at grid 10).
A/B (base vs new, same process, 36 rotating layer states, plane mode on), per layer:
bitwise base vs new: PASS
ntok 1 commit: base 60.11 us new 60.28 us (+0.3%)
ntok 1 verify: base 28.48 us new 27.93 us (-2.0%)
ntok 2 commit: base 60.99 us new 60.57 us (-0.7%)
ntok 2 verify: base 38.36 us new 36.11 us (-5.9%)
ntok 3 commit: base 65.35 us new 63.86 us (-2.3%)
ntok 3 verify: base 49.02 us new 44.35 us (-9.5%)
ntok 4 commit: base 79.50 us new 70.22 us (-11.7%)
ntok 4 verify: base 60.23 us new 55.98 us (-7.1%)
ntok 8 commit: base 108.40 us new 91.64 us (-15.5%)
ntok 8 verify: base 89.86 us new 73.27 us (-18.5%)
the bitwise check covers ntok 1..8 × commit {1, 0, 0x100|k for every k} × plane {0, on}: state, hist_out and y (all planes + the 16-sums).
gdn_parity ALL PASS (IB=4, and IB=8 via GDN_IB8). gdn_prompt_parity ALL PASS.
why ntok=1 doesn't move: commit is ~60 us for 6.3 MB of state read + write ≈ 105 GB/s, i.e. bandwidth. verify is 28 us for 3.1 MB
(≈112 GB/s). the per-token part scales linearly with the threadgroup count (grid 10/20/30/40/48 → slope ×1/×2/×3/×4/×4.9), so the core is
saturated by one 512-thread TG: it's throughput, not barriers. a probe that dropped a whole barrier saved <1%, and setting
max_total_threads to 1024 (fewer registers) changed nothing.
tried and dropped: float4 threadgroup reads of k/q (no gain), alpha/beta prefetch for the next token (slower, +4 us at ntok 2-4),
unroll(disable) on the token loop (same), caching the broadcast k in 32 registers (same).
3. gdn_scan_r (kernels/gdn_prompt.metal): bitwise, -8% on the engine's gdn_scanr_4 #
- a token's dots with the state (ka = S^T k, qa = S^T q) move into the previous token's update pass. the sums and their order are unchanged: st[r] is updated, then fma'd into ka/qa in the same r order. that makes one pass over the state registers a token instead of two; the block's first token still takes its dots on its own (the last token of a block does one redundant dot).
- with R ≤ 32 (KC), the next token's k float4s loaded for the dots stay in registers for its update, which saves 8 of 24 threadgroup float4 loads a token.
A/B, T=2048, one layer:
gdn_scanr_4 T 2048: base 8.193 ms new 7.509 ms (-8.4%)
gdn_scanr_2 T 2048: base 8.316 ms new 8.845 ms (+6.4%)
gdn_scanr_8 T 2048: base 9.626 ms new 9.170 ms (-4.7%)
gdn_scanr_4_8 T 2048: base 7.783 ms new 7.451 ms (-4.3%)
bitwise base vs new: PASS
(bitwise over T ∈ {1,2,3,15,16,17,37,300,2048} × all four scanr instances: state and o.) gdn_scanr_4 is the only one the engine uses (prompt_chain.inc, window.cpp); scanr_2 is a test-only variant whose timing is noisy (base vs an identical copy showed +20% once), so i left it alone. gdn_pre/gdn_post are already ≈ bandwidth-bound (~170 MB / ~125 MB per 2048-token layer), so i didn't touch them.
files #
metal/kernels/gemv.metal: gemv_rows, mixed-format any-KS gemv_cat + instancesmetal/src/engine/window.cpp: gemv_cat signature/instance table, QSA attn_k+attn_v, MTP k_proj+v_proj,mtilemetal/kernels/gdn.metal: gdn_step as abovemetal/kernels/gdn_prompt.metal: gdn_scan_r fused dotsmetal/tests/fuse_parity.cpp: gemv_cat matrix test;metal/tests/token_chain.cpp: the new gemv_cat ABI
not verified here (needs the model / :8080) #
end-to-end tok/s and output identity on the real server. kernel-level bitwise identity suggests generations come out byte-identical with STRATA_METAL_GEMV_CAT on or off, and before or after the patch, but i haven't run that.