Skip to content

cuda: TurboQuant TQ4_1S decode optimisations - #338

Closed
jasstrong wants to merge 13 commits into
TheTom:feature/turboquant-kv-cachefrom
jasstrong:pr/tq4_1s-decode
Closed

cuda: TurboQuant TQ4_1S decode optimisations#338
jasstrong wants to merge 13 commits into
TheTom:feature/turboquant-kv-cachefrom
jasstrong:pr/tq4_1s-decode

Conversation

@jasstrong

Copy link
Copy Markdown

Depends on #336 ([1/2] cuda: native TQ4_1S MMQ prefill on CDNA) and is branched on it, so its commit shows here until it lands.

That dependency is easily severed if #336 is difficult to merge. The coupling is only two symbols, both used by the v_perm_b32 patch: kvalues_tq4 (a 4-line GGML_TABLE in ggml-common.h) and get_int_from_table_16 (reached via the #include "mmq.cuh" that #336 adds to mmvq-tq.cu). Moving those two hunks into this PR makes it stand alone on upstream. Happy to do that on request — this series is decode work and has nothing else to do with MMQ prefill.

Overview

Nine patches on the TurboQuant decode path. They are independent of each other and each is separately measured, so they can be taken in whole or in part:

# patch effect
1 enable the int8 dp4a decode path on CDNA 94.3 → 95.4 t/s
2 fuse the TQ4_1S MoE down-proj with the expert-weighted sum ~+1%
3 per-graph-eval shared-quantize cache for mmvq ~60% of quantize launches were duplicates
4 generalize the MoE expert-reduce to all weight types weight-type agnostic tail
5 cache the TQ activation pre-rotation across MoE projections ~neutral, structural
6 decode TQ4 centroids with v_perm_b32 instead of shift arithmetic +14%
7 fuse the residual ADD into the quantized matvec epilogue ~80 fewer dispatches/token at 40 layers
8 lanes-per-row mapping for the TQ4_1S MoE decode matvec +3.1%
9 elementwise chain fusion (midi-kernel) +2.3%

Patch 1 is arguably a plain bug fix: the int8 dp4a decode path was gated behind !GGML_CUDA_CC_IS_AMD, which excluded CDNA despite gfx90a having the dot4 instructions.

Patch 6 is the significant one. TurboQuant decode was ALU-bound rather than bandwidth-bound — the MoE matvec sustained only ~24% of MI210 peak bandwidth while dense Q8_0 matvecs on the same card reached ~72%, because the nibble-to-centroid expansion burned roughly 100 ALU ops per call. Replacing it with a hardware byte-permute removes that. Note it must use __builtin_amdgcn_perm; HIP's __byte_perm with a runtime selector lowers via PromoteAlloca into a 32KB LDS staging area and comes out about 3x slower.

Patches 3, 4, 7 and 9 are not TurboQuant-specific and help any quantized type.

Additional information

MI210 (gfx90a), ROCm 7.2.3, Qwen3.6-35B-A3B-TQ4max, measured against this PR's own base (#336) with GGML_TQ_MMQ unset on both, so this is exactly the delta these nine patches add:

