@@ -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 (
0 commit comments