Skip to content

vulkan : add mul_mat_vec_id for the TurboQuant weight types - #264

Merged
TheTom merged 1 commit into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/mul-mat-vec-id-tq
Aug 4, 2026
Merged

vulkan : add mul_mat_vec_id for the TurboQuant weight types#264
TheTom merged 1 commit into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/mul-mat-vec-id-tq

Conversation

@Defilan

@Defilan Defilan commented Aug 4, 2026

Copy link
Copy Markdown

MoE decode for a TQ3_1S/TQ4_1S model ran entirely on the CPU. #259 rejected MUL_MAT_ID for both types because no mul_mat_vec_id pipeline existed, and reaching ggml_vk_get_dequantize_mul_mat_vec_id() with a TQ src0 asserted on a null pipeline.

No new shader source is needed. All of the expert indirection already lives in mul_mm_vec_base.glsl (get_offsets(), reduce_result()), which both TQ mat-vec shaders include, and the expert id arrives via gl_WorkGroupID.y, which neither shader touches. Compiling the existing sources with MUL_MAT_ID adds exactly one binding — the ids buffer, 5 → 6, matching mul_mat_vec_id_num_bindings — and introduces no subgroup capability, so the 32-thread workgroup pin and shared-memory butterfly that make these kernels wave64-safe are unaffected. spirv-val passes on both.

The reject became a size gate rather than being removed

There is still no TQ mul_mm_id, so prompt processing takes the generic path: ggml_vk_get_mul_mat_mat_id_pipeline() returns nullptr, qx_needs_dequant goes true, and the entire expert tensor is staged as f16 (x_ne = ggml_nelements(src0), across all experts). This rejects the cases where that staging buffer would not fit, otherwise ggml_vk_mul_mat_id_q_f16() reaches GGML_ABORT("Requested preallocation size is too large").

The gate is deliberately independent of src2->ne[1]. An n-dependent gate is worse than no support: weight_buft_supported() (llama-model-loader.cpp) probes with a fixed ids->ne[1] = 512, so a gate that answers differently at load and at decode parks the experts in one backend's buffer and then runs the op in the other, copying every expert tensor across the bus on every token.

Worked example of what it excludes: DeepSeek-V4-Flash Config-I has ffn_{gate,up,down}_exps of 2048 × 4096 × 256 = 2,147,483,648 elements, so the f16 staging buffer is exactly 4,294,967,296 bytes — one byte over RADV's UINT32_MAX maxStorageBufferRange. Its experts stay on the CPU, as before this change. Lifting that needs a real TQ mul_mm_id that reads the quantized data through dequant_funcs.glsl and never allocates the f16 buffer. I have that in progress.

Validation on gfx1151 (Radeon 8060S, RADV, wave64)

test-backend-ops -o MUL_MAT_ID:

tq3_1s tq4_1s suite
before 2 cases, both NOT_SUPPORTED same 891/891
after 38 OK, 0 FAIL, 0 skipped 38 OK, 0 FAIL, 0 skipped 967/967

891 + 4 newly-supported + 72 new sweep cases = 967, which reconciles exactly. That arithmetic matters here because of #242 — a fully skipped op still reports OK, so I counted executed cases rather than trusting the summary line.

Full sweep on an idle GPU: 24572/24572, 2/2 backends. An earlier run showed one failure, which turned out to be pre-existing: the same sweep on an image without any of this fails identically, on a TOPK_MOE case unrelated to TurboQuant. Filed separately as #261.

Real-model A/B on thetom-ai/Qwen3.6-35B-A3B-ConfigI (431 TQ3_1S tensors, 256 experts, sha256-verified), since synthetic kernel tests are exactly what let the CUDA fused-TQ3_1S bug survive:

chunk[1] chunk[2] final PPL
experts on CPU (control) 4.5161 6.5144 6.5144 ± 0.76005
experts on GPU (this PR) 4.5241 6.5037 6.5037 ± 0.75956

0.16% apart, far inside the error band.

llama-bench -ngl 99 -p 512 -n 128 -r 2:

pp512 tg128
control 7.86 ± 0.00 4.30 ± 0.01
this PR 397.31 ± 3.12 20.89 ± 0.02

To be precise about what those numbers mean: both arms report backend: Vulkan and the control is already on the GPU. What moved is the MoE expert matmuls, which the blanket reject forced onto the CPU per-op. The gain is not decode-only either — replacing the reject with the size gate enables both prefill (via mul_mm_id + f16 dequant) and decode (via mul_mat_vec_id).

Tests

