Skip to content

TurboQuant: head_dim as a multiple of 128, three fixes, test coverage, fused kernel - #2

Merged
Pascal-SAPUI5 merged 18 commits into
masterfrom
feat/turboquant-head-dim-multiples-of-128
Aug 10, 2026
Merged

Pascal-SAPUI5 merged 18 commits into
masterfrom
feat/turboquant-head-dim-multiples-of-128

Conversation

@Pascal-SAPUI5

Copy link
Copy Markdown
Owner

Started from wanting to run Qwen3.5-9B (head_dim 256) with TurboQuant, which was rejected outright. Grew from there.

head_dim support

TurboQuant required head_dim to be exactly 128, which excluded Qwen3.5. The restriction lived only in the guard — the whole encode/decode path already works in fixed 128-element chunks over a flat buffer and never looks at head boundaries. A 256-dim head is now quantized as two independently normalized halves; each half is again a unit vector in R^128, exactly the distribution the Lloyd-Max codebooks were fitted for, so they stay optimal unchanged.

Quality is better at 256 than at 128 (+0.009 vs +0.051 PPL for turbo3), because two half-norms track variance differences within a head more closely than one — at no extra cost, since the norm is replicated per block either way.

Three fixes

  • Out-of-bounds read. turbo_pre_dequantize walked the KV view linearly while scaling nb[3] proportionally, reading past the pool buffer for views that are not contiguously allocated. Invisible in practice only because the default n_parallel = 1 makes the view gap-free; -np 4 --no-kv-unified would have produced silent corruption. Removed the pre-pass entirely — tile/MMA/WMMA all request f16 and launch_fattn already converts correctly.
  • Missing inverse FWHT. ggml_get_to_{fp16,bf16,fp32}_nc_cuda mapped turbo to a decoder without the rotation or the 1/sqrt(128) factor. Unreachable before, live after removing the pre-pass, so it needed a real strided FWHT kernel.
  • Type-punned vector path. get_vec_dot_KQ mapped turbo to the f16 dot, reinterpreting 14-byte blocks as half2. Unreachable, but any dispatch slip would have produced silently wrong numbers rather than a crash. Removed along with the dead turbo vector instances; turbo is now excluded from can_use_vector_kernel, so a mistake is a link error.

Test coverage

The GPU side of TurboQuant had no automated test at all — three things blocked it simultaneously: ggml_quantize_chunk had no turbo case, init_tensor_uniform quantized with 32-element blocks (splitting the 128-element FWHT chunk), and the CPU had no turbo flash-attention entry, so the reference computation dereferenced a null vec_dot.

Now 640 cases per turbo type run through FLASH_ATTN_EXT, plus the write path. That matrix found four bugs during the kernel work that would otherwise have surfaced in production.

Fused kernel

Decodes turbo blocks inside flash attention instead of expanding the cache to f16 once per FA call. The naive form — decode each chunk as loaded — costs O(n_kv) transforms and measures exactly as fast as what it replaces. Working in the Hadamard domain reduces that to O(1):

V:  sum_j p_j H c_j = H (sum_j p_j c_j)    accumulate raw, transform once at the end
K:  <H c, q> = <c, H q>                    transform Q once at kernel start

tg128 at depth 65536, Qwen3.5-9B Q8_0, RX 9070 XT:

stage t/s of f16
bulk conversion 23.58 0.50x
V fused 26.62 0.57x
K and V fused 29.59 0.63x
f16 46.90 1.00x

0.63x matches what llama.cpp discussion #20969 reports for quantized KV at 110K context — quantized KV is slower than f16 at decode across methods and backends, so this is the ceiling rather than a shortfall.

An earlier fused vector kernel is kept but disabled (GGML_CUDA_TURBO_FUSED_FA=1): it loses on GQA amortization, reading K/V once per query head where the tile kernel reads once per four.

Quality over context depth

KVarN (arXiv 2606.03458) argues KV quantization error compounds during decoding, which would make a short-context PPL number an understatement. Measured at three depths, the gap does not widen:

