[Perf][DSv4] Fuse inverse RoPE + FP8 quant into FlashInfer sparse MLA (#58621)
Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Yongye Zhu committed
Sep 24, 2026 at 23:26 UTC
5ff3bbfb089a22430ed7649dcd625c287928b51c
5 files changed
+583
-104
tests/kernels/attention/test_flashmla_sparse.py
+275
@@ -511,6 +511,86 @@ def test_flashinfer_mixed_sparse_indices_separates_window_and_padded_width():
511
assert sparse_lens.cpu().tolist() == [padded_width]
512
513
514
+def test_flashinfer_mixed_sparse_indices_noncausal_rows_have_no_active_gaps():
515
+ """A DSpark non-causal row keeps -1 out of the kernel's active ranges.
516
+
517
+ Each draft token's causal window holds its first visible entries, the rest
518
+ of the block starts at column `window`, and the compressed entries follow
519
+ directly; `sparse_topk_lens` ends right after them.
520
+ """
521
+ from vllm.models.deepseek_v4.common.ops.cache_utils import (
522
+ build_flashinfer_mixed_sparse_indices,
523
+ )
524
+
525
+ device = torch.device("cuda")
526
+ window, width = 4, 8
527
+ # One request: 1 context token, a 2-token draft block, slots 0..2 visible.
528
+ decode_swa = torch.full((2, width), -1, dtype=torch.int32, device=device)
529
+ decode_swa[:, :3] = torch.arange(3, dtype=torch.int32, device=device)
530
+ compressed = torch.tensor(
531
+ [[100, 101, -1, -1]] * 2, dtype=torch.int32, device=device
532
+ )
533
+ sparse_indices, sparse_lens = build_flashinfer_mixed_sparse_indices(
534
+ decode_swa_indices=decode_swa,
535
+ decode_compressed_indices=compressed,
536
+ decode_compressed_topk_lens=torch.tensor(
537
+ [2, 2], dtype=torch.int32, device=device
538
+ ),
539
+ prefill_topk_indices=torch.empty((0, 4), dtype=torch.int32, device=device),
540
+ query_start_loc=torch.tensor([0, 2], dtype=torch.int32, device=device),
541
+ seq_lens=torch.tensor([3], dtype=torch.int32, device=device),
542
+ token_to_req_indices=torch.tensor([0, 0], dtype=torch.int32, device=device),
543
+ swa_block_table=torch.tensor([[0]], dtype=torch.int32, device=device),
544
+ swa_block_size=64,
545
+ compressed_block_table=torch.tensor([[0]], dtype=torch.int32, device=device),
546
+ compressed_block_size=64,
547
+ window_size=window,
548
+ compress_ratio=128,
549
+ topk=0,
550
+ )
551
+
552
+ assert sparse_indices.cpu().tolist() == [
553
+ # pos 1: window [0, 1]; slot 2 spills to column 4; compressed after it.
554
+ [0, 1, -1, -1, 2, 100, 101, -1, -1, -1, -1, -1],
555
+ # pos 2: window [0, 1, 2]; nothing spills; compressed at column 4.
556
+ [0, 1, 2, -1, 100, 101, -1, -1, -1, -1, -1, -1],
557
+ ]
558
+ assert sparse_lens.cpu().tolist() == [7, 6]
559
+
560
+
561
+def test_flashinfer_mixed_sparse_indices_noncausal_pad_rows_keep_min_length():
562
+ """A CUDA-graph pad token of the DSpark graph (a zero-length request) gets
563
+ a causal pad row's shape instead of negative spill arithmetic."""
564
+ from vllm.models.deepseek_v4.common.ops.cache_utils import (
565
+ build_flashinfer_mixed_sparse_indices,
566
+ )
567
+
568
+ device = torch.device("cuda")
569
+ window, width = 128, 192
570
+ decode_swa = torch.full((3, width), -1, dtype=torch.int32, device=device)
571
+ decode_swa[:2, :3] = torch.arange(3, dtype=torch.int32, device=device)
572
+ sparse_indices, sparse_lens = build_flashinfer_mixed_sparse_indices(
573
+ decode_swa_indices=decode_swa,
574
+ decode_compressed_indices=None,
575
+ decode_compressed_topk_lens=None,
576
+ prefill_topk_indices=torch.empty((0, 0), dtype=torch.int32, device=device),
577
+ # Request 1 is the padding: no query tokens, seq_len 0.
578
+ query_start_loc=torch.tensor([0, 2, 2], dtype=torch.int32, device=device),
579
+ seq_lens=torch.tensor([3, 0], dtype=torch.int32, device=device),
580
+ token_to_req_indices=torch.tensor([0, 0, 1], dtype=torch.int32, device=device),
581
+ swa_block_table=torch.zeros((2, 1), dtype=torch.int32, device=device),
582
+ swa_block_size=64,
583
+ compressed_block_table=None,
584
+ compressed_block_size=64,
585
+ window_size=window,
586
+ compress_ratio=1,
587
+ topk=0,
588
+ )
589
+
590
+ assert sparse_lens.cpu().tolist() == [129, 128, 128]
591
+ assert (sparse_indices[2] == -1).all()
592
+
593
+
594
@pytest.mark.parametrize("model", ["deepseek_v4", "deepseek_v41"])
595
@pytest.mark.parametrize("page_padding", [0, 64])
596
def test_flashinfer_sparse_forward_reads_packed_kv_rows(model, page_padding):
@@ -565,3 +645,198 @@ def test_flashinfer_sparse_forward_reads_packed_kv_rows(model, page_padding):
645
attn._forward(query, None, cache, metadata, None, True, output)
646
expected = torch.tensor([2, 3, 3], dtype=output.dtype, device=device)
647
torch.testing.assert_close(output, expected[:, None, None].expand_as(output))
648
+
649
+
650
+def _make_rope_quant_attn(mod, num_rows: int, device: str):
651
+ """A bare DSv4 FlashInfer layer set up for RopeQuant: 128 heads, fp8 KV."""
652
+ from types import SimpleNamespace
653
+
654
+ heads, groups, rank = 128, 16, 256
655
+ angles = torch.arange(2048, device=device, dtype=torch.float32)[:, None] * 0.05
656
+ angles = angles + torch.arange(32, device=device)[None] * 0.1
657
+ attn = object.__new__(mod.DeepseekV4FlashInferMLAAttention)
658
+ attn.compress_ratio = 1
659
+ attn.window_size = 128
660
+ attn.scale = 512**-0.5
661
+ attn.kv_cache_torch_dtype = torch.float8_e4m3fn
662
+ attn._flashinfer_fp8_bmm1_scale = attn.scale
663
+ attn._flashinfer_fp8_bmm2_scale = 1.0
664
+ attn.attn_sink = torch.linspace(-1, 1, heads, device=device)
665
+ attn.padded_heads = heads
666
+ attn.topk_indices_buffer = torch.empty(
667
+ (num_rows, 0), dtype=torch.int32, device=device
668
+ )
669
+ attn.rotary_emb = SimpleNamespace(
670
+ cos_sin_cache=torch.cat((angles.cos(), angles.sin()), -1).contiguous()
671
+ )
672
+ attn.n_local_heads, attn.n_local_groups, attn.o_lora_rank = heads, groups, rank
673
+ attn.nope_head_dim, attn.rope_head_dim = 448, 64
674
+ attn._einsum_recipe, attn._tma_aligned_scales = (1, 1, 128), True
675
+ attn.wo_a = SimpleNamespace(
676
+ weight=(torch.randn((groups, rank, 4096), device=device) * 0.05).to(
677
+ torch.float8_e4m3fn
678
+ ),
679
+ weight_scale=torch.ones((groups, rank, 32), device=device),
680
+ )
681
+ attn.wo_b = lambda z: z
682
+ return attn
683
+
684
+
685
+def _rope_quant_vs_unfused(attn, q, cache, metadata, positions, num_tokens):
686
+ """Run `_forward` unfused and fused; return (bf16 attention, z_unfused, z_fused)."""
687
+ from vllm.models.deepseek_v4.nvidia.ops.o_proj import rope_quant_attn_out
688
+
689
+ heads, groups = attn.n_local_heads, attn.n_local_groups
690
+ unfused = torch.empty(q.shape, dtype=torch.bfloat16, device=q.device)
691
+ attn._forward(q, None, cache, metadata, None, True, unfused)
692
+ fused = rope_quant_attn_out(q.shape[0], q.device)
693
+ metadata.flashinfer_sparse_index_cache.clear()
694
+ attn._forward(q, None, cache, metadata, None, True, fused)
695
+ z_unfused = attn._o_proj(unfused[:num_tokens, :heads], positions)
696
+ z_fused = attn._o_proj(fused, positions)[:num_tokens]
697
+ return unfused[:num_tokens], z_unfused, z_fused.view(num_tokens, groups, -1)
698
+
699
+
700
+def _assert_projects_alike(z_unfused, z_fused):
701
+ z_unfused = z_unfused.float().view_as(z_fused)
702
+ rel = (z_fused.float() - z_unfused).norm() / z_unfused.norm()
703
+ assert rel < 2e-2, rel
704
+
705
+
706
+def _skip_unless_rope_quant():
707
+ from vllm.platforms import current_platform
708
+
709
+ if not current_platform.is_device_capability_family(100):
710
+ pytest.skip("Requires FlashInfer TRTLLM sparse MLA on SM100/SM103")
711
+
712
+
713
+def test_flashinfer_rope_quant_matches_unfused_o_proj():
714
+ """RopeQuant's fused output must project like the bf16 + inv-RoPE/quant path.
715
+
716
+ One mixed step (two decodes, a three-token prefill) plus CUDA-graph pad rows,
717
+ so both calls run over the full buffer with un-rebased cum_seq_lens_q.
718
+ """
719
+ from vllm.models.deepseek_v4.nvidia import flashinfer_sparse as mod
720
+ from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
721
+
722
+ _skip_unless_rope_quant()
723
+ torch.manual_seed(0)
724
+ device = "cuda"
725
+ block_size = 64
726
+ q_lens, seq_lens = [1, 1, 3], [5, 9, 70]
727
+ num_tokens, num_rows = sum(q_lens), sum(q_lens) + 3
728
+ cache = torch.randn((3, block_size, 512), device=device).to(torch.float8_e4m3fn)
729
+ attn = _make_rope_quant_attn(mod, num_rows, device)
730
+
731
+ decode_indices = torch.full((2, 128), -1, dtype=torch.int32, device=device)
732
+ for i, seq_len in enumerate(seq_lens[:2]):
733
+ decode_indices[i, :seq_len] = torch.arange(
734
+ i * block_size, i * block_size + seq_len, device=device
735
+ )
736
+ query_start = torch.tensor([0, 1, 2, 5], dtype=torch.int32)
737
+ metadata = DeepseekSparseSWAMetadata(
738
+ block_table=torch.tensor([[0], [1], [2]], dtype=torch.int32, device=device),
739
+ slot_mapping=torch.zeros(num_tokens, dtype=torch.int64, device=device),
740
+ block_size=block_size,
741
+ seq_lens=torch.tensor(seq_lens, dtype=torch.int32, device=device),
742
+ query_start_loc=query_start.to(device),
743
+ query_start_loc_cpu=query_start,
744
+ token_to_req_indices=torch.tensor(
745
+ [0, 1, 2, 2, 2], dtype=torch.int32, device=device
746
+ ),
747
+ decode_swa_indices=decode_indices,
748
+ decode_swa_lens=torch.tensor(seq_lens[:2], dtype=torch.int32, device=device),
749
+ decode_swa_width=128,
750
+ is_valid_token=torch.ones(num_tokens, dtype=torch.bool, device=device),
751
+ num_decodes=2,
752
+ num_prefills=1,
753
+ num_decode_tokens=2,
754
+ num_prefill_tokens=3,
755
+ max_decode_query_len=1,
756
+ )
757
+ positions = torch.tensor([4, 8, 67, 68, 69], device=device)
758
+ q = torch.randn((num_rows, 128, 512), device=device).to(torch.float8_e4m3fn)
759
+ _, z_unfused, z_fused = _rope_quant_vs_unfused(
760
+ attn, q, cache, metadata, positions, num_tokens
761
+ )
762
+ _assert_projects_alike(z_unfused, z_fused)
763
+
764
+
765
+@pytest.mark.parametrize("context_len", [0, 20, 127, 900])
766
+def test_flashinfer_dspark_noncausal_block_sees_future_tokens(context_len):
767
+ """Every DSpark draft token attends to the whole block, fused or not.
768
+
769
+ The kernel counts every entry of its active index ranges as a key, so the
770
+ builder must place the block around each query's causal window without -1
771
+ gaps, down to an empty context.
772
+ """
773
+ from vllm.models.deepseek_v4.nvidia import flashinfer_sparse as mod
774
+ from vllm.v1.attention.backends.mla.compressor_utils import (
775
+ get_dspark_swa_index_width,
776
+ )
777
+ from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
778
+
779
+ _skip_unless_rope_quant()
780
+ torch.manual_seed(0)
781
+ device = "cuda"
782
+ block_size, window, draft = 64, 128, 4
783
+ width = get_dspark_swa_index_width(window, draft)
784
+ contexts = [context_len, context_len + 37]
785
+ num_reqs, num_tokens = len(contexts), len(contexts) * draft
786
+ num_rows = num_tokens + 2
787
+ pages = -(-(max(contexts) + draft) // block_size)
788
+ cache = torch.randn((num_reqs * pages, block_size, 512), device=device)
789
+ cache = cache.clamp_(-1, 1).to(torch.float8_e4m3fn)
790
+ attn = _make_rope_quant_attn(mod, num_rows, device)
791
+ attn.attn_sink = torch.full((128,), -float("inf"), device=device)
792
+
793
+ # As the DSpark SWA builder lays them out: the trailing window of context
794
+ # and the whole block, contiguous from column 0.
795
+ decode_indices = torch.full((num_tokens, width), -1, dtype=torch.int32)
796
+ visible, positions = [], []
797
+ for r, ctx in enumerate(contexts):
798
+ slots = torch.arange(max(ctx - window, 0), ctx + draft) + r * pages * block_size
799
+ for k in range(draft):
800
+ decode_indices[r * draft + k, : slots.numel()] = slots.to(torch.int32)
801
+ visible.append(slots)
802
+ positions.append(ctx + k)
803
+ query_start = torch.arange(0, num_tokens + 1, draft, dtype=torch.int32)
804
+ seq_lens = torch.tensor([c + draft for c in contexts], dtype=torch.int32)
805
+ metadata = DeepseekSparseSWAMetadata(
806
+ block_table=torch.arange(
807
+ num_reqs * pages, dtype=torch.int32, device=device
808
+ ).view(num_reqs, pages),
809
+ slot_mapping=torch.zeros(num_tokens, dtype=torch.int64, device=device),
810
+ block_size=block_size,
811
+ seq_lens=seq_lens.to(device),
812
+ query_start_loc=query_start.to(device),
813
+ query_start_loc_cpu=query_start,
814
+ token_to_req_indices=torch.arange(
815
+ num_reqs, dtype=torch.int32, device=device
816
+ ).repeat_interleave(draft),
817
+ decode_swa_indices=decode_indices.to(device),
818
+ decode_swa_lens=torch.tensor(
819
+ [v.numel() for v in visible], dtype=torch.int32, device=device
820
+ ),
821
+ decode_swa_width=width,
822
+ is_valid_token=torch.ones(num_tokens, dtype=torch.bool, device=device),
823
+ num_decodes=num_reqs,
824
+ num_prefills=0,
825
+ num_decode_tokens=num_tokens,
826
+ num_prefill_tokens=0,
827
+ max_decode_query_len=draft,
828
+ )
829
+ q = torch.randn((num_rows, 128, 512), device=device).clamp_(-1, 1)
830
+ q = q.to(torch.float8_e4m3fn)
831
+ attention, z_unfused, z_fused = _rope_quant_vs_unfused(
832
+ attn, q, cache, metadata, torch.tensor(positions, device=device), num_tokens
833
+ )
834
+
835
+ keys_all = cache.flatten(0, 1).float()
836
+ for token, slots in enumerate(visible):
837
+ keys = keys_all[slots.to(device)]
838
+ weights = torch.softmax(q[token].float() @ keys.T * attn.scale, -1)
839
+ torch.testing.assert_close(
840
+ attention[token].float(), weights @ keys, atol=0.05, rtol=0.05
841
+ )
842
+ _assert_projects_alike(z_unfused, z_fused)
vllm/models/deepseek_v4/attention.py
+37
-20
@@ -43,6 +43,7 @@ from vllm.distributed import get_tensor_model_parallel_world_size
43
from vllm.forward_context import get_forward_context
44
from vllm.logger import init_logger
45
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
46
+from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
47
from vllm.model_executor.layers.layernorm import RMSNorm
48
from vllm.model_executor.layers.quantization import QuantizationConfig
49
from vllm.model_executor.models.utils import extract_layer_index
@@ -171,14 +172,20 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
172
q: torch.Tensor,
173
kv: torch.Tensor,
174
positions: torch.Tensor,
174
- output: torch.Tensor,
175
+ output: "torch.Tensor | QuantizedActivation",
176
) -> None:
177
"""Platform-specific sparse MLA forward; writes attention into ``output``."""
178
raise NotImplementedError
179
180
@abstractmethod
180
- def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
181
- """Inverse-RoPE + wo_a + wo_b output projection (platform-specific)."""
181
+ def _o_proj(
182
+ self, o: "torch.Tensor | QuantizedActivation", positions: torch.Tensor
183
+ ) -> torch.Tensor:
184
+ """Inverse-RoPE + wo_a + wo_b output projection (platform-specific).
185
+
186
+ ``o`` is the live heads of the bf16 attention output, or the
187
+ QuantizedActivation of a layer whose ``_alloc_attn_out`` returns one.
188
+ """
189
raise NotImplementedError
190
191
def _uses_fp8_ds_mla_layout(self) -> bool:
@@ -467,14 +474,9 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
474
hidden_states: torch.Tensor,
475
llama_4_scaling: torch.Tensor | None = None,
476
) -> torch.Tensor:
470
- # Pre-allocate attention output with FlashMLA-padded head count.
471
- # The op writes into `o_padded`; we slice to n_local_heads after.
472
- num_tokens = hidden_states.shape[0]
473
- o_padded = torch.empty(
474
- (num_tokens, self.padded_heads, self.head_dim),
475
- dtype=hidden_states.dtype,
476
- device=hidden_states.device,
477
- )
477
+ # The eager attention region writes into a caller-owned buffer
478
+ # (breakable_cudagraph needs in-place outputs).
479
+ attn_out = self._alloc_attn_out(hidden_states.shape[0], hidden_states)
480
481
# Keep the attention input preparation in the captured graph. Only the
482
# sparse indexer and MLA attention run in the eager break below.
@@ -492,12 +494,27 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
494
indexer_kv_score,
495
indexer_weights,
496
positions,
495
- o_padded,
497
+ attn_out,
498
+ )
499
+ if isinstance(attn_out, torch.Tensor):
500
+ attn_out = attn_out[:, : self.n_local_heads, :]
501
+ return self._o_proj(attn_out, positions)
502
+
503
+ def _alloc_attn_out(
504
+ self, num_tokens: int, hidden_states: torch.Tensor
505
+ ) -> "torch.Tensor | QuantizedActivation":
506
+ """The buffer ``forward_mqa`` fills.
507
+
508
+ A bf16 ``[num_tokens, padded_heads, head_dim]`` buffer by default, whose
509
+ padding heads are sliced off before ``_o_proj``. A layer whose kernel
510
+ also does the inverse RoPE and the FP8 cast returns a
511
+ QuantizedActivation instead and gets it back in ``_o_proj`` whole.
512
+ """
513
+ return torch.empty(
514
+ (num_tokens, self.padded_heads, self.head_dim),
515
+ dtype=hidden_states.dtype,
516
+ device=hidden_states.device,
517
)
497
- o = o_padded[:, : self.n_local_heads, :]
498
-
499
- # Inverse-RoPE + wo_a + wo_b output projection (platform-specific).
500
- return self._o_proj(o, positions)
518
519
def _split_qkv_and_norm(
520
self, qr_kv: torch.Tensor
@@ -529,7 +546,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
546
indexer_kv_score: torch.Tensor,
547
indexer_weights: torch.Tensor,
548
positions: torch.Tensor,
532
- o_padded: torch.Tensor,
549
+ o_padded: "torch.Tensor | QuantizedActivation",
550
) -> None:
551
"""Wide eager region: the whole of ``_prepare_and_attn`` runs eagerly.
552
@@ -558,7 +575,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
575
indexer_kv_score: torch.Tensor,
576
indexer_weights: torch.Tensor,
577
positions: torch.Tensor,
561
- o_padded: torch.Tensor,
578
+ o_padded: "torch.Tensor | QuantizedActivation",
579
) -> None:
580
"""Attention input preparation followed by the sparse indexer and MLA.
581
@@ -723,7 +740,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
740
q: torch.Tensor,
741
kv: torch.Tensor,
742
positions: torch.Tensor,
726
- out: torch.Tensor,
743
+ out: "torch.Tensor | QuantizedActivation",
744
) -> None:
745
if self.indexer is not None and index_q is not None:
746
assert index_weights is not None
@@ -736,7 +753,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
753
)
754
755
# MLA attention writes into the pre-allocated `out` buffer
739
- # ([num_tokens, padded_heads, head_dim]).
756
+ # (see _alloc_attn_out).
757
self.forward_mqa(q, kv, positions, out)
758
759
def _fused_qnorm_rope_kv_insert(
vllm/models/deepseek_v4/common/ops/cache_utils.py
+72
-8
@@ -1099,6 +1099,7 @@ def build_flashinfer_mixed_sparse_indices(
1099
prefill_left_visible: torch.Tensor | None = None,
1100
prefill_right_visible: torch.Tensor | None = None,
1101
max_image_tokens: int = 0,
1102
+ num_rows: int | None = None,
1103
) -> tuple[torch.Tensor, torch.Tensor]:
1104
"""Build the FlashInfer DSV4 sparse-index matrix for decode-first batches.
1105
@@ -1109,6 +1110,9 @@ def build_flashinfer_mixed_sparse_indices(
1110
tokens derive their SWA window from the position and translate local
1111
compressed indices to global slots via the block tables.
1112
1113
+ ``num_rows`` (>= ``num_tokens``) sizes both outputs for a kernel call that
1114
+ spans a CUDA-graph-padded batch; the rows past ``num_tokens`` stay unset.
1115
+
1116
When ``prefill_left_visible``/``prefill_right_visible`` are given (vision
1117
variant), the SWA column region widens by ``max_image_tokens`` and prefill
1118
tokens inside an image span get a bidirectionally widened window; decode
@@ -1170,13 +1174,15 @@ def build_flashinfer_mixed_sparse_indices(
1174
# by ``sparse_topk_lens``, so padding never changes the attention result.
1175
padded_topk = max(topk, decode_compressed_topk)
1176
padded_topk = (padded_topk + 3) // 4 * 4
1177
+ num_rows = num_tokens if num_rows is None else num_rows
1178
+ assert num_rows >= num_tokens
1179
sparse_indices = torch.empty(
1174
- (num_tokens, swa_total_width + padded_topk),
1180
+ (num_rows, swa_total_width + padded_topk),
1181
dtype=torch.int32,
1182
device=decode_swa_indices.device,
1183
)
1184
sparse_topk_lens = torch.empty(
1179
- num_tokens, dtype=torch.int32, device=decode_swa_indices.device
1185
+ num_rows, dtype=torch.int32, device=decode_swa_indices.device
1186
)
1187
if num_tokens == 0:
1188
return sparse_indices, sparse_topk_lens
@@ -1193,9 +1199,10 @@ def build_flashinfer_mixed_sparse_indices(
1199
if compressed_block_span is None
1200
else compressed_block_span
1201
)
1202
+ # The launch grid follows the output rows, so hand over only the real ones.
1203
_BUILD_FLASHINFER_MIXED_SPARSE_INDICES_KERNEL(
1197
- sparse_indices,
1198
- sparse_topk_lens,
1204
+ sparse_indices[:num_tokens],
1205
+ sparse_topk_lens[:num_tokens],
1206
decode_swa_indices,
1207
decode_compressed_indices,
1208
decode_compressed_topk_lens,
@@ -1322,12 +1329,52 @@ class BuildFlashinferMixedSparseIndicesKernel(
1329
SWA_TOTAL_WIDTH: tl.constexpr = SWA_INDEX_WIDTH + IMAGE_WIDTH
1330
1331
if token_idx < NUM_DECODE_TOKENS:
1332
+ # A decode row wider than the window is DSpark's non-causal block:
1333
+ # the block-anchored window of context followed by the whole block,
1334
+ # contiguous from column 0. The kernel treats every entry of its
1335
+ # active ranges as a key -- the first min(WINDOW_SIZE, pos + 1)
1336
+ # columns and [WINDOW_SIZE, sparse_topk_lens) -- so -1 must not
1337
+ # appear in either. Fill the causal window with the first visible
1338
+ # entries and put the rest from column WINDOW_SIZE on, followed
1339
+ # directly by the compressed entries.
1340
+ NONCAUSAL: tl.constexpr = SWA_INDEX_WIDTH > WINDOW_SIZE
1341
+ compressed_start = SWA_TOTAL_WIDTH
1342
+ if NONCAUSAL:
1343
+ req_idx = tl.load(token_to_req_indices_ptr + token_idx)
1344
+ query_start = tl.load(query_start_loc_ptr + req_idx)
1345
+ query_len = tl.load(query_start_loc_ptr + req_idx + 1) - query_start
1346
+ context_len = tl.load(seq_lens_ptr + req_idx) - query_len
1347
+ token_in_query = token_idx - query_start
1348
+ window_len = tl.minimum(context_len + token_in_query + 1, WINDOW_SIZE)
1349
+ spill_len = (
1350
+ tl.minimum(context_len, WINDOW_SIZE) + query_len - window_len
1351
+ )
1352
+ # A CUDA-graph pad token sits outside its empty request, so
1353
+ # the kernel never reads its row; keep it the shape of a
1354
+ # causal pad row (all -1, WINDOW_SIZE long) rather than let
1355
+ # the formulas above go negative.
1356
+ is_block_token = (token_in_query >= 0) & (token_in_query < query_len)
1357
+ window_len = tl.where(is_block_token, window_len, 0)
1358
+ spill_len = tl.where(is_block_token, spill_len, 0)
1359
+ compressed_start = WINDOW_SIZE + spill_len
1360
for i in range(0, SWA_TOTAL_WIDTH, WINDOW_BLOCK_SIZE):
1361
offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1362
mask = offset < SWA_TOTAL_WIDTH
1363
+ src = offset
1364
+ src_mask = offset < SWA_INDEX_WIDTH
1365
+ if NONCAUSAL:
1366
+ # Columns from compressed_start on belong to the loops below.
1367
+ mask = mask & (offset < compressed_start)
1368
+ spill = offset >= WINDOW_SIZE
1369
+ src = tl.where(spill, offset - WINDOW_SIZE + window_len, offset)
1370
+ src_mask = tl.where(
1371
+ spill,
1372
+ offset < WINDOW_SIZE + spill_len,
1373
+ offset < window_len,
1374
+ )
1375
values = tl.load(
1329
- decode_swa_indices_ptr + token_idx * decode_swa_stride + offset,
1330
- mask=offset < SWA_INDEX_WIDTH,
1376
+ decode_swa_indices_ptr + token_idx * decode_swa_stride + src,
1377
+ mask=src_mask,
1378
other=-1,
1379
)
1380
values = _remap_flashinfer_index(values, swa_block_size, swa_block_span)
@@ -1372,12 +1419,27 @@ class BuildFlashinferMixedSparseIndicesKernel(
1419
tl.store(
1420
sparse_indices_ptr
1421
+ token_idx * sparse_indices_stride
1375
- + SWA_TOTAL_WIDTH
1422
+ + compressed_start
1423
+ offset,
1424
values,
1425
mask=mask,
1426
)
1427
1428
+ if NONCAUSAL:
1429
+ # The row's remaining columns, left over by the shifted
1430
+ # compressed block.
1431
+ for i in range(0, SWA_TOTAL_WIDTH - WINDOW_SIZE, WINDOW_BLOCK_SIZE):
1432
+ offset = i + tl.arange(0, WINDOW_BLOCK_SIZE)
1433
+ tl.store(
1434
+ sparse_indices_ptr
1435
+ + token_idx * sparse_indices_stride
1436
+ + compressed_start
1437
+ + PADDED_TOP_K
1438
+ + offset,
1439
+ tl.full((WINDOW_BLOCK_SIZE,), -1, dtype=tl.int32),
1440
+ mask=offset < SWA_TOTAL_WIDTH - compressed_start,
1441
+ )
1442
+
1443
if DECODE_COMPRESSED_TOPK == 0:
1444
compressed_len = tl.zeros((), dtype=tl.int32)
1445
elif not DECODE_COMPRESSED_INDICES_ARE_LOCAL:
@@ -1388,7 +1450,9 @@ class BuildFlashinferMixedSparseIndicesKernel(
1450
else:
1451
compressed_len = tl.full((), DECODE_COMPRESSED_TOPK, dtype=tl.int32)
1452
1391
- tl.store(sparse_topk_lens_ptr + token_idx, SWA_TOTAL_WIDTH + compressed_len)
1453
+ tl.store(
1454
+ sparse_topk_lens_ptr + token_idx, compressed_start + compressed_len
1455
+ )
1456
return
1457
1458
prefill_idx = token_idx - NUM_DECODE_TOKENS
vllm/models/deepseek_v4/nvidia/flashinfer_sparse.py
+116
-60
@@ -9,6 +9,8 @@ import torch
9
from vllm.config import VllmConfig
10
from vllm.config.cache import CacheDType
11
from vllm.forward_context import get_forward_context
12
+from vllm.logger import init_logger
13
+from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
14
from vllm.models.deepseek_v4.attention import DeepseekV4Attention
15
from vllm.models.deepseek_v4.common.ops import (
16
build_flashinfer_mixed_sparse_indices,
@@ -17,6 +19,8 @@ from vllm.models.deepseek_v4.common.ops import (
19
from vllm.models.deepseek_v4.nvidia.ops.o_proj import (
20
compute_fp8_einsum_recipe,
21
deep_gemm_fp8_o_proj,
22
+ rope_quant_attn_out,
23
+ rope_quant_unsupported_reason,
24
)
25
from vllm.models.deepseek_v4.sparse_mla import (
26
DeepseekV4FlashMLAMetadata,
@@ -37,6 +41,8 @@ from vllm.v1.attention.backends.mla.sparse_swa import (
41
if TYPE_CHECKING:
42
from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata
43
44
+logger = init_logger(__name__)
45
+
46
_FLASHINFER_DSV4_WORKSPACE_BUFFER_SIZE = 128 * 1024 * 1024
47
_flashinfer_dsv4_workspace_by_device: dict[torch.device, torch.Tensor] = {}
48
@@ -203,7 +209,9 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
209
def get_padded_num_q_heads(cls, num_heads: int) -> int:
210
return _pad_to_supported_q_heads(num_heads)
211
206
- def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
212
+ def _o_proj(
213
+ self, o: torch.Tensor | QuantizedActivation, positions: torch.Tensor
214
+ ) -> torch.Tensor:
215
return deep_gemm_fp8_o_proj(
216
o,
217
positions,
@@ -219,9 +227,30 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
227
tma_aligned_scales=self._tma_aligned_scales,
228
)
229
222
- def __init__(self, *args, **kwargs) -> None:
223
- super().__init__(*args, **kwargs)
230
+ def _alloc_attn_out(
231
+ self, num_tokens: int, hidden_states: torch.Tensor
232
+ ) -> torch.Tensor | QuantizedActivation:
233
+ if self._fuse_rope_quant:
234
+ return rope_quant_attn_out(num_tokens, hidden_states.device)
235
+ return super()._alloc_attn_out(num_tokens, hidden_states)
236
+
237
+ def __init__(self, vllm_config: VllmConfig, *args, **kwargs) -> None:
238
+ super().__init__(vllm_config, *args, **kwargs)
239
self._einsum_recipe, self._tma_aligned_scales = compute_fp8_einsum_recipe()
240
+ # RopeQuant derives each query's RoPE position as seq_len - q_len + i,
241
+ # which matches `positions` for DSpark's non-causal draft block too,
242
+ # since every call passes the real per-request seq_lens. The one
243
+ # exception is a draft block running past max_model_len, whose clamped
244
+ # positions it does not reproduce; that only costs those drafts.
245
+ reason = rope_quant_unsupported_reason(self)
246
+ self._fuse_rope_quant = reason is None
247
+ if reason is None:
248
+ logger.info_once(
249
+ "FLASHINFER_MLA_SPARSE_DSV4 fuses the inverse RoPE and FP8 "
250
+ "quant of the attention output."
251
+ )
252
+ else:
253
+ logger.debug_once("FLASHINFER_MLA_SPARSE_DSV4 RopeQuant off: %s", reason)
254
# Per-tensor FP8 scale buffers + precomputed scalar BMM scales. Only the
255
# per-tensor FP8 cache path consumes these; bf16 reads ``self.scale``.
256
if self.kv_cache_torch_dtype != torch.float8_e4m3fn:
@@ -253,32 +282,39 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
282
q: torch.Tensor,
283
kv: torch.Tensor,
284
positions: torch.Tensor,
256
- output: torch.Tensor,
285
+ output: torch.Tensor | QuantizedActivation,
286
) -> None:
258
- # The TRTLLM-gen kernel requires h_q in {64, 128}, so the output buffer
259
- # is allocated at the padded head count while q arrives at the local
260
- # head count; _forward pads q to match before the launcher.
261
- assert output.shape[0] == q.shape[0] and output.shape[-1] == q.shape[-1], (
262
- f"output buffer shape {output.shape} incompatible with q shape {q.shape}"
263
- )
264
- assert output.shape[1] >= q.shape[1], (
265
- f"output heads {output.shape[1]} must be >= q heads {q.shape[1]}"
266
- )
267
- # Per-tensor FP8 q produces a bf16 attention output.
268
- expected_output_dtype = (
269
- torch.bfloat16 if q.dtype == torch.float8_e4m3fn else q.dtype
270
- )
271
- assert output.dtype == expected_output_dtype, (
272
- f"output dtype {output.dtype} must match expected {expected_output_dtype} "
273
- f"for q dtype {q.dtype}"
274
- )
287
+ if isinstance(output, QuantizedActivation):
288
+ assert output.data.shape[0] == q.shape[0]
289
+ else:
290
+ # The TRTLLM-gen kernel requires h_q in {64, 128}, so the output
291
+ # buffer is allocated at the padded head count while q arrives at
292
+ # the local head count; _forward pads q to match before the launcher.
293
+ assert output.shape[0] == q.shape[0] and output.shape[-1] == q.shape[-1], (
294
+ f"output buffer shape {output.shape} incompatible with q {q.shape}"
295
+ )
296
+ assert output.shape[1] >= q.shape[1], (
297
+ f"output heads {output.shape[1]} must be >= q heads {q.shape[1]}"
298
+ )
299
+ # Per-tensor FP8 q produces a bf16 attention output.
300
+ expected_output_dtype = (
301
+ torch.bfloat16 if q.dtype == torch.float8_e4m3fn else q.dtype
302
+ )
303
+ assert output.dtype == expected_output_dtype, (
304
+ f"output dtype {output.dtype} must match expected "
305
+ f"{expected_output_dtype} for q dtype {q.dtype}"
306
+ )
307
308
forward_context = get_forward_context()
309
attn_metadata = forward_context.attn_metadata
310
if attn_metadata is None:
311
# Warmup dummy run: FlashInfer reads the cache directly and lazily
312
# allocates its workspace, so nothing to reserve here.
281
- output.zero_()
313
+ if isinstance(output, QuantizedActivation):
314
+ output.data.zero_()
315
+ output.scale.zero_()
316
+ else:
317
+ output.zero_()
318
return
319
320
assert isinstance(attn_metadata, dict)
@@ -313,11 +349,12 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
349
swa_metadata: "DeepseekSparseSWAMetadata",
350
attn_metadata: DeepseekV4FlashMLAMetadata | None,
351
swa_only: bool,
352
+ num_rows: int | None = None,
353
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
354
"""Build the combined sparse-index tensors for the mixed batch.
355
356
Returns ``(compressed_kv_cache, seq_lens, sparse_indices,
320
- sparse_topk_lens)``.
357
+ sparse_topk_lens)``, sized ``num_rows`` (default: the real tokens).
358
"""
359
num_decodes = swa_metadata.num_decodes
360
num_prefills = swa_metadata.num_prefills
@@ -412,6 +449,8 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
449
if swa_only
450
else ("c128a" if self.compress_ratio == 128 else "c4a")
451
)
452
+ if num_rows is not None:
453
+ cache_key = f"{cache_key}:{num_rows}"
454
cached_sparse = swa_metadata.flashinfer_sparse_index_cache.get(cache_key, None)
455
if cached_sparse is None:
456
swa_block_span = _packed_block_span(swa_k_cache)
@@ -439,8 +478,9 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
478
prefill_right_visible=swa_metadata.prefill_right_visible,
479
# getattr for tests that bypass __init__ via object.__new__.
480
max_image_tokens=getattr(self, "max_image_tokens", 0),
481
+ num_rows=num_rows,
482
)
443
- if cache_key != "c4a":
483
+ if not cache_key.startswith("c4a"):
484
swa_metadata.flashinfer_sparse_index_cache[cache_key] = (
485
sparse_indices,
486
sparse_topk_lens,
@@ -457,7 +497,7 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
497
swa_metadata: "DeepseekSparseSWAMetadata",
498
attn_metadata: DeepseekV4FlashMLAMetadata | None,
499
swa_only: bool,
460
- output: torch.Tensor,
500
+ output: torch.Tensor | QuantizedActivation,
501
) -> None:
502
assert self.kv_cache_torch_dtype in (torch.bfloat16, torch.float8_e4m3fn)
503
num_decodes = swa_metadata.num_decodes
@@ -468,6 +508,11 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
508
num_tokens = num_decode_tokens + num_prefill_tokens
509
if num_tokens == 0:
510
return
511
+ # RopeQuant's output is group-major with a group stride of exactly
512
+ # sum_q tokens, so both calls span every row of the (CUDA-graph
513
+ # padded) buffers and select their tokens through cum_seq_lens_q
514
+ # alone; the kernel never touches rows outside it.
515
+ rope_quant = isinstance(output, QuantizedActivation)
516
517
(
518
compressed_kv_cache,
@@ -480,12 +525,12 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
525
swa_metadata=swa_metadata,
526
attn_metadata=attn_metadata,
527
swa_only=swa_only,
528
+ num_rows=q.shape[0] if rope_quant else None,
529
)
530
531
# CUDA graph execution can pad q/output past the scheduled token count;
532
# restrict to the real tokens (the launcher validates sparse indices).
487
- query = q[:num_tokens]
488
- output = output[:num_tokens]
533
+ query = q if rope_quant else q[:num_tokens]
534
bmm1_scale: float | torch.Tensor = self.scale
535
bmm2_scale: float | torch.Tensor = 1.0
536
if self.kv_cache_torch_dtype == torch.float8_e4m3fn:
@@ -499,7 +544,9 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
544
# The TRTLLM-gen sparse-MLA kernel requires h_q in {64, 128}; zero-pad
545
# the query heads to the allocated output head count. Padded heads attend
546
# to the shared KV and are sliced off downstream (output is padded too).
502
- padded_heads = output.shape[1]
547
+ padded_heads = (
548
+ output.shape[1] if isinstance(output, torch.Tensor) else self.padded_heads
549
+ )
550
if query.shape[1] < padded_heads:
551
padded_query = query.new_zeros(
552
(query.shape[0], padded_heads, query.shape[2])
@@ -512,51 +559,60 @@ class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
559
query_start_loc_cpu = swa_metadata.query_start_loc_cpu
560
assert query_start_loc is not None and query_start_loc_cpu is not None
561
515
- # Keep the TRTLLM-gen decode/prefill split: the launcher is tuned for
516
- # uniform-q batches, and this avoids flattening mixed batches into one call.
517
- if num_decode_tokens > 0:
518
- decode_cu = query_start_loc[: num_decodes + 1]
562
+ def run(
563
+ rows: slice, reqs: slice, cum_seq_lens_q: torch.Tensor, max_q_len: int
564
+ ) -> None:
565
+ if rope_quant:
566
+ assert isinstance(output, QuantizedActivation)
567
+ rows = slice(None)
568
+ out_kwargs = dict(
569
+ out=output.data,
570
+ dsv4_output_scale=output.scale,
571
+ dsv4_inv_rope_cos_sin_cache=self.rotary_emb.cos_sin_cache,
572
+ )
573
+ else:
574
+ assert isinstance(output, torch.Tensor)
575
+ out_kwargs = dict(out=output[rows])
576
flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
520
- query=query[:num_decode_tokens],
577
+ query=query[rows],
578
swa_kv_cache=swa_k_cache,
579
workspace_buffer=workspace,
523
- sparse_indices=sparse_indices[:num_decode_tokens],
580
+ sparse_indices=sparse_indices[rows],
581
sparse_indices_are_storage_offsets=True,
582
compressed_kv_cache=compressed_kv_cache,
526
- sparse_topk_lens=sparse_topk_lens[:num_decode_tokens],
527
- seq_lens=seq_lens[:num_decodes],
528
- out=output[:num_decode_tokens],
583
+ sparse_topk_lens=sparse_topk_lens[rows],
584
+ seq_lens=seq_lens[reqs],
585
bmm1_scale=bmm1_scale,
586
bmm2_scale=bmm2_scale,
587
sinks=self.attn_sink,
532
- cum_seq_lens_q=decode_cu,
533
- max_q_len=swa_metadata.max_decode_query_len,
588
+ cum_seq_lens_q=cum_seq_lens_q,
589
+ max_q_len=max_q_len,
590
+ **out_kwargs,
591
)
592
536
- if num_prefill_tokens > 0:
537
- # The prefill query view re-anchors at offset 0, so rebase the
538
- # cumulative query offsets to start at 0.
539
- prefill_cu = (
540
- query_start_loc[num_decodes : num_reqs + 1]
541
- - query_start_loc[num_decodes]
593
+ # Keep the TRTLLM-gen decode/prefill split: the launcher is tuned for
594
+ # uniform-q batches, and this avoids flattening mixed batches into one call.
595
+ if num_decode_tokens > 0:
596
+ run(
597
+ slice(0, num_decode_tokens),
598
+ slice(0, num_decodes),
599
+ query_start_loc[: num_decodes + 1],
600
+ swa_metadata.max_decode_query_len,
601
)
602
+
603
+ if num_prefill_tokens > 0:
604
+ prefill_cu = query_start_loc[num_decodes : num_reqs + 1]
605
+ if not rope_quant:
606
+ # The prefill query view re-anchors at offset 0, so rebase the
607
+ # cumulative query offsets to start at 0.
608
+ prefill_cu = prefill_cu - query_start_loc[num_decodes]
609
prefill_cu_cpu = query_start_loc_cpu[num_decodes : num_reqs + 1]
610
prefill_lens_cpu = prefill_cu_cpu[1:] - prefill_cu_cpu[:-1]
545
- flashinfer_trtllm_batch_decode_sparse_mla_dsv4(
546
- query=query[num_decode_tokens:num_tokens],
547
- swa_kv_cache=swa_k_cache,
548
- workspace_buffer=workspace,
549
- sparse_indices=sparse_indices[num_decode_tokens:num_tokens],
550
- sparse_indices_are_storage_offsets=True,
551
- compressed_kv_cache=compressed_kv_cache,
552
- sparse_topk_lens=sparse_topk_lens[num_decode_tokens:num_tokens],
553
- seq_lens=seq_lens[num_decodes:num_reqs],
554
- out=output[num_decode_tokens:num_tokens],
555
- bmm1_scale=bmm1_scale,
556
- bmm2_scale=bmm2_scale,
557
- sinks=self.attn_sink,
558
- cum_seq_lens_q=prefill_cu,
559
- max_q_len=int(prefill_lens_cpu.max().item()),
611
+ run(
612
+ slice(num_decode_tokens, num_tokens),
613
+ slice(num_decodes, num_reqs),
614
+ prefill_cu,
615
+ int(prefill_lens_cpu.max().item()),
616
)
617
618
vllm/models/deepseek_v4/nvidia/ops/o_proj.py
+83
-16
@@ -5,11 +5,15 @@ from collections.abc import Callable
5
import torch
6
import torch.nn as nn
7
8
+from vllm.model_executor.layers.fusion.quant_activation import QuantizedActivation
9
+from vllm.model_executor.layers.quantization.utils.quant_utils import (
10
+ kFp8Dynamic128Sym,
11
+)
12
from vllm.models.deepseek_v4.common.ops.fused_inv_rope_fp8_quant import (
13
fused_inv_rope_fp8_quant,
14
)
15
from vllm.platforms import current_platform
12
-from vllm.utils.deep_gemm import fp8_einsum
16
+from vllm.utils.deep_gemm import fp8_einsum, get_tma_aligned_size
17
18
19
def compute_fp8_einsum_recipe(
@@ -29,7 +33,7 @@ def compute_fp8_einsum_recipe(
33
34
35
def deep_gemm_fp8_o_proj(
32
- o: torch.Tensor,
36
+ o: torch.Tensor | QuantizedActivation,
37
positions: torch.Tensor,
38
cos_sin_cache: torch.Tensor,
39
wo_a: nn.Module,
@@ -49,23 +53,31 @@ def deep_gemm_fp8_o_proj(
53
layer selects the recipe at initialization. ``wo_b`` is any callable over
54
the flattened ``z``: the projection module itself, or a wrapper that also
55
reduce-scatters its output (DeepSeek-V4.1 GEMM-RS).
56
+
57
+ A QuantizedActivation ``o`` was already rotated and cast by the attention
58
+ kernel, one ``wo_a`` group per slot with the live groups first, so only
59
+ the padding groups are sliced off.
60
"""
61
use_fp8 = wo_a.weight.dtype == torch.float8_e4m3fn
54
- o_proj_input, o_scale = fused_inv_rope_fp8_quant(
55
- o,
56
- positions,
57
- cos_sin_cache,
58
- n_groups=n_groups,
59
- heads_per_group=heads_per_group,
60
- nope_dim=nope_dim,
61
- rope_dim=rope_dim,
62
- quant_group_size=einsum_recipe[2],
63
- tma_aligned_scales=tma_aligned_scales,
64
- quantize=use_fp8,
65
- )
62
+ if isinstance(o, QuantizedActivation):
63
+ assert use_fp8
64
+ o_proj_input, o_scale = o.data[:, :n_groups], o.scale[:, :n_groups]
65
+ else:
66
+ o_proj_input, o_scale = fused_inv_rope_fp8_quant(
67
+ o,
68
+ positions,
69
+ cos_sin_cache,
70
+ n_groups=n_groups,
71
+ heads_per_group=heads_per_group,
72
+ nope_dim=nope_dim,
73
+ rope_dim=rope_dim,
74
+ quant_group_size=einsum_recipe[2],
75
+ tma_aligned_scales=tma_aligned_scales,
76
+ quantize=use_fp8,
77
+ )
78
z = torch.empty(
67
- (o.shape[0], n_groups, o_lora_rank),
68
- device=o.device,
79
+ (o_proj_input.shape[0], n_groups, o_lora_rank),
80
+ device=o_proj_input.device,
81
dtype=torch.bfloat16,
82
)
83
if use_fp8:
@@ -89,3 +101,58 @@ def deep_gemm_fp8_o_proj(
101
out=z.transpose(0, 1),
102
)
103
return wo_b(z.flatten(1))
104
+
105
+
106
+# TRTLLM-gen's DSv4 RopeQuant epilogue (flashinfer-ai/flashinfer#4918) has one
107
+# fixed schedule: 128 query heads in groups of 8 512-wide heads, and FP8 with
108
+# one packed UE8M0 scale per 128 elements.
109
+_ROPE_QUANT_NUM_HEADS = 128
110
+_ROPE_QUANT_HEADS_PER_GROUP = 8
111
+_ROPE_QUANT_HEAD_DIM = 512
112
+_ROPE_QUANT_BLOCK = 128
113
+
114
+
115
+def rope_quant_unsupported_reason(layer: torch.nn.Module) -> str | None:
116
+ """Why ``layer`` cannot take FlashInfer's fused inverse RoPE + FP8 output.
117
+
118
+ DSv4 always has 8 heads per ``wo_a`` group at any TP size, and the rest of
119
+ the kernel's fixed shape (head/RoPE dims, per-128 einsum scales, FP32 RoPE
120
+ cache) is what the SM100 layer uses anyway. What varies is the local head
121
+ count and the configs the layer also serves unquantized.
122
+ """
123
+ if layer.n_local_heads != _ROPE_QUANT_NUM_HEADS:
124
+ # Padding fewer heads up to the kernel's fixed 128 costs more
125
+ # attention than the fused epilogue saves.
126
+ return f"RopeQuant needs {_ROPE_QUANT_NUM_HEADS} local query heads"
127
+ if layer.kv_cache_torch_dtype != torch.float8_e4m3fn:
128
+ return "RopeQuant needs the per-tensor FP8 KV cache"
129
+ if layer.wo_a.weight.dtype != torch.float8_e4m3fn:
130
+ return "RopeQuant needs an FP8 wo_a"
131
+ return None
132
+
133
+
134
+def rope_quant_attn_out(num_tokens: int, device: torch.device) -> QuantizedActivation:
135
+ """Allocate the output pair the RopeQuant kernel writes.
136
+
137
+ Values are group-major, ``[num_tokens, 16, 8 * 512]`` over physical
138
+ ``[group, token, K]``. Scales are MN-major packed UE8M0, one int32 per
139
+ head over its four 128-wide blocks. Both are what ``fp8_einsum`` consumes.
140
+ """
141
+ n_groups = _ROPE_QUANT_NUM_HEADS // _ROPE_QUANT_HEADS_PER_GROUP
142
+ group_width = _ROPE_QUANT_HEADS_PER_GROUP * _ROPE_QUANT_HEAD_DIM
143
+ data = torch.empty(
144
+ (n_groups, num_tokens, group_width), dtype=torch.float8_e4m3fn, device=device
145
+ ).transpose(0, 1)
146
+ aligned = get_tma_aligned_size(num_tokens, torch.int32.itemsize)
147
+ scale = torch.empty(
148
+ (n_groups, group_width // (_ROPE_QUANT_BLOCK * 4), aligned),
149
+ dtype=torch.int32,
150
+ device=device,
151
+ ).permute(2, 0, 1)[:num_tokens]
152
+ return QuantizedActivation(
153
+ data=data,
154
+ scale=scale,
155
+ orig_dtype=torch.bfloat16,
156
+ orig_shape=data.shape,
157
+ quant_key=kFp8Dynamic128Sym,
158
+ )