[Attention][MiniMax-M3] NVFP4 KV cache on the MSA sparse attention path (#59300)

Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Yongye Zhu committed Oct 1, 2026 at 14:39 UTC 7566d83bd35cdfd7cd3f9e521a005d2fc1ed1c33
12 files changed +1293 -69
CMakeLists.txt
+21
@@ -590,9 +590,30 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
590 endif()
591 endif()
592
593 + # The fused MiniMax-M3 KV insert quantizes NVFP4 with SM100-family-only
594 + # cvt instructions, so its SM10x/11x code is built for the family targets.
595 + set(MINIMAX_M3_FUSED_SRC
596 + "csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu")
597 + list(REMOVE_ITEM VLLM_STABLE_EXT_SRC "${MINIMAX_M3_FUSED_SRC}")
598 set_gencode_flags_for_srcs(
599 SRCS "${VLLM_STABLE_EXT_SRC}"
600 CUDA_ARCHS "${CUDA_ARCHS}")
601 + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0)
602 + cuda_archs_loose_intersection(MINIMAX_M3_FUSED_ARCHS
603 + "10.0f;10.7f;11.0f" "${CUDA_ARCHS}")
604 + else()
605 + cuda_archs_loose_intersection(MINIMAX_M3_FUSED_ARCHS
606 + "10.0a;10.1a;10.3a" "${CUDA_ARCHS}")
607 + endif()
608 + foreach(_ARCH ${CUDA_ARCHS})
609 + if(_ARCH VERSION_LESS 10.0 OR _ARCH VERSION_GREATER_EQUAL 12.0)
610 + list(APPEND MINIMAX_M3_FUSED_ARCHS "${_ARCH}")
611 + endif()
612 + endforeach()
613 + set_gencode_flags_for_srcs(
614 + SRCS "${MINIMAX_M3_FUSED_SRC}"
615 + CUDA_ARCHS "${MINIMAX_M3_FUSED_ARCHS}")
616 + list(APPEND VLLM_STABLE_EXT_SRC "${MINIMAX_M3_FUSED_SRC}")
617
618 if(COOPERATIVE_TOPK_ARCHS)
619 list(APPEND VLLM_STABLE_EXT_SRC
cmake/external_projects/fmha_sm100.cmake
+15 -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 f355c37eb4e1413f21ee2ad8bbad25079e6bef9d
20 + GIT_TAG be4a52aea420b6bef73be9af1a649859964790cb
21 GIT_PROGRESS TRUE
22 CONFIGURE_COMMAND ""
23 BUILD_COMMAND ""
@@ -39,6 +39,8 @@ install(FILES
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}/nvfp4_kv.py"
43 + "${FMHA_SM100_PY_ROOT}/q8kv4_decode_adapter.py"
44 "${FMHA_SM100_PY_ROOT}/sparse.py"
45 "${FMHA_SM100_PY_ROOT}/sparse_fmha_adapter.py"
46 DESTINATION vllm/third_party/fmha_sm100
@@ -51,6 +53,18 @@ install(DIRECTORY "${FMHA_SM100_PY_ROOT}/csrc/"
53 PATTERN "*.pyc" EXCLUDE
54 PATTERN ".git*" EXCLUDE)
55
56 +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/decode_q8kv4/"
57 + DESTINATION vllm/third_party/fmha_sm100/decode_q8kv4
58 + COMPONENT fmha_sm100
59 + PATTERN "__pycache__" EXCLUDE
60 + PATTERN "*.pyc" EXCLUDE)
61 +
62 +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/kvouter/"
63 + DESTINATION vllm/third_party/fmha_sm100/kvouter
64 + COMPONENT fmha_sm100
65 + PATTERN "__pycache__" EXCLUDE
66 + PATTERN "*.pyc" EXCLUDE)
67 +
68 install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cute/"
69 DESTINATION vllm/third_party/fmha_sm100/cute
70 COMPONENT fmha_sm100
csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+387 -38
@@ -285,6 +285,262 @@ __device__ __forceinline__ void storeScaledQElemsFp8(
285 storeElemsFp8(dst, scaled);
286 }
287
288 +// index_q: E4M3(scalar_t(v)) with unit scale, i.e. q is rounded to the model
289 +// dtype before the fp8 cast. On CUDA each element pair is rounded to scalar_t
290 +// and then converted to E4M3, with one packed cvt each.
291 +template <typename scalar_t>
292 +__device__ __forceinline__ void storeIndexQElemsFp8(
293 + uint8_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) {
294 +#ifndef USE_ROCM
295 + using Converter = vllm::_typeConvert<scalar_t>;
296 + __nv_fp8x2_storage_t out2[kElemsPerLane / 2];
297 + #pragma unroll
298 + for (int i = 0; i < kElemsPerLane / 2; i++) {
299 + auto const v =
300 + Converter::convert(make_float2(elems[2 * i], elems[2 * i + 1]));
301 + if constexpr (std::is_same_v<scalar_t, c10::BFloat16>) {
302 + out2[i] = __nv_cvt_bfloat16raw2_to_fp8x2(v, __NV_SATFINITE, __NV_E4M3);
303 + } else {
304 + out2[i] = __nv_cvt_halfraw2_to_fp8x2(v, __NV_SATFINITE, __NV_E4M3);
305 + }
306 + }
307 + *reinterpret_cast<uint32_t*>(dst) = *reinterpret_cast<uint32_t const*>(out2);
308 +#else
309 + storeScaledQElemsFp8<scalar_t>(dst, elems, 1.0f);
310 +#endif
311 +}
312 +
313 +// ────────────────────────────────────────────────────────────────────────────
314 +// NVFP4 main K/V cache store
315 +// ────────────────────────────────────────────────────────────────────────────
316 +// The block encoding of reshape_and_cache_nvfp4_kernel
317 +// (nvfp4_kv_cache_kernels.cu) in the MSA readers' head-slot page layout. The
318 +// quantizer differs from that kernel's reciprocal-multiply math: per 16-value
319 +// block, scale = min(448, max(E4M3(max(amax, 1e-12) / 6), 1/512)) and
320 +// code = sign | RNE-E2M1(min(|x / scale|, 6)), with zero stored as +0, all on
321 +// x / the global scale.
322 +// An HND page is 2*nkv slots of [bs, 72] bytes; slot 2*head + side (K = 0,
323 +// V = 1) holds that head's packed E2M1 data [bs, 64] followed by its E4M3 block
324 +// scales [bs, 8]. Every head is one contiguous 2*bs*72-byte run, so a page
325 +// split across TP ranks is a contiguous byte slice per rank. K scales are
326 +// linear, V scales use the SM100 trtllm-gen 4x4 token-quad swizzle. One
327 +// 16-element scale group is four lanes. SM100-family builds of this file
328 +// (CMake gives it family-specific SM10x/11x targets) encode E2M1 with
329 +// cvt.rn.satfinite.e2m1x2; other targets use a bit-exact software encoder.
330 +#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1000 && \
331 + defined(__CUDA_ARCH_FAMILY_SPECIFIC__)
332 + #define MINIMAX_M3_NVFP4_NATIVE_CVT 1
333 +#else
334 + #define MINIMAX_M3_NVFP4_NATIVE_CVT 0
335 +#endif
336 +
337 +#ifndef USE_ROCM
338 +constexpr int kNvfp4DataBytes = kHeadDim / 2; // 64
339 +constexpr int kNvfp4ScaleBytes = kHeadDim / 16; // 8
340 +constexpr int kNvfp4LanesPerGroup = 16 / kElemsPerLane; // 4
341 +
342 +// Round-to-nearest-even, saturating E2M1 code of ``x`` (matches
343 +// cvt.rn.satfinite.e2m1x2.f32). Below 1 the grid is the 0.5-step subnormals;
344 +// from 1 up it is fp32 rounded to one mantissa bit, whose (exponent, mantissa)
345 +// bits are the code offset by 252 once the exponent is rebiased.
346 +__device__ __forceinline__ uint32_t e2m1Code(float x) {
347 + float const ax = fabsf(x);
348 + uint32_t mag;
349 + if (ax < 1.0f) {
350 + mag = static_cast<uint32_t>(__float2int_rn(2.0f * ax));
351 + } else {
352 + uint32_t const bits = __float_as_uint(ax);
353 + uint32_t const rounded = bits + 0x1FFFFFu + ((bits >> 22) & 1u);
354 + mag = min((rounded >> 22) - 252u, 7u);
355 + }
356 + return mag | ((__float_as_uint(x) >> 28) & 0x8u);
357 +}
358 +
359 +__device__ __forceinline__ float rcpApproxFtz(float a) {
360 + float b;
361 + asm("rcp.approx.ftz.f32 %0, %1;" : "=f"(b) : "f"(a));
362 + return b;
363 +}
364 +
365 +// a / b rounded to nearest even from r = RN(1 / b): Markstein's correction of
366 +// a * r, which is __fdiv_rn's fast path without its range-check branch. Exact
367 +// unless a * r overflows or the residual a - q * b underflows (a within a few
368 +// binades of the subnormals).
369 +__device__ __forceinline__ float divRnByRcp(float a, float b, float r) {
370 + float const q = __fmul_rn(a, r);
371 + return __fmaf_rn(__fmaf_rn(-q, b, a), r, q);
372 +}
373 +
374 +// Block scale E4M3(max(amax, 1e-12) / 6) clamped to [1/512, 448], with
375 +// sf_scale applied to it as to the values, and its reciprocal RN(1 / sf_value).
376 +__device__ __forceinline__ void nvfp4GroupScale(float sf_scale, float group_max,
377 + uint8_t& sf_byte,
378 + float& sf_value,
379 + float& sf_rcp) {
380 + constexpr float kRcp6 = 0x1.555556p-3f; // RN(1 / 6)
381 + float const amax = fmaxf(group_max, 1e-12f);
382 + // amax / 6 (RNE). amax * kRcp6 bounds it from above, and fminf picks it for
383 + // amax = inf, where the correction is NaN.
384 + float const amax_6 =
385 + fminf(divRnByRcp(amax, 6.0f, kRcp6), __fmul_rn(amax, kRcp6));
386 + // Flooring before the rounding gives the 1/512 minimum; satfinite caps it
387 + // at 448.
388 + __nv_fp8_e4m3 const sf(fmaxf(sf_scale * amax_6, 0x1p-9f));
389 + sf_byte = sf.__x;
390 + sf_value = float(sf);
391 + // rcp.approx is correctly rounded on every positive E4M3 value.
392 + sf_rcp = rcpApproxFtz(sf_value);
393 +}
394 +
395 +// nvfp4GroupScale for sf_scale = 1 and a bf16/fp16 group_max. Uncorrected,
396 +// amax * RN(1 / 6) still rounds to the E4M3 byte of amax / 6 (checked for every
397 +// such amax: amax / 6 is too far from an E4M3 rounding boundary to cross it,
398 +// and exact ties round back), and the 1e-12 floor lies below the 1/512 one.
399 +__device__ __forceinline__ void nvfp4UnitGroupScale(float group_max,
400 + uint8_t& sf_byte,
401 + float& sf_rcp) {
402 + constexpr float kRcp6 = 0x1.555556p-3f; // RN(1 / 6)
403 + __nv_fp8_e4m3 const sf(fmaxf(__fmul_rn(group_max, kRcp6), 0x1p-9f));
404 + sf_byte = sf.__x;
405 + sf_rcp = rcpApproxFtz(float(sf));
406 +}
407 +
408 +// (x * sf_scale) / sf_value with the E2M1 code of the IEEE quotient. The
409 +// quotient itself is the IEEE one except below 1e-35 (code 0 either way) and
410 +// for magnitudes over 4096, which are clamped (NaN kept) so that the division
411 +// stays finite: 4096 / 448 > 5, so their code stays the saturated 6.
412 +__device__ __forceinline__ float nvfp4Quotient(float x, float sf_scale,
413 + float sf_value, float sf_rcp) {
414 + constexpr float kMaxMag = 4096.0f;
415 + float n = __fmul_rn(x, sf_scale);
416 + n = fabsf(n) > kMaxMag ? copysignf(kMaxMag, n) : n;
417 + return divRnByRcp(n, sf_value, sf_rcp);
418 +}
419 +
420 +// Zero is stored as +0: clear the sign of every nibble whose magnitude is 0.
421 +// Adding 7 to a 3-bit magnitude carries into the sign bit exactly when it is
422 +// nonzero.
423 +__device__ __forceinline__ uint16_t clearNegZeroNibbles(uint16_t packed) {
424 + return packed & (((packed & 0x7777u) + 0x7777u) | 0x7777u);
425 +}
426 +
427 +// Quantize one K or V head row of one token into its NVFP4 page, where
428 +// ``scale`` is the dequantization (global) scale. Every lane of the warp must
429 +// call this (the scale-group reductions shuffle).
430 +template <typename scalar_t>
431 +__device__ __forceinline__ void storeNvfp4CacheElems(
432 + uint8_t* __restrict__ page, int const kv, int const head, int const token,
433 + int const block_size, int const laneId, float const (&elems)[kElemsPerLane],
434 + float const scale) {
435 + // Quantize the model-dtype value that the unfused path reads back from qkv.
436 + using Converter = vllm::_typeConvert<scalar_t>;
437 + float x[kElemsPerLane];
438 + float group_max = 0.0f;
439 + #pragma unroll
440 + for (int i = 0; i < kElemsPerLane; i++) {
441 + x[i] = static_cast<float>(Converter::convert(elems[i]));
442 + group_max = fmaxf(group_max, fabsf(x[i]));
443 + }
444 + group_max = fmaxf(group_max, __shfl_xor_sync(FINAL_MASK, group_max, 1, 32));
445 + group_max = fmaxf(group_max, __shfl_xor_sync(FINAL_MASK, group_max, 2, 32));
446 +
447 + uint8_t sf_byte;
448 + float sf_rcp;
449 + float q[kElemsPerLane]; // (x / scale) / sf_value, E2M1-encoded below
450 + if (scale == 1.0f) {
451 + static_assert(sizeof(scalar_t) == 2, "the unit-scale path needs bf16/fp16");
452 + nvfp4UnitGroupScale(group_max, sf_byte, sf_rcp);
453 + // x / sf_value truncated from x * RN(1 / sf_value). RN(1 / s) >= 1 / s for
454 + // every E4M3 s, so a bf16/fp16 x on an E2M1 midpoint lands exactly on it
455 + // (and the cvt rounds it to even), while any other bf16/fp16 x is too far
456 + // from a midpoint to reach one. No clamp either: a finite product never
457 + // truncates to inf, and inf saturates in the cvt.
458 + #if MINIMAX_M3_NVFP4_NATIVE_CVT
459 + float2 const r2 = make_float2(sf_rcp, sf_rcp);
460 + #pragma unroll
461 + for (int i = 0; i < kElemsPerLane; i += 2) {
462 + float2 const p = __fmul2_rz(make_float2(x[i], x[i + 1]), r2);
463 + q[i] = p.x;
464 + q[i + 1] = p.y;
465 + }
466 + #else
467 + #pragma unroll
468 + for (int i = 0; i < kElemsPerLane; i++) q[i] = __fmul_rz(x[i], sf_rcp);
469 + #endif
470 + } else {
471 + float const sf_scale = __frcp_rn(scale); // exactly 1.0f / scale
472 + float sf_value;
473 + nvfp4GroupScale(sf_scale, group_max, sf_byte, sf_value, sf_rcp);
474 + // A correctly rounded division, not a reciprocal multiply, so that exact
475 + // E2M1 midpoints round to even.
476 + #if MINIMAX_M3_NVFP4_NATIVE_CVT
477 + // nvfp4Quotient on element pairs, with SM100's packed FP32 instructions.
478 + float2 const s2 = make_float2(sf_scale, sf_scale);
479 + float2 const neg_v2 = make_float2(-sf_value, -sf_value);
480 + float2 const r2 = make_float2(sf_rcp, sf_rcp);
481 + #pragma unroll
482 + for (int i = 0; i < kElemsPerLane; i += 2) {
483 + float2 n = __fmul2_rn(make_float2(x[i], x[i + 1]), s2);
484 + // |n| <= 4096 as in nvfp4Quotient, NaN kept.
485 + asm("max.NaN.f32 %0, %0, 0fC5800000;\n\t"
486 + "min.NaN.f32 %0, %0, 0f45800000;\n\t"
487 + "max.NaN.f32 %1, %1, 0fC5800000;\n\t"
488 + "min.NaN.f32 %1, %1, 0f45800000;"
489 + : "+f"(n.x), "+f"(n.y));
490 + float2 const q0 = __fmul2_rn(n, r2);
491 + float2 const p = __ffma2_rn(__ffma2_rn(q0, neg_v2, n), r2, q0);
492 + q[i] = p.x;
493 + q[i + 1] = p.y;
494 + }
495 + #else
496 + #pragma unroll
497 + for (int i = 0; i < kElemsPerLane; i++) {
498 + q[i] = nvfp4Quotient(x[i], sf_scale, sf_value, sf_rcp);
499 + }
500 + #endif
501 + }
502 + uint16_t packed;
503 + #if MINIMAX_M3_NVFP4_NATIVE_CVT
504 + // Low nibble = even element; cvt packs its first source in the high nibble.
505 + asm("{\n"
506 + " .reg .b8 lo, hi;\n"
507 + " cvt.rn.satfinite.e2m1x2.f32 lo, %2, %1;\n"
508 + " cvt.rn.satfinite.e2m1x2.f32 hi, %4, %3;\n"
509 + " mov.b16 %0, {lo, hi};\n"
510 + "}"
511 + : "=h"(packed)
512 + : "f"(q[0]), "f"(q[1]), "f"(q[2]), "f"(q[3]));
513 + #else
514 + uint32_t codes = 0;
515 + #pragma unroll
516 + for (int i = 0; i < kElemsPerLane; i++) codes |= e2m1Code(q[i]) << (4 * i);
517 + packed = static_cast<uint16_t>(codes);
518 + #endif
519 + packed = clearNegZeroNibbles(packed);
520 +
521 + uint8_t* const slot = page + static_cast<int64_t>(2 * head + kv) *
522 + block_size *
523 + (kNvfp4DataBytes + kNvfp4ScaleBytes);
524 + uint8_t* const data =
525 + slot + token * kNvfp4DataBytes + laneId * (kElemsPerLane / 2);
526 + *reinterpret_cast<uint16_t*>(data) = packed;
527 +
528 + if (laneId % kNvfp4LanesPerGroup == 0) {
529 + int const group = laneId / kNvfp4LanesPerGroup;
530 + int offset;
531 + if (kv == 0) {
532 + offset = token * kNvfp4ScaleBytes + group;
533 + } else {
534 + constexpr int kGroupsPerQuad = kNvfp4ScaleBytes / 4;
535 + int const swizzled_t = (token / 4) * 4 + group / kGroupsPerQuad;
536 + int const swizzled_s = (group % kGroupsPerQuad) * 4 + token % 4;
537 + offset = swizzled_t * kNvfp4ScaleBytes + swizzled_s;
538 + }
539 + slot[block_size * kNvfp4DataBytes + offset] = sf_byte;
540 + }
541 +}
542 +#endif // USE_ROCM
543 +
544 // ────────────────────────────────────────────────────────────────────────────
545 // Kernel
546 // ────────────────────────────────────────────────────────────────────────────
@@ -307,9 +563,8 @@ __device__ __forceinline__ void storeScaledQElemsFp8(
563 // branch and writes index_q/index_k outputs. Skip-index-topk reuse layers keep
564 // kHasIndex=true but set kProcessIndex=false.
565 template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt,
310 - typename out_idx_t, bool kHasIndex, bool kInsertKV,
311 - bool kProcessIndex,
312 - bool kFp8Idx>
566 + bool kNvfp4, typename out_idx_t, bool kHasIndex, bool kInsertKV,
567 + bool kProcessIndex, bool kFp8Idx>
568 __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
569 scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
570 scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
@@ -332,7 +587,10 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
587 // The content (last) dim is always innermost-contiguous (stride 1), so the
588 // NHD/HND layout choice is captured by the head/token strides.
589 int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
335 - int64_t const kv_s_dim) {
590 + int64_t const kv_s_dim,
591 + // NVFP4 main cache only: per-layer K/V dequantization (global) scales.
592 + float const* __restrict__ kv_k_scale,
593 + float const* __restrict__ kv_v_scale) {
594 #if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM)
595 // _typeConvert<BFloat16> is unavailable on pre-Ampere; the M3 kernel only
596 // runs with bf16/fp16 inputs in practice. Discard the bf16 body there.
@@ -352,8 +610,11 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
610 int const idx_slots = kProcessIndex ? niq + 1 : 0;
611 int const slots_per_token = nq + nkv + v_slots + idx_slots;
612
355 - int const tokenIdx = globalWarpIdx / slots_per_token;
356 - int const slot = globalWarpIdx % slots_per_token;
613 + // Unsigned: both are non-negative, and the signed division's sign handling
614 + // would cost every warp.
615 + unsigned const warp_u = globalWarpIdx, slots_u = slots_per_token;
616 + int const tokenIdx = warp_u / slots_u;
617 + int const slot = warp_u % slots_u;
618 if (tokenIdx >= num_tokens) return;
619
620 // Slot boundaries.
@@ -420,10 +681,13 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
681 // flat TMA descriptor can address them as [tokens*heads, head_dim]; this
682 // folds the de-interleaving into the store the kernel already does, instead
683 // of a separate q.contiguous() copy. Everything else stays in place.
684 + // Given q_fp8_out but no q_out, q is emitted only in fp8 (null store_ptr).
685 scalar_t* store_ptr = row_ptr;
686 if (isQ && q_out != nullptr) {
687 store_ptr = q_out + static_cast<int64_t>(tokenIdx) * nq * kHeadDim +
688 slot * kHeadDim;
689 + } else if (isQ && q_fp8_out != nullptr) {
690 + store_ptr = nullptr;
691 } else if (isIQ && index_q_out != nullptr) {
692 // bf16 index_q_out: gather here. fp8: written by the explicit fp8 store.
693 if constexpr (!kFp8Idx) {
@@ -433,6 +697,13 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
697 }
698 }
699
700 + // NVFP4: the per-layer global scale is not produced by the predecessor,
701 + // so load it before the PDL wait.
702 + float nvfp4_scale = 0.0f;
703 + if constexpr (kNvfp4) {
704 + if (isK || isV) nvfp4_scale = isK ? *kv_k_scale : *kv_v_scale;
705 + }
706 +
707 // PDL: wait for the predecessor kernel (the qkv-projection GEMM that
708 // produces ``qkv``) to finish before touching any global memory. No-op
709 // when PDL is not enabled on the launch. The CUDA runtime wrapper emits
@@ -451,16 +722,17 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
722 normAndRope<scalar_t>(elems, laneId, eps, norm_w, do_rope, rotary_dim,
723 cos_ptr, /*apply_norm=*/norm_w != nullptr);
724 if constexpr (kFp8Idx) {
454 - // index_q is e4m3 bytes; Q/K (and in-place index_k) stay scalar_t.
725 + // index_q is E4M3(scalar_t(q)) bytes; Q/K (and in-place index_k) stay
726 + // scalar_t.
727 if (isIQ && index_q_out != nullptr) {
456 - storeElemsFp8(index_q_out +
457 - static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
458 - (slot - iq_begin) * kHeadDim + dim_base,
459 - elems);
460 - } else {
728 + storeIndexQElemsFp8<scalar_t>(
729 + index_q_out + static_cast<int64_t>(tokenIdx) * niq * kHeadDim +
730 + (slot - iq_begin) * kHeadDim + dim_base,
731 + elems);
732 + } else if (store_ptr != nullptr) {
733 storeElems<scalar_t>(store_ptr + dim_base, elems);
734 }
463 - } else {
735 + } else if (store_ptr != nullptr) {
736 storeElems<scalar_t>(store_ptr + dim_base, elems);
737 }
738 if (isQ && q_fp8_out != nullptr) {
@@ -487,6 +759,16 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
759 } else {
760 storeElems<scalar_t>(index_cache + sm * kHeadDim + dim_base, elems);
761 }
762 + } else if constexpr (kNvfp4) {
763 +#ifndef USE_ROCM
764 + // kv_cache is [num_blocks, 2*nkv, block_size, 72] bytes (HND), slot
765 + // 2*head + side; kv_s_block is its page stride.
766 + storeNvfp4CacheElems<scalar_t>(
767 + reinterpret_cast<uint8_t*>(kv_cache) +
768 + (sm / block_size) * kv_s_block,
769 + isK ? 0 : 1, head, static_cast<int>(sm % block_size), block_size,
770 + laneId, elems, nvfp4_scale);
771 +#endif
772 } else if (isK || isV) {
773 // kv_cache logical shape [num_blocks, nkv, block_size, 2*head_dim].
774 // Paging is logical (block = sm/block_size, token = sm%block_size);
@@ -515,7 +797,8 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
797 // ────────────────────────────────────────────────────────────────────────────
798 // Launch wrapper
799 // ────────────────────────────────────────────────────────────────────────────
518 -template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt>
800 +template <typename scalar_t, typename cache_t, Fp8KVCacheDataType kv_dt,
801 + bool kNvfp4 = false>
802 void launchFusedMiniMaxM3(
803 scalar_t* qkv, scalar_t* q_out, uint8_t* q_fp8_out, void* index_q_out,
804 scalar_t const* q_norm_w, scalar_t const* k_norm_w,
@@ -527,7 +810,8 @@ void launchFusedMiniMaxM3(
810 int const nq, int const nkv, int const niq, int const block_size,
811 int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
812 int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
530 - bool const process_index, bool const fp8_idx, cudaStream_t stream) {
813 + bool const process_index, bool const fp8_idx, float const* kv_k_scale,
814 + float const* kv_v_scale, cudaStream_t stream) {
815 // Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
816 // void* pointers per instantiation in the LAUNCH macro.
817 // Slot count must match the kernel's compile-time gating.
@@ -559,35 +843,44 @@ void launchFusedMiniMaxM3(
843 config.attrs = attrs;
844 config.numAttrs = (sm_version >= 90) ? 1 : 0;
845
562 - #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
563 - cudaLaunchKernelEx( \
564 - &config, \
565 - fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
566 - HAS_INDEX, INSERT, \
567 - PROCESS_INDEX, FP8>, \
568 - qkv, q_out, q_fp8_out, reinterpret_cast<OUT_T*>(index_q_out), \
569 - q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
570 - slot_mapping, index_slot_mapping, kv_cache, \
571 - reinterpret_cast<OUT_T*>(index_cache), eps, q_fp8_inv_scale, \
572 - rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
573 - kv_s_head, kv_s_token, kv_s_dim)
846 + #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
847 + cudaLaunchKernelEx( \
848 + &config, \
849 + fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, \
850 + kNvfp4, OUT_T, HAS_INDEX, \
851 + INSERT, PROCESS_INDEX, FP8>, \
852 + qkv, q_out, q_fp8_out, reinterpret_cast<OUT_T*>(index_q_out), \
853 + q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
854 + slot_mapping, index_slot_mapping, kv_cache, \
855 + reinterpret_cast<OUT_T*>(index_cache), eps, q_fp8_inv_scale, \
856 + rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
857 + kv_s_head, kv_s_token, kv_s_dim, kv_k_scale, kv_v_scale)
858 #else
859 // ROCm: standard kernel launch syntax (no PDL/stream serialization).
860 // clang-format off
861 #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
862 fusedMiniMaxM3QNormRopeKVInsertKernel< \
579 - scalar_t, cache_t, kv_dt, OUT_T, HAS_INDEX, INSERT, PROCESS_INDEX, \
580 - FP8><<<grid, kBlockSize, 0, stream>>>( \
863 + scalar_t, cache_t, kv_dt, kNvfp4, OUT_T, HAS_INDEX, INSERT, \
864 + PROCESS_INDEX, FP8><<<grid, kBlockSize, 0, stream>>>( \
865 qkv, q_out, q_fp8_out, reinterpret_cast<OUT_T*>(index_q_out), \
866 q_norm_w, k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
867 slot_mapping, index_slot_mapping, kv_cache, \
868 reinterpret_cast<OUT_T*>(index_cache), eps, q_fp8_inv_scale, \
869 rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
586 - kv_s_head, kv_s_token, kv_s_dim)
870 + kv_s_head, kv_s_token, kv_s_dim, kv_k_scale, kv_v_scale)
871 // clang-format on
872 #endif
873
590 - if (has_index) {
874 + if constexpr (kNvfp4) {
875 + // NVFP4 main cache: sparse serving only (the host checks insert_kv).
876 + if (!process_index) {
877 + LAUNCH(true, true, false, false, scalar_t);
878 + } else if (fp8_idx) {
879 + LAUNCH(true, true, true, true, uint8_t);
880 + } else {
881 + LAUNCH(true, true, true, false, scalar_t);
882 + }
883 + } else if (has_index) {
884 if (!process_index) {
885 if (insert_kv) {
886 LAUNCH(true, true, false, false, scalar_t);
@@ -621,8 +914,9 @@ void launchFusedMiniMaxM3(
914 } // namespace vllm
915
916 // clang-format off
624 -#define CALL_FUSED_MINIMAX_M3(_RAW_T, CACHE_T, KV_DTYPE) \
625 - vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st, CACHE_T, KV_DTYPE>( \
917 +#define CALL_FUSED_MINIMAX_M3_IMPL(CACHE_T, KV_DTYPE, NVFP4) \
918 + vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3<st, CACHE_T, KV_DTYPE, \
919 + NVFP4>( \
920 reinterpret_cast<st*>(qkv.data_ptr()), \
921 q_out.has_value() ? reinterpret_cast<st*>(q_out->data_ptr()) : nullptr, \
922 q_fp8_out.has_value() \
@@ -654,7 +948,10 @@ void launchFusedMiniMaxM3(
948 static_cast<float>(eps), 1.0f / static_cast<float>(q_fp8_scale), \
949 static_cast<int>(rotary_dim), num_tokens, nq, nkv, niq, \
950 static_cast<int>(block_size), kv_s_block, kv_s_head, kv_s_token, \
657 - kv_s_dim, has_index, insert_kv, process_index, fp8_idx, stream)
951 + kv_s_dim, has_index, insert_kv, process_index, fp8_idx, kv_k_scale_ptr, \
952 + kv_v_scale_ptr, stream)
953 +#define CALL_FUSED_MINIMAX_M3(_RAW_T, CACHE_T, KV_DTYPE) \
954 + CALL_FUSED_MINIMAX_M3_IMPL(CACHE_T, KV_DTYPE, false)
955 // clang-format on
956
957 // ────────────────────────────────────────────────────────────────────────────
@@ -681,7 +978,9 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
978 const std::string& kv_cache_dtype, bool skip_index_branch,
979 std::optional<torch::stable::Tensor>
980 q_fp8_out, // [N, nq*128] contiguous E4M3
684 - double q_fp8_scale) {
981 + double q_fp8_scale,
982 + std::optional<torch::stable::Tensor> kv_k_scale, // [] f32, NVFP4 only
983 + std::optional<torch::stable::Tensor> kv_v_scale) {
984 STD_TORCH_CHECK(qkv.is_cuda() && qkv.is_contiguous(),
985 "qkv must be contiguous CUDA");
986 STD_TORCH_CHECK(
@@ -721,8 +1020,10 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
1020 bool const has_index = niq > 0;
1021 bool const insert_kv = kv_cache.has_value();
1022 bool const process_index = has_index && !skip_index_branch;
1023 + bool const nvfp4_kv = kv_cache_dtype == "nvfp4";
1024 vllm::Fp8KVCacheDataType const kv_dt =
725 - vllm::get_fp8_kv_cache_data_type(kv_cache_dtype);
1025 + nvfp4_kv ? vllm::Fp8KVCacheDataType::kAuto
1026 + : vllm::get_fp8_kv_cache_data_type(kv_cache_dtype);
1027 int const kHeadDim = vllm::minimax_m3_fused_ops::kHeadDim;
1028 int const expected_row =
1029 (nq + 2 * nkv + (has_index ? niq + 1 : 0)) * kHeadDim;
@@ -769,8 +1070,13 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
1070 index_slot_mapping->numel() == slot_mapping->numel()),
1071 "index_slot_mapping must be int64 CUDA with slot_mapping length");
1072 }
772 - // Main attention KV cache: auto matches qkv, fp8 uses uint8 storage.
773 - if (kv_dt == vllm::Fp8KVCacheDataType::kAuto) {
1073 + // Main attention KV cache: auto matches qkv, fp8 and nvfp4 use uint8
1074 + // storage.
1075 + if (nvfp4_kv) {
1076 + STD_TORCH_CHECK(
1077 + kv_cache->scalar_type() == torch::headeronly::ScalarType::Byte,
1078 + "nvfp4 kv_cache must use uint8 storage");
1079 + } else if (kv_dt == vllm::Fp8KVCacheDataType::kAuto) {
1080 STD_TORCH_CHECK(kv_cache->scalar_type() == qkv.scalar_type(),
1081 "auto kv_cache dtype must match qkv");
1082 } else {
@@ -790,6 +1096,27 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
1096 STD_TORCH_CHECK(kv_cache->dim() == 4 && kv_cache->stride(3) == 1,
1097 "kv_cache must be [nb,nkv,bs,2*head_dim] with contiguous "
1098 "content dim (stride(3)==1)");
1099 + if (nvfp4_kv) {
1100 + // Packed HND pages [nb, 2*nkv, bs, 72]: slot 2*head + side is one
1101 + // contiguous [bs, 72] block, which the store addresses directly.
1102 + int64_t const full_dim = kHeadDim / 2 + kHeadDim / 16;
1103 + STD_TORCH_CHECK(
1104 + kv_cache->size(1) == 2 * nkv && kv_cache->size(2) == block_size &&
1105 + kv_cache->size(3) == full_dim &&
1106 + kv_cache->stride(2) == full_dim &&
1107 + kv_cache->stride(1) == block_size * full_dim &&
1108 + block_size % 4 == 0,
1109 + "nvfp4 kv_cache must be HND [nb, 2*num_kv_heads, block_size, 72] "
1110 + "with contiguous pages and block_size % 4 == 0");
1111 + for (auto const* scale : {&kv_k_scale, &kv_v_scale}) {
1112 + STD_TORCH_CHECK(
1113 + scale->has_value() && (*scale)->is_cuda() &&
1114 + (*scale)->scalar_type() ==
1115 + torch::headeronly::ScalarType::Float &&
1116 + (*scale)->numel() >= 1,
1117 + "nvfp4 kv_cache requires float32 CUDA kv_k_scale and kv_v_scale");
1118 + }
1119 + }
1120 kv_s_block = kv_cache->stride(0);
1121 kv_s_head = kv_cache->stride(1);
1122 kv_s_token = kv_cache->stride(2);
@@ -854,10 +1181,31 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
1181 "fp8 index path: index_q_out must be fp8 e4m3");
1182 }
1183
1184 + STD_TORCH_CHECK(!nvfp4_kv || insert_kv,
1185 + "nvfp4 kv_cache_dtype requires kv_cache (insert mode)");
1186 + float const* kv_k_scale_ptr =
1187 + nvfp4_kv ? kv_k_scale->const_data_ptr<float>() : nullptr;
1188 + float const* kv_v_scale_ptr =
1189 + nvfp4_kv ? kv_v_scale->const_data_ptr<float>() : nullptr;
1190 +
1191 const torch::stable::accelerator::DeviceGuard device_guard(
1192 qkv.get_device_index());
1193 auto stream = get_current_cuda_stream(qkv.get_device_index());
1194
1195 + if (nvfp4_kv) {
1196 +#ifndef USE_ROCM
1197 + VLLM_STABLE_DISPATCH_HALF_TYPES(
1198 + qkv.scalar_type(), "fused_minimax_m3_qknorm_rope_kv_insert_nvfp4", [&] {
1199 + using st = scalar_t;
1200 + CALL_FUSED_MINIMAX_M3_IMPL(uint8_t, vllm::Fp8KVCacheDataType::kAuto,
1201 + true);
1202 + });
1203 +#else
1204 + STD_TORCH_CHECK(false, "nvfp4 kv_cache is not supported on ROCm");
1205 +#endif
1206 + return;
1207 + }
1208 +
1209 VLLM_STABLE_DISPATCH_HALF_TYPES(
1210 qkv.scalar_type(), "fused_minimax_m3_qknorm_rope_kv_insert", [&] {
1211 using st = scalar_t;
@@ -867,3 +1215,4 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
1215 }
1216
1217 #undef CALL_FUSED_MINIMAX_M3
1218 +#undef CALL_FUSED_MINIMAX_M3_IMPL
csrc/libtorch_stable/ops.h
+3 -1
@@ -381,7 +381,9 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
381 std::optional<torch::stable::Tensor> q_out,
382 std::optional<torch::stable::Tensor> index_q_out,
383 const std::string& kv_cache_dtype, bool skip_index_branch,
384 - std::optional<torch::stable::Tensor> q_fp8_out, double q_fp8_scale);
384 + std::optional<torch::stable::Tensor> q_fp8_out, double q_fp8_scale,
385 + std::optional<torch::stable::Tensor> kv_k_scale,
386 + std::optional<torch::stable::Tensor> kv_v_scale);
387
388 #ifdef VLLM_ENABLE_FUSED_KDA_DECODE
389 void fused_kda_decode(
csrc/libtorch_stable/torch_bindings.cpp
+2 -1
@@ -513,7 +513,8 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
513 "Tensor!? kv_cache, Tensor!? index_cache, "
514 "int block_size, Tensor!? q_out, Tensor!? index_q_out, "
515 "str kv_cache_dtype, bool skip_index_branch=False, "
516 - "Tensor!? q_fp8_out=None, float q_fp8_scale=1.0) -> ()");
516 + "Tensor!? q_fp8_out=None, float q_fp8_scale=1.0, "
517 + "Tensor? kv_k_scale=None, Tensor? kv_v_scale=None) -> ()");
518
519 #ifdef VLLM_ENABLE_FUSED_KDA_DECODE
520 ops.def(
tests/kernels/attention/test_minimax_m3_msa_cutlass_sparse_decode.py
+441 -1
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Correctness tests for MiniMax M3 CUTLASS sparse decode."""
3 +"""Correctness tests for MiniMax M3 MSA sparse attention (CUTLASS decode and
4 +NVFP4 KV cache)."""
5
6 import math
7 from types import SimpleNamespace
@@ -8,6 +9,7 @@ from types import SimpleNamespace
9 import pytest
10 import torch
11
12 +from tests.kernels.quantization.nvfp4_utils import dequant_nvfp4_kv_cache
13 from vllm import _custom_ops as ops
14 from vllm.config import AttentionConfig, CUDAGraphMode
15 from vllm.forward_context import ForwardContext, override_forward_context
@@ -18,6 +20,7 @@ from vllm.models.minimax_m3.common.sparse_attention import (
20 MiniMaxM3SparseBackend,
21 MiniMaxM3SparseMetadata,
22 MiniMaxM3SparseMetadataBuilder,
23 + MiniMaxM3SparsePrefillMetadata,
24 MiniMaxM3SparseTritonImpl,
25 select_main_backend_and_impl_cls,
26 )
@@ -28,6 +31,7 @@ from vllm.models.minimax_m3.nvidia.model import MiniMaxM3SparseAttention
31 from vllm.models.minimax_m3.nvidia.msa_cutlass_sparse_decode import (
32 MSACutlassDecodePlanCache,
33 msa_cutlass_sparse_decode,
34 + nvfp4_kv_cache_views,
35 prepare_decode_metadata,
36 should_prepare_decode_metadata,
37 )
@@ -36,6 +40,7 @@ from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
40 MiniMaxM3SparseMSADecodeMetadata,
41 MiniMaxM3SparseMSAImpl,
42 MiniMaxM3SparseMSAMetadataBuilder,
43 + MiniMaxM3SparseMSANvfp4Backend,
44 )
45 from vllm.platforms import current_platform
46 from vllm.v1.attention.backends.registry import AttentionBackendEnum
@@ -164,6 +169,53 @@ def test_msa_cutlass_decode_static_dispatch_requires_sm100(
169 )
170
171
172 +@pytest.mark.parametrize("decode_backend", ["cutlass", "triton"])
173 +def test_msa_cutlass_decode_static_dispatch_nvfp4_has_no_fallback(
174 + decode_backend: str,
175 +) -> None:
176 + """NVFP4 has no Triton decode, so it plans CUTLASS for any batch size."""
177 + assert should_prepare_decode_metadata(
178 + 1,
179 + DEFAULT_QUERY_LEN,
180 + decode_backend=decode_backend, # type: ignore[arg-type]
181 + num_q_heads=16,
182 + num_kv_heads=1,
183 + kv_cache_dtype="nvfp4",
184 + page_size=BLOCK_SIZE,
185 + topk_blocks=TOPK,
186 + )
187 +
188 +
189 +def test_nvfp4_selects_msa_nvfp4_backend_and_requires_sm100(
190 + monkeypatch: pytest.MonkeyPatch,
191 +) -> None:
192 + backend_cls, impl_cls = select_main_backend_and_impl_cls(
193 + topk_blocks=TOPK,
194 + kv_cache_dtype="nvfp4",
195 + num_kv_heads=1,
196 + )
197 + assert backend_cls is MiniMaxM3SparseMSANvfp4Backend
198 + assert impl_cls is MiniMaxM3SparseMSAImpl
199 + with pytest.raises(ValueError, match="nvfp4_4over6"):
200 + select_main_backend_and_impl_cls(
201 + topk_blocks=TOPK,
202 + kv_cache_dtype="nvfp4_4over6",
203 + num_kv_heads=1,
204 + )
205 +
206 + monkeypatch.setattr(
207 + current_platform,
208 + "is_device_capability_family",
209 + lambda _: False,
210 + )
211 + with pytest.raises(ValueError, match="NVFP4"):
212 + select_main_backend_and_impl_cls(
213 + topk_blocks=TOPK,
214 + kv_cache_dtype="nvfp4",
215 + num_kv_heads=1,
216 + )
217 +
218 +
219 @pytest.mark.parametrize(
220 ("backend", "expected"),
221 [
@@ -639,3 +691,391 @@ def test_msa_cutlass_decode_matches_triton_with_interleaved_cache(
691 graph.replay()
692 current_platform.synchronize()
693 torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
694 +
695 +
696 +def _make_paged_layout(
697 + seq_lens_list: list[int],
698 +) -> tuple[torch.Tensor, int]:
699 + """Block table over a shuffled physical page pool."""
700 + pages_per_request = [math.ceil(seq_len / BLOCK_SIZE) for seq_len in seq_lens_list]
701 + num_pages = sum(pages_per_request)
702 + block_table = torch.zeros(
703 + len(seq_lens_list), max(pages_per_request), dtype=torch.int32, device="cuda"
704 + )
705 + physical_pages = torch.randperm(num_pages, dtype=torch.int32, device="cuda")
706 + offset = 0
707 + for request, request_pages in enumerate(pages_per_request):
708 + block_table[request, :request_pages] = physical_pages[
709 + offset : offset + request_pages
710 + ]
711 + offset += request_pages
712 + return block_table, num_pages
713 +
714 +
715 +def _make_nvfp4_kv_cache(
716 + num_pages: int,
717 + num_kv_heads: int,
718 + kv_cache_dtype: str,
719 + k_scale: torch.Tensor,
720 + v_scale: torch.Tensor,
721 +) -> tuple[torch.Tensor, torch.Tensor]:
722 + """Write random K/V through the NVFP4 ``reshape_and_cache_flash`` writer,
723 + whose pages hold every head's data then every head's scales per side, and
724 + repack them into head slots.
725 +
726 + Returns the packed HND cache ``[pages, 2 * Hkv, 128, 72]`` and its BF16
727 + dequantization ``[pages, Hkv, 128, 2 * 128]`` for the Triton reference.
728 + """
729 + num_slots = num_pages * BLOCK_SIZE
730 + key = torch.randn(
731 + num_slots, num_kv_heads, HEAD_DIM, dtype=torch.bfloat16, device="cuda"
732 + )
733 + value = torch.randn_like(key)
734 + written = torch.zeros(
735 + num_pages,
736 + 2 * num_kv_heads,
737 + BLOCK_SIZE,
738 + HEAD_DIM // 2 + HEAD_DIM // 16,
739 + dtype=torch.uint8,
740 + device="cuda",
741 + )
742 + k_cache, v_cache = written.transpose(1, 2).split(num_kv_heads, dim=-2)
743 + ops.reshape_and_cache_flash(
744 + key,
745 + value,
746 + k_cache,
747 + v_cache,
748 + torch.arange(num_slots, dtype=torch.int64, device="cuda"),
749 + kv_cache_dtype,
750 + k_scale,
751 + v_scale,
752 + )
753 +
754 + kv_cache = torch.empty_like(written)
755 + head_slot_views = iter(nvfp4_kv_cache_views(kv_cache))
756 + dequantized = []
757 + for side, global_scale, swizzled in (
758 + (written[:, :num_kv_heads], k_scale, False),
759 + (written[:, num_kv_heads:], v_scale, True),
760 + ):
761 + data = side.as_strided(
762 + (num_pages, num_kv_heads, BLOCK_SIZE, HEAD_DIM // 2),
763 + (side.stride(0), BLOCK_SIZE * HEAD_DIM // 2, HEAD_DIM // 2, 1),
764 + )
765 + scale = side.as_strided(
766 + (num_pages, num_kv_heads, BLOCK_SIZE, HEAD_DIM // 16),
767 + (side.stride(0), BLOCK_SIZE * HEAD_DIM // 16, HEAD_DIM // 16, 1),
768 + side.storage_offset() + num_kv_heads * BLOCK_SIZE * HEAD_DIM // 2,
769 + )
770 + next(head_slot_views).copy_(data)
771 + next(head_slot_views).copy_(scale)
772 + dequantized.append(
773 + dequant_nvfp4_kv_cache(
774 + data,
775 + scale,
776 + global_scale.item(),
777 + HEAD_DIM,
778 + BLOCK_SIZE,
779 + swizzled_scales=swizzled,
780 + )
781 + )
782 + return kv_cache, torch.cat(dequantized, dim=-1).to(torch.bfloat16)
783 +
784 +
785 +def test_nvfp4_kv_cache_views_address_head_slots() -> None:
786 + """Slot 2h (K) and 2h + 1 (V) hold head h's data then scales, so each head
787 + is one contiguous run of the page: a TP rank's page is a byte slice."""
788 + num_kv_heads, slot_bytes = 4, BLOCK_SIZE * (HEAD_DIM // 2 + HEAD_DIM // 16)
789 + kv_cache = torch.randint(
790 + 0,
791 + 256,
792 + (3, 2 * num_kv_heads, BLOCK_SIZE, HEAD_DIM // 2 + HEAD_DIM // 16),
793 + dtype=torch.uint8,
794 + )
795 + k_data, k_sf, v_data, v_sf = nvfp4_kv_cache_views(kv_cache)
796 + runs = kv_cache.view(3, num_kv_heads, 2 * slot_bytes)
797 + for head in range(num_kv_heads):
798 + expected = torch.cat(
799 + [v[:, head].flatten(1) for v in (k_data, k_sf, v_data, v_sf)], dim=1
800 + )
801 + assert torch.equal(runs[:, head], expected)
802 +
803 +
804 +@pytest.mark.parametrize(
805 + ("num_q_heads", "num_kv_heads", "num_request_pairs", "query_len", "capture"),
806 + [
807 + pytest.param(64, 4, 1, 1, True, id="tp1-small-batch-query-len-1"),
808 + pytest.param(64, 4, 8, 8, False, id="tp1-query-len-8"),
809 + pytest.param(16, 1, 16, 4, True, id="tp4-query-len-4"),
810 + ],
811 +)
812 +def test_msa_cutlass_decode_nvfp4_matches_triton_on_dequantized_cache(
813 + num_q_heads: int,
814 + num_kv_heads: int,
815 + num_request_pairs: int,
816 + query_len: int,
817 + capture: bool,
818 +) -> None:
819 + """CUTLASS decode reads vLLM's packed NVFP4 pages, including swizzled V
820 + scales and device global scales, as their exact dequantized values."""
821 + torch.manual_seed(0)
822 + seq_lens_list = [257, 513] * num_request_pairs
823 + seq_lens_cpu = torch.tensor(seq_lens_list, dtype=torch.int32)
824 + seq_lens = seq_lens_cpu.cuda()
825 + block_table, num_pages = _make_paged_layout(seq_lens_list)
826 + k_scale = torch.tensor(0.5, dtype=torch.float32, device="cuda")
827 + v_scale = torch.tensor(2.0, dtype=torch.float32, device="cuda")
828 + kv_cache, kv_reference = _make_nvfp4_kv_cache(
829 + num_pages, num_kv_heads, "nvfp4", k_scale, v_scale
830 + )
831 +
832 + num_query_tokens = len(seq_lens_list) * query_len
833 + query = torch.randn(
834 + num_query_tokens, num_q_heads, HEAD_DIM, dtype=torch.bfloat16, device="cuda"
835 + )
836 + query_fp8 = query.to(torch.float8_e4m3fn)
837 + topk = _make_topk(seq_lens_list, num_kv_heads, query_len)
838 + expected = torch.empty_like(query)
839 + minimax_m3_sparse_attn_decode(
840 + query_fp8.to(torch.bfloat16),
841 + kv_reference,
842 + topk.transpose(0, 1),
843 + block_table,
844 + seq_lens,
845 + num_kv_heads,
846 + SM_SCALE,
847 + expected,
848 + query_len,
849 + )
850 +
851 + assert should_prepare_decode_metadata(
852 + len(seq_lens_list),
853 + query_len,
854 + decode_backend="triton",
855 + num_q_heads=num_q_heads,
856 + num_kv_heads=num_kv_heads,
857 + kv_cache_dtype="nvfp4",
858 + page_size=BLOCK_SIZE,
859 + topk_blocks=TOPK,
860 + )
861 + metadata = prepare_decode_metadata(
862 + block_table,
863 + seq_lens,
864 + seq_lens_cpu,
865 + query_len,
866 + num_q_heads=num_q_heads,
867 + num_kv_heads=num_kv_heads,
868 + page_size=BLOCK_SIZE,
869 + topk_blocks=TOPK,
870 + )
871 + actual = torch.zeros_like(query)
872 +
873 + def run() -> None:
874 + msa_cutlass_sparse_decode(
875 + query_fp8,
876 + kv_cache,
877 + topk,
878 + actual,
879 + metadata,
880 + scale=SM_SCALE,
881 + q_scale_float=1.0,
882 + k_scale_float=1.0,
883 + v_scale_float=1.0,
884 + k_scale=k_scale,
885 + v_scale=v_scale,
886 + )
887 +
888 + if capture:
889 + run()
890 + graph = torch.cuda.CUDAGraph()
891 + with torch.cuda.graph(graph):
892 + run()
893 + actual.zero_()
894 + graph.replay()
895 + else:
896 + run()
897 + current_platform.synchronize()
898 + torch.testing.assert_close(actual, expected, atol=0.05, rtol=0.05)
899 +
900 +
901 +@pytest.mark.parametrize(
902 + ("num_q_heads", "num_kv_heads"),
903 + [pytest.param(64, 4, id="tp1"), pytest.param(16, 1, id="tp4")],
904 +)
905 +def test_msa_prefill_nvfp4_matches_triton_on_dequantized_cache(
906 + num_q_heads: int,
907 + num_kv_heads: int,
908 +) -> None:
909 + """MSA prefill reads NVFP4 pages as their exact dequantized values,
910 + including chunked prefill over cached context, and takes q from its fp8
911 + copy (the only one the fused insert writes on this path)."""
912 + torch.manual_seed(0)
913 + q_lens = [300, 128, 77]
914 + seq_lens_list = [300, 640, 1100]
915 + block_table, num_pages = _make_paged_layout(seq_lens_list)
916 + k_scale = torch.tensor(0.5, dtype=torch.float32, device="cuda")
917 + v_scale = torch.tensor(2.0, dtype=torch.float32, device="cuda")
918 + kv_cache, kv_reference = _make_nvfp4_kv_cache(
919 + num_pages, num_kv_heads, "nvfp4", k_scale, v_scale
920 + )
921 +
922 + total_q = sum(q_lens)
923 + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device="cuda")
924 + context_lens = seq_lens - torch.tensor(q_lens, dtype=torch.int32, device="cuda")
925 + cu_seqlens_q = torch.zeros(len(q_lens) + 1, dtype=torch.int32, device="cuda")
926 + cu_seqlens_q[1:] = torch.tensor(q_lens, device="cuda").cumsum(0)
927 + cu_seqlens_k = torch.zeros_like(cu_seqlens_q)
928 + cu_seqlens_k[1:] = seq_lens.cumsum(0)
929 + prefill = MiniMaxM3SparsePrefillMetadata(
930 + cu_seqlens_q=cu_seqlens_q,
931 + cu_seqlens_k=cu_seqlens_k,
932 + seq_lens=seq_lens,
933 + context_lens=context_lens,
934 + block_table=block_table,
935 + max_query_len=max(q_lens),
936 + max_seq_len=max(seq_lens_list),
937 + total_kv_blocks=sum(math.ceil(n / BLOCK_SIZE) for n in seq_lens_list),
938 + )
939 + metadata = MiniMaxM3SparseMetadata(
940 + seq_lens=seq_lens,
941 + max_seq_len=max(seq_lens_list),
942 + slot_mapping=torch.empty(total_q, dtype=torch.int64, device="cuda"),
943 + num_actual_tokens=total_q,
944 + num_decodes=0,
945 + num_decode_tokens=0,
946 + num_prefills=len(q_lens),
947 + num_prefill_tokens=total_q,
948 + prefill=prefill,
949 + )
950 +
951 + # Every causally visible page (<= TOPK per query), ending with the local one.
952 + topk = torch.full(
953 + (total_q, num_kv_heads, TOPK), -1, dtype=torch.int32, device="cuda"
954 + )
955 + token = 0
956 + for q_len, seq_len in zip(q_lens, seq_lens_list):
957 + for local_q in range(q_len):
958 + visible = (seq_len - q_len + local_q) // BLOCK_SIZE + 1
959 + assert visible <= TOPK
960 + topk[token, :, :visible] = torch.arange(visible, device="cuda")
961 + token += 1
962 +
963 + layer_name = "model.layers.0.self_attn.attn"
964 + q_scale = 0.5
965 + layer = SimpleNamespace(
966 + layer_name=layer_name,
967 + topk_indices_buffer=topk,
968 + _q_scale_float=q_scale,
969 + _k_scale=k_scale,
970 + _v_scale=v_scale,
971 + )
972 + query_fp8 = (
973 + torch.randn(
974 + total_q, num_q_heads * HEAD_DIM, dtype=torch.bfloat16, device="cuda"
975 + )
976 + / q_scale
977 + ).to(torch.float8_e4m3fn)
978 + query = query_fp8.to(torch.bfloat16) * q_scale
979 + common = dict(
980 + num_heads=num_q_heads,
981 + head_size=HEAD_DIM,
982 + scale=SM_SCALE,
983 + num_kv_heads=num_kv_heads,
984 + topk_blocks=TOPK,
985 + sparse_block_size=BLOCK_SIZE,
986 + )
987 + forward_context = ForwardContext(
988 + no_compile_layers={},
989 + attn_metadata={layer_name: metadata},
990 + slot_mapping={},
991 + )
992 + with override_forward_context(forward_context):
993 + expected = MiniMaxM3SparseTritonImpl(**common).forward(
994 + layer, query, kv_reference, torch.empty_like(query)
995 + )
996 + actual = MiniMaxM3SparseMSAImpl(**common, kv_cache_dtype="nvfp4").forward(
997 + layer,
998 + torch.full_like(query, float("nan")),
999 + kv_cache,
1000 + torch.zeros_like(query),
1001 + query_fp8=query_fp8,
1002 + )
1003 + current_platform.synchronize()
1004 + torch.testing.assert_close(actual, expected, atol=0.05, rtol=0.05)
1005 +
1006 +
1007 +def test_msa_triton_decode_fallback_reads_dequantized_query_fp8() -> None:
1008 + """With CUTLASS decode on but not planned for the batch, the Triton decode
1009 + fallback attends with the dequantized fp8 q, the only q written."""
1010 + torch.manual_seed(0)
1011 + num_q_heads, num_kv_heads, q_scale = 16, 1, 0.5
1012 + seq_lens_list = [257, 513, 129]
1013 + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device="cuda")
1014 + block_table, num_pages = _make_paged_layout(seq_lens_list)
1015 + kv_cache = (
1016 + torch.randn(
1017 + num_pages,
1018 + num_kv_heads,
1019 + BLOCK_SIZE,
1020 + 2 * HEAD_DIM,
1021 + dtype=torch.bfloat16,
1022 + device="cuda",
1023 + )
1024 + * 0.25
1025 + ).to(torch.float8_e4m3fn)
1026 + num_tokens = len(seq_lens_list)
1027 + metadata = MiniMaxM3SparseMetadata(
1028 + seq_lens=seq_lens,
1029 + max_seq_len=max(seq_lens_list),
1030 + slot_mapping=torch.empty(num_tokens, dtype=torch.int64, device="cuda"),
1031 + num_actual_tokens=num_tokens,
1032 + num_decodes=num_tokens,
1033 + num_decode_tokens=num_tokens,
1034 + num_prefills=0,
1035 + num_prefill_tokens=0,
1036 + decode=MiniMaxM3SparseMSADecodeMetadata(
1037 + seq_lens=seq_lens,
1038 + block_table=block_table,
1039 + decode_query_len=1,
1040 + msa_cutlass=None,
1041 + ),
1042 + )
1043 + layer_name = "model.layers.0.self_attn.attn"
1044 + layer = SimpleNamespace(
1045 + layer_name=layer_name,
1046 + topk_indices_buffer=_make_topk(seq_lens_list, num_kv_heads, 1),
1047 + _q_scale_float=q_scale,
1048 + )
1049 + query_fp8 = torch.randn(
1050 + num_tokens, num_q_heads * HEAD_DIM, dtype=torch.bfloat16, device="cuda"
1051 + ).to(torch.float8_e4m3fn)
1052 + query = query_fp8.to(torch.bfloat16) * q_scale
1053 + common = dict(
1054 + num_heads=num_q_heads,
1055 + head_size=HEAD_DIM,
1056 + scale=SM_SCALE,
1057 + num_kv_heads=num_kv_heads,
1058 + kv_cache_dtype="fp8",
1059 + topk_blocks=TOPK,
1060 + sparse_block_size=BLOCK_SIZE,
1061 + )
1062 + impl = MiniMaxM3SparseMSAImpl(**common, msa_decode_backend="cutlass")
1063 + assert impl.use_cutlass_decode
1064 + forward_context = ForwardContext(
1065 + no_compile_layers={},
1066 + attn_metadata={layer_name: metadata},
1067 + slot_mapping={},
1068 + )
1069 + with override_forward_context(forward_context):
1070 + expected = MiniMaxM3SparseTritonImpl(**common).forward(
1071 + layer, query, kv_cache, torch.empty_like(query)
1072 + )
1073 + actual = impl.forward(
1074 + layer,
1075 + torch.full_like(query, float("nan")),
1076 + kv_cache,
1077 + torch.zeros_like(query),
1078 + query_fp8=query_fp8,
1079 + )
1080 + current_platform.synchronize()
1081 + torch.testing.assert_close(actual, expected, atol=0.02, rtol=0.02)
tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py
+226 -4
@@ -320,6 +320,38 @@ def test_sparse_full(num_tokens, block_size, kv_cache_dtype):
320 index_cache.view(-1, HEAD_DIM), expected_index_cache, rtol=0, atol=0
321 )
322
323 + # Without q_out, q is emitted only in fp8: the same bytes, while qkv keeps
324 + # its raw q and every other output matches the run above.
325 + outputs = [qkv_orig.clone(), torch.empty_like(q_fp8), torch.empty_like(index_q)]
326 + caches = [torch.zeros_like(kv_cache), torch.zeros_like(index_cache)]
327 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
328 + outputs[0],
329 + q_w,
330 + k_w,
331 + cos_sin,
332 + positions,
333 + num_heads,
334 + num_kv_heads,
335 + ROTARY_DIM,
336 + eps,
337 + iq_w,
338 + ik_w,
339 + num_idx_heads,
340 + slot_mapping,
341 + index_slot_mapping,
342 + *caches,
343 + block_size,
344 + None,
345 + outputs[2],
346 + kv_cache_dtype,
347 + q_fp8_out=outputs[1],
348 + q_fp8_scale=0.5,
349 + )
350 + for actual, expected in zip(
351 + outputs + caches, [qkv, q_fp8, index_q, kv_cache, index_cache]
352 + ):
353 + assert torch.equal(actual.view(torch.uint8), expected.view(torch.uint8))
354 +
355
356 @pytest.mark.parametrize("num_tokens", [1, 64, 513])
357 @pytest.mark.parametrize("block_size", [16, 64])
@@ -441,8 +473,8 @@ def test_sparse_skip_index_branch(num_tokens, block_size, kv_cache_dtype):
473 # ── Test 4: fp8 (e4m3) index outputs ─────────────────────────────────────────
474 # The fp8 score path stores index_q and the index-K cache as e4m3 while q/k/v +
475 # q_out stay bf16. Asserts: (1) q/k/v/q_out are bit-identical to the bf16 run
444 -# (the index dtype must not perturb the main branch), and (2) the e4m3 index
445 -# outputs dequantize close to the bf16 reference.
476 +# (the index dtype must not perturb the main branch), and (2) index_q is the
477 +# e4m3 cast of the bf16 run's index_q, and the index-K cache is within fp8 ulp.
478
479
480 @pytest.mark.skipif(
@@ -528,6 +560,196 @@ def test_sparse_full_fp8_index(num_tokens, block_size):
560 torch.testing.assert_close(qkv_fp, qkv_bf, rtol=0, atol=0)
561 torch.testing.assert_close(kvc_fp, kvc_bf, rtol=0, atol=0)
562
531 - # (2) Dequantized e4m3 index outputs match the bf16 reference within fp8 ulp.
532 - torch.testing.assert_close(iq_fp.float(), iq_bf.float(), rtol=0.13, atol=0.05)
563 + # (2) index_q is e4m3(bf16(q)); the index-K cache is within fp8 ulp.
564 + assert torch.equal(
565 + iq_fp.view(torch.uint8), iq_bf.to(torch.float8_e4m3fn).view(torch.uint8)
566 + )
567 torch.testing.assert_close(idxc_fp.float(), idxc_bf.float(), rtol=0.13, atol=0.05)
568 +
569 +
570 +# ── Test 5: sparse mode with an NVFP4 main KV cache ──────────────────────────
571 +# The NVFP4 inserts quantize x * sf_scale, where sf_scale = 1 / the global
572 +# scale: per 16-value block,
573 +# scale = min(448, max(E4M3(max(amax, 1e-12) / 6), 1/512)) and
574 +# code = sign | RNE E2M1 of min(|x / scale|, 6), with zero stored as +0.
575 +
576 +E2M1_MIDPOINTS = (0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0)
577 +
578 +
579 +def _div(a, b):
580 + # Division by a Python scalar would become a reciprocal multiply.
581 + return a / torch.as_tensor(b, dtype=a.dtype, device=a.device)
582 +
583 +
584 +def nvfp4_ref(x, sf_scale):
585 + """Returns the E2M1 codes [..., 128] and E4M3 scale bytes [..., 8] of x."""
586 + blocks = x.float().unflatten(-1, (-1, 16))
587 + amax = blocks.abs().amax(-1, keepdim=True).clamp_min(1e-12)
588 + scale = (sf_scale * _div(amax, 6.0)).clamp(max=448.0)
589 + scale = scale.to(torch.float8_e4m3fn).float().clamp(min=2.0**-9)
590 + q = _div(blocks * sf_scale, scale)
591 + # Alternating > / >= rounds exact midpoints to the even code.
592 + mag = sum(
593 + (q.abs() >= m) if i % 2 else (q.abs() > m) for i, m in enumerate(E2M1_MIDPOINTS)
594 + ).to(torch.uint8)
595 + codes = torch.where(mag > 0, mag | (torch.signbit(q).to(torch.uint8) << 3), 0)
596 + scale_bytes = scale.squeeze(-1).to(torch.float8_e4m3fn).view(torch.uint8)
597 + return codes.flatten(-2), scale_bytes
598 +
599 +
600 +def nvfp4_pages(sides, slot_mapping, num_blocks, block_size):
601 + """Expected NVFP4 pages [nb, len(sides) * nkv, bs, 72] for the K (then V)
602 + side given as (x [N, nkv, 128], sf_scale). Slot h * len(sides) + side is
603 + head h's packed E2M1 data [bs, 64] (low nibble = even element), then its
604 + E4M3 scales [bs, 8]: linear for K, 4x4 token-quad swizzled for V."""
605 + nkv, ns, device = sides[0][0].shape[1], HEAD_DIM // 16, slot_mapping.device
606 + row_bytes, data_bytes = HEAD_DIM // 2 + ns, block_size * HEAD_DIM // 2
607 + pages = torch.zeros(
608 + num_blocks,
609 + nkv,
610 + len(sides),
611 + block_size * row_bytes,
612 + dtype=torch.uint8,
613 + device=device,
614 + )
615 + valid = slot_mapping >= 0
616 + blk, tok = slot_mapping[valid] // block_size, slot_mapping[valid] % block_size
617 + g, t = torch.arange(ns, device=device), tok[:, None]
618 + scale_offsets = (t * ns + g, ((t // 4) * 4 + g // 2) * ns + (g % 2) * 4 + t % 4)
619 + for side, (x, sf_scale) in enumerate(sides):
620 + codes, scales = nvfp4_ref(x[valid], sf_scale)
621 + data = pages[:, :, side, :data_bytes].view(num_blocks, nkv, block_size, -1)
622 + data[blk, :, tok] = codes[..., 0::2] | (codes[..., 1::2] << 4)
623 + scale_region = pages[:, :, side, data_bytes:]
624 + for h in range(nkv):
625 + scale_region[blk[:, None], h, scale_offsets[side]] = scales[:, h]
626 + return pages.view(num_blocks, -1, block_size, row_bytes)
627 +
628 +
629 +def nvfp4_edge_values(num_rows, global_scale):
630 + """[num_rows, 128] bf16 whose 16-value blocks, divided by global_scale, hit
631 + the quantizer's corner cases: exact E2M1 midpoints under non-power-of-2 scales
632 + (blocks 0-1), amax under the 1/512 scale floor (2), all -0 (3), and
633 + saturation with |x| > 4096 up to bf16 max (4) or with inf (5)."""
634 + g = torch.Generator().manual_seed(0)
635 + x = torch.randn(num_rows, 8, 16, generator=g)
636 + sign = torch.where(torch.rand(x.shape, generator=g) < 0.5, -1.0, 1.0)
637 + steps = torch.tensor(E2M1_MIDPOINTS + (0.5, 1.0, 3.0))
638 + mant = 1 + torch.randint(1, 8, (num_rows, 2, 1), generator=g) / 8
639 + scale = mant * 2.0 ** torch.randint(-6, 3, (num_rows, 2, 1), generator=g)
640 + x[:, :2] = steps[torch.randint(len(steps), (num_rows, 2, 16), generator=g)] * scale
641 + x[:, :2, :1] = 6 * scale # amax = 6 * scale, so the block scale is `scale`
642 + x[:, 2] = torch.rand(num_rows, 16, generator=g) * 2.0**-8
643 + x[:, 4] *= 5000
644 + x = x * sign * global_scale
645 + x[:, 3] = -0.0
646 + x[:, 4, 0] = sign[:, 4, 0] * torch.finfo(torch.bfloat16).max
647 + x[:, 5, 0] = sign[:, 5, 0] * float("inf")
648 + return x.flatten(1).to(torch.bfloat16)
649 +
650 +
651 +@pytest.mark.skipif(
652 + not torch.cuda.is_available() or torch.cuda.get_device_capability()[0] != 10,
653 + reason="NVFP4 KV caches need SM100",
654 +)
655 +@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513])
656 +@pytest.mark.parametrize("num_kv_heads", [1, 4])
657 +@pytest.mark.parametrize("block_size", [16, 128])
658 +@pytest.mark.parametrize("kv_scales", [(0.5, 2.0), (1.0, 1.0)])
659 +def test_sparse_full_nvfp4(num_tokens, num_kv_heads, block_size, kv_scales):
660 + """The fused NVFP4 insert writes the reference quantizer's bytes (packed
661 + E2M1, linear K / swizzled V E4M3 block scales) for the kernel's own
662 + post-RoPE k and for a raw v that hits the quantizer's corner cases, and
663 + skips padded slots. Unit global scales take the kernel's separate fast
664 + path."""
665 + kv_cache_dtype = "nvfp4"
666 + torch.manual_seed(5)
667 + device, dtype, eps = "cuda", torch.bfloat16, 1e-6
668 + base, max_pos = 5_000_000.0, 4096
669 + num_heads, num_idx_heads = 4 * num_kv_heads, 1
670 +
671 + weights = [
672 + torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 for _ in range(4)
673 + ]
674 + cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device)
675 + positions = torch.randint(
676 + 0, max_pos, (num_tokens,), dtype=torch.int64, device=device
677 + )
678 + qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM
679 + iqsz = num_idx_heads * HEAD_DIM
680 + qkv = torch.randn(
681 + num_tokens, qsz + 2 * kvsz + iqsz + HEAD_DIM, dtype=dtype, device=device
682 + )
683 + k_scale, v_scale = (
684 + torch.tensor(s, dtype=torch.float32, device=device) for s in kv_scales
685 + )
686 + qkv[:, qsz + kvsz : qsz + 2 * kvsz] = nvfp4_edge_values(
687 + num_tokens * num_kv_heads, v_scale.item()
688 + ).view(num_tokens, kvsz)
689 + qkv_orig = qkv.clone()
690 +
691 + num_blocks = (num_tokens + block_size - 1) // block_size + 1
692 + kv_cache = torch.zeros(
693 + num_blocks,
694 + 2 * num_kv_heads,
695 + block_size,
696 + HEAD_DIM // 2 + HEAD_DIM // 16,
697 + dtype=torch.uint8,
698 + device=device,
699 + )
700 + index_cache = torch.zeros(
701 + num_blocks, block_size, HEAD_DIM, dtype=dtype, device=device
702 + )
703 + slot_mapping = torch.randperm(
704 + num_blocks * block_size, dtype=torch.int64, device=device
705 + )[:num_tokens]
706 + slot_mapping[::5] = -1 # padded / unscheduled tokens are not written
707 + index_slot_mapping = torch.randperm(
708 + num_blocks * block_size, dtype=torch.int64, device=device
709 + )[:num_tokens]
710 +
711 + ops.fused_minimax_m3_qknorm_rope_kv_insert(
712 + qkv,
713 + *weights[:2],
714 + cos_sin,
715 + positions,
716 + num_heads,
717 + num_kv_heads,
718 + ROTARY_DIM,
719 + eps,
720 + *weights[2:],
721 + num_idx_heads,
722 + slot_mapping,
723 + index_slot_mapping,
724 + kv_cache,
725 + index_cache,
726 + block_size,
727 + torch.empty(num_tokens, qsz, dtype=dtype, device=device),
728 + torch.empty(num_tokens, iqsz, dtype=dtype, device=device),
729 + kv_cache_dtype,
730 + kv_k_scale=k_scale,
731 + kv_v_scale=v_scale,
732 + )
733 +
734 + _, k_out, v_out, _, index_k = qkv.split([qsz, kvsz, kvsz, iqsz, HEAD_DIM], dim=-1)
735 + torch.testing.assert_close(
736 + v_out, qkv_orig.split([qsz, kvsz, kvsz, iqsz, HEAD_DIM], dim=-1)[2]
737 + )
738 + expected_kv_cache = nvfp4_pages(
739 + [
740 + (k_out.reshape(num_tokens, num_kv_heads, HEAD_DIM), 1 / k_scale),
741 + (v_out.reshape(num_tokens, num_kv_heads, HEAD_DIM), 1 / v_scale),
742 + ],
743 + slot_mapping,
744 + num_blocks,
745 + block_size,
746 + )
747 + assert torch.equal(kv_cache, expected_kv_cache), (
748 + f"{int((kv_cache != expected_kv_cache).sum())} NVFP4 cache bytes differ"
749 + )
750 +
751 + expected_index_cache = torch.zeros_like(index_cache).view(-1, HEAD_DIM)
752 + expected_index_cache[index_slot_mapping] = index_k
753 + torch.testing.assert_close(
754 + index_cache.view(-1, HEAD_DIM), expected_index_cache, rtol=0, atol=0
755 + )
vllm/_custom_ops.py
+12 -1
@@ -2587,6 +2587,8 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2587 skip_index_branch: bool = False,
2588 q_fp8_out: torch.Tensor | None = None,
2589 q_fp8_scale: float = 1.0,
2590 + kv_k_scale: torch.Tensor | None = None,
2591 + kv_v_scale: torch.Tensor | None = None,
2592 ) -> None:
2593 """Fused MiniMax-M3 attention pre-processing (in-place).
2594
@@ -2610,7 +2612,14 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2612 attention's flat TMA descriptor.
2613
2614 If ``q_fp8_out`` is given, the same normalized q is also written in FP8
2613 - E4M3 using ``q_fp8_scale`` as its dequantization scale.
2615 + E4M3 using ``q_fp8_scale`` as its dequantization scale. Without ``q_out``,
2616 + q is then written only in FP8 and the q slice of ``qkv`` is left as is.
2617 +
2618 + ``kv_cache_dtype="nvfp4"`` quantizes k/v into the packed HND
2619 + ``[num_blocks, 2 * num_kv_heads, block_size, 72]`` uint8 cache using the
2620 + device dequantization scales ``kv_k_scale``/``kv_v_scale``. Slot
2621 + ``2 * head + side`` (K = 0, V = 1) holds that head's E2M1 data followed by
2622 + its E4M3 block scales, so every head is one contiguous run of the page.
2623
2624 When ``skip_index_branch`` is true, sparse rows still keep their packed
2625 ``[index_q | index_k]`` tail, but the kernel only processes the main q/k/v
@@ -2641,6 +2650,8 @@ def fused_minimax_m3_qknorm_rope_kv_insert(
2650 skip_index_branch,
2651 q_fp8_out,
2652 q_fp8_scale,
2653 + kv_k_scale,
2654 + kv_v_scale,
2655 )
2656
2657
vllm/models/minimax_m3/common/sparse_attention.py
+19 -3
@@ -56,6 +56,7 @@ from vllm.v1.attention.backends.utils import (
56 from vllm.v1.kv_cache_interface import (
57 AttentionSpec,
58 KVCacheLayout,
59 + get_kv_quant_mode,
60 is_quantized_kv_cache,
61 )
62
@@ -531,10 +532,10 @@ def select_main_backend_and_impl_cls(
532 """Pick the main attention backend and implementation.
533
534 Blackwell (SM100) uses the MSA attend for supported top-k block counts
534 - when the KV cache is BF16 or FP8 E4M3; MI355 uses AITER sparse PA
535 + when the KV cache is BF16, FP8 E4M3 or NVFP4; MI355 uses AITER sparse PA
536 with shuffle KV cache layout; Other platforms and FP8 E5M2 fall
536 - back to Triton. The MSA modules are imported lazily to avoid import errors
537 - on unsupported platforms.
537 + back to Triton. NVFP4 is MSA-only. The MSA modules are imported lazily to
538 + avoid import errors on unsupported platforms.
539 """
540 use_aiter_sparse_pa = minimax_m3_use_aiter_sparse_pa(
541 num_kv_heads, emits_sparse_block_table=emits_sparse_block_table
@@ -545,6 +546,18 @@ def select_main_backend_and_impl_cls(
546 and topk_blocks in (4, 8, 16, 32)
547 and kv_cache_dtype != "fp8_e5m2"
548 )
549 + use_nvfp4 = get_kv_quant_mode(kv_cache_dtype).is_nvfp4
550 + if use_nvfp4 and kv_cache_dtype != "nvfp4":
551 + raise ValueError(
552 + "MiniMax M3 sparse attention supports only the 'nvfp4' NVFP4 KV "
553 + f"cache dtype, got {kv_cache_dtype}"
554 + )
555 + if use_nvfp4 and (use_aiter_sparse_pa or not use_msa):
556 + raise ValueError(
557 + "MiniMax M3 sparse attention supports an NVFP4 KV cache only with "
558 + f"the SM100 MSA backend (kv_cache_dtype={kv_cache_dtype}, "
559 + f"topk_blocks={topk_blocks})"
560 + )
561 selected = (
562 "AITER_SPARSE_PA" if use_aiter_sparse_pa else ("MSA" if use_msa else "Triton")
563 )
@@ -565,8 +578,11 @@ def select_main_backend_and_impl_cls(
578 from vllm.models.minimax_m3.nvidia.sparse_attention_msa import (
579 MiniMaxM3SparseMSABackend,
580 MiniMaxM3SparseMSAImpl,
581 + MiniMaxM3SparseMSANvfp4Backend,
582 )
583
584 + if use_nvfp4:
585 + return MiniMaxM3SparseMSANvfp4Backend, MiniMaxM3SparseMSAImpl
586 return MiniMaxM3SparseMSABackend, MiniMaxM3SparseMSAImpl
587 return MiniMaxM3SparseBackend, MiniMaxM3SparseTritonImpl
588
vllm/models/minimax_m3/nvidia/model.py
+7 -1
@@ -500,6 +500,8 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
500 # of wrapping the generic Attention module. Keep the same runtime scale
501 # attributes so FP8 KV reads can honor vLLM's per-layer descale contract.
502 set_default_quant_scales(self, register_buffer=True)
503 + # The fused insert quantizes k/v into NVFP4 pages with these scales.
504 + self.use_nvfp4_kv = get_kv_quant_mode(self.kv_cache_dtype).is_nvfp4
505 # Indexer side-cache dtype, mirroring --kv-cache-dtype for the main
506 # cache (--attention-config '{"indexer_kv_dtype": ...}').
507 self.indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
@@ -611,6 +613,8 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
613 main_slot_mapping = fwd_slot_mapping[self.layer_name]
614 index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
615 q = qkv.new_empty((num_tokens, self.q_size))
616 + # With query_fp8, q is emitted only in fp8; the attend dequantizes it
617 + # into ``q`` for the tokens that run on bf16-query kernels.
618 query_fp8 = self._allocate_query_fp8(qkv)
619 # index_q matches the index-K cache dtype (e4m3 for the fp8 score path);
620 # the fused kernel emits fp8 directly when this buffer is e4m3.
@@ -636,11 +640,13 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
640 self.kv_cache,
641 self.indexer.index_cache.kv_cache,
642 self.kv_cache.size(2), # paged-cache block size
639 - q,
643 + q if query_fp8 is None else None,
644 index_q,
645 self.kv_cache_dtype,
646 q_fp8_out=query_fp8,
647 q_fp8_scale=self._q_scale_float,
648 + kv_k_scale=self._k_scale if self.use_nvfp4_kv else None,
649 + kv_v_scale=self._v_scale if self.use_nvfp4_kv else None,
650 )
651
652 output = torch.empty_like(q)
vllm/models/minimax_m3/nvidia/msa_cutlass_sparse_decode.py
+52 -7
@@ -12,6 +12,7 @@ import torch
12 from vllm.config.attention import MiniMaxM3MSADecodeBackend
13 from vllm.platforms import current_platform
14 from vllm.triton_utils import tl, triton
15 +from vllm.v1.kv_cache_interface import get_kv_quant_mode
16
17 _MAX_NUM_Q_HEADS = 64
18 _MAX_NUM_KV_HEADS = 4
@@ -23,9 +24,36 @@ _TOPK = 16
24 _MAX_QUERY_HEAD_ROWS = 65536
25 _MAX_DECODE_QUERY_LEN = 32
26 # Kernel benchmarks put the CUTLASS crossover at 16 requests for TP1 and TP4.
27 +# NVFP4 has no Triton fallback, so it takes CUTLASS at every batch size.
28 _MIN_CUTLASS_BATCH_SIZE = 16
29
30
31 +def is_nvfp4_kv_cache(kv_cache_dtype: str) -> bool:
32 + return get_kv_quant_mode(kv_cache_dtype).is_nvfp4
33 +
34 +
35 +def nvfp4_kv_cache_views(
36 + kv_cache: torch.Tensor,
37 +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
38 + """Return the K data, K scale, V data and V scale views of an NVFP4 cache.
39 +
40 + ``kv_cache`` is uint8 ``[pages, 2 * Hkv, page_size, 72]``; slot
41 + ``2 * head + side`` (K = 0, V = 1) is that head's E2M1 data
42 + ``[page_size, 64]`` followed by its E4M3 block scales ``[page_size, 8]``,
43 + the one page layout ``fmha_sm100`` reads.
44 + """
45 + from vllm.third_party.fmha_sm100.nvfp4_kv import nvfp4_head_slot_views
46 +
47 + return nvfp4_head_slot_views(*nvfp4_kv_cache_slots(kv_cache))
48 +
49 +
50 +def nvfp4_kv_cache_slots(
51 + kv_cache: torch.Tensor,
52 +) -> tuple[torch.Tensor, torch.Tensor]:
53 + """Return the per-head K and V slots of an NVFP4 cache."""
54 + return kv_cache[:, 0::2], kv_cache[:, 1::2]
55 +
56 +
57 @dataclass
58 class MSACutlassDecodeMetadata:
59 plan: Any
@@ -91,6 +119,9 @@ class MSACutlassDecodePlanCache:
119 use_fp8_kvcache=True,
120 split_prefill_decode=False,
121 device=device,
122 + # The Q8KV4 route keeps plan-time lengths and its own page offsets,
123 + # which this cached plan does not refresh.
124 + decode_backend="kv_mode3",
125 )
126
127 plan_info = plan[3]
@@ -214,11 +245,12 @@ def supports_cutlass_sparse_decode(
245 topk_blocks: int,
246 ) -> bool:
247 """Return whether static model geometry supports CUTLASS sparse decode."""
248 + nvfp4 = is_nvfp4_kv_cache(kv_cache_dtype)
249 return (
218 - decode_backend == "cutlass"
250 + (decode_backend == "cutlass" or nvfp4)
251 and current_platform.is_cuda()
252 and current_platform.is_device_capability_family(100)
221 - and kv_cache_dtype in ("fp8", "fp8_e4m3")
253 + and (kv_cache_dtype in ("fp8", "fp8_e4m3") or nvfp4)
254 and _supported_head_geometry(num_q_heads, num_kv_heads)
255 and page_size == _PAGE_SIZE
256 and topk_blocks == _TOPK
@@ -248,7 +280,7 @@ def should_prepare_decode_metadata(
280 topk_blocks=topk_blocks,
281 )
282 and 1 <= decode_query_len <= _MAX_DECODE_QUERY_LEN
251 - and batch_size >= _MIN_CUTLASS_BATCH_SIZE
283 + and (batch_size >= _MIN_CUTLASS_BATCH_SIZE or is_nvfp4_kv_cache(kv_cache_dtype))
284 and total_q * num_q_heads <= _MAX_QUERY_HEAD_ROWS
285 )
286
@@ -292,9 +324,22 @@ def msa_cutlass_sparse_decode(
324 q_scale_float: float,
325 k_scale_float: float,
326 v_scale_float: float,
327 + k_scale: torch.Tensor | None = None,
328 + v_scale: torch.Tensor | None = None,
329 ) -> None:
296 - """Run CUTLASS sparse decode with metadata prepared by the MSA builder."""
297 - key, value = kv_cache.split(_HEAD_DIM, dim=-1)
330 + """Run CUTLASS sparse decode with metadata prepared by the MSA builder.
331 +
332 + NVFP4 caches are uint8 head-slot pages (see ``nvfp4_kv_cache_views``) and
333 + take the device global scales ``k_scale``/``v_scale``.
334 + """
335 + if kv_cache.dtype == torch.uint8:
336 + assert k_scale is not None and v_scale is not None
337 + key, value = nvfp4_kv_cache_slots(kv_cache)
338 + k_scale_arg: float | torch.Tensor = k_scale
339 + v_scale_arg: float | torch.Tensor = v_scale
340 + else:
341 + key, value = kv_cache.split(_HEAD_DIM, dim=-1)
342 + k_scale_arg, v_scale_arg = k_scale_float, v_scale_float
343
344 from vllm.third_party.fmha_sm100.api import fmha_sm100
345
@@ -310,7 +355,7 @@ def msa_cutlass_sparse_decode(
355 output_o=True,
356 sm_scale=scale,
357 q_scale=q_scale_float,
313 - k_scale=k_scale_float,
314 - v_scale=v_scale_float,
358 + k_scale=k_scale_arg,
359 + v_scale=v_scale_arg,
360 o_scale=1.0,
361 )
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+108 -11
@@ -4,15 +4,18 @@
4
5 Prefill attends with ``fmha_sm100`` (``build_k2q_csr`` + ``sparse_atten_func``).
6 Decode uses Triton split-K by default, with an opt-in CUTLASS ``fmha_sm100``
7 -path for regular decode and speculative verification.
7 +path for regular decode and speculative verification. NVFP4 KV caches read the
8 +vLLM packed pages directly in both prefill and CUTLASS decode.
9 """
10
10 -from dataclasses import dataclass
11 +from dataclasses import dataclass, replace
12 +from typing import ClassVar
13
14 import torch
15
16 from vllm.config import VllmConfig
17 from vllm.config.attention import MiniMaxM3MSADecodeBackend
18 +from vllm.config.cache import CacheDType
19 from vllm.forward_context import get_forward_context
20 from vllm.logger import init_logger
21 from vllm.models.minimax_m3.common.ops.sparse_attn import (
@@ -29,20 +32,33 @@ from vllm.models.minimax_m3.common.sparse_attention import (
32 from vllm.models.minimax_m3.nvidia.msa_cutlass_sparse_decode import (
33 MSACutlassDecodeMetadata,
34 MSACutlassDecodePlanCache,
35 + is_nvfp4_kv_cache,
36 msa_cutlass_sparse_decode,
37 + nvfp4_kv_cache_views,
38 prepare_decode_metadata,
39 should_prepare_decode_metadata,
40 supports_cutlass_sparse_decode,
41 )
42 +from vllm.utils.torch_utils import get_dtype_size, nvfp4_kv_cache_full_dim
43 from vllm.v1.attention.backend import (
44 AttentionLayer,
45 CommonAttentionMetadata,
46 )
41 -from vllm.v1.kv_cache_interface import AttentionSpec
47 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
48
49 logger = init_logger(__name__)
50
51
52 +def _dequantize_query(
53 + query_fp8: torch.Tensor, q_scale: float, out: torch.Tensor
54 +) -> torch.Tensor:
55 + """Write ``query_fp8 * q_scale`` into ``out`` (E4M3 is exact in BF16)."""
56 + out.copy_(query_fp8)
57 + if q_scale != 1.0:
58 + out.mul_(q_scale)
59 + return out
60 +
61 +
62 class MiniMaxM3SparseMSABackend(MiniMaxM3SparseBackend):
63 """MiniMax M3 backend with NVIDIA MSA-specific decode metadata."""
64
@@ -51,6 +67,30 @@ class MiniMaxM3SparseMSABackend(MiniMaxM3SparseBackend):
67 return MiniMaxM3SparseMSAMetadataBuilder
68
69
70 +class MiniMaxM3SparseMSANvfp4Backend(MiniMaxM3SparseMSABackend):
71 + """MSA backend over HND NVFP4 KV cache pages in per-head K/V slots."""
72 +
73 + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = ["nvfp4"]
74 +
75 + @classmethod
76 + def customize_spec(cls, spec: AttentionSpec) -> AttentionSpec:
77 + """Store each head's K and V as two slots of packed data + block scales
78 + (see ``nvfp4_kv_cache_views``)."""
79 + if spec.state_content_bytes is not None or not spec.kv_quant_mode.is_nvfp4:
80 + return spec
81 + return replace(
82 + spec,
83 + num_head_slots=2 * spec.num_kv_heads,
84 + state_content_bytes=nvfp4_kv_cache_full_dim(spec.head_size)
85 + * get_dtype_size(spec.dtype),
86 + )
87 +
88 + @classmethod
89 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...] | None:
90 + # Every head's K/V slots are one contiguous run of the page.
91 + return (KVCacheLayout.LBHNC,)
92 +
93 +
94 class MiniMaxM3SparseCutlassBackend(MiniMaxM3SparseMSABackend):
95 """Attention-backend alias selecting CUTLASS MSA sparse decode."""
96
@@ -166,6 +206,7 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
206 topk_blocks=topk_blocks,
207 sparse_block_size=sparse_block_size,
208 )
209 + self.use_nvfp4_kv = is_nvfp4_kv_cache(kv_cache_dtype)
210 self.use_cutlass_decode = supports_cutlass_sparse_decode(
211 decode_backend=msa_decode_backend,
212 num_q_heads=self.num_heads,
@@ -174,6 +215,12 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
215 page_size=self.block_size,
216 topk_blocks=self.topk_blocks,
217 )
218 + if self.use_nvfp4_kv and not self.use_cutlass_decode:
219 + raise ValueError(
220 + "MiniMax M3 NVFP4 KV cache requires CUTLASS MSA sparse decode "
221 + f"(num_heads={self.num_heads}, num_kv_heads={self.num_kv_heads}, "
222 + f"block_size={self.block_size}, topk_blocks={self.topk_blocks})"
223 + )
224 logger.info_once(
225 "MiniMax M3 MSA sparse decode selected %s",
226 "CUTLASS" if self.use_cutlass_decode else "Triton",
@@ -218,9 +265,17 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
265 hd = self.head_size
266 q = query[:num_tokens].view(-1, self.num_heads, hd)
267 out = output[:num_tokens].view(-1, self.num_heads, hd)
221 - kv_cache = (
222 - kv_cache.view(self.kv_cache_fp8_dtype) if self.use_fp8_kv else kv_cache
268 + # Given query_fp8, it is the only q the fused insert wrote: CUTLASS
269 + # decode reads it directly, and the bf16-query kernels read it
270 + # dequantized into ``query``.
271 + q_fp8 = (
272 + None
273 + if query_fp8 is None
274 + else query_fp8[:num_tokens].view(-1, self.num_heads, hd)
275 )
276 + q_scale_float = getattr(layer, "_q_scale_float", 1.0)
277 + if self.use_fp8_kv and not self.use_nvfp4_kv:
278 + kv_cache = kv_cache.view(self.kv_cache_fp8_dtype)
279 k_scale = getattr(layer, "_k_scale", None) if self.use_fp8_kv else None
280 v_scale = getattr(layer, "_v_scale", None) if self.use_fp8_kv else None
281
@@ -234,21 +289,31 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
289 else None
290 )
291 if self.use_cutlass_decode and msa_metadata is not None:
237 - assert query_fp8 is not None
292 + assert q_fp8 is not None
293 msa_cutlass_sparse_decode(
239 - query_fp8[:nd].view(-1, self.num_heads, hd),
294 + q_fp8[:nd],
295 kv_cache,
296 topk[:nd],
297 out[:nd],
298 msa_metadata,
299 scale=self.scale,
245 - q_scale_float=getattr(layer, "_q_scale_float", 1.0),
300 + q_scale_float=q_scale_float,
301 k_scale_float=getattr(layer, "_k_scale_float", 1.0),
302 v_scale_float=getattr(layer, "_v_scale_float", 1.0),
303 + k_scale=k_scale if self.use_nvfp4_kv else None,
304 + v_scale=v_scale if self.use_nvfp4_kv else None,
305 + )
306 + elif self.use_nvfp4_kv:
307 + raise RuntimeError(
308 + "MiniMax M3 NVFP4 KV cache has no Triton decode fallback; "
309 + f"CUTLASS decode was not planned for {main_md.num_decodes} "
310 + f"requests with query length {d.decode_query_len}"
311 )
312 else:
313 minimax_m3_sparse_attn_decode(
251 - q[:nd],
314 + q[:nd]
315 + if q_fp8 is None
316 + else _dequantize_query(q_fp8[:nd], q_scale_float, q[:nd]),
317 kv_cache,
318 topk[:nd].transpose(0, 1),
319 d.block_table,
@@ -266,6 +331,7 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
331 from vllm.third_party.fmha_sm100.sparse import (
332 build_k2q_csr,
333 sparse_atten_func,
334 + sparse_atten_nvfp4_kv_func,
335 )
336
337 p = main_md.prefill
@@ -273,8 +339,11 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
339 # [H, prefill, MK] transposed view; build_k2q_csr consumes the
340 # strided view directly (topK stays innermost-contiguous).
341 prefill_topk = topk[nd:num_tokens].transpose(0, 1)
276 - qp = q[nd:]
277 - k_cache, v_cache = kv_cache.split(self.head_size, dim=-1)
342 + qp = (
343 + q[nd:]
344 + if q_fp8 is None
345 + else _dequantize_query(q_fp8[nd:], q_scale_float, q[nd:])
346 + )
347 k2q_row_ptr, k2q_q_indices, schedule = build_k2q_csr(
348 prefill_topk,
349 p.cu_seqlens_q,
@@ -287,6 +356,34 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
356 qhead_per_kv=qp.shape[1] // self.num_kv_heads,
357 return_schedule=True,
358 )
359 + if self.use_nvfp4_kv:
360 + k_data, k_sf, v_data, v_sf = nvfp4_kv_cache_views(kv_cache)
361 + sparse_atten_nvfp4_kv_func(
362 + qp,
363 + k_data,
364 + v_data,
365 + k_sf,
366 + v_sf,
367 + k_scale,
368 + v_scale,
369 + k2q_row_ptr,
370 + k2q_q_indices,
371 + self.topk_blocks,
372 + cu_seqlens_q=p.cu_seqlens_q,
373 + cu_seqlens_k=p.cu_seqlens_k,
374 + max_seqlen_q=p.max_query_len,
375 + max_seqlen_k=p.max_seq_len,
376 + blk_kv=SPARSE_BLOCK_SIZE,
377 + causal=True,
378 + softmax_scale=self.scale,
379 + page_table=p.block_table,
380 + seqused_k=p.seq_lens,
381 + schedule=schedule,
382 + out=out[nd:],
383 + kv_layout="vllm",
384 + )
385 + return output
386 + k_cache, v_cache = kv_cache.split(self.head_size, dim=-1)
387 sparse_atten_func(
388 qp,
389 k_cache,