context f16 turbo3 delta
2048 1.5258 1.5338 +0.0080
8192 1.4107 1.4172 +0.0065
16384 1.3431 1.3497 +0.0066

TurboQuant stores the per-chunk L2 norm in fp16 and never quantizes it, so the per-token magnitude error that KVarN describes cannot occur.

Note on history

This branch was built on a local master that predates this fork's current master; the two share the TurboQuant commits under different hashes. The diff should be clean, but the commit list contains two duplicates. A rebase onto pascal/master would remove them if a linear history is preferred.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q5jMcvdevae8j4T36C9h2z

@github-actions github-actions Bot added documentation Improvements or additions to documentation CUDA examples testing ggml labels Aug 10, 2026
Pascal-SAPUI5 and others added 18 commits August 10, 2026 20:38
TurboQuant rejected any model whose head_dim was not exactly 128, which
excluded Qwen3.5 (head_dim 256).

The restriction was only ever in the guard. The whole data path already
works in fixed 128-element chunks over a flat buffer and never looks at
head boundaries:

  ggml-quants.c:612    for (offset = 0; offset < k; offset += TURBO_HEAD_DIM)
  set-rows.cu:444      GGML_ASSERT(ne00 % TURBO_HEAD_DIM_SR == 0)
  convert.cu:731       num_chunks = k / TURBO_HEAD_DIM_GPU

A 256-dim head is therefore quantized as two independently normalized
halves. Each normalized half is again a unit vector in R^128 — exactly the
Beta((d-1)/2, (d-1)/2) distribution the Lloyd-Max codebooks were fitted
for — so the existing codebooks stay optimal and need no change. The norm
is already replicated per 32-element block, so the second half-norm costs
no extra bytes: bit rate stays at 3.5 / 4.5 bpw.

Chunk boundaries never straddle two heads because heads are contiguous in
the row and head_dim is a multiple of the chunk size.

Reconstruction quality is unchanged (10000 vectors, CPU reference):

  head_dim   turbo3 MSE*d   turbo4 MSE*d
  128        0.0341         0.0093
  256        0.0342         0.0094
  384        0.0341         0.0094

Tests parametrized over head_dim 128/256/384, plus a chunk-independence
test asserting that one 256-element pass packs byte-identically to two
separate 128-element passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
llama-bench on RX 9070 XT shows turbo lifting pp512 by ~53% over f16/f16
(3933 vs 2565 t/s) with token generation unchanged — the opposite of the
~6% seen at head_dim 128. Any turbo operand triggers it, so a different
flash-attention kernel is likely being selected; not verified.

KV cache at 131072 context measures 896 MiB with turbo3 across the 8
attention layers, against 4096 MiB computed for f16.

Perplexity is recorded for f16 only (1.5258). The turbo runs hit an
out-of-memory abort because another llama-server held the GPU; the table
marks them as not yet measured rather than leaving them blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reruns with the GPU idle. Quality loss at head_dim 256 is smaller than at
128: turbo4 +0.002 and turbo3 +0.009 PPL, against +0.010 and +0.051 for
Qwen3-14B. Two independently normalized halves per head track variance
differences more closely than a single norm, and cost nothing extra
because the norm is replicated per block either way.

KV cache at 131072 context: f16 4096 MiB (14.81 GB total VRAM), turbo4
1152 MiB (11.75 GB), turbo3 896 MiB (11.47 GB). f16 leaves almost no room
for the vision encoder on a 16 GB card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TurboQuant had no automated GPU test at all. Three things were missing:

1. ggml_quantize_chunk had no TURBO case, so the default branch asserted and
   test-backend-ops could not even allocate a turbo tensor. Added
   quantize_turbo3_0/quantize_turbo4_0 row wrappers, which assert
   n_per_row % 128 == 0 — a row shorter than one FWHT chunk would let a chunk
   straddle two rows.

2. init_tensor_uniform quantized with n_per_row = blck_size = 32, splitting the
   128-element chunk the FWHT operates on. Turbo now uses 128 there.

