[ROCm][MiniMax-M3] Add AITER sparse paged attention (#47287)

Signed-off-by: Tan Pin Siang <tanpinsiang@gmail.com> Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com> Co-authored-by: vllmellm <vllm.ellm@embeddedllm.com> Co-authored-by: Hongxia Yang <hongxia.yang@amd.com> Co-authored-by: Jun Kang Chow <junkangchow@gmail.com> Co-authored-by: tjtanaa <tunjian.tan@embeddedllm.com>

Tan Pin Siang committed Jul 13, 2026 at 10:27 UTC ee5a89f4d7b818af7d92770dbf6aa1b41b503b4a
15 files changed +1553 -127
csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+126 -75
@@ -39,12 +39,10 @@
39 * The IQ/IK warps address the index_q/index_k sub-blocks *inside* qkv at the
40 * fixed physical offsets (nq+2*nkv)*128 and (nq+2*nkv+niq)*128.
41 *
42 - * Dense vs sparse is a compile-time choice via the ``kIsSparse``/``kInsertKV``
43 - * template bools (3 instantiations: dense <false,false>, sparse-profiling
44 - * <true,false>, sparse-serving <true,true>), so the index slots, the V slots
45 - * and the cache inserts fold away entirely on paths that don't use them. The
46 - * dense layer passes no caches/index: norm+RoPE happens in place and the
47 - * generic ``Attention`` layer owns the cache write.
42 + * Dense vs sparse row layout and index-branch processing are separate template
43 + * choices. Skip-index-topk reuse layers still have sparse rows and insert main
44 + * K/V cache entries, but compile away index_q/index_k work and index-cache
45 + * writes.
46 *
47 * Q/K and (sparse) index_q/index_k are all rewritten in place inside the fused
48 * ``qkv`` tensor. Caches (bf16) are scatter-written by slot.
@@ -223,10 +221,25 @@ __device__ __forceinline__ void storeCacheElems(
221 // model dtype directly. FP8 cache dtypes use the conversion path below.
222 storeElems<scalar_t>(reinterpret_cast<scalar_t*>(dst), elems);
223 } else {
226 -#pragma unroll
224 +#ifdef USE_ROCM
225 + // Match ROCm's model-dtype materialization before FP8 cache conversion.
226 + using Converter = vllm::_typeConvert<scalar_t>;
227 + using rounded_t = typename Converter::hip_type;
228 + rounded_t rounded[kElemsPerLane];
229 + #pragma unroll
230 + for (int i = 0; i < kElemsPerLane; i++) {
231 + rounded[i] = Converter::convert(elems[i]);
232 + }
233 + #pragma unroll
234 + for (int i = 0; i < kElemsPerLane; i++) {
235 + dst[i] = fp8::scaled_convert<cache_t, rounded_t, kv_dt>(rounded[i], 1.0f);
236 + }
237 +#else
238 + #pragma unroll
239 for (int i = 0; i < kElemsPerLane; i++) {
240 dst[i] = fp8::scaled_convert<cache_t, float, kv_dt>(elems[i], 1.0f);
241 }
242 +#endif
243 }
244 }
245
@@ -262,20 +275,25 @@ __device__ __forceinline__ void storeElemsFp8(
275 // Grid: 1D, ceil(num_tokens * slots_per_token / warps_per_block).
276 // Each warp = one (token, slot).
277 //
265 -// `kIsSparse` and `kInsertKV` are compile-time template bools, so all the
266 -// branch decisions that distinguish the dense layer from the sparse layer
267 -// (index slots, KV/index inserts, V slots) fold away per instantiation.
268 -// Three instantiations are built: dense <false,false>, sparse-profiling
269 -// <true,false> and sparse-serving <true,true>. Slots per token:
278 +// `kHasIndex`, `kProcessIndex`, and `kInsertKV` are compile-time template
279 +// bools, so branch decisions that distinguish the dense layer from the sparse
280 +// layer (index slots, KV/index inserts, V slots) fold away per instantiation.
281 +// Slots per token:
282 // Q : nq (always — norm+RoPE)
283 // K : nkv (always — norm+RoPE; +K-cache insert)
284 // 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)
285 +// IQ: niq only if kProcessIndex (norm+RoPE)
286 +// IK: 1 only if kProcessIndex (norm+RoPE; +index-cache insert)
287 // cache_t/kv_dt: main attention KV-cache dtype (auto/fp8). out_idx_t/kFp8Idx:
288 // indexer index-K cache + index-Q output dtype (scalar_t or e4m3 byte).
289 +// kHasIndex means the qkv row is laid out as sparse [q|k|v|index_q|index_k].
290 +// kProcessIndex controls whether this launch actually norms/ropes the index
291 +// branch and writes index_q/index_k outputs. Skip-index-topk reuse layers keep
292 +// kHasIndex=true but set kProcessIndex=false.
293 template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt,
278 - typename out_idx_t, bool kIsSparse, bool kInsertKV, bool kFp8Idx>
294 + typename out_idx_t, bool kHasIndex, bool kInsertKV,
295 + bool kProcessIndex,
296 + bool kFp8Idx>
297 __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
298 scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
299 scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
@@ -308,9 +326,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
326 int const laneId = threadIdx.x % 32;
327 int const globalWarpIdx = blockIdx.x * warpsPerBlock + (threadIdx.x / 32);
328
329 + static_assert(!kProcessIndex || kHasIndex,
330 + "index processing requires sparse row layout");
331 +
332 // Slot layout (compile-time gated: dense has neither V nor index slots).
333 int const v_slots = kInsertKV ? nkv : 0;
313 - int const idx_slots = kIsSparse ? niq + 1 : 0;
334 + int const idx_slots = kProcessIndex ? niq + 1 : 0;
335 int const slots_per_token = nq + nkv + v_slots + idx_slots;
336
337 int const tokenIdx = globalWarpIdx / slots_per_token;
@@ -321,14 +342,14 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
342 int const k_begin = nq;
343 int const v_begin = nq + nkv; // valid only when kInsertKV
344 int const iq_begin = nq + nkv + v_slots; // index block start
324 - int const ik_slot = iq_begin + niq; // valid only when kIsSparse
345 + int const ik_slot = iq_begin + niq; // valid only when kProcessIndex
346
347 bool const isQ = slot < k_begin;
348 bool const isK = slot >= k_begin && slot < v_begin;
349 bool isV = false;
350 if constexpr (kInsertKV) isV = slot >= v_begin && slot < v_begin + nkv;
351 bool isIQ = false, isIK = false;
331 - if constexpr (kIsSparse) {
352 + if constexpr (kProcessIndex) {
353 isIQ = slot >= iq_begin && slot < ik_slot;
354 isIK = slot == ik_slot;
355 }
@@ -336,7 +357,7 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
357 int const dim_base = laneId * kElemsPerLane;
358 // Physical row width of qkv: the dense layer packs [q|k|v]; the sparse
359 // layer additionally packs [index_q (niq heads) | index_k (1 head)].
339 - int const qkv_row = (nq + 2 * nkv + (kIsSparse ? (niq + 1) : 0)) * kHeadDim;
360 + int const qkv_row = (nq + 2 * nkv + (kHasIndex ? (niq + 1) : 0)) * kHeadDim;
361
362 // ── Resolve source pointer + per-branch parameters. ────────────────────
363 scalar_t* row_ptr = nullptr; // in-place output location
@@ -367,10 +388,13 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
388 row_ptr = qkv + static_cast<int64_t>(tokenIdx) * qkv_row +
389 (nq + 2 * nkv + ih) * kHeadDim;
390 norm_w = iq_norm_w;
370 - } else { // isIK -- single shared index key at (nq+2*nkv+niq)*128.
391 + } else if (isIK) {
392 + // Single shared index key at (nq+2*nkv+niq)*128.
393 row_ptr = qkv + static_cast<int64_t>(tokenIdx) * qkv_row +
394 (nq + 2 * nkv + niq) * kHeadDim;
395 norm_w = ik_norm_w;
396 + } else {
397 + return;
398 }
399
400 // Store destination. Q and index_q are gathered into dedicated contiguous
@@ -426,9 +450,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
450 // ── Cache inserts (sparse serving only). ───────────────────────────────
451 if constexpr (kInsertKV) {
452 // Guard (not early-return) so every thread reaches the PDL trigger below.
429 - int64_t const sm = (isK || isV)
430 - ? slot_mapping[tokenIdx]
431 - : (isIK ? index_slot_mapping[tokenIdx] : -1);
453 + int64_t sm = -1;
454 + if (isK || isV) {
455 + sm = slot_mapping[tokenIdx];
456 + } else if constexpr (kProcessIndex) {
457 + if (isIK) sm = index_slot_mapping[tokenIdx];
458 + }
459 if (sm >= 0) { // skip padded / unscheduled tokens
460 if (isIK) {
461 if constexpr (kFp8Idx) {
@@ -475,12 +502,12 @@ void launchFusedMiniMaxM3(
502 int const nkv, int const niq, int const block_size,
503 int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
504 int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
478 - bool const fp8_idx, cudaStream_t stream) {
505 + bool const process_index, bool const fp8_idx, cudaStream_t stream) {
506 // Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
507 // void* pointers per instantiation in the LAUNCH macro.
508 // Slot count must match the kernel's compile-time gating.
509 int const v_slots = insert_kv ? nkv : 0;
483 - int const idx_slots = has_index ? niq + 1 : 0;
510 + int const idx_slots = process_index ? niq + 1 : 0;
511 int const slots_per_token = nq + nkv + v_slots + idx_slots;
512
513 constexpr int kBlockSize = 256;
@@ -507,11 +534,12 @@ void launchFusedMiniMaxM3(
534 config.attrs = attrs;
535 config.numAttrs = (sm_version >= 90) ? 1 : 0;
536
510 - #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
537 + #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
538 cudaLaunchKernelEx( \
539 &config, \
540 fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
514 - IS_SPARSE, INSERT, FP8>, \
541 + HAS_INDEX, INSERT, \
542 + 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), \
@@ -520,37 +548,45 @@ void launchFusedMiniMaxM3(
548 #else
549 // ROCm: standard kernel launch syntax (no PDL/stream serialization).
550 // clang-format off
523 - #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
524 - fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
525 - IS_SPARSE, INSERT, FP8> \
526 - <<<grid, kBlockSize, 0, stream>>>( \
527 - qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, \
528 - k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
529 - slot_mapping, index_slot_mapping, kv_cache, \
530 - reinterpret_cast<OUT_T*>(index_cache), eps, rotary_dim, \
531 - num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_head, \
532 - kv_s_token, kv_s_dim)
551 + #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
552 + fusedMiniMaxM3QNormRopeKVInsertKernel< \
553 + scalar_t, cache_t, kv_dt, OUT_T, HAS_INDEX, INSERT, PROCESS_INDEX, \
554 + 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, \
557 + 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)
561 // clang-format on
562 #endif
563
564 if (has_index) {
537 - if (insert_kv) {
565 + if (!process_index) {
566 + if (insert_kv) {
567 + LAUNCH(true, true, false, false, scalar_t);
568 + } else {
569 + LAUNCH(true, false, false, false, scalar_t);
570 + }
571 + } else if (insert_kv) {
572 if (fp8_idx) {
539 - LAUNCH(true, true, true, uint8_t); // sparse serving, fp8 index outputs
573 + LAUNCH(true, true, true, true,
574 + uint8_t); // sparse serving, fp8 index outputs
575 } else {
541 - LAUNCH(true, true, false, scalar_t); // sparse serving, bf16
576 + LAUNCH(true, true, true, false, scalar_t); // sparse serving, bf16
577 }
578 } else {
579 if (fp8_idx) {
545 - LAUNCH(true, false, true, uint8_t); // sparse profiling, fp8 index_q
580 + LAUNCH(true, false, true, true,
581 + uint8_t); // sparse profiling, fp8 index_q
582 } else {
547 - LAUNCH(true, false, false, scalar_t); // sparse profiling, bf16
583 + LAUNCH(true, false, true, false, scalar_t); // sparse profiling, bf16
584 }
585 }
586 } else {
587 // Dense layer: never has an index branch and never inserts here (the
588 // generic Attention layer owns the KV insert).
553 - LAUNCH(false, false, false, scalar_t);
589 + LAUNCH(false, false, false, false, scalar_t);
590 }
591 #undef LAUNCH
592 }
@@ -558,6 +594,7 @@ void launchFusedMiniMaxM3(
594 } // namespace minimax_m3_fused_ops
595 } // namespace vllm
596
597 +// clang-format off
598 #define CALL_FUSED_MINIMAX_M3(_RAW_T, CACHE_T, KV_DTYPE) \
599 vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st, CACHE_T, KV_DTYPE>( \
600 reinterpret_cast<st*>(qkv.data_ptr()), \
@@ -567,24 +604,29 @@ void launchFusedMiniMaxM3(
604 : nullptr, \
605 reinterpret_cast<st const*>(q_norm_weight.data_ptr()), \
606 reinterpret_cast<st const*>(k_norm_weight.data_ptr()), \
570 - has_index ? reinterpret_cast<st const*>(index_q_norm_weight->data_ptr()) \
571 - : nullptr, \
572 - has_index ? reinterpret_cast<st const*>(index_k_norm_weight->data_ptr()) \
573 - : nullptr, \
607 + process_index \
608 + ? reinterpret_cast<st const*>(index_q_norm_weight->data_ptr()) \
609 + : nullptr, \
610 + process_index \
611 + ? reinterpret_cast<st const*>(index_k_norm_weight->data_ptr()) \
612 + : nullptr, \
613 reinterpret_cast<st const*>(cos_sin_cache.data_ptr()), \
614 reinterpret_cast<int64_t const*>(positions.data_ptr()), \
615 insert_kv ? reinterpret_cast<int64_t const*>(slot_mapping->data_ptr()) \
616 : nullptr, \
578 - insert_kv ? reinterpret_cast<int64_t const*>( \
579 - effective_index_slot_mapping->data_ptr()) \
580 - : nullptr, \
617 + (insert_kv && process_index) \
618 + ? reinterpret_cast<int64_t const*>( \
619 + effective_index_slot_mapping->data_ptr()) \
620 + : nullptr, \
621 insert_kv ? reinterpret_cast<CACHE_T*>(kv_cache->data_ptr()) : nullptr, \
582 - (insert_kv && has_index) \
622 + (insert_kv && process_index) \
623 ? reinterpret_cast<void*>(index_cache->data_ptr()) \
624 : 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, \
587 - kv_s_token, kv_s_dim, has_index, insert_kv, fp8_idx, stream)
627 + kv_s_token, kv_s_dim, has_index, insert_kv, process_index, fp8_idx, \
628 + stream)
629 +// clang-format on
630
631 // ────────────────────────────────────────────────────────────────────────────
632 // Torch op wrapper
@@ -607,7 +649,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
649 std::optional<torch::stable::Tensor> q_out, // [N, nq*128] contiguous
650 std::optional<torch::stable::Tensor>
651 index_q_out, // [N, niq*128] contiguous
610 - const std::string& kv_cache_dtype) {
652 + const std::string& kv_cache_dtype, bool skip_index_branch) {
653 STD_TORCH_CHECK(qkv.is_cuda() && qkv.is_contiguous(),
654 "qkv must be contiguous CUDA");
655 STD_TORCH_CHECK(
@@ -646,6 +688,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
688 // (1 head)]) right after [q|k|v] in the same row; the dense layer does not.
689 bool const has_index = niq > 0;
690 bool const insert_kv = kv_cache.has_value();
691 + bool const process_index = has_index && !skip_index_branch;
692 vllm::Fp8KVCacheDataType const kv_dt =
693 vllm::get_fp8_kv_cache_data_type(kv_cache_dtype);
694 int const kHeadDim = vllm::minimax_m3_fused_ops::kHeadDim;
@@ -661,7 +704,9 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
704 STD_TORCH_CHECK(
705 !insert_kv || has_index,
706 "insert mode (kv_cache) requires the index branch (sparse layer)");
664 - if (has_index) {
707 + STD_TORCH_CHECK(has_index || !skip_index_branch,
708 + "skip_index_branch requires sparse qkv rows");
709 + if (process_index) {
710 STD_TORCH_CHECK(
711 index_q_norm_weight.has_value() && index_k_norm_weight.has_value(),
712 "index branch requires both index norm weights");
@@ -683,13 +728,15 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
728 slot_mapping.has_value() && slot_mapping->is_cuda() &&
729 slot_mapping->scalar_type() == torch::headeronly::ScalarType::Long,
730 "insert mode requires int64 CUDA slot_mapping");
686 - STD_TORCH_CHECK(
687 - !index_slot_mapping.has_value() ||
688 - (index_slot_mapping->is_cuda() &&
689 - index_slot_mapping->scalar_type() ==
690 - torch::headeronly::ScalarType::Long &&
691 - index_slot_mapping->numel() == slot_mapping->numel()),
692 - "index_slot_mapping must be int64 CUDA with slot_mapping length");
731 + if (process_index) {
732 + STD_TORCH_CHECK(
733 + !index_slot_mapping.has_value() ||
734 + (index_slot_mapping->is_cuda() &&
735 + index_slot_mapping->scalar_type() ==
736 + torch::headeronly::ScalarType::Long &&
737 + index_slot_mapping->numel() == slot_mapping->numel()),
738 + "index_slot_mapping must be int64 CUDA with slot_mapping length");
739 + }
740 // Main attention KV cache: auto matches qkv, fp8 uses uint8 storage.
741 if (kv_dt == vllm::Fp8KVCacheDataType::kAuto) {
742 STD_TORCH_CHECK(kv_cache->scalar_type() == qkv.scalar_type(),
@@ -700,12 +747,14 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
747 "fp8 kv_cache must use uint8 storage");
748 }
749 // Indexer index-K cache: independent dtype -- qkv dtype or fp8 e4m3.
703 - STD_TORCH_CHECK(
704 - index_cache.has_value() &&
705 - (index_cache->scalar_type() == qkv.scalar_type() ||
706 - index_cache->scalar_type() ==
707 - torch::headeronly::ScalarType::Float8_e4m3fn),
708 - "insert mode requires index_cache matching qkv dtype or fp8 e4m3");
750 + if (process_index) {
751 + STD_TORCH_CHECK(
752 + index_cache.has_value() &&
753 + (index_cache->scalar_type() == qkv.scalar_type() ||
754 + index_cache->scalar_type() ==
755 + torch::headeronly::ScalarType::Float8_e4m3fn),
756 + "insert mode requires index_cache matching qkv dtype or fp8 e4m3");
757 + }
758 STD_TORCH_CHECK(kv_cache->dim() == 4 && kv_cache->stride(3) == 1,
759 "kv_cache must be [nb,nkv,bs,2*head_dim] with contiguous "
760 "content dim (stride(3)==1)");
@@ -713,9 +762,11 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
762 kv_s_head = kv_cache->stride(1);
763 kv_s_token = kv_cache->stride(2);
764 kv_s_dim = kv_cache->stride(3);
716 - effective_index_slot_mapping = index_slot_mapping.has_value()
717 - ? &index_slot_mapping.value()
718 - : &slot_mapping.value();
765 + if (process_index) {
766 + effective_index_slot_mapping = index_slot_mapping.has_value()
767 + ? &index_slot_mapping.value()
768 + : &slot_mapping.value();
769 + }
770 }
771 // Optional contiguous gather targets: when given, the normed/roped q (and
772 // index_q) are written here instead of in place, so callers avoid a separate
@@ -730,9 +781,8 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
781 "q_out must have num_tokens * num_heads * 128 elements");
782 }
783 if (index_q_out.has_value()) {
733 - STD_TORCH_CHECK(
734 - has_index,
735 - "index_q_out requires the index branch (num_index_heads > 0)");
784 + STD_TORCH_CHECK(process_index,
785 + "index_q_out requires index branch processing");
786 STD_TORCH_CHECK(
787 index_q_out->is_cuda() && index_q_out->is_contiguous() &&
788 (index_q_out->scalar_type() == qkv.scalar_type() ||
@@ -749,8 +799,9 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
799 // q/k/v + q_out stay qkv dtype. Both index outputs must agree.
800 auto const kFp8 = torch::headeronly::ScalarType::Float8_e4m3fn;
801 bool const fp8_idx =
752 - (index_cache.has_value() && index_cache->scalar_type() == kFp8) ||
753 - (index_q_out.has_value() && index_q_out->scalar_type() == kFp8);
802 + process_index &&
803 + ((index_cache.has_value() && index_cache->scalar_type() == kFp8) ||
804 + (index_q_out.has_value() && index_q_out->scalar_type() == kFp8));
805 if (fp8_idx) {
806 STD_TORCH_CHECK(
807 !index_cache.has_value() || index_cache->scalar_type() == kFp8,
csrc/libtorch_stable/ops.h
+1 -1
@@ -313,7 +313,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
313 std::optional<torch::stable::Tensor> index_cache, int64_t block_size,
314 std::optional<torch::stable::Tensor> q_out,
315 std::optional<torch::stable::Tensor> index_q_out,
316 - const std::string& kv_cache_dtype);
316 + const std::string& kv_cache_dtype, bool skip_index_branch);
317
318 // Sampler kernels (shared CUDA/ROCm)
319 void apply_repetition_penalties_(
csrc/libtorch_stable/torch_bindings.cpp
+1 -1
@@ -466,7 +466,7 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
466 "Tensor? slot_mapping, Tensor? index_slot_mapping, "
467 "Tensor!? kv_cache, Tensor!? index_cache, "
468 "int block_size, Tensor!? q_out, Tensor!? index_q_out, "
469 - "str kv_cache_dtype) -> ()");
469 + "str kv_cache_dtype, bool skip_index_branch=False) -> ()");
470
471 // Apply repetition penalties to logits in-place.
472 ops.def(
tests/kernels/attention/test_minimax_m3.py
+41
@@ -820,6 +820,47 @@ def test_main_backend_layout_contract():
820 )
821
822
823 +def test_aiter_sparse_pa_layout_contract(monkeypatch):
824 + """The shuffle-only AITER path retains separately contiguous K/V storage."""
825 + import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
826 +
827 + monkeypatch.setattr(sparse_attn_mod.rocm_aiter_ops, "is_enabled", lambda: True)
828 + monkeypatch.setattr(
829 + sparse_attn_mod.rocm_aiter_ops,
830 + "is_shuffle_kv_cache_enabled",
831 + lambda: True,
832 + )
833 +
834 + nb, bs, h, d = 7, BLOCK_SIZE, 1, HEAD_DIM
835 + logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
836 + order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
837 + assert logical == (nb, 2, bs, h, d)
838 + assert order == (1, 0, 2, 3, 4)
839 +
840 + physical_shape = tuple(logical[i] for i in order)
841 + inv_order = [order.index(i) for i in range(len(order))]
842 + raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
843 + logical_view = raw.permute(*inv_order)
844 + key_cache, value_cache = logical_view.unbind(1)
845 + assert key_cache.is_contiguous()
846 + assert value_cache.is_contiguous()
847 +
848 +
849 +def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
850 + """Do not pair AITER's separated cache layout with the Triton fallback."""
851 + import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
852 +
853 + monkeypatch.setattr(sparse_attn_mod.rocm_aiter_ops, "is_enabled", lambda: True)
854 + monkeypatch.setattr(
855 + sparse_attn_mod.rocm_aiter_ops,
856 + "is_shuffle_kv_cache_enabled",
857 + lambda: True,
858 + )
859 +
860 + with pytest.raises(ValueError, match="num_kv_heads == 1"):
861 + MiniMaxM3SparseBackend.get_kv_cache_shape(7, BLOCK_SIZE, 2, HEAD_DIM)
862 +
863 +
864 def test_main_backend_unknown_layout_raises(monkeypatch):
865 """An unrecognized layout (injected past env-var validation) is rejected."""
866 import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
+130 -13
@@ -171,10 +171,9 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
171 kv_cache_storage_dtype = torch.uint8 if kv_cache_dtype == "fp8" else dtype
172 kv_cache = torch.zeros(
173 num_blocks,
174 - 2,
175 - block_size,
174 num_kv_heads,
177 - HEAD_DIM,
175 + block_size,
176 + 2 * HEAD_DIM,
177 dtype=kv_cache_storage_dtype,
178 device=device,
179 )
@@ -246,18 +245,21 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
245 torch.testing.assert_close(index_k, ik_ref, rtol=1e-2, atol=1e-2)
246
247 # ── Cache inserts. ──
249 - # Main cache layout is [num_blocks, 2, block_size, num_kv_heads, head_dim]
250 - # (the K/V axis sits *before* block_size); index cache is [nb, bs, head_dim].
248 + # Main cache layout is [num_blocks, num_kv_heads, block_size, 2*head_dim];
249 + # index cache is [num_blocks, block_size, head_dim].
250 k_ref_h = k_ref.view(num_tokens, num_kv_heads, HEAD_DIM)
251 v_ref_h = v_in.view(num_tokens, num_kv_heads, HEAD_DIM) # v is raw (no norm/rope)
252 if kv_cache_dtype == "fp8":
253 expected_kv_cache = torch.zeros_like(kv_cache)
254 + expected_k_cache, expected_v_cache = expected_kv_cache.transpose(1, 2).split(
255 + HEAD_DIM, dim=-1
256 + )
257 scale = torch.ones((), device=device)
258 ops.reshape_and_cache_flash(
259 k_out.view(num_tokens, num_kv_heads, HEAD_DIM),
260 v_out.view(num_tokens, num_kv_heads, HEAD_DIM),
259 - expected_kv_cache[:, 0],
260 - expected_kv_cache[:, 1],
261 + expected_k_cache,
262 + expected_v_cache,
263 slot_mapping,
264 kv_cache_dtype,
265 scale,
@@ -269,9 +271,11 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
271 s = slot_mapping[t].item()
272 b, pos = s // block_size, s % block_size
273 torch.testing.assert_close(
272 - kv_cache[b, 0, pos], k_ref_h[t], rtol=1e-2, atol=1e-2
274 + kv_cache[b, :, pos, :HEAD_DIM], k_ref_h[t], rtol=1e-2, atol=1e-2
275 + )
276 + torch.testing.assert_close(
277 + kv_cache[b, :, pos, HEAD_DIM:], v_ref_h[t], rtol=0, atol=0
278 )
274 - torch.testing.assert_close(kv_cache[b, 1, pos], v_ref_h[t], rtol=0, atol=0)
279
280 expected_index_cache = torch.zeros_like(index_cache).view(-1, HEAD_DIM)
281 expected_index_cache[index_slot_mapping] = index_k
@@ -280,7 +284,121 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
284 )
285
286
283 -# ── Test 3: fp8 (e4m3) index outputs ─────────────────────────────────────────
287 +@pytest.mark.parametrize("num_tokens", [1, 64, 513])
288 +@pytest.mark.parametrize("block_size", [16, 64])
289 +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
290 +def test_sparse_skip_index_branch(num_tokens, block_size, kv_cache_dtype):
291 + torch.manual_seed(2)
292 + device, dtype, eps = "cuda", torch.bfloat16, 1e-6
293 + base, max_pos = 5_000_000.0, 4096
294 + num_heads, num_kv_heads, num_idx_heads = 16, 4, 4
295 +
296 + q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
297 + k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1
298 + cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device)
299 + positions = torch.randint(
300 + 0, max_pos, (num_tokens,), dtype=torch.int64, device=device
301 + )
302 +
303 + qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM
304 + iqsz, iksz = num_idx_heads * HEAD_DIM, HEAD_DIM
305 + qkv = torch.randn(
306 + num_tokens, qsz + 2 * kvsz + iqsz + iksz, dtype=dtype, device=device
307 + )
308 + qkv_orig = qkv.clone()
309 + splits = [qsz, kvsz, kvsz, iqsz, iksz]
310 +
311 + num_blocks = (num_tokens + block_size - 1) // block_size + 1
312 + kv_cache_storage_dtype = torch.uint8 if kv_cache_dtype == "fp8" else dtype
313 + kv_cache = torch.zeros(
314 + num_blocks,
315 + num_kv_heads,
316 + block_size,
317 + 2 * HEAD_DIM,
318 + dtype=kv_cache_storage_dtype,
319 + device=device,
320 + )
321 + index_cache = torch.randn(
322 + num_blocks, block_size, HEAD_DIM, dtype=dtype, device=device
323 + )
324 + index_cache_orig = index_cache.clone()
325 + slot_mapping = torch.randperm(
326 + num_blocks * block_size, dtype=torch.int64, device=device
327 + )[:num_tokens]
328 + q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device)
329 +
330 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
331 + qkv,
332 + q_w,
333 + k_w,
334 + cos_sin,
335 + positions,
336 + num_heads,
337 + num_kv_heads,
338 + ROTARY_DIM,
339 + eps,
340 + num_index_heads=num_idx_heads,
341 + slot_mapping=slot_mapping,
342 + kv_cache=kv_cache,
343 + index_cache=index_cache,
344 + block_size=block_size,
345 + q_out=q_out,
346 + kv_cache_dtype=kv_cache_dtype,
347 + skip_index_branch=True,
348 + )
349 +
350 + _, k_out, v_out, index_q_out, index_k_out = qkv.split(splits, dim=-1)
351 + q_in, k_in, v_in, index_q_in, index_k_in = qkv_orig.split(splits, dim=-1)
352 + q_ref = norm_rope_ref(
353 + q_in.view(num_tokens, num_heads, HEAD_DIM), q_w, positions, cos_sin, eps
354 + ).view(num_tokens, qsz)
355 + k_ref = norm_rope_ref(
356 + k_in.view(num_tokens, num_kv_heads, HEAD_DIM),
357 + k_w,
358 + positions,
359 + cos_sin,
360 + eps,
361 + ).view(num_tokens, kvsz)
362 +
363 + torch.testing.assert_close(q_out, q_ref, rtol=1e-2, atol=1e-2)
364 + torch.testing.assert_close(k_out, k_ref, rtol=1e-2, atol=1e-2)
365 + torch.testing.assert_close(v_out, v_in, rtol=0, atol=0)
366 + torch.testing.assert_close(index_q_out, index_q_in, rtol=0, atol=0)
367 + torch.testing.assert_close(index_k_out, index_k_in, rtol=0, atol=0)
368 + torch.testing.assert_close(index_cache, index_cache_orig, rtol=0, atol=0)
369 +
370 + if kv_cache_dtype == "fp8":
371 + expected_kv_cache = torch.zeros_like(kv_cache)
372 + expected_k_cache, expected_v_cache = expected_kv_cache.transpose(1, 2).split(
373 + HEAD_DIM, dim=-1
374 + )
375 + scale = torch.ones((), device=device)
376 + ops.reshape_and_cache_flash(
377 + k_out.view(num_tokens, num_kv_heads, HEAD_DIM),
378 + v_out.view(num_tokens, num_kv_heads, HEAD_DIM),
379 + expected_k_cache,
380 + expected_v_cache,
381 + slot_mapping,
382 + kv_cache_dtype,
383 + scale,
384 + scale,
385 + )
386 + torch.testing.assert_close(kv_cache, expected_kv_cache, rtol=0, atol=0)
387 + else:
388 + k_ref_h = k_ref.view(num_tokens, num_kv_heads, HEAD_DIM)
389 + v_ref_h = v_in.view(num_tokens, num_kv_heads, HEAD_DIM)
390 + for t in range(num_tokens):
391 + s = slot_mapping[t].item()
392 + b, pos = s // block_size, s % block_size
393 + torch.testing.assert_close(
394 + kv_cache[b, :, pos, :HEAD_DIM], k_ref_h[t], rtol=1e-2, atol=1e-2
395 + )
396 + torch.testing.assert_close(
397 + kv_cache[b, :, pos, HEAD_DIM:], v_ref_h[t], rtol=0, atol=0
398 + )
399 +
400 +
401 +# ── Test 4: fp8 (e4m3) index outputs ─────────────────────────────────────────
402 # The fp8 score path stores index_q and the index-K cache as e4m3 while q/k/v +
403 # q_out stay bf16. Asserts: (1) q/k/v/q_out are bit-identical to the bf16 run
404 # (the index dtype must not perturb the main branch), and (2) the e4m3 index
@@ -324,10 +442,9 @@ def test_sparse_full_fp8_index(num_tokens, block_size):
442 qkv = qkv0.clone()
443 kv_cache = torch.zeros(
444 num_blocks,
327 - 2,
328 - block_size,
445 num_kv_heads,
330 - HEAD_DIM,
446 + block_size,
447 + 2 * HEAD_DIM,
448 dtype=dtype,
449 device=device,
450 )
tests/kernels/test_minimax_m3_sparse_attn_fp8_scale.py new
+189
@@ -0,0 +1,189 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +import torch
6 +
7 +from vllm.platforms import current_platform
8 +
9 +if not torch.cuda.is_available() or not current_platform.supports_fp8():
10 + pytest.skip(
11 + "MiniMax-M3 FP8 sparse attention scale tests require an FP8 GPU.",
12 + allow_module_level=True,
13 + )
14 +
15 +if current_platform.is_rocm():
16 + from vllm.models.minimax_m3.amd.ops.sparse_attn import (
17 + SPARSE_BLOCK_SIZE,
18 + minimax_m3_sparse_attn,
19 + minimax_m3_sparse_attn_decode,
20 + )
21 +else:
22 + from vllm.models.minimax_m3.common.ops.sparse_attn import (
23 + SPARSE_BLOCK_SIZE,
24 + minimax_m3_sparse_attn,
25 + minimax_m3_sparse_attn_decode,
26 + )
27 +
28 +
29 +DEVICE = "cuda"
30 +DTYPE = torch.bfloat16
31 +HEAD_DIM = 128
32 +NUM_KV_HEADS = 1
33 +NUM_HEADS = 2
34 +K_SCALE = 0.25
35 +V_SCALE = 0.5
36 +
37 +
38 +def _scale_tensors(mode: str, num_blocks: int):
39 + if mode == "scalar":
40 + k_scale = torch.tensor(K_SCALE, dtype=torch.float32, device=DEVICE)
41 + v_scale = torch.tensor(V_SCALE, dtype=torch.float32, device=DEVICE)
42 + else:
43 + shape = (NUM_KV_HEADS, num_blocks * SPARSE_BLOCK_SIZE)
44 + k_scale = torch.full(shape, K_SCALE, dtype=torch.float32, device=DEVICE)
45 + v_scale = torch.full(shape, V_SCALE, dtype=torch.float32, device=DEVICE)
46 + return k_scale, v_scale
47 +
48 +
49 +def _make_kv_cache(num_blocks: int, seed: int):
50 + torch.manual_seed(seed)
51 + fp8_dtype = current_platform.fp8_dtype()
52 + kv_ref = torch.randn(
53 + num_blocks,
54 + NUM_KV_HEADS,
55 + SPARSE_BLOCK_SIZE,
56 + 2 * HEAD_DIM,
57 + dtype=DTYPE,
58 + device=DEVICE,
59 + )
60 + kv_fp8 = torch.empty_like(kv_ref, dtype=fp8_dtype)
61 + kv_fp8[..., :HEAD_DIM] = (kv_ref[..., :HEAD_DIM].float() / K_SCALE).to(fp8_dtype)
62 + kv_fp8[..., HEAD_DIM:] = (kv_ref[..., HEAD_DIM:].float() / V_SCALE).to(fp8_dtype)
63 +
64 + kv_dequant = torch.empty_like(kv_ref)
65 + kv_dequant[..., :HEAD_DIM] = (kv_fp8[..., :HEAD_DIM].float() * K_SCALE).to(DTYPE)
66 + kv_dequant[..., HEAD_DIM:] = (kv_fp8[..., HEAD_DIM:].float() * V_SCALE).to(DTYPE)
67 + return kv_fp8, kv_dequant
68 +
69 +
70 +@pytest.mark.parametrize("scale_mode", ["scalar", "per_token_head"])
71 +@torch.inference_mode()
72 +def test_minimax_m3_sparse_prefill_fp8_kv_scales(scale_mode: str):
73 + total_q = 17
74 + num_blocks = 1
75 + torch.manual_seed(0)
76 + q = torch.randn(total_q, NUM_HEADS, HEAD_DIM, dtype=DTYPE, device=DEVICE) * 0.1
77 + kv_fp8, kv_dequant = _make_kv_cache(num_blocks, seed=1)
78 + k_scale, v_scale = _scale_tensors(scale_mode, num_blocks)
79 +
80 + topk = torch.zeros(NUM_KV_HEADS, total_q, 1, dtype=torch.int32, device=DEVICE)
81 + block_table = torch.zeros(1, 1, dtype=torch.int32, device=DEVICE)
82 + cu_seqlens = torch.tensor([0, total_q], dtype=torch.int32, device=DEVICE)
83 + seq_lens = torch.tensor([total_q], dtype=torch.int32, device=DEVICE)
84 + prefix_lens = torch.zeros(1, dtype=torch.int32, device=DEVICE)
85 + got = torch.empty_like(q)
86 + ref = torch.empty_like(q)
87 + unscaled = torch.empty_like(q)
88 +
89 + minimax_m3_sparse_attn(
90 + q,
91 + kv_fp8,
92 + topk,
93 + block_table,
94 + cu_seqlens,
95 + seq_lens,
96 + prefix_lens,
97 + total_q,
98 + NUM_KV_HEADS,
99 + HEAD_DIM**-0.5,
100 + got,
101 + k_scale=k_scale,
102 + v_scale=v_scale,
103 + )
104 + minimax_m3_sparse_attn(
105 + q,
106 + kv_dequant,
107 + topk,
108 + block_table,
109 + cu_seqlens,
110 + seq_lens,
111 + prefix_lens,
112 + total_q,
113 + NUM_KV_HEADS,
114 + HEAD_DIM**-0.5,
115 + ref,
116 + )
117 + minimax_m3_sparse_attn(
118 + q,
119 + kv_fp8,
120 + topk,
121 + block_table,
122 + cu_seqlens,
123 + seq_lens,
124 + prefix_lens,
125 + total_q,
126 + NUM_KV_HEADS,
127 + HEAD_DIM**-0.5,
128 + unscaled,
129 + )
130 +
131 + torch.testing.assert_close(got, ref, rtol=2e-2, atol=2e-2)
132 + assert not torch.allclose(unscaled, ref, rtol=1e-1, atol=1e-1)
133 +
134 +
135 +@pytest.mark.parametrize("scale_mode", ["scalar", "per_token_head"])
136 +@torch.inference_mode()
137 +def test_minimax_m3_sparse_decode_fp8_kv_scales(scale_mode: str):
138 + total_q = 2
139 + num_blocks = 1
140 + torch.manual_seed(2)
141 + q = torch.randn(total_q, NUM_HEADS, HEAD_DIM, dtype=DTYPE, device=DEVICE) * 0.1
142 + kv_fp8, kv_dequant = _make_kv_cache(num_blocks, seed=3)
143 + k_scale, v_scale = _scale_tensors(scale_mode, num_blocks)
144 +
145 + topk = torch.zeros(NUM_KV_HEADS, total_q, 1, dtype=torch.int32, device=DEVICE)
146 + block_table = torch.zeros(total_q, 1, dtype=torch.int32, device=DEVICE)
147 + seq_lens = torch.tensor([64, 128], dtype=torch.int32, device=DEVICE)
148 + got = torch.empty_like(q)
149 + ref = torch.empty_like(q)
150 + unscaled = torch.empty_like(q)
151 +
152 + minimax_m3_sparse_attn_decode(
153 + q,
154 + kv_fp8,
155 + topk,
156 + block_table,
157 + seq_lens,
158 + NUM_KV_HEADS,
159 + HEAD_DIM**-0.5,
160 + got,
161 + decode_query_len=1,
162 + k_scale=k_scale,
163 + v_scale=v_scale,
164 + )
165 + minimax_m3_sparse_attn_decode(
166 + q,
167 + kv_dequant,
168 + topk,
169 + block_table,
170 + seq_lens,
171 + NUM_KV_HEADS,
172 + HEAD_DIM**-0.5,
173 + ref,
174 + decode_query_len=1,
175 + )
176 + minimax_m3_sparse_attn_decode(
177 + q,
178 + kv_fp8,
179 + topk,
180 + block_table,
181 + seq_lens,
182 + NUM_KV_HEADS,
183 + HEAD_DIM**-0.5,
184 + unscaled,
185 + decode_query_len=1,
186 + )
187 +
188 + torch.testing.assert_close(got, ref, rtol=2e-2, atol=2e-2)
189 + assert not torch.allclose(unscaled, ref, rtol=1e-1, atol=1e-1)
vllm/_custom_ops.py
+7
@@ -2516,6 +2516,7 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2516 q_out: torch.Tensor | None = None,
2517 index_q_out: torch.Tensor | None = None,
2518 kv_cache_dtype: str = "auto",
2519 + skip_index_branch: bool = False,
2520 ) -> None:
2521 """Fused MiniMax-M3 attention pre-processing (in-place).
2522
@@ -2537,6 +2538,11 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2538 instead of in place — folding the de-interleave into this kernel's store so
2539 callers skip a separate ``.contiguous()`` copy before the SM100 sparse
2540 attention's flat TMA descriptor.
2541 +
2542 + When ``skip_index_branch`` is true, sparse rows still keep their packed
2543 + ``[index_q | index_k]`` tail, but the kernel only processes the main q/k/v
2544 + branches and main KV cache. This is used by MiniMax-M3 index-topk reuse
2545 + layers that consume top-k block ids selected by an earlier sparse layer.
2546 """
2547 torch.ops._C.fused_minimax_m3_qknorm_rope_kv_insert(
2548 qkv,
@@ -2559,6 +2565,7 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2565 q_out,
2566 index_q_out,
2567 kv_cache_dtype,
2568 + skip_index_branch,
2569 )
2570
2571
vllm/models/minimax_m3/amd/model.py
+214 -25
@@ -35,6 +35,7 @@ from vllm.config import (
35 from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size
36 from vllm.forward_context import get_forward_context
37 from vllm.model_executor.layers.attention import Attention
38 +from vllm.model_executor.layers.attention.attention import set_default_quant_scales
39 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
40 from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
41 fused_allreduce_gemma_rms_norm,
@@ -93,6 +94,7 @@ from vllm.models.minimax_m3.common.mm_preprocess import (
94 from vllm.models.minimax_m3.common.sparse_attention import (
95 MiniMaxM3SparseBackend,
96 MiniMaxM3SparseImpl,
97 + minimax_m3_use_aiter_sparse_pa,
98 select_main_impl_cls,
99 )
100 from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
@@ -103,6 +105,7 @@ from vllm.v1.kv_cache_interface import (
105 FullAttentionSpec,
106 KVCacheSpec,
107 get_kv_quant_mode,
108 + is_quantized_kv_cache,
109 )
110
111
@@ -636,6 +639,11 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
639 self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
640 self.kv_cache_dtype, vllm_config.model_config
641 )
642 + # MiniMax-M3 sparse attention owns its KV-cache insert/read path instead
643 + # of wrapping the generic Attention module. Keep the same runtime scale
644 + # attributes so FP8 KV reads can honor vLLM's per-layer descale contract.
645 + self.calculate_kv_scales = False
646 + set_default_quant_scales(self, register_buffer=True)
647
648 # Shared top-k buffer: the indexer writes the selected blocks into it and
649 # the attend impl reads them back (no Python value crosses the break).
@@ -647,6 +655,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
655 self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
656 topk_blocks=sparse_cfg["sparse_topk_blocks"],
657 kv_cache_dtype=self.kv_cache_dtype,
658 + num_kv_heads=self.num_kv_heads,
659 )(
660 self.num_heads,
661 self.head_dim,
@@ -656,6 +665,10 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
665 topk_blocks=sparse_cfg["sparse_topk_blocks"],
666 sparse_block_size=sparse_cfg["sparse_block_size"],
667 )
668 + self.use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(self.num_kv_heads)
669 + self.kv_cache_k = torch.tensor([])
670 + self.kv_cache_v = torch.tensor([])
671 + self._aiter_sparse_pa_cache_data_ptr = 0
672 # Self-contained nn.Module: owns its side cache, selects its impl in init
673 # (Triton on ROCm, where the SM100 gate is always False).
674 self.indexer = MiniMaxM3Indexer(
@@ -694,6 +707,91 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
707 kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
708 )
709
710 + def _ensure_aiter_sparse_pa_kv_cache(self) -> None:
711 + if self.kv_cache.numel() == 0:
712 + return
713 + if self._aiter_sparse_pa_cache_data_ptr == self.kv_cache.data_ptr():
714 + return
715 +
716 + kv_cache = self.kv_cache
717 + if is_quantized_kv_cache(self.kv_cache_dtype):
718 + kv_cache = kv_cache.view(self.impl.kv_cache_fp8_dtype)
719 + key_cache, value_cache = kv_cache.unbind(1)
720 + if not key_cache.is_contiguous() or not value_cache.is_contiguous():
721 + raise RuntimeError(
722 + "MiniMax-M3 AITER sparse PA requires K/V-separated KV cache "
723 + "storage. Set VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT=1 before "
724 + "initializing the engine."
725 + )
726 +
727 + x = 16 // key_cache.element_size()
728 + if self.head_dim % x != 0:
729 + raise RuntimeError(
730 + "MiniMax-M3 AITER sparse PA requires head_dim divisible by "
731 + f"16 / dtype_size, got head_dim={self.head_dim}, x={x}"
732 + )
733 + num_blocks = key_cache.shape[0]
734 + num_phys16 = num_blocks * 8
735 + self.kv_cache_k = key_cache.view(
736 + num_phys16,
737 + self.num_kv_heads,
738 + self.head_dim // x,
739 + 16,
740 + x,
741 + )
742 + self.kv_cache_v = value_cache.view(
743 + num_phys16,
744 + self.num_kv_heads,
745 + 16 // x,
746 + self.head_dim,
747 + x,
748 + )
749 + self._aiter_sparse_pa_cache_data_ptr = self.kv_cache.data_ptr()
750 +
751 + def get_aiter_sparse_pa_kv_cache(self) -> tuple[torch.Tensor, torch.Tensor]:
752 + self._ensure_aiter_sparse_pa_kv_cache()
753 + return self.kv_cache_k, self.kv_cache_v
754 +
755 + def _insert_aiter_sparse_pa_kv(
756 + self,
757 + k: torch.Tensor,
758 + v: torch.Tensor,
759 + index_k: torch.Tensor | None,
760 + slot_mapping: torch.Tensor,
761 + index_slot_mapping: torch.Tensor | None,
762 + ) -> None:
763 + if self.kv_cache.numel() == 0:
764 + return
765 + from aiter import reshape_and_cache
766 +
767 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
768 + minimax_m3_insert_index_cache,
769 + )
770 +
771 + key_cache, value_cache = self.get_aiter_sparse_pa_kv_cache()
772 + kv_cache_dtype = (
773 + self.kv_cache_dtype
774 + if is_quantized_kv_cache(self.kv_cache_dtype)
775 + else "auto"
776 + )
777 + reshape_and_cache(
778 + k.contiguous(),
779 + v.contiguous(),
780 + key_cache,
781 + value_cache,
782 + slot_mapping,
783 + kv_cache_dtype=kv_cache_dtype,
784 + k_scale=getattr(self, "_k_scale", None),
785 + v_scale=getattr(self, "_v_scale", None),
786 + asm_layout=True,
787 + )
788 + if index_k is None or index_slot_mapping is None:
789 + return
790 + index_cache = self.indexer.index_cache.kv_cache
791 + if index_cache.numel() == 0:
792 + return
793 + minimax_m3_insert_index_cache(index_k, index_cache, index_slot_mapping)
794 +
795 def forward(
796 self,
797 positions: torch.Tensor,
@@ -724,31 +822,121 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
822 return qkv.new_zeros((num_tokens, self.hidden_size))
823
824 main_slot_mapping = fwd_slot_mapping[self.layer_name]
727 - index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
825 q = qkv.new_empty((num_tokens, self.q_size))
729 - index_q = qkv.new_empty((num_tokens, self.index_q_size))
730 - ops.fused_minimax_m3_qknorm_rope_kv_insert(
731 - qkv,
732 - self.q_norm.weight,
733 - self.k_norm.weight,
734 - cos_sin_cache,
735 - positions,
736 - self.num_heads,
737 - self.num_kv_heads,
738 - rotary_dim,
739 - eps,
740 - self.index_q_norm.weight,
741 - self.index_k_norm.weight,
742 - self.num_idx_heads,
743 - main_slot_mapping,
744 - index_slot_mapping,
745 - self.kv_cache,
746 - self.indexer.index_cache.kv_cache,
747 - self.kv_cache.size(2), # paged-cache block size
748 - q,
749 - index_q,
750 - self.kv_cache_dtype,
751 - )
826 + if self.skip_index_topk:
827 + index_q = None
828 + if self.use_aiter_sparse_pa:
829 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
830 + qkv,
831 + self.q_norm.weight,
832 + self.k_norm.weight,
833 + cos_sin_cache,
834 + positions,
835 + self.num_heads,
836 + self.num_kv_heads,
837 + rotary_dim,
838 + eps,
839 + num_index_heads=self.num_idx_heads,
840 + q_out=q,
841 + skip_index_branch=True,
842 + )
843 + k_start = self.q_size
844 + v_start = k_start + self.kv_size
845 + k = qkv[:, k_start:v_start].view(
846 + num_tokens, self.num_kv_heads, self.head_dim
847 + )
848 + v = qkv[:, v_start : v_start + self.kv_size].view(
849 + num_tokens, self.num_kv_heads, self.head_dim
850 + )
851 + self._insert_aiter_sparse_pa_kv(
852 + k,
853 + v,
854 + None,
855 + main_slot_mapping,
856 + None,
857 + )
858 + else:
859 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
860 + qkv,
861 + self.q_norm.weight,
862 + self.k_norm.weight,
863 + cos_sin_cache,
864 + positions,
865 + self.num_heads,
866 + self.num_kv_heads,
867 + rotary_dim,
868 + eps,
869 + num_index_heads=self.num_idx_heads,
870 + slot_mapping=main_slot_mapping,
871 + kv_cache=self.kv_cache,
872 + block_size=self.kv_cache.size(2), # paged-cache block size
873 + q_out=q,
874 + kv_cache_dtype=self.kv_cache_dtype,
875 + skip_index_branch=True,
876 + )
877 + else:
878 + index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
879 + index_q = qkv.new_empty((num_tokens, self.index_q_size))
880 + if self.use_aiter_sparse_pa:
881 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
882 + qkv,
883 + self.q_norm.weight,
884 + self.k_norm.weight,
885 + cos_sin_cache,
886 + positions,
887 + self.num_heads,
888 + self.num_kv_heads,
889 + rotary_dim,
890 + eps,
891 + self.index_q_norm.weight,
892 + self.index_k_norm.weight,
893 + self.num_idx_heads,
894 + q_out=q,
895 + index_q_out=index_q,
896 + kv_cache_dtype=self.kv_cache_dtype,
897 + )
898 + k_start = self.q_size
899 + v_start = k_start + self.kv_size
900 + index_k_start = v_start + self.kv_size + self.index_q_size
901 + k = qkv[:, k_start:v_start].view(
902 + num_tokens, self.num_kv_heads, self.head_dim
903 + )
904 + v = qkv[:, v_start : v_start + self.kv_size].view(
905 + num_tokens, self.num_kv_heads, self.head_dim
906 + )
907 + index_k = qkv[
908 + :, index_k_start : index_k_start + self.idx_head_dim
909 + ].view(num_tokens, self.idx_head_dim)
910 + self._insert_aiter_sparse_pa_kv(
911 + k,
912 + v,
913 + index_k,
914 + main_slot_mapping,
915 + index_slot_mapping,
916 + )
917 + else:
918 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
919 + qkv,
920 + self.q_norm.weight,
921 + self.k_norm.weight,
922 + cos_sin_cache,
923 + positions,
924 + self.num_heads,
925 + self.num_kv_heads,
926 + rotary_dim,
927 + eps,
928 + self.index_q_norm.weight,
929 + self.index_k_norm.weight,
930 + self.num_idx_heads,
931 + main_slot_mapping,
932 + index_slot_mapping,
933 + self.kv_cache,
934 + self.indexer.index_cache.kv_cache,
935 + self.kv_cache.size(2), # paged-cache block size
936 + q,
937 + index_q,
938 + self.kv_cache_dtype,
939 + )
940
941 output = torch.empty_like(q)
942 attn_output = self._run_attention(q, index_q, output)
@@ -759,7 +947,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
947 def _run_attention(
948 self,
949 query: torch.Tensor,
762 - index_query: torch.Tensor,
950 + index_query: torch.Tensor | None,
951 output: torch.Tensor,
952 ) -> torch.Tensor:
953 # Single eager break around both: their split-K kernels read per-request
@@ -768,6 +956,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
956 # When skip_index_topk is set (ATOM index_topk_freq), reuse the selection
957 # the preceding compute layer wrote into the shared buffer this forward.
958 if not self.skip_index_topk:
959 + assert index_query is not None
960 self.indexer(index_query)
961 return self.impl.forward(self, query, self.kv_cache, output)
962
vllm/models/minimax_m3/amd/ops/sparse_attn.py
+65 -8
@@ -12,7 +12,9 @@ import torch
12
13 from vllm.models.minimax_m3.common.ops.sparse_attn import (
14 _FP8_DTYPES,
15 + _KV_SCALE_NONE,
16 SPARSE_BLOCK_SIZE,
17 + _kv_scale_args,
18 minimax_m3_sparse_attn_decode,
19 )
20 from vllm.platforms.rocm import on_gfx950, on_mi3xx
@@ -70,7 +72,9 @@ def _sparse_attn_prefill_kwargs() -> dict:
72 @triton.jit(do_not_specialize_on_alignment=["seq_lens", "prefix_lens"])
73 def _gqa_sparse_fwd_kernel(
74 q_ptr, # [total_q, num_heads, head_dim]
73 - kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
75 + kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
76 + k_scale_ptr,
77 + v_scale_ptr,
78 t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
79 o_ptr, # [total_q, num_heads, head_dim]
80 block_table_ptr, # [num_reqs, max_blocks]
@@ -88,10 +92,13 @@ def _gqa_sparse_fwd_kernel(
92 stride_qh,
93 stride_qd,
94 stride_kv_blk,
91 - stride_kv_kv,
92 - stride_kv_pos,
95 stride_kv_h,
96 + stride_kv_pos,
97 stride_kv_d,
98 + stride_ks_h,
99 + stride_ks_t,
100 + stride_vs_h,
101 + stride_vs_t,
102 stride_th,
103 stride_tn,
104 stride_tk,
@@ -106,6 +113,7 @@ def _gqa_sparse_fwd_kernel(
113 BLOCK_SIZE_T: tl.constexpr,
114 BLOCK_SIZE_QH: tl.constexpr,
115 USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
116 + KV_SCALE_MODE: tl.constexpr, # 0: none, 1: scalar, 2: [kv_head, token]
117 SUB_K: tl.constexpr, # CDNA only: KV sub-tile width (see _IS_MI3XX)
118 ):
119 sm_scale_log2e = sm_scale * 1.4426950409
@@ -161,7 +169,6 @@ def _gqa_sparse_fwd_kernel(
169 pos_mask_sub = pos_sub < seq_len
170 k_sub = tl.load(
171 kv_base
164 - + 0 * stride_kv_kv
172 + off_sub[None, :] * stride_kv_pos
173 + off_d[:, None] * stride_kv_d,
174 mask=d_mask[:, None] & pos_mask_sub[None, :],
@@ -169,6 +176,17 @@ def _gqa_sparse_fwd_kernel(
176 )
177 if USE_FP8:
178 k_sub = k_sub.to(q.dtype)
179 + if KV_SCALE_MODE == 1:
180 + k_sub = (k_sub * tl.load(k_scale_ptr)).to(q.dtype)
181 + elif KV_SCALE_MODE == 2:
182 + k_scale = tl.load(
183 + k_scale_ptr
184 + + pid_kh * stride_ks_h
185 + + (page * BLOCK_SIZE_K + off_sub) * stride_ks_t,
186 + mask=pos_mask_sub,
187 + other=1.0,
188 + )
189 + k_sub = (k_sub * k_scale[None, :]).to(q.dtype)
190 off_q_sub = (
191 tl.arange(0, BLOCK_SIZE_Q)[:, None]
192 + pid_q_j * BLOCK_SIZE_Q
@@ -187,14 +205,24 @@ def _gqa_sparse_fwd_kernel(
205 acc_o = acc_o * tl.exp2(m_i - m_ij)[:, None]
206 v_sub = tl.load(
207 kv_base
190 - + 1 * stride_kv_kv
208 + off_sub[:, None] * stride_kv_pos
192 - + off_d[None, :] * stride_kv_d,
209 + + (head_dim + off_d[None, :]) * stride_kv_d,
210 mask=pos_mask_sub[:, None] & d_mask[None, :],
211 other=0.0,
212 )
213 if USE_FP8:
214 v_sub = v_sub.to(q.dtype)
215 + if KV_SCALE_MODE == 1:
216 + v_sub = (v_sub * tl.load(v_scale_ptr)).to(q.dtype)
217 + elif KV_SCALE_MODE == 2:
218 + v_scale = tl.load(
219 + v_scale_ptr
220 + + pid_kh * stride_vs_h
221 + + (page * BLOCK_SIZE_K + off_sub) * stride_vs_t,
222 + mask=pos_mask_sub,
223 + other=1.0,
224 + )
225 + v_sub = (v_sub * v_scale[:, None]).to(q.dtype)
226 acc_o += tl.dot(p_sub.to(v_sub.dtype), v_sub)
227 m_i = m_ij
228 lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
@@ -214,7 +242,7 @@ def _gqa_sparse_fwd_kernel(
242 @torch.no_grad()
243 def minimax_m3_sparse_attn(
244 q: torch.Tensor, # [total_q, num_heads, head_dim]
217 - kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
245 + kv_cache: torch.Tensor, # [num_blocks, num_kv_heads, 128, 2*head_dim]
246 topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
247 block_table: torch.Tensor, # [batch, max_blocks]
248 cu_seqlens_q: torch.Tensor, # [batch+1] int32
@@ -224,6 +252,8 @@ def minimax_m3_sparse_attn(
252 num_kv_heads: int,
253 sm_scale: float,
254 output: torch.Tensor, # [total_q, num_heads, head_dim]
255 + k_scale: torch.Tensor | None = None,
256 + v_scale: torch.Tensor | None = None,
257 ) -> None:
258 """GQA block-sparse attention over the selected blocks. block_size_q == 1."""
259 total_q, num_heads, head_dim = q.shape
@@ -231,10 +261,33 @@ def minimax_m3_sparse_attn(
261 topk = topk_idx.shape[-1]
262 gqa_group_size = num_heads // num_kv_heads
263 use_fp8 = kv_cache.dtype in _FP8_DTYPES
264 + (
265 + k_scale_arg,
266 + v_scale_arg,
267 + stride_ks_h,
268 + stride_ks_t,
269 + stride_vs_h,
270 + stride_vs_t,
271 + kv_scale_mode,
272 + ) = (
273 + _kv_scale_args(output, num_kv_heads, k_scale, v_scale)
274 + if use_fp8
275 + else (
276 + output,
277 + output,
278 + 0,
279 + 0,
280 + 0,
281 + 0,
282 + _KV_SCALE_NONE,
283 + )
284 + )
285 grid = (max_query_len, num_kv_heads, batch)
286 _gqa_sparse_fwd_kernel[grid](
287 q,
288 kv_cache,
289 + k_scale_arg,
290 + v_scale_arg,
291 topk_idx,
292 output,
293 block_table,
@@ -255,7 +308,10 @@ def minimax_m3_sparse_attn(
308 kv_cache.stride(1),
309 kv_cache.stride(2),
310 kv_cache.stride(3),
258 - kv_cache.stride(4),
311 + stride_ks_h,
312 + stride_ks_t,
313 + stride_vs_h,
314 + stride_vs_t,
315 topk_idx.stride(0),
316 topk_idx.stride(1),
317 topk_idx.stride(2),
@@ -266,6 +322,7 @@ def minimax_m3_sparse_attn(
322 BLOCK_SIZE_Q=1,
323 BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
324 USE_FP8=use_fp8,
325 + KV_SCALE_MODE=kv_scale_mode,
326 SUB_K=_SPARSE_ATTN_SUB_K,
327 **_sparse_attn_prefill_kwargs(),
328 )
vllm/models/minimax_m3/amd/ops/sparse_pa.py new
+466
@@ -0,0 +1,466 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""AITER page-16 sparse paged-attention helpers for MiniMax-M3 on ROCm."""
4 +
5 +import torch
6 +
7 +try:
8 + from vllm.triton_utils import tl, triton
9 +except ModuleNotFoundError:
10 + import triton
11 + import triton.language as tl
12 +
13 +from vllm.models.minimax_m3.common.ops.sparse_attn import SPARSE_BLOCK_SIZE
14 +
15 +ASM_PAGE_SIZE = 16
16 +PAGES_PER_SPARSE_BLOCK = SPARSE_BLOCK_SIZE // ASM_PAGE_SIZE
17 +
18 +_FP8_DTYPES = {
19 + dtype
20 + for dtype in (
21 + getattr(torch, "float8_e4m3fn", None),
22 + getattr(torch, "float8_e4m3fnuz", None),
23 + getattr(torch, "float8_e5m2", None),
24 + getattr(torch, "float8_e5m2fnuz", None),
25 + )
26 + if dtype is not None
27 +}
28 +
29 +
30 +def _is_fp8_kv_cache_tensor(kv_cache: torch.Tensor) -> bool:
31 + return kv_cache.dtype in _FP8_DTYPES
32 +
33 +
34 +@triton.jit
35 +def _build_sparse_block_table_kernel(
36 + topk_ptr, # [1, batch, topk] int32, selected logical 128-block ids
37 + block_table_ptr, # [batch, max_blocks] int32, logical 128-page table
38 + seq_lens_ptr, # [batch] int32
39 + sparse_bt_ptr, # [batch, topk * 8] int32, physical 16-page table
40 + sparse_ctx_ptr, # [batch] int32
41 + max_topk,
42 + stride_topk_n,
43 + stride_topk_k,
44 + stride_bt_b,
45 + stride_sbt_b,
46 + SPARSE_BLOCK_SIZE_C: tl.constexpr,
47 + PAGES_PER_BLOCK: tl.constexpr,
48 + BLOCK_SIZE_T: tl.constexpr,
49 +):
50 + pid_b = tl.program_id(0)
51 + seq_len = tl.load(seq_lens_ptr + pid_b)
52 + last_blk = (seq_len - 1) // SPARSE_BLOCK_SIZE_C
53 +
54 + topk_row = topk_ptr + pid_b * stride_topk_n
55 + bt_row = block_table_ptr + pid_b * stride_bt_b
56 + sbt_row = sparse_bt_ptr + pid_b * stride_sbt_b
57 +
58 + off_t = tl.arange(0, BLOCK_SIZE_T)
59 + blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
60 + valid = blk >= 0
61 + is_tail = valid & (blk == last_blk)
62 + is_full = valid & (blk != last_blk)
63 +
64 + n_full = tl.sum(is_full.to(tl.int32), axis=0)
65 + n_valid = tl.sum(valid.to(tl.int32), axis=0)
66 + earlier_full = tl.cumsum(is_full.to(tl.int32), axis=0) - is_full.to(tl.int32)
67 + slot = tl.where(is_full, earlier_full, n_full)
68 +
69 + logical_page = tl.load(bt_row + blk, mask=valid, other=0).to(tl.int32)
70 + base_phys = logical_page * PAGES_PER_BLOCK
71 + dst_base = slot * PAGES_PER_BLOCK
72 +
73 + for j in tl.static_range(PAGES_PER_BLOCK):
74 + tl.store(sbt_row + dst_base + j, base_phys + j, mask=valid)
75 +
76 + n_used = n_valid * PAGES_PER_BLOCK
77 + off_w = tl.arange(0, BLOCK_SIZE_T * PAGES_PER_BLOCK)
78 + row_width = max_topk * PAGES_PER_BLOCK
79 + tl.store(
80 + sbt_row + off_w,
81 + tl.zeros_like(off_w),
82 + mask=(off_w >= n_used) & (off_w < row_width),
83 + )
84 +
85 + tail_tokens = seq_len - last_blk * SPARSE_BLOCK_SIZE_C
86 + has_tail = tl.sum(is_tail.to(tl.int32), axis=0) > 0
87 + ctx = n_full * SPARSE_BLOCK_SIZE_C + tl.where(has_tail, tail_tokens, 0)
88 + ctx = tl.where(has_tail, ctx, tl.minimum(n_valid * SPARSE_BLOCK_SIZE_C, seq_len))
89 + tl.store(sparse_ctx_ptr + pid_b, ctx)
90 +
91 +
92 +@torch.no_grad()
93 +def minimax_m3_build_sparse_block_table(
94 + topk_idx: torch.Tensor, # [1, batch, topk]
95 + block_table: torch.Tensor, # [batch, max_blocks]
96 + seq_lens: torch.Tensor, # [batch]
97 +) -> tuple[torch.Tensor, torch.Tensor]:
98 + """Compact selected logical sparse blocks into physical page-16 tables."""
99 + assert topk_idx.shape[0] == 1, "AITER sparse PA requires num_kv_heads == 1"
100 + batch = topk_idx.shape[1]
101 + topk = topk_idx.shape[-1]
102 + width = topk * PAGES_PER_SPARSE_BLOCK
103 + sparse_bt = torch.empty((batch, width), dtype=torch.int32, device=topk_idx.device)
104 + sparse_ctx = torch.empty((batch,), dtype=torch.int32, device=topk_idx.device)
105 + _build_sparse_block_table_kernel[(batch,)](
106 + topk_idx,
107 + block_table,
108 + seq_lens,
109 + sparse_bt,
110 + sparse_ctx,
111 + topk,
112 + topk_idx.stride(1),
113 + topk_idx.stride(2),
114 + block_table.stride(0),
115 + sparse_bt.stride(0),
116 + SPARSE_BLOCK_SIZE_C=SPARSE_BLOCK_SIZE,
117 + PAGES_PER_BLOCK=PAGES_PER_SPARSE_BLOCK,
118 + BLOCK_SIZE_T=triton.next_power_of_2(topk),
119 + )
120 + return sparse_bt, sparse_ctx
121 +
122 +
123 +@triton.jit
124 +def _build_sparse_block_table_prefill_kernel(
125 + topk_ptr, # [1, total_q, topk]
126 + block_table_ptr, # [batch, max_blocks]
127 + req_id_ptr, # [total_q]
128 + abs_pos_ptr, # [total_q]
129 + sparse_bt_ptr, # [total_q, topk * 8]
130 + sparse_ctx_ptr, # [total_q]
131 + max_topk,
132 + stride_topk_n,
133 + stride_topk_k,
134 + stride_bt_b,
135 + stride_sbt_n,
136 + SPARSE_BLOCK_SIZE_C: tl.constexpr,
137 + PAGES_PER_BLOCK: tl.constexpr,
138 + BLOCK_SIZE_T: tl.constexpr,
139 +):
140 + pid_n = tl.program_id(0)
141 + req_id = tl.load(req_id_ptr + pid_n)
142 + abs_pos = tl.load(abs_pos_ptr + pid_n)
143 + causal_len = abs_pos + 1
144 + self_blk = abs_pos // SPARSE_BLOCK_SIZE_C
145 +
146 + topk_row = topk_ptr + pid_n * stride_topk_n
147 + bt_row = block_table_ptr + req_id * stride_bt_b
148 + sbt_row = sparse_bt_ptr + pid_n * stride_sbt_n
149 +
150 + off_t = tl.arange(0, BLOCK_SIZE_T)
151 + blk = tl.load(topk_row + off_t * stride_topk_k, mask=off_t < max_topk, other=-1)
152 + valid = (blk >= 0) & (blk <= self_blk)
153 + is_tail = valid & (blk == self_blk)
154 + is_full = valid & (blk < self_blk)
155 +
156 + n_full = tl.sum(is_full.to(tl.int32), axis=0)
157 + n_valid = tl.sum(valid.to(tl.int32), axis=0)
158 + earlier_full = tl.cumsum(is_full.to(tl.int32), axis=0) - is_full.to(tl.int32)
159 + slot = tl.where(is_full, earlier_full, n_full)
160 +
161 + logical_page = tl.load(bt_row + blk, mask=valid, other=0).to(tl.int32)
162 + base_phys = logical_page * PAGES_PER_BLOCK
163 + dst_base = slot * PAGES_PER_BLOCK
164 +
165 + for j in tl.static_range(PAGES_PER_BLOCK):
166 + tl.store(sbt_row + dst_base + j, base_phys + j, mask=valid)
167 +
168 + n_used = n_valid * PAGES_PER_BLOCK
169 + off_w = tl.arange(0, BLOCK_SIZE_T * PAGES_PER_BLOCK)
170 + row_width = max_topk * PAGES_PER_BLOCK
171 + tl.store(
172 + sbt_row + off_w,
173 + tl.zeros_like(off_w),
174 + mask=(off_w >= n_used) & (off_w < row_width),
175 + )
176 +
177 + tail_tokens = causal_len - self_blk * SPARSE_BLOCK_SIZE_C
178 + has_tail = tl.sum(is_tail.to(tl.int32), axis=0) > 0
179 + ctx = n_full * SPARSE_BLOCK_SIZE_C + tl.where(has_tail, tail_tokens, 0)
180 + ctx = tl.where(has_tail, ctx, tl.minimum(n_valid * SPARSE_BLOCK_SIZE_C, causal_len))
181 + tl.store(sparse_ctx_ptr + pid_n, ctx)
182 +
183 +
184 +@torch.no_grad()
185 +def minimax_m3_build_sparse_block_table_prefill(
186 + topk_idx: torch.Tensor, # [1, total_q, topk]
187 + block_table: torch.Tensor, # [batch, max_blocks]
188 + query_req_id: torch.Tensor, # [total_q]
189 + query_abs_pos: torch.Tensor, # [total_q]
190 +) -> tuple[torch.Tensor, torch.Tensor]:
191 + """Build one page-16 sparse block table row per prefill query token."""
192 + assert topk_idx.shape[0] == 1, "AITER sparse PA requires num_kv_heads == 1"
193 + total_q = topk_idx.shape[1]
194 + topk = topk_idx.shape[-1]
195 + width = topk * PAGES_PER_SPARSE_BLOCK
196 + sparse_bt = torch.empty((total_q, width), dtype=torch.int32, device=topk_idx.device)
197 + sparse_ctx = torch.empty((total_q,), dtype=torch.int32, device=topk_idx.device)
198 + _build_sparse_block_table_prefill_kernel[(total_q,)](
199 + topk_idx,
200 + block_table,
201 + query_req_id,
202 + query_abs_pos,
203 + sparse_bt,
204 + sparse_ctx,
205 + topk,
206 + topk_idx.stride(1),
207 + topk_idx.stride(2),
208 + block_table.stride(0),
209 + sparse_bt.stride(0),
210 + SPARSE_BLOCK_SIZE_C=SPARSE_BLOCK_SIZE,
211 + PAGES_PER_BLOCK=PAGES_PER_SPARSE_BLOCK,
212 + BLOCK_SIZE_T=triton.next_power_of_2(topk),
213 + )
214 + return sparse_bt, sparse_ctx
215 +
216 +
217 +@triton.jit
218 +def _insert_index_cache_kernel(
219 + index_k_ptr, # [num_tokens, head_dim]
220 + index_cache_ptr, # [num_blocks, block_size, head_dim]
221 + slot_mapping_ptr, # [num_tokens]
222 + stride_k_t,
223 + stride_k_d,
224 + stride_c_b,
225 + stride_c_t,
226 + stride_c_d,
227 + stride_slot_t,
228 + CACHE_BLOCK_SIZE: tl.constexpr,
229 + HEAD_DIM: tl.constexpr,
230 + BLOCK_D: tl.constexpr,
231 +):
232 + pid_t = tl.program_id(0)
233 + offs_d = tl.arange(0, BLOCK_D)
234 + slot = tl.load(slot_mapping_ptr + pid_t * stride_slot_t)
235 + block_id = slot // CACHE_BLOCK_SIZE
236 + block_off = slot - block_id * CACHE_BLOCK_SIZE
237 +
238 + src = index_k_ptr + pid_t * stride_k_t + offs_d * stride_k_d
239 + dst = (
240 + index_cache_ptr
241 + + block_id * stride_c_b
242 + + block_off * stride_c_t
243 + + offs_d * stride_c_d
244 + )
245 + mask = (slot >= 0) & (offs_d < HEAD_DIM)
246 + value = tl.load(src, mask=offs_d < HEAD_DIM, other=0.0)
247 + tl.store(dst, value, mask=mask)
248 +
249 +
250 +@torch.no_grad()
251 +def minimax_m3_insert_index_cache(
252 + index_k: torch.Tensor,
253 + index_cache: torch.Tensor,
254 + index_slot_mapping: torch.Tensor,
255 +) -> None:
256 + """Scatter index keys into MiniMax-M3's key-only side cache."""
257 + if index_k.numel() == 0 or index_cache.numel() == 0:
258 + return
259 + if index_k.dim() != 2 or index_cache.dim() != 3:
260 + raise ValueError("MiniMax-M3 index cache insert expects [N,D] and [B,T,D]")
261 + if index_k.shape[1] != index_cache.shape[2]:
262 + raise ValueError("MiniMax-M3 index key dim must match index cache head dim")
263 + if index_slot_mapping.dim() != 1 or index_slot_mapping.shape[0] != index_k.shape[0]:
264 + raise ValueError("MiniMax-M3 index slot mapping must be a length-N vector")
265 + if index_cache.stride(2) != 1:
266 + raise ValueError("MiniMax-M3 index cache requires contiguous head dimension")
267 +
268 + head_dim = index_k.shape[1]
269 + _insert_index_cache_kernel[(index_k.shape[0],)](
270 + index_k,
271 + index_cache,
272 + index_slot_mapping,
273 + index_k.stride(0),
274 + index_k.stride(1),
275 + index_cache.stride(0),
276 + index_cache.stride(1),
277 + index_cache.stride(2),
278 + index_slot_mapping.stride(0),
279 + CACHE_BLOCK_SIZE=index_cache.shape[1],
280 + HEAD_DIM=head_dim,
281 + BLOCK_D=triton.next_power_of_2(head_dim),
282 + num_warps=4,
283 + )
284 +
285 +
286 +def _gluon_scale_arg(
287 + scale: torch.Tensor | None,
288 + *,
289 + num_phys_pages: int,
290 + num_kv_heads: int,
291 +) -> torch.Tensor | None:
292 + if scale is None:
293 + return None
294 + if scale.numel() == 1:
295 + return scale
296 + if scale.dim() != 2 or scale.shape[0] != num_kv_heads:
297 + raise ValueError(
298 + "MiniMax-M3 AITER sparse PA supports scalar KV scales or "
299 + "[num_kv_heads, max_kv_tokens] scales"
300 + )
301 + max_tokens = num_phys_pages * ASM_PAGE_SIZE
302 + if scale.shape[1] < max_tokens:
303 + raise ValueError(
304 + "MiniMax-M3 AITER sparse PA KV scale tensor is smaller than the "
305 + f"cache token capacity ({scale.shape[1]} < {max_tokens})"
306 + )
307 + scale = scale[:, :max_tokens]
308 + return (
309 + scale.transpose(0, 1)
310 + .contiguous()
311 + .view(num_phys_pages, ASM_PAGE_SIZE, num_kv_heads)
312 + .permute(0, 2, 1)
313 + .contiguous()
314 + .unsqueeze(-1)
315 + )
316 +
317 +
318 +@torch.no_grad()
319 +def minimax_m3_sparse_attn_decode_aiter(
320 + q: torch.Tensor, # [batch, num_heads, head_dim]
321 + k_cache: torch.Tensor, # [phys16, num_kv_heads, head_dim // x, 16, x]
322 + v_cache: torch.Tensor, # [phys16, num_kv_heads, 16 // x, head_dim, x]
323 + topk_idx: torch.Tensor, # [1, batch, topk]
324 + block_table: torch.Tensor, # [batch, max_blocks]
325 + seq_lens: torch.Tensor, # [batch]
326 + num_kv_heads: int,
327 + sm_scale: float,
328 + output: torch.Tensor, # [batch, num_heads, head_dim]
329 + k_scale: torch.Tensor | None = None,
330 + v_scale: torch.Tensor | None = None,
331 +) -> None:
332 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table(
333 + topk_idx, block_table, seq_lens
334 + )
335 + _run_gluon_decode(
336 + q,
337 + k_cache,
338 + v_cache,
339 + sparse_bt,
340 + sparse_ctx,
341 + num_kv_heads,
342 + sm_scale,
343 + output,
344 + k_scale,
345 + v_scale,
346 + )
347 +
348 +
349 +@torch.no_grad()
350 +def minimax_m3_sparse_attn_prefill_aiter(
351 + q: torch.Tensor, # [total_q, num_heads, head_dim]
352 + k_cache: torch.Tensor,
353 + v_cache: torch.Tensor,
354 + topk_idx: torch.Tensor, # [1, total_q, topk]
355 + block_table: torch.Tensor,
356 + cu_seqlens_q: torch.Tensor,
357 + prefix_lens: torch.Tensor,
358 + num_kv_heads: int,
359 + sm_scale: float,
360 + output: torch.Tensor,
361 + k_scale: torch.Tensor | None = None,
362 + v_scale: torch.Tensor | None = None,
363 +) -> None:
364 + total_q = q.shape[0]
365 + pos = torch.arange(total_q, dtype=torch.int32, device=q.device)
366 + query_req_id = torch.searchsorted(cu_seqlens_q[1:].contiguous(), pos, right=True)
367 + query_req_id = query_req_id.to(torch.int32)
368 + query_abs_pos = (prefix_lens[query_req_id] + (pos - cu_seqlens_q[query_req_id])).to(
369 + torch.int32
370 + )
371 + sparse_bt, sparse_ctx = minimax_m3_build_sparse_block_table_prefill(
372 + topk_idx, block_table, query_req_id, query_abs_pos
373 + )
374 + _run_gluon_decode(
375 + q,
376 + k_cache,
377 + v_cache,
378 + sparse_bt,
379 + sparse_ctx,
380 + num_kv_heads,
381 + sm_scale,
382 + output,
383 + k_scale,
384 + v_scale,
385 + )
386 +
387 +
388 +def _run_gluon_decode(
389 + q: torch.Tensor,
390 + k_cache: torch.Tensor,
391 + v_cache: torch.Tensor,
392 + sparse_bt: torch.Tensor,
393 + sparse_ctx: torch.Tensor,
394 + num_kv_heads: int,
395 + sm_scale: float,
396 + output: torch.Tensor,
397 + k_scale: torch.Tensor | None,
398 + v_scale: torch.Tensor | None,
399 +) -> None:
400 + from aiter import dtypes as aiter_dtypes
401 + from aiter.ops.triton.gluon.pa_decode_gluon import (
402 + get_recommended_splits,
403 + pa_decode_gluon,
404 + )
405 +
406 + if not q.is_contiguous():
407 + q = q.contiguous()
408 + if not output.is_contiguous():
409 + raise ValueError("MiniMax-M3 AITER sparse PA output must be contiguous")
410 +
411 + total_q, num_q_heads, head_size = q.shape
412 + if head_size != 128:
413 + raise ValueError("MiniMax-M3 AITER sparse PA requires head_dim == 128")
414 + group_size = num_q_heads // num_kv_heads
415 +
416 + nphys16, hkv = k_cache.shape[0], k_cache.shape[1]
417 + k_cache_view = k_cache.view(nphys16 * hkv, 1, *k_cache.shape[2:])
418 + v_cache_view = v_cache.view(nphys16 * hkv, 1, *v_cache.shape[2:])
419 + q_view = q.view(total_q * num_kv_heads, group_size, head_size)
420 + out_view = output.view(total_q * num_kv_heads, group_size, head_size)
421 +
422 + num_seqs = total_q * num_kv_heads
423 + max_context_partition_num = get_recommended_splits(num_seqs, 1)
424 + context_partition_size = 256
425 + intermediate_shape = (num_seqs, 1, max_context_partition_num, group_size)
426 + exp_sums = torch.empty(intermediate_shape, dtype=torch.float32, device=q.device)
427 + max_logits = torch.empty_like(exp_sums)
428 + temporary_output = torch.empty(
429 + *intermediate_shape, head_size, dtype=q.dtype, device=q.device
430 + )
431 +
432 + is_fp8 = _is_fp8_kv_cache_tensor(k_cache)
433 + compute_type = aiter_dtypes.fp8 if is_fp8 else q.dtype
434 + if is_fp8:
435 + k_scale_arg = _gluon_scale_arg(
436 + k_scale, num_phys_pages=nphys16, num_kv_heads=hkv
437 + )
438 + v_scale_arg = _gluon_scale_arg(
439 + v_scale, num_phys_pages=nphys16, num_kv_heads=hkv
440 + )
441 + else:
442 + k_scale_arg = v_scale_arg = None
443 +
444 + pa_decode_gluon(
445 + output=out_view,
446 + query=q_view,
447 + key_cache=k_cache_view,
448 + value_cache=v_cache_view,
449 + context_lengths=sparse_ctx,
450 + block_tables=sparse_bt,
451 + softmax_scale=sm_scale,
452 + query_length=1,
453 + max_context_partition_num=max_context_partition_num,
454 + context_partition_size=context_partition_size,
455 + compute_type=compute_type,
456 + query_scale=None,
457 + key_scale=k_scale_arg,
458 + value_scale=v_scale_arg,
459 + exp_sums=exp_sums,
460 + max_logits=max_logits,
461 + temporary_output=temporary_output,
462 + alibi_slopes=None,
463 + sinks=None,
464 + sliding_window=-1,
465 + ps=True,
466 + )
vllm/models/minimax_m3/amd/sparse_attention_msa.py new
+94
@@ -0,0 +1,94 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""MSA AITER block-sparse attend for MiniMax M3."""
4 +
5 +import torch
6 +
7 +from vllm.forward_context import get_forward_context
8 +from vllm.models.minimax_m3.common.sparse_attention import (
9 + MiniMaxM3SparseImpl,
10 + MiniMaxM3SparseMetadata,
11 +)
12 +from vllm.v1.attention.backend import (
13 + AttentionLayer,
14 +)
15 +
16 +
17 +class MiniMaxM3SparseAiterPAImpl(MiniMaxM3SparseImpl):
18 + """ROCm AITER page-16 SHUFFLE sparse paged attention."""
19 +
20 + def forward(
21 + self,
22 + layer: AttentionLayer,
23 + query: torch.Tensor,
24 + kv_cache: torch.Tensor,
25 + output: torch.Tensor,
26 + ) -> torch.Tensor:
27 + from vllm.models.minimax_m3.amd.ops.sparse_pa import (
28 + minimax_m3_sparse_attn_decode_aiter,
29 + minimax_m3_sparse_attn_prefill_aiter,
30 + )
31 +
32 + attn_metadata = get_forward_context().attn_metadata
33 + if not isinstance(attn_metadata, dict):
34 + return output
35 + main_md = attn_metadata[layer.layer_name] # type: ignore[attr-defined]
36 + assert isinstance(main_md, MiniMaxM3SparseMetadata)
37 +
38 + nd = main_md.num_decode_tokens
39 + num_tokens = main_md.num_actual_tokens
40 + topk = layer.topk_indices_buffer # type: ignore[attr-defined]
41 + assert topk is not None
42 + if self.num_kv_heads != 1:
43 + raise NotImplementedError(
44 + "MiniMax-M3 AITER sparse PA currently requires per-rank "
45 + f"num_kv_heads == 1, got {self.num_kv_heads}"
46 + )
47 +
48 + hd = self.head_size
49 + q = query[:num_tokens].view(-1, self.num_heads, hd)
50 + out = output[:num_tokens].view(-1, self.num_heads, hd)
51 + k_cache, v_cache = layer.get_aiter_sparse_pa_kv_cache() # type: ignore[attr-defined]
52 + k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
53 + v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
54 +
55 + if main_md.num_decodes > 0:
56 + d = main_md.decode
57 + assert d is not None
58 + if d.decode_query_len != 1:
59 + raise NotImplementedError(
60 + "MiniMax-M3 AITER sparse PA does not support speculative "
61 + f"decode_query_len={d.decode_query_len}"
62 + )
63 + minimax_m3_sparse_attn_decode_aiter(
64 + q[:nd],
65 + k_cache,
66 + v_cache,
67 + topk[:, :nd, :],
68 + d.block_table,
69 + d.seq_lens,
70 + self.num_kv_heads,
71 + self.scale,
72 + out[:nd],
73 + k_scale=k_scale,
74 + v_scale=v_scale,
75 + )
76 +
77 + if main_md.num_prefills > 0:
78 + p = main_md.prefill
79 + assert p is not None
80 + minimax_m3_sparse_attn_prefill_aiter(
81 + q[nd:],
82 + k_cache,
83 + v_cache,
84 + topk[:, nd:num_tokens, :],
85 + p.block_table,
86 + p.cu_seqlens_q,
87 + p.context_lens,
88 + self.num_kv_heads,
89 + self.scale,
90 + out[nd:],
91 + k_scale=k_scale,
92 + v_scale=v_scale,
93 + )
94 + return output
vllm/models/minimax_m3/common/ops/sparse_attn.py
+160
@@ -52,6 +52,8 @@ _FP8_DTYPES = (
52 def _gqa_sparse_fwd_kernel(
53 q_ptr, # [total_q, num_heads, head_dim]
54 kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
55 + k_scale_ptr,
56 + v_scale_ptr,
57 t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
58 o_ptr, # [total_q, num_heads, head_dim]
59 block_table_ptr, # [num_reqs, max_blocks]
@@ -72,6 +74,10 @@ def _gqa_sparse_fwd_kernel(
74 stride_kv_h,
75 stride_kv_pos,
76 stride_kv_d,
77 + stride_ks_h,
78 + stride_ks_t,
79 + stride_vs_h,
80 + stride_vs_t,
81 stride_th,
82 stride_tn,
83 stride_tk,
@@ -85,6 +91,7 @@ def _gqa_sparse_fwd_kernel(
91 BLOCK_SIZE_H: tl.constexpr,
92 BLOCK_SIZE_QH: tl.constexpr,
93 USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
94 + KV_SCALE_MODE: tl.constexpr, # 0: none, 1: scalar, 2: [kv_head, token]
95 ):
96 sm_scale_log2e = sm_scale * 1.4426950409
97 pid_q = tl.program_id(0)
@@ -148,6 +155,17 @@ def _gqa_sparse_fwd_kernel(
155 )
156 if USE_FP8:
157 k = k.to(q.dtype)
158 + if KV_SCALE_MODE == 1:
159 + k = (k * tl.load(k_scale_ptr)).to(q.dtype)
160 + elif KV_SCALE_MODE == 2:
161 + k_scale = tl.load(
162 + k_scale_ptr
163 + + pid_kh * stride_ks_h
164 + + (page * BLOCK_SIZE_K + off_n) * stride_ks_t,
165 + mask=pos_mask,
166 + other=1.0,
167 + )
168 + k = (k * k_scale[None, :]).to(q.dtype)
169 qk = tl.zeros((BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32)
170 # causal: q_abs_pos - k_off >= block_start (c)
171 qk += tl.where(off_q[:, None, :] >= c, 0, float("-inf"))
@@ -169,6 +187,17 @@ def _gqa_sparse_fwd_kernel(
187 )
188 if USE_FP8:
189 v = v.to(q.dtype)
190 + if KV_SCALE_MODE == 1:
191 + v = (v * tl.load(v_scale_ptr)).to(q.dtype)
192 + elif KV_SCALE_MODE == 2:
193 + v_scale = tl.load(
194 + v_scale_ptr
195 + + pid_kh * stride_vs_h
196 + + (page * BLOCK_SIZE_K + off_n) * stride_vs_t,
197 + mask=pos_mask,
198 + other=1.0,
199 + )
200 + v = (v * v_scale[:, None]).to(q.dtype)
201 acc_o += tl.dot(p.to(v.dtype), v)
202 m_i = m_ij
203 lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
@@ -205,6 +234,8 @@ def _gqa_sparse_fwd_kernel(
234 def _gqa_sparse_decode_kernel(
235 q_ptr, # [total_q, num_heads, head_dim]
236 kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
237 + k_scale_ptr,
238 + v_scale_ptr,
239 t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
240 o_ptr, # partial out: [NUM_TOPK_CHUNKS, total_q, num_heads, head_dim]
241 lse_ptr, # partial lse (log2): [NUM_TOPK_CHUNKS, total_q, num_heads]
@@ -223,6 +254,10 @@ def _gqa_sparse_decode_kernel(
254 stride_kv_h,
255 stride_kv_pos,
256 stride_kv_d,
257 + stride_ks_h,
258 + stride_ks_t,
259 + stride_vs_h,
260 + stride_vs_t,
261 stride_th,
262 stride_tn,
263 stride_tk,
@@ -239,6 +274,7 @@ def _gqa_sparse_decode_kernel(
274 BLOCK_SIZE_H: tl.constexpr,
275 BLOCK_SIZE_D: tl.constexpr,
276 USE_FP8: tl.constexpr, # fp8 KV cache: dequantize K/V to q.dtype on load
277 + KV_SCALE_MODE: tl.constexpr, # 0: none, 1: scalar, 2: [kv_head, token]
278 USE_PDL: tl.constexpr,
279 ):
280 sm_scale_log2e = sm_scale * 1.4426950409
@@ -305,6 +341,17 @@ def _gqa_sparse_decode_kernel(
341 )
342 if USE_FP8:
343 k = k.to(q.dtype)
344 + if KV_SCALE_MODE == 1:
345 + k = (k * tl.load(k_scale_ptr)).to(q.dtype)
346 + elif KV_SCALE_MODE == 2:
347 + k_scale = tl.load(
348 + k_scale_ptr
349 + + pid_kh * stride_ks_h
350 + + (page * BLOCK_SIZE_K + off_n) * stride_ks_t,
351 + mask=pos_mask,
352 + other=1.0,
353 + )
354 + k = (k * k_scale[None, :]).to(q.dtype)
355 qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32)
356 qk += tl.where(pos_mask[None, :], 0, float("-inf"))
357 qk += tl.dot(q, k) * sm_scale_log2e
@@ -323,6 +370,17 @@ def _gqa_sparse_decode_kernel(
370 )
371 if USE_FP8:
372 v = v.to(q.dtype)
373 + if KV_SCALE_MODE == 1:
374 + v = (v * tl.load(v_scale_ptr)).to(q.dtype)
375 + elif KV_SCALE_MODE == 2:
376 + v_scale = tl.load(
377 + v_scale_ptr
378 + + pid_kh * stride_vs_h
379 + + (page * BLOCK_SIZE_K + off_n) * stride_vs_t,
380 + mask=pos_mask,
381 + other=1.0,
382 + )
383 + v = (v * v_scale[:, None]).to(q.dtype)
384 acc_o += tl.dot(p.to(v.dtype), v)
385 m_i = m_ij
386 lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
@@ -410,6 +468,48 @@ def _merge_topk_attn_out_kernel(
468 # ---------------------------------------------------------------------------
469 # Python wrappers
470 # ---------------------------------------------------------------------------
471 +_KV_SCALE_NONE = 0
472 +_KV_SCALE_SCALAR = 1
473 +_KV_SCALE_PER_TOKEN_HEAD = 2
474 +
475 +
476 +def _kv_scale_args(
477 + output: torch.Tensor,
478 + num_kv_heads: int,
479 + k_scale: torch.Tensor | None,
480 + v_scale: torch.Tensor | None,
481 +) -> tuple[torch.Tensor, torch.Tensor, int, int, int, int, int]:
482 + if k_scale is None and v_scale is None:
483 + return output, output, 0, 0, 0, 0, _KV_SCALE_NONE
484 + if k_scale is None or v_scale is None:
485 + raise ValueError("k_scale and v_scale must be both provided or both None")
486 + if k_scale.device != output.device or v_scale.device != output.device:
487 + raise ValueError("k_scale and v_scale must be on the same device as output")
488 + if k_scale.numel() == 1 and v_scale.numel() == 1:
489 + return k_scale, v_scale, 0, 0, 0, 0, _KV_SCALE_SCALAR
490 + if k_scale.dim() == 2 and v_scale.dim() == 2:
491 + if k_scale.shape[0] != num_kv_heads or v_scale.shape[0] != num_kv_heads:
492 + raise ValueError(
493 + "per-token/head KV scales must have shape "
494 + f"[{num_kv_heads}, max_kv_tokens]"
495 + )
496 + if k_scale.shape != v_scale.shape:
497 + raise ValueError("k_scale and v_scale must have matching shapes")
498 + return (
499 + k_scale,
500 + v_scale,
501 + k_scale.stride(0),
502 + k_scale.stride(1),
503 + v_scale.stride(0),
504 + v_scale.stride(1),
505 + _KV_SCALE_PER_TOKEN_HEAD,
506 + )
507 + raise ValueError(
508 + "MiniMax-M3 sparse attention supports scalar KV scales or "
509 + "[num_kv_heads, max_kv_tokens] per-token/head scales"
510 + )
511 +
512 +
513 @torch.no_grad()
514 def minimax_m3_sparse_attn(
515 q: torch.Tensor, # [total_q, num_heads, head_dim]
@@ -423,6 +523,8 @@ def minimax_m3_sparse_attn(
523 num_kv_heads: int,
524 sm_scale: float,
525 output: torch.Tensor, # [total_q, num_heads, head_dim]
526 + k_scale: torch.Tensor | None = None,
527 + v_scale: torch.Tensor | None = None,
528 ) -> None:
529 """GQA block-sparse attention over the selected blocks. block_size_q == 1."""
530 total_q, num_heads, head_dim = q.shape
@@ -430,10 +532,33 @@ def minimax_m3_sparse_attn(
532 topk = topk_idx.shape[-1]
533 gqa_group_size = num_heads // num_kv_heads
534 use_fp8 = kv_cache.dtype in _FP8_DTYPES
535 + (
536 + k_scale_arg,
537 + v_scale_arg,
538 + stride_ks_h,
539 + stride_ks_t,
540 + stride_vs_h,
541 + stride_vs_t,
542 + kv_scale_mode,
543 + ) = (
544 + _kv_scale_args(output, num_kv_heads, k_scale, v_scale)
545 + if use_fp8
546 + else (
547 + output,
548 + output,
549 + 0,
550 + 0,
551 + 0,
552 + 0,
553 + _KV_SCALE_NONE,
554 + )
555 + )
556 grid = (max_query_len, num_kv_heads, batch)
557 _gqa_sparse_fwd_kernel[grid](
558 q,
559 kv_cache,
560 + k_scale_arg,
561 + v_scale_arg,
562 topk_idx,
563 output,
564 block_table,
@@ -454,6 +579,10 @@ def minimax_m3_sparse_attn(
579 kv_cache.stride(1),
580 kv_cache.stride(2),
581 kv_cache.stride(3),
582 + stride_ks_h,
583 + stride_ks_t,
584 + stride_vs_h,
585 + stride_vs_t,
586 topk_idx.stride(0),
587 topk_idx.stride(1),
588 topk_idx.stride(2),
@@ -464,6 +593,7 @@ def minimax_m3_sparse_attn(
593 BLOCK_SIZE_Q=1,
594 BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
595 USE_FP8=use_fp8,
596 + KV_SCALE_MODE=kv_scale_mode,
597 )
598
599
@@ -478,6 +608,8 @@ def minimax_m3_sparse_attn_decode(
608 sm_scale: float,
609 output: torch.Tensor, # [total_q, num_heads, head_dim]
610 decode_query_len: int,
611 + k_scale: torch.Tensor | None = None,
612 + v_scale: torch.Tensor | None = None,
613 ) -> None:
614 """GQA block-sparse attention for decode (split-K over the top-k blocks)."""
615 total_q, num_heads, head_dim = q.shape
@@ -485,6 +617,27 @@ def minimax_m3_sparse_attn_decode(
617 max_topk = topk_idx.shape[-1]
618 gqa_group_size = num_heads // num_kv_heads
619 use_fp8 = kv_cache.dtype in _FP8_DTYPES
620 + (
621 + k_scale_arg,
622 + v_scale_arg,
623 + stride_ks_h,
624 + stride_ks_t,
625 + stride_vs_h,
626 + stride_vs_t,
627 + kv_scale_mode,
628 + ) = (
629 + _kv_scale_args(output, num_kv_heads, k_scale, v_scale)
630 + if use_fp8
631 + else (
632 + output,
633 + output,
634 + 0,
635 + 0,
636 + 0,
637 + 0,
638 + _KV_SCALE_NONE,
639 + )
640 + )
641 use_pdl = current_platform.is_arch_support_pdl()
642 # `launch_pdl` is a Triton runtime kwarg only some backends accept (CUDA
643 # SM9+); this ROCm Triton rejects it even when False ("Keyword argument
@@ -505,6 +658,8 @@ def minimax_m3_sparse_attn_decode(
658 _gqa_sparse_decode_kernel[grid](
659 q,
660 kv_cache,
661 + k_scale_arg,
662 + v_scale_arg,
663 topk_idx,
664 o_partial,
665 lse_partial,
@@ -523,6 +678,10 @@ def minimax_m3_sparse_attn_decode(
678 kv_cache.stride(1),
679 kv_cache.stride(2),
680 kv_cache.stride(3),
681 + stride_ks_h,
682 + stride_ks_t,
683 + stride_vs_h,
684 + stride_vs_t,
685 topk_idx.stride(0),
686 topk_idx.stride(1),
687 topk_idx.stride(2),
@@ -537,6 +696,7 @@ def minimax_m3_sparse_attn_decode(
696 BLOCK_SIZE_K=SPARSE_BLOCK_SIZE,
697 NUM_TOPK_CHUNKS=num_topk_chunks,
698 USE_FP8=use_fp8,
699 + KV_SCALE_MODE=kv_scale_mode,
700 USE_PDL=use_pdl,
701 **pdl_launch,
702 )
vllm/models/minimax_m3/common/sparse_attention.py
+48 -4
@@ -20,6 +20,7 @@ from typing import ClassVar
20
21 import torch
22
23 +from vllm._aiter_ops import rocm_aiter_ops
24 from vllm.config import VllmConfig
25 from vllm.config.cache import CacheDType
26 from vllm.forward_context import get_forward_context
@@ -58,6 +59,21 @@ from vllm.v1.kv_cache_interface import AttentionSpec, is_quantized_kv_cache
59 logger = init_logger(__name__)
60
61
62 +def _minimax_m3_aiter_sparse_pa_requested() -> bool:
63 + return rocm_aiter_ops.is_enabled() and rocm_aiter_ops.is_shuffle_kv_cache_enabled()
64 +
65 +
66 +def minimax_m3_use_aiter_sparse_pa(num_kv_heads: int) -> bool:
67 + """Whether to use the ROCm AITER page-16 sparse PA prototype."""
68 + requested = _minimax_m3_aiter_sparse_pa_requested()
69 + if requested and num_kv_heads != 1:
70 + raise ValueError(
71 + "MiniMax M3 AITER sparse paged attention requires "
72 + f"num_kv_heads == 1 per tensor-parallel rank, got {num_kv_heads}."
73 + )
74 + return requested
75 +
76 +
77 class MiniMaxM3SparseBackend(AttentionBackend):
78 """Block-sparse GQA backend for MiniMax M3 sparse attention layers."""
79
@@ -104,6 +120,12 @@ class MiniMaxM3SparseBackend(AttentionBackend):
120 head_size: int,
121 cache_dtype_str: str = "auto",
122 ) -> tuple[int, ...]:
123 + if minimax_m3_use_aiter_sparse_pa(num_kv_heads):
124 + # AITER's assembly paged-attention kernels require independently
125 + # contiguous K and V storage. Keep that specialized layout behind
126 + # the shuffle flag while every other implementation uses the
127 + # packed-content contract introduced by #44455.
128 + return (num_blocks, 2, block_size, num_kv_heads, head_size)
129 # K and V are packed into the content dim: logical (B, H, N, 2*hs).
130 return (num_blocks, num_kv_heads, block_size, 2 * head_size)
131
@@ -116,6 +138,11 @@ class MiniMaxM3SparseBackend(AttentionBackend):
138 # layout we want.
139 if include_num_layers_dimension:
140 raise NotImplementedError # no cross-layer KV blocks in M3
141 + if _minimax_m3_aiter_sparse_pa_requested():
142 + # The AITER page-16 sparse PA path reinterprets the K and V slices
143 + # as separate SHUFFLE caches. Keep K/V physically separated so
144 + # kv_cache[:, 0] and kv_cache[:, 1] are contiguous byte ranges.
145 + return (1, 0, 2, 3, 4)
146 cache_layout = get_kv_cache_layout()
147 if cache_layout == "NHD":
148 # (num_blocks, block_size, num_kv_heads, 2*head_size)
@@ -365,6 +392,8 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
392 kv_cache = (
393 kv_cache.view(self.kv_cache_fp8_dtype) if self.use_fp8_kv else kv_cache
394 )
395 + k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
396 + v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
397
398 # Decode [:nd]: split-K over the selected blocks (request-major chunks).
399 if main_md.num_decodes > 0:
@@ -380,6 +409,8 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
409 self.scale,
410 out[:nd],
411 d.decode_query_len,
412 + k_scale=k_scale,
413 + v_scale=v_scale,
414 )
415
416 # Prefill [nd:]: cu_seqlens_q already rebased to 0.
@@ -398,6 +429,8 @@ class MiniMaxM3SparseTritonImpl(MiniMaxM3SparseImpl):
429 self.num_kv_heads,
430 self.scale,
431 out[nd:],
432 + k_scale=k_scale,
433 + v_scale=v_scale,
434 )
435 return output
436
@@ -406,27 +439,38 @@ def select_main_impl_cls(
439 *,
440 topk_blocks: int,
441 kv_cache_dtype: str,
442 + num_kv_heads: int,
443 ) -> type[MiniMaxM3SparseImpl]:
444 """Pick the main attend impl off the main KV-cache dtype.
445
446 Blackwell (SM100) uses the MSA attend for supported top-k block counts
413 - when the KV cache is BF16 or FP8 E4M3; non-Blackwell and FP8 E5M2 fall
414 - back to Triton. The MSA module is imported lazily so AMD/non-SM100 never
415 - import fmha_sm100.
447 + when the KV cache is BF16 or FP8 E4M3; MI355 uses AITER sparse PA
448 + with shuffle KV cache layout; Other platforms and FP8 E5M2 fall
449 + back to Triton. The MSA modules are imported lazily avoid import errors
450 + on unsupported platforms.
451 """
452 + use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(num_kv_heads)
453 use_msa = (
454 current_platform.is_cuda()
455 and current_platform.is_device_capability_family(100)
456 and topk_blocks in (4, 8, 16, 32)
457 and kv_cache_dtype != "fp8_e5m2"
458 )
423 - selected = "MSA" if use_msa else "Triton"
459 + selected = (
460 + "AITER_SPARSE_PA" if use_aiter_sparse_pa else ("MSA" if use_msa else "Triton")
461 + )
462 logger.info_once(
463 "MiniMax M3 sparse attention selected %s (kv_cache_dtype=%s, topk_blocks=%s)",
464 selected,
465 kv_cache_dtype,
466 topk_blocks,
467 )
468 + if use_aiter_sparse_pa:
469 + from vllm.models.minimax_m3.amd.sparse_attention_msa import (
470 + MiniMaxM3SparseAiterPAImpl,
471 + )
472 +
473 + return MiniMaxM3SparseAiterPAImpl
474 if use_msa:
475 from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
476 MiniMaxM3SparseMSAImpl,
vllm/models/minimax_m3/nvidia/model.py
+7
@@ -25,6 +25,7 @@ from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size
25 from vllm.forward_context import get_forward_context
26 from vllm.model_executor.layers.activation import SiluAndMulWithClamp
27 from vllm.model_executor.layers.attention import Attention
28 +from vllm.model_executor.layers.attention.attention import set_default_quant_scales
29 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
30 from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
31 fused_allreduce_gemma_rms_norm,
@@ -491,6 +492,11 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
492 self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
493 self.kv_cache_dtype, vllm_config.model_config
494 )
495 + # MiniMax-M3 sparse attention owns its KV-cache insert/read path instead
496 + # of wrapping the generic Attention module. Keep the same runtime scale
497 + # attributes so FP8 KV reads can honor vLLM's per-layer descale contract.
498 + self.calculate_kv_scales = False
499 + set_default_quant_scales(self, register_buffer=True)
500 # Indexer side-cache dtype, mirroring --kv-cache-dtype for the main
501 # cache (--attention-config '{"indexer_kv_dtype": ...}').
502 self.indexer_kv_dtype = vllm_config.attention_config.indexer_kv_dtype
@@ -506,6 +512,7 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
512 self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
513 topk_blocks=sparse_cfg["sparse_topk_blocks"],
514 kv_cache_dtype=self.kv_cache_dtype,
515 + num_kv_heads=self.num_kv_heads,
516 )(
517 self.num_heads,
518 self.head_dim,
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+4
@@ -49,6 +49,8 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
49 kv_cache = (
50 kv_cache.view(self.kv_cache_fp8_dtype) if self.use_fp8_kv else kv_cache
51 )
52 + k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
53 + v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
54
55 # Decode [:nd]: Triton split-K placeholder (no MSA decode yet).
56 if main_md.num_decodes > 0:
@@ -64,6 +66,8 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
66 self.scale,
67 out[:nd],
68 d.decode_query_len,
69 + k_scale=k_scale,
70 + v_scale=v_scale,
71 )
72
73 # Prefill [nd:]: MSA sparse FMHA over the selected blocks.