Skip to content

vulkan : add rotated mul_mm_id for the TurboQuant weight types - #266

Merged
TheTom merged 8 commits into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/tq-rotated-mm-id
Aug 7, 2026
Merged

vulkan : add rotated mul_mm_id for the TurboQuant weight types#266
TheTom merged 8 commits into
TheTom:feature/turboquant-kv-cachefrom
Defilan:vulkan/tq-rotated-mm-id

Conversation

@Defilan

@Defilan Defilan commented Aug 4, 2026

Copy link
Copy Markdown

Follow-up to #264. That PR added mul_mat_vec_id so MoE decode runs on the GPU for the TurboQuant weight types, but left prompt processing on the f16 dequant path — and gated out any model whose expert tensor could not be staged as f16. This adds a real TQ mul_mm_id, which removes the staging entirely.

The approach

The A side is loaded as centroid * scale with no inverse WHT, which is only correct against a pre-rotated activation. That is the identity

<S.H.y.k, x>  ==  k.<y, H.S.x>

with H the symmetric 32x32 Hadamard, S = diag(TQ_SIGNS) and k = 1/sqrt(32): rotate the activation once per tile instead of un-rotating every weight block. dequant_funcs.glsl already produced exactly this form, so the A-side loads in mul_mm_funcs.glsl are the only new dequant code.

LOAD_VEC_A is pinned to 8 for these types, which lines up so one invocation covers one 3-byte packing group = 8 contiguous elements in k. k is applied in the rotate shader because the A-side loads omit it — mul_mat_vec_tq3_1s.comp does the opposite and folds it into the weight. Exactly one side must.

Rotating scratch, not src1

Metal's rotated mul_mm_id rotates src1 in place, matmuls, then un-rotates, which needs a hazard flag (ggml_metal_op_mutates_tq_src1) and barriers on both sides. Its rotate also 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.

This rotates ctx->prealloc_y — the staging copy — instead, and never touches the graph's tensor. Staging is forced for these types even when src1 is already contiguous f32, because otherwise y_f32_kernel is true, qy_needs_dequant is false, and d_Y aliases the real src1. The staging pass has already made the copy contiguous, so the stride mismatch cannot arise. Staged in f32: the butterfly is five rounds of adds and f16 would lose precision the quantization did not.

The prealloc_y reuse cache is keyed on the rotate pipeline rather than the copy pipeline, so a later non-rotated consumer of the same src1 cannot reuse rotated data.

The size gate from #264

It now applies only where the f16 dequant fallback is actually taken — when the TQ pipelines do not exist on the device (they are not created for coopmat2) or src0 is not dim01-contiguous. Those are the two conditions tq_rotate is gated on, so the two must agree; if they diverge the fallback hits GGML_ABORT("Requested preallocation size is too large") again.

ggml_vk_get_mul_mat_mat_id_pipeline() ends in GGML_ASSERT(support_fp32acc), so it now returns nullptr for TQ when both acc variants are empty rather than aborting on a coopmat2 device.

Validation — gfx1151, RADV, wave64

test-backend-ops -o MUL_MAT_ID: 967/967, 2/2 backends on the rebased head. Cases at n=9/17/32 exercise the rotated mul_mm_id; n=1/4/8 still take mul_mat_vec_id.

Before any host code, the identity was checked directly: the GLSL (tid & step)==0 pairing is bit-identical to the (j, j+step) loop nest of tq3_0_rht_forward(), and <x, rht_inverse(c)> == <rotate(x), c> over 20000 random blocks gives median relative error 1.9e-16, max 1.2e-12.

Batched perplexity, Qwen3.6-35B-A3B-ConfigI (431 TQ3_1S tensors, 256 experts), -c 512 --chunks 2, three arms:

path chunk[1] final PPL s/pass
experts on CPU 4.5768 6.5360 ± 0.76461 129.82
f16 dequant mul_mm_id 4.5271 6.5269 ± 0.76390 2.90
rotated mul_mm_id 4.5769 6.5663 ± 0.76962 1.45

Measuring the middle arm matters: against experts-on-CPU this looks like 89x, but 44.8x of that is what #264 already delivers. This PR's own contribution is 2.0x over the dequant path, plus removing the staging buffer.

DeepSeek-V4-Flash Config-I, -c 512 --chunks 2 -ub 64:

chunk[1] final PPL s/pass
experts on CPU 10.4794 13.3367 ± 1.86891 316.80
experts on GPU 8.9692 12.9997 ± 1.79589 23.68

Its ffn_{gate,up,down}_exps are 2048 x 4096 x 256, whose f16 staging would be exactly 4,294,967,296 bytes against a 4,294,967,295 limit — excluded by one byte under #264's gate. Nothing is staged now.

Two things I would rather state than have found