3. The CPU had no turbo flash-attention path: type_traits_cpu had no entry, so
   ggml_compute_forward_flash_attn_ext called a null vec_dot pointer and
   segfaulted while computing the reference. ggml_vec_dot_turbo3_0/4_0 already
   existed in ggml-quants.c and are correct (they do the inverse FWHT
   internally), they were just never registered. vec_dot_type is F32; no
   from_float, because quantize_row_turbo*_ref asserts on the chunk size that
   the generic per-block callers do not honor.

With turbo3/turbo4 added to the FLASH_ATTN_EXT matrix (head_dim a multiple of
128, not restricted to 64/72 like the other quantized KV types), all 24 cases
now fail with ERR ~1.0 and a GPU page fault:

  Memory access fault by GPU node-1 ... Reason: Page not present

That is the reproducible symptom of turbo_pre_dequantize reading past its pool
buffer on views that are not contiguously allocated — which is what the test
graphs build. Fix follows separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three related defects, all in the CUDA/HIP path.

B1 — turbo_pre_dequantize dequantized the whole KV view linearly from
src->data while rescaling nb[3] proportionally. For a view that is not
contiguously allocated (nb[3] > nb[2]*ne[2], which holds whenever
n_kv < kv_size) that reads past the pool buffer. Symptom in
test-backend-ops: 24/24 failures plus 'Memory access fault by GPU node-1'.
It stayed invisible in practice only because the default n_parallel = 1
makes n_stream = 1, and then the view happens to be gap-free.

Removed the function entirely rather than adding a guard. The tile, MMA and
WMMA kernels all pass need_f16_K/V = true to launch_fattn, which already
converts K/V correctly via ggml_get_to_fp16_cuda (contiguous) or
ggml_get_to_fp16_nc_cuda (strided). Dropping the pre-pass also removes the
temporary mutation of dst->src[1]/[2] and lets get_best_fattn_kernel see the
real tensor types instead of substituted f16 ones.

B2 — ggml_get_to_{fp16,bf16,fp32}_nc_cuda mapped turbo to
dequantize_block_cuda<..., dequantize_turbo3_0>, which only does
codebook[idx] * norm: no inverse FWHT, no 1/sqrt(128). Since removing
turbo_pre_dequantize makes that path live, it now needs to be right. Added
dequantize_block_turbo_nc_kernel, which computes the source block index from
the row/slice strides and otherwise decodes exactly like the bulk kernel.
The shared decode is factored into turbo_decode_chunk_smem so the contiguous
and strided paths cannot drift apart — they must stay bit-identical for the
fused kernel work to be verifiable against them.

B3 — the vector kernel does not convert its inputs (need_f16_K is
type_K == F16), so it must never receive turbo. get_vec_dot_KQ mapped turbo
to vec_dot_fattn_vec_KQ_f16, reinterpreting 14-byte blocks as half2, and
dequantize_V_turbo3_0 dropped the FWHT. Unreachable so far, but any dispatch
slip would have produced silently wrong numbers. Removed both, along with
the turbo vector template instances (which this build config compiled), and
excluded turbo from can_use_vector_kernel — every VEC return depends on it.
A dispatch mistake is now a link error.

Verified on RX 9070 XT (gfx1201):
  test-turboquant                              23/23
  test-backend-ops FLASH_ATTN_EXT type_KV=turbo3  640 ok, 0 fail
  test-backend-ops FLASH_ATTN_EXT type_KV=turbo4  640 ok, 0 fail
  test-backend-ops FLASH_ATTN_EXT permuted        ok
  test-backend-ops SET_ROWS                       ok
  llama-server -np 4 --no-kv-unified: four concurrent requests, all coherent

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L2 baseline after the non-contiguous fix. Perplexity is unchanged by that
rework (f16 1.5258, turbo4 1.5281, turbo3 1.5343 — identical to before),
confirming it was numerically neutral.

The depth sweep exposes what the aggregate numbers hid: token generation
degrades badly with context depth. At d65536 turbo3 does 31.5 t/s against
f16's 46.9, and turbo3/turbo4 together drop to 24.5. The penalty scales with
the number of converted caches, not which one is converted — the signature of
a per-cache bulk conversion that runs once per FA call and therefore costs
O(n_kv) per generated token. Prompt processing is unaffected (turbo is faster
there) because one conversion amortizes over the whole batch.

Groundwork for the fused kernel that removes this: turbo.cuh collects the
shared TurboQuant primitives, and convert.cu now uses them instead of its own
copies. New is turbo_decode_chunk_warp, which decodes a 128-element chunk in
one warp — 4 registers per lane, two register-local butterfly stages and five
cross-lane shuffles — with no shared memory and no block synchronization.

Stage order and operand order match turbo_fwht_smem_128 exactly, so results
must be bit-identical rather than merely close. A standalone HIP harness
confirms that for turbo3 and turbo4 across 1/2/7/64 chunks, with
reconstruction NMSE landing on the paper values (0.033-0.036 and
0.0086-0.0099). That equivalence is what makes the fused kernel verifiable
against the established path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decodes turbo blocks inside flash attention instead of expanding the whole KV
cache to f16 per FA call. Covers the decode path (ncols <= 2), head_dim 128
and 256; everything else keeps using tile/MMA with launch_fattn conversion.

The naive approach — decode each KV token — would need O(n_kv) inverse FWHTs
and lose to the bulk conversion it replaces. Two properties of the Hadamard
matrix (symmetric, orthogonal) reduce that to O(1). With x = norm * s * H c:

  K: <x_j, q> = norm_j * s * <c_j, H q>   -> transform Q once at kernel start
  V: sum p_j x_j = H(sum p_j norm_j s c_j) -> accumulate in the Hadamard
                                              domain, transform once at the end

Valid for V because online-softmax rescaling is a scalar and H is linear.
Verified against a double-precision reference attention over explicitly
decoded K/V: NMSE ~1e-14 across n_kv 1/4/37/256.

Written as a separate kernel rather than a branch in fattn-vec.cuh. The
accumulator has to be float — codebook values are small and the final
transform amplifies rounding by sqrt(128) — and VKQ's half2 type is wired
through eight shared code sites there. A dedicated kernel cannot regress
f16/bf16/q4_0/q8_0, and drops the type branches turbo never needs.

Lane l owns elements [4l, 4l+4) of a chunk, which is exactly the layout
turbo_fwht_warp_128 expects, so both transforms run in registers with no
shared memory.

Three defects found by the test matrix during bring-up, all now fixed:
mask indexing used the wrong stride and double-counted the loop offset;
FATTN_KQ_MAX_OFFSET was missing, so a fully masked tile produced NaN; and the
dispatch did not require K->ne[1] % FATTN_KQ_STRIDE == 0, so kv=113 read past
the end of the cache.

test-backend-ops FLASH_ATTN_EXT: turbo3 640/640, turbo4 640/640.
GGML_CUDA_TURBO_FUSED_FA_OFF=1 restores the previous path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A/B in the same build, Qwen3.5-9B Q8_0 on gfx1201, tg128:

  depth       fused    bulk     f16
      0        52.5    55.0    56.8
  16384        35.7    42.5    54.1
  65536        16.6    23.6    46.9

The kernel is correct — 640/640 on the FLASH_ATTN_EXT matrix for both turbo3
and turbo4, on either path — but it loses to the bulk conversion it was meant
to replace, by ~30% at depth 65536.

It trades memory bandwidth for per-token work: unpacking 3-bit indices across
byte boundaries plus four codebook lookups per lane per KV token per chunk. On
AMD a __constant__ array is an ordinary vector load rather than a broadcast
from a constant bank, so those lookups are real memory traffic. That likely
outweighs the bandwidth saved, but I have not verified it at ISA level.

Rather than tune on a hunch, the kernel is now opt-in via
GGML_CUDA_TURBO_FUSED_FA=1 and the measurement is written down. Two concrete
leads if this gets picked up again: hold the codebook lane-resident and read it
with __shfl_sync instead of from memory, and check whether the XOR-16 shuffle
stage lowers to ds_bpermute — it crosses the DPP16 row boundary on RDNA 4.

