Skip to content

Commit 1af419b

Browse files
committed
tq4_sdpa: address PR review feedback
- gemma4_31b cuda_source_transformations: pass mask_is_causal=True to tq4_sdpa (the 12th arg was missing). Enables the prefill per-tile causal block-skip (~halves causal-triangle work); no-op for decode. - tq4_sdpa kernel + tests: drop model-specific (Gemma/Qwen) references; describe configs by shape (head_dim / GQA ratio) instead. - tests: add split-K coverage for B=2 (test_splitk_batch2) and a non-contiguous query stride (test_splitk_noncontiguous_query).
1 parent 59838cc commit 1af419b

3 files changed

Lines changed: 117 additions & 52 deletions

File tree

backends/cuda/tests/test_tq4_sdpa.py

Lines changed: 106 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def test_gqa_prefill(self):
252252
self._run_test(1, H_q, H_kv, 64, 64, 128, is_causal=True)
253253

254254
def test_gqa_8x_head_dim_256(self):
255-
"""GQA 8:1 with head_dim=256 — matches Qwen 3.5 MoE config."""
255+
"""GQA 8:1 with head_dim=256."""
256256
self._run_test(1, 16, 2, 1, 128, 256)
257257
L = 64
258258
mask = torch.tril(torch.ones(1, 1, L, L, dtype=torch.bool, device="cuda"))
@@ -374,8 +374,8 @@ def test_float_mask_rejected(self):
374374
float_mask,
375375
)
376376

377-
def test_qwen35_moe_config(self):
378-
"""Qwen 3.5 MoE: head_dim=256, GQA 16:2, decode + prefill."""
377+
def test_config_hd256_gqa_16_2(self):
378+
"""head_dim=256, GQA 16:2, decode + prefill."""
379379
self._run_test(1, 16, 2, 1, 256, 256)
380380
self._run_test(1, 16, 2, 128, 128, 256, is_causal=True)
381381

