Summary
Batched decoding of a many-expert MoE model (Qwen3-Coder-Next 30B-A3B, 512 experts, top-10,
Q4_K_XL) on an AMD Strix Halo iGPU (gfx1151, RADV, Vulkan backend) shows a large throughput
cliff at exactly 9 concurrent sequences: aggregate TG drops from 122.5 t/s (B=8) to
82.9 t/s (B=9) and only recovers above B≥24. I traced it to two fixed batch-size thresholds
in the Vulkan backend that switch from the mul_mat_vec kernels to the tiled matmul kernels
above 8 tokens. Raising them removes the cliff on this hardware/model: +56% at B=9, +34% at
B=16, +20% at B=32, with monotonic scaling through B=32 and no regression at B≤8.
Environment
- HW: AMD Ryzen AI MAX+ 395 (Strix Halo), Radeon 8060S iGPU (gfx1151), 128 GB unified LPDDR5X
- Driver: RADV (Mesa), Vulkan backend, coopmat enabled
- llama.cpp: reproduced on b9293 and on current master (20a04b2)
- Model: Qwen3-Coder-Next-UD-Q4_K_XL (30B-A3B MoE, 512 experts, top-10)
Reproduction
llama-batched-bench -m Qwen3-Coder-Next-UD-Q4_K_XL.gguf -c 32768 -ngl 999 -fa 1 \
-ctk q4_0 -ctv q4_0 --no-mmap -npp 512 -ntg 128 -npl 1,2,4,8,9,12,16,24,32
Aggregate TG t/s, master (20a04b2), stock:
| B |
1 |
2 |
4 |
8 |
9 |
12 |
16 |
24 |
32 |
| stock |
45.4 |
69.0 |
96.2 |
122.5 |
82.9 |
93.1 |
103.8 |
118.9 |
130.9 |
The cliff is at the B=8→9 boundary and recovers slowly — a dispatch-boundary signature, not
bandwidth saturation. GGML_VK_DISABLE_COOPMAT=1 makes B=16 worse (86.4), so it is not a
coopmat tile issue: any mul_mm path at small n loses to the MMV path on this bandwidth-bound
device.
Root cause
Two fixed thresholds switch away from the mul_mat_vec kernels above 8 tokens:
-
mul_mat_vec_max_cols = 8 (ggml-vulkan.cpp) — dense projections;
-
ggml_vk_use_mul_mat_vec_id(): src2->ne[1] <= 8 — the MUL_MAT_ID expert path, which
dominates. GGML_VK_PERF_LOGGER=1 shows the expert matmuls growing super-linearly across
the boundary (per-call, decode phase):
MUL_MAT_ID q4_K m=512 n=10 k=2048 n_expert=512: 166 µs at batch=8 → 440 µs at batch=12 (2.65×)
MUL_MAT_ID q5_K m=2048 n=10 k=512 n_expert=512: 213 µs → 488 µs (2.29×)
Dense MUL_MAT_VEC entries scale sub-linearly per token across the same boundary (healthy).
The <= 8 heuristic makes sense for few-expert MoEs (8-expert Mixtral-style), where tokens
share experts and the gather-based mm-id amortizes weight reads. For a 512-expert/top-10
model, token–expert overlap at these batch sizes is near zero (B=12 → 120 draws over 512
experts), so switching to mm-id amortizes nothing and pays the tile-inefficiency cost.
vec-id dispatches per token (descriptor sets = nei1) and has no structural limit.
Experiment: raising the thresholds
Local change: mul_mat_vec_max_cols 8→16, and the vec-id gate ne[1] <= 8 → <= 32.
| B |
8 |
9 |
12 |
16 |
24 |
32 |
| stock |
122.5 |
82.9 |
93.1 |
103.8 |
118.9 |
130.9 |
| patched |
126.5 |
129.4 |
135.7 |
139.0 |
150.9 |
157.2 |
- Monotonic through B=32; B=1–4 unchanged (49.1/71.5/99.5 vs 45.4/69.0/96.2).
test-backend-ops test -o MUL_MAT and -o MUL_MAT_ID pass vs CPU.
- temp=0 output is byte-identical to stock (same model/prompt).
- End-to-end (llama-server, real prompts, ~1600-tok streams): +41% aggregate at 16 concurrent
slots, J/token floor drops 0.61 → 0.506 on this device.
Suggested direction
A flat constant is probably wrong in both directions — for few-expert MoEs the mm-id switch
is right where it is; for many-expert MoEs vec-id keeps winning far beyond 8. A density-based
gate for MUL_MAT_ID would capture both regimes, e.g. stay on vec-id while expected rows per
activated expert is low:
use_vec_id = n_tokens * n_experts_used <= C * n_expert // C ≈ 1–2, to be tuned
(Qwen 512/top-10 → vec-id up to ~50–100 tokens; Mixtral 8/top-2 → switches at ~8, like today.)
For the dense mul_mat_vec_max_cols, per-arch tuning similar to what PR #20905 did for the
CUDA backend may apply; on gfx1151/RADV, 16 cols showed no downside in my runs.
I'm happy to run benchmarks/validation on this hardware (Strix Halo / gfx1151 / RADV) if
useful — full benchmark logs and the perf-logger dumps are available.
Summary
Batched decoding of a many-expert MoE model (Qwen3-Coder-Next 30B-A3B, 512 experts, top-10,
Q4_K_XL) on an AMD Strix Halo iGPU (gfx1151, RADV, Vulkan backend) shows a large throughput
cliff at exactly 9 concurrent sequences: aggregate TG drops from 122.5 t/s (B=8) to
82.9 t/s (B=9) and only recovers above B≥24. I traced it to two fixed batch-size thresholds
in the Vulkan backend that switch from the mul_mat_vec kernels to the tiled matmul kernels
above 8 tokens. Raising them removes the cliff on this hardware/model: +56% at B=9, +34% at
B=16, +20% at B=32, with monotonic scaling through B=32 and no regression at B≤8.
Environment
Reproduction
Aggregate TG t/s, master (20a04b2), stock:
The cliff is at the B=8→9 boundary and recovers slowly — a dispatch-boundary signature, not
bandwidth saturation.
GGML_VK_DISABLE_COOPMAT=1makes B=16 worse (86.4), so it is not acoopmat tile issue: any mul_mm path at small n loses to the MMV path on this bandwidth-bound
device.
Root cause
Two fixed thresholds switch away from the mul_mat_vec kernels above 8 tokens:
mul_mat_vec_max_cols = 8(ggml-vulkan.cpp) — dense projections;ggml_vk_use_mul_mat_vec_id(): src2->ne[1] <= 8— the MUL_MAT_ID expert path, whichdominates.
GGML_VK_PERF_LOGGER=1shows the expert matmuls growing super-linearly acrossthe boundary (per-call, decode phase):
MUL_MAT_ID q4_K m=512 n=10 k=2048 n_expert=512: 166 µs at batch=8 → 440 µs at batch=12 (2.65×)MUL_MAT_ID q5_K m=2048 n=10 k=512 n_expert=512: 213 µs → 488 µs (2.29×)Dense MUL_MAT_VEC entries scale sub-linearly per token across the same boundary (healthy).
The
<= 8heuristic makes sense for few-expert MoEs (8-expert Mixtral-style), where tokensshare experts and the gather-based mm-id amortizes weight reads. For a 512-expert/top-10
model, token–expert overlap at these batch sizes is near zero (B=12 → 120 draws over 512
experts), so switching to mm-id amortizes nothing and pays the tile-inefficiency cost.
vec-id dispatches per token (descriptor sets = nei1) and has no structural limit.
Experiment: raising the thresholds
Local change:
mul_mat_vec_max_cols8→16, and the vec-id gatene[1] <= 8→<= 32.test-backend-ops test -o MUL_MATand-o MUL_MAT_IDpass vs CPU.slots, J/token floor drops 0.61 → 0.506 on this device.
Suggested direction
A flat constant is probably wrong in both directions — for few-expert MoEs the mm-id switch
is right where it is; for many-expert MoEs vec-id keeps winning far beyond 8. A density-based
gate for MUL_MAT_ID would capture both regimes, e.g. stay on vec-id while expected rows per
activated expert is low:
(Qwen 512/top-10 → vec-id up to ~50–100 tokens; Mixtral 8/top-2 → switches at ~8, like today.)
For the dense
mul_mat_vec_max_cols, per-arch tuning similar to what PR #20905 did for theCUDA backend may apply; on gfx1151/RADV, 16 cols showed no downside in my runs.
I'm happy to run benchmarks/validation on this hardware (Strix Halo / gfx1151 / RADV) if
useful — full benchmark logs and the perf-logger dumps are available.