[Minimax-M3] BF16/FP8 Indexer using MSA (#45892)
Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Thien Tran <gau.nernst@yahoo.com.sg>
Yongye Zhu committed
Jun 23, 2026 at 10:28 UTC
6691f087a65bc161192ced91360bf11313828258
13 files changed
+1048
-104
cmake/external_projects/fmha_sm100.cmake
+26
-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 544eee5e09ae2dfa774d5b06739013f9b7402c57
20
+ GIT_TAG fee783153f3efe57e3e933c5cb7e267a7cebcfb5
21
GIT_PROGRESS TRUE
22
CONFIGURE_COMMAND ""
23
BUILD_COMMAND ""
@@ -36,13 +36,38 @@ set(FMHA_SM100_PY_ROOT "${fmha_sm100_SOURCE_DIR}/python/fmha_sm100")
36
37
install(FILES
38
"${FMHA_SM100_PY_ROOT}/__init__.py"
39
+ "${FMHA_SM100_PY_ROOT}/api.py"
40
+ "${FMHA_SM100_PY_ROOT}/bench_utils.py"
41
+ "${FMHA_SM100_PY_ROOT}/jit.py"
42
"${FMHA_SM100_PY_ROOT}/sparse.py"
43
+ "${FMHA_SM100_PY_ROOT}/sparse_fmha_adapter.py"
44
DESTINATION vllm/third_party/fmha_sm100
45
COMPONENT fmha_sm100)
46
47
+install(DIRECTORY "${FMHA_SM100_PY_ROOT}/csrc/"
48
+ DESTINATION vllm/third_party/fmha_sm100/csrc
49
+ COMPONENT fmha_sm100
50
+ PATTERN "__pycache__" EXCLUDE
51
+ PATTERN "*.pyc" EXCLUDE
52
+ PATTERN ".git*" EXCLUDE)
53
+
54
install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cute/"
55
DESTINATION vllm/third_party/fmha_sm100/cute
56
COMPONENT fmha_sm100
57
PATTERN "__pycache__" EXCLUDE
58
PATTERN "*.pyc" EXCLUDE
59
PATTERN ".git*" EXCLUDE)
60
+
61
+install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cutlass/include/"
62
+ DESTINATION vllm/third_party/fmha_sm100/cutlass/include
63
+ COMPONENT fmha_sm100
64
+ PATTERN "__pycache__" EXCLUDE
65
+ PATTERN "*.pyc" EXCLUDE
66
+ PATTERN ".git*" EXCLUDE)
67
+
68
+install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cutlass/tools/util/include/"
69
+ DESTINATION vllm/third_party/fmha_sm100/cutlass/tools/util/include
70
+ COMPONENT fmha_sm100
71
+ PATTERN "__pycache__" EXCLUDE
72
+ PATTERN "*.pyc" EXCLUDE
73
+ PATTERN ".git*" EXCLUDE)
csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+152
-51
@@ -67,6 +67,13 @@
67
#include "../quantization/w8a8/fp8/nvidia/quant_utils.cuh"
68
#endif
69
70
+// Direct float -> E4M3 FP8 conversion for the indexer Q / index-K outputs.
71
+#ifndef USE_ROCM
72
+ #include <cuda_fp8.h>
73
+#else
74
+ #include <hip/hip_fp8.h>
75
+#endif
76
+
77
#ifndef FINAL_MASK
78
#ifdef USE_ROCM
79
#define FINAL_MASK 0xffffffffffffffffULL
@@ -75,6 +82,19 @@
82
#endif
83
#endif
84
85
+#ifdef USE_ROCM
86
+// ROCm-compatible direct float -> E4M3 FP8 conversion (mirrors the DeepSeek V4
87
+// fused kernel).
88
+__device__ __forceinline__ uint8_t rocm_cvt_float_to_fp8_e4m3(float val) {
89
+ #if defined(HIP_FP8_TYPE_OCP)
90
+ __hip_fp8_e4m3 fp8_val(val);
91
+ #else
92
+ __hip_fp8_e4m3_fnuz fp8_val(val);
93
+ #endif
94
+ return reinterpret_cast<uint8_t&>(fp8_val);
95
+}
96
+#endif
97
+
98
namespace vllm {
99
namespace minimax_m3_fused_ops {
100
@@ -193,6 +213,8 @@ __device__ __forceinline__ void storeElems(
213
*reinterpret_cast<uint2*>(dst) = v;
214
}
215
216
+// Main K/V cache store. kAuto = unquantized (cache_t == scalar_t); fp8 cache
217
+// dtypes use the scaled-convert path with identity scale.
218
template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt>
219
__device__ __forceinline__ void storeCacheElems(
220
cache_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) {
@@ -208,6 +230,32 @@ __device__ __forceinline__ void storeCacheElems(
230
}
231
}
232
233
+// Store 4 fp32 registers -> 4 contiguous E4M3 FP8 bytes (direct cast,
234
+// saturating to ±448). Used for the fp8 indexer-Q / index-K outputs; no scale
235
+// (RMSNorm outputs are O(1) and the score path only needs relative block
236
+// ordering).
237
+__device__ __forceinline__ void storeElemsFp8(
238
+ uint8_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) {
239
+ constexpr float kFp8Max = 448.0f;
240
+#ifndef USE_ROCM
241
+ __nv_fp8x2_storage_t out2[kElemsPerLane / 2];
242
+ #pragma unroll
243
+ for (int i = 0; i < kElemsPerLane / 2; i++) {
244
+ float2 vv = make_float2(elems[2 * i], elems[2 * i + 1]);
245
+ vv.x = fminf(fmaxf(vv.x, -kFp8Max), kFp8Max);
246
+ vv.y = fminf(fmaxf(vv.y, -kFp8Max), kFp8Max);
247
+ out2[i] = __nv_cvt_float2_to_fp8x2(vv, __NV_SATFINITE, __NV_E4M3);
248
+ }
249
+ *reinterpret_cast<uint32_t*>(dst) = *reinterpret_cast<uint32_t const*>(out2);
250
+#else
251
+ #pragma unroll
252
+ for (int i = 0; i < kElemsPerLane; i++) {
253
+ float vv = fminf(fmaxf(elems[i], -kFp8Max), kFp8Max);
254
+ dst[i] = rocm_cvt_float_to_fp8_e4m3(vv);
255
+ }
256
+#endif
257
+}
258
+
259
// ────────────────────────────────────────────────────────────────────────────
260
// Kernel
261
// ────────────────────────────────────────────────────────────────────────────
@@ -224,12 +272,14 @@ __device__ __forceinline__ void storeCacheElems(
272
// V : nkv only if kInsertKV (V-cache insert; no warps in dense)
273
// IQ: niq only if kIsSparse (norm+RoPE)
274
// IK: 1 only if kIsSparse (norm+RoPE; +index-cache insert)
275
+// cache_t/kv_dt: main attention KV-cache dtype (auto/fp8). out_idx_t/kFp8Idx:
276
+// indexer index-K cache + index-Q output dtype (scalar_t or e4m3 byte).
277
template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt,
228
- bool kIsSparse, bool kInsertKV>
278
+ typename out_idx_t, bool kIsSparse, bool kInsertKV, bool kFp8Idx>
279
__global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
280
scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
231
- scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
232
- scalar_t* __restrict__ index_q_out, // [N, niq*128] contiguous, or nullptr
281
+ scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
282
+ out_idx_t* __restrict__ index_q_out, // [N, niq*128]; scalar_t or e4m3 byte
283
scalar_t const* __restrict__ q_norm_w,
284
scalar_t const* __restrict__ k_norm_w,
285
scalar_t const* __restrict__ iq_norm_w,
@@ -238,8 +288,8 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
288
int64_t const* __restrict__ positions, // [N] i64
289
int64_t const* __restrict__ slot_mapping, // main K/V slots or nullptr
290
int64_t const* __restrict__ index_slot_mapping, // index K slots/nullptr
241
- cache_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr
242
- scalar_t* __restrict__ index_cache, // [nb*bs, 128] or nullptr
291
+ cache_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr
292
+ out_idx_t* __restrict__ index_cache, // [nb*bs, 128]; scalar_t or e4m3 byte
293
float const eps, int const rotary_dim, int const num_tokens, int const nq,
294
int const nkv, int const niq, int const block_size,
295
// kv_cache strides (in elements) for logical shape [nb, 2, bs, nkv, 128].
@@ -334,9 +384,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
384
store_ptr = q_out + static_cast<int64_t>(tokenIdx) * nq * kHeadDim +
385
slot * kHeadDim;
386
} else if (isIQ && index_q_out != nullptr) {
337
- store_ptr = index_q_out +
338
- static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
339
- (slot - iq_begin) * kHeadDim;
387
+ // bf16 index_q_out: gather here. fp8: written by the explicit fp8 store.
388
+ if constexpr (!kFp8Idx) {
389
+ store_ptr = index_q_out +
390
+ static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
391
+ (slot - iq_begin) * kHeadDim;
392
+ }
393
}
394
395
// PDL: wait for the predecessor kernel (the qkv-projection GEMM that
@@ -356,7 +409,19 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
409
scalar_t const* cos_ptr = cos_sin_cache + pos * rotary_dim;
410
normAndRope<scalar_t>(elems, laneId, eps, norm_w, do_rope, rotary_dim,
411
cos_ptr, /*apply_norm=*/norm_w != nullptr);
359
- storeElems<scalar_t>(store_ptr + dim_base, elems);
412
+ if constexpr (kFp8Idx) {
413
+ // index_q is e4m3 bytes; Q/K (and in-place index_k) stay scalar_t.
414
+ if (isIQ && index_q_out != nullptr) {
415
+ storeElemsFp8(index_q_out +
416
+ static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
417
+ (slot - iq_begin) * kHeadDim + dim_base,
418
+ elems);
419
+ } else {
420
+ storeElems<scalar_t>(store_ptr + dim_base, elems);
421
+ }
422
+ } else {
423
+ storeElems<scalar_t>(store_ptr + dim_base, elems);
424
+ }
425
}
426
427
// ── Cache inserts (sparse serving only). ───────────────────────────────
@@ -367,8 +432,11 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
432
: (isIK ? index_slot_mapping[tokenIdx] : -1);
433
if (sm >= 0) { // skip padded / unscheduled tokens
434
if (isIK) {
370
- scalar_t* dst = index_cache + sm * kHeadDim + dim_base;
371
- storeElems<scalar_t>(dst, elems);
435
+ if constexpr (kFp8Idx) {
436
+ storeElemsFp8(index_cache + sm * kHeadDim + dim_base, elems);
437
+ } else {
438
+ storeElems<scalar_t>(index_cache + sm * kHeadDim + dim_base, elems);
439
+ }
440
} else if (isK || isV) {
441
// kv_cache logical shape [num_blocks, 2, block_size, nkv, head_dim].
442
// Paging is logical (block = sm/block_size, token = sm%block_size);
@@ -398,19 +466,19 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
466
// Launch wrapper
467
// ────────────────────────────────────────────────────────────────────────────
468
template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt>
401
-void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
402
- scalar_t const* q_norm_w, scalar_t const* k_norm_w,
403
- scalar_t const* iq_norm_w, scalar_t const* ik_norm_w,
404
- scalar_t const* cos_sin_cache,
405
- int64_t const* positions, int64_t const* slot_mapping,
406
- int64_t const* index_slot_mapping, cache_t* kv_cache,
407
- scalar_t* index_cache, float const eps,
408
- int const rotary_dim, int const num_tokens,
409
- int const nq, int const nkv, int const niq,
410
- int const block_size, int64_t const kv_s_block,
411
- int64_t const kv_s_kv, int64_t const kv_s_token,
412
- int64_t const kv_s_head, bool const has_index,
413
- bool const insert_kv, cudaStream_t stream) {
469
+void launchFusedMiniMaxM3(
470
+ scalar_t* qkv, scalar_t* q_out, void* index_q_out, scalar_t const* q_norm_w,
471
+ scalar_t const* k_norm_w, scalar_t const* iq_norm_w,
472
+ scalar_t const* ik_norm_w, scalar_t const* cos_sin_cache,
473
+ int64_t const* positions, int64_t const* slot_mapping,
474
+ int64_t const* index_slot_mapping, cache_t* kv_cache, void* index_cache,
475
+ float const eps, int const rotary_dim, int const num_tokens, int const nq,
476
+ int const nkv, int const niq, int const block_size,
477
+ int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
478
+ int64_t const kv_s_head, bool const has_index, bool const insert_kv,
479
+ bool const fp8_idx, cudaStream_t stream) {
480
+ // Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
481
+ // void* pointers per instantiation in the LAUNCH macro.
482
// Slot count must match the kernel's compile-time gating.
483
int const v_slots = insert_kv ? nkv : 0;
484
int const idx_slots = has_index ? niq + 1 : 0;
@@ -440,25 +508,27 @@ void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
508
config.attrs = attrs;
509
config.numAttrs = (sm_version >= 90) ? 1 : 0;
510
443
- #define LAUNCH(IS_SPARSE, INSERT) \
444
- cudaLaunchKernelEx( \
445
- &config, \
446
- fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, \
447
- IS_SPARSE, INSERT>, \
448
- qkv, q_out, index_q_out, q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, \
449
- cos_sin_cache, positions, slot_mapping, index_slot_mapping, kv_cache, \
450
- index_cache, eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, \
451
- kv_s_block, kv_s_kv, kv_s_token, kv_s_head)
511
+ #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
512
+ cudaLaunchKernelEx( \
513
+ &config, \
514
+ fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
515
+ IS_SPARSE, INSERT, FP8>, \
516
+ qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, k_norm_w, \
517
+ iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \
518
+ index_slot_mapping, kv_cache, reinterpret_cast<OUT_T*>(index_cache), \
519
+ eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
520
+ kv_s_kv, kv_s_token, kv_s_head)
521
#else
522
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
523
// clang-format off
455
- #define LAUNCH(IS_SPARSE, INSERT) \
456
- fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, \
457
- IS_SPARSE, INSERT> \
524
+ #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
525
+ fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
526
+ IS_SPARSE, INSERT, FP8> \
527
<<<grid, kBlockSize, 0, stream>>>( \
459
- qkv, q_out, index_q_out, q_norm_w, k_norm_w, iq_norm_w, \
460
- ik_norm_w, cos_sin_cache, positions, slot_mapping, \
461
- index_slot_mapping, kv_cache, index_cache, eps, rotary_dim, \
528
+ qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, \
529
+ k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
530
+ slot_mapping, index_slot_mapping, kv_cache, \
531
+ reinterpret_cast<OUT_T*>(index_cache), eps, rotary_dim, \
532
num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_kv, \
533
kv_s_token, kv_s_head)
534
// clang-format on
@@ -466,14 +536,22 @@ void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
536
537
if (has_index) {
538
if (insert_kv) {
469
- LAUNCH(true, true); // sparse serving
539
+ if (fp8_idx) {
540
+ LAUNCH(true, true, true, uint8_t); // sparse serving, fp8 index outputs
541
+ } else {
542
+ LAUNCH(true, true, false, scalar_t); // sparse serving, bf16
543
+ }
544
} else {
471
- LAUNCH(true, false); // sparse profiling
545
+ if (fp8_idx) {
546
+ LAUNCH(true, false, true, uint8_t); // sparse profiling, fp8 index_q
547
+ } else {
548
+ LAUNCH(true, false, false, scalar_t); // sparse profiling, bf16
549
+ }
550
}
551
} else {
552
// Dense layer: never has an index branch and never inserts here (the
553
// generic Attention layer owns the KV insert).
476
- LAUNCH(false, false);
554
+ LAUNCH(false, false, false, scalar_t);
555
}
556
#undef LAUNCH
557
}
@@ -485,8 +563,9 @@ void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
563
vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st, CACHE_T, KV_DTYPE>( \
564
reinterpret_cast<st*>(qkv.data_ptr()), \
565
q_out.has_value() ? reinterpret_cast<st*>(q_out->data_ptr()) : nullptr, \
488
- index_q_out.has_value() ? reinterpret_cast<st*>(index_q_out->data_ptr()) \
489
- : nullptr, \
566
+ index_q_out.has_value() \
567
+ ? reinterpret_cast<void*>(index_q_out->data_ptr()) \
568
+ : nullptr, \
569
reinterpret_cast<st const*>(q_norm_weight.data_ptr()), \
570
reinterpret_cast<st const*>(k_norm_weight.data_ptr()), \
571
has_index ? reinterpret_cast<st const*>(index_q_norm_weight->data_ptr()) \
@@ -502,11 +581,11 @@ void launchFusedMiniMaxM3(scalar_t* qkv, scalar_t* q_out, scalar_t* index_q_out,
581
: nullptr, \
582
insert_kv ? reinterpret_cast<CACHE_T*>(kv_cache->data_ptr()) : nullptr, \
583
(insert_kv && has_index) \
505
- ? reinterpret_cast<st*>(index_cache->data_ptr()) \
584
+ ? reinterpret_cast<void*>(index_cache->data_ptr()) \
585
: nullptr, \
586
static_cast<float>(eps), static_cast<int>(rotary_dim), num_tokens, nq, \
587
nkv, niq, static_cast<int>(block_size), kv_s_block, kv_s_kv, kv_s_token, \
509
- kv_s_head, has_index, insert_kv, stream)
588
+ kv_s_head, has_index, insert_kv, fp8_idx, stream)
589
590
// ────────────────────────────────────────────────────────────────────────────
591
// Torch op wrapper
@@ -612,6 +691,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
691
torch::headeronly::ScalarType::Long &&
692
index_slot_mapping->numel() == slot_mapping->numel()),
693
"index_slot_mapping must be int64 CUDA with slot_mapping length");
694
+ // Main attention KV cache: auto matches qkv, fp8 uses uint8 storage.
695
if (kv_dt == vllm::Fp8KVCacheDataType::kAuto) {
696
STD_TORCH_CHECK(kv_cache->scalar_type() == qkv.scalar_type(),
697
"auto kv_cache dtype must match qkv");
@@ -620,9 +700,13 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
700
kv_cache->scalar_type() == torch::headeronly::ScalarType::Byte,
701
"fp8 kv_cache must use uint8 storage");
702
}
623
- STD_TORCH_CHECK(index_cache.has_value() &&
624
- index_cache->scalar_type() == qkv.scalar_type(),
625
- "insert mode requires matching index_cache");
703
+ // Indexer index-K cache: independent dtype -- qkv dtype or fp8 e4m3.
704
+ STD_TORCH_CHECK(
705
+ index_cache.has_value() &&
706
+ (index_cache->scalar_type() == qkv.scalar_type() ||
707
+ index_cache->scalar_type() ==
708
+ torch::headeronly::ScalarType::Float8_e4m3fn),
709
+ "insert mode requires index_cache matching qkv dtype or fp8 e4m3");
710
STD_TORCH_CHECK(kv_cache->dim() == 5 && kv_cache->stride(4) == 1,
711
"kv_cache must be [nb,2,bs,nkv,head_dim] with contiguous "
712
"head_dim (stride(4)==1)");
@@ -652,14 +736,31 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
736
"index_q_out requires the index branch (num_index_heads > 0)");
737
STD_TORCH_CHECK(
738
index_q_out->is_cuda() && index_q_out->is_contiguous() &&
655
- index_q_out->scalar_type() == qkv.scalar_type(),
656
- "index_q_out must be a contiguous CUDA tensor matching qkv dtype");
739
+ (index_q_out->scalar_type() == qkv.scalar_type() ||
740
+ index_q_out->scalar_type() ==
741
+ torch::headeronly::ScalarType::Float8_e4m3fn),
742
+ "index_q_out must be contiguous CUDA, qkv dtype or fp8 e4m3");
743
STD_TORCH_CHECK(index_q_out->numel() ==
744
static_cast<int64_t>(num_tokens) * niq * kHeadDim,
745
"index_q_out must have num_tokens * num_index_heads * 128 "
746
"elements");
747
}
748
749
+ // fp8 index path: the index-K cache and index-Q outputs are e4m3 bytes while
750
+ // q/k/v + q_out stay qkv dtype. Both index outputs must agree.
751
+ auto const kFp8 = torch::headeronly::ScalarType::Float8_e4m3fn;
752
+ bool const fp8_idx =
753
+ (index_cache.has_value() && index_cache->scalar_type() == kFp8) ||
754
+ (index_q_out.has_value() && index_q_out->scalar_type() == kFp8);
755
+ if (fp8_idx) {
756
+ STD_TORCH_CHECK(
757
+ !index_cache.has_value() || index_cache->scalar_type() == kFp8,
758
+ "fp8 index path: index_cache must be fp8 e4m3");
759
+ STD_TORCH_CHECK(
760
+ !index_q_out.has_value() || index_q_out->scalar_type() == kFp8,
761
+ "fp8 index path: index_q_out must be fp8 e4m3");
762
+ }
763
+
764
const torch::stable::accelerator::DeviceGuard device_guard(
765
qkv.get_device_index());
766
auto stream = get_current_cuda_stream(qkv.get_device_index());
setup.py
+8
@@ -1171,7 +1171,15 @@ package_data = {
1171
"third_party/deep_gemm/include/**/*.h",
1172
"third_party/deep_gemm/include/**/*.hpp",
1173
# fmha_sm100 sparse CuTe-DSL helper kernels (vendored via cmake)
1174
+ "third_party/fmha_sm100/csrc/**/*.cu",
1175
+ "third_party/fmha_sm100/csrc/**/*.h",
1176
+ "third_party/fmha_sm100/csrc/**/*.jinja",
1177
+ "third_party/fmha_sm100/csrc/**/*.cu.jinja",
1178
"third_party/fmha_sm100/cute/**/*.cu",
1179
+ "third_party/fmha_sm100/cutlass/include/**/*.h",
1180
+ "third_party/fmha_sm100/cutlass/include/**/*.hpp",
1181
+ "third_party/fmha_sm100/cutlass/tools/util/include/**/*.h",
1182
+ "third_party/fmha_sm100/cutlass/tools/util/include/**/*.hpp",
1183
]
1184
}
1185
tests/kernels/attention/test_minimax_m3.py
+325
-1
@@ -134,6 +134,7 @@ def _reference_index_topk(
134
topk: int,
135
init_blocks: int,
136
local_blocks: int,
137
+ sm_scale: float = 1.0,
138
) -> torch.Tensor:
139
total_q, num_idx_heads, _ = idx_q.shape
140
out = torch.full(
@@ -149,7 +150,7 @@ def _reference_index_topk(
150
num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
151
pages = block_table[req_id, :num_blocks]
152
k = index_kv_cache[pages].reshape(num_blocks * BLOCK_SIZE, -1)
152
- score = torch.einsum("qhd,kd->hqk", q.float(), k.float())
153
+ score = torch.einsum("qhd,kd->hqk", q.float(), k.float()) * sm_scale
154
155
q_pos = prefix_len + torch.arange(q_len, device=idx_q.device)
156
k_pos = torch.arange(k.shape[0], device=idx_q.device)
@@ -244,6 +245,270 @@ def test_prefill_index_topk_correctness():
245
_assert_topk_indices_equal_unordered(actual, expected)
246
247
248
+# MSA indexer (SM100): fmha_sm100 OnlyScore for the per-block scores, then the
249
+# Triton minimax_m3_index_topk for selection (no sparse_topk_select). Uses a
250
+# deterministic construction (idx_q == 1, distinct e4m3-exact per-block values)
251
+# so scores are strictly monotonic in the block id -> exact top-k agreement.
252
+def _fmha_indexer_topk(
253
+ idx_q: torch.Tensor, # [total_q, H, 128] bf16/e4m3
254
+ index_cache: torch.Tensor, # [num_pages, 128, 128] bf16/e4m3
255
+ block_table: torch.Tensor,
256
+ q_lens: torch.Tensor,
257
+ seq_lens: torch.Tensor,
258
+ prefix_lens: torch.Tensor,
259
+ sm_scale: float,
260
+ topk: int,
261
+) -> torch.Tensor:
262
+ """Replicate MiniMaxM3IndexerMSAImpl's score path (single decode/prefill side)."""
263
+ from vllm.third_party.fmha_sm100.api import _fmha_sm100, _fmha_sm100_plan
264
+
265
+ num_idx_heads, head_dim = idx_q.shape[1], idx_q.shape[2]
266
+ nvp = [(s + 127) // 128 for s in seq_lens.tolist()]
267
+ kv_indices = torch.cat([block_table[r, : nvp[r]] for r in range(len(nvp))]).to(
268
+ torch.int32
269
+ )
270
+
271
+ qo = q_lens.cpu().to(torch.int32)
272
+ kv = seq_lens.cpu().to(torch.int32)
273
+ plan = _fmha_sm100_plan(
274
+ qo,
275
+ kv,
276
+ num_idx_heads,
277
+ num_kv_heads=1,
278
+ qo_offset=kv - qo,
279
+ page_size=128,
280
+ output_maxscore=True,
281
+ causal=True,
282
+ num_kv_splits=1,
283
+ )
284
+ k_pages = index_cache.view(index_cache.shape[0], 1, 128, head_dim)
285
+ _, max_score = _fmha_sm100(
286
+ idx_q,
287
+ k_pages,
288
+ k_pages,
289
+ plan,
290
+ kv_indices=kv_indices,
291
+ output_o=False,
292
+ output_maxscore=True,
293
+ sm_scale=sm_scale,
294
+ )
295
+
296
+ batch = q_lens.numel()
297
+ cu = torch.zeros(batch + 1, dtype=torch.int32, device=idx_q.device)
298
+ cu[1:] = q_lens.to(torch.int32).cumsum(0)
299
+ # max_score [H, k_tiles, total_q] -> transpose to [H, total_q, k_tiles].
300
+ return minimax_m3_index_topk(
301
+ max_score.transpose(1, 2),
302
+ cu,
303
+ prefix_lens.to(torch.int32),
304
+ int(q_lens.max()),
305
+ topk,
306
+ 0, # init_blocks
307
+ 0, # local_blocks
308
+ )
309
+
310
+
311
+# e4m3-exact, strictly-increasing per-block values: with idx_q == 1 (also exact)
312
+# the per-block scores are exact and distinct in BOTH bf16 and e4m3, so the fp8
313
+# score path selects the same top-k as the reference (no quantization ties).
314
+_E4M3_EXACT_VALUES = [
315
+ *range(1, 17), # 1..16 (step 1)
316
+ *range(18, 33, 2), # 18..32 (step 2)
317
+ *range(36, 65, 4), # 36..64 (step 4)
318
+ *range(72, 129, 8), # 72..128 (step 8)
319
+]
320
+
321
+
322
+@pytest.mark.skipif(
323
+ not current_platform.is_device_capability_family(100),
324
+ reason="fmha_sm100 indexer requires SM100 (Blackwell).",
325
+)
326
+@pytest.mark.parametrize("index_dtype", [torch.bfloat16, torch.float8_e4m3fn])
327
+@pytest.mark.parametrize(
328
+ ("q_lens", "prefix_lens"),
329
+ [
330
+ ((4, 3), (2048, 2560)), # prefill: every token sees >= 16 causal blocks
331
+ ((1, 1, 1), (2048, 3000, 4096)), # decode: one query token per request
332
+ ],
333
+)
334
+def test_fmha_sm100_indexer_matches_reference(q_lens, prefix_lens, index_dtype):
335
+ torch.manual_seed(0)
336
+ num_idx_heads, head_dim = 4, HEAD_DIM
337
+ device = "cuda"
338
+
339
+ q_lens_t = torch.tensor(q_lens, device=device, dtype=torch.int32)
340
+ prefix_lens_t = torch.tensor(prefix_lens, device=device, dtype=torch.int32)
341
+ seq_lens = prefix_lens_t + q_lens_t
342
+ batch = len(q_lens)
343
+ max_blocks = (int(seq_lens.max()) + BLOCK_SIZE - 1) // BLOCK_SIZE
344
+ assert max_blocks <= len(_E4M3_EXACT_VALUES)
345
+ num_pages = batch * max_blocks
346
+ block_table = torch.randperm(num_pages, device=device, dtype=torch.int32).reshape(
347
+ batch, max_blocks
348
+ )
349
+
350
+ idx_q = torch.ones(
351
+ int(q_lens_t.sum()), num_idx_heads, head_dim, device=device, dtype=index_dtype
352
+ )
353
+ index_cache = torch.empty(
354
+ num_pages, BLOCK_SIZE, head_dim, device=device, dtype=index_dtype
355
+ )
356
+ for r in range(batch):
357
+ for b in range(max_blocks):
358
+ index_cache[block_table[r, b]] = float(_E4M3_EXACT_VALUES[b])
359
+
360
+ sm_scale = head_dim**-0.5
361
+ actual = _fmha_indexer_topk(
362
+ idx_q,
363
+ index_cache,
364
+ block_table,
365
+ q_lens_t,
366
+ seq_lens,
367
+ prefix_lens_t,
368
+ sm_scale,
369
+ TOPK,
370
+ )
371
+ expected = _reference_index_topk(
372
+ idx_q,
373
+ index_cache,
374
+ block_table,
375
+ q_lens_t,
376
+ seq_lens,
377
+ prefix_lens_t,
378
+ TOPK,
379
+ init_blocks=0,
380
+ local_blocks=0,
381
+ sm_scale=sm_scale,
382
+ )
383
+ _assert_topk_indices_equal_unordered(actual, expected)
384
+
385
+
386
+# Full impl-level parity: drive both MiniMaxM3IndexerMSAImpl (fmha_sm100 score +
387
+# Triton top-k) and MiniMaxM3IndexerTritonImpl through their real metadata
388
+# builders on the SAME CommonAttentionMetadata + index cache, and assert the
389
+# selected blocks agree. This exercises all the metadata the impl/kernels consume
390
+# (decode/prefill split, cu_seqlens_q rebasing, prefix_lens, kv_indices gather,
391
+# decode_pages split) -- a metadata bug on either side shifts the causal window
392
+# or the block->page mapping and breaks the comparison.
393
+@pytest.mark.skipif(
394
+ not current_platform.is_device_capability_family(100),
395
+ reason="fmha_sm100 indexer requires SM100 (Blackwell).",
396
+)
397
+@pytest.mark.parametrize("topk", [8, 16])
398
+def test_msa_indexer_impl_matches_triton(topk, monkeypatch):
399
+ import vllm.models.minimax_m3.common.indexer as indexer_mod
400
+ from tests.v1.attention.utils import (
401
+ BatchSpec,
402
+ create_common_attn_metadata,
403
+ create_vllm_config,
404
+ )
405
+ from vllm.config import set_current_vllm_config
406
+ from vllm.forward_context import set_forward_context
407
+ from vllm.models.minimax_m3.common.indexer import (
408
+ MiniMaxM3IndexerTritonImpl,
409
+ MiniMaxM3IndexerTritonMetadataBuilder,
410
+ )
411
+ from vllm.models.minimax_m3.nvidia.indexer_msa import (
412
+ MiniMaxM3IndexerMSAImpl,
413
+ MiniMaxM3IndexerMSAMetadataBuilder,
414
+ )
415
+
416
+ torch.manual_seed(0)
417
+ device = torch.device("cuda")
418
+ num_idx_heads, head_dim = 4, HEAD_DIM
419
+ # TP=1: avoid requiring an initialized distributed group in a unit test.
420
+ monkeypatch.setattr(indexer_mod, "get_tensor_model_parallel_world_size", lambda: 1)
421
+
422
+ vllm_config = create_vllm_config(
423
+ block_size=BLOCK_SIZE, max_model_len=8192, max_num_batched_tokens=8192
424
+ )
425
+ vllm_config.model_config.hf_config.sparse_attention_config = {
426
+ "sparse_num_index_heads": num_idx_heads
427
+ }
428
+
429
+ # Decode-first mixed batch: 2 decode reqs (q_len 1) then 2 prefill reqs. Long
430
+ # prefixes so every token sees > TOPK causal blocks (non-trivial selection).
431
+ batch = BatchSpec(seq_lens=[2305, 2561, 2624, 2720], query_lens=[1, 1, 64, 96])
432
+ common = create_common_attn_metadata(
433
+ batch, BLOCK_SIZE, device, arange_block_indices=True
434
+ )
435
+ num_tokens = batch.compute_num_tokens()
436
+
437
+ # Deterministic index cache: distinct, monotonic per-logical-block values so
438
+ # the top-k is unambiguous (both kernels pick the same blocks, no fp ties).
439
+ block_table = common.block_table_tensor
440
+ num_pages = int(block_table.max().item()) + 1
441
+ index_cache = torch.zeros(
442
+ num_pages, BLOCK_SIZE, head_dim, device=device, dtype=DTYPE
443
+ )
444
+ for r, seq_len in enumerate(batch.seq_lens):
445
+ for b in range((seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE):
446
+ index_cache[block_table[r, b]] = float(b + 1)
447
+ index_q = torch.ones(
448
+ num_tokens, num_idx_heads * head_dim, device=device, dtype=DTYPE
449
+ )
450
+
451
+ spec = MLAAttentionSpec(
452
+ block_size=BLOCK_SIZE, num_kv_heads=1, head_size=head_dim, dtype=DTYPE
453
+ )
454
+ impl_kwargs = dict(
455
+ num_kv_heads=num_idx_heads,
456
+ scale=head_dim**-0.5,
457
+ topk_blocks=topk,
458
+ sparse_block_size=BLOCK_SIZE,
459
+ num_index_heads=num_idx_heads,
460
+ index_head_dim=head_dim,
461
+ init_blocks=0,
462
+ local_blocks=0,
463
+ )
464
+
465
+ with set_current_vllm_config(vllm_config):
466
+ msa_impl = MiniMaxM3IndexerMSAImpl(prefix="idx_msa", **impl_kwargs)
467
+ triton_impl = MiniMaxM3IndexerTritonImpl(prefix="idx_triton", **impl_kwargs)
468
+ msa_builder = MiniMaxM3IndexerMSAMetadataBuilder(
469
+ spec, [msa_impl.index_cache.prefix], vllm_config, device
470
+ )
471
+ triton_builder = MiniMaxM3IndexerTritonMetadataBuilder(
472
+ spec, [triton_impl.index_cache.prefix], vllm_config, device
473
+ )
474
+
475
+ # Both impls score against the same index keys.
476
+ msa_impl.index_cache.kv_cache = index_cache
477
+ triton_impl.index_cache.kv_cache = index_cache
478
+
479
+ # Exercise the shared persistent top-k buffer for BOTH impls: each must write
480
+ # decode ([:, :nd]) and prefill ([:, nd:]) into its buffer and return views.
481
+ # Separate buffers so the two forwards don't clobber each other.
482
+ nd = sum(q for q in batch.query_lens if q <= 1)
483
+ msa_impl.topk_indices_buffer = torch.full(
484
+ (num_idx_heads, num_tokens, topk), -2, dtype=torch.int32, device=device
485
+ )
486
+ triton_impl.topk_indices_buffer = torch.full(
487
+ (num_idx_heads, num_tokens, topk), -2, dtype=torch.int32, device=device
488
+ )
489
+
490
+ attn_metadata = {
491
+ msa_impl.index_cache.prefix: msa_builder.build(0, common),
492
+ triton_impl.index_cache.prefix: triton_builder.build(0, common),
493
+ }
494
+ with set_forward_context(attn_metadata, vllm_config):
495
+ msa_decode, msa_prefill = msa_impl(index_q)
496
+ tri_decode, tri_prefill = triton_impl(index_q)
497
+
498
+ assert msa_decode is not None and tri_decode is not None
499
+ assert msa_prefill is not None and tri_prefill is not None
500
+ _assert_topk_indices_equal_unordered(msa_decode, tri_decode)
501
+ _assert_topk_indices_equal_unordered(msa_prefill, tri_prefill)
502
+ # decode/prefill outputs are views into each impl's persistent buffer.
503
+ for impl, dec, pre in (
504
+ (msa_impl, msa_decode, msa_prefill),
505
+ (triton_impl, tri_decode, tri_prefill),
506
+ ):
507
+ buf = impl.topk_indices_buffer
508
+ assert dec.data_ptr() == buf[:, :nd, :].data_ptr()
509
+ assert pre.data_ptr() == buf[:, nd:, :].data_ptr()
510
+
511
+
512
@pytest.mark.parametrize(
513
("decode_query_len", "max_decode_query_len"),
514
[
@@ -317,6 +582,65 @@ def test_decode_index_topk_correctness(
582
_assert_topk_indices_equal_unordered(actual, expected)
583
584
585
+@pytest.mark.skipif(
586
+ not current_platform.is_device_capability_family(100),
587
+ reason="fp8 e4m3 indexer cache is the SM100 (MSA) path.",
588
+)
589
+@pytest.mark.parametrize("num_idx_heads", [1, 4])
590
+def test_decode_index_topk_fp8(num_idx_heads: int):
591
+ """The fp8 (e4m3) indexer cache feeds the Triton decode kernel on the MSA
592
+ path. The kernel must score in fp32 (no scaling) so its top-k matches a
593
+ reference computed from the dequantized fp8 values."""
594
+ torch.manual_seed(0)
595
+ topk, init_blocks, local_blocks, head_dim = 8, 0, 1, 128
596
+ decode_query_len = 1
597
+ active_seq_lens = torch.tensor((129, 1025, 4097), device="cuda", dtype=torch.int32)
598
+ q_lens = torch.full_like(active_seq_lens, decode_query_len)
599
+ prefix_lens = active_seq_lens - decode_query_len
600
+ batch = active_seq_lens.numel()
601
+ max_seq_len = int(active_seq_lens.max())
602
+ max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE
603
+ num_pages = batch * max_blocks
604
+ block_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape(
605
+ batch, max_blocks
606
+ )
607
+ idx_q = torch.randn(
608
+ batch * decode_query_len, num_idx_heads, head_dim, device="cuda"
609
+ ).to(torch.float8_e4m3fn)
610
+ index_kv_cache = torch.randn(num_pages, BLOCK_SIZE, head_dim, device="cuda").to(
611
+ torch.float8_e4m3fn
612
+ )
613
+
614
+ actual = minimax_m3_index_decode(
615
+ idx_q,
616
+ index_kv_cache,
617
+ block_table,
618
+ active_seq_lens,
619
+ max_seq_len=max_seq_len,
620
+ topk=topk,
621
+ init_blocks=init_blocks,
622
+ local_blocks=local_blocks,
623
+ num_kv_heads=num_idx_heads,
624
+ sm_scale=head_dim**-0.5,
625
+ decode_query_len=decode_query_len,
626
+ )
627
+ # Reference from the DEQUANTIZED fp8 values (the kernel computes the fp8 QK
628
+ # in fp32, so it must match an fp32 matmul of the same e4m3 values).
629
+ expected = _reference_index_topk(
630
+ idx_q.float(),
631
+ index_kv_cache.float(),
632
+ block_table,
633
+ q_lens,
634
+ active_seq_lens,
635
+ prefix_lens,
636
+ topk,
637
+ init_blocks,
638
+ local_blocks,
639
+ head_dim**-0.5,
640
+ )
641
+ _assert_topk_indices_equal_unordered(actual, expected)
642
+
643
+
644
# Sparse attention kernels.
645
def _reference_sparse_attn(
646
q: torch.Tensor,
tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
+96
@@ -278,3 +278,99 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
278
torch.testing.assert_close(
279
index_cache.view(-1, HEAD_DIM), expected_index_cache, rtol=0, atol=0
280
)
281
+
282
+
283
+# ── Test 3: fp8 (e4m3) index outputs ─────────────────────────────────────────
284
+# The fp8 score path stores index_q and the index-K cache as e4m3 while q/k/v +
285
+# q_out stay bf16. Asserts: (1) q/k/v/q_out are bit-identical to the bf16 run
286
+# (the index dtype must not perturb the main branch), and (2) the e4m3 index
287
+# outputs dequantize close to the bf16 reference.
288
+
289
+
290
+@pytest.mark.skipif(
291
+ not torch.cuda.is_available() or torch.cuda.get_device_capability() < (8, 9),
292
+ reason="e4m3 conversion requires CUDA SM89+.",
293
+)
294
+@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513])
295
+@pytest.mark.parametrize("block_size", [16, 64])
296
+def test_sparse_full_fp8_index(num_tokens, block_size):
297
+ torch.manual_seed(1)
298
+ device, dtype, eps = "cuda", torch.bfloat16, 1e-6
299
+ base, max_pos = 5_000_000.0, 4096
300
+ num_heads, num_kv_heads, num_idx_heads = 16, 4, 4
301
+
302
+ q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
303
+ k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
304
+ iq_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
305
+ ik_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
306
+ cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device)
307
+ positions = torch.randint(
308
+ 0, max_pos, (num_tokens,), dtype=torch.int64, device=device
309
+ )
310
+
311
+ qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM
312
+ iqsz, iksz = num_idx_heads * HEAD_DIM, HEAD_DIM
313
+ qkv0 = torch.randn(
314
+ num_tokens, qsz + 2 * kvsz + iqsz + iksz, dtype=dtype, device=device
315
+ )
316
+
317
+ num_blocks = (num_tokens + block_size - 1) // block_size + 1
318
+ slot_mapping = torch.randperm(
319
+ num_blocks * block_size, dtype=torch.int64, device=device
320
+ )[:num_tokens]
321
+ index_slot_mapping = torch.roll(slot_mapping, shifts=1)
322
+
323
+ def run(index_dtype):
324
+ qkv = qkv0.clone()
325
+ kv_cache = torch.zeros(
326
+ num_blocks,
327
+ 2,
328
+ block_size,
329
+ num_kv_heads,
330
+ HEAD_DIM,
331
+ dtype=dtype,
332
+ device=device,
333
+ )
334
+ index_cache = torch.zeros(
335
+ num_blocks, block_size, HEAD_DIM, dtype=index_dtype, device=device
336
+ )
337
+ q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device)
338
+ index_q = torch.empty(num_tokens, iqsz, dtype=index_dtype, device=device)
339
+ ops.fused_minimax_m3_qknorm_rope_kv_insert(
340
+ qkv,
341
+ q_w,
342
+ k_w,
343
+ cos_sin,
344
+ positions,
345
+ num_heads,
346
+ num_kv_heads,
347
+ ROTARY_DIM,
348
+ eps,
349
+ iq_w,
350
+ ik_w,
351
+ num_idx_heads,
352
+ slot_mapping,
353
+ index_slot_mapping,
354
+ kv_cache,
355
+ index_cache,
356
+ block_size,
357
+ q_out,
358
+ index_q,
359
+ )
360
+ return qkv, kv_cache, index_cache, q_out, index_q
361
+
362
+ qkv_bf, kvc_bf, idxc_bf, qo_bf, iq_bf = run(torch.bfloat16)
363
+ qkv_fp, kvc_fp, idxc_fp, qo_fp, iq_fp = run(torch.float8_e4m3fn)
364
+
365
+ assert iq_fp.dtype == torch.float8_e4m3fn
366
+ assert idxc_fp.dtype == torch.float8_e4m3fn
367
+
368
+ # (1) The main branch (q/k/v in qkv, q_out, kv cache) must be bit-identical:
369
+ # the index output dtype must not perturb anything else.
370
+ torch.testing.assert_close(qo_fp, qo_bf, rtol=0, atol=0)
371
+ torch.testing.assert_close(qkv_fp, qkv_bf, rtol=0, atol=0)
372
+ torch.testing.assert_close(kvc_fp, kvc_bf, rtol=0, atol=0)
373
+
374
+ # (2) Dequantized e4m3 index outputs match the bf16 reference within fp8 ulp.
375
+ torch.testing.assert_close(iq_fp.float(), iq_bf.float(), rtol=0.13, atol=0.05)
376
+ torch.testing.assert_close(idxc_fp.float(), idxc_bf.float(), rtol=0.13, atol=0.05)
vllm/envs.py
+3
-4
@@ -1580,10 +1580,9 @@ environment_variables: dict[str, Callable[[], Any]] = {
1580
os.getenv("VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS", "1")
1581
),
1582
# Enforce function parameter schemas in structural-tag based tool calling.
1583
- "VLLM_ENFORCE_STRICT_TOOL_CALLING": lambda: os.getenv(
1584
- "VLLM_ENFORCE_STRICT_TOOL_CALLING", "True"
1585
- ).lower()
1586
- in ("true", "1"),
1583
+ "VLLM_ENFORCE_STRICT_TOOL_CALLING": lambda: (
1584
+ os.getenv("VLLM_ENFORCE_STRICT_TOOL_CALLING", "True").lower() in ("true", "1")
1585
+ ),
1586
# Control the max chunk bytes (in MB) for the rpc message queue.
1587
# Object larger than this threshold will be broadcast to worker
1588
# processes via zmq.
vllm/models/minimax_m3/amd/model.py
+28
-3
@@ -457,6 +457,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
457
quant_config: QuantizationConfig | None = None,
458
prefix: str = "",
459
cache_config: CacheConfig | None = None,
460
+ topk_indices_buffer: torch.Tensor | None = None,
461
) -> None:
462
super().__init__()
463
self.hidden_size = config.hidden_size
@@ -535,6 +536,9 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
536
self.kv_cache_dtype, vllm_config.model_config
537
)
538
539
+ # Shared top-k buffer: the indexer writes the selected blocks into it and
540
+ # the attend impl reads them back (no Python value crosses the break).
541
+ self.topk_indices_buffer = topk_indices_buffer
542
self.attn_backend = MiniMaxM3SparseBackend
543
# Indexer and main attention are separate impls. On ROCm the SM100 gate
544
# is always False, so both pick Triton and the index cache stays bf16.
@@ -565,6 +569,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
569
local_blocks=sparse_cfg.get("sparse_local_block", 0),
570
score_type=sparse_cfg.get("sparse_score_type", "max"),
571
cache_config=cache_config,
572
+ topk_indices_buffer=topk_indices_buffer,
573
)
574
575
# Register the main K/V cache so the KV-cache manager allocates it.
@@ -657,9 +662,10 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
662
output: torch.Tensor,
663
) -> torch.Tensor:
664
# Single eager break around both: their split-K kernels read per-request
660
- # metadata and can't be captured into a cudagraph.
661
- topk_idx = self.indexer(index_query)
662
- return self.impl.forward(self, query, self.kv_cache, topk_idx, output)
665
+ # metadata and can't be captured into a cudagraph. The indexer writes its
666
+ # top-k into the shared ``topk_indices_buffer``; the attend reads it back.
667
+ self.indexer(index_query)
668
+ return self.impl.forward(self, query, self.kv_cache, output)
669
670
671
class MiniMaxM3DecoderLayer(nn.Module):
@@ -671,6 +677,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
677
quant_config: QuantizationConfig | None = None,
678
force_sparse_attn: bool = False,
679
force_moe: bool = False,
680
+ topk_indices_buffer: torch.Tensor | None = None,
681
) -> None:
682
super().__init__()
683
self.hidden_size = config.hidden_size
@@ -690,6 +697,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
697
quant_config=quant_config,
698
prefix=f"{prefix}.self_attn",
699
cache_config=cache_config,
700
+ topk_indices_buffer=topk_indices_buffer,
701
)
702
else:
703
self.self_attn = MiniMaxM3Attention(
@@ -771,6 +779,22 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
779
prefix=f"{prefix}.embed_tokens",
780
)
781
782
+ # Reserved top-k indices buffer shared by all sparse-attention indexer
783
+ # layers (mirrors DeepseekV4); the indexer writes its per-head decode/
784
+ # prefill block selection into it, the attend reads it back.
785
+ sparse_cfg = getattr(config, "sparse_attention_config", None)
786
+ if sparse_cfg is not None:
787
+ tp_size = get_tensor_model_parallel_world_size()
788
+ num_index_heads = max(1, sparse_cfg["sparse_num_index_heads"] // tp_size)
789
+ self.topk_indices_buffer = torch.empty(
790
+ num_index_heads,
791
+ vllm_config.scheduler_config.max_num_batched_tokens,
792
+ sparse_cfg["sparse_topk_blocks"],
793
+ dtype=torch.int32,
794
+ )
795
+ else:
796
+ self.topk_indices_buffer = None
797
+
798
self.start_layer, self.end_layer, self.layers = make_layers(
799
config.num_hidden_layers,
800
lambda prefix: MiniMaxM3DecoderLayer(
@@ -778,6 +802,7 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
802
prefix,
803
cache_config=cache_config,
804
quant_config=quant_config,
805
+ topk_indices_buffer=self.topk_indices_buffer,
806
),
807
prefix=f"{prefix}.layers",
808
)
vllm/models/minimax_m3/common/indexer.py
+60
-8
@@ -25,12 +25,14 @@ from vllm.config.attention import IndexerKVDType
25
from vllm.config.cache import CacheDType
26
from vllm.distributed import get_tensor_model_parallel_world_size
27
from vllm.forward_context import get_forward_context
28
+from vllm.logger import init_logger
29
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
30
from vllm.models.minimax_m3.common.ops.index_topk import (
31
minimax_m3_index_decode,
32
minimax_m3_index_score,
33
minimax_m3_index_topk,
34
)
35
+from vllm.platforms import current_platform
36
from vllm.v1.attention.backend import (
37
AttentionBackend,
38
AttentionCGSupport,
@@ -46,6 +48,8 @@ from vllm.v1.kv_cache_interface import (
48
MLAAttentionSpec,
49
)
50
51
+logger = init_logger(__name__)
52
+
53
54
class MiniMaxM3IndexerBackend(AttentionBackend):
55
"""Indexer side-cache backend (key-only)."""
@@ -120,16 +124,20 @@ class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
124
backend_cls: type[AttentionBackend] = MiniMaxM3IndexerBackend,
125
) -> None:
126
super().__init__()
123
- if indexer_kv_dtype != "bf16":
127
+ if indexer_kv_dtype in ("fp8", "fp8_e4m3"):
128
+ cache_dtype = torch.float8_e4m3fn
129
+ elif indexer_kv_dtype == "bf16":
130
+ cache_dtype = torch.bfloat16
131
+ else:
132
raise NotImplementedError(
125
- f"indexer_kv_dtype={indexer_kv_dtype!r} is not supported yet "
126
- "for the MiniMax M3 indexer cache (only 'bf16')."
133
+ f"indexer_kv_dtype={indexer_kv_dtype!r} is not supported by the "
134
+ "MiniMax M3 indexer cache (only 'bf16' or 'fp8'/'fp8_e4m3')."
135
)
136
self.kv_cache = torch.tensor([])
137
self.head_dim = head_dim
138
self.indexer_kv_dtype = indexer_kv_dtype
131
- # Storage dtype for the side cache (bf16 today; quantized layouts later).
132
- self.dtype = torch.bfloat16
139
+ # Side-cache storage dtype: bf16, or e4m3 for the fp8 score path.
140
+ self.dtype = cache_dtype
141
self.prefix = prefix
142
self.cache_config = cache_config
143
# Impl-chosen backend -> each impl gets its own builder (get_attn_backend).
@@ -344,6 +352,7 @@ class MiniMaxM3IndexerImpl(nn.Module):
352
score_type: str = "max",
353
cache_config: CacheConfig | None = None,
354
indexer_kv_dtype: IndexerKVDType = "bf16",
355
+ topk_indices_buffer: torch.Tensor | None = None,
356
) -> None:
357
super().__init__()
358
self.num_kv_heads = num_kv_heads
@@ -356,6 +365,9 @@ class MiniMaxM3IndexerImpl(nn.Module):
365
self.num_index_heads = num_index_heads
366
self.index_head_dim = index_head_dim
367
self.indexer_kv_dtype = indexer_kv_dtype
368
+ # Shared, stable-address top-k output buffer (set by the model for the
369
+ # cudagraph-safe MSA impl); None -> impl allocates fresh (eager).
370
+ self.topk_indices_buffer = topk_indices_buffer
371
# Owns the side cache (registers itself in the static forward context).
372
self.index_cache = MiniMaxM3IndexerCache(
373
head_dim=index_head_dim,
@@ -392,6 +404,10 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
404
)
405
kv = self.index_cache.kv_cache
406
407
+ # Both sides write into the single shared persistent topk_indices_buffer
408
+ # (decode at [:, :nd], prefill at [:, nd:]) and return views into it; the
409
+ # kernels' out= writes out[:, :total_q]. None -> allocate fresh.
410
+ buf = self.topk_indices_buffer
411
decode_topk: torch.Tensor | None = None
412
prefill_topk: torch.Tensor | None = None
413
if index_md.num_decodes > 0:
@@ -409,6 +425,7 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
425
self.num_kv_heads,
426
d.decode_query_len,
427
d.max_decode_query_len,
428
+ out=buf,
429
)
430
if index_md.num_prefills > 0:
431
p = index_md.prefill
@@ -432,29 +449,61 @@ class MiniMaxM3IndexerTritonImpl(MiniMaxM3IndexerImpl):
449
self.topk_blocks,
450
self.init_blocks,
451
self.local_blocks,
452
+ out=buf[:, nd:, :] if buf is not None else None,
453
)
454
return decode_topk, prefill_topk
455
456
457
def select_indexer_impl_cls(
458
*,
459
+ topk_blocks: int,
460
indexer_kv_dtype: IndexerKVDType = "bf16",
461
) -> type[MiniMaxM3IndexerImpl]:
443
- """Pick the indexer impl off the index-cache dtype.
462
+ """Pick the indexer impl off the platform, top-k count, and cache dtype.
463
445
- The SM100 MSA indexer score path is disabled for now; use the local Triton
446
- indexer. If re-enabled, add a NVIDIA-specific ``MiniMaxM3IndexerImpl`` here.
464
+ On Blackwell (SM100) with ``topk_blocks`` in ``(4, 8, 16, 32)`` (matching the
465
+ main MSA attend), the fmha_sm100 score path + Triton top-k is used for both
466
+ bf16 and fp8 index caches. Everything else falls back to the Triton indexer
467
+ (bf16 only).
468
"""
469
if indexer_kv_dtype in ("mxfp4", "nvfp4"):
470
raise NotImplementedError(
471
f"indexer_kv_dtype={indexer_kv_dtype!r} needs the (not-yet-added) "
472
"CuteDSL indexer impl."
473
)
474
+ is_sm100 = (
475
+ current_platform.is_cuda() and current_platform.is_device_capability_family(100)
476
+ )
477
+ use_msa = (
478
+ is_sm100
479
+ and topk_blocks in (4, 8, 16, 32)
480
+ and indexer_kv_dtype in ("bf16", "fp8", "fp8_e4m3")
481
+ )
482
+ if use_msa:
483
+ # Lazy import so AMD / non-SM100 never import fmha_sm100.
484
+ from vllm.models.minimax_m3.nvidia.indexer_msa import (
485
+ MiniMaxM3IndexerMSAImpl,
486
+ )
487
+
488
+ logger.info_once(
489
+ "MiniMax M3 indexer: selected MSA (fmha_sm100 score + Triton top-k) "
490
+ "[topk_blocks=%d, indexer_kv_dtype=%s]",
491
+ topk_blocks,
492
+ indexer_kv_dtype,
493
+ )
494
+ return MiniMaxM3IndexerMSAImpl
495
if indexer_kv_dtype != "bf16":
496
raise NotImplementedError(
497
f"indexer_kv_dtype={indexer_kv_dtype!r} is not supported by the "
498
"Triton indexer impl."
499
)
500
+ logger.info_once(
501
+ "MiniMax M3 indexer: selected Triton (no fmha_sm100) "
502
+ "[topk_blocks=%d, indexer_kv_dtype=%s, sm100=%s]",
503
+ topk_blocks,
504
+ indexer_kv_dtype,
505
+ is_sm100,
506
+ )
507
return MiniMaxM3IndexerTritonImpl
508
509
@@ -480,9 +529,11 @@ class MiniMaxM3Indexer(nn.Module):
529
score_type: str = "max",
530
cache_config: CacheConfig | None = None,
531
indexer_kv_dtype: IndexerKVDType = "bf16",
532
+ topk_indices_buffer: torch.Tensor | None = None,
533
) -> None:
534
super().__init__()
535
impl_cls = select_indexer_impl_cls(
536
+ topk_blocks=topk_blocks,
537
indexer_kv_dtype=indexer_kv_dtype,
538
)
539
self.impl = impl_cls(
@@ -498,6 +549,7 @@ class MiniMaxM3Indexer(nn.Module):
549
score_type=score_type,
550
cache_config=cache_config,
551
indexer_kv_dtype=indexer_kv_dtype,
552
+ topk_indices_buffer=topk_indices_buffer,
553
)
554
555
@property
vllm/models/minimax_m3/common/ops/index_topk.py
+30
-12
@@ -373,7 +373,10 @@ def _decode_index_score_kernel(
373
+ off_k[:, None] * stride_ik_pos
374
+ off_d * stride_ik_d,
375
) # [N,D]
376
- kq = tl.dot(k, q) # [N,HQ]
376
+ # fp32 accumulation is required for the fp8 (e4m3) index cache: q/k are
377
+ # loaded in their stored dtype (bf16 or e4m3) and the MMA accumulates in
378
+ # fp32 so the per-block max score is exact for the fp8 indexer too.
379
+ kq = tl.dot(k, q, out_dtype=tl.float32) # [N,HQ]
380
kq = tl.where(pos_mask & q_mask[None, :], kq, float("-inf"))
381
score = tl.max(kq, axis=0) # [HQ]
382
is_visible_block = blk < num_blocks_q
@@ -709,16 +712,25 @@ def minimax_m3_index_topk(
712
topk: int,
713
init_blocks: int,
714
local_blocks: int,
715
+ out: torch.Tensor | None = None,
716
) -> torch.Tensor:
713
- """Select index top-k from a precomputed score tensor."""
717
+ """Select index top-k from a precomputed score tensor.
718
+
719
+ When ``out`` is provided (a ``[num_idx_heads, >=total_q, topk]`` buffer), the
720
+ result is written into ``out[:, :total_q, :]`` instead of a fresh tensor --
721
+ used to keep the top-k output at a stable address for cudagraph capture.
722
+ """
723
num_idx_heads = score.shape[0]
724
batch = cu_seqlens_q.shape[0] - 1
725
total_q = score.shape[1]
717
- topk_idx = torch.empty(
718
- (num_idx_heads, total_q, topk),
719
- dtype=torch.int32,
720
- device=score.device,
721
- )
726
+ if out is not None:
727
+ topk_idx = out[:, :total_q, :]
728
+ else:
729
+ topk_idx = torch.empty(
730
+ (num_idx_heads, total_q, topk),
731
+ dtype=torch.int32,
732
+ device=score.device,
733
+ )
734
# block_size_q == 1 -> query blocks coincide with query tokens.
735
grid_topk = (max_query_len, batch, num_idx_heads)
736
_topk_index_kernel[grid_topk](
@@ -757,10 +769,13 @@ def minimax_m3_index_decode(
769
num_kv_heads: int,
770
decode_query_len: int,
771
max_decode_query_len: int,
772
+ out: torch.Tensor | None = None,
773
) -> torch.Tensor:
774
"""Decode index block-score + top-k, both split-K (cudagraph-safe).
775
776
Returns topk_idx [num_kv_heads, total_q, topk] (0-indexed block ids, -1 pad).
777
+ When ``out`` ([num_kv_heads, >=total_q, topk]) is given, writes into
778
+ ``out[:, :total_q, :]`` (stable address for cudagraph) instead of allocating.
779
"""
780
total_q, num_idx_heads, head_dim = idx_q.shape
781
assert num_idx_heads == num_kv_heads, (
@@ -834,11 +849,14 @@ def minimax_m3_index_decode(
849
**score_kwargs,
850
)
851
837
- topk_idx = torch.empty(
838
- (num_idx_heads, total_q, topk),
839
- dtype=torch.int32,
840
- device=idx_q.device,
841
- )
852
+ if out is not None:
853
+ topk_idx = out[:, :total_q, :]
854
+ else:
855
+ topk_idx = torch.empty(
856
+ (num_idx_heads, total_q, topk),
857
+ dtype=torch.int32,
858
+ device=idx_q.device,
859
+ )
860
# Chunk count is shape-constant (cudagraph-safe), capped so the merge sorts
861
# pow2(num_topk_chunks * pow2(topk)) candidates.
862
TOPK_TARGET_GRID = 64
vllm/models/minimax_m3/common/sparse_attention.py
+22
-15
@@ -2,10 +2,11 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Main block-sparse GQA attention for MiniMax M3 sparse layers.
4
5
-The lightning indexer (``indexer.py``) selects the top-k KV blocks; this module
6
-holds the main attention that attends only to those blocks: the paged K/V cache
7
-backend, its metadata + builder, and the impl that consumes the indexer's
8
-``topk_idx``. The Triton attend kernel lives here; the SM100 (MSA)
5
+The lightning indexer (``indexer.py``) selects the top-k KV blocks (written into
6
+the shared ``layer.topk_indices_buffer``); this module holds the main attention
7
+that attends only to those blocks: the paged K/V cache backend, its metadata +
8
+builder, and the impl that reads the indexer's top-k from that buffer. The Triton
9
+attend kernel lives here; the SM100 (MSA)
10
``build_k2q_csr`` + ``sparse_atten_func`` attend lives in
11
``nvidia/sparse_attention_msa.py``.
12
@@ -272,9 +273,10 @@ class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
273
"""Abstract base for block-sparse GQA over the indexer-selected blocks.
274
275
Inherits ``AttentionImplBase`` for a custom forward signature (the layer
275
- pre-inserts K/V and runs the indexer, so forward takes the queries +
276
- ``topk_idx``). The Triton and MSA subclasses each own a full ``forward`` --
277
- no shared forward code.
276
+ pre-inserts K/V and runs the indexer, which writes the selected blocks into
277
+ the shared ``layer.topk_indices_buffer``; the attend reads them back from
278
+ there). The Triton and MSA subclasses each own a full ``forward`` -- no
279
+ shared forward code.
280
"""
281
282
def __init__(
@@ -311,10 +313,14 @@ class MiniMaxM3SparseImpl(AttentionImplBase[MiniMaxM3SparseMetadata]):
313
layer: AttentionLayer,
314
query: torch.Tensor,
315
kv_cache: torch.Tensor,
314
- topk_idx: tuple[torch.Tensor | None, torch.Tensor | None],
316
output: torch.Tensor,
317
) -> torch.Tensor:
317
- """Attend the queries to the indexer-selected blocks. Per kernel."""
318
+ """Attend the queries to the indexer-selected blocks. Per kernel.
319
+
320
+ The indexer has already written the top-k block ids into
321
+ ``layer.topk_indices_buffer`` (decode at ``[:, :nd]``, prefill at
322
+ ``[:, nd:num_tokens]``); the attend reads them from there.
323
+ """
324
raise NotImplementedError
325
326
@@ -326,7 +332,6 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
332
layer: AttentionLayer,
333
query: torch.Tensor,
334
kv_cache: torch.Tensor,
329
- topk_idx: tuple[torch.Tensor | None, torch.Tensor | None],
335
output: torch.Tensor,
336
) -> torch.Tensor:
337
attn_metadata = get_forward_context().attn_metadata
@@ -334,10 +339,12 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
339
return output # profiling run; caches unbound
340
main_md = attn_metadata[layer.layer_name] # type: ignore[attr-defined]
341
assert isinstance(main_md, MiniMaxM3SparseMetadata)
337
- decode_topk, prefill_topk = topk_idx
342
343
nd = main_md.num_decode_tokens
344
num_tokens = main_md.num_actual_tokens
345
+ # Indexer top-k from the shared buffer: decode [:, :nd], prefill [:, nd:].
346
+ topk = layer.topk_indices_buffer # type: ignore[attr-defined]
347
+ assert topk is not None
348
hd = self.head_size
349
q = query[:num_tokens].view(-1, self.num_heads, hd)
350
out = output[:num_tokens].view(-1, self.num_heads, hd)
@@ -348,11 +355,11 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
355
# Decode [:nd]: split-K over the selected blocks (request-major chunks).
356
if main_md.num_decodes > 0:
357
d = main_md.decode
351
- assert d is not None and decode_topk is not None
358
+ assert d is not None
359
minimax_m3_sparse_attn_decode(
360
q[:nd],
361
kv_cache,
355
- decode_topk,
362
+ topk[:, :nd, :],
363
d.block_table,
364
d.seq_lens,
365
self.num_kv_heads,
@@ -364,11 +371,11 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
371
# Prefill [nd:]: cu_seqlens_q already rebased to 0.
372
if main_md.num_prefills > 0:
373
p = main_md.prefill
367
- assert p is not None and prefill_topk is not None
374
+ assert p is not None
375
minimax_m3_sparse_attn(
376
q[nd:],
377
kv_cache,
371
- prefill_topk,
378
+ topk[:, nd:num_tokens, :],
379
p.block_table,
380
p.cu_seqlens_q,
381
p.seq_lens,
vllm/models/minimax_m3/nvidia/indexer_msa.py
new
+251
@@ -0,0 +1,251 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""MSA (SM100/Blackwell) indexer impl for MiniMax M3.
4
+
5
+Prefill scores with ``fmha_sm100``'s score-only (``OnlyScore``) path then selects
6
+top-k blocks with the Triton ``minimax_m3_index_topk`` kernel -- fmha is much
7
+faster than Triton for the wide prefill score (benchmarked ~3-5x).
8
+
9
+Decode uses the Triton fused ``minimax_m3_index_decode`` (the same kernel the
10
+Triton indexer impl uses): for q_len==1 it is a purpose-built vector x matrix
11
+score (no wasted tensor-core tiles) with a 256-way split-K and a fused split-K
12
+top-k, which beats fmha's OnlyScore (wasted MMA on a single query, 64-split cap)
13
+by ~1.1-3.7x. It is cudagraph-safe by construction (shape-constant split grids)
14
+and writes the shared ``topk_indices_buffer`` via ``out=``.
15
+
16
+``fmha_sm100`` imports are function-local so this module is import-safe on
17
+AMD / non-SM100.
18
+"""
19
+
20
+from dataclasses import dataclass
21
+from typing import ClassVar
22
+
23
+import torch
24
+
25
+from vllm.forward_context import get_forward_context
26
+from vllm.models.minimax_m3.common.indexer import (
27
+ MiniMaxM3IndexerBackend,
28
+ MiniMaxM3IndexerDecodeMetadata,
29
+ MiniMaxM3IndexerImpl,
30
+ MiniMaxM3IndexerMetadata,
31
+ MiniMaxM3IndexerMetadataBuilder,
32
+)
33
+from vllm.models.minimax_m3.common.ops.index_topk import (
34
+ minimax_m3_index_decode,
35
+ minimax_m3_index_topk,
36
+)
37
+from vllm.v1.attention.backend import (
38
+ AttentionBackend,
39
+ AttentionCGSupport,
40
+ CommonAttentionMetadata,
41
+)
42
+from vllm.v1.attention.backends.utils import split_decodes_and_prefills
43
+
44
+# Page size == sparse block size == index-K block; fmha tile id == M3 block id.
45
+PAGE_SIZE = 128
46
+
47
+
48
+class MiniMaxM3IndexerMSABackend(MiniMaxM3IndexerBackend):
49
+ """Indexer side-cache backend selecting the MSA builder."""
50
+
51
+ @staticmethod
52
+ def get_builder_cls() -> type["MiniMaxM3IndexerMSAMetadataBuilder"]:
53
+ return MiniMaxM3IndexerMSAMetadataBuilder
54
+
55
+
56
+@dataclass
57
+class MiniMaxM3IndexerMSAPrefillMetadata:
58
+ """fmha score plan + Triton top-k inputs for the prefill side (eager)."""
59
+
60
+ plan: dict # fmha_sm100 PlanInfo
61
+ cu_seqlens_q: torch.Tensor # [num_prefills + 1] int32, rebased to 0
62
+ prefix_lens: torch.Tensor # [num_prefills] int32, context tokens
63
+ max_query_len: int
64
+ page_table: torch.Tensor # flat physical page indices for the prefill side
65
+
66
+
67
+@dataclass
68
+class MiniMaxM3IndexerMSAMetadata(MiniMaxM3IndexerMetadata):
69
+ """Decode reuses the inherited base ``decode`` field (the Triton decode
70
+ metadata); ``prefill_msa`` carries the fmha score plan for the prefill side
71
+ (the base ``prefill`` field is unused on this path)."""
72
+
73
+ prefill_msa: MiniMaxM3IndexerMSAPrefillMetadata | None = None
74
+
75
+
76
+class MiniMaxM3IndexerMSAMetadataBuilder(MiniMaxM3IndexerMetadataBuilder):
77
+ """Decode metadata is the cudagraph-safe Triton decode metadata; the prefill
78
+ fmha plan is built eagerly (prefill batches are not captured)."""
79
+
80
+ _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
81
+
82
+ def build(
83
+ self,
84
+ common_prefix_len: int,
85
+ common_attn_metadata: CommonAttentionMetadata,
86
+ fast_build: bool = False,
87
+ ) -> MiniMaxM3IndexerMSAMetadata:
88
+ num_reqs = common_attn_metadata.num_reqs
89
+ num_tokens = common_attn_metadata.num_actual_tokens
90
+ seq_lens = common_attn_metadata.seq_lens
91
+ block_table = common_attn_metadata.block_table_tensor
92
+ query_start_loc = common_attn_metadata.query_start_loc
93
+
94
+ num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
95
+ split_decodes_and_prefills(
96
+ common_attn_metadata,
97
+ decode_threshold=self.reorder_batch_threshold,
98
+ require_uniform=True,
99
+ )
100
+ )
101
+ assert num_decodes + num_prefills == num_reqs
102
+ assert num_decode_tokens + num_prefill_tokens == num_tokens
103
+
104
+ # Context (prefix) lengths into the stable cudagraph buffer.
105
+ context_lens = self.context_len_buffer[:num_reqs]
106
+ context_lens.copy_(
107
+ common_attn_metadata.compute_num_computed_tokens(), non_blocking=True
108
+ )
109
+
110
+ decode: MiniMaxM3IndexerDecodeMetadata | None = None
111
+ if num_decodes > 0:
112
+ qsl_cpu = common_attn_metadata.query_start_loc_cpu
113
+ query_lens_cpu = qsl_cpu[1 : num_decodes + 1] - qsl_cpu[:num_decodes]
114
+ decode_query_len = int(query_lens_cpu[0].item())
115
+ assert decode_query_len > 0
116
+ assert torch.all(
117
+ (query_lens_cpu == decode_query_len) | (query_lens_cpu == 0)
118
+ )
119
+ decode = MiniMaxM3IndexerDecodeMetadata(
120
+ seq_lens=seq_lens[:num_decodes],
121
+ block_table=block_table[:num_decodes],
122
+ max_seq_len=common_attn_metadata.max_seq_len,
123
+ decode_query_len=decode_query_len,
124
+ max_decode_query_len=self.max_decode_query_len,
125
+ )
126
+
127
+ prefill: MiniMaxM3IndexerMSAPrefillMetadata | None = None
128
+ if num_prefills > 0:
129
+ # Prefill is eager (not captured); the host lengths it needs (and the
130
+ # _fmha_sm100_plan .tolist() inside) make the D->H sync acceptable.
131
+ from vllm.third_party.fmha_sm100.api import _fmha_sm100_plan
132
+
133
+ lo, hi = num_decodes, num_reqs
134
+ qsl_cpu = common_attn_metadata.query_start_loc_cpu[: num_reqs + 1]
135
+ qo_lens_cpu = (qsl_cpu[1:] - qsl_cpu[:-1]).to(torch.int32)
136
+ kv_lens_cpu = seq_lens[:num_reqs].cpu().to(torch.int32)
137
+ nvp = (kv_lens_cpu + PAGE_SIZE - 1) // PAGE_SIZE
138
+ side_qo = qo_lens_cpu[lo:hi]
139
+ side_kv = kv_lens_cpu[lo:hi]
140
+ plan = _fmha_sm100_plan(
141
+ side_qo,
142
+ side_kv,
143
+ self.num_index_heads,
144
+ num_kv_heads=1,
145
+ qo_offset=side_kv - side_qo, # bottom-right causal
146
+ page_size=PAGE_SIZE,
147
+ output_maxscore=True,
148
+ causal=True,
149
+ num_kv_splits=1,
150
+ )
151
+ cols = torch.arange(block_table.shape[1], device=block_table.device)
152
+ valid = cols[None, :] < nvp[lo:hi].to(block_table.device)[:, None]
153
+ prefill = MiniMaxM3IndexerMSAPrefillMetadata(
154
+ plan=plan,
155
+ cu_seqlens_q=(query_start_loc[lo : hi + 1] - query_start_loc[lo]).to(
156
+ torch.int32
157
+ ),
158
+ prefix_lens=context_lens[lo:hi],
159
+ max_query_len=int(side_qo.max()),
160
+ page_table=block_table[lo:hi][valid].to(torch.int32),
161
+ )
162
+
163
+ return MiniMaxM3IndexerMSAMetadata(
164
+ seq_lens=seq_lens,
165
+ max_seq_len=common_attn_metadata.max_seq_len,
166
+ slot_mapping=common_attn_metadata.slot_mapping,
167
+ num_actual_tokens=num_tokens,
168
+ num_decodes=num_decodes,
169
+ num_decode_tokens=num_decode_tokens,
170
+ num_prefills=num_prefills,
171
+ num_prefill_tokens=num_prefill_tokens,
172
+ decode=decode,
173
+ prefill_msa=prefill,
174
+ )
175
+
176
+
177
+class MiniMaxM3IndexerMSAImpl(MiniMaxM3IndexerImpl):
178
+ """Decode: Triton fused score+top-k. Prefill: fmha_sm100 OnlyScore + top-k."""
179
+
180
+ indexer_backend_cls: ClassVar[type[AttentionBackend]] = MiniMaxM3IndexerMSABackend
181
+
182
+ def forward(
183
+ self,
184
+ index_query: torch.Tensor,
185
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None]:
186
+ attn_metadata = get_forward_context().attn_metadata
187
+ if not isinstance(attn_metadata, dict):
188
+ return None, None # profiling run; caches unbound
189
+ md = attn_metadata[self.index_cache.prefix]
190
+ assert isinstance(md, MiniMaxM3IndexerMSAMetadata)
191
+
192
+ num_tokens = md.num_actual_tokens
193
+ nd = md.num_decode_tokens
194
+ index_q = index_query[:num_tokens].view(
195
+ -1, self.num_index_heads, self.index_head_dim
196
+ )
197
+ kv = self.index_cache.kv_cache
198
+ # Both sides write into the single shared persistent topk_indices_buffer:
199
+ # decode at [:, :nd], prefill at [:, nd:] (each kernel writes [:, :total_q]).
200
+ buf = self.topk_indices_buffer
201
+
202
+ decode_topk: torch.Tensor | None = None
203
+ if md.decode is not None:
204
+ d = md.decode
205
+ decode_topk = minimax_m3_index_decode(
206
+ index_q[:nd],
207
+ kv,
208
+ d.block_table,
209
+ d.seq_lens,
210
+ d.max_seq_len,
211
+ self.topk_blocks,
212
+ self.init_blocks,
213
+ self.local_blocks,
214
+ self.num_kv_heads,
215
+ d.decode_query_len,
216
+ d.max_decode_query_len,
217
+ out=buf,
218
+ )
219
+
220
+ prefill_topk: torch.Tensor | None = None
221
+ if md.prefill_msa is not None:
222
+ from vllm.third_party.fmha_sm100.api import _fmha_sm100
223
+
224
+ p = md.prefill_msa
225
+ # Index-K cache (num_blocks, 128, D) -> paged MQA (num_blocks,1,128,D).
226
+ k_pages = kv.view(kv.shape[0], 1, PAGE_SIZE, self.index_head_dim)
227
+ _, max_score = _fmha_sm100(
228
+ index_q[nd:],
229
+ k_pages,
230
+ k_pages, # V placeholder; not read in OnlyScore
231
+ p.plan,
232
+ kv_indices=p.page_table,
233
+ output_o=False,
234
+ output_maxscore=True,
235
+ sm_scale=self.scale,
236
+ )
237
+ # Triton top-k wants [num_index_heads, num_tokens, max_block]; the
238
+ # transpose is a strided view (the kernel reads via strides).
239
+ out = buf[:, nd:, :] if buf is not None else None
240
+ prefill_topk = minimax_m3_index_topk(
241
+ max_score.transpose(1, 2),
242
+ p.cu_seqlens_q,
243
+ p.prefix_lens,
244
+ p.max_query_len,
245
+ self.topk_blocks,
246
+ self.init_blocks,
247
+ self.local_blocks,
248
+ out=out,
249
+ )
250
+
251
+ return decode_topk, prefill_topk
vllm/models/minimax_m3/nvidia/model.py
+40
-4
@@ -402,6 +402,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
402
quant_config: QuantizationConfig | None = None,
403
prefix: str = "",
404
cache_config: CacheConfig | None = None,
405
+ topk_indices_buffer: torch.Tensor | None = None,
406
) -> None:
407
super().__init__()
408
self.hidden_size = config.hidden_size
@@ -489,6 +490,10 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
490
# cache (--attention-config '{"indexer_kv_dtype": ...}').
491
self.indexer_kv_dtype = vllm_config.attention_config.indexer_kv_dtype
492
493
+ # Shared top-k buffer: the indexer writes the selected blocks into it and
494
+ # the attend impl reads them back (so nothing crosses the eager break as a
495
+ # Python value, which would freeze at capture).
496
+ self.topk_indices_buffer = topk_indices_buffer
497
self.attn_backend = MiniMaxM3SparseBackend
498
# Indexer (top-k selection) and main attention are separate impls, each
499
# picking Triton vs MSA off its cache dtype. impl is AttentionImplBase
@@ -519,6 +524,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
524
score_type=sparse_cfg.get("sparse_score_type", "max"),
525
cache_config=cache_config,
526
indexer_kv_dtype=self.indexer_kv_dtype,
527
+ topk_indices_buffer=topk_indices_buffer,
528
)
529
530
# Register the main K/V cache so the KV-cache manager allocates it.
@@ -576,7 +582,12 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
582
main_slot_mapping = fwd_slot_mapping[self.layer_name]
583
index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
584
q = qkv.new_empty((num_tokens, self.q_size))
579
- index_q = qkv.new_empty((num_tokens, self.index_q_size))
585
+ # index_q matches the index-K cache dtype (e4m3 for the fp8 score path);
586
+ # the fused kernel emits fp8 directly when this buffer is e4m3.
587
+ index_q = qkv.new_empty(
588
+ (num_tokens, self.index_q_size),
589
+ dtype=self.indexer.index_cache.dtype,
590
+ )
591
ops.fused_minimax_m3_qknorm_rope_kv_insert(
592
qkv,
593
self.q_norm.weight,
@@ -613,9 +624,10 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
624
output: torch.Tensor,
625
) -> torch.Tensor:
626
# Single eager break around both: their split-K kernels read per-request
616
- # metadata and can't be captured into a cudagraph.
617
- topk_idx = self.indexer(index_query)
618
- return self.impl.forward(self, query, self.kv_cache, topk_idx, output)
627
+ # metadata and can't be captured into a cudagraph. The indexer writes its
628
+ # top-k into the shared ``topk_indices_buffer``; the attend reads it back.
629
+ self.indexer(index_query)
630
+ return self.impl.forward(self, query, self.kv_cache, output)
631
632
633
class MiniMaxM3DecoderLayer(nn.Module):
@@ -627,6 +639,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
639
force_sparse_attn: bool = False,
640
force_moe: bool = False,
641
is_mtp_block: bool = False,
642
+ topk_indices_buffer: torch.Tensor | None = None,
643
) -> None:
644
super().__init__()
645
if is_mtp_block:
@@ -662,6 +675,7 @@ class MiniMaxM3DecoderLayer(nn.Module):
675
quant_config=quant_config,
676
prefix=f"{prefix}.self_attn",
677
cache_config=cache_config,
678
+ topk_indices_buffer=topk_indices_buffer,
679
)
680
else:
681
self.self_attn = MiniMaxM3Attention(
@@ -747,11 +761,33 @@ class MiniMaxM3Model(nn.Module, EagleModelMixin):
761
prefix=f"{prefix}.embed_tokens",
762
)
763
764
+ # Reserved top-k indices buffer shared by all sparse-attention indexer
765
+ # layers (mirrors DeepseekV4); kept at a stable address so the indexer's
766
+ # top-k output survives cudagraph capture/replay. Shape matches the
767
+ # per-head index top-k output [num_index_heads, total_q, topk].
768
+ sparse_cfg = getattr(config, "sparse_attention_config", None)
769
+ if sparse_cfg is not None:
770
+ tp_size = get_tensor_model_parallel_world_size()
771
+ num_index_heads = max(1, sparse_cfg["sparse_num_index_heads"] // tp_size)
772
+ # Pad tokens to a multiple of 4 so the buffer head stride stays
773
+ # int4-aligned for build_k2q_csr's vectorised int4 loads.
774
+ max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
775
+ padded_num_tokens = (max_num_batched_tokens + 3) // 4 * 4
776
+ self.topk_indices_buffer = torch.empty(
777
+ num_index_heads,
778
+ padded_num_tokens,
779
+ sparse_cfg["sparse_topk_blocks"],
780
+ dtype=torch.int32,
781
+ )
782
+ else:
783
+ self.topk_indices_buffer = None
784
+
785
self.start_layer, self.end_layer, self.layers = make_layers(
786
config.num_hidden_layers,
787
lambda prefix: MiniMaxM3DecoderLayer(
788
vllm_config=vllm_config,
789
prefix=prefix,
790
+ topk_indices_buffer=self.topk_indices_buffer,
791
),
792
prefix=f"{prefix}.layers",
793
)
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+7
-5
@@ -29,7 +29,6 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
29
layer: AttentionLayer,
30
query: torch.Tensor,
31
kv_cache: torch.Tensor,
32
- topk_idx: tuple[torch.Tensor | None, torch.Tensor | None],
32
output: torch.Tensor,
33
) -> torch.Tensor:
34
attn_metadata = get_forward_context().attn_metadata
@@ -37,10 +36,12 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
36
return output # profiling run; caches unbound
37
main_md = attn_metadata[layer.layer_name] # type: ignore[attr-defined]
38
assert isinstance(main_md, MiniMaxM3SparseMetadata)
40
- decode_topk, prefill_topk = topk_idx
39
40
nd = main_md.num_decode_tokens
41
num_tokens = main_md.num_actual_tokens
42
+ # Indexer top-k from the shared buffer: decode [:, :nd], prefill [:, nd:].
43
+ topk = layer.topk_indices_buffer # type: ignore[attr-defined]
44
+ assert topk is not None
45
hd = self.head_size
46
q = query[:num_tokens].view(-1, self.num_heads, hd)
47
out = output[:num_tokens].view(-1, self.num_heads, hd)
@@ -51,11 +52,11 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
52
# Decode [:nd]: Triton split-K placeholder (no MSA decode yet).
53
if main_md.num_decodes > 0:
54
d = main_md.decode
54
- assert d is not None and decode_topk is not None
55
+ assert d is not None
56
minimax_m3_sparse_attn_decode(
57
q[:nd],
58
kv_cache,
58
- decode_topk,
59
+ topk[:, :nd, :],
60
d.block_table,
61
d.seq_lens,
62
self.num_kv_heads,
@@ -72,7 +73,8 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
73
)
74
75
p = main_md.prefill
75
- assert p is not None and prefill_topk is not None
76
+ assert p is not None
77
+ prefill_topk = topk[:, nd:num_tokens, :]
78
qp = q[nd:]
79
k_cache = kv_cache[:, 0].transpose(1, 2)
80
v_cache = kv_cache[:, 1].transpose(1, 2)