decode (tg128) prefill (pp512)
this PR 121.73 ± 0.19 1170.24 ± 20.03
base (#336) 95.08 ± 0.15 970.99 ± 20.24

+28% decode, and +21% on the non-MMQ prefill path as a side effect of the fusion work (with GGML_TQ_MMQ=1 prefill is unchanged at ~2120, since MMQ handles it).

Patch 9 was also checked on the other two AMD architectures and ports cleanly: +1.8% on a 7900XTX (gfx1100) and +1.4% on a V620 (gfx1030). Patch 8's GGML_TQ_LPR was swept 2→32 on both and is flat within ±0.2, so its wave64-tuned default of 16 is safe on wave32.

Verification

Patches 2, 4, 6 and 8 all live in the MoE decode matvec, which is why #334 and #335 exist: on AMD the MUL_MAT_ID suite aborted before reaching any TurboQuant case, so that path had no exercised coverage at all. With those two in place it is 979/979 including cases at real model shapes (256 experts, 8 used). I would rather land those first than have this reviewed on the strength of llama-bench numbers alone.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: YES — Claude Code (Opus 5) was used throughout this series: profiling and diagnosis, the kernel and fusion work, commit messages, and all the benchmark and perplexity verification across the MI210, 7900XTX and V620. I reviewed the changes and am responsible for every line of them.

jas and others added 10 commits August 31, 2026 21:59
TQ4_1S weights previously had no MMQ path, so prefill dequantised them to
f16 and went through hipBLAS. That threw away the format's whole point:
the 5bpw footprint was paid for at load time and then not used at compute
time.

Add a TQ4_1S tile loader, MMQ type traits, and dispatch, and pre-rotate
the activation so the block-local turbo WHT cancels against the weights.
The weights stay in their rotated-domain int8 centroid form, so the stock
MFMA-i8 MMQ kernel consumes them directly. The type reuses the Q3_K
shared-memory layout, since its per-16 scales have the same shape, and so
takes the same tile geometry Q3_K and IQ2_XS use on CDNA (occupancy 1,
I=128); other geometries do not match what that layout expects and write
out of bounds.

Measured on an MI210 with Qwen3.6-35B-A3B-TQ4max, against this same
branch with the path disabled:

  prefill  971 -> 2124 t/s  (2.19x)
  decode    95 ->   93 t/s  (-2.6%, this is a prefill-only path)
  ppl     3.2653 -> 3.2653  (identical)

It also keeps roughly 13GB less VRAM than converting the experts to Q8_0.
All 147 tq4_1s MUL_MAT cases pass.

The path is env-gated behind GGML_TQ_MMQ so it can be A/B'd against the
hipBLAS fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
The MoE decode dispatch gated the int8 dp4a path behind
!GGML_CUDA_CC_IS_AMD, which excluded CDNA even though gfx90a has the
dot4 instructions. Enable it there, keeping RDNA on the scalar path.

Decode 94.3 -> 95.4 t/s on an MI210 with Qwen3.6-35B-A3B-TQ4max.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
At decode the MoE tail ran the down-projection, then a separate MUL by
the expert weights, then a reduction over the expert slots. Fold all
three into the matvec so each expert's contribution is scaled and
accumulated in registers.

Two things were needed to make it fire in practice: the pattern match has
to follow the graph shape the allocator actually produces, and the
weights and ids have to be staged in pool scratch so the fusion applies
on every layer instead of only some.

About +1% decode on an MI210.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
Several matvecs in each decode layer consume the same normed activation,
and each was quantizing it to q8_1 independently: roughly 60% of the
quantize launches per token were duplicates. Cache the most recent
quantization, keyed on source pointer, size, type and graph epoch, and
reuse it within a graph evaluation.

The cache buffer is returned to the pool when the backend context is
destroyed, so it never outlives the pool it was taken from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
The fused expert-reduce tail replaces MUL(expert weights) -> PERMUTE ->
CONT -> SUM_ROWS after a MUL_MAT_ID. It reads the f32 MUL_MAT_ID output
directly, so nothing in it is specific to TQ weights: make it
weight-type agnostic and use it as the default tail everywhere.

It accumulates into pool scratch and copies to the graph tensor last,
which resolves the allocator aliasing hazards without having to veto the
fusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxP6x5bmUDYFvmouceN2mR
The MoE gate and up projections consume the same normed activation, so the
forward-WHT + q8_1 rotation ran once per projection. Cache it per graph
eval (same key as the mmvq shared-quantize cache: tensor identity, data
pointer, size, epoch; main stream only) and return the cached buffer.

Removes ~40 kernel dispatches per token on a 40-layer MoE. At the measured
~4.4us cost of a small dispatch on MI210 (1.3us hardware floor plus
unhidable cold-miss latency for a kernel too small to fill the GPU) that is
worth about 0.18ms/token. GGML_TQ_ROTCACHE=0 disables.
…thmetic

tq4_cents8_reg expanded 8 nibble indices to 8 int8 centroids with ~100 ALU
ops of shifts and selects, about 400 per 32-weight block. That made the TQ
decode kernels ALU-bound rather than bandwidth-bound: the MoE matvec was
sustaining only ~24% of MI210 peak bandwidth while the dense Q8_0 matvecs
on the same card reached ~72%.

Use get_int_from_table_16 (__builtin_amdgcn_perm, the IQ4_NL/MXFP4 path) to
do the lookup in hardware: about 14 ops instead of 100.

The shift fallback existed because HIP __byte_perm() with a runtime selector
lowers through a dynamically indexed byte union that PromoteAlloca turns
into a 32KB LDS staging area. __builtin_amdgcn_perm has no such problem and
maps 1:1 to the instruction - this is the same idiom already validated in
the TQ4_1S MMQ tile loader.
Every transformer layer ends its attention and FFN blocks with
MUL_MAT -> ADD(residual), which ran as two kernels. The mmvq fused path
already indexes x_bias exactly like dst (per row and per column), so a
full same-shape tensor works there, not just a broadcast bias - it was
simply never wired up for a bare MUL_MAT -> ADD.

About 80 dispatches per token on a 40-layer model. Decode only
(should_fuse_mul_mat_vec_q requires ncols_dst == 1) and placed after the
gate/up/GLU patterns so it cannot preempt them. GGML_CUDA_FUSE_RESIDUAL=0
disables.
One output row was spread over all 32 lanes, so each lane covered only
blocks_per_row/32 blocks and then paid a full 5-round warp reduction. The
reduction (5 dependent ds_bpermute, ~200 cycles) cost more than the work
(~150 cycles), which is why every attempt to add parallelism made things
worse: split-K was monotonically bad (115 -> 92 t/s at KS=8), 64-lane rows
lost 7%, and extra per-lane ILP was a wash.

Give each row 16 lanes instead: 4 reduction rounds, twice the work per
lane, and the warp still reads only two contiguous weight regions (going
below 16 scatters reads across more rows and loses more to coalescing than
it saves - 8 and 4 both measured worse). Never assign more lanes than there
are blocks, so no lane idles on narrow projections; previously the down
projection (16 blocks) left half the warp idle.

Paired A/B, alternating, 3 pairs: +3.1%, +2.6%, +3.0%. Output is
byte-identical to the old mapping under greedy decode.
GGML_TQ_LPR=N overrides.
Collapse a run of consecutive same-shape elementwise ops into one kernel
that carries the value in a register across the whole run, instead of one
dispatch and one HBM round trip per op. Hybrid MoE graphs are full of these:
SIGMOID->MUL->ADD->ADD, ADD->SOFTPLUS->MUL, CLAMP->DIV, SILU->MUL, MUL->SCALE.

Supports ADD/MUL/DIV/SCALE/CLAMP and the common unary ops, chains up to 8
long, with same-shape or single-value-broadcast operands.

Aliasing: same-shape operands are read at index i and dst is written at
index i after those reads, so an in-place output (which the allocator
produces constantly for residual adds) is safe and must not be vetoed -
using the generic memory-range guard here rejected almost every candidate.
Only a broadcast operand, read at index 0 by every thread, needs the check.

1622 -> 1552 kernels/token on Qwen3.6-35B-A3B; +2.3% decode (paired A/B:
+3.5/+2.0/+1.6). Output byte-identical. GGML_CUDA_FUSE_CHAIN=0 disables.
…and MUSA

tq4_1s_qs_to_int8x4() called the AMD-only byte-permute builtin unconditionally,
so the CUDA and MUSA CI builds failed to compile mmq-load-tiles.cuh. Keep
v_perm_b32 on HIP and use the equivalent __byte_perm selector elsewhere. The
TQ4_1S MMQ path itself is still gated to AMD at dispatch time; this only makes
the header compile on the other toolchains.
… and MUSA

tq4_cents8_reg() is on the dp4a decode path that NVIDIA also compiles, so the
unguarded AMD builtin broke the CUDA and MUSA builds. Keep v_perm_b32 on HIP
and use the equivalent __byte_perm selectors (0x5140 / 0x7362) elsewhere.
@TheTom

TheTom commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Pushed two commits here: a merge of the #336 nvcc/MUSA guard, plus 949de16 which guards the two __builtin_amdgcn_perm calls in tq4_cents8_reg(). That one is on the dp4a decode path NVIDIA compiles, so it was an independent build break. Selectors are __byte_perm(v.x, v.y, 0x5140) and 0x7362, same pattern vecdotq.cuh already uses for the even/odd re-interleave.

Review notes, in priority order. Patches 1, 5, 6, 8 (the TQ-specific ones) look sound and are the ones test-backend-ops actually exercises. The generic ones need work before they go in as default-on for every weight type:

  1. Patch 7, ggml-cuda.cu:3681. res = add->src[0] == node ? add->src[1] : ... returns node itself when the ADD is x + x. The fusion check counts two uses by the same ADD as fine, so x_bias would then point at the matmul output that never gets materialised. Needs a res != node guard.

  2. Patch 9, chain.cu. Only broadcast operands are range-checked against out. A same-shape operand that partially overlaps out at a nonzero offset gets through. Patch 7 uses ggml_cuda_check_fusion_memory_ranges for this; the chain should too. Also TQ_CHAIN_GELU uses 0.79788456f * (v + 0.044715f*v*v*v) and SOFTPLUS uses log1pf(expf(v)), which round differently from ggml's SQRT_2_OVER_PI * x * (1 + GELU_COEF_A*x*x) and logf(1 + expf(x)). Every f32 model on CUDA and HIP picks that up by default. Please use ggml's forms so the fused and unfused paths agree bit for bit.

  3. Patch 4. The fused tail sums expert slots sequentially in one thread where the old SUM_ROWS did a tree reduction. Previously TQ4_1S-only, now every MoE type. Small, but it is a default-on numerics change; worth a line in the description and a ppl number on a non-TQ MoE.

  4. Patch 3. q8_cache.ptr is grow-only pool memory that gets freed and re-alloced on grow. With more than one captured CUDA graph per context (--n-cpu-moe splits do this), a sibling graph that was not re-captured can replay against the old pointer. The cache is also not keyed on curr_stream_no the way patch 5's tq_rot_cache is.

  5. Patch 8. use_dp4a is true on NVIDIA, so the lanes-per-row default moves 32 to 16 there too. All the numbers in the PR are MI210. Either gate the change to AMD or post a 5090 / GB10 decode number.

  6. Coverage. Patches 4, 7 and 9 are multi-node fusions and test-backend-ops builds single-op graphs, so none of them run under CI at all. Patch 3's cache can never hit in a one-matmul graph either.

Suggestion: split this into a TQ-only PR (patches 1, 5, 6, 8) and a generic-fusions PR (3, 4, 7, 9). I'll take the TQ half as soon as #336 lands and CI is green on it. The generic half needs the fixes above plus some kind of multi-op test before it goes default-on.

Two nits: ggml_cuda_mul_mat_id_tq has a duplicated "16 measured best on CDNA" comment block, and mmvq.cu calls getenv/atoi without including <cstdlib>.

@jasstrong

Copy link
Copy Markdown
Author

Thanks for the thorough pass, and for the two guard commits. Agreed on all six points and on the split. I'll open a TQ-only PR with patches 1, 5, 6 and 8 (with the lanes-per-row default gated to AMD, the duplicated comment removed and the <cstdlib> include added), and a separate generic-fusions PR with 2, 3, 4, 7 and 9 carrying the fixes you listed: the res != node guard, the memory-range check plus ggml's GELU and softplus forms in the chain kernel, the stream-keyed and graph-safe quantize cache, the note and a non-TQ MoE perplexity number for the reduce tail, and multi-op test-backend-ops cases so the fusions actually run under CI. The static const bool for GGML_TQ_MMQ I'll put on #337 as a follow-up commit. Once both PRs are up I'll close this one and link them here.

On a personal note: I had surgery this week. It went well, but it will be a while before I can type properly, which is why this series landed in your queue all at once. I wanted what I had been working on in front of you in case things had not gone so well. Replies from me may be slow and terse for a bit.

@jasstrong

Copy link
Copy Markdown
Author

Split as discussed:

Closing this one in favour of the two above.

@jasstrong jasstrong closed this Sep 3, 2026
TheTom added a commit that referenced this pull request Sep 3, 2026
cuda: TurboQuant TQ4_1S decode optimisations (TQ-only half of #338)
@TheTom

TheTom commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Just saw the note about the surgery. Glad it went well, and I hope the recovery is quick. No rush on any of these, health first. The series was in good shape when it landed, so nothing here needs you typing before you're ready. Slow and terse is completely fine.

TheTom added a commit that referenced this pull request Sep 3, 2026
cuda: shared-quantize cache, residual and elementwise-chain fusions (generic half of #338)
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