[Qwen3.8-Flash-Next] Separate prefill and decode paths for QSA indexer (#54513)

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>

Thien Tran committed Sep 2, 2026 at 09:08 UTC 003e34341a824ab8e3bec4861e92b5e84180af8b
7 files changed +1105 -523
tests/models/qwen4_exp/test_qsa_reference.py
+252 -83
@@ -14,6 +14,7 @@ from vllm.models.qwen4_exp.nvidia import (
14 model as _qwen4_exp_model, # noqa: F401
15 )
16 from vllm.models.qwen4_exp.nvidia.ops import qsa as qsa_ops
17 +from vllm.models.qwen4_exp.nvidia.ops import qsa_indexer as qsa_indexer_ops
18 from vllm.platforms import current_platform
19 from vllm.triton_utils import HAS_TRITON
20
@@ -67,8 +68,13 @@ def test_qsa_mtp_index_share_updates_cache_but_skips_selection(
68 lambda *args, **kwargs: updates.append((args, kwargs)),
69 )
70 monkeypatch.setattr(
70 - qsa_ops,
71 - "qsa_select_paged_tokens",
71 + qsa_indexer_ops,
72 + "qsa_select_paged_decode",
73 + lambda *args, **kwargs: selections.append((args, kwargs)),
74 + )
75 + monkeypatch.setattr(
76 + qsa_indexer_ops,
77 + "qsa_select_paged_prefill",
78 lambda *args, **kwargs: selections.append((args, kwargs)),
79 )
80
@@ -158,7 +164,7 @@ def _expand_qsa_indices_reference(
164 return result.gather(1, torch.argsort(sort_key, dim=1, stable=True)).to(torch.int32)
165
166
161 -def _qsa_select_paged_tokens_reference(
167 +def _qsa_select_paged_reference(
168 q: torch.Tensor,
169 k_cache: torch.Tensor,
170 page_table: torch.Tensor,
@@ -181,19 +187,12 @@ def _qsa_select_paged_tokens_reference(
187 visible_blocks,
188 )
189 starts = torch.zeros_like(visible_blocks)
184 - blocks = _qsa_relative_topk_reference(
190 + return _qsa_relative_topk_reference(
191 logits,
192 starts,
193 visible_blocks,
194 token_topk // compress_ratio,
195 )
190 - return _expand_qsa_indices_reference(
191 - blocks,
192 - query_positions,
193 - row_sequence_lengths,
194 - compress_ratio,
195 - token_topk,
196 - )
196
197
198 def _qsa_sparse_paged_attention_reference(
@@ -231,16 +230,20 @@ def test_qsa_side_metadata_marks_cudagraph_padding_inert() -> None:
230 device = torch.device("cuda")
231 builder = QSAMetadataBuilder.__new__(QSAMetadataBuilder)
232 builder.compress_ratio = 1
233 + builder.reorder_batch_threshold = 4
234 builder.is_circular_buffer = False
235 builder.storage_block_size = 64
236 builder.token_to_req_buffer = torch.empty(16, dtype=torch.int32, device=device)
237 builder.slot_mapping_buffer = torch.empty(16, dtype=torch.int64, device=device)
238 builder.logical_positions_buffer = torch.empty(16, dtype=torch.int64, device=device)
239 + builder.visible_blocks_buffer = torch.empty(16, dtype=torch.int32, device=device)
240 builder.k_work_metadata_buffer = torch.empty(0, 2, dtype=torch.int32, device=device)
241 query_start_loc = torch.tensor([0, 4, 8, 12, 12], dtype=torch.int32, device=device)
242 token_to_req = torch.tensor([0] * 4 + [1] * 4 + [2] * 4 + [0] * 4, device=device)
243 common = SimpleNamespace(
244 num_actual_tokens=16,
245 + num_reqs=4,
246 + max_query_len=4,
247 query_start_loc=query_start_loc,
248 query_start_loc_cpu=query_start_loc.cpu(),
249 seq_lens=torch.tensor([68, 68, 68, 0], dtype=torch.int32, device=device),
@@ -270,6 +273,7 @@ def test_qsa_side_metadata_marks_cudagraph_padding_inert() -> None:
273 -1,
274 ]
275 assert metadata.slot_mapping.tolist() == list(range(12)) + [-1] * 4
276 + assert metadata.visible_blocks.tolist() == [65, 66, 67, 68] * 3 + [0] * 4
277
278
279 @requires_qsa_kernels
@@ -277,18 +281,22 @@ def test_qsa_circular_buffer_metadata_keeps_only_each_requests_suffix() -> None:
281 device = torch.device("cuda")
282 builder = QSAMetadataBuilder.__new__(QSAMetadataBuilder)
283 builder.compress_ratio = 4
284 + builder.reorder_batch_threshold = 1
285 builder.is_circular_buffer = True
286 builder.kv_cache_spec = SimpleNamespace(block_size=4)
287 builder.storage_block_size = 4
288 builder.token_to_req_buffer = torch.empty(16, dtype=torch.int32, device=device)
289 builder.slot_mapping_buffer = torch.empty(16, dtype=torch.int64, device=device)
290 builder.logical_positions_buffer = torch.empty(16, dtype=torch.int64, device=device)
291 + builder.visible_blocks_buffer = torch.empty(16, dtype=torch.int32, device=device)
292 builder.k_work_metadata_buffer = torch.empty(0, 2, dtype=torch.int32, device=device)
293 query_start_loc = torch.tensor([0, 7, 13, 13], dtype=torch.int32, device=device)
294 token_to_req = torch.tensor([0] * 7 + [1] * 6 + [0] * 3, device=device)
295 block_table = torch.tensor([[1], [0], [2]], dtype=torch.int32, device=device)
296 common = SimpleNamespace(
297 num_actual_tokens=16,
298 + num_reqs=3,
299 + max_query_len=7,
300 query_start_loc=query_start_loc,
301 query_start_loc_cpu=query_start_loc.cpu(),
302 seq_lens=torch.tensor([9, 11, 0], dtype=torch.int32, device=device),
@@ -402,11 +410,13 @@ def test_qsa_compressed_metadata_keeps_dummy_slots_inert() -> None:
410 device = torch.device("cuda")
411 builder = QSAMetadataBuilder.__new__(QSAMetadataBuilder)
412 builder.compress_ratio = 4
413 + builder.reorder_batch_threshold = 1
414 builder.is_circular_buffer = False
415 builder.storage_block_size = 16
416 builder.token_to_req_buffer = torch.empty(8, dtype=torch.int32, device=device)
417 builder.slot_mapping_buffer = torch.empty(8, dtype=torch.int64, device=device)
418 builder.logical_positions_buffer = torch.empty(8, dtype=torch.int64, device=device)
419 + builder.visible_blocks_buffer = torch.empty(8, dtype=torch.int32, device=device)
420 # Simulate max_num_seqs exceeding the three live requests below.
421 builder.request_capacity = 8
422 builder.k_work_metadata_buffer = torch.empty(4, 2, dtype=torch.int32, device=device)
@@ -416,6 +426,8 @@ def test_qsa_compressed_metadata_keeps_dummy_slots_inert() -> None:
426 )
427 common = SimpleNamespace(
428 num_actual_tokens=8,
429 + num_reqs=3,
430 + max_query_len=5,
431 query_start_loc=query_start_loc,
432 query_start_loc_cpu=query_start_loc.cpu(),
433 seq_lens=torch.tensor([7, 0, 12], dtype=torch.int32, device=device),
@@ -427,6 +439,7 @@ def test_qsa_compressed_metadata_keeps_dummy_slots_inert() -> None:
439 metadata = builder.build(0, common)
440
441 assert metadata.slot_mapping.tolist() == [-1] * 8
442 + assert metadata.visible_blocks.tolist() == [1, 1, 1, 2, 2, 2, 2, 3]
443 assert metadata.k_work_metadata.tolist() == [[0, 0], [2, 0], [2, 1], [-1, -1]]
444
445
@@ -473,10 +486,11 @@ def test_qsa_triton_metadata_matches_pytorch(
486 token_to_req_indices=lambda buffer: buffer.copy_(token_to_req),
487 )
488
476 - def make_buffers() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
489 + def make_buffers() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
490 return (
491 torch.empty(num_tokens, dtype=torch.int32, device=device),
492 torch.empty(num_tokens, dtype=torch.int64, device=device),
493 + torch.empty(num_tokens, dtype=torch.int32, device=device),
494 torch.empty(num_tokens, dtype=torch.int64, device=device),
495 )
496
@@ -543,10 +557,11 @@ def test_qsa_fused_metadata_matches_pytorch_for_large_padded_prefill() -> None:
557 token_to_req_indices=lambda buffer: buffer.zero_(),
558 )
559
546 - def make_buffers() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
560 + def make_buffers() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
561 return (
562 torch.empty(num_tokens, dtype=torch.int32, device=device),
563 torch.empty(num_tokens, dtype=torch.int64, device=device),
564 + torch.empty(num_tokens, dtype=torch.int32, device=device),
565 torch.empty(num_tokens, dtype=torch.int64, device=device),
566 )
567
@@ -575,70 +590,163 @@ def test_qsa_fused_metadata_matches_pytorch_for_large_padded_prefill() -> None:
590
591 @requires_qsa_kernels
592 @pytest.mark.parametrize(
578 - "num_rows",
593 + ("decode_query_len", "num_requests"),
594 [
580 - pytest.param(3, id="one_tile_per_program"),
581 - pytest.param(33, id="looped_tiles"),
595 + (1, 2),
596 + (2, 2),
597 + (3, 2),
598 + (4, 2),
599 + (4, 33),
600 ],
601 )
584 -def test_qsa_mqa_paged_matches_test_reference(num_rows: int) -> None:
602 +def test_qsa_decode_selection_correctness(
603 + decode_query_len: int, num_requests: int
604 +) -> None:
605 torch.manual_seed(1)
586 - head_dim = 128
587 - q = torch.randn(num_rows, 4, head_dim, device="cuda", dtype=torch.bfloat16)
588 - cache = torch.randn(40, 4, 1, head_dim, device="cuda", dtype=torch.bfloat16)
589 - page_table = torch.randperm(40, device="cuda", dtype=torch.int32).reshape(2, 20)
590 - request_rows = [(num_rows + 1) // 2, num_rows // 2]
606 + heads, head_dim = 4, 128
607 + rows = num_requests * decode_query_len
608 + q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16)
609 + page_size, pages_per_request, max_sequence_length = (
610 + (16, 40, 2560) if num_requests > 32 else (4, 20, 320)
611 + )
612 + num_pages = num_requests * pages_per_request
613 + cache = torch.randn(
614 + num_pages,
615 + page_size,
616 + 1,
617 + head_dim,
618 + device="cuda",
619 + dtype=torch.bfloat16,
620 + )
621 + page_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape(
622 + num_requests, pages_per_request
623 + )
624 token_to_req = torch.repeat_interleave(
592 - torch.arange(2, device="cuda", dtype=torch.int32),
593 - torch.tensor(request_rows, device="cuda"),
625 + torch.arange(num_requests, device="cuda", dtype=torch.int32),
626 + decode_query_len,
627 )
595 - sequence_length_values = [320, 264]
596 - sequence_lengths = torch.tensor(
597 - sequence_length_values, device="cuda", dtype=torch.int32
628 + sequence_lengths = max_sequence_length - 4 * (
629 + torch.arange(num_requests, device="cuda", dtype=torch.int32) % 8
630 )
631 query_positions = torch.cat(
632 [
601 - torch.arange(length - rows, length, device="cuda", dtype=torch.int32)
602 - for rows, length in zip(request_rows, sequence_length_values, strict=True)
633 + torch.arange(
634 + length - decode_query_len,
635 + length,
636 + device="cuda",
637 + dtype=torch.int32,
638 + )
639 + for length in sequence_lengths.tolist()
640 ]
641 )
605 - compress_ratio = 4
606 - visible_lengths = (query_positions + 1) // compress_ratio
642 + visible_blocks = torch.minimum(
643 + (query_positions + 1) // 4,
644 + sequence_lengths.index_select(0, token_to_req.long()) // 4,
645 + )
646
608 - actual, actual_visible_blocks = qsa_ops.qsa_mqa_paged(
647 + token_topk, compress_ratio = 2048, 4
648 + actual = torch.empty(
649 + (rows, token_topk // compress_ratio), device="cuda", dtype=torch.int32
650 + )
651 + qsa_indexer_ops.qsa_select_paged_decode(
652 + q,
653 + cache,
654 + page_table,
655 + visible_blocks,
656 + token_topk,
657 + compress_ratio,
658 + decode_query_len,
659 + actual,
660 + )
661 + expected = _qsa_select_paged_reference(
662 q,
663 cache,
664 page_table,
665 token_to_req,
666 query_positions,
667 sequence_lengths,
615 - compress_ratio=compress_ratio,
668 + token_topk,
669 + compress_ratio,
670 + )
671 +
672 + torch.testing.assert_close(actual.sort().values, expected.sort().values)
673 +
674 +
675 +@requires_qsa_kernels
676 +def test_qsa_prefill_selection_correctness() -> None:
677 + torch.manual_seed(2)
678 + query_lens = [3, 33]
679 + rows, heads, head_dim = sum(query_lens), 4, 128
680 + q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16)
681 + cache = torch.randn(160, 16, 1, head_dim, device="cuda", dtype=torch.bfloat16)
682 + page_table = torch.randperm(160, device="cuda", dtype=torch.int32).reshape(2, 80)
683 + token_to_req = torch.repeat_interleave(
684 + torch.arange(2, device="cuda", dtype=torch.int32),
685 + torch.tensor(query_lens, device="cuda"),
686 + )
687 + query_start_loc = torch.tensor([0, 3, 36], device="cuda", dtype=torch.int32)
688 + sequence_lengths = torch.tensor([5120, 4224], device="cuda", dtype=torch.int32)
689 + query_positions = torch.cat(
690 + [
691 + torch.arange(length - query_len, length, device="cuda", dtype=torch.int32)
692 + for query_len, length in zip(
693 + query_lens, sequence_lengths.tolist(), strict=True
694 + )
695 + ]
696 + )
697 + token_topk, compress_ratio = 2048, 4
698 + visible_blocks = torch.minimum(
699 + (query_positions + 1) // compress_ratio,
700 + sequence_lengths.index_select(0, token_to_req.long()) // compress_ratio,
701 + )
702 +
703 + actual = torch.empty(
704 + (rows, token_topk // compress_ratio), device="cuda", dtype=torch.int32
705 )
617 - expected = _qsa_mqa_paged_reference(
618 - q, cache, page_table, token_to_req, visible_lengths
706 + qsa_indexer_ops.qsa_select_paged_prefill(
707 + q,
708 + cache,
709 + page_table,
710 + query_start_loc,
711 + visible_blocks,
712 + token_topk,
713 + compress_ratio,
714 + max(query_lens),
715 + actual,
716 + )
717 + expected = _qsa_select_paged_reference(
718 + q,
719 + cache,
720 + page_table,
721 + token_to_req,
722 + query_positions,
723 + sequence_lengths,
724 + token_topk,
725 + compress_ratio,
726 )
727
621 - torch.testing.assert_close(actual_visible_blocks, visible_lengths)
622 - # Top-k is bounded by visible_blocks; later columns are intentionally unwritten.
623 - columns = torch.arange(actual.shape[1], device=actual.device)
624 - visible = columns[None, :] < actual_visible_blocks[:, None]
625 - torch.testing.assert_close(actual[visible], expected[visible], rtol=1e-3, atol=1e-3)
728 + torch.testing.assert_close(actual.sort().values, expected.sort().values)
729
730
731 @requires_qsa_kernels
629 -def test_qsa_block_expansion_matches_test_reference() -> None:
732 +def test_qsa_block_expansion_correctness() -> None:
733 blocks = torch.tensor([[0, -1], [1, 0]], device="cuda", dtype=torch.int32)
734 query_positions = torch.tensor([5, 10], device="cuda")
735 sequence_lengths = torch.tensor([6, 11], device="cuda")
736 token_to_req = torch.tensor([0, 1], device="cuda", dtype=torch.int32)
737 + visible_blocks = torch.minimum(
738 + (query_positions + 1) // 4,
739 + sequence_lengths.index_select(0, token_to_req.long()) // 4,
740 + ).to(torch.int32)
741
635 - actual = qsa_ops.expand_qsa_block_indices_cuda(
742 + actual = torch.empty((2, 11), device="cuda", dtype=torch.int32)
743 + qsa_indexer_ops.expand_qsa_block_indices(
744 blocks,
745 query_positions,
638 - sequence_lengths,
639 - token_to_req,
746 + visible_blocks,
747 compress_ratio=4,
748 token_topk=8,
749 + out=actual,
750 )
751 expected = _expand_qsa_indices_reference(
752 blocks,
@@ -663,7 +771,7 @@ def test_qsa_block_expansion_matches_test_reference() -> None:
771 pytest.param(513, 6, 1, 1024, id="tp4_split1"),
772 ],
773 )
666 -def test_qsa_sparse_paged_attention_matches_test_reference(
774 +def test_qsa_sparse_paged_attention_correctness(
775 num_rows: int,
776 num_query_heads: int,
777 num_kv_heads: int,
@@ -724,13 +832,20 @@ def test_qsa_sparse_paged_attention_matches_test_reference(
832 sequence_lengths = torch.full(
833 (num_requests,), context_length, device="cuda", dtype=torch.int32
834 )
727 - logical_indices = qsa_ops.expand_qsa_block_indices_cuda(
835 + visible_blocks = torch.minimum(
836 + (query_positions + 1) // indexer_compress_ratio,
837 + sequence_lengths.index_select(0, token_to_req.long()) // indexer_compress_ratio,
838 + ).to(torch.int32)
839 + logical_indices = torch.empty(
840 + (num_rows, selection_width), device="cuda", dtype=torch.int32
841 + )
842 + qsa_indexer_ops.expand_qsa_block_indices(
843 block_indices,
844 query_positions,
730 - sequence_lengths,
731 - token_to_req,
845 + visible_blocks,
846 indexer_compress_ratio,
847 indexer_budget,
848 + logical_indices,
849 )
850 assert logical_indices.shape == (num_rows, selection_width)
851 scale = q.shape[-1] ** -0.5
@@ -757,40 +872,78 @@ def test_qsa_sparse_paged_attention_matches_test_reference(
872
873
874 @requires_qsa_kernels
760 -def test_qsa_selection_chunks_workspace_and_matches_test_reference(
761 - monkeypatch: pytest.MonkeyPatch,
762 - workspace_init,
763 -) -> None:
764 - rows, keys, heads, head_dim = 65, 640, 4, 16
875 +@pytest.mark.parametrize("decode_query_len", [1, 2, 3, 4])
876 +def test_qsa_split_selection_correctness(workspace_init, decode_query_len: int) -> None:
877 + query_lens = [decode_query_len, decode_query_len, 33]
878 + rows, heads, head_dim = sum(query_lens), 4, 128
879 token_topk, compress_ratio = 2048, 4
766 - torch.manual_seed(3)
880 + torch.manual_seed(13)
881 q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16)
768 - cache = torch.randn(40, 16, 1, head_dim, device="cuda", dtype=torch.bfloat16)
769 - page_table = torch.randperm(40, device="cuda", dtype=torch.int32).unsqueeze(0)
770 - token_to_req = torch.zeros(rows, device="cuda", dtype=torch.int32)
771 - query_positions = torch.full((rows,), 2559, device="cuda", dtype=torch.int32)
772 - sequence_lengths = torch.tensor([2560], device="cuda", dtype=torch.int32)
773 - monkeypatch.setattr(qsa_ops, "_LOGITS_WORKSPACE_BYTES", 32 * keys * 4)
774 - original_score = qsa_ops.qsa_mqa_paged
775 - scored_row_counts = []
776 -
777 - def record_score(query: torch.Tensor, *args, **kwargs):
778 - scored_row_counts.append(query.shape[0])
779 - return original_score(query, *args, **kwargs)
780 -
781 - monkeypatch.setattr(qsa_ops, "qsa_mqa_paged", record_score)
782 -
783 - actual = qsa_ops.qsa_select_paged_tokens(
784 - q,
882 + cache = torch.randn(120, 16, 1, head_dim, device="cuda", dtype=torch.bfloat16)
883 + page_table = torch.arange(120, device="cuda", dtype=torch.int32).view(3, 40)
884 + token_to_req = torch.repeat_interleave(
885 + torch.arange(3, device="cuda", dtype=torch.int32),
886 + torch.tensor(query_lens, device="cuda"),
887 + )
888 + query_start_loc = torch.tensor(
889 + [0, decode_query_len, 2 * decode_query_len, rows],
890 + device="cuda",
891 + dtype=torch.int32,
892 + )
893 + sequence_lengths = torch.full((3,), 2560, device="cuda", dtype=torch.int32)
894 + query_positions = torch.cat(
895 + [
896 + torch.arange(2560 - query_len, 2560, device="cuda")
897 + for query_len in query_lens
898 + ]
899 + )
900 +
901 + block_indices = torch.empty(
902 + rows,
903 + token_topk // compress_ratio,
904 + device="cuda",
905 + dtype=torch.int32,
906 + )
907 + visible_blocks = torch.minimum(
908 + (query_positions + 1) // compress_ratio,
909 + sequence_lengths.index_select(0, token_to_req.long()) // compress_ratio,
910 + ).to(torch.int32)
911 + num_decode_tokens = 2 * decode_query_len
912 + decode_slice = slice(0, num_decode_tokens)
913 + qsa_indexer_ops.qsa_select_paged_decode(
914 + q[decode_slice],
915 cache,
786 - page_table,
787 - token_to_req,
788 - query_positions,
789 - sequence_lengths,
916 + page_table[:2],
917 + visible_blocks[decode_slice],
918 token_topk,
919 compress_ratio,
920 + decode_query_len,
921 + block_indices[decode_slice],
922 + )
923 + prefill_slice = slice(num_decode_tokens, rows)
924 + qsa_indexer_ops.qsa_select_paged_prefill(
925 + q[prefill_slice],
926 + cache,
927 + page_table[2:],
928 + query_start_loc[2:],
929 + visible_blocks[prefill_slice],
930 + token_topk,
931 + compress_ratio,
932 + query_lens[-1],
933 + block_indices[prefill_slice],
934 + )
935 + actual = torch.empty(
936 + (rows, token_topk + compress_ratio - 1), device="cuda", dtype=torch.int32
937 + )
938 + qsa_indexer_ops.expand_qsa_block_indices(
939 + block_indices,
940 + query_positions,
941 + visible_blocks,
942 + compress_ratio,
943 + token_topk,
944 + actual,
945 )
793 - expected = _qsa_select_paged_tokens_reference(
946 + expected_blocks = _qsa_select_paged_reference(
947 q,
948 cache,
949 page_table,
@@ -800,9 +953,15 @@ def test_qsa_selection_chunks_workspace_and_matches_test_reference(
953 token_topk,
954 compress_ratio,
955 )
956 + expected = _expand_qsa_indices_reference(
957 + expected_blocks,
958 + query_positions,
959 + sequence_lengths.index_select(0, token_to_req.long()),
960 + compress_ratio,
961 + token_topk,
962 + )
963
964 torch.testing.assert_close(actual.sort().values, expected.sort().values)
805 - assert scored_row_counts == [32, 32, 1]
965
966
967 @requires_qsa_kernels
@@ -810,19 +969,29 @@ def test_qsa_selection_handles_no_complete_compressed_blocks(workspace_init) ->
969 q = torch.zeros(2, 4, 8, device="cuda", dtype=torch.bfloat16)
970 cache = torch.zeros(1, 16, 1, 8, device="cuda", dtype=torch.bfloat16)
971 page_table = torch.zeros(1, 1, device="cuda", dtype=torch.int32)
813 - token_to_req = torch.zeros(2, device="cuda", dtype=torch.int32)
972 query_positions = torch.tensor([1, 2], device="cuda", dtype=torch.int32)
815 - sequence_lengths = torch.tensor([3], device="cuda", dtype=torch.int32)
973 + visible_blocks = torch.zeros(2, device="cuda", dtype=torch.int32)
974
817 - selected = qsa_ops.qsa_select_paged_tokens(
975 + block_indices = torch.empty((2, 512), device="cuda", dtype=torch.int32)
976 + qsa_indexer_ops.qsa_select_paged_prefill(
977 q,
978 cache,
979 page_table,
821 - token_to_req,
822 - query_positions,
823 - sequence_lengths,
980 + torch.tensor([0, 2], device="cuda", dtype=torch.int32),
981 + visible_blocks,
982 token_topk=2048,
983 compress_ratio=4,
984 + max_query_len=2,
985 + block_indices=block_indices,
986 + )
987 + selected = torch.empty((2, 2051), device="cuda", dtype=torch.int32)
988 + qsa_indexer_ops.expand_qsa_block_indices(
989 + block_indices,
990 + query_positions,
991 + visible_blocks,
992 + compress_ratio=4,
993 + token_topk=2048,
994 + out=selected,
995 )
996
997 assert selected[0, :2].tolist() == [0, 1]
vllm/model_executor/warmup/kernel_warmup.py
+4
@@ -37,6 +37,9 @@ from vllm.model_executor.warmup.flashinfer_sparse_mla_warmup import (
37 from vllm.model_executor.warmup.kimi_k3_triton_warmup import (
38 kimi_k3_triton_warmup,
39 )
40 +from vllm.model_executor.warmup.qwen4_exp_qsa_warmup import (
41 + qwen4_exp_qsa_triton_warmup,
42 +)
43 from vllm.model_executor.warmup.qwen_triton_warmup import qwen_triton_warmup
44 from vllm.model_executor.warmup.replayssm_warmup import (
45 replayssm_autotune_warmup,
@@ -157,6 +160,7 @@ def kernel_warmup(worker: "Worker", *, process_local_only: bool = False):
160 if worker.vllm_config.kernel_config.enable_jit_warmup:
161 kimi_k3_triton_warmup(worker)
162 fa4_cutedsl_warmup(worker)
163 + qwen4_exp_qsa_triton_warmup(worker)
164
165 if current_platform.has_device_capability(90):
166 _warmup_ll_bf16_router_gemm(worker.get_model())
vllm/model_executor/warmup/qwen4_exp_qsa_warmup.py new
+66
@@ -0,0 +1,66 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Connect loaded Qwen4Exp QSA modules to their kernel-owned warmup."""
4 +
5 +import sys
6 +from typing import TYPE_CHECKING, cast
7 +
8 +from vllm.logger import init_logger
9 +
10 +if TYPE_CHECKING:
11 + from vllm.v1.worker.gpu.model_runner import GPUModelRunner as GPUModelRunnerV2
12 + from vllm.v1.worker.gpu_worker import Worker
13 +
14 +logger = init_logger(__name__)
15 +
16 +
17 +def qwen4_exp_qsa_triton_warmup(worker: "Worker") -> None:
18 + """Warm every reachable QSA decode-query-length specialization."""
19 +
20 + qsa_module = sys.modules.get("vllm.models.qwen4_exp.nvidia.indexer_qsa")
21 + if qsa_module is None:
22 + return
23 + indexer = next(
24 + (
25 + layer
26 + for layer in worker.get_model().modules()
27 + if isinstance(layer, qsa_module.QSAIndexer)
28 + ),
29 + None,
30 + )
31 + if indexer is None:
32 + return
33 +
34 + runner = worker.model_runner
35 + prefix = indexer.compressed_key_cache.prefix
36 + group_id = next(
37 + i
38 + for i, group in enumerate(runner.kv_cache_config.kv_cache_groups)
39 + if prefix in group.layer_names
40 + )
41 + if worker.use_v2_model_runner:
42 + runner_v2 = cast("GPUModelRunnerV2", runner)
43 + block_table = runner_v2.block_tables.input_block_tables[group_id]
44 + max_decode_query_len = runner_v2.decode_query_len
45 + else:
46 + block_table = runner.input_batch.block_table[group_id].get_device_tensor(
47 + runner.max_num_reqs
48 + )
49 + max_decode_query_len = runner.uniform_decode_query_len
50 +
51 + from vllm.models.qwen4_exp.nvidia.ops.qsa_indexer import (
52 + warmup_qsa_mqa_paged_decode,
53 + )
54 +
55 + k_cache = indexer.compressed_key_cache.kv_cache
56 + assert k_cache.numel()
57 + profiles = warmup_qsa_mqa_paged_decode(
58 + k_cache,
59 + block_table,
60 + num_heads=indexer.index_n_heads,
61 + head_dim=indexer.index_head_dim,
62 + max_decode_query_len=max_decode_query_len,
63 + max_num_reqs=runner.max_num_reqs,
64 + max_num_batched_tokens=runner.max_num_tokens,
65 + )
66 + logger.info("Warmed up Qwen4Exp QSA decode kernels: %s.", profiles)
vllm/models/qwen4_exp/common/qsa_cache.py
+86 -20
@@ -11,8 +11,6 @@ table follows the main KV-cache lifecycle. Their physical tensor storage is
11 shared by the generic cache-layout planner.
12 """
13
14 -from __future__ import annotations
15 -
14 import math
15 from dataclasses import dataclass
16 from functools import cache
@@ -34,7 +32,10 @@ from vllm.v1.attention.backend import (
32 AttentionMetadataBuilder,
33 CommonAttentionMetadata,
34 )
37 -from vllm.v1.attention.backends.utils import PAD_SLOT_ID
35 +from vllm.v1.attention.backends.utils import (
36 + PAD_SLOT_ID,
37 + split_decodes_and_prefills,
38 +)
39 from vllm.v1.kv_cache_interface import (
40 AttentionSpec,
41 CircularBufferSpec,
@@ -211,6 +212,7 @@ def _build_qsa_metadata_kernel(
212 block_table_ptr,
213 token_to_req_ptr,
214 logical_positions_ptr,
215 + visible_blocks_ptr,
216 slot_mapping_ptr,
217 k_work_metadata_ptr,
218 block_table_stride_0: tl.constexpr,
@@ -266,6 +268,14 @@ def _build_qsa_metadata_kernel(
268 logical_position,
269 mask=store_mask,
270 )
271 + visible_blocks = tl.maximum(
272 + 0,
273 + tl.minimum(
274 + (logical_position + 1) // compress_ratio,
275 + seq_len // compress_ratio,
276 + ),
277 + ).to(tl.int32)
278 + tl.store(visible_blocks_ptr + token_idx, visible_blocks, mask=store_mask)
279
280 # circular_buffer_size is constexpr, so each builder instance compiles out
281 # the other QSA cache owner's slot-mapping rule.
@@ -380,6 +390,7 @@ def build_qsa_metadata_triton(
390 common_attn_metadata: CommonAttentionMetadata,
391 token_to_req_buffer: torch.Tensor,
392 logical_positions_buffer: torch.Tensor,
393 + visible_blocks_buffer: torch.Tensor,
394 slot_mapping_buffer: torch.Tensor,
395 *,
396 storage_block_size: int,
@@ -387,12 +398,13 @@ def build_qsa_metadata_triton(
398 circular_buffer_size: int = 0,
399 k_work_metadata_buffer: torch.Tensor | None = None,
400 request_capacity: int | None = None,
390 -) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
401 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
402 """Build QSA side-cache and optional pre-indexer work metadata."""
403 num_tokens = common_attn_metadata.num_actual_tokens
404 num_mapped_tokens = int(common_attn_metadata.query_start_loc_cpu[-1])
405 token_to_req = token_to_req_buffer[:num_tokens]
406 logical_positions = logical_positions_buffer[:num_tokens]
407 + visible_blocks = visible_blocks_buffer[:num_tokens]
408 slot_mapping = slot_mapping_buffer[:num_tokens]
409 num_reqs = common_attn_metadata.query_start_loc.shape[0] - 1
410 assert num_reqs > 0
@@ -409,7 +421,7 @@ def build_qsa_metadata_triton(
421 max_num_work = 0
422
423 if num_tokens == 0 and k_work_metadata_buffer is None:
412 - return token_to_req, logical_positions, slot_mapping
424 + return token_to_req, logical_positions, visible_blocks, slot_mapping
425
426 block_table = common_attn_metadata.block_table_tensor
427 num_search_steps = int(math.ceil(math.log2(num_reqs)))
@@ -426,6 +438,7 @@ def build_qsa_metadata_triton(
438 block_table,
439 token_to_req,
440 logical_positions,
441 + visible_blocks,
442 slot_mapping,
443 k_work_metadata_buffer,
444 block_table.stride(0),
@@ -448,13 +461,14 @@ def build_qsa_metadata_triton(
461 )
462 if circular_buffer_size == 0 and compress_ratio == 1:
463 slot_mapping = common_attn_metadata.slot_mapping[:num_tokens]
451 - return token_to_req, logical_positions, slot_mapping
464 + return token_to_req, logical_positions, visible_blocks, slot_mapping
465
466
467 def _build_qsa_metadata_torch(
468 common_attn_metadata: CommonAttentionMetadata,
469 token_to_req_buffer: torch.Tensor,
470 logical_positions_buffer: torch.Tensor,
471 + visible_blocks_buffer: torch.Tensor,
472 slot_mapping_buffer: torch.Tensor,
473 *,
474 storage_block_size: int,
@@ -462,11 +476,12 @@ def _build_qsa_metadata_torch(
476 circular_buffer_size: int = 0,
477 k_work_metadata_buffer: torch.Tensor | None = None,
478 request_capacity: int | None = None,
465 -) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
479 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
480 del request_capacity
481 num_tokens = common_attn_metadata.num_actual_tokens
482 num_mapped_tokens = int(common_attn_metadata.query_start_loc_cpu[-1])
483 logical_positions = logical_positions_buffer[:num_tokens]
484 + visible_blocks = visible_blocks_buffer[:num_tokens]
485
486 token_to_req = common_attn_metadata.token_to_req_indices(token_to_req_buffer)[
487 :num_tokens
@@ -481,6 +496,15 @@ def _build_qsa_metadata_torch(
496 )
497 if num_mapped_tokens < num_tokens:
498 logical_positions[num_mapped_tokens:].fill_(-1)
499 + row_seq_lens = common_attn_metadata.seq_lens.index_select(0, token_to_req.long())
500 + visible_blocks.copy_(
501 + torch.minimum(
502 + (logical_positions + 1) // compress_ratio,
503 + row_seq_lens // compress_ratio,
504 + )
505 + .clamp_min_(0)
506 + .to(torch.int32)
507 + )
508 if circular_buffer_size > 0:
509 slot_mapping = circular_qsa_slot_mapping(
510 common_attn_metadata.block_table_tensor,
@@ -539,7 +563,7 @@ def _build_qsa_metadata_torch(
563 k_work_metadata_buffer[:, 1].copy_(
564 torch.where(active, work_in_request, -1).to(torch.int32)
565 )
542 - return token_to_req, logical_positions, slot_mapping
566 + return token_to_req, logical_positions, visible_blocks, slot_mapping
567
568
569 # Resolve the fallback outside the per-step metadata hot path.
@@ -558,8 +582,15 @@ class QSAForwardMetadata(AttentionMetadata):
582 query_start_loc: torch.Tensor
583 token_to_req: torch.Tensor
584 logical_positions: torch.Tensor
585 + visible_blocks: torch.Tensor
586 k_work_metadata: torch.Tensor
587 num_actual_tokens: int
588 + num_decodes: int
589 + num_decode_tokens: int
590 + num_prefills: int
591 + num_prefill_tokens: int
592 + max_query_len: int
593 + decode_query_len: int
594 storage_block_size: int
595 compress_ratio: int
596
@@ -577,6 +608,8 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
608 device: torch.device,
609 ) -> None:
610 super().__init__(kv_cache_spec, layer_names, vllm_config, device)
611 + self._init_reorder_batch_threshold(1, supports_spec_as_decode=True)
612 + assert self.reorder_batch_threshold is not None
613 self.is_circular_buffer = isinstance(kv_cache_spec, CircularBufferSpec)
614 if isinstance(kv_cache_spec, MLAAttentionSpec):
615 compress_ratio = kv_cache_spec.tokens_per_state
@@ -597,6 +630,9 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
630 self.logical_positions_buffer = torch.empty(
631 max_tokens, dtype=torch.int64, device=device
632 )
633 + self.visible_blocks_buffer = torch.empty(
634 + max_tokens, dtype=torch.int32, device=device
635 + )
636 max_requests = vllm_config.scheduler_config.max_num_seqs
637 self.request_capacity = max_requests
638 if not self.is_circular_buffer and self.compress_ratio != 1:
@@ -619,6 +655,26 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
655 ) -> QSAForwardMetadata:
656 del common_prefix_len, fast_build
657 num_tokens = common_attn_metadata.num_actual_tokens
658 + decode_threshold = self.reorder_batch_threshold
659 + assert decode_threshold is not None
660 + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
661 + split_decodes_and_prefills(
662 + common_attn_metadata,
663 + decode_threshold=decode_threshold,
664 + require_uniform=True,
665 + )
666 + )
667 + assert num_decodes + num_prefills == common_attn_metadata.num_reqs
668 + assert num_decode_tokens + num_prefill_tokens == num_tokens
669 + decode_query_len = 0
670 + if num_decodes > 0:
671 + query_lens_cpu = torch.diff(
672 + common_attn_metadata.query_start_loc_cpu[: num_decodes + 1]
673 + )
674 + nonzero_query_lens = query_lens_cpu[query_lens_cpu > 0]
675 + if nonzero_query_lens.numel() > 0:
676 + decode_query_len = int(nonzero_query_lens[0].item())
677 + assert torch.all(nonzero_query_lens == decode_query_len)
678 build_k_work = not self.is_circular_buffer and self.compress_ratio != 1
679 k_work_metadata = self.k_work_metadata_buffer
680 request_capacity = None
@@ -629,18 +685,21 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
685 num_tokens + (self.compress_ratio - 1) * num_requests
686 ) // self.compress_ratio
687 k_work_metadata = self.k_work_metadata_buffer[:max_num_work]
632 - token_to_req, logical_positions, slot_mapping = build_qsa_metadata(
633 - common_attn_metadata,
634 - self.token_to_req_buffer,
635 - self.logical_positions_buffer,
636 - self.slot_mapping_buffer,
637 - storage_block_size=self.storage_block_size,
638 - compress_ratio=self.compress_ratio,
639 - circular_buffer_size=(
640 - self.kv_cache_spec.block_size if self.is_circular_buffer else 0
641 - ),
642 - k_work_metadata_buffer=k_work_metadata if build_k_work else None,
643 - request_capacity=request_capacity,
688 + token_to_req, logical_positions, visible_blocks, slot_mapping = (
689 + build_qsa_metadata(
690 + common_attn_metadata,
691 + self.token_to_req_buffer,
692 + self.logical_positions_buffer,
693 + self.visible_blocks_buffer,
694 + self.slot_mapping_buffer,
695 + storage_block_size=self.storage_block_size,
696 + compress_ratio=self.compress_ratio,
697 + circular_buffer_size=(
698 + self.kv_cache_spec.block_size if self.is_circular_buffer else 0
699 + ),
700 + k_work_metadata_buffer=k_work_metadata if build_k_work else None,
701 + request_capacity=request_capacity,
702 + )
703 )
704 return QSAForwardMetadata(
705 block_table=common_attn_metadata.block_table_tensor,
@@ -649,8 +708,15 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
708 query_start_loc=common_attn_metadata.query_start_loc,
709 token_to_req=token_to_req,
710 logical_positions=logical_positions,
711 + visible_blocks=visible_blocks,
712 k_work_metadata=k_work_metadata,
713 num_actual_tokens=num_tokens,
714 + num_decodes=num_decodes,
715 + num_decode_tokens=num_decode_tokens,
716 + num_prefills=num_prefills,
717 + num_prefill_tokens=num_prefill_tokens,
718 + max_query_len=common_attn_metadata.max_query_len,
719 + decode_query_len=decode_query_len,
720 storage_block_size=self.storage_block_size,
721 compress_ratio=self.compress_ratio,
722 )
vllm/models/qwen4_exp/nvidia/indexer_qsa.py
+79 -15
@@ -2,8 +2,6 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Qwen4Exp weight-free QSA indexer."""
4
5 -from __future__ import annotations
6 -
5 from typing import cast
6
7 import torch
@@ -185,6 +183,22 @@ class QSAIndexer(nn.Module):
183 )
184 if raw.num_actual_tokens != compressed.num_actual_tokens:
185 raise RuntimeError("QSA side-cache metadata token counts disagree")
186 + raw_split = (
187 + raw.num_decodes,
188 + raw.num_decode_tokens,
189 + raw.num_prefills,
190 + raw.num_prefill_tokens,
191 + raw.decode_query_len,
192 + )
193 + compressed_split = (
194 + compressed.num_decodes,
195 + compressed.num_decode_tokens,
196 + compressed.num_prefills,
197 + compressed.num_prefill_tokens,
198 + compressed.decode_query_len,
199 + )
200 + if raw_split != compressed_split:
201 + raise RuntimeError("QSA side-cache metadata batch splits disagree")
202 if not raw.logical_positions.is_cuda and (
203 not torch.equal(raw.logical_positions, compressed.logical_positions)
204 ):
@@ -215,10 +229,11 @@ class QSAIndexer(nn.Module):
229 return out
230 return result
231
218 - from .ops.qsa import (
219 - qsa_compress_groups_with_ratio,
220 - qsa_select_paged_tokens,
221 - qsa_store_cache_rows,
232 + from .ops.qsa import qsa_compress_groups_with_ratio, qsa_store_cache_rows
233 + from .ops.qsa_indexer import (
234 + expand_qsa_block_indices,
235 + qsa_select_paged_decode,
236 + qsa_select_paged_prefill,
237 )
238
239 raw_metadata, compressed_metadata = metadata
@@ -339,18 +354,67 @@ class QSAIndexer(nn.Module):
354 raise RuntimeError("QSA top-k reuse requires an output buffer")
355 return out
356
342 - # Score compressed keys, select blocks, then expand them to token indices.
343 - return qsa_select_paged_tokens(
344 - q,
345 - compressed_key_cache,
346 - compressed_metadata.block_table,
347 - compressed_metadata.token_to_req,
348 - compressed_metadata.logical_positions,
349 - compressed_metadata.seq_lens,
350 - self.token_topk,
357 + if out is None:
358 + out = torch.empty(
359 + num_tokens,
360 + self.output_width,
361 + dtype=torch.int32,
362 + device=q.device,
363 + )
364 + elif out.shape != (num_tokens, self.output_width):
365 + raise ValueError("QSA selection output has an invalid shape")
366 +
367 + num_decode_tokens = compressed_metadata.num_decode_tokens
368 + decode_query_len = compressed_metadata.decode_query_len
369 + visible_blocks = compressed_metadata.visible_blocks[:num_tokens]
370 + block_indices = torch.empty(
371 + num_tokens,
372 + self.token_topk // self.compress_ratio,
373 + dtype=torch.int32,
374 + device=q.device,
375 + )
376 +
377 + # Decode requests occupy the leading rows and share one query length.
378 + if num_decode_tokens:
379 + num_decodes = compressed_metadata.num_decodes
380 + if num_decodes * decode_query_len != num_decode_tokens:
381 + raise ValueError("QSA decode rows must form a uniform request batch")
382 + decode_slice = slice(0, num_decode_tokens)
383 + qsa_select_paged_decode(
384 + q[decode_slice],
385 + compressed_key_cache,
386 + compressed_metadata.block_table[:num_decodes],
387 + visible_blocks[decode_slice],
388 + self.token_topk,
389 + self.compress_ratio,
390 + decode_query_len,
391 + block_indices[decode_slice],
392 + )
393 +
394 + # Prefill requests follow the leading decode rows in the reordered batch.
395 + if num_decode_tokens < num_tokens:
396 + num_decodes = compressed_metadata.num_decodes
397 + prefill_slice = slice(num_decode_tokens, num_tokens)
398 + qsa_select_paged_prefill(
399 + q[prefill_slice],
400 + compressed_key_cache,
401 + compressed_metadata.block_table[num_decodes:],
402 + compressed_metadata.query_start_loc[num_decodes:],
403 + visible_blocks[prefill_slice],
404 + self.token_topk,
405 + self.compress_ratio,
406 + compressed_metadata.max_query_len,
407 + block_indices[prefill_slice],
408 + )
409 + expand_qsa_block_indices(
410 + block_indices,
411 + compressed_metadata.logical_positions[:num_tokens],
412 + visible_blocks,
413 self.compress_ratio,
414 + self.token_topk,
415 out,
416 )
417 + return out
418
419
420 __all__ = ["QSAIndexer", "apply_qsa_rope"]
vllm/models/qwen4_exp/nvidia/ops/qsa.py
+1 -405
@@ -1,193 +1,13 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Triton kernels for the Qwen4Exp weight-free QSA path."""
3 +"""Triton kernels for Qwen4Exp QSA sparse attention and cache updates."""
4
5 from __future__ import annotations
6
7 -import math
8 -
7 import torch
8
11 -from vllm.platforms import current_platform
9 from vllm.triton_utils import HAS_TRITON, tl, triton
10
14 -_LOGITS_WORKSPACE_BYTES = 128 * 1024 * 1024
15 -_TOPK_WORKSPACE_BYTES = 1024 * 1024
16 -
17 -
18 -@triton.jit
19 -def _qsa_mqa_paged_kernel(
20 - q_ptr,
21 - k_cache_ptr,
22 - page_table_ptr,
23 - token_to_req_ptr,
24 - query_positions_ptr,
25 - sequence_lengths_ptr,
26 - visible_blocks_ptr,
27 - logits_ptr,
28 - stride_q_row,
29 - stride_q_head,
30 - stride_q_dim,
31 - stride_cache_block,
32 - stride_cache_token,
33 - stride_cache_dim,
34 - stride_table_req,
35 - stride_table_page,
36 - stride_logits_row,
37 - num_rows,
38 - num_columns,
39 - num_pages,
40 - num_requests,
41 - score_divisor,
42 - PAGE_SIZE: tl.constexpr,
43 - PAGE_TABLE_WIDTH: tl.constexpr,
44 - NUM_HEADS: tl.constexpr,
45 - HEAD_DIM: tl.constexpr,
46 - BLOCK_N: tl.constexpr,
47 - BLOCK_D: tl.constexpr,
48 - TILES_PER_PROG: tl.constexpr,
49 - STAGES: tl.constexpr,
50 - MAX_N: tl.constexpr,
51 - COMPRESS_RATIO: tl.constexpr,
52 -) -> None:
53 - row = tl.program_id(0)
54 - dims = tl.arange(0, BLOCK_D)
55 - heads = tl.arange(0, MAX_N)
56 - request = tl.load(token_to_req_ptr + row)
57 - safe_request = tl.minimum(tl.maximum(request, 0), num_requests - 1)
58 - query_position = tl.load(query_positions_ptr + row)
59 - sequence_length = tl.load(
60 - sequence_lengths_ptr + safe_request,
61 - mask=(request >= 0) & (request < num_requests),
62 - other=0,
63 - )
64 - visible = tl.minimum(
65 - (query_position + 1) // COMPRESS_RATIO,
66 - sequence_length // COMPRESS_RATIO,
67 - )
68 - if tl.program_id(1) == 0:
69 - tl.store(visible_blocks_ptr + row, visible)
70 - tile_start = tl.program_id(1) * TILES_PER_PROG
71 - # Top-k is bounded by visible_blocks, so columns beyond it need no value.
72 - if tile_start * BLOCK_N >= visible:
73 - return
74 - tile_end = tl.minimum(tile_start + TILES_PER_PROG, tl.cdiv(visible, BLOCK_N))
75 - tile_end = tl.minimum(tile_end, tl.cdiv(num_columns, BLOCK_N))
76 -
77 - # Pad the small head axis to a tensor-core-compatible N dimension.
78 - query = tl.load(
79 - q_ptr
80 - + row * stride_q_row
81 - + heads[None, :] * stride_q_head
82 - + dims[:, None] * stride_q_dim,
83 - mask=(heads[None, :] < NUM_HEADS) & (dims[:, None] < HEAD_DIM),
84 - other=0.0,
85 - )
86 - column_offsets = tl.arange(0, BLOCK_N)
87 - for tile in tl.range(tile_start, tile_end, num_stages=STAGES):
88 - columns = tile * BLOCK_N + column_offsets
89 - live = columns < visible
90 - logical_page = tl.minimum(columns // PAGE_SIZE, PAGE_TABLE_WIDTH - 1)
91 - page_offset = columns % PAGE_SIZE
92 - physical_page = tl.load(
93 - page_table_ptr
94 - + safe_request * stride_table_req
95 - + logical_page * stride_table_page,
96 - mask=live,
97 - other=-1,
98 - )
99 - page_valid = live & (physical_page >= 0) & (physical_page < num_pages)
100 - # physical_page * block stride can overflow int32 for large caches.
101 - safe_physical_page = tl.maximum(physical_page, 0).to(tl.int64)
102 - keys = tl.load(
103 - k_cache_ptr
104 - + safe_physical_page[:, None] * stride_cache_block
105 - + page_offset[:, None] * stride_cache_token
106 - + dims[None, :] * stride_cache_dim,
107 - mask=page_valid[:, None] & (dims[None, :] < HEAD_DIM),
108 - other=0.0,
109 - eviction_policy="evict_first",
110 - )
111 - scores = tl.dot(keys, query, out_dtype=tl.float32)
112 - scores = tl.where(heads[None, :] < NUM_HEADS, tl.maximum(scores, 0.0), 0.0)
113 - score = tl.sum(scores, axis=1) / score_divisor
114 - tl.store(
115 - logits_ptr + row * stride_logits_row + columns,
116 - tl.where(page_valid, score, -float("inf")),
117 - mask=live & (columns < num_columns),
118 - )
119 -
120 -
121 -@triton.jit
122 -def _expand_qsa_indices_kernel(
123 - block_indices_ptr,
124 - query_positions_ptr,
125 - sequence_lengths_ptr,
126 - token_to_req_ptr,
127 - output_ptr,
128 - stride_blocks_row,
129 - stride_blocks_column,
130 - stride_output_row,
131 - stride_output_column,
132 - rows,
133 - num_requests,
134 - BLOCK_TOPK: tl.constexpr,
135 - COMPRESS_RATIO: tl.constexpr,
136 - TOKEN_TOPK: tl.constexpr,
137 - OUTPUT_WIDTH: tl.constexpr,
138 - COLUMN_BLOCK: tl.constexpr,
139 -) -> None:
140 - row = tl.program_id(0)
141 - columns = tl.program_id(1) * COLUMN_BLOCK + tl.arange(0, COLUMN_BLOCK)
142 - query_position = tl.load(query_positions_ptr + row)
143 - request = tl.load(token_to_req_ptr + row)
144 - safe_request = tl.minimum(tl.maximum(request, 0), num_requests - 1)
145 - sequence_length = tl.load(
146 - sequence_lengths_ptr + safe_request,
147 - mask=(request >= 0) & (request < num_requests),
148 - other=0,
149 - )
150 - complete_blocks = tl.minimum(
151 - tl.minimum(
152 - (query_position + 1) // COMPRESS_RATIO,
153 - sequence_length // COMPRESS_RATIO,
154 - ),
155 - BLOCK_TOPK,
156 - )
157 - expanded_count = complete_blocks * COMPRESS_RATIO
158 - tail_start = ((query_position + 1) // COMPRESS_RATIO) * COMPRESS_RATIO
159 - tail_count = (query_position + 1) - tail_start
160 -
161 - is_expanded = columns < expanded_count
162 - block_rank = columns // COMPRESS_RATIO
163 - offset = columns % COMPRESS_RATIO
164 - safe_rank = tl.minimum(block_rank, BLOCK_TOPK - 1)
165 - block = tl.load(
166 - block_indices_ptr + row * stride_blocks_row + safe_rank * stride_blocks_column,
167 - mask=(row < rows) & is_expanded,
168 - other=-1,
169 - )
170 - expanded = block * COMPRESS_RATIO + offset
171 - tail_offset = columns - expanded_count
172 - is_tail = (
173 - (columns >= expanded_count)
174 - & (tail_offset < tail_count)
175 - & (tail_offset < COMPRESS_RATIO - 1)
176 - )
177 - token = tl.where(is_expanded, expanded, tail_start + tail_offset)
178 - valid = (
179 - (row < rows)
180 - & (columns < OUTPUT_WIDTH)
181 - & (is_expanded | is_tail)
182 - & (token >= 0)
183 - & (token < sequence_length)
184 - )
185 - tl.store(
186 - output_ptr + row * stride_output_row + columns * stride_output_column,
187 - tl.where(valid, token, -1),
188 - mask=(row < rows) & (columns < OUTPUT_WIDTH),
189 - )
190 -
11
12 @triton.jit
13 def _qsa_sparse_paged_gqa_splitk_kernel(
@@ -588,227 +408,6 @@ def _compress_qsa_groups_kernel(
408 )
409
410
591 -def _validate_mqa(q: torch.Tensor) -> None:
592 - if q.ndim != 3 or q.shape[1] <= 0 or q.shape[2] <= 0:
593 - raise ValueError("QSA query must be [rows, heads, head_dim]")
594 -
595 -
596 -def qsa_mqa_paged(
597 - q: torch.Tensor,
598 - k_cache: torch.Tensor,
599 - page_table: torch.Tensor,
600 - token_to_req: torch.Tensor,
601 - query_positions: torch.Tensor,
602 - sequence_lengths: torch.Tensor,
603 - compress_ratio: int,
604 - num_columns: int | None = None,
605 - score_scale: float | None = None,
606 -) -> tuple[torch.Tensor, torch.Tensor]:
607 - """Compute QSA scores directly from a paged compressed-key cache."""
608 -
609 - _validate_mqa(q)
610 - if not q.is_cuda or not HAS_TRITON:
611 - raise RuntimeError("paged QSA scoring requires CUDA and Triton")
612 - if k_cache.ndim != 4 or k_cache.shape[2] != 1:
613 - raise ValueError("QSA cache must be [pages, page_size, 1, head_dim]")
614 - if k_cache.shape[3] != q.shape[2]:
615 - raise ValueError("QSA query and cache dimensions must match")
616 - if page_table.ndim != 2:
617 - raise ValueError("QSA page table must be two-dimensional")
618 - if q.shape[0] and (not all(k_cache.shape[:2]) or not all(page_table.shape)):
619 - raise ValueError("QSA paged scoring cache and page table must be nonempty")
620 - if token_to_req.shape != (q.shape[0],):
621 - raise ValueError("QSA request mapping must match query rows")
622 - if query_positions.shape != (q.shape[0],):
623 - raise ValueError("QSA query positions must match query rows")
624 - if sequence_lengths.shape != (page_table.shape[0],):
625 - raise ValueError("QSA sequence lengths must match page-table requests")
626 - if compress_ratio <= 0:
627 - raise ValueError("QSA compression ratio must be positive")
628 - score_divisor = math.sqrt(q.shape[2]) if score_scale is None else score_scale
629 - if score_divisor <= 0:
630 - raise ValueError("QSA score scale must be positive")
631 -
632 - capacity = page_table.shape[1] * k_cache.shape[1]
633 - columns = capacity if num_columns is None else num_columns
634 - if columns < 0:
635 - raise ValueError("QSA score width must be non-negative")
636 - logits = torch.empty((q.shape[0], columns), dtype=torch.float32, device=q.device)
637 - visible_blocks = torch.empty(q.shape[0], dtype=torch.int32, device=q.device)
638 - if not q.shape[0] or not columns:
639 - return logits, visible_blocks
640 - BLOCK_N = 64
641 - BLOCK_D = max(16, triton.next_power_of_2(q.shape[2]))
642 - MAX_N = max(16, triton.next_power_of_2(q.shape[1]))
643 - # Tuned on GB300: larger row batches provide enough parallelism to reuse Q.
644 - tiles_per_program = 1 if q.shape[0] <= 32 else 8
645 - _qsa_mqa_paged_kernel[
646 - (q.shape[0], triton.cdiv(columns, BLOCK_N * tiles_per_program))
647 - ](
648 - q,
649 - k_cache,
650 - page_table,
651 - token_to_req,
652 - query_positions,
653 - sequence_lengths,
654 - visible_blocks,
655 - logits,
656 - q.stride(0),
657 - q.stride(1),
658 - q.stride(2),
659 - k_cache.stride(0),
660 - k_cache.stride(1),
661 - k_cache.stride(3),
662 - page_table.stride(0),
663 - page_table.stride(1),
664 - logits.stride(0),
665 - q.shape[0],
666 - columns,
667 - k_cache.shape[0],
668 - page_table.shape[0],
669 - float(score_divisor),
670 - PAGE_SIZE=k_cache.shape[1],
671 - PAGE_TABLE_WIDTH=page_table.shape[1],
672 - NUM_HEADS=q.shape[1],
673 - HEAD_DIM=q.shape[2],
674 - BLOCK_N=BLOCK_N,
675 - BLOCK_D=BLOCK_D,
676 - TILES_PER_PROG=tiles_per_program,
677 - STAGES=2,
678 - MAX_N=MAX_N,
679 - COMPRESS_RATIO=compress_ratio,
680 - num_warps=2,
681 - )
682 - return logits, visible_blocks
683 -
684 -
685 -def expand_qsa_block_indices_cuda(
686 - block_indices: torch.Tensor,
687 - query_positions: torch.Tensor,
688 - sequence_lengths: torch.Tensor,
689 - token_to_req: torch.Tensor,
690 - compress_ratio: int,
691 - token_topk: int,
692 - out: torch.Tensor | None = None,
693 -) -> torch.Tensor:
694 - """Expand compressed blocks and compact the causal tail of the open group."""
695 -
696 - if not block_indices.is_cuda or not HAS_TRITON:
697 - raise RuntimeError("QSA CUDA expansion requires Triton")
698 - if token_topk % compress_ratio:
699 - raise ValueError("QSA token top-k must be divisible by compression ratio")
700 - block_topk = token_topk // compress_ratio
701 - output_width = token_topk + compress_ratio - 1
702 - if block_indices.shape != (query_positions.numel(), block_topk):
703 - raise ValueError("QSA compressed top-k has an invalid shape")
704 - if token_to_req.shape != query_positions.shape:
705 - raise ValueError("QSA request mapping must match query positions")
706 - if sequence_lengths.ndim != 1 or not sequence_lengths.shape[0]:
707 - raise ValueError("QSA request sequence lengths must be nonempty")
708 - if out is None:
709 - out = torch.empty(
710 - (block_indices.shape[0], output_width),
711 - dtype=torch.int32,
712 - device=block_indices.device,
713 - )
714 - elif out.shape != (block_indices.shape[0], output_width):
715 - raise ValueError("QSA expansion output has an invalid shape")
716 - if not block_indices.shape[0]:
717 - return out
718 - column_block = 256
719 - _expand_qsa_indices_kernel[
720 - (block_indices.shape[0], triton.cdiv(output_width, column_block))
721 - ](
722 - block_indices,
723 - query_positions,
724 - sequence_lengths,
725 - token_to_req,
726 - out,
727 - block_indices.stride(0),
728 - block_indices.stride(1),
729 - out.stride(0),
730 - out.stride(1),
731 - block_indices.shape[0],
732 - sequence_lengths.shape[0],
733 - BLOCK_TOPK=block_topk,
734 - COMPRESS_RATIO=compress_ratio,
735 - TOKEN_TOPK=token_topk,
736 - OUTPUT_WIDTH=output_width,
737 - COLUMN_BLOCK=column_block,
738 - num_warps=4,
739 - )
740 - return out
741 -
742 -
743 -def qsa_select_paged_tokens(
744 - q: torch.Tensor,
745 - k_cache: torch.Tensor,
746 - page_table: torch.Tensor,
747 - token_to_req: torch.Tensor,
748 - query_positions: torch.Tensor,
749 - sequence_lengths: torch.Tensor,
750 - token_topk: int,
751 - compress_ratio: int,
752 - out: torch.Tensor | None = None,
753 -) -> torch.Tensor:
754 - """Score, select, and expand QSA indices without host synchronization."""
755 -
756 - rows = q.shape[0]
757 - output_width = token_topk + compress_ratio - 1
758 - if out is None:
759 - out = torch.empty((rows, output_width), dtype=torch.int32, device=q.device)
760 - if out.shape != (rows, output_width):
761 - raise ValueError("QSA selection output has an invalid shape")
762 - if not rows:
763 - return out
764 -
765 - columns = page_table.shape[1] * k_cache.shape[1]
766 - block_topk = token_topk // compress_ratio
767 - rows_per_chunk = max(1, _LOGITS_WORKSPACE_BYTES // max(columns * 4, 1))
768 - chunk_rows = min(rows, rows_per_chunk)
769 - blocks_buffer = torch.empty(
770 - (chunk_rows, block_topk), dtype=torch.int32, device=q.device
771 - )
772 - topk_workspace = torch.empty(
773 - (_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device
774 - )
775 - for row_start in range(0, rows, rows_per_chunk):
776 - row_end = min(row_start + rows_per_chunk, rows)
777 - row_slice = slice(row_start, row_end)
778 - logits, visible_blocks = qsa_mqa_paged(
779 - q[row_slice],
780 - k_cache,
781 - page_table,
782 - token_to_req[row_slice],
783 - query_positions[row_slice],
784 - sequence_lengths,
785 - compress_ratio,
786 - )
787 - blocks = blocks_buffer[: row_end - row_start]
788 - use_cooperative_topk = (
789 - blocks.shape[0] <= 32
790 - and logits.stride(0) % 4 == 0
791 - and current_platform.has_device_capability(90)
792 - and not current_platform.is_device_capability_family(120)
793 - )
794 - topk_op = (
795 - torch.ops._C.cooperative_topk
796 - if use_cooperative_topk
797 - else torch.ops._C.persistent_topk
798 - )
799 - topk_op(logits, visible_blocks, blocks, topk_workspace, block_topk, columns)
800 - expand_qsa_block_indices_cuda(
801 - blocks,
802 - query_positions[row_slice],
803 - sequence_lengths,
804 - token_to_req[row_slice],
805 - compress_ratio,
806 - token_topk,
807 - out[row_slice],
808 - )
809 - return out
810 -
811 -
411 def qsa_sparse_paged_attention(
412 q: torch.Tensor,
413 k_cache: torch.Tensor,
@@ -1106,10 +705,7 @@ def qsa_compress_groups_with_ratio(
705
706
707 __all__ = [
1109 - "expand_qsa_block_indices_cuda",
708 "qsa_compress_groups_with_ratio",
1111 - "qsa_mqa_paged",
1112 - "qsa_select_paged_tokens",
709 "qsa_sparse_paged_attention",
710 "qsa_store_cache_rows",
711 ]
vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py new
+617
@@ -0,0 +1,617 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Triton kernels for Qwen4Exp QSA index selection."""
4 +
5 +import torch
6 +
7 +import vllm.envs as envs
8 +from vllm.model_executor.warmup.jit_warmup_triton_helper import (
9 + TritonWarmupTensor,
10 +)
11 +from vllm.platforms import current_platform
12 +from vllm.triton_utils import tl, triton
13 +
14 +_TOPK_WORKSPACE_BYTES = 1024 * 1024
15 +_DECODE_BLOCK_N = 64
16 +
17 +
18 +@triton.jit
19 +def _qsa_mqa_paged_uniform_kernel(
20 + q_ptr,
21 + k_cache_ptr,
22 + page_table_ptr,
23 + visible_blocks_ptr,
24 + logits_ptr,
25 + stride_q_row,
26 + stride_q_head,
27 + stride_cache_block,
28 + stride_cache_token,
29 + stride_table_req,
30 + stride_logits_row,
31 + PAGE_SIZE: tl.constexpr,
32 + PAGE_TABLE_WIDTH: tl.constexpr,
33 + NUM_HEADS: tl.constexpr,
34 + HEAD_DIM: tl.constexpr,
35 + DECODE_QUERY_LEN: tl.constexpr,
36 + BLOCK_N: tl.constexpr,
37 + TILES_PER_PROG: tl.constexpr,
38 + STAGES: tl.constexpr,
39 +) -> None:
40 + NUM_COLUMNS: tl.constexpr = PAGE_TABLE_WIDTH * PAGE_SIZE
41 + DECODE_QUERY_LEN_PADDED: tl.constexpr = triton.next_power_of_2(DECODE_QUERY_LEN)
42 + NUM_HEADS_PADDED: tl.constexpr = triton.next_power_of_2(NUM_HEADS)
43 + # tl.dot requires a reduction dimension of at least 16.
44 + BLOCK_D: tl.constexpr = max(16, triton.next_power_of_2(HEAD_DIM))
45 + request = tl.program_id(0)
46 + tile_start = tl.program_id(1) * TILES_PER_PROG
47 + query_offsets = tl.arange(0, DECODE_QUERY_LEN_PADDED)
48 + valid_query_offsets = query_offsets < DECODE_QUERY_LEN
49 + rows = request * DECODE_QUERY_LEN + query_offsets
50 + visible = tl.load(
51 + visible_blocks_ptr + rows,
52 + mask=valid_query_offsets,
53 + other=0,
54 + )
55 + max_visible = tl.max(visible, axis=0)
56 + if tile_start * BLOCK_N >= max_visible:
57 + return
58 + tile_end = tl.minimum(tile_start + TILES_PER_PROG, tl.cdiv(max_visible, BLOCK_N))
59 + tile_end = tl.minimum(tile_end, tl.cdiv(NUM_COLUMNS, BLOCK_N))
60 +
61 + dims = tl.arange(0, BLOCK_D)
62 + n = tl.arange(0, DECODE_QUERY_LEN_PADDED * NUM_HEADS_PADDED)
63 + query_offset = n // NUM_HEADS_PADDED
64 + head = n % NUM_HEADS_PADDED
65 + valid_query = (query_offset < DECODE_QUERY_LEN) & (head < NUM_HEADS)
66 + query = tl.load(
67 + q_ptr
68 + + (request * DECODE_QUERY_LEN + query_offset)[None, :] * stride_q_row
69 + + head[None, :] * stride_q_head
70 + + dims[:, None],
71 + mask=valid_query[None, :] & (dims[:, None] < HEAD_DIM),
72 + other=0.0,
73 + )
74 + column_offsets = tl.arange(0, BLOCK_N)
75 + for tile in tl.range(tile_start, tile_end, num_stages=STAGES):
76 + columns = tile * BLOCK_N + column_offsets
77 + live = columns < max_visible
78 + logical_page = tl.minimum(columns // PAGE_SIZE, PAGE_TABLE_WIDTH - 1)
79 + page_offset = columns % PAGE_SIZE
80 + physical_page = tl.load(
81 + page_table_ptr + request * stride_table_req + logical_page,
82 + mask=live,
83 + other=0,
84 + )
85 + keys = tl.load(
86 + k_cache_ptr
87 + + physical_page[:, None].to(tl.int64) * stride_cache_block
88 + + page_offset[:, None] * stride_cache_token
89 + + dims[None, :],
90 + mask=live[:, None] & (dims[None, :] < HEAD_DIM),
91 + other=0.0,
92 + eviction_policy="evict_first",
93 + )
94 + scores = tl.dot(keys, query, out_dtype=tl.float32)
95 + scores = tl.where(valid_query[None, :], tl.maximum(scores, 0.0), 0.0)
96 + scores = tl.reshape(
97 + scores,
98 + (BLOCK_N, DECODE_QUERY_LEN_PADDED, NUM_HEADS_PADDED),
99 + )
100 + score = tl.sum(scores, axis=2) / HEAD_DIM**0.5
101 + tl.store(
102 + logits_ptr + rows[None, :] * stride_logits_row + columns[:, None],
103 + score,
104 + mask=valid_query_offsets[None, :]
105 + & (columns[:, None] < NUM_COLUMNS)
106 + & (columns[:, None] < visible[None, :]),
107 + )
108 +
109 +
110 +@triton.jit(do_not_specialize=["num_rows", "query_offset"])
111 +def _qsa_mqa_paged_prefill_kernel(
112 + q_ptr,
113 + k_cache_ptr,
114 + page_table_ptr,
115 + query_start_loc_ptr,
116 + visible_blocks_ptr,
117 + logits_ptr,
118 + stride_q_row,
119 + stride_q_head,
120 + stride_cache_block,
121 + stride_cache_token,
122 + stride_table_req,
123 + stride_logits_row,
124 + num_rows,
125 + query_offset,
126 + PAGE_SIZE: tl.constexpr,
127 + PAGE_TABLE_WIDTH: tl.constexpr,
128 + NUM_HEADS: tl.constexpr,
129 + HEAD_DIM: tl.constexpr,
130 + TILE_R: tl.constexpr,
131 + BLOCK_N: tl.constexpr,
132 + K_TILES: tl.constexpr,
133 + STAGES: tl.constexpr,
134 +) -> None:
135 + NUM_COLUMNS: tl.constexpr = PAGE_TABLE_WIDTH * PAGE_SIZE
136 + NUM_HEADS_PADDED: tl.constexpr = triton.next_power_of_2(NUM_HEADS)
137 + # tl.dot requires a reduction dimension of at least 16.
138 + BLOCK_D: tl.constexpr = max(16, triton.next_power_of_2(HEAD_DIM))
139 + request = tl.program_id(0)
140 + query_base = tl.load(query_start_loc_ptr)
141 + query_end = query_offset + num_rows
142 + request_start = tl.maximum(
143 + tl.load(query_start_loc_ptr + request) - query_base, query_offset
144 + )
145 + request_end = tl.minimum(
146 + tl.load(query_start_loc_ptr + request + 1) - query_base, query_end
147 + )
148 + absolute_row_start = request_start + tl.program_id(1) * TILE_R
149 + if absolute_row_start >= request_end:
150 + return
151 +
152 + lanes = tl.arange(0, TILE_R)
153 + absolute_rows = absolute_row_start + lanes
154 + rows = absolute_rows - query_offset
155 + valid_rows = absolute_rows < request_end
156 + visible = tl.load(
157 + visible_blocks_ptr + absolute_rows,
158 + mask=valid_rows,
159 + other=0,
160 + )
161 + max_visible = tl.max(visible, axis=0)
162 + k_tile_start = tl.program_id(2) * K_TILES
163 + if k_tile_start * BLOCK_N >= max_visible:
164 + return
165 + k_tile_end = tl.minimum(k_tile_start + K_TILES, tl.cdiv(max_visible, BLOCK_N))
166 + k_tile_end = tl.minimum(k_tile_end, tl.cdiv(NUM_COLUMNS, BLOCK_N))
167 +
168 + dims = tl.arange(0, BLOCK_D)
169 + m = tl.arange(0, TILE_R * NUM_HEADS_PADDED)
170 + q_row_offsets = m // NUM_HEADS_PADDED
171 + q_rows = absolute_row_start + q_row_offsets
172 + heads = m % NUM_HEADS_PADDED
173 + query = tl.load(
174 + q_ptr
175 + + q_rows[None, :] * stride_q_row
176 + + heads[None, :] * stride_q_head
177 + + dims[:, None],
178 + mask=(heads[None, :] < NUM_HEADS)
179 + & (absolute_row_start + q_row_offsets[None, :] < request_end)
180 + & (dims[:, None] < HEAD_DIM),
181 + other=0.0,
182 + )
183 + column_offsets = tl.arange(0, BLOCK_N)
184 + for tile in tl.range(k_tile_start, k_tile_end, num_stages=STAGES):
185 + columns = tile * BLOCK_N + column_offsets
186 + live = columns < max_visible
187 + logical_page = tl.minimum(columns // PAGE_SIZE, PAGE_TABLE_WIDTH - 1)
188 + page_offset = columns % PAGE_SIZE
189 + physical_page = tl.load(
190 + page_table_ptr + request * stride_table_req + logical_page,
191 + mask=live,
192 + other=0,
193 + )
194 + keys = tl.load(
195 + k_cache_ptr
196 + + physical_page[:, None].to(tl.int64) * stride_cache_block
197 + + page_offset[:, None] * stride_cache_token
198 + + dims[None, :],
199 + mask=live[:, None] & (dims[None, :] < HEAD_DIM),
200 + other=0.0,
201 + eviction_policy="evict_first",
202 + )
203 + scores = tl.dot(keys, query, out_dtype=tl.float32)
204 + scores = tl.reshape(scores, (BLOCK_N, TILE_R, NUM_HEADS_PADDED))
205 + score = tl.sum(tl.maximum(scores, 0.0), axis=2) / HEAD_DIM**0.5
206 + store_mask = (
207 + valid_rows[None, :]
208 + & (columns[:, None] < visible[None, :])
209 + & (columns[:, None] < NUM_COLUMNS)
210 + )
211 + tl.store(
212 + logits_ptr + rows[None, :] * stride_logits_row + columns[:, None],
213 + score,
214 + mask=store_mask,
215 + )
216 +
217 +
218 +@triton.jit
219 +def _expand_qsa_indices_kernel(
220 + block_indices_ptr,
221 + query_positions_ptr,
222 + visible_blocks_ptr,
223 + output_ptr,
224 + stride_blocks_row,
225 + stride_blocks_column,
226 + stride_output_row,
227 + stride_output_column,
228 + BLOCK_TOPK: tl.constexpr,
229 + COMPRESS_RATIO: tl.constexpr,
230 + COLUMN_BLOCK: tl.constexpr,
231 +) -> None:
232 + OUTPUT_WIDTH: tl.constexpr = BLOCK_TOPK * COMPRESS_RATIO + COMPRESS_RATIO - 1
233 + row = tl.program_id(0)
234 + columns = tl.program_id(1) * COLUMN_BLOCK + tl.arange(0, COLUMN_BLOCK)
235 + query_position = tl.load(query_positions_ptr + row)
236 + visible_blocks = tl.load(visible_blocks_ptr + row)
237 + complete_blocks = tl.minimum(visible_blocks, BLOCK_TOPK)
238 + expanded_count = complete_blocks * COMPRESS_RATIO
239 + tail_start = ((query_position + 1) // COMPRESS_RATIO) * COMPRESS_RATIO
240 + tail_count = (query_position + 1) - tail_start
241 +
242 + is_expanded = columns < expanded_count
243 + block_rank = columns // COMPRESS_RATIO
244 + offset = columns % COMPRESS_RATIO
245 + safe_rank = tl.minimum(block_rank, BLOCK_TOPK - 1)
246 + block = tl.load(
247 + block_indices_ptr + row * stride_blocks_row + safe_rank * stride_blocks_column,
248 + mask=is_expanded,
249 + other=-1,
250 + )
251 + expanded = block * COMPRESS_RATIO + offset
252 + tail_offset = columns - expanded_count
253 + is_tail = (
254 + (columns >= expanded_count)
255 + & (tail_offset < tail_count)
256 + & (tail_offset < COMPRESS_RATIO - 1)
257 + )
258 + token = tl.where(is_expanded, expanded, tail_start + tail_offset)
259 + valid = (columns < OUTPUT_WIDTH) & (is_expanded | is_tail) & (token >= 0)
260 + tl.store(
261 + output_ptr + row * stride_output_row + columns * stride_output_column,
262 + tl.where(valid, token, -1),
263 + mask=columns < OUTPUT_WIDTH,
264 + )
265 +
266 +
267 +def _decode_tiles_per_program(num_requests: int, columns: int) -> int:
268 + programs = num_requests * triton.cdiv(columns, _DECODE_BLOCK_N)
269 + if programs < 16384:
270 + return 1
271 + if programs < 32768:
272 + return 2
273 + if programs < 131072:
274 + return 4
275 + return 8
276 +
277 +
278 +def _qsa_decode_warmup_profiles(
279 + max_dql: int,
280 + max_num_reqs: int,
281 + max_num_batched_tokens: int,
282 + columns: int,
283 +) -> tuple[tuple[int, int], ...]:
284 + profiles: list[tuple[int, int]] = []
285 + for dql in range(1, max_dql + 1):
286 + max_reqs = min(max_num_reqs, max_num_batched_tokens // dql)
287 + requests_by_grouping: dict[int, int] = {}
288 + for num_requests in range(1, max_reqs + 1):
289 + tiles_per_program = _decode_tiles_per_program(num_requests, columns)
290 + requests_by_grouping.setdefault(tiles_per_program, num_requests)
291 + profiles.extend(
292 + (dql, num_requests) for num_requests in requests_by_grouping.values()
293 + )
294 + return tuple(profiles)
295 +
296 +
297 +def warmup_qsa_mqa_paged_decode(
298 + k_cache: torch.Tensor,
299 + page_table: torch.Tensor,
300 + *,
301 + num_heads: int,
302 + head_dim: int,
303 + max_decode_query_len: int,
304 + max_num_reqs: int,
305 + max_num_batched_tokens: int,
306 +) -> tuple[tuple[int, int], ...]:
307 + """Compile every reachable decode specialization without launching it."""
308 +
309 + page_size = k_cache.shape[1]
310 + page_table_width = page_table.shape[1]
311 + columns = page_table_width * page_size
312 + profiles = _qsa_decode_warmup_profiles(
313 + max_decode_query_len,
314 + max_num_reqs,
315 + max_num_batched_tokens,
316 + columns,
317 + )
318 + if not profiles:
319 + return ()
320 +
321 + k_cache_ptr = TritonWarmupTensor(k_cache.dtype, shape=tuple(k_cache.shape))
322 + page_table_ptr = TritonWarmupTensor(
323 + page_table.dtype,
324 + shape=(max_num_reqs, page_table_width),
325 + )
326 + visible_blocks_ptr = TritonWarmupTensor(torch.int32)
327 +
328 + for decode_query_len, num_requests in profiles:
329 + num_rows = decode_query_len * num_requests
330 + q_ptr = TritonWarmupTensor(
331 + torch.bfloat16,
332 + shape=(num_rows, num_heads, head_dim),
333 + )
334 + logits_ptr = TritonWarmupTensor(
335 + torch.float32,
336 + shape=(num_rows, columns),
337 + )
338 + tiles_per_program = _decode_tiles_per_program(num_requests, columns)
339 + _qsa_mqa_paged_uniform_kernel.warmup(
340 + q_ptr,
341 + k_cache_ptr,
342 + page_table_ptr,
343 + visible_blocks_ptr,
344 + logits_ptr,
345 + num_heads * head_dim,
346 + head_dim,
347 + k_cache.stride(0),
348 + k_cache.stride(1),
349 + page_table.stride(0),
350 + columns,
351 + PAGE_SIZE=page_size,
352 + PAGE_TABLE_WIDTH=page_table_width,
353 + NUM_HEADS=num_heads,
354 + HEAD_DIM=head_dim,
355 + DECODE_QUERY_LEN=decode_query_len,
356 + BLOCK_N=_DECODE_BLOCK_N,
357 + TILES_PER_PROG=tiles_per_program,
358 + STAGES=2,
359 + num_warps=2,
360 + grid=(
361 + num_requests,
362 + triton.cdiv(columns, _DECODE_BLOCK_N * tiles_per_program),
363 + ),
364 + )
365 + return profiles
366 +
367 +
368 +def _prefill_logits(
369 + q: torch.Tensor,
370 + k_cache: torch.Tensor,
371 + page_table: torch.Tensor,
372 + query_start_loc: torch.Tensor,
373 + visible_blocks: torch.Tensor,
374 + max_query_len: int,
375 + query_offset: int,
376 + num_queries: int,
377 +) -> torch.Tensor:
378 + assert query_start_loc.shape == (page_table.shape[0] + 1,)
379 + assert visible_blocks.shape == (q.shape[0],)
380 + assert 0 <= query_offset <= query_offset + num_queries <= q.shape[0]
381 +
382 + columns = page_table.shape[1] * k_cache.shape[1]
383 + logits = torch.empty((num_queries, columns), dtype=torch.float32, device=q.device)
384 + TILE_R = 64
385 + BLOCK_N = 64
386 + K_TILES = 16
387 + grid = (
388 + page_table.shape[0],
389 + triton.cdiv(min(num_queries, max_query_len), TILE_R),
390 + triton.cdiv(columns, BLOCK_N * K_TILES),
391 + )
392 + _qsa_mqa_paged_prefill_kernel[grid](
393 + q,
394 + k_cache,
395 + page_table,
396 + query_start_loc,
397 + visible_blocks,
398 + logits,
399 + *q.stride()[:-1],
400 + *k_cache.stride()[:2],
401 + *page_table.stride()[:-1],
402 + *logits.stride()[:-1],
403 + num_queries,
404 + query_offset,
405 + PAGE_SIZE=k_cache.shape[1],
406 + PAGE_TABLE_WIDTH=page_table.shape[1],
407 + NUM_HEADS=q.shape[1],
408 + HEAD_DIM=q.shape[2],
409 + TILE_R=TILE_R,
410 + BLOCK_N=BLOCK_N,
411 + K_TILES=K_TILES,
412 + STAGES=2,
413 + num_warps=4,
414 + )
415 + return logits
416 +
417 +
418 +def expand_qsa_block_indices(
419 + block_indices: torch.Tensor,
420 + query_positions: torch.Tensor,
421 + visible_blocks: torch.Tensor,
422 + compress_ratio: int,
423 + token_topk: int,
424 + out: torch.Tensor,
425 +) -> None:
426 + """Expand compressed blocks and compact the causal tail of the open group."""
427 +
428 + assert token_topk % compress_ratio == 0
429 + block_topk = token_topk // compress_ratio
430 + output_width = token_topk + compress_ratio - 1
431 + assert block_indices.shape == (query_positions.numel(), block_topk)
432 + assert visible_blocks.shape == query_positions.shape
433 + assert out.shape == (block_indices.shape[0], output_width)
434 + column_block = 256
435 + grid = (block_indices.shape[0], triton.cdiv(output_width, column_block))
436 + _expand_qsa_indices_kernel[grid](
437 + block_indices,
438 + query_positions,
439 + visible_blocks,
440 + out,
441 + *block_indices.stride(),
442 + *out.stride(),
443 + BLOCK_TOPK=block_topk,
444 + COMPRESS_RATIO=compress_ratio,
445 + COLUMN_BLOCK=column_block,
446 + num_warps=4,
447 + )
448 +
449 +
450 +def _topk(
451 + logits: torch.Tensor,
452 + visible_blocks: torch.Tensor,
453 + token_topk: int,
454 + compress_ratio: int,
455 + block_indices: torch.Tensor,
456 + topk_workspace: torch.Tensor,
457 +) -> None:
458 + # similar dispatch logic as DeepSeek indexer
459 + block_topk = token_topk // compress_ratio
460 + use_cooperative_topk = (
461 + logits.shape[0] <= 64
462 + and logits.stride(0) % 4 == 0
463 + and current_platform.has_device_capability(90)
464 + and not current_platform.is_device_capability_family(120)
465 + )
466 + topk_op = (
467 + torch.ops._C.cooperative_topk
468 + if use_cooperative_topk
469 + else torch.ops._C.persistent_topk
470 + )
471 + topk_op(
472 + logits,
473 + visible_blocks,
474 + block_indices,
475 + topk_workspace,
476 + block_topk,
477 + logits.shape[1],
478 + )
479 +
480 +
481 +def qsa_select_paged_decode(
482 + q: torch.Tensor,
483 + k_cache: torch.Tensor,
484 + page_table: torch.Tensor,
485 + visible_blocks: torch.Tensor,
486 + token_topk: int,
487 + compress_ratio: int,
488 + decode_query_len: int,
489 + block_indices: torch.Tensor,
490 +) -> None:
491 + """Score and select compressed blocks for a request-major decode batch.
492 +
493 + Args:
494 + q: Query tensor shaped ``[num_requests * decode_query_len, heads,
495 + head_dim]``.
496 + k_cache: Compressed key cache shaped ``[blocks, page_size, 1,
497 + head_dim]``.
498 + page_table: Request block table shaped ``[num_requests, max_pages]``.
499 + visible_blocks: Number of visible compressed blocks per query.
500 + token_topk: Number of logical tokens selected per query.
501 + compress_ratio: Number of logical tokens represented by a cache row.
502 + decode_query_len: Number of query tokens per request.
503 + block_indices: Compressed-index output buffer.
504 + """
505 +
506 + assert token_topk % compress_ratio == 0
507 + assert block_indices.shape == (q.shape[0], token_topk // compress_ratio)
508 + assert decode_query_len > 0 and q.shape[0] % decode_query_len == 0
509 + num_requests = q.shape[0] // decode_query_len
510 + assert page_table.shape[0] == num_requests
511 + assert visible_blocks.shape == (q.shape[0],)
512 +
513 + columns = page_table.shape[1] * k_cache.shape[1]
514 + logits = torch.empty((q.shape[0], columns), dtype=torch.float32, device=q.device)
515 + tiles_per_program = _decode_tiles_per_program(num_requests, columns)
516 + grid = (
517 + num_requests,
518 + triton.cdiv(columns, _DECODE_BLOCK_N * tiles_per_program),
519 + )
520 + _qsa_mqa_paged_uniform_kernel[grid](
521 + q,
522 + k_cache,
523 + page_table,
524 + visible_blocks,
525 + logits,
526 + *q.stride()[:-1],
527 + *k_cache.stride()[:2],
528 + *page_table.stride()[:-1],
529 + *logits.stride()[:-1],
530 + PAGE_SIZE=k_cache.shape[1],
531 + PAGE_TABLE_WIDTH=page_table.shape[1],
532 + NUM_HEADS=q.shape[1],
533 + HEAD_DIM=q.shape[2],
534 + DECODE_QUERY_LEN=decode_query_len,
535 + BLOCK_N=_DECODE_BLOCK_N,
536 + TILES_PER_PROG=tiles_per_program,
537 + STAGES=2,
538 + num_warps=2,
539 + )
540 + _topk(
541 + logits,
542 + visible_blocks,
543 + token_topk,
544 + compress_ratio,
545 + block_indices,
546 + torch.empty((_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device),
547 + )
548 +
549 +
550 +def qsa_select_paged_prefill(
551 + q: torch.Tensor,
552 + k_cache: torch.Tensor,
553 + page_table: torch.Tensor,
554 + query_start_loc: torch.Tensor,
555 + visible_blocks: torch.Tensor,
556 + token_topk: int,
557 + compress_ratio: int,
558 + max_query_len: int,
559 + block_indices: torch.Tensor,
560 +) -> None:
561 + """Score and select compressed prefill blocks in bounded chunks.
562 +
563 + Args:
564 + q: Packed prefill query tensor shaped ``[num_tokens, heads, head_dim]``.
565 + k_cache: Compressed key cache shaped ``[blocks, page_size, 1,
566 + head_dim]``.
567 + page_table: Block table shaped ``[num_requests, max_pages]``.
568 + query_start_loc: Packed prefill query offsets with a terminal offset.
569 + Offsets may share the base of a larger backing tensor.
570 + visible_blocks: Number of visible compressed blocks per query.
571 + token_topk: Number of logical tokens selected per query.
572 + compress_ratio: Number of logical tokens represented by a cache row.
573 + max_query_len: Maximum number of query tokens in one request.
574 + block_indices: Compressed-index output buffer.
575 + """
576 +
577 + assert token_topk % compress_ratio == 0
578 + assert block_indices.shape == (q.shape[0], token_topk // compress_ratio)
579 + rows = q.shape[0]
580 + columns = page_table.shape[1] * k_cache.shape[1]
581 +
582 + # chunk the inputs to keep temp logits below VLLM_SPARSE_INDEXER_MAX_LOGITS_MB
583 + max_logits_bytes = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
584 + rows_per_chunk = max(1, max_logits_bytes // (columns * 4))
585 + topk_workspace = torch.empty(
586 + (_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device
587 + )
588 +
589 + for query_start in range(0, rows, rows_per_chunk):
590 + query_end = min(query_start + rows_per_chunk, rows)
591 + query_slice = slice(query_start, query_end)
592 + logits = _prefill_logits(
593 + q,
594 + k_cache,
595 + page_table,
596 + query_start_loc,
597 + visible_blocks,
598 + max_query_len,
599 + query_offset=query_start,
600 + num_queries=query_end - query_start,
601 + )
602 + _topk(
603 + logits,
604 + visible_blocks[query_slice],
605 + token_topk,
606 + compress_ratio,
607 + block_indices[query_slice],
608 + topk_workspace,
609 + )
610 +
611 +
612 +__all__ = [
613 + "expand_qsa_block_indices",
614 + "qsa_select_paged_decode",
615 + "qsa_select_paged_prefill",
616 + "warmup_qsa_mqa_paged_decode",
617 +]