@@ -443,9 +443,8 @@ def test_output_shape_and_dtype(self):
443443
# Every test above calls tq4_sdpa WITHOUT kv_len and WITHOUT
444444
# mask_is_causal, so they only exercise the kv_len=None fallback
445445
# (full-Lk loop) at short KV. The cases below drive the actual
446-
# long-context paths used in production by the Gemma-4 31B global
447-
# layers (head_dim=512, GQA 8:4) and Qwen 3.5 MoE (head_dim=256,
448-
# GQA 16:2):
446+
# long-context paths at two representative GQA shapes (head_dim=512
447+
# GQA 8:4, and head_dim=256 GQA 16:2):
449448
# * the on-device kv_len scalar that bounds the KV loop to the
450449
# filled context (decode), and
451450
# * the mask_is_causal per-tile causal block-skip (prefill).
@@ -466,7 +465,7 @@ def test_output_shape_and_dtype(self):
466465
# of a kv_len-long context) there are two ways to place the causal
467466
# triangle. PyTorch F.sdpa(is_causal=True) uses TOP-LEFT alignment
468467
# (query row i attends to keys [0, i]) -- wrong for a KV cache. This
469-
# kernel and gemma4_31b/model.py::_build_masks use BOTTOM-RIGHT
468+
# kernel (and a KV-cache decoder's mask builder) use BOTTOM-RIGHT
470469
# alignment: query row i is absolute position (kv_len - Lq + i) and
471470
# attends to keys [0, kv_len - Lq + i]. So the reference below builds
472471
# an explicit bottom-right mask (q_pos >= cache_pos) rather than
@@ -498,12 +497,12 @@ def _run_long_kv_test(
498497
causal mask) must confine attention to ``[0, kv_len)``.
499498
500499
``causal=True`` builds a bottom-right-aligned mask (the Lq queries
501-
are the last Lq positions of a kv_len-long context), mirroring the
502-
production ``q_pos >= cache_pos`` mask in gemma4_31b/model.py
503-
``_build_masks`` and the kernel's ``(kv_len - Lq) + seq_pos`` block
504-
bound. We deliberately do NOT use ``F.sdpa(is_causal=True)`` for the
505-
reference: PyTorch aligns is_causal top-left when L_q < L_kv, while
506-
this kernel (and the model) align bottom-right.
500+
are the last Lq positions of a kv_len-long context), mirroring a
501+
KV-cache decoder's ``q_pos >= cache_pos`` mask and the kernel's
502+
``(kv_len - Lq) + seq_pos`` block bound. We deliberately do NOT use
503+
``F.sdpa(is_causal=True)`` for the reference: PyTorch aligns
504+
is_causal top-left when L_q < L_kv, while this kernel (and such a
505+
decoder) align bottom-right.
507506
"""
508507
torch.manual_seed(seed)
509508
centroids, boundaries, rotation = _make_codebook_and_rotation(D)
@@ -592,6 +591,7 @@ def _run_splitk_vs_fused_test(
592591
Lq,
593592
kv_len,
594593
buffer_len,
594+
B=1,
595595
seed=42,
596596
):
597597
"""Verify split-K output matches fused kernel output for same inputs.
@@ -606,7 +606,6 @@ def _run_splitk_vs_fused_test(
606606
boundaries = boundaries.cuda()
607607
rotation = rotation.cuda()
608608

609-
B = 1
610609
k = torch.randn(B, H_kv, buffer_len, D, dtype=torch.bfloat16, device="cuda")
611610
v = torch.randn(B, H_kv, buffer_len, D, dtype=torch.bfloat16, device="cuda")
612611
# Add garbage tail to ensure split-K respects kv_len bound
@@ -671,22 +670,85 @@ def _run_splitk_vs_fused_test(
671670
cos,
672671
0.99,
673672
f"Split-K vs Fused cosine {cos:.5f} < 0.99 "
674-
f"(H_q={H_q} H_kv={H_kv} D={D} kv_len={kv_len})",
673+
f"(B={B} H_q={H_q} H_kv={H_kv} D={D} kv_len={kv_len})",
674+
)
675+
676+
def test_splitk_batch2(self):
677+
"""Split-K decode (Lq=1) with batch size B=2.
678+
679+
Exercises the per-batch indexing in the split-K and reduce kernels
680+
(b = pid_bh // H_grid). Split-K output must match the fused-kernel
681+
path for the same inputs."""
682+
self._run_splitk_vs_fused_test(
683+
H_q=16, H_kv=2, D=256, Lq=1, kv_len=512, buffer_len=1024, B=2
684+
)
685+
686+
def test_splitk_noncontiguous_query(self):
687+
"""Split-K decode (Lq=1, B=2) with a non-contiguous query.
688+
689+
The host wrapper rotates Q (Q @ Pi^T) before launching the kernel,
690+
so a strided query must yield the same result as its contiguous
691+
copy. Builds a query whose last-dim stride is 2 by slicing a padded
692+
buffer, then checks it matches the contiguous query."""
693+
H_q, H_kv, D, kv_len, B = 16, 2, 256, 512, 2
694+
torch.manual_seed(42)
695+
centroids, boundaries, rotation = _make_codebook_and_rotation(D)
696+
centroids = centroids.cuda()
697+
boundaries = boundaries.cuda()
698+
rotation = rotation.cuda()
699+
700+
k = torch.randn(B, H_kv, kv_len, D, dtype=torch.bfloat16, device="cuda")
701+
v = torch.randn(B, H_kv, kv_len, D, dtype=torch.bfloat16, device="cuda")
702+
k_packed, k_norms = _compress(k, boundaries, rotation)
703+
v_packed, v_norms = _compress(v, boundaries, rotation)
704+
705+
q = torch.randn(B, H_q, 1, D, dtype=torch.bfloat16, device="cuda")
706+
# Non-contiguous alias with identical values (last-dim stride 2).
707+
q_pad = torch.empty(B, H_q, 1, D, 2, dtype=torch.bfloat16, device="cuda")
708+
q_pad[..., 0] = q
709+
q_nc = q_pad[..., 0]
710+
self.assertFalse(q_nc.is_contiguous(), "query should be non-contiguous")
711+
712+
kv_len_t = torch.tensor([kv_len], dtype=torch.int32, device="cuda")
713+
714+
def _run(query):
715+
return self.tq4_sdpa(
716+
query,
717+
k_packed,
718+
k_norms,
719+
v_packed,
720+
v_norms,
721+
centroids,
722+
rotation,
723+
attn_mask=None,
724+
is_causal=False,
725+
scale=None,
726+
kv_len=kv_len_t,
727+
mask_is_causal=False,
728+
)
729+
730+
out_contig = _run(q)
731+
out_nc = _run(q_nc)
732+
733+
self.assertFalse(torch.isnan(out_nc).any(), "NaN in non-contiguous output")
734+
cos = _cosine_sim(out_nc, out_contig)
735+
self.assertGreater(
736+
cos, 0.999, f"non-contiguous vs contiguous query cosine {cos:.5f}"
675737
)
676738

677-
def test_kv_len_clamp_decode_gemma_global(self):
678-
"""Decode (Lq=1) kv_len clamp at Gemma-4 31B global-layer shape
679-
(head_dim=512, GQA 8:4). N=8192 leaves a 24k garbage tail in a 32k
680-
buffer (clamp guard); N=32768 fills the buffer (full 32k loop)."""
739+
def test_kv_len_clamp_decode_hd512_gqa_8_4(self):
740+
"""Decode (Lq=1) kv_len clamp at a head_dim=512, GQA 8:4 shape.
741+
N=8192 leaves a 24k garbage tail in a 32k buffer (clamp guard);
742+
N=32768 fills the buffer (full 32k loop)."""
681743
for N in (8192, 32768):
682744
with self.subTest(N=N):
683745
self._run_long_kv_test(
684746
H_q=8, H_kv=4, D=512, Lq=1, kv_len=N, buffer_len=32768
685747
)
686748

687-
def test_kv_len_clamp_decode_gemma_global_splitk(self):
688-
"""Split-K decode (Lq=1) at Gemma global shape with long KV.
689-
Verifies split-K output matches BOTH (a) fp32 reference over first
749+
def test_kv_len_clamp_decode_hd512_gqa_8_4_splitk(self):
750+
"""Split-K decode (Lq=1) at a head_dim=512, GQA 8:4 shape with long
751+
KV. Verifies split-K output matches BOTH (a) fp32 reference over first
690752
kv_len positions AND (b) existing fused-kernel output (byte-identical
691753
within fp tolerance). Uses garbage tail as negative control."""
692754
for N in (8192, 32768):
@@ -707,18 +769,18 @@ def test_kv_len_clamp_decode_gemma_global_splitk(self):
707769
H_q=8, H_kv=4, D=512, Lq=1, kv_len=N, buffer_len=32768
708770
)
709771

710-
def test_kv_len_clamp_decode_qwen(self):
711-
"""Decode (Lq=1) kv_len clamp at Qwen 3.5 MoE shape
712-
(head_dim=256, GQA 16:2)."""
772+
def test_kv_len_clamp_decode_hd256_gqa_16_2(self):
773+
"""Decode (Lq=1) kv_len clamp at a head_dim=256, GQA 16:2 shape."""
713774
for N in (8192, 32768):
714775
with self.subTest(N=N):
715776
self._run_long_kv_test(
716777
H_q=16, H_kv=2, D=256, Lq=1, kv_len=N, buffer_len=32768
717778
)
718779

719-
def test_kv_len_clamp_decode_qwen_splitk(self):
720-
"""Split-K decode (Lq=1) at Qwen shape with long KV.
721-
Verifies split-K output matches BOTH fp32 reference AND fused kernel."""
780+
def test_kv_len_clamp_decode_hd256_gqa_16_2_splitk(self):
781+
"""Split-K decode (Lq=1) at a head_dim=256, GQA 16:2 shape with long
782+
KV. Verifies split-K output matches BOTH fp32 reference AND fused
783+
kernel."""
722784
for N in (8192, 32768):
723785
with self.subTest(N=N):
724786
_ = self._run_long_kv_test(
@@ -734,12 +796,12 @@ def test_kv_len_clamp_decode_qwen_splitk(self):
734796
H_q=16, H_kv=2, D=256, Lq=1, kv_len=N, buffer_len=32768
735797
)
736798

737-
def test_mask_is_causal_prefill_gemma_global(self):
738-
"""Chunked prefill (Lq>1) with mask_is_causal at Gemma global shape.
739-
The Lq queries are the last Lq of a kv_len-long context; the
740-
per-tile causal block-skip plus bottom-right mask must match the
741-
fp32 causal reference over the first kv_len positions. A garbage
742-
tail beyond kv_len also exercises the clamp."""
799+
def test_mask_is_causal_prefill_hd512_gqa_8_4(self):
800+
"""Chunked prefill (Lq>1) with mask_is_causal at a head_dim=512,
801+
GQA 8:4 shape. The Lq queries are the last Lq of a kv_len-long
802+
context; the per-tile causal block-skip plus bottom-right mask must
803+
match the fp32 causal reference over the first kv_len positions. A
804+
garbage tail beyond kv_len also exercises the clamp."""
743805
for Lq, kv_len, buf in ((256, 4096, 8192), (2048, 8192, 16384)):
744806
with self.subTest(Lq=Lq, kv_len=kv_len):
745807
self._run_long_kv_test(
@@ -752,8 +814,9 @@ def test_mask_is_causal_prefill_gemma_global(self):
752814
causal=True,
753815
)
754816

755-
def test_mask_is_causal_prefill_qwen(self):
756-
"""Chunked prefill (Lq>1) with mask_is_causal at Qwen shape."""
817+
def test_mask_is_causal_prefill_hd256_gqa_16_2(self):
818+
"""Chunked prefill (Lq>1) with mask_is_causal at a head_dim=256,
819+
GQA 16:2 shape."""
757820
for Lq, kv_len, buf in ((256, 4096, 8192), (2048, 8192, 16384)):
758821
with self.subTest(Lq=Lq, kv_len=kv_len):
759822
self._run_long_kv_test(
@@ -766,11 +829,11 @@ def test_mask_is_causal_prefill_qwen(self):
766829
causal=True,
767830
)
768831

769-
def test_kv_len_none_fallback_qwen(self):
832+
def test_kv_len_none_fallback_hd256_gqa_16_2(self):
770833
"""Regression: the kv_len=None fallback (HAS_KV_LEN False, full-Lk
771-
loop) that the Qwen path relies on still matches the fp32 reference.
772-
This guards the original behavior the kv_len feature must preserve
773-
for callers that pass neither kv_len nor mask_is_causal."""
834+
loop) still matches the fp32 reference. This guards the original
835+
behavior the kv_len feature must preserve for callers that pass
836+
neither kv_len nor mask_is_causal."""
774837
self._run_long_kv_test(
775838
H_q=16,
776839
H_kv=2,
@@ -787,9 +850,9 @@ def test_kv_len_none_fallback_qwen(self):
787850
"128k case is heavy for the 24GB CI runner; set TQ4_RUN_128K=1 to run",
788851
)
789852
def test_kv_len_clamp_128k(self):
790-
"""Full 131072-entry buffer (Qwen shape). (a) kv_len=8192 with a
791-
~123k garbage tail — the clamp keeps decode O(context) and never
792-
touches the tail; (b) kv_len=131072 — correctness at true 128k
853+
"""Full 131072-entry buffer (head_dim=256, GQA 16:2). (a) kv_len=8192
854+
with a ~123k garbage tail — the clamp keeps decode O(context) and
855+
never touches the tail; (b) kv_len=131072 — correctness at true 128k
793856
scale. Gated behind TQ4_RUN_128K because the fp32 reference for (b)
794857
needs >~6GB and CI runs on a 24GB A10G."""
795858
self._run_long_kv_test(

backends/cuda/triton/kernels/tq4_sdpa.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ def _tq4_sdpa_fwd_kernel_body(
175175
# chunk it is chunk_end. This makes the global-layer attention O(context)
176176
# rather than O(max_seq_len) — the empty tail of the cache is never touched.
177177
# kv_len is read from a GPU scalar so the bound updates across CUDA-graph
178-
# replays (decode is graph-captured). When not provided (HAS_KV_LEN False,
179-
# e.g. qwen) it falls back to Lk, preserving the original behavior exactly.
178+
# replays (decode is graph-captured). When not provided (HAS_KV_LEN False)
179+
# it falls back to Lk, preserving the original behavior exactly.
180180
if HAS_KV_LEN:
181181
kv_len = tl.load(KV_LEN_ptr)
182182
else:
@@ -191,8 +191,8 @@ def _tq4_sdpa_fwd_kernel_body(
191191
# prefill analogue of the kv_len decode clamp and ~halves the causal-triangle
192192
# work. For decode (Lq=1, max(seq_pos)=0) this evaluates to kv_len, so decode
193193
# is byte-identical. Applied only when MASK_IS_CAUSAL (the caller guarantees a
194-
# causal mask, e.g. Gemma's full-attention layers); otherwise the full kv_len
195-
# bound is kept, which is safe for an arbitrary mask.
194+
# causal mask); otherwise the full kv_len bound is kept, which is safe for an
195+
# arbitrary mask.
196196
loop_end = kv_len
197197
if MASK_IS_CAUSAL:
198198
max_q_pos = (kv_len - Lq) + tl.max(seq_pos)
@@ -714,8 +714,9 @@ def tq4_sdpa(
714714
is_causal: apply causal masking (requires L_Q == L_KV)
715715
scale: softmax scale applied to ``Q @ K^T``. Defaults to
716716
``1/sqrt(HEAD_DIM)`` when ``None``. Models that handle their
717-
own normalization (e.g. Gemma 4 with QK-norm uses ``1.0``)
718-
should pass an explicit value.
717+
own normalization (e.g. QK-norm models that fold the
718+
``1/sqrt(d)`` factor into the trained weights, which then use
719+
``1.0``) should pass an explicit value.
719720
kv_len: Optional GPU int scalar = number of valid (filled) KV
720721
positions. When provided, the inner KV loop is bounded to
721722
``kv_len`` instead of the full pre-allocated ``L_KV``, making
@@ -876,7 +877,7 @@ def tq4_sdpa(
876877
# Split-K decode kernel (flash-decoding) for TQ4
877878
# ==============================================================================
878879
# When L_q == 1 with GQA, the standard kernel launches only
879-
# ceil(num_groups / BLOCK_M) * B * H_kv CTAs (e.g. 4 for gemma global 8:4).
880+
# ceil(num_groups / BLOCK_M) * B * H_kv CTAs (e.g. 4 for a B=1, 8:4 GQA shape).
880881
# Split-K partitions the KV sequence across many CTAs for better occupancy,
881882
# then reduces partial results in a second kernel.
882883

@@ -1201,7 +1202,7 @@ def _launch_tq4_decode_splitk(
12011202
H_grid = H_KV
12021203
# Size BLOCK_M to the actually-packed rows (L_q * num_groups; for decode
12031204
# L_q=1 so == num_groups), rounded up to the bf16 tensor-core MMA minimum
1204-
# of 16 -- instead of a fixed 64. Gemma4 global has num_groups=8, so the
1205+
# of 16 -- instead of a fixed 64. For example with num_groups=8, the
12051206
# old BLOCK_M=64 left 56/64 M-rows idle (the QK/PV MMAs still computed all
12061207
# 64 rows) AND made acc[BLOCK_M, HEAD_DIM] fp32 = 64*512*4 = 128 KB/CTA,
12071208
# which blows past the register file and spills to local memory. Matching

examples/models/gemma4_31b/cuda_source_transformations.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ def _turboquant_attention_forward(
100100
self.kv_cache.centroids,
101101
self.kv_cache.rotation,
102102
attn_mask,
103-
False, # is_causal attn_mask already encodes causal masking
103+
False, # is_causal: attn_mask already encodes causal masking
104104
self.scaling,
105105
kv_len,
106+
True, # mask_is_causal: Gemma full-attention mask is standard causal
106107
)
107108

108109
y = y.transpose(1, 2).contiguous().view(B, T, self.n_heads * self.head_dim)

0 commit comments

Comments
 (0)