[Qwen3.8-Flash-Next] Compact indexer logits workspace to improve prefill efficiency (#54915)

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

Thien Tran committed Sep 4, 2026 at 15:43 UTC a5c9179e731e8d2ec7e03485a3ab0754a5a27182
4 files changed +46 -16
tests/models/qwen4_exp/test_qsa_reference.py
+20 -3
@@ -244,6 +244,7 @@ def test_qsa_side_metadata_marks_cudagraph_padding_inert() -> None:
244 num_actual_tokens=16,
245 num_reqs=4,
246 max_query_len=4,
247 + max_seq_len=68,
248 query_start_loc=query_start_loc,
249 query_start_loc_cpu=query_start_loc.cpu(),
250 seq_lens=torch.tensor([68, 68, 68, 0], dtype=torch.int32, device=device),
@@ -297,6 +298,7 @@ def test_qsa_circular_buffer_metadata_keeps_only_each_requests_suffix() -> None:
298 num_actual_tokens=16,
299 num_reqs=3,
300 max_query_len=7,
301 + max_seq_len=11,
302 query_start_loc=query_start_loc,
303 query_start_loc_cpu=query_start_loc.cpu(),
304 seq_lens=torch.tensor([9, 11, 0], dtype=torch.int32, device=device),
@@ -428,6 +430,7 @@ def test_qsa_compressed_metadata_keeps_dummy_slots_inert() -> None:
430 num_actual_tokens=8,
431 num_reqs=3,
432 max_query_len=5,
433 + max_seq_len=12,
434 query_start_loc=query_start_loc,
435 query_start_loc_cpu=query_start_loc.cpu(),
436 seq_lens=torch.tensor([7, 0, 12], dtype=torch.int32, device=device),
@@ -673,13 +676,24 @@ def test_qsa_decode_selection_correctness(
676
677
678 @requires_qsa_kernels
676 -def test_qsa_prefill_selection_correctness() -> None:
679 +@pytest.mark.parametrize("seq_len_slack", [0, 1792])
680 +@pytest.mark.parametrize("force_chunk", [False, True])
681 +def test_qsa_prefill_selection_correctness(
682 + monkeypatch: pytest.MonkeyPatch, seq_len_slack: int, force_chunk: bool
683 +) -> None:
684 + # page_size=24 (does not divide the 64-aligned clipped width) and an
685 + # oversized page table, so the clipped logits width comes from
686 + # max_seq_len, not page geometry. seq_len_slack > 0 simulates the
687 + # spec-decode case where the bound is an over-estimate. force_chunk
688 + # drives the logits budget to one row per chunk.
689 + if force_chunk:
690 + monkeypatch.setenv("VLLM_SPARSE_INDEXER_MAX_LOGITS_MB", "0")
691 torch.manual_seed(2)
692 query_lens = [3, 33]
693 rows, heads, head_dim = sum(query_lens), 4, 128
694 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)
695 + cache = torch.randn(128, 24, 1, head_dim, device="cuda", dtype=torch.bfloat16)
696 + page_table = torch.randperm(128, device="cuda", dtype=torch.int32).reshape(2, 64)
697 token_to_req = torch.repeat_interleave(
698 torch.arange(2, device="cuda", dtype=torch.int32),
699 torch.tensor(query_lens, device="cuda"),
@@ -713,6 +727,7 @@ def test_qsa_prefill_selection_correctness() -> None:
727 compress_ratio,
728 max(query_lens),
729 actual,
730 + max_seq_len=sequence_lengths.max().item() + seq_len_slack,
731 )
732 expected = _qsa_select_paged_reference(
733 q,
@@ -931,6 +946,7 @@ def test_qsa_split_selection_correctness(workspace_init, decode_query_len: int)
946 compress_ratio,
947 query_lens[-1],
948 block_indices[prefill_slice],
949 + max_seq_len=sequence_lengths.max().item(),
950 )
951 actual = torch.empty(
952 (rows, token_topk + compress_ratio - 1), device="cuda", dtype=torch.int32
@@ -983,6 +999,7 @@ def test_qsa_selection_handles_no_complete_compressed_blocks(workspace_init) ->
999 compress_ratio=4,
1000 max_query_len=2,
1001 block_indices=block_indices,
1002 + max_seq_len=64, # clamps to the page-table capacity
1003 )
1004 selected = torch.empty((2, 2051), device="cuda", dtype=torch.int32)
1005 qsa_indexer_ops.expand_qsa_block_indices(
vllm/models/qwen4_exp/common/qsa_cache.py
+2
@@ -591,6 +591,7 @@ class QSAForwardMetadata(AttentionMetadata):
591 num_prefill_tokens: int
592 max_query_len: int
593 decode_query_len: int
594 + max_seq_len: int
595 storage_block_size: int
596 compress_ratio: int
597
@@ -717,6 +718,7 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
718 num_prefill_tokens=num_prefill_tokens,
719 max_query_len=common_attn_metadata.max_query_len,
720 decode_query_len=decode_query_len,
721 + max_seq_len=common_attn_metadata.max_seq_len,
722 storage_block_size=self.storage_block_size,
723 compress_ratio=self.compress_ratio,
724 )
vllm/models/qwen4_exp/nvidia/indexer_qsa.py
+1
@@ -405,6 +405,7 @@ class QSAIndexer(nn.Module):
405 self.compress_ratio,
406 compressed_metadata.max_query_len,
407 block_indices[prefill_slice],
408 + compressed_metadata.max_seq_len,
409 )
410 expand_qsa_block_indices(
411 block_indices,
vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
+23 -13
@@ -97,7 +97,7 @@ def _qsa_mqa_paged_uniform_kernel(
97 scores,
98 (BLOCK_N, DECODE_QUERY_LEN_PADDED, NUM_HEADS_PADDED),
99 )
100 - score = tl.sum(scores, axis=2) / HEAD_DIM**0.5
100 + score = tl.sum(scores, axis=2)
101 tl.store(
102 logits_ptr + rows[None, :] * stride_logits_row + columns[:, None],
103 score,
@@ -123,8 +123,8 @@ def _qsa_mqa_paged_prefill_kernel(
123 stride_logits_row,
124 num_rows,
125 query_offset,
126 + page_table_width,
127 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,
@@ -132,7 +132,7 @@ def _qsa_mqa_paged_prefill_kernel(
132 K_TILES: tl.constexpr,
133 STAGES: tl.constexpr,
134 ) -> None:
135 - NUM_COLUMNS: tl.constexpr = PAGE_TABLE_WIDTH * PAGE_SIZE
135 + num_columns = 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))
@@ -163,7 +163,7 @@ def _qsa_mqa_paged_prefill_kernel(
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))
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)
@@ -184,7 +184,7 @@ def _qsa_mqa_paged_prefill_kernel(
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)
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,
@@ -202,11 +202,11 @@ def _qsa_mqa_paged_prefill_kernel(
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
205 + score = tl.sum(tl.maximum(scores, 0.0), axis=2)
206 store_mask = (
207 valid_rows[None, :]
208 & (columns[:, None] < visible[None, :])
209 - & (columns[:, None] < NUM_COLUMNS)
209 + & (columns[:, None] < num_columns)
210 )
211 tl.store(
212 logits_ptr + rows[None, :] * stride_logits_row + columns[:, None],
@@ -372,22 +372,25 @@ def _prefill_logits(
372 query_start_loc: torch.Tensor,
373 visible_blocks: torch.Tensor,
374 max_query_len: int,
375 + logits_width: int,
376 query_offset: int,
377 num_queries: int,
378 ) -> torch.Tensor:
379 assert query_start_loc.shape == (page_table.shape[0] + 1,)
380 assert visible_blocks.shape == (q.shape[0],)
381 assert 0 <= query_offset <= query_offset + num_queries <= q.shape[0]
382 + assert 0 < logits_width <= page_table.shape[1] * k_cache.shape[1]
383
382 - columns = page_table.shape[1] * k_cache.shape[1]
383 - logits = torch.empty((num_queries, columns), dtype=torch.float32, device=q.device)
384 + logits = torch.empty(
385 + (num_queries, logits_width), dtype=torch.float32, device=q.device
386 + )
387 TILE_R = 64
388 BLOCK_N = 64
389 K_TILES = 16
390 grid = (
391 page_table.shape[0],
392 triton.cdiv(min(num_queries, max_query_len), TILE_R),
390 - triton.cdiv(columns, BLOCK_N * K_TILES),
393 + triton.cdiv(logits_width, BLOCK_N * K_TILES),
394 )
395 _qsa_mqa_paged_prefill_kernel[grid](
396 q,
@@ -402,8 +405,8 @@ def _prefill_logits(
405 *logits.stride()[:-1],
406 num_queries,
407 query_offset,
408 + page_table.shape[1],
409 PAGE_SIZE=k_cache.shape[1],
406 - PAGE_TABLE_WIDTH=page_table.shape[1],
410 NUM_HEADS=q.shape[1],
411 HEAD_DIM=q.shape[2],
412 TILE_R=TILE_R,
@@ -557,6 +560,7 @@ def qsa_select_paged_prefill(
560 compress_ratio: int,
561 max_query_len: int,
562 block_indices: torch.Tensor,
563 + max_seq_len: int,
564 ) -> None:
565 """Score and select compressed prefill blocks in bounded chunks.
566
@@ -572,16 +576,21 @@ def qsa_select_paged_prefill(
576 compress_ratio: Number of logical tokens represented by a cache row.
577 max_query_len: Maximum number of query tokens in one request.
578 block_indices: Compressed-index output buffer.
579 + max_seq_len: Longest context length in the batch this step.
580 """
581
582 assert token_topk % compress_ratio == 0
583 assert block_indices.shape == (q.shape[0], token_topk // compress_ratio)
584 rows = q.shape[0]
580 - columns = page_table.shape[1] * k_cache.shape[1]
585 + # No row scores beyond cdiv(max_seq_len, compress_ratio) compressed
586 + # columns. Round up to 64 to keep the logits row stride
587 + # cooperative_topk-compatible.
588 + logits_width = triton.cdiv(triton.cdiv(max_seq_len, compress_ratio), 64) * 64
589 + logits_width = min(max(64, logits_width), page_table.shape[1] * k_cache.shape[1])
590
591 # chunk the inputs to keep temp logits below VLLM_SPARSE_INDEXER_MAX_LOGITS_MB
592 max_logits_bytes = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
584 - rows_per_chunk = max(1, max_logits_bytes // (columns * 4))
593 + rows_per_chunk = max(1, max_logits_bytes // (logits_width * 4))
594 topk_workspace = torch.empty(
595 (_TOPK_WORKSPACE_BYTES,), dtype=torch.uint8, device=q.device
596 )
@@ -596,6 +605,7 @@ def qsa_select_paged_prefill(
605 query_start_loc,
606 visible_blocks,
607 max_query_len,
608 + logits_width,
609 query_offset=query_start,
610 num_queries=query_end - query_start,
611 )