test-backend-ops gains a TurboQuant MUL_MAT_ID sweep. TQ3_1S/TQ4_1S are in all_types but not base_types, so their only prior coverage was two cases. This covers both sides of the n <= 8 threshold that ggml_vk_use_mul_mat_vec_id() splits on, three n_used counts, and broadcast, which exercises the expert-index wrap.

AI usage disclosure: written with Claude Code; I reviewed every change and ran all the measurements above on my own hardware.

MoE decode for a TQ3_1S/TQ4_1S model ran entirely on the CPU: ggml-org#259 rejected
MUL_MAT_ID for both types because no mul_mat_vec_id pipeline existed, and
reaching ggml_vk_get_dequantize_mul_mat_vec_id() with a TQ src0 asserted on a
null pipeline.

No new shader source is needed. All of the expert indirection lives in
mul_mat_vec_base.glsl (get_offsets(), reduce_result()), which both TQ mat-vec
shaders already include, and the expert id arrives via gl_WorkGroupID.y, which
neither shader touches. Compiling the existing sources with MUL_MAT_ID adds
exactly one binding (the ids buffer, 5 -> 6, matching
mul_mat_vec_id_num_bindings) and introduces no subgroup capability, so the
32-thread workgroup pin and the shared-memory butterfly that make these
kernels wave64-safe are unaffected. spirv-val passes on both.

The blanket reject is replaced by a size gate rather than removed. There is
still no TQ mul_mm_id, so prompt processing takes the generic path, where
ggml_vk_get_mul_mat_mat_id_pipeline() returns nullptr, qx_needs_dequant goes
true, and the ENTIRE expert tensor is staged as f16 -- x_ne is
ggml_nelements(src0), across all experts. Reject when that staging buffer
would exceed maxStorageBufferRange, or ggml_vk_mul_mat_id_q_f16() reaches
GGML_ABORT("Requested preallocation size is too large").

The gate is deliberately independent of src2->ne[1]. An n-dependent gate is
worse than no support: weight_buft_supported() (llama-model-loader.cpp) probes
with a fixed ids->ne[1] = 512, so a gate that answers differently at load and
at decode parks the experts in one backend's buffer and then runs the op in
the other, copying every expert tensor across the bus on every token.

Worked example of what the gate excludes: DeepSeek-V4-Flash Config-I has
ffn_{gate,up,down}_exps of 2048 x 4096 x 256 = 2,147,483,648 elements, so the
f16 staging buffer is exactly 4,294,967,296 bytes -- one byte over RADV's
UINT32_MAX maxStorageBufferRange. Its experts therefore stay on the CPU, as
before this change. Lifting that needs a real TQ mul_mm_id, which reads the
quantized data through dequant_funcs.glsl and never allocates the f16 buffer.

test-backend-ops gains a TurboQuant MUL_MAT_ID sweep. TQ3_1S/TQ4_1S are in
all_types but not base_types, so their only prior coverage was two cases; this
covers both sides of the n <= 8 threshold ggml_vk_use_mul_mat_vec_id() splits
on, three n_used counts, and broadcast, which exercises the expert-index wrap.

Assisted-by: Claude
@Defilan

Defilan commented Aug 4, 2026

Copy link
Copy Markdown
Author

Tangential, but it came out of reading this area and it affects the branch this PR targets: I have filed #265 — the CUDA TQ3_1S correctness fixes from #256 are merged but not reachable from feature/turboquant-kv-cache, so feature still routes TQ3_1S through the fused kernel that c29f0d1cd disables.

Relevant to this PR in two ways.

2293b1da6 ("gate fused TQ mul_mat paths on contiguous src1/dst") describes the same hazard class I hit reading the Metal rotated mul_mm_id path: its activation rotate indexes src1 flat while the matmul reads it with nb11/nb12/nb13, so a strided or padded src1 rotates different elements than the matmul consumes. Good to know that shape is already understood on the CUDA side.

And c29f0d1cd's note that "test-backend-ops MUL_MAT cases at the exact failing shape pass with random data" is exactly why I did not treat the 38/38 synthetic result above as sufficient here, and ran the Qwen3.6-35B-A3B perplexity A/B against a CPU-expert control instead. Synthetic kernel tests are the condition under which that CUDA bug survived.

@TheTom

TheTom commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Verified on wave32 and merging. This closes the gap #259 left, and closes it better than I would have.

GB10, NVIDIA, warp size 32

check result
Vulkan build clean
TQ3_1S/TQ4_1S MUL_MAT_ID cases 76 executed, 76 OK, 0 skipped
MUL_MAT_ID suite 967/967
full test-backend-ops 24572/24572, zero failures

