[DSA] Wire DeepGEMM sparse MQA logits into the DeepSeek V4.1 indexer (#56254)
Signed-off-by: Jared Wen <w13431838023@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Jared Wen committed
Sep 16, 2026 at 05:07 UTC
12575123059c2142e39c9af4e217a4792c3c8176
13 files changed
+1830
-26
tests/kernels/test_fused_indexer_q_rope_quant.py
+52
@@ -560,3 +560,55 @@ def test_indexer_k_store_roundtrips_through_rocm_gather(block_size, compress_rat
560
torch.testing.assert_close(
561
k_scale[emitted], expected_scale[emitted], rtol=0, atol=0
562
)
563
+
564
+
565
+@pytest.mark.parametrize("use_cutedsl", [False, True])
566
+@pytest.mark.skipif(
567
+ not (
568
+ current_platform.is_cuda() and current_platform.is_device_capability_family(100)
569
+ ),
570
+ reason="MXFP4 indexer cache requires an SM100-family GPU",
571
+)
572
+@torch.inference_mode()
573
+def test_fused_indexer_q_rope_quant_writes_bf16_weights(use_cutedsl):
574
+ """The MXFP4 path can emit the per-head weights in bf16 (what DeepGEMM's
575
+ sparse MQA-logits kernels take) instead of fp32; the values are the fp32
576
+ result rounded once, so the scoring kernel needs no cast."""
577
+ if use_cutedsl and not has_cutedsl():
578
+ pytest.skip("cutedsl (cutlass) not installed")
579
+
580
+ device = "cuda"
581
+ torch.manual_seed(0)
582
+ num_tokens, n_head = 257, 32
583
+ q = torch.randn(num_tokens, n_head, HEAD_DIM, dtype=torch.bfloat16, device=device)
584
+ positions = torch.randint(
585
+ 0, MAX_POS, (num_tokens,), dtype=torch.int64, device=device
586
+ )
587
+ cos_sin_cache = torch.randn(MAX_POS, ROPE_DIM, dtype=torch.float32, device=device)
588
+ weights = torch.randn(num_tokens, n_head, dtype=torch.bfloat16, device=device)
589
+ softmax_scale, head_scale = HEAD_DIM**-0.5, 0.125 # pow2 head_scale, see above
590
+
591
+ _, weights_ref = _reference(
592
+ positions, q, cos_sin_cache, weights, softmax_scale, head_scale, n_head, True
593
+ )
594
+ cutedsl_patch = (
595
+ mock.patch(
596
+ "vllm.models.deepseek_v4.common.ops.fused_indexer_q.has_cutedsl",
597
+ return_value=False,
598
+ )
599
+ if not use_cutedsl
600
+ else contextlib.nullcontext()
601
+ )
602
+ with cutedsl_patch:
603
+ _, weights_fused = fused_indexer_q_rope_quant(
604
+ positions,
605
+ q.clone(),
606
+ cos_sin_cache,
607
+ weights,
608
+ softmax_scale,
609
+ head_scale,
610
+ use_fp4=True,
611
+ weights_out_dtype=torch.bfloat16,
612
+ )
613
+ assert weights_fused.dtype == torch.bfloat16
614
+ assert torch.equal(weights_fused, weights_ref.to(torch.bfloat16))
tests/model_executor/layers/test_mla_short_prefill_indexer.py
+544
@@ -450,3 +450,547 @@ def test_candidate_kernels_preserve_packed_bounds_and_padding(
450
keep = valid & (block >= 0) & ((cols - ks[:, None]) // block_size == block)
451
reference = torch.where(keep, 3.0, -torch.inf)
452
torch.testing.assert_close(logits, reference, rtol=0, atol=0)
453
+
454
+
455
+# --- DeepGEMM sparse-MQA indexer path (V4.1 two-level candidate filtering) ---
456
+
457
+
458
+def _make_candidates(lens: torch.Tensor, num_candidates: int, cbk: int):
459
+ """Production-faithful candidates: top min(avail, K) blocks per row —
460
+ full coverage for lens <= K*cbk, a fixed-size subset beyond that."""
461
+ avail = ((lens.cpu() + cbk - 1) // cbk).tolist()
462
+ rows = []
463
+ for a in avail:
464
+ n = min(a, num_candidates)
465
+ picks = torch.randperm(a)[:n].sort().values[:num_candidates].tolist()
466
+ rows.append(picks + [-1] * (num_candidates - len(picks)))
467
+ return torch.tensor(rows, dtype=torch.int32)
468
+
469
+
470
+def _candidate_token_mask(
471
+ candidates: torch.Tensor, cbk: int, total: int, ks: torch.Tensor | None = None
472
+):
473
+ """[rows, total] bool mask of the candidate-covered token positions.
474
+
475
+ ``ks`` converts request-local candidate blocks to absolute positions
476
+ (prefill's packed workspace); pass None when already request-local.
477
+ """
478
+ rows = candidates.shape[0]
479
+ device = candidates.device
480
+ # Sentinel block keeps -1 padding from scattering onto real columns.
481
+ width = max(total, (candidates.shape[1] + 1) * cbk)
482
+ if ks is not None:
483
+ width += int(ks.max()) + cbk
484
+ in_cand = torch.zeros(rows, width, dtype=torch.bool, device=device)
485
+ block_id = torch.where(candidates >= 0, candidates.long(), candidates.shape[1])
486
+ tok = block_id.unsqueeze(2) * cbk + torch.arange(cbk, device=device)
487
+ if ks is not None:
488
+ tok = tok + ks.unsqueeze(1).unsqueeze(2)
489
+ keep = (candidates >= 0).unsqueeze(2).expand(-1, -1, cbk)
490
+ in_cand.scatter_(1, tok.flatten(1), keep.flatten(1))
491
+ return in_cand[:, :total]
492
+
493
+
494
+def _reference_topk(masked: torch.Tensor, topk: int) -> torch.Tensor:
495
+ """Plain ``torch.topk`` over -inf-masked logits; slots past a row's valid
496
+ count are -1 (the indexer's "no token" sentinel)."""
497
+ values, indices = masked.topk(topk, dim=1)
498
+ return torch.where(values > float("-inf"), indices.int(), -1)
499
+
500
+
501
+def _assert_same_selection(
502
+ logits: torch.Tensor,
503
+ got: torch.Tensor,
504
+ expected: torch.Tensor,
505
+ row_starts: torch.Tensor | None = None,
506
+) -> None:
507
+ """Both top-k index sets must pick the same multiset of logit values per
508
+ row (-1 padding must match too). Indices themselves may differ: the
509
+ top-k kernels break ties among equal values in nondeterministic order."""
510
+ for r in range(logits.shape[0]):
511
+ base = int(row_starts[r]) if row_starts is not None else 0
512
+ g, e = got[r][got[r] >= 0], expected[r][expected[r] >= 0]
513
+ assert g.numel() == e.numel(), f"row {r}: {g.numel()} vs {e.numel()} selected"
514
+ vg = logits[r][(base + g).long()].sort().values
515
+ ve = logits[r][(base + e).long()].sort().values
516
+ assert torch.equal(vg, ve), f"row {r}: selected values differ"
517
+
518
+
519
+def _sparse_scratch(rows: int, num_sparse: int, topk: int) -> dict:
520
+ """Caller-owned buffers the sparse path writes into (the metadata builder
521
+ owns these in production)."""
522
+ i32 = dict(dtype=torch.int32, device="cuda")
523
+ return dict(
524
+ sparse_indices=torch.zeros(rows, num_sparse, **i32),
525
+ end=torch.zeros(rows, **i32),
526
+ col_indices=torch.zeros(rows, topk, **i32),
527
+ )
528
+
529
+
530
+def _expand_candidates_reference(
531
+ candidates: torch.Tensor, ks: torch.Tensor, ke: torch.Tensor, cbk: int, sbk: int
532
+) -> tuple[torch.Tensor, torch.Tensor]:
533
+ """PyTorch reference for `candidate_blocks_to_sparse_indices`: expand
534
+ each candidate block into ``cbk // sbk`` sparse blocks, keep those inside
535
+ ``[0, ke - ks)``, sort ascending, anchor at ``ks // sbk``, pad by
536
+ repeating the last valid block, and count the valid sparse columns."""
537
+ ratio = cbk // sbk
538
+ rows, num_candidates = candidates.shape
539
+ num_sparse = num_candidates * ratio
540
+ big = torch.iinfo(torch.int32).max
541
+ offsets = torch.arange(ratio, device=candidates.device, dtype=torch.int32)
542
+ blocks = (candidates.unsqueeze(-1) * ratio + offsets).flatten(1)
543
+ max_block = ((ke - ks).clamp(min=0) + sbk - 1) // sbk
544
+ valid = (blocks >= 0) & (blocks < max_block.unsqueeze(1))
545
+ sorted_blocks, _ = torch.where(valid, blocks, big).sort(dim=1)
546
+ n_valid = (sorted_blocks != big).sum(dim=1, dtype=torch.int32)
547
+ base = (ks // sbk).unsqueeze(1)
548
+ kernel_blocks = torch.where(sorted_blocks != big, sorted_blocks, 0) + base
549
+ last_slot = (n_valid - 1).clamp(min=0).unsqueeze(1).long()
550
+ last_block = kernel_blocks.gather(1, last_slot)
551
+ pad = torch.arange(num_sparse, device=candidates.device) >= n_valid.unsqueeze(1)
552
+ sparse_indices = torch.where(pad, last_block, kernel_blocks)
553
+ last_start = last_block.squeeze(1) * sbk + ks % sbk
554
+ last_fill = (ke - last_start).clamp(min=1, max=sbk)
555
+ end = torch.where(n_valid > 0, (n_valid - 1) * sbk + last_fill, 0)
556
+ return sparse_indices.int(), end.int()
557
+
558
+
559
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="Triton kernel")
560
+def test_candidate_blocks_to_sparse_indices_math():
561
+ """Candidate->sparse-block expansion: ks anchoring, range filtering,
562
+ repeat-last padding, and the per-row valid column count (``end``)."""
563
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
564
+ candidate_blocks_to_sparse_indices,
565
+ )
566
+
567
+ # Rows cover: exactly one block, an unaligned ks with a partial tail
568
+ # block, an empty row, duplicates / -1 padding / out-of-range garbage
569
+ # (warmup runs touch uninitialized buffers), and int32-overflow junk.
570
+ ks = torch.tensor([64, 13, 100, 8, 0], dtype=torch.int32, device="cuda")
571
+ ke = torch.tensor([72, 30, 100, 40, 1000], dtype=torch.int32, device="cuda")
572
+ candidates = torch.tensor(
573
+ [
574
+ [0, -1, -1, -1],
575
+ [2, 0, -1, -1],
576
+ [3, -1, -1, -1],
577
+ [1, 1, 7, 0],
578
+ [2**30, -(2**30), 0, -1],
579
+ ],
580
+ dtype=torch.int32,
581
+ device="cuda",
582
+ )
583
+ indices, end = candidate_blocks_to_sparse_indices(
584
+ candidates, ks, ke, candidate_block_size=8, sparse_block_kv=8
585
+ )
586
+ assert indices.tolist() == [
587
+ [8, 8, 8, 8],
588
+ [1, 3, 3, 3],
589
+ [12, 12, 12, 12],
590
+ [1, 2, 2, 2],
591
+ [0, 0, 0, 0],
592
+ ]
593
+ # end: row 1's last block covers [29, 37) but ke = 30 -> one valid token;
594
+ # row 3 keeps the duplicated block (production candidates are unique).
595
+ assert end.tolist() == [8, 9, 0, 24, 8]
596
+
597
+ # Wider candidate blocks expand into multiple sparse blocks.
598
+ idx2, end2 = candidate_blocks_to_sparse_indices(
599
+ torch.tensor([[1, 0]], dtype=torch.int32, device="cuda"),
600
+ torch.tensor([0], dtype=torch.int32, device="cuda"),
601
+ torch.tensor([48], dtype=torch.int32, device="cuda"),
602
+ candidate_block_size=16,
603
+ sparse_block_kv=8,
604
+ )
605
+ assert idx2.tolist() == [[0, 1, 2, 3]]
606
+ assert end2.tolist() == [32]
607
+
608
+
609
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="Triton kernel")
610
+@pytest.mark.parametrize("cbk,sbk", [(8, 8), (16, 8), (16, 16)])
611
+def test_candidate_blocks_to_sparse_indices_matches_reference(cbk: int, sbk: int):
612
+ """The Triton expansion equals the PyTorch reference on production-shaped
613
+ random inputs, including strided (column-sliced) candidate rows and
614
+ strided output buffers."""
615
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
616
+ candidate_blocks_to_sparse_indices,
617
+ )
618
+
619
+ torch.manual_seed(0)
620
+ rows, num_candidates = 41, 2048
621
+ ratio = cbk // sbk
622
+ lens = torch.randint(1, 40000, (rows,), dtype=torch.int32)
623
+ lens[0], lens[1] = 1, 3
624
+ ks = torch.zeros(rows, dtype=torch.int32)
625
+ ks[1:] = lens.cumsum(0)[:-1]
626
+ ks, ke = ks.cuda(), (ks + lens).cuda()
627
+ candidates = _make_candidates(lens, num_candidates, cbk).cuda()
628
+ # Row 2 gets out-of-range garbage past the padding, as a CUDA-graph
629
+ # warm-up on uninitialized buffers would.
630
+ candidates[2, -3:] = torch.tensor([2**30, -(2**29), 10**6], dtype=torch.int32)
631
+
632
+ ref_indices, ref_end = _expand_candidates_reference(candidates, ks, ke, cbk, sbk)
633
+
634
+ cand_buf = torch.full((rows, num_candidates + 64), -7, dtype=torch.int32).cuda()
635
+ cand_buf[:, :num_candidates] = candidates
636
+ si_buf = torch.zeros(rows, num_candidates * ratio + 32, dtype=torch.int32).cuda()
637
+ end_buf = torch.zeros(rows * 2, dtype=torch.int32, device="cuda")
638
+ ks_buf = torch.zeros(rows * 2, dtype=torch.int32, device="cuda")
639
+ ks_buf[::2] = ks
640
+ got_indices, got_end = candidate_blocks_to_sparse_indices(
641
+ cand_buf[:, :num_candidates],
642
+ ks_buf[::2],
643
+ ke,
644
+ cbk,
645
+ sbk,
646
+ out=(si_buf[:, : num_candidates * ratio], end_buf[::2]),
647
+ )
648
+ assert torch.equal(got_indices, ref_indices)
649
+ assert torch.equal(got_end, ref_end)
650
+ assert torch.equal(
651
+ cand_buf[:, num_candidates:], torch.full_like(cand_buf[:, num_candidates:], -7)
652
+ )
653
+ assert not si_buf[:, num_candidates * ratio :].any()
654
+ assert not end_buf[1::2].any()
655
+
656
+
657
+def _skip_unless_sm100_sparse_kernels():
658
+ if not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] != 10:
659
+ pytest.skip("SM100 required")
660
+ deep_gemm = pytest.importorskip("deep_gemm")
661
+ if not hasattr(deep_gemm, "fp8_fp4_sparse_mqa_logits"):
662
+ pytest.skip("DeepGEMM >= 2.8 required for sparse MQA logits")
663
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
664
+ has_deep_select,
665
+ )
666
+
667
+ if not has_deep_select():
668
+ pytest.skip("DeepSelect extension (vllm._deepselect_C) required")
669
+ return deep_gemm
670
+
671
+
672
+def _quant_fp4(x: torch.Tensor):
673
+ from deep_gemm.utils import per_token_cast_to_fp4
674
+
675
+ return per_token_cast_to_fp4(x, use_ue8m0=True, gran_k=32, use_packed_ue8m0=True)
676
+
677
+
678
+@pytest.mark.parametrize("capture", [False, True])
679
+def test_sparse_mqa_logits_prefill_matches_dense_masked(capture: bool):
680
+ """The sparse-MQA candidate path selects exactly the same logit values as
681
+ ``torch.topk`` over dense bf16 logits masked to the candidates (the two
682
+ DeepGEMM kernels agree bitwise on candidate columns), incl. under CUDA
683
+ graph capture, and a later layer reusing the step's expansion/schedule
684
+ selects the same values."""
685
+ deep_gemm = _skip_unless_sm100_sparse_kernels()
686
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
687
+ sparse_mqa_logits_prefill_chunk,
688
+ )
689
+
690
+ torch.manual_seed(0)
691
+ rows, num_heads, head_dim = 37, 32, 128
692
+ cbk, topk, num_candidates = 8, 512, 2048 # V4.1-Flash production values
693
+ lens = torch.randint(1, 30000, (rows,), dtype=torch.int32)
694
+ lens[0], lens[1], lens[2] = 1, 5, 2048 # single token, sub-block, long
695
+ ks = torch.zeros(rows, dtype=torch.int32)
696
+ ks[1:] = lens.cumsum(0)[:-1]
697
+ ks, ke, lens = ks.cuda(), (ks + lens).cuda(), lens.cuda()
698
+ total = int(lens.sum())
699
+ q_fp, q_sf = _quant_fp4(
700
+ torch.randn(rows * num_heads, head_dim, device="cuda", dtype=torch.bfloat16)
701
+ )
702
+ k_fp, k_sf = _quant_fp4(
703
+ torch.randn(total, head_dim, device="cuda", dtype=torch.bfloat16)
704
+ )
705
+ weights = torch.randn(rows, num_heads, device="cuda", dtype=torch.bfloat16)
706
+ candidates = _make_candidates(lens, num_candidates, cbk).cuda()
707
+
708
+ # Reference: dense bf16 logits masked to the candidates, then torch.topk.
709
+ dense = deep_gemm.fp8_fp4_mqa_logits(
710
+ (
711
+ q_fp.view(torch.int8).view(rows, num_heads, head_dim // 2),
712
+ q_sf.view(rows, num_heads),
713
+ ),
714
+ (k_fp.view(torch.int8), k_sf.view(total)),
715
+ weights,
716
+ ks,
717
+ ke,
718
+ clean_logits=False,
719
+ logits_dtype=torch.bfloat16,
720
+ )
721
+ cols = torch.arange(total, device="cuda")
722
+ in_range = (cols - ks.unsqueeze(1) >= 0) & (
723
+ cols - ks.unsqueeze(1) < lens.unsqueeze(1)
724
+ )
725
+ masked = dense.masked_fill(
726
+ ~(in_range & _candidate_token_mask(candidates, cbk, total, ks)),
727
+ float("-inf"),
728
+ ).float()
729
+ ref_abs = _reference_topk(masked, topk) # packed-workspace positions
730
+ ref = torch.where(ref_abs >= 0, ref_abs - ks.unsqueeze(1), -1)
731
+
732
+ out = torch.full((rows, topk), -1, dtype=torch.int32, device="cuda")
733
+ scratch = _sparse_scratch(rows, num_candidates, topk)
734
+
735
+ def run_sparse(kernel_metadata=None):
736
+ return sparse_mqa_logits_prefill_chunk(
737
+ q_fp.view(torch.int8).view(rows, num_heads, head_dim // 2),
738
+ q_sf.view(rows, num_heads),
739
+ k_fp.view(torch.int8),
740
+ k_sf.view(total),
741
+ weights,
742
+ ks,
743
+ ke,
744
+ candidates,
745
+ cbk,
746
+ cbk,
747
+ topk,
748
+ out,
749
+ kernel_metadata=kernel_metadata,
750
+ **scratch,
751
+ )
752
+
753
+ if capture:
754
+ stream = torch.cuda.Stream()
755
+ stream.wait_stream(torch.cuda.current_stream())
756
+ with torch.cuda.stream(stream):
757
+ run_sparse() # warm up JIT + the DeepGEMM workspace pre-capture
758
+ graph = torch.cuda.CUDAGraph()
759
+ with torch.cuda.graph(graph, stream=stream):
760
+ run_sparse()
761
+ out.fill_(-1)
762
+ graph.replay()
763
+ else:
764
+ kernel_metadata = run_sparse()
765
+ _assert_same_selection(dense, out, ref, row_starts=ks)
766
+ out.fill_(-1)
767
+ run_sparse(kernel_metadata)
768
+
769
+ _assert_same_selection(dense, out, ref, row_starts=ks)
770
+
771
+
772
+def _build_fp4_paged_cache(req_lens: torch.Tensor, page_kv: int, head_dim: int):
773
+ """MXFP4 paged cache in DeepGEMM's fused layout: per page, all tokens'
774
+ fp4 data first, then their packed UE8M0 scales."""
775
+ device = req_lens.device
776
+ pages_per = (req_lens + page_kv - 1) // page_kv
777
+ num_pages, max_pages = int(pages_per.sum()), int(pages_per.max())
778
+ kv_cache = torch.zeros(
779
+ num_pages, page_kv, 1, head_dim // 2 + 4, dtype=torch.uint8, device=device
780
+ )
781
+ block_table = torch.zeros(
782
+ len(req_lens), max_pages, dtype=torch.int32, device=device
783
+ )
784
+ page_pool = torch.randperm(num_pages, device=device)
785
+ offset = 0
786
+ for b in range(len(req_lens)):
787
+ n = int(pages_per[b])
788
+ block_table[b, :n] = page_pool[offset : offset + n].to(torch.int32)
789
+ offset += n
790
+ k_fp, k_sf = _quant_fp4(
791
+ torch.randn(int(req_lens[b]), head_dim, device=device, dtype=torch.bfloat16)
792
+ )
793
+ rows68 = torch.zeros(n * page_kv, 68, dtype=torch.uint8, device=device)
794
+ rows68[: int(req_lens[b]), : head_dim // 2] = k_fp.view(torch.uint8)
795
+ rows68[: int(req_lens[b]), head_dim // 2 :] = k_sf.view(torch.uint8).view(-1, 4)
796
+ by_page = rows68.view(n, page_kv, 68)
797
+ split = torch.cat(
798
+ [by_page[..., :64].reshape(n, -1), by_page[..., 64:].reshape(n, -1)], dim=1
799
+ ).view(n, page_kv, 1, head_dim // 2 + 4)
800
+ kv_cache[block_table[b, :n].long()] = split
801
+ return kv_cache, block_table
802
+
803
+
804
+@pytest.mark.parametrize("next_n", [1, 2])
805
+@pytest.mark.parametrize("capture", [False, True])
806
+def test_sparse_mqa_logits_paged_matches_dense_masked(next_n: int, capture: bool):
807
+ """Paged sparse-MQA decode selects exactly the values ``torch.topk`` picks
808
+ from the dense bf16 masked logits, incl. MTP rows (next_n > 1 flattened to
809
+ one query row each), CUDA graph capture/replay and schedule reuse by a
810
+ later layer."""
811
+ deep_gemm = _skip_unless_sm100_sparse_kernels()
812
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
813
+ sparse_mqa_logits_paged_decode,
814
+ )
815
+
816
+ torch.manual_seed(0)
817
+ batch, num_heads, head_dim, page_kv = 9, 32, 128, 128
818
+ cbk, topk, num_candidates = 8, 512, 2048
819
+ rows = batch * next_n
820
+ req_lens = torch.randint(1, 30000, (batch,), dtype=torch.int32, device="cuda")
821
+ req_lens[0], req_lens[1] = 1, 30000 # tiny row + a filtered (subset) row
822
+ # Row (b, j) sees L_b - next_n + j + 1 tokens (native MTP layout).
823
+ row_lens = (
824
+ (req_lens.unsqueeze(1) - next_n + torch.arange(1, next_n + 1, device="cuda"))
825
+ .clamp(min=0)
826
+ .to(torch.int32)
827
+ )
828
+ kv_cache, block_table = _build_fp4_paged_cache(req_lens, page_kv, head_dim)
829
+ assert kv_cache.stride(0) % 512 == 0 # the paged sparse kernel requires it
830
+
831
+ q_fp, q_sf = _quant_fp4(
832
+ torch.randn(rows * num_heads, head_dim, device="cuda", dtype=torch.bfloat16)
833
+ )
834
+ q = q_fp.view(torch.int8).view(batch, next_n, num_heads, head_dim // 2)
835
+ q_scale = q_sf.view(batch, next_n, num_heads)
836
+ weights = torch.randn(rows, num_heads, device="cuda", dtype=torch.bfloat16)
837
+ candidates = _make_candidates(row_lens.flatten(), num_candidates, cbk).cuda()
838
+
839
+ def reference_topk():
840
+ context_lens_2d = row_lens.view(batch, next_n)
841
+ sched = deep_gemm.get_paged_mqa_logits_metadata(
842
+ context_lens_2d, page_kv, deep_gemm.get_num_sms(), None
843
+ )
844
+ logits = deep_gemm.fp8_fp4_paged_mqa_logits(
845
+ (q, q_scale),
846
+ kv_cache,
847
+ weights,
848
+ context_lens_2d,
849
+ block_table,
850
+ sched,
851
+ int(req_lens.max()),
852
+ False,
853
+ torch.bfloat16,
854
+ None,
855
+ )
856
+ lens_flat = row_lens.flatten()
857
+ cols = torch.arange(logits.shape[1], device="cuda")
858
+ masked = logits.masked_fill(
859
+ ~(
860
+ (cols.unsqueeze(0) < lens_flat.unsqueeze(1))
861
+ & _candidate_token_mask(candidates, cbk, logits.shape[1])
862
+ ),
863
+ float("-inf"),
864
+ ).float()
865
+ return _reference_topk(masked, topk), logits
866
+
867
+ expected, dense_logits = reference_topk()
868
+ out = torch.full((rows, topk), -1, dtype=torch.int32, device="cuda")
869
+
870
+ # The metadata builder flattens every query to one row: per-row context
871
+ # lengths, an expanded block table and a row -> request map.
872
+ context_lens = row_lens.flatten().contiguous()
873
+ row_block_table = block_table.repeat_interleave(next_n, dim=0)
874
+ row_indices = torch.arange(batch, dtype=torch.int32, device="cuda")
875
+ row_indices = row_indices.repeat_interleave(next_n)
876
+ row_ks = torch.zeros(rows, dtype=torch.int32, device="cuda")
877
+ scratch = _sparse_scratch(rows, num_candidates, topk)
878
+
879
+ def run_sparse(o: torch.Tensor, kernel_metadata=None):
880
+ return sparse_mqa_logits_paged_decode(
881
+ q.view(rows, 1, num_heads, head_dim // 2),
882
+ q_scale.view(rows, 1, num_heads),
883
+ kv_cache,
884
+ weights,
885
+ context_lens,
886
+ row_block_table,
887
+ row_indices,
888
+ candidates,
889
+ cbk,
890
+ cbk,
891
+ topk,
892
+ o,
893
+ row_ks=row_ks,
894
+ kernel_metadata=kernel_metadata,
895
+ **scratch,
896
+ )
897
+
898
+ if capture:
899
+ stream = torch.cuda.Stream()
900
+ stream.wait_stream(torch.cuda.current_stream())
901
+ with torch.cuda.stream(stream):
902
+ run_sparse(out)
903
+ graph = torch.cuda.CUDAGraph()
904
+ with torch.cuda.graph(graph, stream=stream):
905
+ run_sparse(out)
906
+ out.fill_(-1)
907
+ graph.replay()
908
+ else:
909
+ kernel_metadata = run_sparse(out)
910
+ _assert_same_selection(dense_logits, out, expected)
911
+ out.fill_(-1)
912
+ run_sparse(out, kernel_metadata)
913
+
914
+ _assert_same_selection(dense_logits, out, expected)
915
+
916
+
917
+def test_sparse_mqa_logits_accuracy_vs_torch():
918
+ """Sparse-kernel numerics vs a PyTorch fp32 reference on the dequantized
919
+ MXFP4 inputs."""
920
+ _skip_unless_sm100_sparse_kernels()
921
+ from deep_gemm.utils import cast_back_from_fp4
922
+
923
+ from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
924
+ candidate_blocks_to_sparse_indices,
925
+ )
926
+ from vllm.utils.deep_gemm import (
927
+ fp8_fp4_sparse_mqa_logits,
928
+ get_sparse_mqa_logits_metadata,
929
+ )
930
+
931
+ torch.manual_seed(0)
932
+ rows, num_heads, head_dim = 17, 32, 128
933
+ sbk, num_candidates = 8, 2048
934
+ lens = torch.randint(1, 1500, (rows,), dtype=torch.int32)
935
+ lens[0] = 1
936
+ ks = torch.zeros(rows, dtype=torch.int32)
937
+ ks[1:] = lens.cumsum(0)[:-1]
938
+ ks, ke, lens = ks.cuda(), (ks + lens).cuda(), lens.cuda()
939
+ total = int(lens.sum())
940
+ q_fp, q_sf = _quant_fp4(
941
+ torch.randn(rows * num_heads, head_dim, device="cuda", dtype=torch.bfloat16)
942
+ )
943
+ k_fp, k_sf = _quant_fp4(
944
+ torch.randn(total, head_dim, device="cuda", dtype=torch.bfloat16)
945
+ )
946
+ weights = torch.randn(rows, num_heads, device="cuda", dtype=torch.bfloat16)
947
+ candidates = _make_candidates(lens, num_candidates, sbk).cuda()
948
+
949
+ sparse_idx, end = candidate_blocks_to_sparse_indices(candidates, ks, ke, sbk, sbk)
950
+ metadata = get_sparse_mqa_logits_metadata(
951
+ ks, ke, total, sparse_idx, torch.int8, sbk
952
+ )
953
+ logits = fp8_fp4_sparse_mqa_logits(
954
+ (
955
+ q_fp.view(torch.int8).view(rows, num_heads, head_dim // 2),
956
+ q_sf.view(rows, num_heads),
957
+ ),
958
+ (k_fp.view(torch.int8), k_sf.view(total)),
959
+ weights,
960
+ metadata,
961
+ sparse_idx.shape[1],
962
+ sbk,
963
+ )
964
+
965
+ # PyTorch fp32 reference on the dequantized inputs.
966
+ q_deq = cast_back_from_fp4(q_fp, q_sf, gran_k=32, use_packed_ue8m0=True)
967
+ k_deq = cast_back_from_fp4(k_fp, k_sf, gran_k=32, use_packed_ue8m0=True)
968
+ dots = torch.einsum(
969
+ "rhd,td->rht",
970
+ q_deq.view(rows, num_heads, head_dim).float(),
971
+ k_deq.float(),
972
+ )
973
+ ref = (dots.relu() * weights.float().unsqueeze(-1)).sum(1) # [rows, total]
974
+
975
+ # Compare on valid sparse columns (absolute packed-workspace positions).
976
+ width = sparse_idx.shape[1] * sbk
977
+ cols = torch.arange(width, device="cuda")
978
+ valid = cols.unsqueeze(0) < end.unsqueeze(1).long()
979
+ pos = (
980
+ sparse_idx.repeat_interleave(sbk, dim=1).long() * sbk
981
+ + (ks % sbk).unsqueeze(1)
982
+ + (cols % sbk).unsqueeze(0)
983
+ ).clamp(min=0, max=total - 1)
984
+ abs_err = (logits.float() - ref.gather(1, pos)).abs()[valid]
985
+ row_scale = ref.abs().amax(dim=1)
986
+ norm_err = ((logits.float() - ref.gather(1, pos)).abs() / row_scale.unsqueeze(1))[
987
+ valid
988
+ ]
989
+ print(
990
+ f"sparse vs torch fp32: max_abs={abs_err.max().item():.4f}; "
991
+ f"rowmax-normalized p50={norm_err.median().item():.5f} "
992
+ f"p99={norm_err.quantile(0.99).item():.5f} max={norm_err.max().item():.5f}"
993
+ )
994
+ # bf16-level numerics: far below the value gaps that change top-k selection.
995
+ assert norm_err.quantile(0.99).item() < 1e-2
996
+ assert norm_err.max().item() < 2e-2
vllm/config/attention.py
+9
@@ -86,6 +86,15 @@ class AttentionConfig:
86
indexer). Quantized formats (fp8, mxfp4, nvfp4) require indexer kernel
87
support in the backend."""
88
89
+ indexer_sparse_logits: bool = False
90
+ """DeepSeek V4.1 two-level indexer: score only the candidate blocks with
91
+ DeepGEMM's sparse MQA-logits kernels instead of computing dense logits over
92
+ the whole context and masking them. Requires `indexer_kv_dtype="mxfp4"`,
93
+ an SM100-class GPU, DeepGEMM >= 2.8 and the DeepSelect top-k extension
94
+ (the top-k runs on the kernels' bf16 logits). The sparse path costs
95
+ O(candidate blocks) per query regardless of context length, so it pays off
96
+ for long contexts (roughly 32K tokens and beyond) and is slower below."""
97
+
98
hisparse_config: HiSparseConfig | None = None
99
"""HiSparse host-resident KV configuration. Setting this enables experimental
100
Model Runner V2-only HiSparse sparse-MLA decode hot-buffering. It is inferred
vllm/model_executor/kernels/attention/dsa/sparse_mqa_logits.py
new
+469
@@ -0,0 +1,469 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepGEMM sparse MQA-logits path for the DeepSeek V4.1 two-level indexer.
4
+
5
+The candidate-source indexer publishes the top candidate blocks (in units of
6
+``candidate_block_size`` compressed positions). Instead of scoring every KV
7
+position and masking the dense logits down to those blocks, this path scores
8
+only the candidate tokens with DeepGEMM's sparse MQA logits kernels
9
+(``fp8_fp4_(paged_)sparse_mqa_logits``, DeepGEMM >= 2.8, SM100 only), runs
10
+DeepSelect's top-k directly on the bf16 logits they produce, and remaps the
11
+selected columns back to request-local positions.
12
+
13
+Only the MXFP4 indexer cache is supported: the kernels require UE8M0-packed
14
+(granularity-32) Q/KV scales, which is exactly the MXFP4 cache layout. The
15
+FP8 indexer cache stores one fp32 scale per quant block instead.
16
+
17
+The functions here take caller-owned output buffers (``sparse_indices``,
18
+``end``, ``col_indices``) so the hot path allocates nothing; the indexer
19
+metadata builder owns them.
20
+"""
21
+
22
+import importlib.util
23
+
24
+import torch
25
+
26
+from vllm.model_executor.layers.indexer_topk import (
27
+ deep_select_topk,
28
+ get_deep_select_stride_requirement,
29
+)
30
+from vllm.platforms import current_platform
31
+from vllm.triton_utils import tl, triton
32
+from vllm.utils.deep_gemm import (
33
+ fp8_fp4_paged_sparse_mqa_logits,
34
+ fp8_fp4_sparse_mqa_logits,
35
+ get_paged_sparse_mqa_logits_metadata,
36
+ get_sparse_mqa_logits_metadata,
37
+)
38
+
39
+# Sparse-block sizes (in tokens) supported by the DeepGEMM kernels.
40
+SPARSE_BLOCK_KV_CHOICES = (16, 8)
41
+
42
+
43
+def has_deep_select() -> bool:
44
+ """Whether the DeepSelect top-k extension is built and this GPU runs it."""
45
+ return (
46
+ importlib.util.find_spec("vllm._deepselect_C") is not None
47
+ and current_platform.is_cuda()
48
+ and current_platform.is_device_capability_family(100)
49
+ )
50
+
51
+
52
+def check_deep_select_layout(num_sparse_cols: int, topk_tokens: int) -> None:
53
+ """Raise unless DeepSelect accepts the sparse logits layout.
54
+
55
+ The sparse kernels return contiguous bf16 ``[rows, num_sparse_cols]``
56
+ logits and the top-k writes int32 ``[rows, topk_tokens]`` indices;
57
+ DeepSelect needs both row strides aligned (1024B / 32B on SM100).
58
+ """
59
+ in_align, out_align = get_deep_select_stride_requirement()
60
+ logits_row_bytes = num_sparse_cols * torch.bfloat16.itemsize
61
+ if num_sparse_cols >= 2**23 or logits_row_bytes % in_align != 0:
62
+ raise ValueError(
63
+ f"DeepSelect needs the sparse logits row ({num_sparse_cols} bf16 "
64
+ f"columns, {logits_row_bytes} B) to be {in_align}B-aligned."
65
+ )
66
+ if topk_tokens > 4096 or (topk_tokens * torch.int32.itemsize) % out_align != 0:
67
+ raise ValueError(
68
+ f"DeepSelect needs topk <= 4096 with a {out_align}B-aligned int32 "
69
+ f"output row, got topk={topk_tokens}."
70
+ )
71
+
72
+
73
+_INT32_BIG = tl.constexpr(2**31 - 1)
74
+
75
+
76
+@triton.jit
77
+def _expand_candidates_kernel(
78
+ cand_ptr,
79
+ cand_stride,
80
+ ks_ptr,
81
+ ks_stride,
82
+ ke_ptr,
83
+ ke_stride,
84
+ si_ptr,
85
+ si_stride,
86
+ end_ptr,
87
+ end_stride,
88
+ K: tl.constexpr,
89
+ S: tl.constexpr,
90
+ RATIO: tl.constexpr,
91
+ SBK: tl.constexpr,
92
+):
93
+ """One program per row: expand + filter + sort candidate blocks.
94
+
95
+ Each row's valid sparse blocks come out sorted ascending (no dedup pass:
96
+ production candidates are unique, and the kernel tolerates repeats),
97
+ padded by repeating the last valid block — DeepGEMM's padding convention.
98
+ ``end`` counts valid sparse-token columns; valid columns form a prefix
99
+ because block ids are sorted.
100
+ """
101
+ row = tl.program_id(0)
102
+ ks = tl.load(ks_ptr + row * ks_stride)
103
+ ke = tl.load(ke_ptr + row * ke_stride)
104
+ cols = tl.arange(0, K)
105
+ cand = tl.load(cand_ptr + row * cand_stride + cols) # K is pow2, no mask
106
+ if RATIO == 2:
107
+ cand = tl.interleave(cand * RATIO, cand * RATIO + 1)
108
+
109
+ # Keep only blocks inside the row's KV range [0, ke - ks); the lower
110
+ # bound is free since candidates are non-negative.
111
+ kv_len = tl.maximum(ke - ks, 0)
112
+ max_block = (kv_len + SBK - 1) // SBK
113
+ valid = (cand >= 0) & (cand < max_block)
114
+ vals = tl.where(valid, cand, _INT32_BIG)
115
+ vals = tl.sort(vals)
116
+
117
+ # Anchor at the row's ks rounded down to a sparse-block boundary
118
+ # (identity for the paged path, where ks == 0).
119
+ base = ks // SBK
120
+ si = tl.where(vals != _INT32_BIG, vals, 0) + base
121
+ n_valid = tl.sum(valid.to(tl.int32))
122
+ last_rel = tl.max(tl.where(valid, cand, -1))
123
+ pad_val = tl.where(n_valid > 0, last_rel + base, base)
124
+ tl.store(
125
+ si_ptr + row * si_stride + tl.arange(0, S),
126
+ tl.where(tl.arange(0, S) < n_valid, si, pad_val),
127
+ )
128
+
129
+ # Valid sparse-token column count; only the last block can be partial.
130
+ # Absolute start of the last block: (base + last_rel) * SBK + ks % SBK,
131
+ # which is exactly last_rel * SBK + ks.
132
+ last_start = last_rel * SBK + ks
133
+ fill = tl.minimum(tl.maximum(ke - last_start, 1), SBK)
134
+ end = tl.where(n_valid > 0, (n_valid - 1) * SBK + fill, 0)
135
+ tl.store(end_ptr + row * end_stride, end)
136
+
137
+
138
+@triton.jit
139
+def _sparse_topk_remap_kernel(
140
+ col_ptr,
141
+ col_stride,
142
+ si_ptr,
143
+ si_stride,
144
+ ks_ptr,
145
+ ks_stride,
146
+ out_ptr,
147
+ out_stride,
148
+ k,
149
+ SBK: tl.constexpr,
150
+ K_POW2: tl.constexpr,
151
+):
152
+ """Remap sparse top-k columns to request-local positions.
153
+
154
+ Column ``j * SBK + o`` scores the token at
155
+ ``(si[row, j] - ks // SBK) * SBK + o``; -1 columns stay -1.
156
+ """
157
+ row = tl.program_id(0)
158
+ ks_base = tl.load(ks_ptr + row * ks_stride) // SBK
159
+ cols = tl.arange(0, K_POW2)
160
+ c = tl.load(col_ptr + row * col_stride + cols, mask=cols < k, other=-1)
161
+ valid = c >= 0
162
+ cc = tl.where(valid, c, 0)
163
+ blocks = tl.load(si_ptr + row * si_stride + cc // SBK)
164
+ pos = (blocks - ks_base) * SBK + cc % SBK
165
+ tl.store(out_ptr + row * out_stride + cols, tl.where(valid, pos, -1), mask=cols < k)
166
+
167
+
168
+def pick_sparse_block_kv(candidate_block_size: int) -> int:
169
+ """Largest sparse-block size (tokens) dividing the candidate block size."""
170
+ for sbk in SPARSE_BLOCK_KV_CHOICES:
171
+ if candidate_block_size % sbk == 0:
172
+ return sbk
173
+ raise ValueError(
174
+ f"candidate_block_size={candidate_block_size} is not a multiple of "
175
+ f"any supported sparse_block_kv {SPARSE_BLOCK_KV_CHOICES}."
176
+ )
177
+
178
+
179
+def candidate_blocks_to_sparse_indices(
180
+ candidate_blocks: torch.Tensor,
181
+ row_ks: torch.Tensor,
182
+ row_ke: torch.Tensor,
183
+ candidate_block_size: int,
184
+ sparse_block_kv: int,
185
+ out: tuple[torch.Tensor, torch.Tensor] | None = None,
186
+) -> tuple[torch.Tensor, torch.Tensor]:
187
+ """Expand request-local candidate blocks into DeepGEMM sparse block ids.
188
+
189
+ Args:
190
+ candidate_blocks: [rows, K] int32 request-local candidate block ids
191
+ (-1 padded), in units of ``candidate_block_size`` positions. K
192
+ must be a power of two (the in-kernel sort); rows may be strided.
193
+ row_ks/row_ke: [rows] int32 per-row K range; bounds are in the same
194
+ (packed-workspace) coordinates the sparse kernel iterates over.
195
+ Pass zeros for the paged path, whose blocks are context-relative.
196
+ candidate_block_size: Positions per candidate block.
197
+ sparse_block_kv: Positions per sparse block (8 or 16).
198
+ out: Optional ``(sparse_indices, end)`` buffers to write into,
199
+ shaped [rows, K * ratio] and [rows], both int32.
200
+
201
+ Returns:
202
+ sparse_indices: [rows, K * ratio] int32 block ids for DeepGEMM,
203
+ sorted ascending per row with the valid prefix first (unique in
204
+ production, where candidates are unique) and the remaining slots
205
+ repeating the last valid block (the kernel's padding convention).
206
+ With the unaligned-ks variant, block ``i`` starts at
207
+ ``i * sparse_block_kv + ks % sparse_block_kv``.
208
+ end: [rows] int32 count of valid sparse-token columns; valid columns
209
+ always form a prefix because the block ids are sorted.
210
+ """
211
+ rows, num_candidates = candidate_blocks.shape
212
+ ratio = candidate_block_size // sparse_block_kv
213
+ assert ratio * sparse_block_kv == candidate_block_size and ratio in (1, 2), (
214
+ candidate_block_size,
215
+ sparse_block_kv,
216
+ )
217
+ assert num_candidates & (num_candidates - 1) == 0, num_candidates
218
+ num_sparse = num_candidates * ratio
219
+ if out is None:
220
+ device = candidate_blocks.device
221
+ out = (
222
+ torch.empty(rows, num_sparse, dtype=torch.int32, device=device),
223
+ torch.empty(rows, dtype=torch.int32, device=device),
224
+ )
225
+ sparse_indices, end = out
226
+ assert sparse_indices.shape == (rows, num_sparse) and end.shape == (rows,)
227
+ assert candidate_blocks.stride(1) == 1 and sparse_indices.stride(1) == 1
228
+ for t in (candidate_blocks, row_ks, row_ke, sparse_indices, end):
229
+ assert t.dtype == torch.int32, t.dtype
230
+
231
+ _expand_candidates_kernel[(rows,)](
232
+ candidate_blocks,
233
+ candidate_blocks.stride(0),
234
+ row_ks,
235
+ row_ks.stride(0),
236
+ row_ke,
237
+ row_ke.stride(0),
238
+ sparse_indices,
239
+ sparse_indices.stride(0),
240
+ end,
241
+ end.stride(0),
242
+ K=num_candidates,
243
+ S=num_sparse,
244
+ RATIO=ratio,
245
+ SBK=sparse_block_kv,
246
+ num_warps=16,
247
+ )
248
+ return sparse_indices, end
249
+
250
+
251
+def sparse_topk_remap(
252
+ logits: torch.Tensor,
253
+ sparse_indices: torch.Tensor,
254
+ end: torch.Tensor,
255
+ row_ks: torch.Tensor,
256
+ sparse_block_kv: int,
257
+ topk_tokens: int,
258
+ topk_indices: torch.Tensor,
259
+ *,
260
+ col_indices: torch.Tensor,
261
+) -> None:
262
+ """Row top-k over sparse logits, remapped to request-local positions.
263
+
264
+ Column ``j * sparse_block_kv + o`` of ``logits`` scores the token at
265
+ request-local position
266
+ ``(sparse_indices[row, j] - ks // sparse_block_kv) * sparse_block_kv + o``.
267
+
268
+ DeepSelect consumes the bf16 logits as they come out of the sparse
269
+ kernels; the valid columns form a prefix (block ids are sorted), so the
270
+ per-row ``end`` bounds drive it directly. Slots beyond a row's valid
271
+ count come back as -1.
272
+
273
+ Args:
274
+ col_indices: [rows, topk_tokens] int32 scratch for the sparse-column
275
+ top-k result (row stride 32B-aligned for DeepSelect).
276
+ """
277
+ rows, width = logits.shape
278
+ assert logits.dtype == torch.bfloat16 and width >= topk_tokens
279
+ assert end.dtype == torch.int32 and end.shape == (rows,)
280
+ cols = col_indices[:rows]
281
+ deep_select_topk(logits, topk_tokens, end=end, output_idx=cols)
282
+ _sparse_topk_remap_kernel[(rows,)](
283
+ cols,
284
+ cols.stride(0),
285
+ sparse_indices,
286
+ sparse_indices.stride(0),
287
+ row_ks,
288
+ row_ks.stride(0),
289
+ topk_indices,
290
+ topk_indices.stride(0),
291
+ topk_tokens,
292
+ SBK=sparse_block_kv,
293
+ K_POW2=triton.next_power_of_2(topk_tokens),
294
+ )
295
+
296
+
297
+def sparse_mqa_logits_prefill_chunk(
298
+ q: torch.Tensor,
299
+ q_scale: torch.Tensor,
300
+ k_quant: torch.Tensor,
301
+ k_scale: torch.Tensor,
302
+ weights: torch.Tensor,
303
+ cu_seqlen_ks: torch.Tensor,
304
+ cu_seqlen_ke: torch.Tensor,
305
+ candidate_blocks: torch.Tensor,
306
+ candidate_block_size: int,
307
+ sparse_block_kv: int,
308
+ topk_tokens: int,
309
+ topk_indices: torch.Tensor,
310
+ *,
311
+ sparse_indices: torch.Tensor,
312
+ end: torch.Tensor,
313
+ col_indices: torch.Tensor,
314
+ kernel_metadata: torch.Tensor | None = None,
315
+) -> torch.Tensor:
316
+ """Sparse-MQA logits + top-k for one prefill chunk (packed KV workspace).
317
+
318
+ Args:
319
+ q: [rows, H, D] packed Q values (MXFP4 viewed as int8).
320
+ q_scale: [rows, H] int32 packed UE8M0 Q scales.
321
+ k_quant: [total_kv, D] packed K workspace (MXFP4 viewed as int8).
322
+ k_scale: [total_kv] int32 packed UE8M0 K scales.
323
+ weights: [rows, H] bf16 per-head weights; the sparse kernels take
324
+ bf16 and do not fold the Q scale in.
325
+ cu_seqlen_ks/cu_seqlen_ke: [rows] int32 per-token K bounds in the
326
+ packed workspace.
327
+ candidate_blocks: [rows, K] int32 request-local candidate block ids.
328
+ topk_indices: [rows, topk_tokens] output buffer.
329
+ sparse_indices/end/col_indices: Caller-owned scratch, see
330
+ `candidate_blocks_to_sparse_indices` and `sparse_topk_remap`.
331
+ kernel_metadata: DeepGEMM schedule from a previous call with the same
332
+ candidates and bounds (i.e. another indexer layer in the same
333
+ step). When given, the candidate expansion is skipped and
334
+ ``sparse_indices``/``end`` are assumed to be up to date.
335
+
336
+ Returns:
337
+ The DeepGEMM schedule metadata used, for reuse by later layers.
338
+ """
339
+ assert weights.dtype == torch.bfloat16, weights.dtype
340
+ if kernel_metadata is None:
341
+ candidate_blocks_to_sparse_indices(
342
+ candidate_blocks,
343
+ cu_seqlen_ks,
344
+ cu_seqlen_ke,
345
+ candidate_block_size,
346
+ sparse_block_kv,
347
+ out=(sparse_indices, end),
348
+ )
349
+ kernel_metadata = get_sparse_mqa_logits_metadata(
350
+ cu_seqlen_ks,
351
+ cu_seqlen_ke,
352
+ k_quant.shape[0],
353
+ sparse_indices,
354
+ q.dtype,
355
+ sparse_block_kv,
356
+ )
357
+ logits = fp8_fp4_sparse_mqa_logits(
358
+ (q, q_scale),
359
+ (k_quant, k_scale),
360
+ weights,
361
+ kernel_metadata,
362
+ sparse_indices.shape[1],
363
+ sparse_block_kv,
364
+ )
365
+ sparse_topk_remap(
366
+ logits,
367
+ sparse_indices,
368
+ end,
369
+ cu_seqlen_ks,
370
+ sparse_block_kv,
371
+ topk_tokens,
372
+ topk_indices,
373
+ col_indices=col_indices,
374
+ )
375
+ return kernel_metadata
376
+
377
+
378
+def sparse_mqa_logits_paged_decode(
379
+ q: torch.Tensor,
380
+ q_scale: torch.Tensor,
381
+ kv_cache: torch.Tensor,
382
+ weights: torch.Tensor,
383
+ context_lens: torch.Tensor,
384
+ block_table: torch.Tensor,
385
+ row_indices: torch.Tensor,
386
+ candidate_blocks: torch.Tensor,
387
+ candidate_block_size: int,
388
+ sparse_block_kv: int,
389
+ topk_tokens: int,
390
+ topk_indices: torch.Tensor,
391
+ *,
392
+ row_ks: torch.Tensor,
393
+ sparse_indices: torch.Tensor,
394
+ end: torch.Tensor,
395
+ col_indices: torch.Tensor,
396
+ kernel_metadata: torch.Tensor | None = None,
397
+) -> torch.Tensor:
398
+ """Sparse-MQA logits + top-k for paged decode rows.
399
+
400
+ Every query is one row (spec-decode rows are flattened by the metadata
401
+ builder), so ``block_table`` is already per row.
402
+
403
+ Args:
404
+ q: [rows, 1, H, D] packed Q values (MXFP4 viewed as int8).
405
+ q_scale: [rows, 1, H] int32 packed UE8M0 Q scales.
406
+ kv_cache: 4D [num_pages, page_kv, 1, head_bytes] uint8 fused cache
407
+ view; the page stride must be 512B-aligned.
408
+ weights: [rows, H] bf16 per-head weights.
409
+ context_lens: [rows] int32 per-row (compressed) context lengths.
410
+ block_table: [rows, P] int32 per-row page table, ``stride(-1) == 1``.
411
+ row_indices: [rows] int32 row -> request map (pairing only affects
412
+ scheduling).
413
+ candidate_blocks: [rows, K] int32 candidate block ids.
414
+ topk_indices: [rows, topk_tokens] output buffer.
415
+ row_ks: [rows] int32 zeros (paged blocks are context-relative).
416
+ sparse_indices/end/col_indices: Caller-owned scratch.
417
+ kernel_metadata: See `sparse_mqa_logits_prefill_chunk`.
418
+
419
+ Returns:
420
+ The DeepGEMM schedule metadata used, for reuse by later layers.
421
+ """
422
+ rows = q.shape[0]
423
+ page_kv = kv_cache.shape[1]
424
+ assert weights.dtype == torch.bfloat16, weights.dtype
425
+ assert kv_cache.stride(0) % 512 == 0, (
426
+ "paged cache page stride must be 512B-aligned: "
427
+ f"shape={tuple(kv_cache.shape)} stride={kv_cache.stride()}"
428
+ )
429
+ assert page_kv % sparse_block_kv == 0, (page_kv, sparse_block_kv)
430
+ assert block_table.shape[0] == rows and block_table.stride(-1) == 1
431
+ assert context_lens.shape == (rows,) and context_lens.is_contiguous()
432
+
433
+ if kernel_metadata is None:
434
+ candidate_blocks_to_sparse_indices(
435
+ candidate_blocks,
436
+ row_ks,
437
+ context_lens,
438
+ candidate_block_size,
439
+ sparse_block_kv,
440
+ out=(sparse_indices, end),
441
+ )
442
+ kernel_metadata = get_paged_sparse_mqa_logits_metadata(
443
+ context_lens,
444
+ block_table,
445
+ row_indices,
446
+ page_kv,
447
+ sparse_indices,
448
+ q.dtype,
449
+ sparse_block_kv,
450
+ )
451
+ logits = fp8_fp4_paged_sparse_mqa_logits(
452
+ (q, q_scale),
453
+ kv_cache,
454
+ weights,
455
+ kernel_metadata,
456
+ sparse_indices.shape[1],
457
+ sparse_block_kv,
458
+ )
459
+ sparse_topk_remap(
460
+ logits,
461
+ sparse_indices,
462
+ end,
463
+ row_ks,
464
+ sparse_block_kv,
465
+ topk_tokens,
466
+ topk_indices,
467
+ col_indices=col_indices,
468
+ )
469
+ return kernel_metadata
vllm/model_executor/layers/sparse_mqa_indexer.py
new
+209
@@ -0,0 +1,209 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Sparse Attention Indexer that scores only the candidate blocks.
4
+
5
+DeepSeek V4.1 two-level selection: the candidate-source indexer publishes
6
+the top candidate blocks and later indexers pick their top-k inside them.
7
+`SparseAttnIndexer` does that by computing dense logits over the whole
8
+context and masking; this layer instead calls DeepGEMM's sparse MQA-logits
9
+kernels on the candidate blocks only, so the work is O(candidate blocks)
10
+instead of O(context). It requires the `DeepseekV41SparseIndexerBackend`
11
+metadata (see ``AttentionConfig.indexer_sparse_logits``).
12
+"""
13
+
14
+import torch
15
+from torch import nn
16
+
17
+import vllm.envs as envs
18
+from vllm import _custom_ops as ops
19
+from vllm.forward_context import get_forward_context
20
+from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
21
+ has_deep_select,
22
+ sparse_mqa_logits_paged_decode,
23
+ sparse_mqa_logits_prefill_chunk,
24
+)
25
+from vllm.model_executor.layers.sparse_attn_indexer import (
26
+ _gather_workspace_shapes,
27
+ kv_cache_as_quant_view,
28
+)
29
+from vllm.platforms import current_platform
30
+from vllm.utils.deep_gemm import has_deep_gemm_sparse_mqa
31
+from vllm.v1.attention.backends.mla.indexer import (
32
+ DeepseekV32IndexerPrefillChunkMetadata,
33
+)
34
+from vllm.v1.attention.backends.mla.sparse_indexer import (
35
+ DeepseekV41SparseIndexerMetadata,
36
+)
37
+from vllm.v1.worker.workspace import current_workspace_manager
38
+
39
+
40
+def _prefill_k_workspaces(
41
+ total_seq_lens: int, head_dim: int
42
+) -> tuple[torch.Tensor, torch.Tensor]:
43
+ """The packed MXFP4 K-gather workspace ``(values, scales)``, shared with
44
+ the dense indexer layers of the same model."""
45
+ values_spec, scales_spec = _gather_workspace_shapes(
46
+ total_seq_lens, head_dim, current_platform.fp8_dtype(), use_fp4_cache=True
47
+ )
48
+ k_quant, k_scale = current_workspace_manager().get_simultaneous(
49
+ values_spec, scales_spec
50
+ )
51
+ return k_quant, k_scale
52
+
53
+
54
+def _gather_prefill_chunk_k(
55
+ kv_cache: torch.Tensor,
56
+ k_quant_full: torch.Tensor,
57
+ k_scale_full: torch.Tensor,
58
+ chunk: DeepseekV32IndexerPrefillChunkMetadata,
59
+) -> tuple[torch.Tensor, torch.Tensor]:
60
+ """Gather one prefill chunk's paged K into the packed workspace."""
61
+ assert chunk.local_cu_seq_lens is not None
62
+ k_quant = k_quant_full[: chunk.max_local_total_seq_lens]
63
+ k_scale = k_scale_full[: chunk.max_local_total_seq_lens]
64
+ if not chunk.skip_kv_gather and chunk.local_total_seq_lens > 0:
65
+ ops.cp_gather_indexer_k_quant_cache(
66
+ kv_cache,
67
+ k_quant,
68
+ k_scale,
69
+ chunk.block_table,
70
+ chunk.local_cu_seq_lens,
71
+ )
72
+ return k_quant, k_scale
73
+
74
+
75
+class SparseMQAIndexer(nn.Module):
76
+ """Candidate-consuming indexer on DeepGEMM's sparse MQA-logits kernels.
77
+
78
+ Only valid for indexer layers that read candidate blocks with the MXFP4
79
+ indexer cache on SM100. The K cache is written by the model before this
80
+ runs; ``forward`` takes the same arguments as `SparseAttnIndexer` so the
81
+ attention layer can call either.
82
+ """
83
+
84
+ weights_dtype = torch.bfloat16
85
+ """Per-head weights dtype the sparse kernels take. The fused Q RoPE-quant
86
+ kernel writes it directly so no cast runs per step."""
87
+
88
+ def __init__(
89
+ self,
90
+ k_cache,
91
+ topk_tokens: int,
92
+ head_dim: int,
93
+ max_total_seq_len: int,
94
+ topk_indices_buffer: torch.Tensor,
95
+ candidate_blocks: torch.Tensor,
96
+ candidate_block_size: int,
97
+ ):
98
+ super().__init__()
99
+ if not (
100
+ current_platform.is_cuda()
101
+ and current_platform.is_device_capability_family(100)
102
+ and has_deep_gemm_sparse_mqa()
103
+ ):
104
+ raise ValueError(
105
+ "SparseMQAIndexer requires an SM100-class GPU and DeepGEMM >= 2.8."
106
+ )
107
+ if not has_deep_select():
108
+ raise ValueError(
109
+ "SparseMQAIndexer requires the DeepSelect top-k extension "
110
+ "(vllm._deepselect_C)."
111
+ )
112
+ self.k_cache = k_cache
113
+ self.topk_tokens = topk_tokens
114
+ self.head_dim = head_dim
115
+ self.max_total_seq_len = max_total_seq_len
116
+ self.topk_indices_buffer = topk_indices_buffer
117
+ self.candidate_blocks = candidate_blocks
118
+ self.candidate_block_size = candidate_block_size
119
+
120
+ def _reserve_workspaces(self, device: torch.device) -> None:
121
+ """Profiling run: claim the K-gather workspace and the peak sparse
122
+ logits allocation so the memory estimate covers them."""
123
+ _prefill_k_workspaces(self.max_total_seq_len, self.head_dim)
124
+ max_logits_bytes = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024
125
+ torch.empty(max_logits_bytes, dtype=torch.uint8, device=device)
126
+
127
+ def forward(
128
+ self,
129
+ hidden_states: torch.Tensor,
130
+ q_quant: tuple[torch.Tensor, torch.Tensor],
131
+ k: torch.Tensor | None,
132
+ weights: torch.Tensor,
133
+ ) -> torch.Tensor:
134
+ attn_metadata = get_forward_context().attn_metadata
135
+ if not isinstance(attn_metadata, dict):
136
+ self._reserve_workspaces(hidden_states.device)
137
+ return self.topk_indices_buffer
138
+
139
+ metadata = attn_metadata[self.k_cache.prefix]
140
+ assert isinstance(metadata, DeepseekV41SparseIndexerMetadata), (
141
+ "SparseMQAIndexer needs DeepseekV41SparseIndexerBackend metadata"
142
+ )
143
+ assert k is None, "the model writes the indexer K cache"
144
+ q_values, q_scale = q_quant
145
+ kv_cache = self.k_cache.kv_cache
146
+ topk_tokens = self.topk_tokens
147
+ topk_indices_buffer = self.topk_indices_buffer
148
+ topk_indices_buffer[: hidden_states.shape[0]] = -1
149
+ sparse_block_kv = metadata.sparse_block_kv
150
+
151
+ if metadata.num_prefills > 0:
152
+ prefill = metadata.prefill
153
+ sparse_chunks = metadata.sparse_prefill
154
+ assert prefill is not None and sparse_chunks is not None
155
+ k_quant_full, k_scale_full = _prefill_k_workspaces(
156
+ self.max_total_seq_len, self.head_dim
157
+ )
158
+ for chunk, rows in zip(prefill.chunks, sparse_chunks):
159
+ k_quant, k_scale = _gather_prefill_chunk_k(
160
+ kv_cache, k_quant_full, k_scale_full, chunk
161
+ )
162
+ if chunk.local_total_seq_lens == 0:
163
+ continue # buffer already holds -1
164
+ start, end = chunk.token_start, chunk.token_end
165
+ rows.kernel_metadata = sparse_mqa_logits_prefill_chunk(
166
+ q_values[start:end].view(torch.int8),
167
+ q_scale[start:end],
168
+ k_quant.view(torch.int8),
169
+ k_scale.view(torch.int32).squeeze(-1),
170
+ weights[start:end],
171
+ chunk.cu_seqlen_ks,
172
+ chunk.cu_seqlen_ke,
173
+ self.candidate_blocks[start:end],
174
+ self.candidate_block_size,
175
+ sparse_block_kv,
176
+ topk_tokens,
177
+ topk_indices_buffer[start:end, :topk_tokens],
178
+ sparse_indices=rows.sparse_indices,
179
+ end=rows.end,
180
+ col_indices=rows.col_indices,
181
+ kernel_metadata=rows.kernel_metadata,
182
+ )
183
+
184
+ if metadata.num_decodes > 0:
185
+ decode_rows = metadata.sparse_decode
186
+ assert decode_rows is not None and decode_rows.block_table is not None
187
+ assert decode_rows.row_indices is not None
188
+ num_rows = decode_rows.row_ke.shape[0]
189
+ if num_rows > 0:
190
+ decode_rows.kernel_metadata = sparse_mqa_logits_paged_decode(
191
+ q_values[:num_rows].view(torch.int8).unsqueeze(1),
192
+ q_scale[:num_rows].unsqueeze(1),
193
+ kv_cache_as_quant_view(kv_cache, self.head_dim, use_fp4_cache=True),
194
+ weights[:num_rows],
195
+ decode_rows.row_ke,
196
+ decode_rows.block_table,
197
+ decode_rows.row_indices,
198
+ self.candidate_blocks[:num_rows],
199
+ self.candidate_block_size,
200
+ sparse_block_kv,
201
+ topk_tokens,
202
+ topk_indices_buffer[:num_rows, :topk_tokens],
203
+ row_ks=decode_rows.row_ks,
204
+ sparse_indices=decode_rows.sparse_indices,
205
+ end=decode_rows.end,
206
+ col_indices=decode_rows.col_indices,
207
+ kernel_metadata=decode_rows.kernel_metadata,
208
+ )
209
+ return topk_indices_buffer
vllm/models/deepseek_v4/common/ops/fused_indexer_q.py
+22
-2
@@ -309,12 +309,22 @@ class FusedIndexerQRopeQuantTritonKernel(
309
)
310
311
312
+def _indexer_weights_out_dtypes(vllm_config: Any) -> tuple[torch.dtype, ...]:
313
+ """Weights dtypes the model's indexer layers ask for: fp32 for the dense
314
+ scoring kernels, plus bf16 when the DeepSeek V4.1 sparse-logits indexer
315
+ (`SparseMQAIndexer`) is enabled."""
316
+ if vllm_config.attention_config.indexer_sparse_logits:
317
+ return (torch.float32, torch.bfloat16)
318
+ return (torch.float32,)
319
+
320
+
321
class FusedIndexerQRopeMxFp4TritonKernel(
322
VllmTritonJitKernel["FusedIndexerQRopeMxFp4TritonKernel.CompileKey"]
323
):
324
@dataclass(frozen=True)
325
class CompileKey:
326
dtype: torch.dtype
327
+ weights_out_dtype: torch.dtype
328
num_heads: int
329
index_q_half_rot_dim: int
330
index_q_head_dim: int
@@ -434,12 +444,14 @@ class FusedIndexerQRopeMxFp4TritonKernel(
444
self,
445
*,
446
dtype: torch.dtype,
447
+ weights_out_dtype: torch.dtype,
448
num_heads: int,
449
head_dim: int,
450
rope_dim: int,
451
) -> CompileKey:
452
return self.CompileKey(
453
dtype=dtype,
454
+ weights_out_dtype=weights_out_dtype,
455
num_heads=num_heads,
456
index_q_half_rot_dim=rope_dim // 2,
457
index_q_head_dim=head_dim,
@@ -456,6 +468,7 @@ class FusedIndexerQRopeMxFp4TritonKernel(
468
469
return self._trace_dispatch(self.dispatch)(
470
dtype=vllm_config.model_config.dtype,
471
+ weights_out_dtype=_indexer_weights_out_dtypes(vllm_config),
472
num_heads=num_heads,
473
head_dim=head_dim,
474
rope_dim=rope_dim,
@@ -491,7 +504,7 @@ class FusedIndexerQRopeMxFp4TritonKernel(
504
shape=(1, compile_key.num_heads, scale_head_dim),
505
),
506
index_weights_out=TritonWarmupTensor(
494
- torch.float32,
507
+ compile_key.weights_out_dtype,
508
shape=(1, compile_key.num_heads),
509
),
510
)
@@ -540,12 +553,17 @@ def fused_indexer_q_rope_quant(
553
index_weights_softmax_scale: float,
554
index_weights_head_scale: float,
555
use_fp4: bool = False,
556
+ weights_out_dtype: torch.dtype = torch.float32,
557
) -> tuple[
558
torch.Tensor | tuple[torch.Tensor, torch.Tensor],
559
torch.Tensor,
560
]:
561
"""Fused RoPE + quantize Q for the sparse indexer.
562
563
+ ``weights_out_dtype`` is the dtype the downstream scoring kernel takes:
564
+ fp32 for the dense MQA-logits kernels, bf16 for DeepGEMM's sparse
565
+ MQA-logits kernels (CUDA MXFP4 path only).
566
+
567
Weight-fold semantics (important — the two paths differ):
568
569
FP8 path (use_fp4=False, default):
@@ -577,7 +595,7 @@ def fused_indexer_q_rope_quant(
595
num_index_q_heads = index_q.shape[1]
596
index_q_head_dim = index_q.shape[2]
597
580
- index_weights_out = torch.empty_like(index_weights, dtype=torch.float32)
598
+ index_weights_out = torch.empty_like(index_weights, dtype=weights_out_dtype)
599
600
if use_fp4:
601
assert index_q_head_dim % MXFP4_BLOCK_SIZE == 0, (
@@ -613,6 +631,7 @@ def fused_indexer_q_rope_quant(
631
weights_out=index_weights_out,
632
)
633
elif current_platform.is_xpu():
634
+ assert weights_out_dtype == torch.float32, weights_out_dtype
635
torch.ops.vllm.xpu_deepseek_fused_indexer_q_rope_mxfp4(
636
index_q,
637
positions,
@@ -647,6 +666,7 @@ def fused_indexer_q_rope_quant(
666
index_q_scale.view(torch.int32).squeeze(-1),
667
), index_weights_out
668
669
+ assert weights_out_dtype == torch.float32, weights_out_dtype
670
fp8_dtype = current_platform.fp8_dtype()
671
use_fnuz = fp8_dtype == torch.float8_e4m3fnuz
672
fp8_max = 224.0 if use_fnuz else 448.0
vllm/models/deepseek_v4/nvidia/ops/fused_indexer_q_cutedsl.py
+15
-2
@@ -165,6 +165,7 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
165
rope_dim: int
166
num_heads: int
167
cos_sin_dtype: type[cutlass.Numeric]
168
+ weights_out_dtype: type[cutlass.Numeric]
169
coarsen: int
170
171
@staticmethod
@@ -177,6 +178,7 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
178
# process multiple heads at the same time to armotize RoPE load costs
179
coarsen = compile_key.coarsen
180
tb_size = IndexerQMxFp4Kernel.tb_size
181
+ weights_out_dtype = compile_key.weights_out_dtype
182
assert num_heads % coarsen == 0
183
184
# later we will use 32B load = 16 BF16 elems
@@ -287,7 +289,7 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
289
weight_head_id = global_tid % num_heads
290
weights_out[weight_token_id, weight_head_id] = (
291
weights[weight_token_id, weight_head_id].to(Float32) * scale
290
- )
292
+ ).to(weights_out_dtype)
293
294
@cute.jit
295
def host_entrypoint(
@@ -320,11 +322,13 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
322
self,
323
*,
324
cos_sin_dtype: type[cutlass.Numeric],
325
+ weights_out_dtype: type[cutlass.Numeric],
326
**compile_key_fields: int,
327
) -> CompileKey:
328
return self.CompileKey(
329
**compile_key_fields,
330
cos_sin_dtype=cos_sin_dtype,
331
+ weights_out_dtype=weights_out_dtype,
332
)
333
334
def get_warmup_keys(self, vllm_config: Any) -> list[CompileKey]:
@@ -340,11 +344,17 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
344
if num_heads <= 0 or head_dim <= 0 or rope_dim <= 0:
345
return []
346
347
+ # The DeepSeek V4.1 sparse-logits indexer takes bf16 weights; the
348
+ # dense scoring kernels take fp32.
349
+ weights_out_dtypes: tuple[type[cutlass.Numeric], ...] = (Float32,)
350
+ if vllm_config.attention_config.indexer_sparse_logits:
351
+ weights_out_dtypes = (Float32, BFloat16)
352
return self._trace_dispatch(self.dispatch)(
353
head_dim=head_dim,
354
rope_dim=rope_dim,
355
num_heads=num_heads,
356
cos_sin_dtype=Float32,
357
+ weights_out_dtype=weights_out_dtypes,
358
coarsen=(1, 4),
359
)
360
@@ -380,7 +390,9 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
390
divisibility=4,
391
)
392
weights_out = make_fake_tensor(
383
- Float32, (num_tokens, compile_key.num_heads), divisibility=4
393
+ compile_key.weights_out_dtype,
394
+ (num_tokens, compile_key.num_heads),
395
+ divisibility=4,
396
)
397
return (
398
positions,
@@ -413,6 +425,7 @@ class IndexerQMxFp4Kernel(VllmCuTeDSLJitKernel["IndexerQMxFp4Kernel.CompileKey"]
425
rope_dim=cos_sin_cache.shape[-1],
426
num_heads=num_heads,
427
cos_sin_dtype=torch_to_cute_dtype(cos_sin_cache.dtype),
428
+ weights_out_dtype=torch_to_cute_dtype(weights_out.dtype),
429
coarsen=1 if num_tokens < 512 else 4,
430
)
431
launch_args = (
vllm/models/deepseek_v41/attention.py
+67
-20
@@ -4,6 +4,7 @@
4
DeepseekV4 MLA Attention Layer
5
"""
6
7
+import math
8
from abc import ABC, abstractmethod
9
from collections.abc import Callable
10
from functools import cached_property
@@ -25,6 +26,7 @@ from vllm.model_executor.layers.linear import (
26
RowParallelLinear,
27
)
28
from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
29
+from vllm.model_executor.layers.sparse_mqa_indexer import SparseMQAIndexer
30
from vllm.models.common.ops import fused_q_kv_rmsnorm
31
from vllm.models.deepseek_v41.common.ops import (
32
MXFP4_BLOCK_SIZE,
@@ -63,6 +65,9 @@ from vllm.v1.attention.backends.mla.indexer import (
65
dsa_indexer_uses_fp4,
66
get_max_prefill_buffer_size,
67
)
68
+from vllm.v1.attention.backends.mla.sparse_indexer import (
69
+ DeepseekV41SparseIndexerBackend,
70
+)
71
from vllm.v1.attention.backends.mla.sparse_swa import DeepseekV4SWACache
72
from vllm.v1.kv_cache_interface import (
73
KVCacheSpec,
@@ -997,7 +1002,9 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
1002
self.cache_config = cache_config
1003
self.dtype = dtype
1004
self.compress_ratio = compress_ratio
1000
- compilation_config = get_current_vllm_config().compilation_config
1005
+ vllm_config = get_current_vllm_config()
1006
+ self.sparse_logits = vllm_config.attention_config.indexer_sparse_logits
1007
+ compilation_config = vllm_config.compilation_config
1008
if prefix in compilation_config.static_forward_context:
1009
raise ValueError(f"Duplicate layer name: {prefix}")
1010
compilation_config.static_forward_context[prefix] = self
@@ -1010,6 +1017,9 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
1017
# head_dim already carries the fp8 scale padding
1018
# tokens_per_state=1 for V3.2, >1 for DeepseekV4; same cache layout.
1019
uses_fp8_ds_mla_layout = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
1020
+ page_alignment = (
1021
+ 576 if uses_fp8_ds_mla_layout and not _use_v41_mxfp8_kv_record() else 512
1022
+ )
1023
return MLAAttentionSpec(
1024
block_size=self.cache_config.block_size,
1025
num_kv_heads=1,
@@ -1020,16 +1030,21 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
1030
# (the block stride is the sum of every page in the group), so it
1031
# has to carry the same padding: the packed record's TMA stride,
1032
# else 512B for FlashInfer sparse (#44577).
1023
- alignment=(
1024
- 576
1025
- if uses_fp8_ds_mla_layout and not _use_v41_mxfp8_kv_record()
1026
- else 512
1033
+ alignment=page_alignment,
1034
+ # DeepGEMM's paged sparse MQA logits address pages by the block
1035
+ # stride, which packs every layer's page in this layout, and need
1036
+ # it 512B-aligned; the main KV record read from the same blocks
1037
+ # needs its own TMA stride, so keep both.
1038
+ block_stride_alignment=(
1039
+ math.lcm(512, page_alignment) if self.sparse_logits else None
1040
),
1041
)
1042
1043
def forward(self): ...
1044
1045
def get_attn_backend(self) -> type[AttentionBackend]:
1046
+ if self.sparse_logits:
1047
+ return DeepseekV41SparseIndexerBackend
1048
return DeepseekV41IndexerBackend
1049
1050
@@ -1129,21 +1144,52 @@ class DeepseekV4Indexer(nn.Module):
1144
self.k_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
1145
self.k_cache = k_cache
1146
1132
- self.indexer_op = SparseAttnIndexer(
1133
- self.k_cache,
1134
- self.quant_block_size,
1135
- self.scale_fmt,
1136
- self.topk_tokens,
1137
- self.head_dim,
1138
- self.max_model_len,
1139
- self.max_total_seq_len,
1140
- self.topk_indices_buffer,
1141
- skip_k_cache_insert=True,
1142
- use_fp4_cache=self.use_fp4_kv,
1143
- compress_ratio=self.compress_ratio,
1144
- candidate_blocks=candidate_block_buffer,
1145
- candidate_block_size=candidate_block_size,
1146
- candidate_write=candidate_write,
1147
+ # Candidate consumers can score only the candidate blocks (opt-in);
1148
+ # the candidate source and pre-candidate indexers stay dense.
1149
+ use_sparse_logits = (
1150
+ vllm_config.attention_config.indexer_sparse_logits
1151
+ and candidate_block_buffer is not None
1152
+ and not candidate_write
1153
+ )
1154
+ self.indexer_op: SparseAttnIndexer | SparseMQAIndexer
1155
+ if use_sparse_logits:
1156
+ if not self.use_fp4_kv:
1157
+ raise ValueError(
1158
+ "attention_config.indexer_sparse_logits requires the MXFP4 "
1159
+ "indexer cache (attention_config.indexer_kv_dtype='mxfp4')."
1160
+ )
1161
+ assert candidate_block_buffer is not None
1162
+ assert topk_indices_buffer is not None
1163
+ self.indexer_op = SparseMQAIndexer(
1164
+ self.k_cache,
1165
+ self.topk_tokens,
1166
+ self.head_dim,
1167
+ self.max_total_seq_len,
1168
+ topk_indices_buffer,
1169
+ candidate_block_buffer,
1170
+ candidate_block_size,
1171
+ )
1172
+ else:
1173
+ self.indexer_op = SparseAttnIndexer(
1174
+ self.k_cache,
1175
+ self.quant_block_size,
1176
+ self.scale_fmt,
1177
+ self.topk_tokens,
1178
+ self.head_dim,
1179
+ self.max_model_len,
1180
+ self.max_total_seq_len,
1181
+ self.topk_indices_buffer,
1182
+ skip_k_cache_insert=True,
1183
+ use_fp4_cache=self.use_fp4_kv,
1184
+ compress_ratio=self.compress_ratio,
1185
+ candidate_blocks=candidate_block_buffer,
1186
+ candidate_block_size=candidate_block_size,
1187
+ candidate_write=candidate_write,
1188
+ )
1189
+ # The fused Q kernel writes the per-head weights in the dtype the
1190
+ # scoring kernels take, so no cast runs per step.
1191
+ self.indexer_weights_dtype = (
1192
+ SparseMQAIndexer.weights_dtype if use_sparse_logits else torch.float32
1193
)
1194
1195
def _produce_k(
@@ -1231,6 +1277,7 @@ class DeepseekV4Indexer(nn.Module):
1277
self.softmax_scale,
1278
self.n_head**-0.5,
1279
use_fp4=self.use_fp4_kv,
1280
+ weights_out_dtype=self.indexer_weights_dtype,
1281
)
1282
if isinstance(q_quant, tuple):
1283
q, q_scale = q_quant
vllm/utils/deep_gemm.py
+183
@@ -158,6 +158,10 @@ _grouped_fp4_impl: Callable[..., Any] | None = None
158
_fp8_fp4_mqa_logits_impl: Callable[..., Any] | None = None
159
_fp8_fp4_paged_mqa_logits_impl: Callable[..., Any] | None = None
160
_get_paged_mqa_logits_metadata_impl: Callable[..., Any] | None = None
161
+_fp8_fp4_sparse_mqa_logits_impl: Callable[..., Any] | None = None
162
+_fp8_fp4_paged_sparse_mqa_logits_impl: Callable[..., Any] | None = None
163
+_get_sparse_mqa_logits_metadata_impl: Callable[..., Any] | None = None
164
+_get_paged_sparse_mqa_logits_metadata_impl: Callable[..., Any] | None = None
165
_tf32_hc_prenorm_gemm_impl: Callable[..., Any] | None = None
166
_mega_mhc_impl: Callable[..., Any] | None = None
167
_get_mn_major_tma_aligned_tensor_impl: Callable[..., Any] | None = None
@@ -232,6 +236,9 @@ def _lazy_init() -> None:
236
global _grouped_impl, _grouped_masked_impl, _grouped_fp4_impl
237
global _fp8_fp4_mqa_logits_impl, _fp8_fp4_paged_mqa_logits_impl
238
global _get_paged_mqa_logits_metadata_impl
239
+ global _fp8_fp4_sparse_mqa_logits_impl, _fp8_fp4_paged_sparse_mqa_logits_impl
240
+ global _get_sparse_mqa_logits_metadata_impl
241
+ global _get_paged_sparse_mqa_logits_metadata_impl
242
global _tf32_hc_prenorm_gemm_impl, _mega_mhc_impl
243
global _get_mn_major_tma_aligned_tensor_impl
244
global _get_mk_alignment_for_contiguous_layout_impl
@@ -295,6 +302,17 @@ def _lazy_init() -> None:
302
_get_paged_mqa_logits_metadata_impl = getattr(
303
_dg, "get_paged_mqa_logits_metadata", None
304
)
305
+ # Sparse-indexer kernels (DeepGEMM >= 2.8, SM100 only).
306
+ _fp8_fp4_sparse_mqa_logits_impl = getattr(_dg, "fp8_fp4_sparse_mqa_logits", None)
307
+ _fp8_fp4_paged_sparse_mqa_logits_impl = getattr(
308
+ _dg, "fp8_fp4_paged_sparse_mqa_logits", None
309
+ )
310
+ _get_sparse_mqa_logits_metadata_impl = getattr(
311
+ _dg, "get_sparse_mqa_logits_metadata", None
312
+ )
313
+ _get_paged_sparse_mqa_logits_metadata_impl = getattr(
314
+ _dg, "get_paged_sparse_mqa_logits_metadata", None
315
+ )
316
_tf32_hc_prenorm_gemm_impl = getattr(_dg, "tf32_hc_prenorm_gemm", None)
317
_mega_mhc_impl = getattr(_dg, "mega_mhc", None)
318
_get_mn_major_tma_aligned_tensor_impl = getattr(
@@ -681,6 +699,171 @@ def fp8_fp4_paged_mqa_logits(
699
)
700
701
702
+def has_deep_gemm_sparse_mqa() -> bool:
703
+ """Whether the installed DeepGEMM provides the sparse-indexer kernels
704
+ (``fp8_fp4_(paged_)sparse_mqa_logits``, added in DeepGEMM 2.8, SM100-only)."""
705
+ _lazy_init()
706
+ return (
707
+ _fp8_fp4_sparse_mqa_logits_impl is not None
708
+ and _fp8_fp4_paged_sparse_mqa_logits_impl is not None
709
+ and _get_sparse_mqa_logits_metadata_impl is not None
710
+ and _get_paged_sparse_mqa_logits_metadata_impl is not None
711
+ )
712
+
713
+
714
+def get_sparse_mqa_logits_metadata(
715
+ cu_seqlen_ks: torch.Tensor,
716
+ cu_seqlen_ke: torch.Tensor,
717
+ num_kv_tokens: int,
718
+ sparse_kv_block_indices: torch.Tensor,
719
+ qk_dtype: torch.dtype,
720
+ sparse_block_kv: int,
721
+) -> torch.Tensor:
722
+ """Build scheduling metadata for `fp8_fp4_sparse_mqa_logits`.
723
+
724
+ Always uses the unaligned-ks variant, which degenerates to the aligned
725
+ variant when ``cu_seqlen_ks % sparse_block_kv == 0``, so callers never
726
+ need a host-side sync to pick one.
727
+
728
+ Args:
729
+ cu_seqlen_ks/cu_seqlen_ke: Per-row K range bounds in the packed KV
730
+ workspace, shape [num_q_tokens], dtype int32.
731
+ num_kv_tokens: Total KV tokens in the packed workspace.
732
+ sparse_kv_block_indices: Per-row candidate block ids, shape
733
+ [num_q_tokens, num_max_sparse_blocks], dtype int32. Each row's
734
+ valid prefix must be sorted ascending (repeats are tolerated but
735
+ wasteful); pad by repeating the last valid block. Block ``i``
736
+ covers tokens
737
+ ``[i * sparse_block_kv + ks % sparse_block_kv, ...)``.
738
+ qk_dtype: dtype of the packed Q values (``torch.int8`` for MXFP4,
739
+ ``torch.float8_e4m3fn`` for FP8).
740
+ sparse_block_kv: Tokens per sparse block, 8 or 16.
741
+ """
742
+ _lazy_init()
743
+ if _get_sparse_mqa_logits_metadata_impl is None:
744
+ return _missing()
745
+ return _get_sparse_mqa_logits_metadata_impl(
746
+ cu_seqlen_ks,
747
+ cu_seqlen_ke,
748
+ num_kv_tokens,
749
+ sparse_kv_block_indices,
750
+ qk_dtype,
751
+ sparse_block_kv,
752
+ use_unaligned_ks=True,
753
+ )
754
+
755
+
756
+def get_paged_sparse_mqa_logits_metadata(
757
+ context_lens: torch.Tensor,
758
+ block_table: torch.Tensor,
759
+ indices: torch.Tensor,
760
+ page_kv: int,
761
+ sparse_kv_block_indices: torch.Tensor,
762
+ qk_dtype: torch.dtype,
763
+ sparse_block_kv: int,
764
+) -> torch.Tensor:
765
+ """Build scheduling metadata for `fp8_fp4_paged_sparse_mqa_logits`.
766
+
767
+ Args:
768
+ context_lens: Per-row context length, shape [num_q_tokens], int32.
769
+ block_table: Per-row page table, shape [num_q_tokens, max_pages],
770
+ int32; rows of queries paired with the same request must be
771
+ identical (expand it per query row before calling).
772
+ indices: Request index for each query row, shape [num_q_tokens].
773
+ page_kv: Tokens per KV page; must be a multiple of sparse_block_kv.
774
+ sparse_kv_block_indices: Per-row logical (context-relative) candidate
775
+ block ids, [num_q_tokens, num_max_sparse_blocks], int32.
776
+ qk_dtype: dtype of the packed Q values.
777
+ sparse_block_kv: Tokens per sparse block, 8 or 16.
778
+ """
779
+ _lazy_init()
780
+ if _get_paged_sparse_mqa_logits_metadata_impl is None:
781
+ return _missing()
782
+ return _get_paged_sparse_mqa_logits_metadata_impl(
783
+ context_lens,
784
+ block_table,
785
+ indices,
786
+ page_kv,
787
+ sparse_kv_block_indices,
788
+ qk_dtype,
789
+ sparse_block_kv,
790
+ )
791
+
792
+
793
+def fp8_fp4_sparse_mqa_logits(
794
+ q: tuple[torch.Tensor, torch.Tensor],
795
+ kv: tuple[torch.Tensor, torch.Tensor],
796
+ weights: torch.Tensor,
797
+ metadata: torch.Tensor,
798
+ num_max_sparse_blocks: int,
799
+ sparse_block_kv: int,
800
+) -> torch.Tensor:
801
+ """Compute MQA logits only at the candidate blocks (prefill path).
802
+
803
+ Args:
804
+ q: ``(q_values, q_scale)``. q_values is [M, H, D] (packed FP4 viewed
805
+ as int8, or FP8); q_scale is the packed UE8M0 scale tensor [M, H]
806
+ int32 (mandatory, unlike the dense kernels).
807
+ kv: ``(kv_values, kv_scale)`` — the packed KV workspace with UE8M0
808
+ int32 scales.
809
+ weights: [M, H] ``torch.bfloat16`` (the Q scale is NOT folded in).
810
+ metadata: From `get_sparse_mqa_logits_metadata`.
811
+ num_max_sparse_blocks: Width of each sparse-index row.
812
+ sparse_block_kv: Tokens per sparse block, 8 or 16.
813
+
814
+ Returns:
815
+ bf16 logits of shape [M, num_max_sparse_blocks * sparse_block_kv];
816
+ column ``j * sparse_block_kv + o`` scores the token at
817
+ ``sparse_kv_block_indices[row, j] * sparse_block_kv + ks % sbk + o``.
818
+ """
819
+ _lazy_init()
820
+ if _fp8_fp4_sparse_mqa_logits_impl is None:
821
+ return _missing()
822
+ return _fp8_fp4_sparse_mqa_logits_impl(
823
+ q,
824
+ kv,
825
+ weights,
826
+ metadata,
827
+ num_max_sparse_blocks,
828
+ sparse_block_kv,
829
+ use_unaligned_ks=True,
830
+ )
831
+
832
+
833
+def fp8_fp4_paged_sparse_mqa_logits(
834
+ q: tuple[torch.Tensor, torch.Tensor],
835
+ kv_cache: torch.Tensor,
836
+ weights: torch.Tensor,
837
+ metadata: torch.Tensor,
838
+ num_max_sparse_blocks: int,
839
+ sparse_block_kv: int,
840
+) -> torch.Tensor:
841
+ """Compute MQA logits only at the candidate blocks (paged decode path).
842
+
843
+ Args:
844
+ q: ``(q_values, q_scale)``; q_values is [num_q_tokens, 1, H, D].
845
+ kv_cache: Fused paged cache [num_pages, page_kv, 1, D' + 4] uint8,
846
+ page stride 512B-aligned.
847
+ weights: [num_q_tokens, H] ``torch.bfloat16``.
848
+ metadata: From `get_paged_sparse_mqa_logits_metadata`.
849
+
850
+ Returns:
851
+ bf16 logits of shape
852
+ [num_q_tokens, num_max_sparse_blocks * sparse_block_kv].
853
+ """
854
+ _lazy_init()
855
+ if _fp8_fp4_paged_sparse_mqa_logits_impl is None:
856
+ return _missing()
857
+ return _fp8_fp4_paged_sparse_mqa_logits_impl(
858
+ q,
859
+ kv_cache,
860
+ weights,
861
+ metadata,
862
+ num_max_sparse_blocks,
863
+ sparse_block_kv,
864
+ )
865
+
866
+
867
def tf32_hc_prenorm_gemm(
868
x: torch.Tensor,
869
fn: torch.Tensor,
vllm/v1/attention/backends/mla/indexer.py
+6
-1
@@ -1116,6 +1116,11 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1116
for request_slice, query_slice in chunk_specs
1117
]
1118
1119
+ def _prefill_split_seq_lens(self, seq_lens_cpu: torch.Tensor) -> torch.Tensor:
1120
+ """Per-request KV lengths the prefill chunker budgets logits with;
1121
+ subclasses whose logits rows are wider than the context override."""
1122
+ return seq_lens_cpu
1123
+
1124
@staticmethod
1125
def _split_indexer_prefill_chunks(
1126
compressed_seq_lens_cpu: torch.Tensor,
@@ -1278,7 +1283,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1283
)
1284
else:
1285
chunk_specs = self._split_indexer_prefill_chunks(
1281
- compressed_seq_lens_cpu[num_decodes:],
1286
+ self._prefill_split_seq_lens(compressed_seq_lens_cpu[num_decodes:]),
1287
prefill_query_lens_cpu,
1288
self.max_prefill_buffer_size,
1289
max_logits_bytes,
vllm/v1/attention/backends/mla/sparse_indexer.py
new
+235
@@ -0,0 +1,235 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""DeepSeek V4.1 indexer backend for the DeepGEMM sparse MQA-logits path.
4
+
5
+Candidate-consuming indexer layers score only the candidate blocks published
6
+by the candidate-source layer (``fp8_fp4_(paged_)sparse_mqa_logits``) instead
7
+of dense logits over the whole context. This backend extends the dense
8
+indexer metadata with the per-row scratch that path needs, so the layer op
9
+allocates nothing per step, and lets all consumer layers of a step share one
10
+candidate expansion and DeepGEMM schedule (they all read the same candidate
11
+blocks). Opt in with ``AttentionConfig.indexer_sparse_logits``.
12
+"""
13
+
14
+from dataclasses import dataclass, fields
15
+
16
+import torch
17
+
18
+from vllm.config import VllmConfig
19
+from vllm.logger import init_logger
20
+from vllm.model_executor.kernels.attention.dsa.sparse_mqa_logits import (
21
+ check_deep_select_layout,
22
+ has_deep_select,
23
+ pick_sparse_block_kv,
24
+)
25
+from vllm.platforms import current_platform
26
+from vllm.utils.deep_gemm import has_deep_gemm_sparse_mqa
27
+from vllm.v1.attention.backend import CommonAttentionMetadata
28
+from vllm.v1.attention.backends.mla.indexer import (
29
+ DeepseekV32IndexerMetadata,
30
+ DeepseekV32IndexerMetadataBuilder,
31
+ DeepseekV41IndexerBackend,
32
+ dsa_indexer_uses_fp4,
33
+)
34
+from vllm.v1.kv_cache_interface import AttentionSpec
35
+
36
+logger = init_logger(__name__)
37
+
38
+
39
+@dataclass
40
+class SparseMQARowsMetadata:
41
+ """Per-row state for one sparse-logits call: the decode rows, or one
42
+ prefill chunk. Buffers are builder-owned views sized to the rows.
43
+
44
+ ``kernel_metadata`` (the DeepGEMM schedule) is filled by the first
45
+ consumer indexer layer of the step together with ``sparse_indices`` /
46
+ ``end``; later consumer layers reuse all three.
47
+ """
48
+
49
+ row_ks: torch.Tensor
50
+ """[rows] int32 K-range start per row; zeros for paged decode."""
51
+ row_ke: torch.Tensor
52
+ """[rows] int32 K-range end (prefill) or compressed context length."""
53
+ sparse_indices: torch.Tensor
54
+ """[rows, num_sparse_blocks] int32 DeepGEMM sparse block ids."""
55
+ end: torch.Tensor
56
+ """[rows] int32 valid sparse-column count per row."""
57
+ col_indices: torch.Tensor
58
+ """[rows, topk] int32 scratch for the sparse-column top-k."""
59
+ block_table: torch.Tensor | None = None
60
+ """Decode only: [rows, pages] per-row page table."""
61
+ row_indices: torch.Tensor | None = None
62
+ """Decode only: [rows] row -> request index."""
63
+ kernel_metadata: torch.Tensor | None = None
64
+
65
+
66
+@dataclass
67
+class DeepseekV41SparseIndexerMetadata(DeepseekV32IndexerMetadata):
68
+ sparse_block_kv: int = 0
69
+ sparse_decode: SparseMQARowsMetadata | None = None
70
+ sparse_prefill: list[SparseMQARowsMetadata] | None = None
71
+ """Parallel to ``prefill.chunks``."""
72
+
73
+
74
+class DeepseekV41SparseIndexerBackend(DeepseekV41IndexerBackend):
75
+ @staticmethod
76
+ def get_name() -> str:
77
+ return "DEEPSEEK_V41_SPARSE_INDEXER"
78
+
79
+ @staticmethod
80
+ def get_builder_cls() -> type["DeepseekV41SparseIndexerMetadataBuilder"]:
81
+ return DeepseekV41SparseIndexerMetadataBuilder
82
+
83
+
84
+class DeepseekV41SparseIndexerMetadataBuilder(DeepseekV32IndexerMetadataBuilder):
85
+ """Dense indexer metadata plus the sparse-logits row scratch.
86
+
87
+ Every requirement of the sparse kernels is checked here, at engine start,
88
+ rather than falling back to the dense path per step.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ kv_cache_spec: AttentionSpec,
94
+ layer_names: list[str],
95
+ vllm_config: VllmConfig,
96
+ device: torch.device,
97
+ **kwargs,
98
+ ) -> None:
99
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device, **kwargs)
100
+ hf_config = vllm_config.model_config.hf_text_config
101
+ candidate_block_size = getattr(hf_config, "candidate_block_size", 0)
102
+ candidate_topk_blocks = getattr(hf_config, "candidate_topk_blocks", 0)
103
+ topk_tokens = getattr(hf_config, "index_topk", 0)
104
+ prefix = "attention_config.indexer_sparse_logits"
105
+ if candidate_block_size <= 0 or candidate_topk_blocks <= 0:
106
+ raise ValueError(
107
+ f"{prefix} requires a two-level (candidate block) indexer model "
108
+ "such as DeepSeek-V4.1."
109
+ )
110
+ if not dsa_indexer_uses_fp4(vllm_config):
111
+ raise ValueError(
112
+ f"{prefix} requires the MXFP4 indexer cache "
113
+ "(attention_config.indexer_kv_dtype='mxfp4'): the sparse kernels "
114
+ "take UE8M0-packed granularity-32 scales."
115
+ )
116
+ if not (
117
+ current_platform.is_cuda()
118
+ and current_platform.is_device_capability_family(100)
119
+ ):
120
+ raise ValueError(f"{prefix} requires an SM100-class GPU.")
121
+ if not has_deep_gemm_sparse_mqa():
122
+ raise ValueError(
123
+ f"{prefix} requires DeepGEMM >= 2.8 "
124
+ "(fp8_fp4_sparse_mqa_logits not found)."
125
+ )
126
+ if self.dcp_world_size > 1 or self.use_pcp:
127
+ raise NotImplementedError(
128
+ f"{prefix} is not supported with decode/prefill context parallel."
129
+ )
130
+ if not self.supports_varlen:
131
+ raise ValueError(
132
+ f"{prefix} requires the varlen paged MQA logits decode layout."
133
+ )
134
+ # The top-k runs on the kernels' bf16 logits with DeepSelect; there is
135
+ # no fp32 fallback, so the other top-k backends cannot be honored.
136
+ topk_backend = vllm_config.kernel_config.sparse_indexer_topk_backend
137
+ if not has_deep_select():
138
+ raise ValueError(
139
+ f"{prefix} requires the DeepSelect top-k extension "
140
+ "(vllm._deepselect_C) for this GPU."
141
+ )
142
+ if topk_backend not in ("auto", "deep_select"):
143
+ raise ValueError(
144
+ f"{prefix} uses DeepSelect for the top-k; "
145
+ f"kernel_config.sparse_indexer_topk_backend='{topk_backend}' "
146
+ "is not supported with it."
147
+ )
148
+ self.sparse_block_kv = pick_sparse_block_kv(candidate_block_size)
149
+ if kv_cache_spec.block_size % self.sparse_block_kv != 0:
150
+ raise ValueError(
151
+ f"{prefix}: indexer cache pages ({kv_cache_spec.block_size} "
152
+ f"tokens) must be a multiple of {self.sparse_block_kv} tokens."
153
+ )
154
+ self.num_sparse_blocks = candidate_topk_blocks * (
155
+ candidate_block_size // self.sparse_block_kv
156
+ )
157
+ num_sparse_cols = self.num_sparse_blocks * self.sparse_block_kv
158
+ check_deep_select_layout(num_sparse_cols, topk_tokens)
159
+ # Sparse logits are bf16 [rows, num_sparse_cols]; make the prefill
160
+ # chunker (which budgets fp32 [rows, seq_len]) size chunks as if every
161
+ # row were this wide.
162
+ self.min_split_seq_len = (num_sparse_cols * 2 + 3) // 4
163
+
164
+ # Rows are indexed by batch token (decode rows first, then prefill
165
+ # chunks by token range), like ``arange_buffer``.
166
+ max_rows = self.arange_buffer.shape[0]
167
+ int32 = dict(dtype=torch.int32, device=device)
168
+ self.sparse_indices_buffer = torch.zeros(
169
+ (max_rows, self.num_sparse_blocks), **int32
170
+ )
171
+ self.end_buffer = torch.zeros(max_rows, **int32)
172
+ self.col_indices_buffer = torch.zeros((max_rows, topk_tokens), **int32)
173
+ self.zero_row_ks = torch.zeros(max_rows, **int32)
174
+ logger.info_once(
175
+ "DeepSeek V4.1 indexer: candidate consumers score only the candidate "
176
+ "blocks with DeepGEMM sparse MQA logits (%d sparse blocks of %d "
177
+ "tokens per row).",
178
+ self.num_sparse_blocks,
179
+ self.sparse_block_kv,
180
+ )
181
+
182
+ def _prefill_split_seq_lens(self, seq_lens_cpu: torch.Tensor) -> torch.Tensor:
183
+ return seq_lens_cpu.clamp(min=self.min_split_seq_len)
184
+
185
+ def _rows(self, start: int, end: int, row_ks, row_ke, **kwargs):
186
+ return SparseMQARowsMetadata(
187
+ row_ks=row_ks,
188
+ row_ke=row_ke,
189
+ sparse_indices=self.sparse_indices_buffer[start:end],
190
+ end=self.end_buffer[start:end],
191
+ col_indices=self.col_indices_buffer[start:end],
192
+ **kwargs,
193
+ )
194
+
195
+ def build(
196
+ self,
197
+ common_prefix_len: int,
198
+ common_attn_metadata: CommonAttentionMetadata,
199
+ fast_build: bool = False,
200
+ ) -> DeepseekV41SparseIndexerMetadata:
201
+ base = super().build(common_prefix_len, common_attn_metadata, fast_build)
202
+ metadata = DeepseekV41SparseIndexerMetadata(
203
+ **{f.name: getattr(base, f.name) for f in fields(base)},
204
+ sparse_block_kv=self.sparse_block_kv,
205
+ )
206
+ if base.decode is not None:
207
+ decode = base.decode
208
+ rows = decode.seq_lens.shape[0]
209
+ # The varlen layout flattens every query to one row, so the base
210
+ # decode tensors are already per row.
211
+ assert decode.seq_lens.shape[1] == 1 and not decode.requires_padding
212
+ assert decode.block_table.shape[0] == rows
213
+ metadata.sparse_decode = self._rows(
214
+ 0,
215
+ rows,
216
+ row_ks=self.zero_row_ks[:rows],
217
+ row_ke=decode.seq_lens.view(-1),
218
+ block_table=decode.block_table,
219
+ row_indices=(
220
+ decode.indices
221
+ if decode.indices is not None
222
+ else self.arange_buffer[:rows]
223
+ ),
224
+ )
225
+ if base.prefill is not None:
226
+ metadata.sparse_prefill = [
227
+ self._rows(
228
+ chunk.token_start,
229
+ chunk.token_end,
230
+ row_ks=chunk.cu_seqlen_ks,
231
+ row_ke=chunk.cu_seqlen_ke,
232
+ )
233
+ for chunk in base.prefill.chunks
234
+ ]
235
+ return metadata
vllm/v1/core/kv_cache_utils.py
+9
@@ -1608,6 +1608,15 @@ def _get_kv_cache_bytes_per_block(
1608
]
1609
if hot_page_sizes:
1610
bytes_per_block = round_up(bytes_per_block, math.lcm(*hot_page_sizes))
1611
+ stride_alignments = [
1612
+ spec.block_stride_alignment
1613
+ for group in kv_cache_groups
1614
+ for layer_name in group.layer_names
1615
+ if isinstance(spec := _get_per_layer_spec(group, layer_name), MLAAttentionSpec)
1616
+ and spec.block_stride_alignment
1617
+ ]
1618
+ if stride_alignments:
1619
+ bytes_per_block = round_up(bytes_per_block, math.lcm(*stride_alignments))
1620
return bytes_per_block
1621
1622
vllm/v1/kv_cache_interface.py
+10
-1
@@ -653,6 +653,12 @@ class MLAAttentionSpec(FullAttentionSpec):
653
is_index_group_leader: bool = False
654
storage_block_size: int | None = None
655
"""Token width used to view storage when it differs from the kernel block."""
656
+ block_stride_alignment: int | None = None
657
+ """Required alignment, in bytes, of the distance between consecutive
658
+ blocks of this cache. In block-major layouts that distance is the whole
659
+ block (all layers' pages), so the allocator rounds the block up to it.
660
+ DeepGEMM's paged sparse MQA-logits kernels address pages as
661
+ ``base + page * stride`` and need it 512B-aligned."""
662
# Group capability enabled when any member flattens a non-causal query block
663
# into decode rows. Runtime metadata still selects causal vs. non-causal mode.
664
non_causal_multi_token_decode: bool = False
@@ -674,6 +680,7 @@ class MLAAttentionSpec(FullAttentionSpec):
680
cache_role_set = {spec.cache_role for spec in specs}
681
index_group_leader_set = {spec.is_index_group_leader for spec in specs}
682
storage_block_size_set = set(spec.storage_block_size for spec in specs)
683
+ block_stride_alignment_set = {spec.block_stride_alignment for spec in specs}
684
assert (
685
len(cache_dtype_str_set) == 1
686
and len(tokens_per_state_set) == 1
@@ -681,10 +688,11 @@ class MLAAttentionSpec(FullAttentionSpec):
688
and len(cache_role_set) == 1
689
and len(index_group_leader_set) == 1
690
and len(storage_block_size_set) == 1
691
+ and len(block_stride_alignment_set) == 1
692
), (
693
"All attention layers in the same KV cache group must use the same "
694
"quantization method, tokens per state, model version, cache role, "
687
- "index-sharing role, and storage block size."
695
+ "index-sharing role, storage block size and block stride alignment."
696
)
697
merged_spec = cls(
698
block_size=specs[0].block_size,
@@ -701,6 +709,7 @@ class MLAAttentionSpec(FullAttentionSpec):
709
cache_role=cache_role_set.pop(),
710
is_index_group_leader=index_group_leader_set.pop(),
711
storage_block_size=storage_block_size_set.pop(),
712
+ block_stride_alignment=block_stride_alignment_set.pop(),
713
non_causal_multi_token_decode=any(
714
spec.non_causal_multi_token_decode for spec in specs
715
),