[Qwen3.8-Flash-Next] Improve QSA sparse GQA for prefill and short-ctx decode (#54873)
Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg> Co-authored-by: Kimi <noreply@moonshot.cn>
Thien Tran committed
Sep 4, 2026 at 21:08 UTC
31a8a266622781917cef482d01db415cfa3cbd92
6 files changed
+366
-107
tests/models/qwen4_exp/test_qsa_reference.py
+65
-26
@@ -754,7 +754,10 @@ def test_qsa_block_expansion_correctness() -> None:
754
sequence_lengths.index_select(0, token_to_req.long()) // 4,
755
).to(torch.int32)
756
757
- actual = torch.empty((2, 11), device="cuda", dtype=torch.int32)
757
+ # Packed layout: one trailing column per row holds the valid-entry count
758
+ # (never a token index). Row 0: 1 visible block + 2 tail; row 1: 2 blocks
759
+ # + 3 tail.
760
+ actual = torch.empty((2, 12), device="cuda", dtype=torch.int32)
761
qsa_indexer_ops.expand_qsa_block_indices(
762
blocks,
763
query_positions,
@@ -771,19 +774,35 @@ def test_qsa_block_expansion_correctness() -> None:
774
token_topk=8,
775
)
776
774
- torch.testing.assert_close(actual, expected)
777
+ torch.testing.assert_close(actual[:, :11], expected)
778
+ assert actual[:, 11].tolist() == [6, 11]
779
780
781
@requires_qsa_kernels
782
@pytest.mark.parametrize(
779
- ("num_rows", "num_query_heads", "num_kv_heads", "page_size"),
783
+ (
784
+ "num_rows",
785
+ "num_query_heads",
786
+ "num_kv_heads",
787
+ "page_size",
788
+ "use_prefill_config",
789
+ "num_requests",
790
+ ),
791
[
781
- # Kernel-visible pages with --block-size 256 and hybrid-cache alignment.
782
- pytest.param(1, 24, 2, 1792, id="tp1_split64"),
783
- pytest.param(16, 12, 1, 1792, id="tp2_split32"),
784
- pytest.param(32, 6, 1, 1024, id="tp4_split8"),
785
- pytest.param(257, 6, 1, 1024, id="tp4_split4"),
786
- pytest.param(513, 6, 1, 1024, id="tp4_split1"),
792
+ # Production page sizes from hybrid-cache block alignment: 784/800
793
+ # at TP4 and 1568/1600 at TP1/TP2 (no-MTP / MTP num_spec=3). Head
794
+ # splits are per-rank TP1/TP2/TP4; the largest batch runs both
795
+ # use_prefill_config variants.
796
+ pytest.param(1, 24, 2, 1600, True, 2, id="tp1_r1"),
797
+ pytest.param(16, 12, 1, 1600, True, 3, id="tp2_r16"),
798
+ pytest.param(32, 6, 1, 800, True, 5, id="tp4_r32"),
799
+ pytest.param(128, 24, 2, 1568, True, 7, id="tp1_r128"),
800
+ pytest.param(257, 6, 1, 800, True, 13, id="tp4_r257"),
801
+ pytest.param(513, 6, 1, 784, True, 17, id="tp4_r513"),
802
+ pytest.param(700, 6, 1, 800, True, 23, id="tp4_r700"),
803
+ pytest.param(1024, 24, 2, 1600, True, 33, id="tp1_r1024"),
804
+ pytest.param(2048, 24, 2, 1600, True, 63, id="tp1_r2048_prefill"),
805
+ pytest.param(2048, 24, 2, 1600, False, 63, id="tp1_r2048_uniform"),
806
],
807
)
808
def test_qsa_sparse_paged_attention_correctness(
@@ -791,10 +810,11 @@ def test_qsa_sparse_paged_attention_correctness(
810
num_query_heads: int,
811
num_kv_heads: int,
812
page_size: int,
813
+ use_prefill_config: bool,
814
+ num_requests: int,
815
) -> None:
816
torch.manual_seed(2)
817
head_dim = 256
797
- num_requests = 2
818
num_selected_pages = 64
819
# Keep the newest page outside the synthetic top-k as causal headroom.
820
num_pages_per_request = num_selected_pages + 1
@@ -822,13 +842,22 @@ def test_qsa_sparse_paged_attention_correctness(
842
rows_per_request = math.ceil(num_rows / num_requests)
843
row_indices = torch.arange(num_rows, device="cuda", dtype=torch.int32)
844
token_to_req = row_indices // rows_per_request
825
- request_row_counts = torch.tensor(
826
- [rows_per_request, num_rows - rows_per_request],
845
+ # Uniform row split; the last request takes the remainder (possibly 0).
846
+ request_row_counts = torch.full(
847
+ (num_requests,), rows_per_request, device="cuda", dtype=torch.int32
848
+ )
849
+ request_row_counts[-1] = num_rows - rows_per_request * (num_requests - 1)
850
+
851
+ # Mix context lengths: every third request is short-context, attending
852
+ # to only its first few pages; the rest fill their cache.
853
+ context_lengths = torch.full(
854
+ (num_requests,),
855
+ num_pages_per_request * page_size - 1,
856
device="cuda",
857
dtype=torch.int32,
858
)
830
-
831
- context_length = num_pages_per_request * page_size - 1
859
+ short_requests = torch.arange(num_requests, device="cuda") % 3 == 1
860
+ context_lengths[short_requests] = request_row_counts[short_requests] + 8
861
block_topk = indexer_budget // indexer_compress_ratio
862
compressed_blocks_per_page = page_size // indexer_compress_ratio
863
selection = torch.arange(block_topk, device="cuda")
@@ -842,17 +871,19 @@ def test_qsa_sparse_paged_attention_correctness(
871
)
872
rows_within_request = row_indices % rows_per_request
873
query_positions = (
845
- context_length - request_row_counts[token_to_req.long()] + rows_within_request
874
+ context_lengths[token_to_req.long()]
875
+ - request_row_counts[token_to_req.long()]
876
+ + rows_within_request
877
).to(torch.int64)
847
- sequence_lengths = torch.full(
848
- (num_requests,), context_length, device="cuda", dtype=torch.int32
849
- )
878
+ sequence_lengths = context_lengths
879
visible_blocks = torch.minimum(
880
(query_positions + 1) // indexer_compress_ratio,
881
sequence_lengths.index_select(0, token_to_req.long()) // indexer_compress_ratio,
882
).to(torch.int32)
883
+ # +1: the packed trailing column holds each row's valid-entry count
884
+ # (never a token index); the reference reads only the selection region.
885
logical_indices = torch.empty(
855
- (num_rows, selection_width), device="cuda", dtype=torch.int32
886
+ (num_rows, selection_width + 1), device="cuda", dtype=torch.int32
887
)
888
qsa_indexer_ops.expand_qsa_block_indices(
889
block_indices,
@@ -862,7 +893,7 @@ def test_qsa_sparse_paged_attention_correctness(
893
indexer_budget,
894
logical_indices,
895
)
865
- assert logical_indices.shape == (num_rows, selection_width)
896
+ assert logical_indices.shape == (num_rows, selection_width + 1)
897
scale = q.shape[-1] ** -0.5
898
899
actual = qsa_ops.qsa_sparse_paged_attention(
@@ -872,12 +903,13 @@ def test_qsa_sparse_paged_attention_correctness(
903
logical_indices,
904
block_table,
905
token_to_req,
906
+ use_prefill_config=use_prefill_config,
907
)
908
expected = _qsa_sparse_paged_attention_reference(
909
q,
910
k_cache,
911
v_cache,
880
- logical_indices,
912
+ logical_indices[:, :selection_width],
913
block_table,
914
token_to_req,
915
scale,
@@ -948,8 +980,10 @@ def test_qsa_split_selection_correctness(workspace_init, decode_query_len: int)
980
block_indices[prefill_slice],
981
max_seq_len=sequence_lengths.max().item(),
982
)
983
+ # +1: the packed trailing count column (never a token index; excluded
984
+ # from the comparison).
985
actual = torch.empty(
952
- (rows, token_topk + compress_ratio - 1), device="cuda", dtype=torch.int32
986
+ (rows, token_topk + compress_ratio), device="cuda", dtype=torch.int32
987
)
988
qsa_indexer_ops.expand_qsa_block_indices(
989
block_indices,
@@ -977,7 +1011,10 @@ def test_qsa_split_selection_correctness(workspace_init, decode_query_len: int)
1011
token_topk,
1012
)
1013
980
- torch.testing.assert_close(actual.sort().values, expected.sort().values)
1014
+ torch.testing.assert_close(
1015
+ actual[:, : token_topk + compress_ratio - 1].sort().values,
1016
+ expected.sort().values,
1017
+ )
1018
1019
1020
@requires_qsa_kernels
@@ -1001,7 +1038,7 @@ def test_qsa_selection_handles_no_complete_compressed_blocks(workspace_init) ->
1038
block_indices=block_indices,
1039
max_seq_len=64, # clamps to the page-table capacity
1040
)
1004
- selected = torch.empty((2, 2051), device="cuda", dtype=torch.int32)
1041
+ selected = torch.empty((2, 2052), device="cuda", dtype=torch.int32)
1042
qsa_indexer_ops.expand_qsa_block_indices(
1043
block_indices,
1044
query_positions,
@@ -1013,8 +1050,10 @@ def test_qsa_selection_handles_no_complete_compressed_blocks(workspace_init) ->
1050
1051
assert selected[0, :2].tolist() == [0, 1]
1052
assert selected[1, :3].tolist() == [0, 1, 2]
1016
- assert torch.all(selected[0, 2:] == -1)
1017
- assert torch.all(selected[1, 3:] == -1)
1053
+ assert torch.all(selected[0, 2:2051] == -1)
1054
+ assert torch.all(selected[1, 3:2051] == -1)
1055
+ # The packed trailing column holds each row's valid-entry count.
1056
+ assert selected[:, 2051].tolist() == [2, 3]
1057
1058
1059
@requires_qsa_kernels
vllm/model_executor/warmup/qwen4_exp_qsa_warmup.py
+47
-24
@@ -5,6 +5,8 @@
5
import sys
6
from typing import TYPE_CHECKING, cast
7
8
+import torch
9
+
10
from vllm.logger import init_logger
11
12
if TYPE_CHECKING:
@@ -15,37 +17,41 @@ logger = init_logger(__name__)
17
18
19
def qwen4_exp_qsa_triton_warmup(worker: "Worker") -> None:
18
- """Warm every reachable QSA decode-query-length specialization."""
20
+ """Warm every reachable QSA specialization: indexer decode-query-length
21
+ profiles plus the sparse attention split-K/merge configs."""
22
23
qsa_module = sys.modules.get("vllm.models.qwen4_exp.nvidia.indexer_qsa")
21
- if qsa_module is None:
24
+ attn_module = sys.modules.get("vllm.models.qwen4_exp.nvidia.qsa")
25
+ if qsa_module is None or attn_module is None:
26
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:
27
+ indexer = None
28
+ owner = None
29
+ for layer in worker.get_model().modules():
30
+ if indexer is None and isinstance(layer, qsa_module.QSAIndexer):
31
+ indexer = layer
32
+ elif owner is None and isinstance(layer, attn_module.Qwen4ExpQSAAttention):
33
+ owner = layer
34
+ if indexer is None or owner is None:
35
return
36
37
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(
38
+
39
+ def block_table_for(prefix: str) -> torch.Tensor:
40
+ group_id = next(
41
+ i
42
+ for i, group in enumerate(runner.kv_cache_config.kv_cache_groups)
43
+ if prefix in group.layer_names
44
+ )
45
+ if worker.use_v2_model_runner:
46
+ runner_v2 = cast("GPUModelRunnerV2", runner)
47
+ return runner_v2.block_tables.input_block_tables[group_id]
48
+ return runner.input_batch.block_table[group_id].get_device_tensor(
49
runner.max_num_reqs
50
)
51
+
52
+ if worker.use_v2_model_runner:
53
+ max_decode_query_len = cast("GPUModelRunnerV2", runner).decode_query_len
54
+ else:
55
max_decode_query_len = runner.uniform_decode_query_len
56
57
from vllm.models.qwen4_exp.nvidia.ops.qsa_indexer import (
@@ -56,7 +62,7 @@ def qwen4_exp_qsa_triton_warmup(worker: "Worker") -> None:
62
assert k_cache.numel()
63
profiles = warmup_qsa_mqa_paged_decode(
64
k_cache,
59
- block_table,
65
+ block_table_for(indexer.compressed_key_cache.prefix),
66
num_heads=indexer.index_n_heads,
67
head_dim=indexer.index_head_dim,
68
max_decode_query_len=max_decode_query_len,
@@ -64,3 +70,20 @@ def qwen4_exp_qsa_triton_warmup(worker: "Worker") -> None:
70
max_num_batched_tokens=runner.max_num_tokens,
71
)
72
logger.info("Warmed up Qwen4Exp QSA decode kernels: %s.", profiles)
73
+
74
+ from vllm.models.qwen4_exp.nvidia.ops.qsa import (
75
+ warmup_qsa_sparse_paged_attention,
76
+ )
77
+
78
+ kv_cache = owner.kv_cache
79
+ assert kv_cache.numel()
80
+ attention_profiles = warmup_qsa_sparse_paged_attention(
81
+ kv_cache,
82
+ block_table_for(owner.layer_name),
83
+ num_query_heads=owner.num_heads,
84
+ selection_width=indexer.output_width,
85
+ )
86
+ logger.info(
87
+ "Warmed up Qwen4Exp QSA sparse attention kernels: %s.",
88
+ attention_profiles,
89
+ )
vllm/models/qwen4_exp/nvidia/indexer_qsa.py
+24
-4
@@ -167,8 +167,19 @@ class QSAIndexer(nn.Module):
167
168
@property
169
def output_width(self) -> int:
170
+ """Selection (index) columns per row."""
171
return self.token_topk + self.compress_ratio - 1
172
173
+ @property
174
+ def packed_output_width(self) -> int:
175
+ """Packed selection-buffer width: output_width + 1.
176
+
177
+ The trailing column holds each row's valid-entry count (written by
178
+ the expand kernel) — never a token index; the sparse attention
179
+ kernel reads it as its tile-loop bound.
180
+ """
181
+ return self.output_width + 1
182
+
183
def _metadata(
184
self,
185
) -> tuple[QSAForwardMetadata, QSAForwardMetadata] | None:
@@ -211,7 +222,14 @@ class QSAIndexer(nn.Module):
222
positions: torch.Tensor,
223
out: torch.Tensor | None = None,
224
) -> torch.Tensor:
214
- """Return fixed-width request-relative token indices padded with ``-1``."""
225
+ """Select each query row's token indices.
226
+
227
+ Returns the packed buffer of shape [num_tokens, output_width + 1]:
228
+ the leading ``output_width`` columns are ``-1``-padded
229
+ request-relative token indices, and the trailing column is the row's
230
+ valid-entry count (the attention kernel's loop bound, never a token
231
+ index).
232
+ """
233
234
metadata = self._metadata()
235
if metadata is None:
@@ -219,11 +237,13 @@ class QSAIndexer(nn.Module):
237
if self.skip_topk and out is not None:
238
return out
239
result = torch.full(
222
- (hidden_states.shape[0], self.output_width),
240
+ (hidden_states.shape[0], self.packed_output_width),
241
-1,
242
dtype=torch.int32,
243
device=hidden_states.device,
244
)
245
+ # Inert rows carry a zero valid count (empty loop bound), not -1.
246
+ result[:, -1] = 0
247
if out is not None:
248
out.copy_(result)
249
return out
@@ -357,11 +377,11 @@ class QSAIndexer(nn.Module):
377
if out is None:
378
out = torch.empty(
379
num_tokens,
360
- self.output_width,
380
+ self.packed_output_width,
381
dtype=torch.int32,
382
device=q.device,
383
)
364
- elif out.shape != (num_tokens, self.output_width):
384
+ elif out.shape != (num_tokens, self.packed_output_width):
385
raise ValueError("QSA selection output has an invalid shape")
386
387
num_decode_tokens = compressed_metadata.num_decode_tokens
vllm/models/qwen4_exp/nvidia/ops/qsa.py
+202
-44
@@ -6,10 +6,14 @@ from __future__ import annotations
6
7
import torch
8
9
+from vllm.model_executor.warmup.jit_warmup_triton_helper import (
10
+ TritonWarmupTensor,
11
+ triton_scalar_specialization_rep,
12
+)
13
from vllm.triton_utils import HAS_TRITON, tl, triton
14
15
12
-@triton.jit
16
+@triton.jit(do_not_specialize=["num_rows", "num_requests"])
17
def _qsa_sparse_paged_gqa_splitk_kernel(
18
q_ptr,
19
k_cache_ptr,
@@ -52,6 +56,12 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
56
request = tl.load(token_to_req_ptr + row)
57
safe_request = tl.minimum(tl.maximum(request, 0), num_requests - 1)
58
59
+ # The packed selection buffer carries one TRAILING COUNT COLUMN per row
60
+ # (column TOPK of a TOPK+1-wide buffer): the row's valid-entry count,
61
+ # written by the expand kernel. It is never a token index — the tile loop
62
+ # and the index load below only ever cover columns [0, TOPK).
63
+ valid_count = tl.load(indices_ptr + row * stride_indices_row + TOPK)
64
+
65
head_offsets = tl.arange(0, BLOCK_M)
66
dim_offsets = tl.arange(0, HEAD_DIM)
67
column_offsets = tl.arange(0, BLOCK_N)
@@ -70,10 +80,9 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
80
accumulator = tl.zeros((BLOCK_M, HEAD_DIM), dtype=tl.float32)
81
softmax_scale_log2: tl.constexpr = (HEAD_DIM**-0.5) * 1.4426950408889634
82
73
- # Dynamic bounds avoid padded main-loop iterations for uneven splits.
74
- split_tile_start = split_id * NUM_TILES // NUM_SPLITS
75
- split_tile_end = (split_id + 1) * NUM_TILES // NUM_SPLITS
76
- for tile in range(split_tile_start, split_tile_end):
83
+ tile_end = tl.minimum(NUM_TILES, tl.cdiv(tl.minimum(valid_count, TOPK), BLOCK_N))
84
+
85
+ for tile in range(split_id, tile_end, NUM_SPLITS):
86
columns = tile * BLOCK_N + column_offsets
87
logical_token = tl.load(
88
indices_ptr + row * stride_indices_row + columns,
@@ -156,29 +165,23 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
165
max_value + tl.math.log2(tl.maximum(normalizer, 1.0e-20)),
166
-float("inf"),
167
)
168
+ partial_row = (split_id * num_rows + row).to(tl.int64)
169
tl.store(
170
partial_output_ptr
161
- + (
162
- (split_id * num_rows + row) * NUM_QUERY_HEADS
163
- + first_head
164
- + head_offsets[:, None]
165
- )
171
+ + (partial_row * NUM_QUERY_HEADS + first_head + head_offsets[:, None])
172
* HEAD_DIM
173
+ dim_offsets[None, :],
174
normalized_output,
175
mask=output_mask,
176
)
177
tl.store(
172
- partial_lse_ptr
173
- + (split_id * num_rows + row) * NUM_QUERY_HEADS
174
- + first_head
175
- + head_offsets,
178
+ partial_lse_ptr + partial_row * NUM_QUERY_HEADS + first_head + head_offsets,
179
partial_lse,
180
mask=head_offsets < GROUP_SIZE,
181
)
182
183
181
-@triton.jit
184
+@triton.jit(do_not_specialize=["num_rows"])
185
def _qsa_merge_splitk_kernel(
186
partial_output_ptr,
187
partial_lse_ptr,
@@ -206,10 +209,10 @@ def _qsa_merge_splitk_kernel(
209
shifted = tl.where(split_mask & has_values, lse - lse_max, -float("inf"))
210
weights = tl.math.exp2(shifted)
211
denominator = tl.sum(weights, axis=0)
212
+ split_rows = split_offsets.to(tl.int64) * num_rows + row
213
partial_output = tl.load(
214
partial_output_ptr
211
- + ((split_offsets[:, None] * num_rows + row) * NUM_QUERY_HEADS + head)
212
- * HEAD_DIM
215
+ + (split_rows[:, None] * NUM_QUERY_HEADS + head) * HEAD_DIM
216
+ dim_offsets[None, :],
217
mask=split_mask[:, None],
218
other=0.0,
@@ -408,6 +411,41 @@ def _compress_qsa_groups_kernel(
411
)
412
413
414
+def _select_config(
415
+ num_rows: int, num_kv_heads: int, use_prefill_config: bool, num_columns: int
416
+) -> tuple[int, int, int, int]:
417
+ """Select (block_n, num_warps, num_tiles, num_splits) for the kernel.
418
+
419
+ Tuned on GB300 for the Qwen3.8-Flash-Next TP1/TP2/TP4 shapes, keyed on
420
+ base_programs = num_rows * num_kv_heads. The bp > 2048 region splits on
421
+ use_prefill_config (capture-stable: at FULL-graph capture max_query_len is the
422
+ uniform decode/verify length).
423
+ """
424
+ base_programs = num_rows * num_kv_heads
425
+ if base_programs > 2048:
426
+ BLOCK_N, target_splits, num_warps = (
427
+ (32, 1, 1) if use_prefill_config else (64, 1, 2)
428
+ )
429
+ elif base_programs <= 24:
430
+ BLOCK_N, target_splits, num_warps = 32, 64, 4
431
+ elif base_programs <= 32:
432
+ BLOCK_N, target_splits, num_warps = 32, 16, 1
433
+ elif base_programs <= 64:
434
+ BLOCK_N, target_splits, num_warps = 32, 8, 1
435
+ elif base_programs <= 128:
436
+ BLOCK_N, target_splits, num_warps = 32, 4, 1
437
+ elif base_programs <= 256:
438
+ BLOCK_N, target_splits, num_warps = 32, 8, 1
439
+ elif base_programs <= 512:
440
+ BLOCK_N, target_splits, num_warps = 64, 4, 2
441
+ else:
442
+ BLOCK_N, target_splits, num_warps = 64, 1, 2
443
+ num_tiles = triton.cdiv(num_columns, BLOCK_N)
444
+ # Never more splits than tiles, never empty.
445
+ num_splits = min(target_splits, num_tiles)
446
+ return BLOCK_N, num_warps, num_tiles, num_splits
447
+
448
+
449
def qsa_sparse_paged_attention(
450
q: torch.Tensor,
451
k_cache: torch.Tensor,
@@ -415,12 +453,17 @@ def qsa_sparse_paged_attention(
453
logical_indices: torch.Tensor,
454
block_table: torch.Tensor,
455
token_to_req: torch.Tensor,
456
+ use_prefill_config: bool,
457
out: torch.Tensor | None = None,
458
) -> torch.Tensor:
420
- """Run sparse GQA directly over paged BF16 K/V caches."""
421
-
422
- if not q.is_cuda or not HAS_TRITON:
423
- raise RuntimeError("paged QSA sparse attention requires CUDA and Triton")
459
+ """Run sparse GQA directly over paged BF16 K/V caches.
460
+
461
+ logical_indices is the PACKED selection buffer: [rows, selection_width + 1]
462
+ with the trailing column holding each row's valid-entry count (written by
463
+ the expand kernel; never a token index). The kernel reads it as the
464
+ tile-loop bound. use_prefill_config only steers the top of the config table; see
465
+ _select_config.
466
+ """
467
if q.ndim != 3 or k_cache.ndim != 4 or v_cache.shape != k_cache.shape:
468
raise ValueError("QSA sparse attention received invalid Q/K/V shapes")
469
if logical_indices.ndim != 2 or logical_indices.shape[0] != q.shape[0]:
@@ -429,8 +472,10 @@ def qsa_sparse_paged_attention(
472
raise ValueError("QSA sparse attention metadata has invalid shapes")
473
if not all(k_cache.shape[:3]) or not all(block_table.shape):
474
raise ValueError("QSA sparse attention cache and block table must be nonempty")
432
- if logical_indices.shape[1] <= 0:
433
- raise ValueError("QSA sparse attention requires a positive selection width")
475
+ if logical_indices.shape[1] < 2:
476
+ raise ValueError(
477
+ "QSA packed indices need selection columns plus the count column"
478
+ )
479
if q.shape[2] != k_cache.shape[3] or q.shape[1] % k_cache.shape[2]:
480
raise ValueError("QSA sparse attention requires valid grouped-query heads")
481
head_dim = q.shape[2]
@@ -444,6 +489,7 @@ def qsa_sparse_paged_attention(
489
assert q.stride(2) == k_cache.stride(3) == v_cache.stride(3) == 1
490
assert logical_indices.stride(1) == block_table.stride(1) == 1
491
assert token_to_req.stride(0) == 1
492
+
493
if out is None:
494
out = torch.empty_like(q)
495
if out.shape != q.shape:
@@ -455,26 +501,10 @@ def qsa_sparse_paged_attention(
501
502
group_size = q.shape[1] // k_cache.shape[2]
503
block_m = triton.next_power_of_2(group_size)
458
- base_programs = q.shape[0] * k_cache.shape[2]
459
- small_profile_limit = 8 if block_m <= 8 else 4
460
-
461
- # Tuned on GB300 for the Qwen-Air TP1, TP2, and TP4 attention shapes.
462
- # Narrow tiles favor decode; wide tiles improve throughput for prefill.
463
- if base_programs <= small_profile_limit:
464
- block_n, target_splits, partial_warps = 16, 64, 4
465
- elif base_programs < 32:
466
- block_n, target_splits, partial_warps = 16, 32, 4
467
- elif base_programs <= 256:
468
- block_n, target_splits, partial_warps = 64, 8, 2
469
- elif base_programs <= 512:
470
- block_n, target_splits, partial_warps = 64, 4, 2
471
- else:
472
- block_n, target_splits, partial_warps = 64, 1, 2
473
-
474
- num_tiles = triton.cdiv(logical_indices.shape[1], block_n)
475
- # Avoid empty splits when the selection width is smaller than the profile.
476
- max_useful_splits = 1 << (num_tiles.bit_length() - 1)
477
- num_splits = min(max_useful_splits, target_splits)
504
+ selection_width = logical_indices.shape[1] - 1 # trailing column is the count
505
+ block_n, partial_warps, num_tiles, num_splits = _select_config(
506
+ q.shape[0], k_cache.shape[2], use_prefill_config, selection_width
507
+ )
508
509
# Split=1 writes output directly and compiles out all workspace accesses.
510
if num_splits == 1:
@@ -518,7 +548,7 @@ def qsa_sparse_paged_attention(
548
q.shape[0],
549
k_cache.shape[0],
550
block_table.shape[0],
521
- TOPK=logical_indices.shape[1],
551
+ TOPK=selection_width,
552
PAGE_SIZE=k_cache.shape[1],
553
PAGE_TABLE_WIDTH=block_table.shape[1],
554
GROUP_SIZE=group_size,
@@ -551,6 +581,133 @@ def qsa_sparse_paged_attention(
581
return out
582
583
584
+def warmup_qsa_sparse_paged_attention(
585
+ kv_cache: torch.Tensor,
586
+ block_table: torch.Tensor,
587
+ *,
588
+ num_query_heads: int,
589
+ selection_width: int,
590
+) -> tuple[tuple[int, int, int], ...]:
591
+ """Compile every production-reachable split-K/merge specialization."""
592
+
593
+ head_dim = kv_cache.shape[-1] // 2
594
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(head_dim, dim=-1)
595
+ num_kv_heads = key_cache.shape[2]
596
+ group_size = num_query_heads // num_kv_heads
597
+ block_m = triton.next_power_of_2(group_size)
598
+
599
+ # Every config the dispatch can pick for this group size.
600
+ profiles = {
601
+ _select_config(num_rows, num_kv_heads, use_prefill_config, selection_width)
602
+ for num_rows in range(1, 8193)
603
+ for use_prefill_config in (False, True)
604
+ }
605
+
606
+ # Scalars constant per deployment get their real values (their divisibility
607
+ # specialization is wanted); the batch-varying ones are do_not_specialize'd
608
+ # on the kernels, so any value here compiles the only variant.
609
+ num_rows = 16
610
+ num_requests = 16
611
+ q_ptr = TritonWarmupTensor(
612
+ torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
613
+ )
614
+ k_cache_ptr = TritonWarmupTensor(
615
+ key_cache.dtype,
616
+ shape=tuple(key_cache.shape),
617
+ strides=tuple(key_cache.stride()),
618
+ )
619
+ v_cache_ptr = TritonWarmupTensor(
620
+ value_cache.dtype,
621
+ shape=tuple(value_cache.shape),
622
+ strides=tuple(value_cache.stride()),
623
+ )
624
+ # +1: the packed buffer's trailing count column.
625
+ indices_ptr = TritonWarmupTensor(torch.int32, shape=(num_rows, selection_width + 1))
626
+ block_table_ptr = TritonWarmupTensor(
627
+ block_table.dtype,
628
+ shape=tuple(block_table.shape),
629
+ strides=tuple(block_table.stride()),
630
+ )
631
+ token_to_req_ptr = TritonWarmupTensor(torch.int32)
632
+ output_ptr = TritonWarmupTensor(
633
+ torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
634
+ )
635
+ head_stride = head_dim
636
+ row_stride = num_query_heads * head_dim
637
+ num_cache_blocks = triton_scalar_specialization_rep(kv_cache.shape[0])
638
+
639
+ warmed = []
640
+ for block_n, warps, num_tiles, num_splits in sorted(profiles):
641
+ if num_splits == 1:
642
+ partial_output_ptr = output_ptr
643
+ partial_lse_ptr = output_ptr
644
+ else:
645
+ partial_output_ptr = TritonWarmupTensor(
646
+ torch.float32,
647
+ shape=(num_splits, num_rows, num_query_heads, head_dim),
648
+ )
649
+ partial_lse_ptr = TritonWarmupTensor(
650
+ torch.float32, shape=(num_splits, num_rows, num_query_heads)
651
+ )
652
+ _qsa_sparse_paged_gqa_splitk_kernel.warmup(
653
+ q_ptr,
654
+ k_cache_ptr,
655
+ v_cache_ptr,
656
+ indices_ptr,
657
+ block_table_ptr,
658
+ token_to_req_ptr,
659
+ partial_output_ptr,
660
+ partial_lse_ptr,
661
+ output_ptr,
662
+ row_stride,
663
+ head_stride,
664
+ key_cache.stride(0),
665
+ key_cache.stride(1),
666
+ key_cache.stride(2),
667
+ value_cache.stride(0),
668
+ value_cache.stride(1),
669
+ value_cache.stride(2),
670
+ selection_width + 1,
671
+ block_table.stride(0),
672
+ row_stride,
673
+ head_stride,
674
+ num_rows,
675
+ num_cache_blocks,
676
+ num_requests,
677
+ TOPK=selection_width,
678
+ PAGE_SIZE=key_cache.shape[1],
679
+ PAGE_TABLE_WIDTH=block_table.shape[1],
680
+ GROUP_SIZE=group_size,
681
+ HEAD_DIM=head_dim,
682
+ NUM_QUERY_HEADS=num_query_heads,
683
+ NUM_SPLITS=num_splits,
684
+ NUM_TILES=num_tiles,
685
+ BLOCK_M=block_m,
686
+ BLOCK_N=block_n,
687
+ num_warps=warps,
688
+ num_stages=2,
689
+ grid=(num_rows, num_kv_heads, num_splits),
690
+ )
691
+ if num_splits > 1:
692
+ _qsa_merge_splitk_kernel.warmup(
693
+ partial_output_ptr,
694
+ partial_lse_ptr,
695
+ output_ptr,
696
+ row_stride,
697
+ head_stride,
698
+ num_rows,
699
+ HEAD_DIM=head_dim,
700
+ NUM_QUERY_HEADS=num_query_heads,
701
+ NUM_SPLITS=num_splits,
702
+ BLOCK_SPLITS=triton.next_power_of_2(num_splits),
703
+ num_warps=2,
704
+ num_stages=1,
705
+ grid=(num_rows, num_query_heads),
706
+ )
707
+ warmed.append((block_n, num_splits, warps))
708
+ return tuple(warmed)
709
+
710
+
711
def qsa_store_cache_rows(
712
cache: torch.Tensor,
713
slot_mapping: torch.Tensor,
@@ -708,4 +865,5 @@ __all__ = [
865
"qsa_compress_groups_with_ratio",
866
"qsa_sparse_paged_attention",
867
"qsa_store_cache_rows",
868
+ "warmup_qsa_sparse_paged_attention",
869
]
vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
+13
-1
@@ -262,6 +262,15 @@ def _expand_qsa_indices_kernel(
262
tl.where(valid, token, -1),
263
mask=columns < OUTPUT_WIDTH,
264
)
265
+ # TRAILING COUNT COLUMN: the packed buffer is OUTPUT_WIDTH+1 wide; the
266
+ # last column holds this row's valid-entry count (expanded blocks plus
267
+ # causal tail). It is NOT a token index — the sparse attention kernel
268
+ # reads it as the row's tile-loop bound.
269
+ if tl.program_id(1) == 0:
270
+ tl.store(
271
+ output_ptr + row * stride_output_row + OUTPUT_WIDTH * stride_output_column,
272
+ expanded_count + tail_count,
273
+ )
274
275
276
def _decode_tiles_per_program(num_requests: int, columns: int) -> int:
@@ -433,7 +442,10 @@ def expand_qsa_block_indices(
442
output_width = token_topk + compress_ratio - 1
443
assert block_indices.shape == (query_positions.numel(), block_topk)
444
assert visible_blocks.shape == query_positions.shape
436
- assert out.shape == (block_indices.shape[0], output_width)
445
+ # +1: the packed buffer's trailing column holds each row's valid-entry
446
+ # count (never a token index); the index region below only writes
447
+ # columns [0, output_width).
448
+ assert out.shape == (block_indices.shape[0], output_width + 1)
449
column_block = 256
450
grid = (block_indices.shape[0], triton.cdiv(output_width, column_block))
451
_expand_qsa_indices_kernel[grid](
vllm/models/qwen4_exp/nvidia/qsa.py
+15
-8
@@ -30,7 +30,6 @@ from vllm.utils.torch_utils import (
30
LayerNameType,
31
_encode_layer_name,
32
_resolve_layer_name,
33
- canonicalize_singleton_dim_strides,
33
direct_register_custom_op,
34
kv_cache_dtype_str_to_dtype,
35
)
@@ -124,6 +123,7 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
123
attn_metadata: FlashAttentionMetadata,
124
output: torch.Tensor,
125
token_to_req: torch.Tensor,
126
+ use_prefill_config: bool,
127
output_scale: torch.Tensor | None = None,
128
output_block_scale: torch.Tensor | None = None,
129
) -> torch.Tensor:
@@ -146,8 +146,6 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
146
logical_indices = topk_buffer[:num_tokens]
147
token_to_req = token_to_req[:num_tokens]
148
key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
149
- key_cache = canonicalize_singleton_dim_strides(key_cache)
150
- value_cache = canonicalize_singleton_dim_strides(value_cache)
149
if key_cache.dtype != torch.bfloat16 or query.dtype != torch.bfloat16:
150
raise NotImplementedError("Qwen4Exp QSA requires BF16 Q/K/V")
151
@@ -160,6 +158,7 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
158
logical_indices,
159
attn_metadata.block_table,
160
token_to_req,
161
+ use_prefill_config,
162
output[:num_tokens],
163
)
164
return output
@@ -209,6 +208,11 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
208
if self.total_num_heads % tp_size:
209
raise ValueError("QSA attention heads must be divisible by TP size")
210
self.num_heads = self.total_num_heads // tp_size
211
+ # Decode/verify batches have at most 1 + num_spec query tokens per
212
+ # request; use_prefill_config (max_query_len > this) steers the
213
+ # config table. Shorter batches take the decode profile — harmless,
214
+ # the difference is tile-shape tuning, not correctness.
215
+ self._max_decode_query_len = 1 + vllm_config.num_speculative_tokens
216
self.total_num_kv_heads = int(config.num_key_value_heads)
217
if self.total_num_kv_heads >= tp_size:
218
if self.total_num_kv_heads % tp_size:
@@ -310,11 +314,16 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
314
prefix=f"{prefix}.indexer",
315
)
316
max_tokens = vllm_config.scheduler_config.max_num_batched_tokens
317
+ # PACKED selection buffer: the trailing column holds each row's
318
+ # valid-entry count (written by the expand kernel) — never a token
319
+ # index; the sparse attention kernel reads it as its loop bound.
320
+ # MTP skip_topk steps reuse rows frozen from step 0; the count is
321
+ # a row column, so compaction/reuse keep it paired with the content.
322
self.register_buffer(
323
"topk_indices_buffer",
324
torch.empty(
325
max_tokens,
317
- self.indexer.output_width,
326
+ self.indexer.packed_output_width,
327
dtype=torch.int32,
328
),
329
persistent=False,
@@ -369,10 +378,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
378
positions,
379
self.topk_indices_buffer[:num_tokens],
380
)
372
- if selected.shape != (
373
- num_tokens,
374
- self.indexer.output_width,
375
- ):
381
+ if selected.shape != (num_tokens, self.indexer.packed_output_width):
382
raise RuntimeError("QSA indexer returned an invalid selection shape")
383
impl = cast(Qwen4ExpQSAFlashAttentionImpl, self.impl)
384
impl.do_kv_cache_update(
@@ -391,6 +397,7 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
397
main_metadata,
398
output,
399
token_to_req=side_metadata.token_to_req,
400
+ use_prefill_config=main_metadata.max_query_len > self._max_decode_query_len,
401
)
402
403
def forward(