The default path is unchanged and still measures exactly as in the L2 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version reduced over all 32 lanes for every KV token — five shuffle
stages each, 160 per warp iteration. Splitting the two lane layouts fixes that:
the FWHT still needs 4 consecutive elements per lane, but the KQ dot does not.
Q is transformed in the 4-per-lane layout, then redistributed through shared
memory into 16 consecutive elements per lane, so 8 lanes cover one token, four
tokens are dotted at once and a reduction is 3 stages. At E=16 the turbo3 3-bit
indices also land on byte boundaries (16*3 = 6 bytes).

tg128, Qwen3.5-9B Q8_0, gfx1201:

  depth    KQ=32   KQ=8    bulk     f16
      0     52.5   53.2    55.0    56.8
  16384     35.7   39.2    42.5    54.1
  65536     16.6   20.4    23.6    46.9

23% better at depth 65536, still behind. The rest is structural, not a tuning
problem: for Q->ne[1] == 1 with gqa_opt_applies, f16 does not use the vector
kernel at all — it goes to the tile kernel, which handles all gqa_ratio query
heads per KV read. This kernel handles one head per block and reads K/V four
times as often on Qwen3.5. Per element and query head that is 0.4375 bytes
against tile+f16's 0.50, so the 4.57x compression is worth 12% of bandwidth,
which unpacking and codebook lookups more than spend.

Left disabled by default, with the analysis written down. The next step that
could actually win is a fused tile kernel (fattn-tile.cuh:663 already holds a
complete contiguous head_dim vector per V-tile row in shared memory), not
further micro-optimization here.

test-backend-ops FLASH_ATTN_EXT turbo3: 640/640 on both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preparation for fusing TurboQuant into the tile kernel. The existing decoders
assume either 128 threads (one per chunk element) or a full 32-lane warp with 4
registers each. The tile kernel has neither: at head_dim 256 on RDNA it runs
with 64 threads.

turbo_decode_chunk_smem_n strides over the chunk instead of fixing one element
per thread, so it works for any thread count that divides or exceeds 128. The
butterfly stages keep the same order and operand order as
turbo_fwht_smem_128, which the test confirms: 128-thread, 32-lane-warp and
64/32-thread-strided decode all produce bit-identical output for turbo3 and
turbo4 across 1/2/7/64 chunks.

That equivalence matters because the tile kernel's V path will decode into the
same shared-memory tile the f16 path fills, and any divergence between decoders
would show up as a silent accuracy difference rather than a test failure.

No behavior change: nothing calls the new function yet.
test-backend-ops FLASH_ATTN_EXT turbo3 still 640/640.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second attempt at avoiding the bulk KV conversion, this time in the tile kernel
rather than the vector kernel — the tile path is what f16 actually uses for
decode with GQA, and its V tile already holds complete contiguous head_dim rows.

type_V is threaded through flash_attn_tile -> _iter -> load_tile with a default
of F16, so existing instantiations are untouched; four turbo instances are added
for head_dim 128 and 256. flash_attn_tile_load_tile_turbo decodes a chunk per
warp straight into the tile via turbo_decode_chunk_warp — no scratch shared
memory, no extra barrier — and need_f16_V is set false so launch_fattn skips the
conversion.

Correct on the first try: test-backend-ops FLASH_ATTN_EXT 640/640 for turbo3 and
turbo4. But it does not help throughput, measured at depth 65536:

  f16/turbo3     31.46 (bulk)  ->  31.08 (fused V)
  turbo3/turbo3  23.58 (bulk)  ->  23.54 (fused V)

Traffic does drop from 2 to 0.4375 bytes per element. The kernel is not waiting
on that: the original tile load is a plain copy, fully pipelined across all 64
threads, while decoding walks chunk by chunk through an FWHT whose five shuffle
stages form a dependency chain. It is latency-bound, not bandwidth-bound.

Two independent attempts now agree — the fused vector kernel and this — that
TurboQuant decoding is too expensive to do inside attention. The bulk conversion
amortizes it across the whole cache in a way an attention kernel cannot.

Kept anyway: it removes the f16 staging buffer for V (~128 MB at 65536 context)
at no throughput cost, and it is the groundwork any future attempt would need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both attempts measured and written down: the fused vector kernel (20.4 t/s after
optimization, against 23.6 for bulk conversion) and the fused tile V path (23.54
against 23.58). Neither beats expanding the cache to f16 once per FA call.

The vector kernel loses on GQA amortization — it reads K/V once per query head
where the tile kernel reads once per four. The tile V path removes that
objection entirely and still gains nothing, which locates the cost in the decode
itself: an FWHT with five dependent shuffle stages per chunk, walked chunk by
chunk, against a plain fully-pipelined copy.

Documented so the next person does not spend the same two attempts finding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
KVarN (arXiv 2606.03458, llama.cpp #24139) argues that KV-cache quantization
error compounds during autoregressive decoding, which would make the +0.009
figure in this document — measured at context 2048 — an understatement.

Measured at 2048 and 8192 on Qwen3.5-9B Q8_0, the gap to f16 does not widen:

  context   f16      turbo4   delta     turbo3   delta
     2048   1.5258   1.5276   +0.0018   1.5338   +0.0080
     8192   1.4107   1.4124   +0.0017   1.4172   +0.0065

For turbo3 it narrows slightly, absolutely and relatively (0.52% -> 0.46%).

The explanation is in the format: the per-chunk L2 norm is stored in fp16 and
never quantized, so only the direction of the normalized vector is approximated.
KVarN attributes the accumulating error to incorrect per-token magnitude
scaling, which cannot happen here. The per-chunk normalization that makes
TurboQuant expensive at runtime is what makes it robust over long contexts.

The 16384 row is incomplete — f16 finished at 1.3431, the turbo runs hit the
job's time limit. Two depths are a thin basis for a trend; they are enough to
rule out the failure mode, not to characterize the curve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
turbo3 at context 16384 measures 1.3497 against f16's 1.3431, a delta of
+0.0066. With the earlier points that gives:

  ctx      f16      turbo3   delta     relative
   2048   1.5258   1.5338   +0.0080     0.52%
   8192   1.4107   1.4172   +0.0065     0.46%
  16384   1.3431   1.3497   +0.0066     0.49%

Flat after the first step, across an 8x range of context lengths. The previous
commit called two depths a thin basis for the claim; three across that range
make it solid. KVarN's accumulation failure mode does not apply to TurboQuant.

turbo4 at 16384 was not measured — the job was stopped — but its delta sits at
~0.0017 at both measured depths, where the question barely matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First version of the fused tile V path decoded each chunk as it was loaded,
which meant one inverse FWHT per KV token — the same O(n_kv) cost the bulk
conversion has, just relocated. It measured exactly as fast as what it replaced.

The two good ideas were split across two kernels: the vector kernel accumulated
in the Hadamard domain (O(1) transforms) but lost on GQA; the tile kernel had
GQA but decoded eagerly. This combines them.

The V tile is now filled with raw codebook values scaled by norm/sqrt(128), no
rotation. Because H is linear and every scaling along the way is a scalar,
sum_j p_j H c_j = H (sum_j p_j c_j): the accumulator stays in the Hadamard
domain and one inverse FWHT per 128-element chunk at the end of the kernel
brings it back. The transform is a dependency chain, the accumulation is not —
which is why moving it out of the inner loop is what matters.

The final transform goes through the KV_tmp scratch buffer rather than a third
register-layout FWHT variant: VKQ is interleaved (lane x holds elements 2x,
2x+1, 64+2x, ...), and it runs once per launch, so the shared-memory round trip
is irrelevant.

tg128 at depth 65536, Qwen3.5-9B Q8_0 on gfx1201:

  config           bulk    eager decode   Hadamard accumulation
  f16 / turbo3     31.46          31.08                   37.07   (+18%)
  turbo3 / turbo3  23.58          23.54                   26.62   (+13%)

First measured gain from fusing anything into attention. The remaining gap to
f16's 46.9 is the K side, which still goes through the bulk conversion.

test-backend-ops FLASH_ATTN_EXT: turbo3 640/640, turbo4 640/640.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for fusing the K path, the remaining half of the bulk conversion.
flash_attn_tile_load_tile_turbo_k fills a K tile with raw codebook values
scaled by norm/sqrt(128).

Unlike the V tile this loads a slice of each row — nbatch_K is 64 at the head
sizes turbo supports, half a chunk. That is fine because the KQ dot is
element-wise and never needs a whole chunk at once; it only requires Q to be in
the Hadamard domain first, since <H c, q> = <c, H q>. A slice never straddles a
chunk boundary, so the norm is one load per row.

Threads iterate over half2 pairs rather than single elements: having two threads
write the two halves of one slot would be a read-modify-write race.

Not called yet — type_K still has to be threaded through flash_attn_tile_iter_KQ
and the Q load has to apply the FWHT. Committed so the work is not lost;
behavior is unchanged and test-backend-ops FLASH_ATTN_EXT turbo3 is still
640/640.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
type_K is now threaded through flash_attn_tile / _iter / _iter_KQ, the K tile can
be filled with raw codebook values, and Q is transformed into the Hadamard domain
once after loading (in float via the KV_tmp scratch — Q_tmp may be half, and
rotating in half would cost accuracy).

Correct for small batches, wrong for large ones:

  nb=1    160/160 ok
  nb=3    160/160 ok
  nb=32   132/160 ok
  nb=75    24/160 ok      ERR ~0.042, not noise

The failures track the batch size, i.e. the configurations with many Q columns
per block, where cpw > 1 and nbatch_K varies (64, 128 and 256 all occur at these
head sizes). Something in the Q transform or the slice indexing does not hold
there; the per-element chunk derivation added for nbatch_K = 256 is the most
recent suspect and has not been isolated yet.

Left disabled rather than debugged further in this pass: the dispatch and the
instances select <F16, TURBO*>, so K goes through launch_fattn's conversion as
before and the V-side gain (+18% isolated, +13% combined) is kept. All 640 cases
green again for both turbo types.

The K-side code is retained because it is most of the work and the remaining bug
is bounded — it only shows up in a known subset of configurations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the fused tile path. K now uses the same Hadamard-domain trick as V:
Q is transformed once at kernel start and the KQ dot runs against raw codebook
values, since <H c, q> = <c, H q> and H is symmetric.

tg128 at depth 65536, Qwen3.5-9B Q8_0:

  bulk conversion   23.58   0.50x of f16
  V fused           26.62   0.57x
  K and V fused     29.59   0.63x
  f16               46.90   1.00x

+25% over the baseline. 0.63x is what llama.cpp discussion #20969 reports for
quantized KV at 110K context, so this is the expected ceiling rather than a
shortfall — quantized KV is slower than f16 at decode across methods and
backends.

Two bugs, both surfaced by the test matrix:

The K tile loader assumed a slice narrower than one chunk. That holds for
nbatch_K = 64 but not for 128 or 256, which the config table also selects at
these head sizes; the chunk and its norm are now derived per element.

Staging all of Q for the transform needed ncols*DKQ floats in KV_tmp, which is
dimensioned by nbatch_fa*nbatch_K — and those shrink as ncols grows. At ncols=64
the buffer was short by 1.8x, which is why nb=1 passed and nb=75 failed. Q is now
transformed one chunk at a time, needing 128 floats regardless of ncols.

test-backend-ops FLASH_ATTN_EXT: turbo3 640/640, turbo4 640/640.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Pascal-SAPUI5
Pascal-SAPUI5 force-pushed the feat/turboquant-head-dim-multiples-of-128 branch from c0e80c1 to 7e40072 Compare August 10, 2026 18:43
@Pascal-SAPUI5
Pascal-SAPUI5 merged commit c9c7ee8 into master Aug 10, 2026
16 of 46 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CUDA documentation Improvements or additions to documentation examples ggml testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant