[Attention][MiniMax-M3] Add MSA speculative decode verification (#50032)
Jason Li committed
Aug 2, 2026 at 16:46 UTC
0055b8bfa3a2c7c49c51ff5962c162ba85f6de38
15 files changed
+1275
-59
.buildkite/test_areas/kernels.yaml
+12
-1
@@ -287,8 +287,17 @@ steps:
287
- vllm/cute_utils/
288
- vllm/model_executor/layers/mamba/ops/gdn_chunk_cutedsl/
289
- vllm/model_executor/layers/fused_moe/router/bf16x3_router_gemm_cutedsl.py
290
- - vllm/models/minimax_m3/nvidia/
290
- cmake/external_projects/fmha_sm100.cmake
291
+ - vllm/models/minimax_m3/common/sparse_attention.py
292
+ - vllm/models/minimax_m3/nvidia/
293
+ - vllm/config/attention.py
294
+ - vllm/v1/attention/backends/registry.py
295
+ - vllm/_custom_ops.py
296
+ - csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
297
+ - csrc/libtorch_stable/ops.h
298
+ - csrc/libtorch_stable/torch_bindings.cpp
299
+ - tests/kernels/attention/test_minimax_m3_msa_cutlass_sparse_decode.py
300
+ - tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
301
- tests/kernels/mamba/test_gdn_prefill_cutedsl.py
302
- tests/kernels/test_bf16x3_router_gemm_cutedsl.py
303
- tests/kernels/attention/test_minimax_m3.py
@@ -304,6 +313,8 @@ steps:
313
- pytest -v -s tests/kernels/attention/test_flashinfer_trtllm_attention.py
314
- pytest -v -s tests/kernels/attention/test_cutlass_mla_decode.py
315
- pytest -v -s tests/kernels/attention/test_flashinfer_mla_decode.py
316
+ - pytest -v -s tests/kernels/attention/test_minimax_m3_msa_cutlass_sparse_decode.py
317
+ - pytest -v -s tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
318
- pytest -v -s tests/kernels/test_top_k_per_row.py
319
# Quantization
320
- pytest -v -s tests/kernels/quantization/test_cutlass_scaled_mm.py -k 'fp8'
cmake/external_projects/fmha_sm100.cmake
+1
-1
@@ -17,7 +17,7 @@ else()
17
FetchContent_Declare(
18
fmha_sm100
19
GIT_REPOSITORY https://github.com/vllm-project/MSA.git
20
- GIT_TAG 890aaa1a37a598ad17ccff0827fea21540d381fa
20
+ GIT_TAG 087c161814d4d9c735b46c21212a09e5f8eb92fa
21
GIT_PROGRESS TRUE
22
CONFIGURE_COMMAND ""
23
BUILD_COMMAND ""
csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+66
-23
@@ -269,6 +269,22 @@ __device__ __forceinline__ void storeElemsFp8(
269
#endif
270
}
271
272
+// Match scaled_fp8_quant(q_out): materialize q in scalar_t before applying the
273
+// inverse dequantization scale and converting it to E4M3.
274
+template <typename scalar_t>
275
+__device__ __forceinline__ void storeScaledQElemsFp8(
276
+ uint8_t* __restrict__ dst, float const (&elems)[kElemsPerLane],
277
+ float const inv_scale) {
278
+ using Converter = vllm::_typeConvert<scalar_t>;
279
+ float scaled[kElemsPerLane];
280
+#pragma unroll
281
+ for (int i = 0; i < kElemsPerLane; i++) {
282
+ auto const rounded = Converter::convert(elems[i]);
283
+ scaled[i] = static_cast<float>(rounded) * inv_scale;
284
+ }
285
+ storeElemsFp8(dst, scaled);
286
+}
287
+
288
// ────────────────────────────────────────────────────────────────────────────
289
// Kernel
290
// ────────────────────────────────────────────────────────────────────────────
@@ -297,6 +313,7 @@ template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt,
313
__global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
314
scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
315
scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
316
+ uint8_t* __restrict__ q_fp8_out, // [N, nq*128] E4M3, or nullptr
317
out_idx_t* __restrict__ index_q_out, // [N, niq*128]; scalar_t or e4m3 byte
318
scalar_t const* __restrict__ q_norm_w,
319
scalar_t const* __restrict__ k_norm_w,
@@ -308,8 +325,9 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
325
int64_t const* __restrict__ index_slot_mapping, // index K slots/nullptr
326
cache_t* __restrict__ kv_cache, // [nb,nkv,bs,2*128] or nullptr
327
out_idx_t* __restrict__ index_cache, // [nb*bs, 128]; scalar_t or e4m3 byte
311
- float const eps, int const rotary_dim, int const num_tokens, int const nq,
312
- int const nkv, int const niq, int const block_size,
328
+ float const eps, float const q_fp8_inv_scale, int const rotary_dim,
329
+ int const num_tokens, int const nq, int const nkv, int const niq,
330
+ int const block_size,
331
// kv_cache strides (in elements) for logical shape [nb, nkv, bs, 2*128].
332
// The content (last) dim is always innermost-contiguous (stride 1), so the
333
// NHD/HND layout choice is captured by the head/token strides.
@@ -445,6 +463,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
463
} else {
464
storeElems<scalar_t>(store_ptr + dim_base, elems);
465
}
466
+ if (isQ && q_fp8_out != nullptr) {
467
+ storeScaledQElemsFp8<scalar_t>(
468
+ q_fp8_out + static_cast<int64_t>(tokenIdx) * nq * kHeadDim +
469
+ slot * kHeadDim + dim_base,
470
+ elems, q_fp8_inv_scale);
471
+ }
472
}
473
474
// ── Cache inserts (sparse serving only). ───────────────────────────────
@@ -493,13 +517,14 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
517
// ────────────────────────────────────────────────────────────────────────────
518
template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt>
519
void launchFusedMiniMaxM3(
496
- scalar_t* qkv, scalar_t* q_out, void* index_q_out, scalar_t const* q_norm_w,
497
- scalar_t const* k_norm_w, scalar_t const* iq_norm_w,
498
- scalar_t const* ik_norm_w, scalar_t const* cos_sin_cache,
499
- int64_t const* positions, int64_t const* slot_mapping,
500
- int64_t const* index_slot_mapping, cache_t* kv_cache, void* index_cache,
501
- float const eps, int const rotary_dim, int const num_tokens, int const nq,
502
- int const nkv, int const niq, int const block_size,
520
+ scalar_t* qkv, scalar_t* q_out, uint8_t* q_fp8_out, void* index_q_out,
521
+ scalar_t const* q_norm_w, scalar_t const* k_norm_w,
522
+ scalar_t const* iq_norm_w, scalar_t const* ik_norm_w,
523
+ scalar_t const* cos_sin_cache, int64_t const* positions,
524
+ int64_t const* slot_mapping, int64_t const* index_slot_mapping,
525
+ cache_t* kv_cache, void* index_cache, float const eps,
526
+ float const q_fp8_inv_scale, int const rotary_dim, int const num_tokens,
527
+ int const nq, int const nkv, int const niq, int const block_size,
528
int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
529
int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
530
bool const process_index, bool const fp8_idx, cudaStream_t stream) {
@@ -540,10 +565,11 @@ void launchFusedMiniMaxM3(
565
fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
566
HAS_INDEX, INSERT, \
567
PROCESS_INDEX, FP8>, \
543
- qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, k_norm_w, \
544
- iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \
545
- index_slot_mapping, kv_cache, reinterpret_cast<OUT_T*>(index_cache), \
546
- eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
568
+ qkv, q_out, q_fp8_out, reinterpret_cast<OUT_T*>(index_q_out), \
569
+ q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
570
+ slot_mapping, index_slot_mapping, kv_cache, \
571
+ reinterpret_cast<OUT_T*>(index_cache), eps, q_fp8_inv_scale, \
572
+ rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
573
kv_s_head, kv_s_token, kv_s_dim)
574
#else
575
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
@@ -552,12 +578,12 @@ void launchFusedMiniMaxM3(
578
fusedMiniMaxM3QNormRopeKVInsertKernel< \
579
scalar_t, cache_t, kv_dt, OUT_T, HAS_INDEX, INSERT, PROCESS_INDEX, \
580
FP8><<<grid, kBlockSize, 0, stream>>>( \
555
- qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, \
556
- k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
581
+ qkv, q_out, q_fp8_out, reinterpret_cast<OUT_T*>(index_q_out), \
582
+ q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
583
slot_mapping, index_slot_mapping, kv_cache, \
558
- reinterpret_cast<OUT_T*>(index_cache), eps, rotary_dim, num_tokens, \
559
- nq, nkv, niq, block_size, kv_s_block, kv_s_head, kv_s_token, \
560
- kv_s_dim)
584
+ reinterpret_cast<OUT_T*>(index_cache), eps, q_fp8_inv_scale, \
585
+ rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
586
+ kv_s_head, kv_s_token, kv_s_dim)
587
// clang-format on
588
#endif
589
@@ -599,6 +625,9 @@ void launchFusedMiniMaxM3(
625
vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st, CACHE_T, KV_DTYPE>( \
626
reinterpret_cast<st*>(qkv.data_ptr()), \
627
q_out.has_value() ? reinterpret_cast<st*>(q_out->data_ptr()) : nullptr, \
628
+ q_fp8_out.has_value() \
629
+ ? reinterpret_cast<uint8_t*>(q_fp8_out->data_ptr()) \
630
+ : nullptr, \
631
index_q_out.has_value() \
632
? reinterpret_cast<void*>(index_q_out->data_ptr()) \
633
: nullptr, \
@@ -622,10 +651,10 @@ void launchFusedMiniMaxM3(
651
(insert_kv && process_index) \
652
? reinterpret_cast<void*>(index_cache->data_ptr()) \
653
: nullptr, \
625
- static_cast<float>(eps), static_cast<int>(rotary_dim), num_tokens, nq, \
626
- nkv, niq, static_cast<int>(block_size), kv_s_block, kv_s_head, \
627
- kv_s_token, kv_s_dim, has_index, insert_kv, process_index, fp8_idx, \
628
- stream)
654
+ static_cast<float>(eps), 1.0f / static_cast<float>(q_fp8_scale), \
655
+ static_cast<int>(rotary_dim), num_tokens, nq, nkv, niq, \
656
+ static_cast<int>(block_size), kv_s_block, kv_s_head, kv_s_token, \
657
+ kv_s_dim, has_index, insert_kv, process_index, fp8_idx, stream)
658
// clang-format on
659
660
// ────────────────────────────────────────────────────────────────────────────
@@ -649,7 +678,10 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
678
std::optional<torch::stable::Tensor> q_out, // [N, nq*128] contiguous
679
std::optional<torch::stable::Tensor>
680
index_q_out, // [N, niq*128] contiguous
652
- const std::string& kv_cache_dtype, bool skip_index_branch) {
681
+ const std::string& kv_cache_dtype, bool skip_index_branch,
682
+ std::optional<torch::stable::Tensor>
683
+ q_fp8_out, // [N, nq*128] contiguous E4M3
684
+ double q_fp8_scale) {
685
STD_TORCH_CHECK(qkv.is_cuda() && qkv.is_contiguous(),
686
"qkv must be contiguous CUDA");
687
STD_TORCH_CHECK(
@@ -780,6 +812,17 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
812
q_out->numel() == static_cast<int64_t>(num_tokens) * nq * kHeadDim,
813
"q_out must have num_tokens * num_heads * 128 elements");
814
}
815
+ if (q_fp8_out.has_value()) {
816
+ STD_TORCH_CHECK(q_fp8_out->is_cuda() && q_fp8_out->is_contiguous() &&
817
+ q_fp8_out->scalar_type() ==
818
+ torch::headeronly::ScalarType::Float8_e4m3fn,
819
+ "q_fp8_out must be a contiguous CUDA fp8 e4m3 tensor");
820
+ STD_TORCH_CHECK(
821
+ q_fp8_out->numel() == static_cast<int64_t>(num_tokens) * nq * kHeadDim,
822
+ "q_fp8_out must have num_tokens * num_heads * 128 elements");
823
+ STD_TORCH_CHECK(std::isfinite(q_fp8_scale) && q_fp8_scale > 0.0,
824
+ "q_fp8_scale must be finite and positive");
825
+ }
826
if (index_q_out.has_value()) {
827
STD_TORCH_CHECK(process_index,
828
"index_q_out requires index branch processing");
csrc/libtorch_stable/ops.h
+2
-1
@@ -376,7 +376,8 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
376
std::optional<torch::stable::Tensor> index_cache, int64_t block_size,
377
std::optional<torch::stable::Tensor> q_out,
378
std::optional<torch::stable::Tensor> index_q_out,
379
- const std::string& kv_cache_dtype, bool skip_index_branch);
379
+ const std::string& kv_cache_dtype, bool skip_index_branch,
380
+ std::optional<torch::stable::Tensor> q_fp8_out, double q_fp8_scale);
381
382
#ifdef VLLM_ENABLE_FUSED_KDA_DECODE
383
void fused_kda_decode(
csrc/libtorch_stable/torch_bindings.cpp
+2
-1
@@ -514,7 +514,8 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
514
"Tensor? slot_mapping, Tensor? index_slot_mapping, "
515
"Tensor!? kv_cache, Tensor!? index_cache, "
516
"int block_size, Tensor!? q_out, Tensor!? index_q_out, "
517
- "str kv_cache_dtype, bool skip_index_branch=False) -> ()");
517
+ "str kv_cache_dtype, bool skip_index_branch=False, "
518
+ "Tensor!? q_fp8_out=None, float q_fp8_scale=1.0) -> ()");
519
520
#ifdef VLLM_ENABLE_FUSED_KDA_DECODE
521
ops.def(
tests/kernels/attention/test_minimax_m3_msa_cutlass_sparse_decode.py
new
+561
@@ -0,0 +1,561 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Correctness tests for MiniMax M3 CUTLASS sparse decode."""
4
+
5
+import math
6
+from types import SimpleNamespace
7
+
8
+import pytest
9
+import torch
10
+
11
+from vllm import _custom_ops as ops
12
+from vllm.config import AttentionConfig
13
+from vllm.models.minimax_m3.common.ops.sparse_attn import (
14
+ minimax_m3_sparse_attn_decode,
15
+)
16
+from vllm.models.minimax_m3.common.sparse_attention import (
17
+ MiniMaxM3SparseBackend,
18
+ MiniMaxM3SparseMetadataBuilder,
19
+ MiniMaxM3SparseTritonImpl,
20
+ select_main_backend_and_impl_cls,
21
+)
22
+from vllm.models.minimax_m3.nvidia import (
23
+ sparse_attention_msa as sparse_attention_msa_module,
24
+)
25
+from vllm.models.minimax_m3.nvidia.msa_cutlass_sparse_decode import (
26
+ MSACutlassDecodePlanCache,
27
+ msa_cutlass_sparse_decode,
28
+ prepare_decode_metadata,
29
+ should_prepare_decode_metadata,
30
+)
31
+from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
32
+ MiniMaxM3SparseMSABackend,
33
+ MiniMaxM3SparseMSADecodeMetadata,
34
+ MiniMaxM3SparseMSAImpl,
35
+ MiniMaxM3SparseMSAMetadataBuilder,
36
+)
37
+from vllm.platforms import current_platform
38
+from vllm.v1.attention.backends.registry import AttentionBackendEnum
39
+
40
+if not current_platform.is_device_capability_family(100):
41
+ pytest.skip(
42
+ "fmha_sm100 sparse decode requires SM100 (Blackwell).",
43
+ allow_module_level=True,
44
+ )
45
+
46
+
47
+HEAD_DIM = 128
48
+BLOCK_SIZE = 128
49
+TOPK = 16
50
+DEFAULT_QUERY_LEN = 4
51
+SM_SCALE = HEAD_DIM**-0.5
52
+
53
+
54
+@pytest.mark.parametrize(
55
+ (
56
+ "batch_size",
57
+ "decode_query_len",
58
+ "num_q_heads",
59
+ "num_kv_heads",
60
+ "expected",
61
+ ),
62
+ [
63
+ pytest.param(8, 4, 64, 4, False, id="tp1-below-min-batch"),
64
+ pytest.param(16, 4, 64, 4, True, id="tp1-supported"),
65
+ pytest.param(16, 4, 16, 1, True, id="tp4-min-batch"),
66
+ pytest.param(24, 4, 16, 1, True, id="tp4-intermediate-batch"),
67
+ pytest.param(32, 4, 16, 1, True, id="tp4-supported"),
68
+ pytest.param(16, 1, 64, 4, True, id="tp1-query-len-1"),
69
+ pytest.param(16, 1, 16, 1, True, id="tp4-query-len-1"),
70
+ pytest.param(16, 2, 64, 4, True, id="tp1-query-len-2"),
71
+ pytest.param(16, 2, 16, 1, True, id="tp4-query-len-2"),
72
+ pytest.param(16, 32, 64, 4, True, id="query-len-upper-bound"),
73
+ pytest.param(16, 0, 64, 4, False, id="query-len-zero"),
74
+ pytest.param(16, 33, 64, 4, False, id="query-len-above-bound"),
75
+ ],
76
+)
77
+def test_msa_cutlass_decode_static_dispatch(
78
+ batch_size: int,
79
+ decode_query_len: int,
80
+ expected: bool,
81
+ num_q_heads: int,
82
+ num_kv_heads: int,
83
+) -> None:
84
+ assert (
85
+ should_prepare_decode_metadata(
86
+ batch_size,
87
+ decode_query_len,
88
+ decode_backend="cutlass",
89
+ num_q_heads=num_q_heads,
90
+ num_kv_heads=num_kv_heads,
91
+ kv_cache_dtype="fp8_e4m3",
92
+ page_size=BLOCK_SIZE,
93
+ topk_blocks=TOPK,
94
+ )
95
+ is expected
96
+ )
97
+
98
+
99
+def test_msa_cutlass_decode_static_dispatch_requires_opt_in() -> None:
100
+ assert not should_prepare_decode_metadata(
101
+ 32,
102
+ DEFAULT_QUERY_LEN,
103
+ decode_backend="triton",
104
+ num_q_heads=16,
105
+ num_kv_heads=1,
106
+ kv_cache_dtype="fp8_e4m3",
107
+ page_size=BLOCK_SIZE,
108
+ topk_blocks=TOPK,
109
+ )
110
+
111
+
112
+def test_msa_cutlass_decode_static_dispatch_accepts_fp8_alias() -> None:
113
+ assert should_prepare_decode_metadata(
114
+ 32,
115
+ DEFAULT_QUERY_LEN,
116
+ decode_backend="cutlass",
117
+ num_q_heads=16,
118
+ num_kv_heads=1,
119
+ kv_cache_dtype="fp8",
120
+ page_size=BLOCK_SIZE,
121
+ topk_blocks=TOPK,
122
+ )
123
+
124
+
125
+@pytest.mark.parametrize(
126
+ "kv_cache_dtype",
127
+ ["auto", "bfloat16", "float16", "fp8_e5m2"],
128
+)
129
+def test_msa_cutlass_decode_static_dispatch_requires_fp8_e4m3(
130
+ kv_cache_dtype: str,
131
+) -> None:
132
+ assert not should_prepare_decode_metadata(
133
+ 32,
134
+ DEFAULT_QUERY_LEN,
135
+ decode_backend="cutlass",
136
+ num_q_heads=16,
137
+ num_kv_heads=1,
138
+ kv_cache_dtype=kv_cache_dtype,
139
+ page_size=BLOCK_SIZE,
140
+ topk_blocks=TOPK,
141
+ )
142
+
143
+
144
+def test_msa_cutlass_decode_static_dispatch_requires_sm100(
145
+ monkeypatch: pytest.MonkeyPatch,
146
+) -> None:
147
+ monkeypatch.setattr(
148
+ current_platform,
149
+ "is_device_capability_family",
150
+ lambda _: False,
151
+ )
152
+ assert not should_prepare_decode_metadata(
153
+ 32,
154
+ DEFAULT_QUERY_LEN,
155
+ decode_backend="cutlass",
156
+ num_q_heads=16,
157
+ num_kv_heads=1,
158
+ kv_cache_dtype="fp8",
159
+ page_size=BLOCK_SIZE,
160
+ topk_blocks=TOPK,
161
+ )
162
+
163
+
164
+@pytest.mark.parametrize(
165
+ ("backend", "expected"),
166
+ [
167
+ (AttentionBackendEnum.CUTLASS_MSA, "cutlass"),
168
+ (AttentionBackendEnum.TRITON_MSA, "triton"),
169
+ ],
170
+)
171
+def test_msa_attention_backend_alias(
172
+ backend: AttentionBackendEnum,
173
+ expected: str,
174
+) -> None:
175
+ config = AttentionConfig(backend=backend)
176
+ assert config.backend is None
177
+ assert config.minimax_m3_msa_decode_backend == expected
178
+
179
+
180
+def test_msa_backend_owns_msa_metadata_builder(
181
+ monkeypatch: pytest.MonkeyPatch,
182
+) -> None:
183
+ backend_cls, impl_cls = select_main_backend_and_impl_cls(
184
+ topk_blocks=TOPK,
185
+ kv_cache_dtype="fp8_e4m3",
186
+ num_kv_heads=1,
187
+ )
188
+ assert backend_cls is MiniMaxM3SparseMSABackend
189
+ assert impl_cls is MiniMaxM3SparseMSAImpl
190
+
191
+ monkeypatch.setattr(
192
+ current_platform,
193
+ "is_device_capability_family",
194
+ lambda _: False,
195
+ )
196
+ backend_cls, impl_cls = select_main_backend_and_impl_cls(
197
+ topk_blocks=TOPK,
198
+ kv_cache_dtype="fp8_e4m3",
199
+ num_kv_heads=1,
200
+ )
201
+ assert backend_cls is MiniMaxM3SparseBackend
202
+ assert impl_cls is MiniMaxM3SparseTritonImpl
203
+
204
+
205
+def test_msa_metadata_builder_prepares_cutlass_for_regular_decode(
206
+ monkeypatch: pytest.MonkeyPatch,
207
+) -> None:
208
+ batch = 16
209
+ block_table = torch.zeros(batch, 3, dtype=torch.int32, device="cuda")
210
+ seq_lens = torch.full((batch,), 257, dtype=torch.int32, device="cuda")
211
+ base_metadata = SimpleNamespace(
212
+ num_decodes=batch,
213
+ decode=SimpleNamespace(
214
+ block_table=block_table,
215
+ seq_lens=seq_lens,
216
+ decode_query_len=1,
217
+ ),
218
+ )
219
+ monkeypatch.setattr(
220
+ MiniMaxM3SparseMetadataBuilder,
221
+ "build",
222
+ lambda *args, **kwargs: base_metadata,
223
+ )
224
+ expected_metadata = object()
225
+ monkeypatch.setattr(
226
+ sparse_attention_msa_module,
227
+ "prepare_decode_metadata",
228
+ lambda *args, **kwargs: expected_metadata,
229
+ )
230
+
231
+ builder = object.__new__(MiniMaxM3SparseMSAMetadataBuilder)
232
+ builder.num_q_heads = 64
233
+ builder.num_kv_heads = 4
234
+ builder.topk_blocks = TOPK
235
+ builder.kv_cache_spec = SimpleNamespace(num_kv_heads=4)
236
+ builder.kv_cache_dtype = "fp8_e4m3"
237
+ builder.decode_backend = "cutlass"
238
+ builder.msa_cutlass_plan_cache = object()
239
+
240
+ metadata = builder.build(
241
+ 0,
242
+ SimpleNamespace(
243
+ seq_lens_cpu_upper_bound=torch.full((batch,), 257, dtype=torch.int32)
244
+ ),
245
+ )
246
+
247
+ assert isinstance(metadata.decode, MiniMaxM3SparseMSADecodeMetadata)
248
+ assert metadata.decode.decode_query_len == 1
249
+ assert metadata.decode.msa_cutlass is expected_metadata
250
+
251
+
252
+def test_msa_cutlass_plan_cache_keys_query_len(
253
+ monkeypatch: pytest.MonkeyPatch,
254
+) -> None:
255
+ batch = 16
256
+ block_table = torch.zeros(batch, 3, dtype=torch.int32, device="cuda")
257
+ seq_lens = torch.full((batch,), 257, dtype=torch.int32, device="cuda")
258
+ seq_lens_cpu = torch.full((batch,), 257, dtype=torch.int32)
259
+ plan_cache = MSACutlassDecodePlanCache()
260
+ built_query_lens: list[int] = []
261
+
262
+ def fake_build_plan(**kwargs):
263
+ query_len = kwargs["decode_query_len"]
264
+ built_query_lens.append(query_len)
265
+ num_rows = batch * query_len
266
+ return (
267
+ None,
268
+ None,
269
+ None,
270
+ {
271
+ "kv_segment_lens": torch.empty(
272
+ num_rows, dtype=torch.int32, device="cuda"
273
+ ),
274
+ "qo_offset": torch.empty(num_rows, dtype=torch.int32, device="cuda"),
275
+ },
276
+ )
277
+
278
+ monkeypatch.setattr(plan_cache, "_build_plan", fake_build_plan)
279
+ first = prepare_decode_metadata(
280
+ block_table,
281
+ seq_lens,
282
+ seq_lens_cpu,
283
+ 1,
284
+ num_q_heads=64,
285
+ num_kv_heads=4,
286
+ page_size=BLOCK_SIZE,
287
+ topk_blocks=TOPK,
288
+ plan_cache=plan_cache,
289
+ )
290
+ repeated = prepare_decode_metadata(
291
+ block_table,
292
+ seq_lens,
293
+ seq_lens_cpu,
294
+ 1,
295
+ num_q_heads=64,
296
+ num_kv_heads=4,
297
+ page_size=BLOCK_SIZE,
298
+ topk_blocks=TOPK,
299
+ plan_cache=plan_cache,
300
+ )
301
+ different = prepare_decode_metadata(
302
+ block_table,
303
+ seq_lens,
304
+ seq_lens_cpu,
305
+ 2,
306
+ num_q_heads=64,
307
+ num_kv_heads=4,
308
+ page_size=BLOCK_SIZE,
309
+ topk_blocks=TOPK,
310
+ plan_cache=plan_cache,
311
+ )
312
+ current_platform.synchronize()
313
+
314
+ assert first.plan is repeated.plan
315
+ assert different.plan is not first.plan
316
+ assert built_query_lens == [1, 2]
317
+
318
+
319
+def _make_topk(
320
+ seq_lens: list[int],
321
+ num_kv_heads: int,
322
+ query_len: int,
323
+) -> torch.Tensor:
324
+ topk = torch.full(
325
+ (len(seq_lens) * query_len, num_kv_heads, TOPK),
326
+ -1,
327
+ dtype=torch.int32,
328
+ device="cuda",
329
+ )
330
+ for request, seq_len in enumerate(seq_lens):
331
+ for local_query in range(query_len):
332
+ token = request * query_len + local_query
333
+ visible_tokens = seq_len - query_len + local_query + 1
334
+ visible_pages = math.ceil(visible_tokens / BLOCK_SIZE)
335
+ topk[token, :, :visible_pages] = torch.arange(
336
+ visible_pages, dtype=torch.int32, device="cuda"
337
+ )
338
+ return topk
339
+
340
+
341
+@pytest.mark.parametrize(
342
+ (
343
+ "num_q_heads",
344
+ "num_kv_heads",
345
+ "num_request_pairs",
346
+ "query_len",
347
+ "capture_graph",
348
+ ),
349
+ [
350
+ pytest.param(64, 4, 8, 1, True, id="tp1-query-len-1"),
351
+ pytest.param(64, 4, 8, 2, False, id="tp1-query-len-2"),
352
+ pytest.param(64, 4, 8, 3, False, id="tp1-query-len-3"),
353
+ pytest.param(64, 4, 8, 4, True, id="tp1-query-len-4"),
354
+ pytest.param(64, 4, 8, 8, False, id="tp1-query-len-8"),
355
+ pytest.param(16, 1, 8, 1, True, id="tp4-query-len-1"),
356
+ pytest.param(16, 1, 16, 4, True, id="tp4-query-len-4"),
357
+ ],
358
+)
359
+def test_msa_cutlass_decode_matches_triton_with_interleaved_cache(
360
+ num_q_heads: int,
361
+ num_kv_heads: int,
362
+ num_request_pairs: int,
363
+ query_len: int,
364
+ capture_graph: bool,
365
+) -> None:
366
+ torch.manual_seed(0)
367
+ seq_lens_list = [257, 513] * num_request_pairs
368
+ seq_lens_cpu = torch.tensor(seq_lens_list, dtype=torch.int32)
369
+ seq_lens = seq_lens_cpu.cuda()
370
+ pages_per_request = [math.ceil(seq_len / BLOCK_SIZE) for seq_len in seq_lens_list]
371
+ num_pages = sum(pages_per_request)
372
+ max_pages = max(pages_per_request)
373
+
374
+ block_table = torch.zeros(
375
+ len(seq_lens_list), max_pages, dtype=torch.int32, device="cuda"
376
+ )
377
+ physical_pages = torch.randperm(num_pages, dtype=torch.int32, device="cuda")
378
+ offset = 0
379
+ for request, request_pages in enumerate(pages_per_request):
380
+ block_table[request, :request_pages] = physical_pages[
381
+ offset : offset + request_pages
382
+ ]
383
+ offset += request_pages
384
+
385
+ key = (
386
+ torch.randn(
387
+ num_pages,
388
+ num_kv_heads,
389
+ BLOCK_SIZE,
390
+ HEAD_DIM,
391
+ dtype=torch.bfloat16,
392
+ device="cuda",
393
+ )
394
+ * 0.25
395
+ ).to(torch.float8_e4m3fn)
396
+ value = (torch.randn_like(key, dtype=torch.bfloat16) * 0.25).to(torch.float8_e4m3fn)
397
+ kv_cache = torch.cat((key, value), dim=-1)
398
+ assert kv_cache.stride() == (
399
+ num_kv_heads * BLOCK_SIZE * 2 * HEAD_DIM,
400
+ BLOCK_SIZE * 2 * HEAD_DIM,
401
+ 2 * HEAD_DIM,
402
+ 1,
403
+ )
404
+
405
+ num_query_tokens = len(seq_lens_list) * query_len
406
+ query = torch.randn(
407
+ num_query_tokens,
408
+ num_q_heads,
409
+ HEAD_DIM,
410
+ dtype=torch.bfloat16,
411
+ device="cuda",
412
+ )
413
+ q_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda")
414
+ query_fp8 = torch.empty_like(query, dtype=torch.float8_e4m3fn)
415
+ ops.scaled_fp8_quant(
416
+ query.view(num_query_tokens, -1),
417
+ scale=q_scale,
418
+ output=query_fp8.view(num_query_tokens, -1),
419
+ )
420
+ query_dequantized = query_fp8.to(torch.bfloat16) * q_scale
421
+
422
+ topk_token_major = _make_topk(seq_lens_list, num_kv_heads, query_len)
423
+ expected = torch.empty_like(query)
424
+ minimax_m3_sparse_attn_decode(
425
+ query_dequantized,
426
+ kv_cache,
427
+ topk_token_major.transpose(0, 1),
428
+ block_table,
429
+ seq_lens,
430
+ num_kv_heads,
431
+ SM_SCALE,
432
+ expected,
433
+ query_len,
434
+ k_scale=None,
435
+ v_scale=None,
436
+ )
437
+
438
+ plan_cache = MSACutlassDecodePlanCache()
439
+ metadata = prepare_decode_metadata(
440
+ block_table,
441
+ seq_lens,
442
+ seq_lens_cpu,
443
+ query_len,
444
+ num_q_heads=num_q_heads,
445
+ num_kv_heads=num_kv_heads,
446
+ page_size=BLOCK_SIZE,
447
+ topk_blocks=TOPK,
448
+ plan_cache=plan_cache,
449
+ )
450
+ assert metadata.page_table.data_ptr() == block_table.data_ptr()
451
+ actual = torch.empty_like(query)
452
+ msa_cutlass_sparse_decode(
453
+ query_fp8,
454
+ kv_cache,
455
+ topk_token_major,
456
+ actual,
457
+ metadata,
458
+ scale=SM_SCALE,
459
+ q_scale_float=1.0,
460
+ k_scale_float=1.0,
461
+ v_scale_float=1.0,
462
+ )
463
+
464
+ torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
465
+
466
+ # The same captured plan must remain correct as ragged lengths change.
467
+ updated_seq_lens_list = [129, 385] * num_request_pairs
468
+ seq_lens.copy_(
469
+ torch.tensor(updated_seq_lens_list, dtype=torch.int32, device="cuda")
470
+ )
471
+ updated_seq_lens_cpu = torch.tensor(updated_seq_lens_list, dtype=torch.int32)
472
+ topk_token_major.copy_(_make_topk(updated_seq_lens_list, num_kv_heads, query_len))
473
+ updated_metadata = prepare_decode_metadata(
474
+ block_table,
475
+ seq_lens,
476
+ updated_seq_lens_cpu,
477
+ query_len,
478
+ num_q_heads=num_q_heads,
479
+ num_kv_heads=num_kv_heads,
480
+ page_size=BLOCK_SIZE,
481
+ topk_blocks=TOPK,
482
+ plan_cache=plan_cache,
483
+ )
484
+ assert updated_metadata.plan is metadata.plan
485
+ assert updated_metadata.page_table.data_ptr() == metadata.page_table.data_ptr()
486
+
487
+ minimax_m3_sparse_attn_decode(
488
+ query_dequantized,
489
+ kv_cache,
490
+ topk_token_major.transpose(0, 1),
491
+ block_table,
492
+ seq_lens,
493
+ num_kv_heads,
494
+ SM_SCALE,
495
+ expected,
496
+ query_len,
497
+ k_scale=None,
498
+ v_scale=None,
499
+ )
500
+ msa_cutlass_sparse_decode(
501
+ query_fp8,
502
+ kv_cache,
503
+ topk_token_major,
504
+ actual,
505
+ updated_metadata,
506
+ scale=SM_SCALE,
507
+ q_scale_float=1.0,
508
+ k_scale_float=1.0,
509
+ v_scale_float=1.0,
510
+ )
511
+ torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
512
+
513
+ if capture_graph:
514
+ graph = torch.cuda.CUDAGraph()
515
+ with torch.cuda.graph(graph):
516
+ msa_cutlass_sparse_decode(
517
+ query_fp8,
518
+ kv_cache,
519
+ topk_token_major,
520
+ actual,
521
+ updated_metadata,
522
+ scale=SM_SCALE,
523
+ q_scale_float=1.0,
524
+ k_scale_float=1.0,
525
+ v_scale_float=1.0,
526
+ )
527
+
528
+ replay_seq_lens_list = [257, 513] * num_request_pairs
529
+ seq_lens.copy_(
530
+ torch.tensor(replay_seq_lens_list, dtype=torch.int32, device="cuda")
531
+ )
532
+ topk_token_major.copy_(
533
+ _make_topk(replay_seq_lens_list, num_kv_heads, query_len)
534
+ )
535
+ prepare_decode_metadata(
536
+ block_table,
537
+ seq_lens,
538
+ torch.tensor(replay_seq_lens_list, dtype=torch.int32),
539
+ query_len,
540
+ num_q_heads=num_q_heads,
541
+ num_kv_heads=num_kv_heads,
542
+ page_size=BLOCK_SIZE,
543
+ topk_blocks=TOPK,
544
+ plan_cache=plan_cache,
545
+ )
546
+ minimax_m3_sparse_attn_decode(
547
+ query_dequantized,
548
+ kv_cache,
549
+ topk_token_major.transpose(0, 1),
550
+ block_table,
551
+ seq_lens,
552
+ num_kv_heads,
553
+ SM_SCALE,
554
+ expected,
555
+ query_len,
556
+ k_scale=None,
557
+ v_scale=None,
558
+ )
559
+ graph.replay()
560
+ current_platform.synchronize()
561
+ torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
+15
@@ -208,6 +208,12 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
208
# index_q here (de-interleaved from the packed qkv); k/v/index_k stay in
209
# place inside qkv and are scatter-inserted into the caches.
210
q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device)
211
+ q_fp8 = torch.empty(
212
+ num_tokens,
213
+ qsz,
214
+ dtype=torch.float8_e4m3fn,
215
+ device=device,
216
+ )
217
index_q = torch.empty(num_tokens, iqsz, dtype=dtype, device=device)
218
219
ops.fused_minimax_m3_qknorm_rope_kv_insert(
@@ -231,6 +237,8 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
237
q_out,
238
index_q,
239
kv_cache_dtype,
240
+ q_fp8_out=q_fp8,
241
+ q_fp8_scale=0.5,
242
)
243
244
# ── norm+rope parity. q/index_q land in their gather buffers; k/index_k are
@@ -262,6 +270,13 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
270
# reference materializes bf16 after the norm (the unfused boundary), so
271
# rounding-boundary elements can differ by ~1 bf16 ulp.
272
torch.testing.assert_close(q_out, q_ref, rtol=2e-2, atol=2e-2)
273
+ expected_q_fp8 = torch.empty_like(q_fp8)
274
+ ops.scaled_fp8_quant(
275
+ q_out,
276
+ scale=torch.tensor(0.5, dtype=torch.float32, device=device),
277
+ output=expected_q_fp8,
278
+ )
279
+ torch.testing.assert_close(q_fp8, expected_q_fp8, rtol=0, atol=0)
280
torch.testing.assert_close(k_out, k_ref, rtol=2e-2, atol=2e-2)
281
torch.testing.assert_close(index_q, iq_ref, rtol=1e-2, atol=1e-2)
282
torch.testing.assert_close(index_k, ik_ref, rtol=1e-2, atol=1e-2)
vllm/_custom_ops.py
+7
@@ -2655,6 +2655,8 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2655
index_q_out: torch.Tensor | None = None,
2656
kv_cache_dtype: str = "auto",
2657
skip_index_branch: bool = False,
2658
+ q_fp8_out: torch.Tensor | None = None,
2659
+ q_fp8_scale: float = 1.0,
2660
) -> None:
2661
"""Fused MiniMax-M3 attention pre-processing (in-place).
2662
@@ -2677,6 +2679,9 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2679
callers skip a separate ``.contiguous()`` copy before the SM100 sparse
2680
attention's flat TMA descriptor.
2681
2682
+ If ``q_fp8_out`` is given, the same normalized q is also written in FP8
2683
+ E4M3 using ``q_fp8_scale`` as its dequantization scale.
2684
+
2685
When ``skip_index_branch`` is true, sparse rows still keep their packed
2686
``[index_q | index_k]`` tail, but the kernel only processes the main q/k/v
2687
branches and main KV cache. This is used by MiniMax-M3 index-topk reuse
@@ -2704,6 +2709,8 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2709
index_q_out,
2710
kv_cache_dtype,
2711
skip_index_branch,
2712
+ q_fp8_out,
2713
+ q_fp8_scale,
2714
)
2715
2716
vllm/config/attention.py
+15
@@ -11,6 +11,7 @@ from vllm.v1.attention.backends.mla.prefill.registry import MLAPrefillBackendEnu
11
from vllm.v1.attention.backends.registry import AttentionBackendEnum
12
13
IndexerKVDType = Literal["bf16", "fp8", "mxfp4", "nvfp4"]
14
+MiniMaxM3MSADecodeBackend = Literal["triton", "cutlass"]
15
16
17
@config
@@ -20,6 +21,9 @@ class AttentionConfig:
21
backend: AttentionBackendEnum | None = None
22
"""Attention backend to use. Use "auto" or None for automatic selection."""
23
24
+ minimax_m3_msa_decode_backend: MiniMaxM3MSADecodeBackend = "triton"
25
+ """Sparse decode kernel used by the MiniMax M3 MSA backend."""
26
+
27
backend_per_kind: dict[str, AttentionBackendEnum] = field(default_factory=dict)
28
"""Per-KV-cache-group attention backend overrides, keyed by
29
`KVCacheSpecKind` (e.g. `{"mla_attention": "FLASHINFER_MLA",
@@ -99,6 +103,17 @@ class AttentionConfig:
103
PyTorch >= 2.9, and 128 for encoder-only attention or older PyTorch
104
versions."""
105
106
+ def __post_init__(self) -> None:
107
+ msa_aliases: dict[AttentionBackendEnum, MiniMaxM3MSADecodeBackend] = {
108
+ AttentionBackendEnum.CUTLASS_MSA: "cutlass",
109
+ AttentionBackendEnum.TRITON_MSA: "triton",
110
+ }
111
+ if self.backend in msa_aliases:
112
+ self.minimax_m3_msa_decode_backend = msa_aliases[self.backend]
113
+ # The alias selects only MiniMax's sparse decode kernel. Dense
114
+ # layers still use the platform's normal automatic backend.
115
+ self.backend = None
116
+
117
def compute_hash(self) -> str:
118
"""
119
Provide a hash that uniquely identifies all the configs
vllm/models/minimax_m3/amd/sparse_attention_msa.py
+2
@@ -23,6 +23,8 @@ class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
23
query: torch.Tensor,
24
kv_cache: torch.Tensor,
25
output: torch.Tensor,
26
+ *,
27
+ query_fp8: torch.Tensor | None = None,
28
) -> torch.Tensor:
29
from vllm.models.minimax_m3.amd.ops.sparse_pa import (
30
minimax_m3_sparse_attn_decode_aiter,
vllm/models/minimax_m3/common/sparse_attention.py
+30
-7
@@ -330,6 +330,7 @@ class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
330
*,
331
topk_blocks: int,
332
sparse_block_size: int,
333
+ msa_decode_backend: str = "triton",
334
) -> None:
335
self.num_heads = num_heads
336
self.head_size = head_size
@@ -355,6 +356,8 @@ class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
356
query: torch.Tensor,
357
kv_cache: torch.Tensor,
358
output: torch.Tensor,
359
+ *,
360
+ query_fp8: torch.Tensor | None = None,
361
) -> torch.Tensor:
362
"""Attend the queries to the indexer-selected blocks. Per kernel.
363
@@ -364,6 +367,9 @@ class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
367
"""
368
raise NotImplementedError
369
370
+ def should_use_msa_decode(self, layer_name: str) -> bool:
371
+ return False
372
+
373
374
class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
375
"""Triton block-sparse attend (``minimax_m3_sparse_attn``) + Triton decode."""
@@ -374,6 +380,8 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
380
query: torch.Tensor,
381
kv_cache: torch.Tensor,
382
output: torch.Tensor,
383
+ *,
384
+ query_fp8: torch.Tensor | None = None,
385
) -> torch.Tensor:
386
attn_metadata = get_forward_context().attn_metadata
387
if not isinstance(attn_metadata, dict):
@@ -442,18 +450,18 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
450
return output
451
452
445
-def select_main_impl_cls(
453
+def select_main_backend_and_impl_cls(
454
*,
455
topk_blocks: int,
456
kv_cache_dtype: str,
457
num_kv_heads: int,
450
-) -> type[MiniMaxM3SparseImpl]:
451
- """Pick the main attend impl off the main KV-cache dtype.
458
+) -> tuple[type[MiniMaxM3SparseBackend], type[MiniMaxM3SparseImpl]]:
459
+ """Pick the main attention backend and implementation.
460
461
Blackwell (SM100) uses the MSA attend for supported top-k block counts
462
when the KV cache is BF16 or FP8 E4M3; MI355 uses AITER sparse PA
463
with shuffle KV cache layout; Other platforms and FP8 E5M2 fall
456
- back to Triton. The MSA modules are imported lazily avoid import errors
464
+ back to Triton. The MSA modules are imported lazily to avoid import errors
465
on unsupported platforms.
466
"""
467
use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(num_kv_heads)
@@ -477,11 +485,26 @@ def select_main_impl_cls(
485
MiniMaxM3SparseAiterPAImpl,
486
)
487
480
- return MiniMaxM3SparseAiterPAImpl
488
+ return MiniMaxM3SparseBackend, MiniMaxM3SparseAiterPAImpl
489
if use_msa:
490
from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
491
+ MiniMaxM3SparseMSABackend,
492
MiniMaxM3SparseMSAImpl,
493
)
494
486
- return MiniMaxM3SparseMSAImpl
487
- return MiniMaxM3SparseTritonImpl
495
+ return MiniMaxM3SparseMSABackend, MiniMaxM3SparseMSAImpl
496
+ return MiniMaxM3SparseBackend, MiniMaxM3SparseTritonImpl
497
+
498
+
499
+def select_main_impl_cls(
500
+ *,
501
+ topk_blocks: int,
502
+ kv_cache_dtype: str,
503
+ num_kv_heads: int,
504
+) -> type[MiniMaxM3SparseImpl]:
505
+ """Backward-compatible implementation-only selector."""
506
+ return select_main_backend_and_impl_cls(
507
+ topk_blocks=topk_blocks,
508
+ kv_cache_dtype=kv_cache_dtype,
509
+ num_kv_heads=num_kv_heads,
510
+ )[1]
vllm/models/minimax_m3/nvidia/model.py
+28
-6
@@ -79,7 +79,7 @@ from vllm.models.minimax_m3.common.mm_preprocess import (
79
from vllm.models.minimax_m3.common.sparse_attention import (
80
MiniMaxM3SparseBackend,
81
MiniMaxM3SparseImpl,
82
- select_main_impl_cls,
82
+ select_main_backend_and_impl_cls,
83
)
84
from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
85
from vllm.multimodal import MULTIMODAL_REGISTRY
@@ -505,15 +505,15 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
505
# the attend impl reads them back (so nothing crosses the eager break as a
506
# Python value, which would freeze at capture).
507
self.topk_indices_buffer = topk_indices_buffer
508
- self.attn_backend = MiniMaxM3SparseBackend
508
# Indexer (top-k selection) and main attention are separate impls, each
509
# picking Triton vs MSA off its cache dtype. impl is AttentionImplBase
510
# (broader than the AttentionImpl that AttentionLayerBase annotates).
512
- self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
511
+ self.attn_backend, impl_cls = select_main_backend_and_impl_cls(
512
topk_blocks=sparse_cfg["sparse_topk_blocks"],
513
kv_cache_dtype=self.kv_cache_dtype,
514
num_kv_heads=self.num_kv_heads,
516
- )(
515
+ )
516
+ self.impl: MiniMaxM3SparseImpl = impl_cls( # type: ignore[assignment]
517
self.num_heads,
518
self.head_dim,
519
self.scaling,
@@ -521,6 +521,9 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
521
kv_cache_dtype=self.kv_cache_dtype,
522
topk_blocks=sparse_cfg["sparse_topk_blocks"],
523
sparse_block_size=sparse_cfg["sparse_block_size"],
524
+ msa_decode_backend=(
525
+ vllm_config.attention_config.minimax_m3_msa_decode_backend
526
+ ),
527
)
528
# Self-contained nn.Module: owns its side cache, selects its impl in init.
529
self.indexer = MiniMaxM3Indexer(
@@ -594,6 +597,16 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
597
main_slot_mapping = fwd_slot_mapping[self.layer_name]
598
index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
599
q = qkv.new_empty((num_tokens, self.q_size))
600
+ use_msa_decode = self.impl.should_use_msa_decode(self.layer_name)
601
+ query_fp8 = (
602
+ torch.empty(
603
+ (num_tokens, self.q_size),
604
+ dtype=torch.float8_e4m3fn,
605
+ device=qkv.device,
606
+ )
607
+ if use_msa_decode
608
+ else None
609
+ )
610
# index_q matches the index-K cache dtype (e4m3 for the fp8 score path);
611
# the fused kernel emits fp8 directly when this buffer is e4m3.
612
index_q = qkv.new_empty(
@@ -621,10 +634,12 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
634
q,
635
index_q,
636
self.kv_cache_dtype,
637
+ q_fp8_out=query_fp8,
638
+ q_fp8_scale=self._q_scale_float,
639
)
640
641
output = torch.empty_like(q)
627
- attn_output = self._run_attention(q, index_q, output)
642
+ attn_output = self._run_attention(q, query_fp8, index_q, output)
643
output, _ = self.o_proj(attn_output)
644
return output
645
@@ -632,6 +647,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
647
def _run_attention(
648
self,
649
query: torch.Tensor,
650
+ query_fp8: torch.Tensor | None,
651
index_query: torch.Tensor,
652
output: torch.Tensor,
653
) -> torch.Tensor:
@@ -639,7 +655,13 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
655
# metadata and can't be captured into a cudagraph. The indexer writes its
656
# top-k into the shared ``topk_indices_buffer``; the attend reads it back.
657
self.indexer(index_query)
642
- return self.impl.forward(self, query, self.kv_cache, output)
658
+ return self.impl.forward(
659
+ self,
660
+ query,
661
+ self.kv_cache,
662
+ output,
663
+ query_fp8=query_fp8,
664
+ )
665
666
667
class MiniMaxM3DecoderLayer(nn.Module):
vllm/models/minimax_m3/nvidia/msa_cutlass_sparse_decode.py
new
+316
@@ -0,0 +1,316 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""MiniMax CUTLASS sparse decode using per-query-token page indices."""
4
+
5
+from __future__ import annotations
6
+
7
+from dataclasses import dataclass, field
8
+from typing import Any
9
+
10
+import torch
11
+
12
+from vllm.config.attention import MiniMaxM3MSADecodeBackend
13
+from vllm.platforms import current_platform
14
+from vllm.triton_utils import tl, triton
15
+
16
+_MAX_NUM_Q_HEADS = 64
17
+_MAX_NUM_KV_HEADS = 4
18
+_HEAD_DIM = 128
19
+_PAGE_SIZE = 128
20
+_TOPK = 16
21
+# fmha_sm100 plans one row per query head. Keep every cached plan within the
22
+# fixed planner allocation used by the MSA decode kernel.
23
+_MAX_QUERY_HEAD_ROWS = 65536
24
+_MAX_DECODE_QUERY_LEN = 32
25
+# Kernel benchmarks put the CUTLASS crossover at 16 requests for TP1 and TP4.
26
+_MIN_CUTLASS_BATCH_SIZE = 16
27
+
28
+
29
+@dataclass
30
+class MSACutlassDecodeMetadata:
31
+ plan: Any
32
+ page_table: torch.Tensor
33
+
34
+
35
+@triton.jit
36
+def _update_runtime_metadata_kernel(
37
+ seq_lens_ptr,
38
+ kv_segment_lens_ptr,
39
+ qo_offset_ptr,
40
+ num_rows: tl.constexpr,
41
+ decode_query_len: tl.constexpr,
42
+ BLOCK_SIZE: tl.constexpr,
43
+):
44
+ offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
45
+ mask = offsets < num_rows
46
+ request = offsets // decode_query_len
47
+ local_query = offsets % decode_query_len
48
+ seq_len = tl.load(seq_lens_ptr + request, mask=mask)
49
+ tl.store(kv_segment_lens_ptr + offsets, seq_len, mask=mask)
50
+ tl.store(
51
+ qo_offset_ptr + offsets,
52
+ seq_len - decode_query_len + local_query,
53
+ mask=mask,
54
+ )
55
+
56
+
57
+@dataclass
58
+class MSACutlassDecodePlanCache:
59
+ """Reusable plans whose mutable tensors retain cudagraph-stable addresses."""
60
+
61
+ plans: dict[tuple[int, ...], Any] = field(init=False, default_factory=dict)
62
+
63
+ def _build_plan(
64
+ self,
65
+ *,
66
+ batch: int,
67
+ decode_query_len: int,
68
+ page_table_stride: int,
69
+ initial_seq_lens_cpu: torch.Tensor,
70
+ device: torch.device,
71
+ num_q_heads: int,
72
+ num_kv_heads: int,
73
+ page_size: int,
74
+ topk_blocks: int,
75
+ ) -> Any:
76
+ from vllm.third_party.fmha_sm100.api import fmha_sm100_plan
77
+
78
+ qo_lens_cpu = torch.full((batch,), decode_query_len, dtype=torch.int32)
79
+ kv_lens_cpu = initial_seq_lens_cpu
80
+ plan = fmha_sm100_plan(
81
+ qo_lens_cpu,
82
+ kv_lens_cpu,
83
+ num_q_heads,
84
+ num_kv_heads=num_kv_heads,
85
+ qo_offset=kv_lens_cpu - qo_lens_cpu,
86
+ page_size=page_size,
87
+ output_maxscore=False,
88
+ kv_block_num=topk_blocks,
89
+ causal=True,
90
+ sparse_kernel_mode="decode",
91
+ use_fp8_kvcache=True,
92
+ split_prefill_decode=False,
93
+ device=device,
94
+ )
95
+
96
+ plan_info = plan[3]
97
+ row_starts = (
98
+ torch.arange(batch, dtype=torch.int32, device=device)
99
+ .mul_(page_table_stride)
100
+ .repeat_interleave(decode_query_len)
101
+ )
102
+ page_indptr = torch.cat(
103
+ (
104
+ row_starts,
105
+ torch.tensor(
106
+ [batch * page_table_stride],
107
+ dtype=torch.int32,
108
+ device=device,
109
+ ),
110
+ )
111
+ )
112
+ plan_info["kv_page_indptr"].copy_(page_indptr)
113
+ return plan
114
+
115
+ def prepare(
116
+ self,
117
+ block_table: torch.Tensor,
118
+ seq_lens: torch.Tensor,
119
+ seq_lens_cpu: torch.Tensor,
120
+ decode_query_len: int,
121
+ *,
122
+ num_q_heads: int,
123
+ num_kv_heads: int,
124
+ page_size: int,
125
+ topk_blocks: int,
126
+ ) -> MSACutlassDecodeMetadata:
127
+ batch = int(seq_lens.shape[0])
128
+ if (
129
+ block_table.device.type != "cuda"
130
+ or block_table.dtype != torch.int32
131
+ or not block_table.is_contiguous()
132
+ or block_table.shape[0] != batch
133
+ ):
134
+ raise ValueError(
135
+ "MSA sparse decode requires a contiguous CUDA int32 block "
136
+ "table with one row per request"
137
+ )
138
+ if (
139
+ seq_lens.dtype != torch.int32
140
+ or not seq_lens.is_contiguous()
141
+ or seq_lens.device != block_table.device
142
+ ):
143
+ raise ValueError(
144
+ "MSA sparse decode requires contiguous CUDA int32 sequence "
145
+ "lengths on the block table device"
146
+ )
147
+ if (
148
+ seq_lens_cpu.device.type != "cpu"
149
+ or seq_lens_cpu.dtype != torch.int32
150
+ or not seq_lens_cpu.is_contiguous()
151
+ or seq_lens_cpu.shape != seq_lens.shape
152
+ ):
153
+ raise ValueError(
154
+ "MSA sparse decode requires contiguous CPU int32 sequence "
155
+ "lengths matching the device sequence lengths"
156
+ )
157
+
158
+ page_table_stride = int(block_table.stride(0))
159
+ key = (
160
+ batch,
161
+ decode_query_len,
162
+ page_table_stride,
163
+ num_q_heads,
164
+ num_kv_heads,
165
+ page_size,
166
+ topk_blocks,
167
+ )
168
+ plan = self.plans.get(key)
169
+ if plan is None:
170
+ plan = self._build_plan(
171
+ batch=batch,
172
+ decode_query_len=decode_query_len,
173
+ page_table_stride=page_table_stride,
174
+ initial_seq_lens_cpu=seq_lens_cpu,
175
+ device=seq_lens.device,
176
+ num_q_heads=num_q_heads,
177
+ num_kv_heads=num_kv_heads,
178
+ page_size=page_size,
179
+ topk_blocks=topk_blocks,
180
+ )
181
+ self.plans[key] = plan
182
+
183
+ plan_info = plan[3]
184
+ num_rows = batch * decode_query_len
185
+ _update_runtime_metadata_kernel[(triton.cdiv(num_rows, 128),)](
186
+ seq_lens,
187
+ plan_info["kv_segment_lens"],
188
+ plan_info["qo_offset"],
189
+ num_rows=num_rows,
190
+ decode_query_len=decode_query_len,
191
+ BLOCK_SIZE=128,
192
+ )
193
+ return MSACutlassDecodeMetadata(
194
+ plan=plan,
195
+ page_table=block_table.view(-1),
196
+ )
197
+
198
+
199
+def _supported_head_geometry(num_q_heads: int, num_kv_heads: int) -> bool:
200
+ return (
201
+ 0 < num_q_heads <= _MAX_NUM_Q_HEADS
202
+ and 0 < num_kv_heads <= _MAX_NUM_KV_HEADS
203
+ and num_q_heads % num_kv_heads == 0
204
+ )
205
+
206
+
207
+def supports_cutlass_sparse_decode(
208
+ *,
209
+ decode_backend: MiniMaxM3MSADecodeBackend,
210
+ num_q_heads: int,
211
+ num_kv_heads: int,
212
+ kv_cache_dtype: str,
213
+ page_size: int,
214
+ topk_blocks: int,
215
+) -> bool:
216
+ """Return whether static model geometry supports CUTLASS sparse decode."""
217
+ return (
218
+ decode_backend == "cutlass"
219
+ and current_platform.is_cuda()
220
+ and current_platform.is_device_capability_family(100)
221
+ and kv_cache_dtype in ("fp8", "fp8_e4m3")
222
+ and _supported_head_geometry(num_q_heads, num_kv_heads)
223
+ and page_size == _PAGE_SIZE
224
+ and topk_blocks == _TOPK
225
+ )
226
+
227
+
228
+def should_prepare_decode_metadata(
229
+ batch_size: int,
230
+ decode_query_len: int,
231
+ *,
232
+ decode_backend: MiniMaxM3MSADecodeBackend,
233
+ num_q_heads: int,
234
+ num_kv_heads: int,
235
+ kv_cache_dtype: str,
236
+ page_size: int,
237
+ topk_blocks: int,
238
+) -> bool:
239
+ """Return whether a graph shape can use the CUTLASS decode path."""
240
+ total_q = batch_size * decode_query_len
241
+ return (
242
+ supports_cutlass_sparse_decode(
243
+ decode_backend=decode_backend,
244
+ num_q_heads=num_q_heads,
245
+ num_kv_heads=num_kv_heads,
246
+ kv_cache_dtype=kv_cache_dtype,
247
+ page_size=page_size,
248
+ topk_blocks=topk_blocks,
249
+ )
250
+ and 1 <= decode_query_len <= _MAX_DECODE_QUERY_LEN
251
+ and batch_size >= _MIN_CUTLASS_BATCH_SIZE
252
+ and total_q * num_q_heads <= _MAX_QUERY_HEAD_ROWS
253
+ )
254
+
255
+
256
+@torch.no_grad()
257
+def prepare_decode_metadata(
258
+ block_table: torch.Tensor,
259
+ seq_lens: torch.Tensor,
260
+ seq_lens_cpu: torch.Tensor,
261
+ decode_query_len: int,
262
+ *,
263
+ num_q_heads: int,
264
+ num_kv_heads: int,
265
+ page_size: int,
266
+ topk_blocks: int,
267
+ plan_cache: MSACutlassDecodePlanCache | None = None,
268
+) -> MSACutlassDecodeMetadata:
269
+ """Prepare graph-stable runtime metadata for one sparse decode step."""
270
+ cache = plan_cache or MSACutlassDecodePlanCache()
271
+ return cache.prepare(
272
+ block_table,
273
+ seq_lens,
274
+ seq_lens_cpu,
275
+ decode_query_len,
276
+ num_q_heads=num_q_heads,
277
+ num_kv_heads=num_kv_heads,
278
+ page_size=page_size,
279
+ topk_blocks=topk_blocks,
280
+ )
281
+
282
+
283
+@torch.no_grad()
284
+def msa_cutlass_sparse_decode(
285
+ query_fp8: torch.Tensor,
286
+ kv_cache: torch.Tensor,
287
+ topk: torch.Tensor,
288
+ output: torch.Tensor,
289
+ metadata: MSACutlassDecodeMetadata,
290
+ *,
291
+ scale: float,
292
+ q_scale_float: float,
293
+ k_scale_float: float,
294
+ v_scale_float: float,
295
+) -> None:
296
+ """Run CUTLASS sparse decode with metadata prepared by the MSA builder."""
297
+ key, value = kv_cache.split(_HEAD_DIM, dim=-1)
298
+
299
+ from vllm.third_party.fmha_sm100.api import fmha_sm100
300
+
301
+ fmha_sm100(
302
+ query_fp8,
303
+ key,
304
+ value,
305
+ metadata.plan,
306
+ kv_indices=metadata.page_table,
307
+ kv_block_indexes=topk,
308
+ out=output,
309
+ output_maxscore=False,
310
+ output_o=True,
311
+ sm_scale=scale,
312
+ q_scale=q_scale_float,
313
+ k_scale=k_scale_float,
314
+ v_scale=v_scale_float,
315
+ o_scale=1.0,
316
+ )
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+210
-19
@@ -1,28 +1,198 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""MSA (SM100/Blackwell) block-sparse attend for MiniMax M3.
3
+"""MSA (SM100/Blackwell) block-sparse attention for MiniMax M3.
4
5
-Prefill attends with ``fmha_sm100`` (``build_k2q_csr`` + ``sparse_atten_func``);
6
-decode falls back to the Triton split-K kernel (no MSA decode yet). ``fmha_sm100``
7
-imports are function-local, so this module is import-safe on AMD/non-SM100.
5
+Prefill attends with ``fmha_sm100`` (``build_k2q_csr`` + ``sparse_atten_func``).
6
+Decode uses Triton split-K by default, with an opt-in CUTLASS ``fmha_sm100``
7
+path for regular decode and speculative verification.
8
"""
9
10
+from dataclasses import dataclass
11
+
12
import torch
13
14
+from vllm.config import VllmConfig
15
+from vllm.config.attention import MiniMaxM3MSADecodeBackend
16
from vllm.forward_context import get_forward_context
17
+from vllm.logger import init_logger
18
from vllm.models.minimax_m3.common.ops.sparse_attn import (
19
SPARSE_BLOCK_SIZE,
20
minimax_m3_sparse_attn_decode,
21
)
22
from vllm.models.minimax_m3.common.sparse_attention import (
23
+ MiniMaxM3SparseBackend,
24
+ MiniMaxM3SparseDecodeMetadata,
25
MiniMaxM3SparseImpl,
26
MiniMaxM3SparseMetadata,
27
+ MiniMaxM3SparseMetadataBuilder,
28
+)
29
+from vllm.models.minimax_m3.nvidia.msa_cutlass_sparse_decode import (
30
+ MSACutlassDecodeMetadata,
31
+ MSACutlassDecodePlanCache,
32
+ msa_cutlass_sparse_decode,
33
+ prepare_decode_metadata,
34
+ should_prepare_decode_metadata,
35
+ supports_cutlass_sparse_decode,
36
+)
37
+from vllm.v1.attention.backend import (
38
+ AttentionLayer,
39
+ CommonAttentionMetadata,
40
)
21
-from vllm.v1.attention.backend import AttentionLayer
41
+from vllm.v1.kv_cache_interface import AttentionSpec
42
+
43
+logger = init_logger(__name__)
44
+
45
+
46
+class MiniMaxM3SparseMSABackend(MiniMaxM3SparseBackend):
47
+ """MiniMax M3 backend with NVIDIA MSA-specific decode metadata."""
48
+
49
+ @staticmethod
50
+ def get_builder_cls() -> type["MiniMaxM3SparseMSAMetadataBuilder"]:
51
+ return MiniMaxM3SparseMSAMetadataBuilder
52
+
53
+
54
+class MiniMaxM3SparseCutlassBackend(MiniMaxM3SparseMSABackend):
55
+ """Attention-backend alias selecting CUTLASS MSA sparse decode."""
56
+
57
+ @staticmethod
58
+ def get_name() -> str:
59
+ return "CUTLASS_MSA"
60
+
61
+
62
+class MiniMaxM3SparseTritonBackend(MiniMaxM3SparseMSABackend):
63
+ """Attention-backend alias selecting Triton MSA sparse decode."""
64
+
65
+ @staticmethod
66
+ def get_name() -> str:
67
+ return "TRITON_MSA"
68
+
69
+
70
+@dataclass
71
+class MiniMaxM3SparseMSADecodeMetadata(MiniMaxM3SparseDecodeMetadata):
72
+ msa_cutlass: MSACutlassDecodeMetadata | None = None
73
+
74
+
75
+class MiniMaxM3SparseMSAMetadataBuilder(MiniMaxM3SparseMetadataBuilder):
76
+ """Prepare MSA plans only for decode shapes supported by ``fmha_sm100``."""
77
+
78
+ def __init__(
79
+ self,
80
+ kv_cache_spec: AttentionSpec,
81
+ layer_names: list[str],
82
+ vllm_config: VllmConfig,
83
+ device: torch.device,
84
+ ) -> None:
85
+ super().__init__(kv_cache_spec, layer_names, vllm_config, device)
86
+ config = vllm_config.model_config.hf_text_config
87
+ tp_size = vllm_config.parallel_config.tensor_parallel_size
88
+ self.num_q_heads = config.num_attention_heads // tp_size
89
+ self.num_kv_heads = kv_cache_spec.num_kv_heads
90
+ self.topk_blocks = config.sparse_attention_config["sparse_topk_blocks"]
91
+ # AttentionSpec stores every FP8 mode as uint8, so retain the configured
92
+ # format to distinguish E4M3 (supported) from E5M2 before planning.
93
+ self.kv_cache_dtype = vllm_config.cache_config.cache_dtype
94
+ self.decode_backend = vllm_config.attention_config.minimax_m3_msa_decode_backend
95
+ self.msa_cutlass_plan_cache = MSACutlassDecodePlanCache()
96
+
97
+ def build(
98
+ self,
99
+ common_prefix_len: int,
100
+ common_attn_metadata: CommonAttentionMetadata,
101
+ fast_build: bool = False,
102
+ ) -> MiniMaxM3SparseMetadata:
103
+ metadata = super().build(
104
+ common_prefix_len,
105
+ common_attn_metadata,
106
+ fast_build,
107
+ )
108
+ decode = metadata.decode
109
+ if decode is None:
110
+ return metadata
111
+
112
+ msa_cutlass = None
113
+ if should_prepare_decode_metadata(
114
+ metadata.num_decodes,
115
+ decode.decode_query_len,
116
+ decode_backend=self.decode_backend,
117
+ num_q_heads=self.num_q_heads,
118
+ num_kv_heads=self.num_kv_heads,
119
+ kv_cache_dtype=self.kv_cache_dtype,
120
+ page_size=SPARSE_BLOCK_SIZE,
121
+ topk_blocks=self.topk_blocks,
122
+ ):
123
+ seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
124
+ assert seq_lens_cpu is not None
125
+ msa_cutlass = prepare_decode_metadata(
126
+ decode.block_table,
127
+ decode.seq_lens,
128
+ seq_lens_cpu[: metadata.num_decodes],
129
+ decode.decode_query_len,
130
+ num_q_heads=self.num_q_heads,
131
+ num_kv_heads=self.num_kv_heads,
132
+ page_size=SPARSE_BLOCK_SIZE,
133
+ topk_blocks=self.topk_blocks,
134
+ plan_cache=self.msa_cutlass_plan_cache,
135
+ )
136
+ metadata.decode = MiniMaxM3SparseMSADecodeMetadata(
137
+ seq_lens=decode.seq_lens,
138
+ block_table=decode.block_table,
139
+ decode_query_len=decode.decode_query_len,
140
+ msa_cutlass=msa_cutlass,
141
+ )
142
+ return metadata
143
144
145
class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
25
- """MSA block-sparse attend (``fmha_sm100``); Triton split-K decode."""
146
+ """MSA block-sparse attention with guarded CUTLASS sparse decode."""
147
+
148
+ def __init__(
149
+ self,
150
+ num_heads: int,
151
+ head_size: int,
152
+ scale: float,
153
+ num_kv_heads: int | None = None,
154
+ kv_cache_dtype: str = "auto",
155
+ *,
156
+ topk_blocks: int,
157
+ sparse_block_size: int,
158
+ msa_decode_backend: MiniMaxM3MSADecodeBackend = "triton",
159
+ ) -> None:
160
+ super().__init__(
161
+ num_heads,
162
+ head_size,
163
+ scale,
164
+ num_kv_heads,
165
+ kv_cache_dtype,
166
+ topk_blocks=topk_blocks,
167
+ sparse_block_size=sparse_block_size,
168
+ )
169
+ self.use_cutlass_decode = supports_cutlass_sparse_decode(
170
+ decode_backend=msa_decode_backend,
171
+ num_q_heads=self.num_heads,
172
+ num_kv_heads=self.num_kv_heads,
173
+ kv_cache_dtype=self.kv_cache_dtype,
174
+ page_size=self.block_size,
175
+ topk_blocks=self.topk_blocks,
176
+ )
177
+ logger.info_once(
178
+ "MiniMax M3 MSA sparse decode selected %s",
179
+ "CUTLASS" if self.use_cutlass_decode else "Triton",
180
+ )
181
+
182
+ def should_use_msa_decode(self, layer_name: str) -> bool:
183
+ if not self.use_cutlass_decode:
184
+ return False
185
+ attn_metadata = get_forward_context().attn_metadata
186
+ if not isinstance(attn_metadata, dict):
187
+ return False
188
+ main_md = attn_metadata[layer_name]
189
+ if not isinstance(main_md, MiniMaxM3SparseMetadata):
190
+ return False
191
+ decode = main_md.decode
192
+ return (
193
+ isinstance(decode, MiniMaxM3SparseMSADecodeMetadata)
194
+ and decode.msa_cutlass is not None
195
+ )
196
197
def forward(
198
self,
@@ -30,6 +200,8 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
200
query: torch.Tensor,
201
kv_cache: torch.Tensor,
202
output: torch.Tensor,
203
+ *,
204
+ query_fp8: torch.Tensor | None = None,
205
) -> torch.Tensor:
206
attn_metadata = get_forward_context().attn_metadata
207
if not isinstance(attn_metadata, dict):
@@ -52,23 +224,42 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
224
k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
225
v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
226
55
- # Decode [:nd]: Triton split-K placeholder (no MSA decode yet).
227
+ # Decode [:nd]: CUTLASS for planned shapes, otherwise Triton.
228
if main_md.num_decodes > 0:
229
d = main_md.decode
230
assert d is not None
59
- minimax_m3_sparse_attn_decode(
60
- q[:nd],
61
- kv_cache,
62
- topk[:nd].transpose(0, 1),
63
- d.block_table,
64
- d.seq_lens,
65
- self.num_kv_heads,
66
- self.scale,
67
- out[:nd],
68
- d.decode_query_len,
69
- k_scale=k_scale,
70
- v_scale=v_scale,
231
+ msa_metadata = (
232
+ d.msa_cutlass
233
+ if isinstance(d, MiniMaxM3SparseMSADecodeMetadata)
234
+ else None
235
)
236
+ if self.use_cutlass_decode and msa_metadata is not None:
237
+ assert query_fp8 is not None
238
+ msa_cutlass_sparse_decode(
239
+ query_fp8[:nd].view(-1, self.num_heads, hd),
240
+ kv_cache,
241
+ topk[:nd],
242
+ out[:nd],
243
+ msa_metadata,
244
+ scale=self.scale,
245
+ q_scale_float=getattr(layer, "_q_scale_float", 1.0),
246
+ k_scale_float=getattr(layer, "_k_scale_float", 1.0),
247
+ v_scale_float=getattr(layer, "_v_scale_float", 1.0),
248
+ )
249
+ else:
250
+ minimax_m3_sparse_attn_decode(
251
+ q[:nd],
252
+ kv_cache,
253
+ topk[:nd].transpose(0, 1),
254
+ d.block_table,
255
+ d.seq_lens,
256
+ self.num_kv_heads,
257
+ self.scale,
258
+ out[:nd],
259
+ d.decode_query_len,
260
+ k_scale=k_scale,
261
+ v_scale=v_scale,
262
+ )
263
264
# Prefill [nd:]: MSA sparse FMHA over the selected blocks.
265
if main_md.num_prefills > 0:
vllm/v1/attention/backends/registry.py
+8
@@ -101,6 +101,14 @@ class AttentionBackendEnum(Enum, metaclass=_AttentionBackendEnumMeta):
101
MINIMAX_M3_SPARSE = (
102
"vllm.models.minimax_m3.common.sparse_attention.MiniMaxM3SparseBackend"
103
)
104
+ CUTLASS_MSA = (
105
+ "vllm.models.minimax_m3.nvidia.sparse_attention_msa."
106
+ "MiniMaxM3SparseCutlassBackend"
107
+ )
108
+ TRITON_MSA = (
109
+ "vllm.models.minimax_m3.nvidia.sparse_attention_msa."
110
+ "MiniMaxM3SparseTritonBackend"
111
+ )
112
NO_ATTENTION = "vllm.v1.attention.backends.no_attention.NoAttentionBackend"
113
FLEX_ATTENTION = "vllm.v1.attention.backends.flex_attention.FlexAttentionBackend"
114
# HPC Attention Backend: