Skip to content

vulkan : add TQ3_1S and register TQ4_1S weight types - #259

Merged
TheTom merged 3 commits into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/tq-weight-types
Aug 4, 2026
Merged

vulkan : add TQ3_1S and register TQ4_1S weight types#259
TheTom merged 3 commits into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/tq-weight-types

Conversation

@Defilan

@Defilan Defilan commented Aug 3, 2026

Copy link
Copy Markdown

What

Brings the TurboQuant weight types up on the Vulkan backend. Two commits:

  1. Restores TQ4_1S wiring lost in the rebase. Simon Gardling already did this in vulkan: TQ4_1s support for model weights #69 (f03d331, 2026-04-20). That commit is not an ancestor of 2a716ac, so the current branch carries the shaders without the host wiring: dequant_tq4_1s.comp and mul_mat_vec_tq4_1s.comp are present but never compiled, because the Vulkan SPIR-V build is driven entirely by explicit string_to_spv() calls in vulkan-shaders-gen.cpp (there is no glob) and tq4_1s appears in neither type_names nor any generation loop. ggml-vulkan.cpp on the current tip has zero references to GGML_TYPE_TQ4_1S, so no pipeline is created. This is the same class of loss as 28c68fe ("reconstruct supports_op after rebase merge damage"), which re-applied the three turbo KV grafts but not the weight types. This commit adds no shader code.
  2. Adds TQ3_1S. New dequant_tq3_1s.comp and mul_mat_vec_tq3_1s.comp, plus the types.glsl / dequant_funcs.glsl entries and the same host wiring. Ported from the in-tree CUDA (ggml-cuda/mmvq-tq.cu, turbo-quant.cuh) and Metal (ggml-metal.metal) implementations against the C reference in ggml-turbo-quant.c.

Both are generated by explicit string_to_spv() calls rather than by adding the type names to type_names, because that loop would also emit three things that must not be used:

  • USE_SUBGROUP_ADD / _NO_SHMEM reduction variants. Both mat-vec shaders map one thread to one element of a 32-element block: they index a 32-entry shared array by gl_LocalInvocationID.x, pair lanes as (tid, tid + step) for the WHT butterfly, and select the half-block scale with (tid < 16 ? d0 : d1). All three are correct only at a 32-thread workgroup, and a subgroup reduction over a wave that is not exactly the workgroup folds in lanes from an unrelated block. RADV on gfx1151 reports warp size: 64, so this is not hypothetical there. The host side pins these pipelines to a 32-thread workgroup with a shared-memory reduction, independent of the device subgroup size.
  • mul_mat_vec_id_*, for which no host pipeline is created. MUL_MAT_ID stays rejected by the existing mul_mat_id_s/m/l guard in supports_op.
  • get_rows_* via get_rows_quant.comp, which applies no inverse RHT and whose get_dm() returns vec2(1, 0). GET_ROWS support is deliberately not claimed for either type; see the note below.

Also adds the TQ3_1S MUL_MAT sweeps to test-backend-ops.cpp, mirroring the TQ4_1S blocks (production dimensions plus the large-batch block that exercises the dequant + f16 matmul path), and the matching tolerance entry.

Why

