Files
rocky-vulkan-llama/docs/shader-tuning.md
T

5.0 KiB
Raw Blame History

Shader tuning — Mali-tuned mul_mm warptile (the throughput unlock)

After the driver fix (no OOM) revealed the real ceiling was llama.cpp's desktop-tuned matmul shader, we tune it for Mali. Fable implements the tile; hardware-in-the-loop measures on the G610.

The model (Fable, verified against our instrument)

Per-thread matmul accumulators = WM·WN/WARP (mul_mm.comp: sums[WMITER·TM·WNITER·TN/2], WNITER=WM·WN/(WARP·TM·TN·WMITER)). That count IS the register-spill driver, measured directly by our panvk [panvk-tls] tls_size instrument. Stock on G610 (WARP=16):

  • L: 32·64/16 = 128 acc → 5328 B spill (matches measured L exactly)
  • M: 16·32/16 = 32 acc → 544 B spill (matches measured M exactly)

Fix = shrink WM·WN/WARP so the tile fits Mali's register file (no spill → occupancy recovers).

Iteration 1 — L-variant WM=WN=16 (16 acc). Qwen2.5-3B-Q4_K_M, pp512

ubatch shader before after it1 tls_size
64 M (control, untouched) 21.0 20.6 t/s 544 (unchanged)
128 L (new tile) 5.8 23.4 t/s (4.0×) 5328 → 96
256 L (new tile) 7.4 23.8 t/s (3.2×) 5328 → 96

Spill collapsed 55×; large ubatch now beats the ub64/M ceiling AND the CPU. Confirms: the ceiling was the shader, NOT the hardware. ~24 t/s ≈ 2530% of roofline (~80150 t/s ideal) → headroom left. Patch: patches/llama.cpp-0003-mali-warptile-iter1.patch (adds VK_VENDOR_ID_ARM + is_mali warptile branch, L tuples only). Iteration 2: grow the tile toward the register ceiling for arithmetic reuse.

Iteration 2 — grow tile to 32 acc (WM=32,WN=16) — REGRESSED

ubatch iter1 iter2 tls_size
128 23.4 9.7 t/s (2.4× slower) 96 → 624
256 23.8 10.1 t/s (2.4× slower) 96 → 624

Bigger tile improved arithmetic intensity (AI 1.6→2.67) but tanked throughput. We're OCCUPANCY-bound, not AI-bound: 8704 B shared + 624 B spill cut resident workgroups per core. "Smaller is better" wins the head-to-head — iter1 (16 acc) stays champion at ~23.5 t/s. Iteration 3: go smaller / maximize concurrent workgroups, not bigger.

Iteration 3 — bigger workgroup, SAME registers (BLOCK 256, still 16 acc) — WINS

ubatch iter1 iter3 tls_size
128 23.4 25.8 t/s (+10%) 96 (held)
256 23.8 26.2 t/s (+10%) 96 (held)

Confirms shared-occupancy is the lever (not registers, not AI). Fable back-derived max_threads_per_core=2048 and Valhall reg-bands from our TLS data: at iter1's shape shared-mem/wg capped occupancy ~640 threads; bigger tiles lower shared-per-thread (shared∝BM+BN, threads∝BM·BN) → ~768 threads → +10%. Progression so far: 5.8 (broken) → 23.4 (i1) → 26.2 (i3), ~30% of roofline. Iter4: aggressive BM=128 (BLOCK 512, ~1024 threads) to saturate the register ceiling; then the only remaining lever is a mul_mm.comp register-pressure cut (f16 acc / fewer dequant temporaries).

Iteration 4 — BK_STEP 4→2 (2× occupancy via halved shared) — L path WINS, M regressed

Fable course-corrected: the Q4_K prefill shader is mul_mmq.comp (int-dot), shared = BK_STEP·(20·BM+36·BN). Under BK_STEP=4 iter3 was the warptile occupancy ceiling (2 wg/512 threads); BK_STEP=2 → 4 wg/1024 threads (2× occupancy).

ubatch iter3 iter4
64 (M) 20.6 15.5 ↓ (M shares mul_mmq.comp; extra barriers hurt the saturated M path)
128 (L) 25.8 28.1 (+9%) ↑ tls→{16,48}
256 (L) 26.2 28.5 (+9%)
BK_STEP is a global #define → hit M too. Fix: scope BK_STEP to the L pipeline (spec constant /
per-variant define). Then BK_STEP=1 for full 2048-thread occupancy. Progression (L path):
5.8 → 23.4 → 26.2 → 28.1 t/s (~4.8× from broken, ~35% of roofline). Then f16-acc (iter5) to
break the register band toward 2048 threads.

Iteration 5 — scope BK_STEP to L via spec constant #11 — CLEAN CHAMPION

BK_STEP became layout(constant_id=11) in mul_mmq.comp; only the Mali l_warptile_mmq_int[_k] tuples carry the 12th element (=2), so L gets BK_STEP=2 while M/S inherit default 4.

ubatch iter4 iter5
64 (M) 15.5 20.63 ↑ RESTORED (control intact)
128 (L) 28.1 28.08 held (L win kept)
256 (L) 28.5 28.46 held
L-path 28 t/s WITHOUT the M regression. Shippable champion. Progression (L): 5.8→23.4→26.2→28.1.

Iteration 6 — BK_STEP=1 (full 2048 occupancy attempt) — REGRESSED (barrier crossover)

ubatch iter5 iter6
128 (L) 28.1 23.1 ↓ 4× K-loop barriers outran the occupancy gain
256 (L) 28.5 23.3
BK_STEP=2 is the occupancy sweet spot; reverted to iter5 champion (28 t/s). Shared-based occupancy
exhausted. Next lever: f16 accumulators (register-based occupancy, no extra barriers) to break
the 3364 reg band toward 2048 resident threads.