The perplexity improves, which is also what a subtle bug looks like. The reason is that TQ3_1S has .vec_dot_type = GGML_TYPE_Q8_0, so the CPU fallback quantizes the activation to q8_0 before every dot; the rotated path keeps f32. The three DeepSeek runs order monotonically by how much intermediate quantization each avoids (10.4794 CPU q8_0, 9.7819 mat-vec q8_0, 8.9692 rotated f32), and on Qwen — where the comparison is GPU-versus-GPU — chunk[1] moves 4.5768 to 4.5769.

The rotated path is marginally less accurate than the dequant path (+0.46% versus -0.14% against the CPU control). Both are far inside the band. This is consistent with buf_a holding centroid*scale in f16 on the fp16 path: the rotated domain has a different dynamic range than true weights.

Not included

mul_mm for plain MUL_MAT is untouched — it still uses the dequant path, which works and is validated. Only mul_mm_id is wired here. coopmat2 is excluded throughout: no dequant_funcs_cm2.glsl entry, and gfx1151 is KHR_coopmat only, so I cannot test it.

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

Defilan added 8 commits August 4, 2026 14:41
…on rotate)

Groundwork for a real TQ mul_mm_id, so MoE prompt processing stops staging the
entire expert tensor as f16. Not yet wired: no generation entries and no host
pipelines, so this is inert until those land (tq3_1s/tq4_1s are not in
type_names, so no matmul variant defines DATA_A_TQ*_1S today).

mul_mm_funcs.glsl gains TQ3_1S and TQ4_1S A-side loads that produce
centroid*scale and deliberately do NOT apply the inverse WHT. That is only
correct against a pre-rotated activation, via
<S.H.y.k, x> == k.<y, H.S.x>. LOAD_VEC_A is 8 for these types, which lines up
exactly: one invocation covers one 3-byte packing group = 8 contiguous elements
in k, so buf_idx uses the LOAD_VEC_A/2 contiguous form rather than Q4_0's
split-nibble form.

tq_rotate_act.comp applies signs -> butterfly -> 1/sqrt(32) to the activation.
k lives here because the A-side loads omit it; mul_mat_vec_tq3_1s.comp folds k
into the weight instead. Exactly one side must apply it.

Deliberately NOT in place, unlike Metal. Metal rotates src1 in place, matmuls,
then un-rotates, needing ggml_metal_op_mutates_tq_src1 plus barriers -- and its
rotate indexes src1 flat while the matmul reads with nb11/nb12/nb13, so a
strided or padded src1 rotates different elements than the matmul consumes.
Writing to scratch costs n_tokens*k*4 bytes and removes that class entirely.

Verified before any host code:
- glslc + spirv-val clean for mul_mm and mul_mm_id on both types; rotate shader
  VALID at LocalSize 32 with zero subgroup capabilities, so the wave64 pin that
  makes the butterfly correct still holds.
- The GLSL (tid & step)==0 pairing is bit-identical to the (j, j+step) loop nest
  of tq3_0_rht_forward() in ggml-turbo-quant.c (0.0 diff over random inputs).
- <x, rht_inverse(c)> == <rotate(x), c> over 20000 random blocks: median
  relative error 1.9e-16, p99 1.1e-14, max 1.2e-12 (float64 rounding over 32
  terms). Centroid tables copied from dequant_funcs.glsl, TQ3 asymmetric.

Assisted-by: Claude
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 ggml-org#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
Declares and creates pipeline_tq_rotate_act. Still inert: no dispatch path
selects it, and the TQ matmul pipelines are not registered yet.

Landing this alone is deliberate. Registering TQ in
ggml_vk_get_mul_mat_mat_id_pipeline() before the rotate orchestration exists
would run the rotated matmul against an UNROTATED activation and return
silently wrong numbers, so the getter cases and the orchestration have to land
together. This commit only proves the shader builds and the pipeline is
creatable.

Workgroup fixed at 32 for the same reason as the TQ mat-vec pipelines: the
butterfly pairs lanes through a 32-entry shared array, so it is correct only
when the workgroup equals the block size. gfx1151 reports warp size 64.

Assisted-by: Claude
The TQ generation block passed LOAD_VEC_B = load_vec, which is 8 on the fp16
path, but its float_type_dict only carried FLOAT_TYPE/V2/V4. load_b_to_shmem()
references FLOAT_TYPEV8 whenever LOAD_VEC_B is 8, so every fp16 TQ matmul
variant failed to compile with "'FLOAT_TYPEV8' : undeclared identifier" at
mul_mm_funcs.glsl:638. The dict now matches the one the type_names loop builds.

Caught by a real build, not by the local glslc pre-check: that check passed
hand-specified defines and so could not reproduce the generator's dict. Running
the actual vulkan-shaders-gen locally does reproduce it and takes about a
minute, which is the right loop for this class of error.

Assisted-by: Claude
…buffer

Simplification of the rotate design. The hazard being avoided is mutating
src1, a graph tensor -- not in-place work as such. ctx->prealloc_y is
context-owned scratch, so rotating THAT in place is safe and needs no extra
allocation: each workgroup reads, transforms and writes back exactly its own 32
contiguous elements, with no cross-workgroup dependency.