Every TQ4_1S MUL_MAT on Vulkan currently falls back to CPU, and nothing says so. On gfx1151 test-backend-ops -o MUL_MAT reported 991/991 tests passed / OK while every TQ4_1S case was skipped rather than run, including the 258 Gemma-4-dimension cases added alongside the shaders, whose comments describe the fused mul_mat_vec kernel and pipeline_dequant[TQ4_1S] as though both were wired. A fully skipped op still reports success (#242), so the gap was invisible from the test suite.

The practical cost is in the llama-bench numbers below: prefill on a real TQ4_1S GGUF was running at 1.19 t/s because the matmuls were on the CPU.

TQ3_1S had no Vulkan kernels at all, which is what blocks DeepSeek-V4-Flash Config-I (expert-down, attention, indexer and compressor tensors are TQ3_1S) from running on a 128GB Strix Halo box.

Validation

All on gfx1151, Radeon 8060S Graphics (RADV STRIX_HALO), warp size: 64, matrix cores: KHR_coopmat.

Full test-backend-ops sweep, all ops:

24472/24472 tests passed
2/2 backends passed
  tq3_1s:  OK=149  FAIL=0  NOT_SUPPORTED=120
  tq4_1s:  OK=149  FAIL=0  NOT_SUPPORTED=120

Zero FAIL, error or mismatch lines anywhere in the sweep.

MUL_MAT progression, one arm per commit: 991/991 (before) to 1139/1139 (TQ4_1S wiring restored, +148, no new shader code) to 1287/1287 (TQ3_1S kernels, +148). The TQ4_1S delta is entirely host wiring, which is the cleanest evidence that the shaders themselves were fine and only the registration was missing.

llama-bench, thetom-ai/Nemotron-H-8B-Base-8K-Config-I-GGUF (...-tq4_1s.gguf, 4.82 GiB, 8.10B), -ngl 99 -p 512 -n 128 -r 2, same image base and same GPU on both arms:

before (d0e2a8b) after ratio
pp512 1.19 ± 0.00 t/s 461.71 ± 2.93 t/s 388x
tg128 1.11 ± 0.00 t/s 7.95 ± 0.02 t/s 7.2x

Worth being precise about what that measures: both arms report backend: Vulkan and load libggml-vulkan.so, so the before arm is on the GPU. Only the TQ4_1S matmuls fall back to CPU per-op via the scheduler. Prefill is matmul-dominated so nearly all of its work was on the CPU, hence 1.19 t/s; decode is bandwidth-bound with more non-matmul work already on the GPU, hence the smaller recovery. This is prefill moving from CPU-bound to GPU-resident, not a kernel getting 388x faster. llama.cpp also prints nemotron_h 31B.A3.5B unknown, may not work for this arch, unrelated to this change, so the ratio is the meaningful number rather than the absolute t/s.

Pre-hardware checks on the TQ3_1S port, since silent numeric corruption is the failure mode for this family: the 3-bit unpack was verified exhaustively against the reference bit-field unpack over all 2^24 possible 3-byte groups; a C transliteration of the dequant shader is bit-for-bit identical to dequantize_row_tq3_1s over 4096 random blocks; the mat-vec identity holds to 1.2e-8 relative against dequant-then-dot at K = 32/256/1536/2048/3072/4096; and the emitted SPIR-V confirms ArrayStride 16 matching sizeof(block_tq3_1s) (the drift class #204 had to fix for turbo4) with zero subgroup capabilities in the mat-vec module.

Not covered

  • The f16 activation pipelines are registered but unverified. test-backend-ops' CPU comparison backend cannot run type_b=f16 for these types, so 120 of 269 cases per type report not supported [CPU]. This is a pre-existing limitation that TQ4_1S shares; I did not want to imply those cases passed.
  • TQ3_1S is unbenchmarked. It is correctness-verified across 149 cases against the CPU reference, but the only TQ3_1S model I know of is the 95 GiB DeepSeek-V4-Flash Config-I, which I have not staged. All the llama-bench numbers above are TQ4_1S.
  • Only tested on one device. gfx1151 / RADV, which is wave64. The 32-thread pin is what makes that correct, but a wave32 device (NVIDIA, Intel) would be a useful second data point and I do not have one to hand.
  • copy_to_quant.comp / copy_from_quant.comp have TQ4_1S branches that are also orphaned today (no cpy shaders are generated for either TQ type). vulkan: TQ4_1s support for model weights #69 wired set_rows_tq4_1s and cpy_tq4_1s_f32 and this PR does not, so that part of the regression is still outstanding. I left it out because I have no SET_ROWS/CPY coverage for these types on hardware; happy to restore it here if you would rather it land in one go.

Note on get_rows

dequant_funcs.glsl's TQ branches return centroid * scale with no inverse RHT, and get_dm() returns vec2(1, 0), which the comment there already says. That is harmless on master precisely because nothing registers a get_rows pipeline for these types, so the path is unreachable. I have kept it that way: no pipeline_get_rows[TQ*], no GET_ROWS case in supports_op. Flagging it because it is a live trap for whoever wires get_rows for a TurboQuant type next, and the generic all_types GET_ROWS sweep would go green against a wrong implementation.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: YES. Claude wrote the shader and host-side code and ran the validation on my hardware, working from the in-tree CUDA/Metal/C references. I directed the work, chose the approach (clean-room from the in-tree references rather than reusing vulkan: add TurboQuant KV cache support and optimized turbo mat-vec paths #140), reviewed the diff, and independently re-verified the centroid and sign tables against ggml-turbo-quant.c before submitting. The commits carry Assisted-by: trailers. I am responsible for these changes.

@Defilan

Defilan commented Aug 3, 2026

Copy link
Copy Markdown
Author

Correction to the original description, prompted by the question about whether this was a rebase regression: it was, and I had it wrong.

TQ4_1S was already wired on Vulkan by @Titaniumtown in #69 (f03d331, 2026-04-20), including the mat-vec and dequant pipeline registrations, the supports_op cases, the dmmv_wg pin, and the same fixed-32-thread-workgroup reasoning I arrived at independently. #69 also wired set_rows_tq4_1s and cpy_tq4_1s_f32.

git merge-base --is-ancestor f03d3314 2a716ac4 is false, so that work is not in the current branch's ancestry. The result is the shaders present without their host wiring, which is the same class of loss as 28c68fe ("reconstruct supports_op after rebase merge damage") where the three turbo KV grafts were re-applied but the weight types were not.

My original wording said the wiring "was never created". That was wrong, and it came from searching history scoped to the branch rather than --all, so #69 did not show up. I have edited the description: commit 1 is now framed as restoring lost wiring rather than adding new, with credit to #69.

Two things follow from that:

  1. Commit 1 is effectively a re-landing of vulkan: TQ4_1s support for model weights #69's Vulkan host wiring, minus set_rows/cpy. If you would prefer, I can rebase vulkan: TQ4_1s support for model weights #69's original hunks in place of mine so the attribution sits in the git history rather than only in this thread.
  2. The set_rows_tq4_1s / cpy_tq4_1s_f32 half of the regression is still outstanding. I left it out because I have no SET_ROWS/CPY coverage for these types on hardware, but it should be restored, either here or separately.

The TQ3_1S half of the PR is unaffected: that has no prior art in this repo and the validation numbers stand as posted.

@TheTom

TheTom commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Reviewed, and validated on the device class you were missing. This is a well-made PR: the rebase-loss diagnosis is correct, the reasoning for bypassing the generic shader-gen loop is right, and pre-flagging the get_rows trap for the next person is exactly the kind of thing that saves someone a week later. One blocker, described below with a repro.

Verified

Your rebase-loss claim, independently confirmed. f03d3314 is not an ancestor of the current branch, the two TQ4_1S shaders sit in the tree orphaned, and ggml-vulkan.cpp on the tip has zero GGML_TYPE_TQ4_1S references. Same class as 28c68fe. Exactly as you described.

Shader math audited against the in-repo C, CUDA and Metal references — no discrepancies. Block layouts match byte-for-byte (16B tq3_1s, 20B tq4_1s, field order identical); all 8 TQ3 and 16 TQ4 centroids match to the digit across four implementations; the 32-entry sign table matches; the WHT butterfly index formula reproduces the C triple loop pair-for-pair; (tid<16 ? d0 : d1) matches the C, CUDA and Metal half-block convention. The "rotate the activation instead of the weight" identity in the mat-vec kernels is sound. Given #241 was precisely a GLSL-vs-C layout mismatch, I checked that one hardest, and it is clean.

The 32-thread pin is real and genuinely enforced. tq_wg_size = 32 passed as the local_size_x specialization constant, tq_use_subgroups = false, no USE_SUBGROUP_ADD in the macro set, so reduce_result() takes the shmem tree path regardless of device wave size. Not hand-waved.

wave32 data point — the one you asked for. NVIDIA GB10, RADV-free NVIDIA driver, warp size: 32:

test result
MUL_MAT tq3_1s 148/148 OK
MUL_MAT tq4_1s 148/148 OK
MUL_MAT (all types) 1287/1287
full test-backend-ops 24,472/24,472, zero failures

So the 32-thread pin holds on wave32 as well as your wave64, and nothing else in the backend regressed from the shader-gen changes.

Blocker: MUL_MAT_ID is not actually rejected, and aborts on the decode path

The PR states MUL_MAT_ID stays rejected by the existing mul_mat_id_s/m/l guard. That guard is a generic shared-memory heuristic that was never taught about these types: ggml_vk_matmul_shmem_support() has no lut_size case for TQ3_1S/TQ4_1S, so it computes a trivially-fitting size and leaves the flags true on every mainstream vendor. supports_op therefore returns true for MUL_MAT_ID.

What saved the suite from noticing is a coverage accident: the all_types MUL_MAT_ID sweep uses n=16, and ggml_vk_use_mul_mat_vec_id() selects the mat-vec path only when src2->ne[1] <= 8. At n=16 it routes to mul_mm_id, which works because your pipeline_dequant[TQ*] registration feeds the generic dequant-to-f16 path. So both TQ cases pass, and the full suite is green, while the decode-shaped path is never touched.

Add one decode-shaped case next to the existing one and it aborts immediately:

// tests/test-backend-ops.cpp, beside the existing n=16 line
test_cases.emplace_back(new test_mul_mat_id(type_a, GGML_TYPE_F32, 4, 2, false, 64, 1, 3*ggml_blck_size(type_a)));
MUL_MAT_ID(type_a=tq3_1s,...,n=16,k=96): OK
ggml-vulkan.cpp:10331: GGML_ASSERT(dmmv != nullptr) failed

Chain: supports_op true → ggml_vk_mul_mat_iduse_mul_mat_vec_id true at n<=8 → ggml_vk_get_dequantize_mul_mat_vec_id(), whose type switch has no TQ cases → nullptr → assert. This is MoE decode, i.e. the normal path for a TQ-quantized MoE model, and Metal already ships kernel_mul_mm_id_tq{3,4}_1s_f32, so it is a real configuration rather than a hypothetical.

Either fix works and both are small: an explicit case GGML_TYPE_TQ3_1S: case GGML_TYPE_TQ4_1S: return false; in the MUL_MAT_ID branch of supports_op, or wire mul_mat_vec_id_tq{3,4}_1s properly if you want MoE support now. If you take the rejection route, please add the decode-shaped test case above in the same commit so the gap cannot silently reopen.

Minor, not blocking

The new DATA_A_TQ3_1S block in dequant_funcs.glsl returns centroid*scale with no inverse RHT and get_dm() returning vec2(1,0). It is dead today (TQ types are out of type_names, so no mul_mm, get_rows or mul_mat_vec instantiation), and your file comment is honest about it. Worth noting it is the same trap you flagged for get_rows but one door over: it would also silently mis-serve the coopmat1 mul_mm.comp GEMM path, not only coopmat2, if someone later adds these names to type_names to "finish" the wiring. Your commit message says the exclusion is coopmat2-specific; it is actually total, which is safer than described, just worth correcting so the next reader does not assume coopmat1 GEMM already works.

Fix the MUL_MAT_ID rejection and I will re-run the full battery plus the decode case on wave32 and merge. Nice work on this one.

@Defilan

Defilan commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thanks for this, particularly the GB10 run. wave32 was the gap I flagged and could not close myself, and having the full sweep clean on a 32-wide device is the evidence this needed.

MUL_MAT_ID

You are right, and the mistake was mine rather than an oversight in the code. I checked that the mul_mat_id_s/m/l guard existed and asserted it rejected these types without checking that it does. It does not, for exactly the reason you give: it is a shared-memory heuristic and ggml_vk_matmul_shmem_support() has no lut_size case for TQ3_1S/TQ4_1S, so it computes a trivially-fitting size and leaves the flags true.

The coverage accident is the part I should have caught. n=16 routing to mul_mm_id while the mat-vec path only engages at src2->ne[1] <= 8 means the suite was green over a path it never touched, which is the same shape as the original problem in this PR: a passing result over cases that never ran.

Taken the rejection route, since I have no MoE model staged to validate a mul_mat_vec_id implementation against and would rather not ship one unexercised. Both your suggestions are in:

  • explicit GGML_TYPE_TQ3_1S / GGML_TYPE_TQ4_1S rejection in the MUL_MAT_ID branch of supports_op, above the shmem check, with the chain written out in a comment
  • your decode-shaped case added at n=1 beside the existing n=16 in the all_types sweep

On gfx1151 after the fix: MUL_MAT_ID 891/891, no aborts, and the full sweep at 24496/24496 (up 24 from 24472, one new case per all_types entry). To be clear about provenance, the failing repro is yours; I verified the after-state, not the before.

Attribution

Taking you up on the offer. A verbatim cherry-pick of #69 is not workable: it predates this branch by 2049 commits, conflicts in 6 of its 9 files, and its two shader files are already in the tree via 2a716ac, so it would try to re-add them. What I have done instead is re-author the first commit as a re-land of #69 with Co-authored-by: Simon Gardling, and the message now says plainly that this is not a verbatim pick and that the approach was Simon's first. If you would rather see it structured differently, say so and I will redo it.

Force-pushed for that reason only. The tree is byte-identical to what you reviewed, so nothing in your audit needs revisiting.

Unrelated CI failure

ubuntu (arm64, ubuntu-24.04-arm) is red on tests/test-turbo-quant.c, roughly twenty -Wdouble-promotion errors on printf calls passing float to a variadic. That file is untouched by this branch and the failure reproduces on feature/turboquant-kv-cache itself; it only surfaced here because the base branch runs Build Actions Cache and never the full matrix. ubuntu-arm64 passes on the same tree, so it is specific to that leg's warning configuration.

Happy to send a separate one-commit PR casting those to double, or leave it with you. It did not feel right to fix an unrelated file of yours inside this PR.

Still outstanding

#69's set_rows_tq4_1s / cpy_tq4_1s_f32 wiring is still missing, so that half of the regression remains. I left it out because I have no SET_ROWS/CPY coverage for these types on hardware. Say the word if you would rather it land here than separately.

TheTom added a commit that referenced this pull request Aug 4, 2026
The ubuntu arm64 leg builds with -Wdouble-promotion -Werror, where passing
float through a variadic is an error: 33 of them in this file. The other
legs do not enable it, and the base branch only runs Build Actions Cache,
so it stayed invisible until a PR triggered the full matrix (reported by
@Defilan in #259).

Cast the arguments explicitly; program output is byte-identical.
@TheTom

TheTom commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Fix verified on wave32, and your CI report was correct and is now fixed on the branch.

MUL_MAT_ID fix — confirmed on GB10 (warp size 32)

All four TQ cases now reject cleanly and fall back to CPU, including the decode shape that aborted before:

MUL_MAT_ID(type_a=tq3_1s,...,n=16,k=96): not supported [Vulkan0]
MUL_MAT_ID(type_a=tq3_1s,...,n=1,k=96):  not supported [Vulkan0]
MUL_MAT_ID(type_a=tq4_1s,...,n=16,k=96): not supported [Vulkan0]
MUL_MAT_ID(type_a=tq4_1s,...,n=1,k=96):  not supported [Vulkan0]
MUL_MAT_ID: 891/891

Full sweep: 24496/24496, zero failures — the same number you got on gfx1151, from a 32-wide device. Two architectures, two wave widths, identical counts.

I also confirmed the tree is byte-identical to what I reviewed apart from the two expected files, so the force-push needed no re-audit. The comment you left above the rejection is better than the fix itself; anyone who later wires mul_mat_vec_id_tq* will know exactly why the guard is there and when to remove it.

arm64 CI — mine, fixed

Reproduced on the base branch on an aarch64 box: 33 -Wdouble-promotion errors, and your read of why it stayed hidden is right (the base branch only runs Build Actions Cache, so the full matrix never ran against it). That is my file and my breakage, not something to carry in your PR. Fixed on feature/turboquant-kv-cache in ffc210bfb: explicit casts, program output byte-identical before and after. Rebase onto the branch tip and that leg should go green.

Two message-only items before I merge

  1. The Co-authored-by: Simon Gardling trailer is not in the tree. All three commits carry only Assisted-by: Claude, both locally and via the GitHub API. I suspect it did not survive the amend. Since crediting Simon's original was your point and I agree with it, worth getting right.
  2. Commit 1's message still says "MUL_MAT_ID stays rejected by the existing mul_mat_id_s/m/l guard" — the claim commit 3 disproves. Worth deleting or amending to point at commit 3, so the history does not assert something the same branch later fixes.

Neither touches code. Fix those and I will merge immediately; no further validation needed from my side.

set_rows / cpy

Leave it out, and thank you for asking rather than assuming. This PR is a clean, well-scoped unit and I would rather not widen it. Separate PR whenever you feel like it, and if you would rather hand it off, say so and I will take it — the gap is documented well enough in your description that someone else can pick it up.

Defilan and others added 3 commits August 3, 2026 19:40
Re-lands the Vulkan host wiring from ggml-org#69 (f03d331), which is not an ancestor of
the current branch. 2a716ac brought the two TQ4_1S shaders back by a different
route without the registration, so dequant_tq4_1s.comp and
mul_mat_vec_tq4_1s.comp sit in the tree and are never passed to glslc: the
Vulkan SPIR-V build is driven entirely by explicit string_to_spv() calls (there
is no glob) and tq4_1s appears in neither type_names nor any generation loop.
ggml-vulkan.cpp on the tip has zero GGML_TYPE_TQ4_1S references, so no pipeline
is created and every TQ4_1S MUL_MAT falls back to CPU. Same class of loss as
28c68fe, which re-applied the three turbo KV grafts but not the weight types.

Not a verbatim cherry-pick of ggml-org#69: it predates this branch by 2049 commits,
conflicts in 6 of its 9 files, and its shader files are already present. The
approach here is the one Simon reached first, including the fixed 32-thread
workgroup, and is credited accordingly.

Generated by explicit string_to_spv() calls rather than by adding tq4_1s to
type_names, because that loop would also emit USE_SUBGROUP_ADD reduction
variants (the shader indexes a 32-entry shared array by gl_LocalInvocationID.x
and pairs lanes as (tid, tid + step), so it is correct only at a 32-thread
workgroup; RADV on gfx1151 reports warp size 64), a mul_mat_vec_id with no host
pipeline, and a get_rows via get_rows_quant.comp which applies no inverse RHT.

Does not restore ggml-org#69's set_rows_tq4_1s / cpy_tq4_1s_f32 wiring; that half of the
regression is still outstanding.

Co-authored-by: Simon Gardling <titaniumtown@proton.me>
Assisted-by: Claude
TQ3_1S had no Vulkan shaders at all -- unlike TQ4_1S, whose shaders existed but
were never compiled (348dac47b). Add dequant_tq3_1s.comp and
mul_mat_vec_tq3_1s.comp, then register them the same way TQ4_1S was.

Derived clean-room from this repo's own CUDA (ggml-cuda/mmvq-tq.cu,
turbo-quant.cuh) and Metal (ggml-metal.metal kernel_mul_mv_tq3_1s_f32_impl)
implementations plus the C reference in ggml-turbo-quant.c.

Format: 16-byte block, d0 (elements 0-15) + d1 (16-31) + 12 bytes holding 32
three-bit indices as 4 groups of 8-in-3-bytes. The 8 Lloyd-Max centroids are
ASYMMETRIC, unlike TQ4's mirrored 16, which is the exact drift ggml-org#204 had to fix
for turbo4; the tables here were checked byte for byte against TQ3_0_CENTROIDS /
TQ3_0_SIGNS / TQ_INV_SQRT32 in ggml-turbo-quant.c, and against their CUDA and
Metal copies.

Indices are unpacked by reading each 3-byte group as a 24-bit little-endian word
and shifting, matching tq3_extract_index() in mmvq-tq.cu. This is exact rather
than incidental: the C packer lays index i at bits [3i, 3i+2] of that word,
including indices 2 and 5 which straddle a byte boundary. Verified exhaustively
against the reference bit-field unpack over all 2^24 possible groups.

The mat-vec kernel never dequantizes a weight. With H the 32x32 symmetric
Hadamard realised by the butterfly, S = diag(signs) and k = 1/sqrt(32), a stored
block c dequantizes to w = k*S*H*c, so

    <w, x> = k*<H*c, S*x> = k*<c, H^T*S*x> = k*<c, H*S*x>

and the rotation moves onto the activation: sign-flip and butterfly the
activation block once per workgroup in shared memory, then dot against raw
centroid*scale straight out of memory. That turns one WHT per (block, row) into
one per (block, column). The identity is written down nowhere in the repo, so it
is spelled out in the shader -- the absence of an inverse WHT in a mat-vec
kernel is otherwise indistinguishable from a bug.

Registration mirrors 348dac47b exactly: explicit string_to_spv() rather than
adding "tq3_1s" to type_names, because that loop would emit USE_SUBGROUP_ADD
variants (wrong on gfx1151's wave64 -- the shader maps one thread per element of
a 32-element block and is only correct at a 32-thread workgroup), an unused
mul_mat_vec_id, and a get_rows via get_rows_quant.comp that applies no inverse
WHT. Host side pins tq_wg_size = 32 with a SHMEM reduction. GET_ROWS support is
deliberately not claimed, and TQ3_1S is excluded from the coopmat2 paths (there
is no dequant_funcs_cm2.glsl entry; gfx1151 is coopmat1 only, and a stub
returning zeros would be worse than no support).

dequant_tq3_1s.comp deviates from dequant_tq4_1s.comp in one respect: its
butterfly is a flat 16-iteration loop per stage with a compile-time constant
stride, not the rolled triple loop. The rolled form has a non-constant inner
increment, so glslang cannot unroll it and buf[] stays a dynamically indexed
function-local array -- scratch, on a shader that runs over the entire weight
tensor during prefill. The flat form compiles to zero loops and no function-local
array, and produces bit-for-bit identical results (checked against
dequantize_row_tq3_1s over 4096 random blocks). dequant_tq4_1s.comp still has the
rolled form; fixing that belongs in a separate change.

tests: 258 TQ3_1S MUL_MAT cases mirroring the TQ4_1S sweeps -- 240 mat-vec cases
at Gemma-4 dimensions plus 18 large-batch cases that force the dequant + f16
matmul path, which is the only coverage the inverse WHT gets. With the 10 cases
the existing all_types sweeps already produced, that is 268 TQ3_1S MUL_MAT cases
total, matching TQ4_1S exactly. 120 of them use type_b=f16.

Not yet validated on hardware. Local verification was: all three shaders compile
under glslc -O and pass spirv-val; the emitted block stride is 16 bytes, matching
sizeof(block_tq3_1s); the mat-vec SPIR-V contains no subgroup capability at all;
and a C transliteration of both shaders reproduces the CPU reference bitwise. The
f16 activation pipeline is registered but cannot be checked by test-backend-ops,
whose CPU comparison backend does not run type_b=f16.

Assisted-by: Claude
I claimed in ggml-org#259 that MUL_MAT_ID stayed rejected by the existing
mul_mat_id_s/m/l guard. It does not. That guard is a shared-memory heuristic,
and ggml_vk_matmul_shmem_support() has no lut_size case for TQ3_1S/TQ4_1S, so it
computes a trivially-fitting size and leaves the flags true on mainstream
vendors. supports_op therefore returned true for MUL_MAT_ID on both types.

That was only survivable because of a coverage gap: the all_types MUL_MAT_ID
sweep used n=16, and ggml_vk_use_mul_mat_vec_id() selects the mat-vec path only
when src2->ne[1] <= 8. At n=16 it routes to mul_mm_id, which works because
pipeline_dequant[TQ*] feeds the generic dequant-to-f16 path, so the suite stayed
green while the decode path was never touched. At n<=8 it reaches
ggml_vk_get_dequantize_mul_mat_vec_id(), whose type switch has no TQ cases, and
asserts on a null pipeline. That is ordinary MoE decode for a TQ-quantized
model, not a hypothetical.

Reject both types explicitly in the MUL_MAT_ID branch until
mul_mat_vec_id_tq{3,4}_1s exists, and add n=1 alongside the existing n=16 in the
all_types MUL_MAT_ID sweep so the decode path is covered and the gap cannot
reopen silently.

Found by TheTom in review of ggml-org#259, including the repro.

Assisted-by: Claude
@Defilan
Defilan force-pushed the vulkan/tq-weight-types branch from ccb7ecc to 6373003 Compare August 4, 2026 02:41
@TheTom
TheTom merged commit 21eab71 into TheTom:feature/turboquant-kv-cache Aug 4, 2026
9 of 25 checks passed
@TheTom

TheTom commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Merged.

On reflection, holding this over two commit messages was the wrong call — you had already done the hard part twice over, and asking for another force-push round to move a trailer would have been me optimizing for tidiness at your expense. I carried both in the merge commit instead: Co-authored-by: Simon Gardling, a line saying this re-lands #69, and a note that commit 1's MUL_MAT_ID claim is superseded by commit 3. History is accurate and Simon is credited; nothing further needed from you.

Worth saying plainly what this PR did, because it is more than the diff: you found a rebase loss nobody had noticed, explained the wave64 subgroup hazard well enough that the pin is self-documenting for the next person, flagged the get_rows trap prospectively, and — after I found the MUL_MAT_ID gap — you diagnosed why it hid rather than just patching it. That reasoning is what got the decode-shaped test case written, which now protects every backend in this repo from the same class of hole. You also caught a real CI break in one of my files and handed it back rather than silently working around it.

Standing offer: if there is another corner of this fork you want to take on, say so and it is yours. set_rows / cpy whenever you feel like it, no timeline, and the hand-off offer stays open if you would rather someone else close that one out.

TheTom pushed a commit that referenced this pull request Aug 4, 2026
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 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
giveen added a commit to giveen/llama-cpp-turboquant that referenced this pull request Aug 4, 2026
- vulkan-shaders-gen: add set_rows and cpy_tq4_1s_f32 SPIR-V
  (kept outside type_names to avoid duplicate dequant symbols
  and unsafe wave-size mul_mat_vec variants, per PR TheTom#259)
- ggml-vulkan: register set_rows, cpy_quant_f32 pipelines;
  add get_cpy_pipeline and SET_ROWS dispatch;
  add to get_to_fp16

The rebase already had all shader code: dequant_tq4_1s.comp,
mul_mat_vec_tq4_1s.comp, TQ4_1S blocks in copy_to_quant.comp,
copy_from_quant.comp, dequant_funcs.glsl, and types.glsl.
Only the C++ pipeline wiring was missing.

Post-review: dropped \"tq4_1s\" from type_names (PR TheTom#259 already
covers dequant/mul_mat_vec via explicit string_to_spv calls),
removed duplicate pipeline_dequant registration.

Assisted-by: Buffy (Freebuff)
TheTom pushed a commit that referenced this pull request Aug 7, 2026
Emits matmul{,_id,_id_subgroup}_tq{3,4}_1s_{f32,f16} from mul_mm.comp plus the
type-independent tq_rotate_act pipeline. Still inert on the host side: no
pipelines are created and no dispatch path selects them yet.

Generated explicitly rather than by adding the types to type_names, matching
the mat-vec precedent in #259 -- that loop would also emit q8_1 mmq variants,
for which these types have no integer-dot path.

coopmat2 is excluded: TQ has no dequant_funcs_cm2.glsl entry, and the target
(gfx1151) exposes KHR_coopmat only.

LOAD_VEC_A is pinned to 8 rather than taken from load_vec_quant, because the
A-side block in mul_mm_funcs.glsl indexes idx/4 and idx&3 to map one invocation
onto exactly one 3-byte packing group of 8 contiguous elements. Any other value
would silently misindex.

One rotate pipeline serves both types: TQ3 and TQ4 share the same 32-element
sign pattern and butterfly, and the shader takes no DATA_A_* define because it
only touches the activation.

Note on shared memory: ggml_vk_matmul_shmem_support() has no lut_size case for
these types, and that is now correct rather than accidental -- the A-side block
uses a function-local const centroid array, not a shared-memory LUT, so its
true LUT cost is zero.

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