opus_r9_qsa — Round 9 Track A: qsa_flash threadgroup memory 19,904 B -> 7,616 B #

patch: /tmp/opus_r9_qsa/opus_r9_qsa.patch (git -C vendor/strata apply --check clean against the current dirty working tree). touches only metal/kernels/qsa_prompt.metal (qsa_flash only; qsa_attn_mm untouched) and metal/tests/qsa_prompt_parity.cpp. scratch: /tmp/opus_r9_qsa/strata, build /tmp/opus_r9_qsa/build, baseline kernel /tmp/opus_r9_qsa/baseline_qsa_prompt.metal, experiment kernel dirs /tmp/opus_r9_qsa/v*/.

result (Apple M5, paired alternating, 12 dispatches per command buffer = 12 QSA layers, pos0 0) #

T before median / min (ms, x12) after median / min (ms, x12) delta
512 24.49 / 24.45 20.68 / 20.61 -15.6%
1024 90.77 / 90.62 75.33 / 75.29 -17.0%
1984 330.54 / 329.96 272.54 / 271.44 -17.5%

(second repeat: 24.76->21.17, 90.79->75.62, 330.84->272.44.) single-dispatch, pos0 30000: T512 17.19->13.75, T1024 34.61->27.32, T1984 67.57->52.95 ms. T=96 pos0 0 is ~+5% (0.545->0.570 ms, tiny grid, two extra barriers per chunk); T=96 pos0 30000 is -17%.

bitwise: memcmp PASS for T = 96, 512, 1024, 1984 x pos0 = 0, 30000, plus (96,1984), (96,2050), (1,0), (64,1987), (200,1900). full qsa_prompt_parity (default mode, incl. qsa_prep fused memcmp through qsa_flash): ALL PASS. every process < 3 s.

what changed #

  • sp[4][16*QCH] (16 KiB) -> sp[2][HPG*QCH] (6 KiB); S partials stored with the matmul2d tensor store into a 12-row (dextents(QCH, HPG)) view, in two rounds: sg2/sg3 store, the softmax's own lanes (8 lanes x 8 cells per head) read s2+s3 into registers, barrier, sg0/sg1 store, lanes compute ((s0+s1) + s23) * 0.0625f -> same IEEE order as before, kept in registers for both the max and the exp loops (no re-read / re-add).
  • p (fp16 P, 2 KiB) aliases sp[1], which is dead after the combine (one extra barrier before the P writes); sg3 still zeroes P's 4 padding rows each chunk (S stores clobber them), fac[12..15] zeroed once before the chunk loop.
  • the serial lsum reads float4 at a time, same sequential add order.
  • total 7,616 B -> 4 TGs/core by the 32 KiB rule (was 1).
  • test: --flash-compare T pos0 baseline [layers pairs] now times layers back-to-back dispatches per sample and prints median+min (old 5-arg form unchanged: layers 1, pairs 3).

what the brief got wrong (measured, worth knowing) #

  • Variant A as written is ~40% slower (T1984: 27.4 -> 38.5 ms/layer) while bitwise-identical. the cost is not the barrier and not occupancy: element-wise access of the destination cooperative tensor (s[i] + get_multidimensional_index) is expensive. isolation: original code + one extra barrier = no change (27.43); original code with manual scalar stores instead of s.store = 37.5; float4 manual stores (dest layout is runs of 4 cells, coop_layout 16_64_32) = 35.1. the tensor store into a 12-row view instead of 16 is ~2.5% faster by itself (26.76).
  • variant B (single 3 KiB buffer, 4 serialized store rounds, 6,592 B) is bitwise but slower than the 2-buffer version (T1984 x12: 285.9 vs 279.4) - the serialized stores cost more than the extra occupancy buys.
  • attribution of the 2-buffer register-combine version at 9,664 B (before p aliasing): same code padded back to 19,904 B gives 302.6 ms x12, so of the 330 -> 279 win about half is the code (no 4-way re-read, 12-row store) and half is occupancy; p aliasing (9,664 -> 7,616 B, 3 -> 4 TGs/core) took 279 -> 272.