Two consequences the host side must honour, or this is silently wrong:

  1. Staging must be FORCED for these types. If src1 is already contiguous and
     the right dtype, d_Y would otherwise alias the real tensor and the rotate
     would mutate the graph's activation -- exactly Metal's problem.
  2. The staged copy must be f32, and the f32 B variant of the matmul selected
     to match. The butterfly is 5 rounds of adds; doing it in f16 would lose
     precision the quantization did not.

Rotating a packed scratch copy also sidesteps the stride mismatch in Metal's
version, where the rotate indexes src1 flat while the matmul reads it with
nb11/nb12/nb13: the staging pass has already made the copy contiguous.

Shader drops to a single read-write binding and 3 push constants
(k, nrows, stride); the pipeline creation is updated to match. Verified by
running the real vulkan-shaders-gen: exits clean, tq_rotate_act symbols emitted.

Assisted-by: Claude
Creates pipeline_dequant_mul_mat_mat_id[TQ3_1S/TQ4_1S] at all five non-coopmat2
sites (coopmat1, and the scalar subgroup / non-subgroup variants). Still inert:
ggml_vk_get_mul_mat_mat_id_pipeline()'s type switch does not include these
types, so nothing selects them yet.

That split is deliberate. Adding the getter cases before the rotate
orchestration exists would run the rotated matmul against an UNROTATED
activation and return silently wrong numbers, so the getter and the
orchestration land together in the next commit.

coopmat2 is skipped: TQ has no dequant_funcs_cm2.glsl entry, and gfx1151 is
KHR_coopmat only.

Note for whoever adds the getter cases: ggml_vk_get_mul_mat_mat_id_pipeline()
ends in GGML_ASSERT(support_fp32acc), so a type listed in its switch whose
pipelines were NOT created on the running device ABORTS rather than falling
back. Because coopmat2 is skipped here, the getter must explicitly return
nullptr for TQ when both acc variants are empty, letting it degrade to the f16
dequant path instead of asserting on a coopmat2 device.

Assisted-by: Claude
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 ggml-org#264.

Assisted-by: Claude
…llback

The gate rejected MUL_MAT_ID for TQ weights whose f16 staging buffer would
exceed maxStorageBufferRange. That premise no longer holds everywhere: the
rotated mul_mm_id path reads the quantized weights directly through
mul_mm_funcs.glsl and never materialises the expert tensor as f16, so there is
no staging buffer to overflow.

The gate now applies only where the fallback is actually taken -- when the TQ
matmul pipelines do not exist on this device (they are not created for
coopmat2) or src0 is not dim01-contiguous. Those are exactly the two conditions
ggml_vk_mul_mat_id_q_f16() gates tq_rotate on, so the two stay in agreement; if
they ever diverge the fallback would hit
GGML_ABORT("Requested preallocation size is too large") again.

Still independent of src2->ne[1], for the same reason as before:
weight_buft_supported() probes with a fixed ids->ne[1] = 512, so an n-dependent
answer parks the experts in one backend's buffer and runs the op in the other.

Effect: DeepSeek-V4-Flash Config-I becomes eligible. Its
ffn_{gate,up,down}_exps are 2048 x 4096 x 256, whose f16 staging would have been
exactly 4,294,967,296 bytes against a 4,294,967,295 limit -- excluded by one
byte. Nothing is staged now, so the limit is not consulted.

Assisted-by: Claude
@TheTom

TheTom commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Verified on wave32 and merging. This is the other half of #264 and it closes the gap that PR deliberately left.

GB10, NVIDIA, warp size 32, head 160649054:

check result
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

Same totals as #264 left the tree at, so the new mul_mm_id path and the new tq_rotate_act.comp shader introduce no collateral anywhere else in the backend.

The gate change is the part I looked at hardest, because it is the piece most likely to be wrong in a subtle way. Narrowing the f16 staging limit so it applies only to the dequant fallback rather than to MUL_MAT_ID as a whole is correct: the limit exists because the fallback stages the entire expert tensor as f16, and a path that never stages should not inherit a restriction that only makes sense for staging. Keeping it size-based rather than src2->ne[1]-based also preserves the load-versus-decode consistency you argued for in #264, which is the reasoning I would most likely have got wrong on my own.

Worth noting what you have built over three PRs: #259 wired the weight types, #264 put MoE decode on the GPU, and this puts prefill there too and removes the model-size ceiling that #264 needed. That is a coherent arc, each step verified on real models rather than synthetic shapes alone, and each one landing with the gap it does not yet close stated plainly rather than papered over. The stating-the-remaining-gap habit is why these have been quick to review.

Merging.

@TheTom
TheTom merged commit 1371e37 into TheTom:feature/turboquant-kv-cache Aug 7, 2026
9 of 25 checks passed
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