Round 5 Track C Report: Direct FP16 qcurh in qsa_prep + PromptGemm Dead Buffer Removal & Layer-Local Aliasing #

  • Agent: argon-qsa-prep-f16-and-scratch (opencode-go/gemini-4-argon-contributor)
  • Patch Artifact: /tmp/argon_r5_scratch/argon_r5_scratch.patch (verified clean via git -C vendor/strata apply --check /tmp/argon_r5_scratch/argon_r5_scratch.patch and clang++ -fsyntax-only on window.cpp)
  • Test Binaries: /tmp/argon_r5_scratch/{qsa_prompt_parity,qsa_parity,arena_budget_test,qsa_prep_bench,need_bytes_check}

1. Summary of Changes #

Target 1: Fuse qsa_q_f16 into qsa_prep when p.flash is enabled (metal/kernels/qsa.metal + metal/src/engine/window.cpp) #

  • Added constant bool QSA_Q_F16_FC [[function_constant(25)]] (qsa_qf16) to metal/kernels/qsa.metal:
    • When qsa_qf16 is true, qsa_prep writes ((device half*)qcur)[(t * QH + r) * QD + tid] = half(o); directly to buffer 10 instead of float (qcur[(t * QH + r) * QD + tid] = o;).
  • In Window::prompt_chunk (metal/src/engine/window.cpp lines 3424–3444):
    • When p.flash is true, qsa_prep is dispatched with qsa_c().b(25, true) (pipeline key qsa_k() + "#qf16") and p.qcurh bound directly at buffer 10 (p.flash ? p.qcurh : p.qcur), eliminating the separate qsa_q_f16 kernel dispatch.
    • Across the 12 QSA layers per 2048-token chunk, this eliminates 12 qsa_q_f16 dispatches, 906 MB of DRAM reads/writes (12 * (50.33 MB FP32 write + 50.33 MB FP32 read - 25.17 MB FP16 write)), and makes p.qcur (50.33 MB) completely unused when p.flash == true.

Target 2: Remove Dead Prompt Scratch Allocations (p.XQ, p.yg, p.qcur) + Alias Disjoint GDN vs QSA Prompt Scratch Buffers (metal/src/engine/window.cpp lines 3080–3189) #

  • Dead buffer removal (-323.96 MB at MT=2048, -171.39 MB at MT=1024):
    1. p.XQ: Changed from unconditional F32(4 * MT * 6144 + 3 * (size_t) p.QS * 6144 + MT * 384) (223.35 MB at MT=2048) to p.flash ? F32(16) : F32(4 * MT * 6144 + 3 * (size_t) p.QS * 6144 + MT * 384).
    2. p.yg: Changed from F32(MT * 6144) (50.33 MB at MT=2048, never written because gdn_post runs with ga.f32 = 0) to F32(16).
    3. p.qcur: Changed from F32(MT * 24 * 256) (50.33 MB at MT=2048) to p.flash ? F32(16) : p.z.
  • Disjoint GDN (l % 4 != 3) vs QSA (l % 4 == 3) layer-local scratch aliasing (-250.59 MB additional at MT=2048, -165.15 MB additional at MT=1024):
    • Because every GDN and QSA scratch buffer is produced and consumed strictly inside its own if (l % 4 != 3) ... else ... branch before gemm(..., p.Wout, p.bo, ...) writes p.bo, QSA's layer-local buffers cleanly alias GDN's allocations:
      • p.qkv sized to F32(MT * 12288) (+16.78 MB over 10240) and shared with p.qfull = p.qkv (-83.89 MB net at MT=2048).
      • p.kcur = p.gqn (512 <= 2048 floats/token, -4.19 MB) and p.vcur = p.gkn (512 <= 2048 floats/token, -4.19 MB).
      • p.qcurh = p.ygh (both F16(MT * 6144), -25.17 MB).
      • p.gvc sized to F32(MT * std::max<size_t>(6144, (size_t) MB)) and shared with p.scores = p.gvc (-50.33 MB).
      • p.qids = p.go (2051 <= 6144 words/token, -16.81 MB).
      • p.Wqkv sized to F16((size_t) 12288 * N) (+10.49 MB over 10240 * N) and shared with p.Wq = p.Wqkv (-52.43 MB net).
      • p.Wk = p.Wz (512 * N <= 6144 * N, -2.62 MB), p.Wv = p.Wal (Wal sized to F16(512 * N), -0.25 MB net), p.Wiq = p.Wbe (Wbe sized to F16(512 * N), -0.25 MB net).
    • Both make() in Window::prompt_gemm_init and Window::prompt_gemm_need_bytes() are updated in lockstep.

2. Verification & Benchmark Results on Apple M5 #

2.1 Bitwise Parity (qsa_prompt_parity, qsa_parity, arena_budget_test) #

  • qsa_prompt_parity:
    • qsa_prep(QSA_Q_F16_FC) vs qsa_prep+qsa_q_f16: qcurh memcmp PASS, qsa_flash memcmp PASS (100% bitwise identical qcurh and qsa_flash output).
    • All existing qsa_attn_mm, qsa_flash, and qsa_score_mm checks PASS.
  • qsa_parity: ALL PASS (prep, score, top-k, attention + gate).
  • arena_budget_test: all passed (46/46 checks).
  • need_bytes_check: Verified across 480 combinations of C in {64, 512, 1024, 2048, 4096}, flash in {false, true}, stream in {false, true}, pred2 in {false, true}, bo_alias in {false, true}, ple_on in {false, true}, max_cells in {4096, 32768, 131072} that need == prompt_gemm_need_bytes() and every aliased buffer's capacity is >= its required QSA byte size.

2.2 Microbenchmark (qsa_prep_bench on Apple M5) #

Chunk Size T qsa_prep(f32) (us) qsa_q_f16 (us) Unfused Pair (us / 12L ms) Fused qsa_prep(qf16) (us / 12L ms) Savings per Chunk (12 QSA Layers) qcurh memcmp
512 226.0 us 101.9 us 284.5 us (3.41 ms) 186.7 us (2.24 ms) -1.17 ms (-34.3%) PASS
1024 454.7 us 239.1 us 699.7 us (8.40 ms) 373.2 us (4.48 ms) -3.92 ms (-46.7%) PASS
2048 933.9 us 544.6 us 1497.2 us (17.97 ms) 754.4 us (9.05 ms) -8.91 ms (-49.6%) PASS

2.3 prompt_gemm_need_bytes() Memory Reduction (max_cells = 32768, stream = true, ple_on = true) #

Chunk Size C Baseline need After Dead-Buffer Removal (-XQ, -yg, -qcur) After GDN/QSA Layer-Local Aliasing Total Saved
1024 (flash=true) 1.286 GB (1,286.24 MB) 1.115 GB (-171.39 MB) 0.950 GB (949.70 MB) -336.54 MB (-26.2%)
2048 (flash=true) 2.146 GB (2,146.29 MB) 1.822 GB (-323.96 MB) 1.573 GB (1,572.76 MB) -573.53 MB (-26.7%)