The count arithmetic is a nice confirmation on its own: the tree was 24496 before this PR, your sweep adds 76 TQ cases, and the run lands on exactly 24572 with nothing else moved.

And the case that mattered most:

MUL_MAT_ID(type_a=tq3_1s,...,n_used=2,m=64,n=1,k=96): OK
MUL_MAT_ID(type_a=tq4_1s,...,n_used=2,m=64,n=1,k=96): OK

That is the decode shape that asserted on a null pipeline in #259 — the one my reject was standing in for. It now runs on the GPU and matches the reference. Two wave widths, your gfx1151 and this GB10, same conclusion.

On the parts that are better than my reject

The src2->ne[1]-independence argument is the part I would have got wrong. I would have reached for an n-dependent gate as the conservative option, and your reasoning about weight_buft_supported() probing at a fixed ids->ne[1] = 512 is correct — a gate that answers differently at load and at decode parks the experts in one backend and runs the op in the other, which trades a clean CPU fallback for a per-token bus copy. Sizing the gate on the f16 staging buffer instead is the right axis.

Compiling the existing sources with MUL_MAT_ID rather than writing new shaders is also the right instinct, and I checked the property it rests on: the binding count moves 5 to 6 as expected, no subgroup capability is introduced, and the 32-thread pin plus SHMEM reduction survive intact in the id pipelines. The wave64 safety argument from #259 still holds.

Running a real-model PPL A/B because synthetic kernel tests let the CUDA fused-TQ3_1S bug through is the correct lesson to draw from that incident, and 6.5037 vs 6.5144 inside the error band is the evidence that matters. The 7.86 to 397 pp512 and 4.30 to 20.89 tg128 numbers are the payoff — MoE decode for these types was running entirely on CPU before this.

Filing the RADV TOPK_MOE flakiness separately as #261 rather than folding it in was also right; I confirmed it reproduces without any of this and it is not yours to carry.

Standing offer, renewed

You have now found and fixed two real gaps in this backend, each time with the diagnosis rather than just the patch. If you want the set_rows_tq4_1s / cpy_tq4_1s_f32 work as well it is yours — worth knowing that #260 is currently attempting it and the shader is failing 6 of 7 shapes with out-of-bounds writes on the broadcast cases, so there may be more value in you two comparing notes than in either doing it alone.

@TheTom
TheTom merged commit 0463c8e into TheTom:feature/turboquant-kv-cache Aug 4, 2026
9 of 25 checks passed
@Defilan

Defilan commented Aug 4, 2026

Copy link
Copy Markdown
Author

Closing the loop on the "I have that in progress" note above, since it changes what this PR's size gate costs in practice.

The TQ mul_mm_id follow-up works. On the same box (gfx1151, RADV, wave64), DeepSeek-V4-Flash Config-I, llama-perplexity -c 512 --chunks 2 -ngl 99 --no-mmap -ub 64, identical flags in both arms:

chunk[1] chunk[2] final PPL s/pass
experts on CPU (this PR's behaviour) 10.4794 13.3367 13.3367 ± 1.86891 316.80
experts on GPU (rotated mul_mm_id) 8.9692 12.9997 12.9997 ± 1.79589 23.68

13.4x on the pass, and the perplexity improves rather than degrades.

The improvement is worth explaining rather than just reporting, because a shift that size is also what a subtle kernel bug looks like. TQ3_1S has .vec_dot_type = GGML_TYPE_Q8_0 (ggml-cpu.c), so the CPU fallback quantizes the activation to q8_0 before every dot product. The rotated GPU path keeps it in f32. Less lossy input, lower perplexity. The three DeepSeek runs line up monotonically with how much intermediate quantization each avoids:

ub=64, experts CPU (q8_0 activations)   chunk[1] 10.4794
ub=1,  experts CPU (mat-vec, q8_0)      chunk[1]  9.7819
ub=64, experts GPU (rotated, f32)       chunk[1]  8.9692

On Qwen3.6-35B-A3B, where the comparison is GPU-versus-GPU rather than GPU-versus-q8_0-CPU, chunk[1] moves 4.5768 -> 4.5769 against a CPU-expert control — which is the agreement I would expect when no lossy intermediate is being removed.

None of this needs to land in this PR. The size gate here is still the right conservative behaviour for a tree without a TQ mul_mm_id: without one, prompt processing really does try to stage 4,294,967,296 bytes and abort. I will send the follow-up separately once you have had a chance to look at this one, rather than stack two changes to the same gate.

@Defilan

Defilan commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thank you — and for taking the time to verify it on wave32 rather than just taking my numbers. Two wave widths agreeing is worth more than either run alone, and the count arithmetic landing on 24572 is a better check than I could do from one machine.

On set_rows_tq4_1s / cpy_tq4_1s_f32: I would rather help @giveen land it than take it over. He has done the wiring and surfaced a real bug in it, and that is the harder half.

I have already put what I found on #260 — short version, since it postdates your comment there and changes the shape of it a little:

  • On gfx1151 wave64 it is 20 of 24 failing, not 6 of 7. The four "passes" are all r=1, v=1, where the view has r/2 = 0 rows, so ggml_is_empty(src0) makes ggml_vk_set_rows early-return and nothing is dispatched. They pass by writing nothing.
  • So every case that writes a row fails, including single-row, contiguous, no broadcast. Broadcast is not the discriminator.
  • The likely cause is the bounds check: p.ne is ggml_nelements(src0) for SET_ROWS, so the old p.ne / 32 counted source rows (r), while gpr * ne21*ne22*ne23 counts destination rows (ne1). Every generic case has ne1 > r, so the shader runs past the source. For ne=[256,5,1,3], r=1 that is 120 groups where 24 are correct.
  • Turbo did not catch it because the turbo types are not in the generic SET_ROWS sweep — they have bespoke test_set_rows_turbo3/4. TQ4_1S is in all_types, so it is the first type through a sweep that varies r independently of ne1.

I got one thing wrong on the way there and it is worth flagging: I first concluded the bounds-check change was a harmless refactor, because p.ne is ggml_nelements(dst) — in the unary push-constant initialiser. SET_ROWS uses the binary one. Anyone verifying this should check ggml_vk_set_rows at :13026 specifically.

Happy to test a candidate on wave64 whenever he has one, or to take a pass at the fix myself if he would rather hand it over — his call, not mine.

Separately: the TQ mul_mm_id follow-up is rebased onto this merge and I will open it shortly, so the DeepSeek numbers above have somewhere to live.

TheTom pushed a commit that referenced this pull request Aug 7, 2026
Wires the pieces together, so MoE prompt processing for a TQ3_1S/TQ4_1S model
runs the rotated matmul instead of staging every expert tensor as f16.

The getter cases and the rotate orchestration land in one commit on purpose:
either alone runs the rotated matmul against an unrotated activation and
returns silently wrong numbers.

Flow when tq_rotate is set:
  1. force staging, so d_Y is ctx->prealloc_y and never aliases src1
  2. contiguous f32 copy src1 -> prealloc_y
  3. barrier, then tq_rotate_act in place on prealloc_y
  4. mul_mm_id reads the rotated f32 copy against centroid*scale weights

Details that have to agree or the result is quietly wrong:

- `tq_rotate` requires `mmp != nullptr && !x_non_contig`, i.e. that the TQ
  pipeline was really selected. On the f16 dequant fallback the weights are
  true weights and must NOT see a rotated activation.
- Staging is forced even for a contiguous f32 src1. Otherwise y_f32_kernel is
  true, qy_needs_dequant is false, and d_Y aliases the graph's own tensor --
  the hazard Metal's in-place rotate/un-rotate has to work around. The
  "not implemented" assert is relaxed for exactly this case.
- effective_src1_type and y_sz both switch to f32, matching what the rotate
  writes. The staging pipeline is an f32 cpy, not the usual f16 one: the
  butterfly is 5 rounds of adds and f16 would lose precision quantization did
  not.
- The prealloc_y reuse cache is keyed on the ROTATE pipeline pointer rather
  than the cpy pipeline. prealloc_y now holds rotated data, so a later
  non-rotated consumer of the same src1 must not reuse it; keying this way
  forces a re-stage for anyone else.
- Only the ne11*ne12*ne13 rows the copy wrote are rotated; the padded_n rows
  above ne11 are left alone, as in the f16 staging path.
- ggml_vk_get_mul_mat_mat_id_pipeline() ends in GGML_ASSERT(support_fp32acc),
  so listing TQ in its switch would ABORT on a coopmat2 device, where these
  pipelines are deliberately not created. It now returns nullptr when both acc
  variants are empty, falling back to the f16 dequant path.

Not yet done: the maxStorageBufferRange gate in supports_op still rejects large
expert tensors on the old premise that prompt processing must stage them as
f16. With this path that premise no longer holds, but relaxing it is a separate
commit pending review of #264.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants