[minimax-m3] fp8 attention GEMMs on SM100 (fp8_e4m3 KV + trtllm_mha) (#30971)

Co-authored-by: qiuyue <qiuyue@minimaxi.com>
Co-authored-by: xuebi <xuebi@minimaxi.com>
Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
This commit is contained in:
alumkal
2026-08-01 09:39:52 +08:00
committed by GitHub
co-authored by qiuyue xuebi Xiaoyu Zhang
parent e6a4cefc69
commit bae8eb8d6c
23 changed files with 1766 additions and 145 deletions
@@ -9,7 +9,7 @@
#include <cfloat> #include <cfloat>
#include <cstdint> #include <cstdint>
#if defined(__HIP_PLATFORM_AMD__) #ifdef USE_ROCM
static constexpr unsigned long long kWarpSyncMask = 0xFFFFFFFFFFFFFFFFull; static constexpr unsigned long long kWarpSyncMask = 0xFFFFFFFFFFFFFFFFull;
#else #else
#include <math_constants.h> #include <math_constants.h>
@@ -30,7 +30,9 @@ namespace {
// The trivial case num_blocks <= topk (every block selected) is handled by the // The trivial case num_blocks <= topk (every block selected) is handled by the
// kernels below, outside the Trait. // kernels below, outside the Trait.
struct TopKTrait { struct TopKTrait {
static constexpr uint32_t kMaxTopK = 32; // Also sizes the kernels' smem staging for the ascending-order emit; the
// block-id path's test contract goes up to topk == 64.
static constexpr uint32_t kMaxTopK = 64;
static constexpr uint32_t kCTASize = 512; static constexpr uint32_t kCTASize = 512;
static constexpr uint32_t kNumWarps = kCTASize / device::kWarpThreads; static constexpr uint32_t kNumWarps = kCTASize / device::kWarpThreads;
static constexpr uint32_t kMaxNumBlocks = 4096; // block topk static constexpr uint32_t kMaxNumBlocks = 4096; // block topk
@@ -67,8 +69,14 @@ struct TopKTrait {
}; };
constexpr auto warp_inclusive_sum = [](uint32_t lane_id, uint32_t val) { constexpr auto warp_inclusive_sum = [](uint32_t lane_id, uint32_t val) {
#pragma unroll #pragma unroll
for (uint32_t offset = 1; offset < 32; offset *= 2) { for (uint32_t offset = 1; offset < device::kWarpThreads; offset *= 2) {
uint32_t n = __shfl_up_sync(kWarpSyncMask, val, offset, 32); // Width-32 up-shuffle. On wave64 HIP the un-suffixed __shfl_up takes the
// logical-warp width directly; CUDA needs the active mask.
#ifdef USE_ROCM
uint32_t n = __shfl_up(val, offset, device::kWarpThreads);
#else
uint32_t n = __shfl_up_sync(kWarpSyncMask, val, offset, device::kWarpThreads);
#endif
if (lane_id >= offset) val += n; if (lane_id >= offset) val += n;
} }
return val; return val;
@@ -264,8 +272,10 @@ struct TopKTrait {
// Trait; otherwise the Trait selects the top-k block ids. // Trait; otherwise the Trait selects the top-k block ids.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Block-id output: topk_idx[h, b, 0:k_eff) = selected block ids (front-packed, // Block-id output: topk_idx[h, b, 0:k_eff) = selected block ids (sorted
// unordered), [k_eff:topk) = -1. // ascending), [k_eff:topk) = -1. Ascending order is a hard requirement of the
// MSA fmha_sm100 consumer (kv_block_indexes must be strictly ascending; its
// sorted-order early-exit otherwise mis-masks the partial last block).
template <typename SeqLenT, bool kUsePDL> template <typename SeqLenT, bool kUsePDL>
__global__ void minimax_decode_topk_block_kernel( __global__ void minimax_decode_topk_block_kernel(
const float* __restrict__ score, const float* __restrict__ score,
@@ -297,7 +307,43 @@ __global__ void minimax_decode_topk_block_kernel(
const float* __restrict__ row = score + (static_cast<int64_t>(h) * batch + b) * max_seqblock; const float* __restrict__ row = score + (static_cast<int64_t>(h) * batch + b) * max_seqblock;
__shared__ TopKTrait::Smem smem; __shared__ TopKTrait::Smem smem;
TopKTrait::forward(row, static_cast<uint32_t>(num_blocks), out, static_cast<uint32_t>(topk), &smem); __shared__ int32_t s_topk[TopKTrait::kMaxTopK];
TopKTrait::forward(row, static_cast<uint32_t>(num_blocks), s_topk, static_cast<uint32_t>(topk), &smem);
__syncthreads(); // s_topk fully written before the sort reads it
// Emit ascending: num_blocks > topk here, so all topk slots hold distinct
// ids and rank(v) = |{x : x < v}| is a permutation. deepseek_v4
// topk_impl.cuh warp sort (32x32 / 64x64 branches; topk <= kMaxTopK = 64):
// lanes hold the elements in registers (INT32_MAX sentinel past topk), warp
// w ranks targets {w, w + kNumWarps, ...} via ballot+popc, lane 0 emits.
static_assert(TopKTrait::kMaxTopK <= 2 * device::kWarpThreads);
const auto warp_id = tx / device::kWarpThreads;
const auto lane_id = tx % device::kWarpThreads;
#ifdef USE_ROCM
// wave64: __ballot spans the full 64-lane wave (would count the sibling
// 32-lane logical warp too); use the file's width-32 shuffle reduction.
const auto count_lt = [](int32_t x, int32_t v) { return device::warp::reduce_sum(static_cast<int>(x < v)); };
#else
const auto count_lt = [](int32_t x, int32_t v) { return __popc(__ballot_sync(kWarpSyncMask, x < v)); };
#endif
if (topk <= static_cast<int>(device::kWarpThreads)) { // 32 x 32
const int32_t tie = (lane_id < static_cast<uint32_t>(topk)) ? s_topk[lane_id] : INT32_MAX;
for (uint32_t t = warp_id; t < static_cast<uint32_t>(topk); t += TopKTrait::kNumWarps) {
const int32_t target = s_topk[t];
const auto rank = count_lt(tie, target);
if (lane_id == 0) out[rank] = target;
}
} else { // 64 x 64: each lane takes 2 elements
const int32_t tie_0 = s_topk[lane_id];
const int32_t tie_1 = (lane_id + device::kWarpThreads < static_cast<uint32_t>(topk))
? s_topk[lane_id + device::kWarpThreads]
: INT32_MAX;
for (uint32_t t = warp_id; t < static_cast<uint32_t>(topk); t += TopKTrait::kNumWarps) {
const int32_t target = s_topk[t];
const auto rank = count_lt(tie_0, target) + count_lt(tie_1, target);
if (lane_id == 0) out[rank] = target;
}
}
} }
// Page-table output: for each (batch b, kv-head h) pseudo-request emit the // Page-table output: for each (batch b, kv-head h) pseudo-request emit the
@@ -436,6 +482,7 @@ void minimax_decode_topk(
topk_i, topk_i,
")"); ")");
RuntimeCheck(block_size > 0, "block_size must be > 0, got ", block_size); RuntimeCheck(block_size > 0, "block_size must be > 0, got ", block_size);
RuntimeCheck(topk <= static_cast<int64_t>(TopKTrait::kMaxTopK), "topk exceeds kMaxTopK (ascending-sort smem buffer)");
if (batch == 0 || num_heads == 0) return; if (batch == 0 || num_heads == 0) return;
const dim3 grid(static_cast<unsigned>(batch), static_cast<unsigned>(num_heads)); const dim3 grid(static_cast<unsigned>(batch), static_cast<unsigned>(num_heads));
@@ -3,8 +3,10 @@
Drop-in replacement for the 2-stage split-K Triton topk Drop-in replacement for the 2-stage split-K Triton topk
(``_topk_index_partial_kernel`` + ``_topk_index_merge_kernel``): given the (``_topk_index_partial_kernel`` + ``_topk_index_merge_kernel``): given the
decode score tensor ``[num_heads, batch, max_seqblock]`` it produces decode score tensor ``[num_heads, batch, max_seqblock]`` it produces
``topk_idx`` ``[num_heads, batch, topk]`` (0-indexed block ids, front-packed, ``topk_idx`` ``[num_heads, batch, topk]`` (0-indexed block ids, sorted
``-1`` padded), matching the consumer ``_gqa_share_sparse_decode_kernel``. ascending, ``-1`` padded at the tail). Ascending order is required by the MSA
fmha_sm100 consumer; the Triton ``_gqa_share_sparse_decode_kernel`` is
order-insensitive.
``minimax_decode_topk_page_table`` additionally fuses the page-table transform ``minimax_decode_topk_page_table`` additionally fuses the page-table transform
for the dense paged backend (trtllm_mha / fa3) and returns the page table plus for the dense paged backend (trtllm_mha / fa3) and returns the page table plus
@@ -17,7 +19,12 @@ from typing import TYPE_CHECKING, Tuple
import torch import torch
from sglang.kernels.jit.utils import cache_once, load_jit, make_cpp_args from sglang.kernels.jit.utils import (
cache_once,
is_arch_support_pdl,
load_jit,
make_cpp_args,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from tvm_ffi.module import Module from tvm_ffi.module import Module
@@ -25,7 +32,7 @@ if TYPE_CHECKING:
@cache_once @cache_once
def _jit_module(seq_dtype: torch.dtype) -> Module: def _jit_module(seq_dtype: torch.dtype) -> Module:
args = make_cpp_args(seq_dtype, True) # SeqLenT, kUsePDL args = make_cpp_args(seq_dtype, is_arch_support_pdl()) # SeqLenT, kUsePDL
return load_jit( return load_jit(
"minimax_decode_topk", "minimax_decode_topk",
*args, *args,
@@ -10,11 +10,14 @@ import triton.language as tl
_tma_keep_alive_buf = deque(maxlen=200) _tma_keep_alive_buf = deque(maxlen=200)
# Q is always bf16/fp16. The paged main K/V cache may be fp8 (unit-scaled) under # The paged main K/V cache may be fp8 (unit-scaled) under --kv-cache-dtype
# --kv-cache-dtype fp8_*; the kernel widens it to the Q dtype on load (IS_FP8 # fp8_*; with a bf16/fp16 Q the kernel widens K/V to the Q dtype on load
# branch). Accepted on both HIP and CUDA (the bf16->fp8 cache write is unit-scaled, # (IS_FP8 branch — the bf16->fp8 cache write is unit-scaled, so the widening
# so the widening cast is the exact inverse dequant). The bf16/fp16-only MSA # cast is the exact inverse dequant). Under fp8 attn-GEMM mode Q itself is
# (fmha_sm100) kernel is excluded for fp8 KV by the backend use_msa gate. # fp8_e4m3: the IS_FP8 casts become no-ops and tl.dot runs fp8x8 on tensor
# cores (P is quantized to the V dtype for the PV MMA, same contract as the
# fmha_sm100 fp8 kernel). Accepted on both HIP and CUDA. MSA (fmha_sm100)
# accepts fp8 only in the uniform-e4m3 fp8 attn-GEMM mode (backend gate).
SPARSE_KV_FP8_DTYPES = ( SPARSE_KV_FP8_DTYPES = (
torch.float8_e4m3fn, torch.float8_e4m3fn,
torch.float8_e5m2, torch.float8_e5m2,
@@ -25,26 +28,56 @@ SPARSE_KV_FP8_DTYPES = (
def check_sparse_kv_fp8( def check_sparse_kv_fp8(
q: torch.Tensor, q: torch.Tensor,
k_cache: torch.Tensor, k_cache: torch.Tensor,
v_cache: torch.Tensor, v_cache: Optional[torch.Tensor],
*, *,
label: str, label: str,
) -> bool: ) -> bool:
"""Validate the sparse-attention KV cache dtype contract. """Validate the sparse-attention Q/KV dtype contract.
Returns True iff the K cache is fp8 (then widened to Q dtype in the kernel). Returns True iff the K cache is fp8 (drives the kernels' IS_FP8 constexpr).
Raises AssertionError otherwise, mirroring the contract the decode and prefill Two fp8 modes are allowed:
topk kernels both enforce. fp8 is accepted on both HIP and CUDA. * widening (Q bf16/fp16, K/V any fp8): K/V widened to Q dtype on load;
* all-fp8 GEMM (fp8 attn-GEMM mode): Q/K/V all fp8_e4m3fn. e5m2 Q is
rejected — fmha_sm100's variant lookup silently mis-dispatches e5m2
to the e4m3 kernel, so uniform e4m3 is enforced on the sglang side.
""" """
assert q.dtype in (torch.bfloat16, torch.float16)
is_fp8 = k_cache.dtype in SPARSE_KV_FP8_DTYPES is_fp8 = k_cache.dtype in SPARSE_KV_FP8_DTYPES
if q.dtype == torch.float8_e4m3fn:
assert k_cache.dtype == torch.float8_e4m3fn, (
f"sparse {label} with fp8 Q requires an fp8_e4m3fn K cache, "
f"got {k_cache.dtype}"
)
else:
assert q.dtype in (
torch.bfloat16,
torch.float16,
), f"sparse {label} expects Q dtype bf16/fp16/fp8_e4m3fn, got {q.dtype}"
assert k_cache.dtype == q.dtype or is_fp8, ( assert k_cache.dtype == q.dtype or is_fp8, (
f"sparse {label} expects K cache dtype == Q dtype ({q.dtype}) " f"sparse {label} expects K cache dtype == Q dtype ({q.dtype}) "
f"or fp8, got {k_cache.dtype}" f"or fp8, got {k_cache.dtype}"
) )
if v_cache is not None:
assert v_cache.dtype == k_cache.dtype assert v_cache.dtype == k_cache.dtype
return is_fp8 return is_fp8
def sparse_out_dtype(q: torch.Tensor) -> torch.dtype:
"""Attention output dtype: bf16 for fp8 Q (fp8 accumulates to bf16 out,
matching fmha_sm100's fp8 variant), else the Q dtype."""
return torch.bfloat16 if q.dtype == torch.float8_e4m3fn else q.dtype
def unit_scale(scale: Optional[float]) -> float:
"""Normalize an optional per-tensor dequant scale: None means unit scale.
All sparse-op entry points take ``Optional[float] = None`` scales (matching
``k_scale_float`` / ``v_scale_float`` on RadixAttention, which are None
unless a checkpoint provides them) and normalize here at the kernel-launch
boundary, where a concrete float is needed.
"""
return 1.0 if scale is None else scale
try: try:
make_tensor_descriptor = tl.make_tensor_descriptor make_tensor_descriptor = tl.make_tensor_descriptor
except Exception: except Exception:
@@ -260,3 +293,26 @@ def _bitonic_merge(
for i in tl.static_range(stage): for i in tl.static_range(stage):
x, ids = _compare_and_swap(x, ids, flip, i + (n_dims - stage), n_dims) x, ids = _compare_and_swap(x, ids, flip, i + (n_dims - stage), n_dims)
return x, ids return x, ids
@triton.jit
def _sort_ids_ascending(ids, valid_n, BLOCK_SIZE_T: tl.constexpr):
"""Sort a top-k id row ascending, invalids packed as -1 at the tail.
``ids``: int32 [BLOCK_SIZE_T] block ids (0-indexed, -1 = invalid); entries at
positions >= ``valid_n`` are also treated as invalid. Valid ids must be
distinct and < 2**30 (block ids always are). The MSA fmha_sm100 consumer
requires kv_block_indexes strictly ascending with -1 tail padding — its
sorted-order early-exit otherwise mis-masks the partial last block.
O(T^2) rank sort (T = BLOCK_SIZE_T <= 64): invalid entries get unique keys
above every valid id so ranks form a permutation.
"""
off = tl.arange(0, BLOCK_SIZE_T)
invalid = (off >= valid_n) | (ids < 0)
key = tl.where(invalid, 0x40000000 + off, ids)
rank = tl.sum(tl.where(key[None, :] < key[:, None], 1, 0), axis=1)
sorted_key = tl.sum(
tl.where(rank[None, :] == off[:, None], key[None, :], 0), axis=1
)
return tl.where(sorted_key >= 0x40000000, -1, sorted_key)
@@ -8,7 +8,14 @@ import triton.language as tl
from sglang.srt.environ import envs from sglang.srt.environ import envs
from ..common.utils import _bitonic_merge, robust_allocator from ..common.utils import (
_bitonic_merge,
_sort_ids_ascending,
check_sparse_kv_fp8,
robust_allocator,
sparse_out_dtype,
unit_scale,
)
@triton.heuristics( @triton.heuristics(
@@ -53,6 +60,8 @@ def _decode_score_kernel(
topk: tl.constexpr, topk: tl.constexpr,
# sm_scale # sm_scale
sm_scale, sm_scale,
# per-tensor K dequant scale (1.0 when the cache is unit-scaled)
k_scale,
# init and local blocks # init and local blocks
init_blocks, init_blocks,
local_blocks, local_blocks,
@@ -75,6 +84,7 @@ def _decode_score_kernel(
NUM_KV_CHUNKS: tl.constexpr, NUM_KV_CHUNKS: tl.constexpr,
SCORE_TYPE: tl.constexpr, SCORE_TYPE: tl.constexpr,
SKIP_TRIVIAL_TOPK_SCORE: tl.constexpr, SKIP_TRIVIAL_TOPK_SCORE: tl.constexpr,
IS_FP8: tl.constexpr,
): ):
tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse") tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse")
sm_scale_log2e = sm_scale * 1.4426950409 sm_scale_log2e = sm_scale * 1.4426950409
@@ -160,11 +170,15 @@ def _decode_score_kernel(
mask=dim_mask[:, None] & pos_mask[None, :], mask=dim_mask[:, None] & pos_mask[None, :],
other=0.0, other=0.0,
) )
if IS_FP8:
# fp8 index K cache: widening cast with bf16/fp16 Q, no-op with fp8
# Q (fp8 attn-GEMM mode; tl.dot runs fp8x8). Compiled out for bf16.
k = k.to(q.dtype)
# compute qk # compute qk
qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32) qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32)
qk += tl.where(off_n[None, :] < chunk_end - i, 0, float("-inf")) qk += tl.where(off_n[None, :] < chunk_end - i, 0, float("-inf"))
# [H, D], [D, N] -> [H, N] # [H, D], [D, N] -> [H, N]
qk += tl.dot(q, k) * sm_scale_log2e qk += tl.dot(q, k) * (sm_scale_log2e * k_scale)
# save qk to score # save qk to score
score = tl.reshape( score = tl.reshape(
qk, qk,
@@ -237,6 +251,9 @@ def _decode_score_attn_kernel(
topk: tl.constexpr, topk: tl.constexpr,
# sm_scale # sm_scale
sm_scale, sm_scale,
# per-tensor KV dequant scales (1.0 when the cache is unit-scaled)
k_scale,
v_scale,
# init and local blocks # init and local blocks
init_blocks, init_blocks,
local_blocks, local_blocks,
@@ -271,6 +288,7 @@ def _decode_score_attn_kernel(
HAS_SINK: tl.constexpr, HAS_SINK: tl.constexpr,
SCORE_TYPE: tl.constexpr, SCORE_TYPE: tl.constexpr,
SKIP_TRIVIAL_TOPK_SCORE: tl.constexpr, SKIP_TRIVIAL_TOPK_SCORE: tl.constexpr,
IS_FP8: tl.constexpr,
): ):
tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse") tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse")
sm_scale_log2e = sm_scale * 1.4426950409 sm_scale_log2e = sm_scale * 1.4426950409
@@ -367,6 +385,10 @@ def _decode_score_attn_kernel(
mask=dim_mask[:, None] & pos_mask[None, :], mask=dim_mask[:, None] & pos_mask[None, :],
other=0.0, other=0.0,
) )
if IS_FP8:
# fp8 index K cache: widening cast with bf16/fp16 Q, no-op with fp8
# Q (fp8 attn-GEMM mode; tl.dot runs fp8x8). Compiled out for bf16.
k = k.to(q.dtype)
# load V as (BLOCK_SIZE_N, head_dim) via indirect addressing # load V as (BLOCK_SIZE_N, head_dim) via indirect addressing
v_off = ( v_off = (
slots[:, None] * stride_v_s slots[:, None] * stride_v_s
@@ -378,11 +400,15 @@ def _decode_score_attn_kernel(
mask=pos_mask[:, None] & dim_mask[None, :], mask=pos_mask[:, None] & dim_mask[None, :],
other=0.0, other=0.0,
) )
if IS_FP8:
# Cast V to the compute dtype (widening for bf16/fp16 Q; no-op for
# fp8 Q where P is quantized to e4m3 for the fp8 PV MMA).
v = v.to(q.dtype)
# compute qk # compute qk
qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32) qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32)
qk += tl.where(off_n[None, :] < chunk_end - i, 0, float("-inf")) qk += tl.where(off_n[None, :] < chunk_end - i, 0, float("-inf"))
# [H, D], [D, N] -> [H, N] # [H, D], [D, N] -> [H, N]
qk += tl.dot(q, k) * sm_scale_log2e qk += tl.dot(q, k) * (sm_scale_log2e * k_scale)
# save qk to score # save qk to score
score = tl.reshape( score = tl.reshape(
qk, qk,
@@ -426,7 +452,7 @@ def _decode_score_attn_kernel(
acc_o_scale = tl.exp2(m_i - m_ij) acc_o_scale = tl.exp2(m_i - m_ij)
acc_o = acc_o * acc_o_scale[:, None] acc_o = acc_o * acc_o_scale[:, None]
# [H, N], [N, D] -> [H, D] # [H, N], [N, D] -> [H, D]
acc_o += tl.dot(p.to(v.dtype), v) acc_o += tl.dot(p.to(v.dtype), v) * v_scale
m_i = m_ij m_i = m_ij
l_i = l_i * acc_o_scale + l_ij l_i = l_i * acc_o_scale + l_ij
# update ptrs # update ptrs
@@ -744,8 +770,14 @@ def _topk_index_merge_kernel(
+ pid_b * stride_tif_b + pid_b * stride_tif_b
+ off_t * stride_tif_t + off_t * stride_tif_t
) )
topk_idx_final = tl.where(off_t < tl.minimum(topk, num_blocks), topk_idx_final, -1) # Ascending by block id, -1 tail: the MSA fmha_sm100 consumer requires
tl.store(tif_ptrs, topk_idx_final.to(ti_final_ptr.dtype.element_ty)) # sorted kv_block_indexes (the bitonic pass above orders by score).
topk_idx_final = _sort_ids_ascending(
topk_idx_final, tl.minimum(topk, num_blocks), BLOCK_SIZE_T
)
tl.store(
tif_ptrs, topk_idx_final.to(ti_final_ptr.dtype.element_ty), mask=off_t < topk
)
@torch.no_grad() @torch.no_grad()
@@ -768,18 +800,21 @@ def flash_decode_with_topk_idx(
disable_index_value: bool = False, disable_index_value: bool = False,
use_dense_main_attn: bool = False, # NOTE: need transform idx in this case use_dense_main_attn: bool = False, # NOTE: need transform idx in this case
page_size: int = 1, page_size: int = 1,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
) -> torch.Tensor: ) -> torch.Tensor:
assert score_type in ( assert score_type in (
"max", "max",
"lse", "lse",
), f"score_type must be 'max' or 'lse', got {score_type!r}" ), f"score_type must be 'max' or 'lse', got {score_type!r}"
triton.set_allocator(robust_allocator) triton.set_allocator(robust_allocator)
# dtype check # dtype check (v_cache is None under disable_index_value)
assert ( is_fp8 = check_sparse_kv_fp8(
q.dtype == torch.bfloat16 q, k_cache, None if disable_index_value else v_cache, label="decode indexer"
or q.dtype == torch.float16
and k_cache.dtype == q.dtype
) )
k_scale = unit_scale(k_scale)
v_scale = unit_scale(v_scale)
if not disable_index_value: if not disable_index_value:
assert v_cache is not None assert v_cache is not None
# shape # shape
@@ -793,6 +828,10 @@ def flash_decode_with_topk_idx(
# sm scale # sm scale
if sm_scale is None: if sm_scale is None:
sm_scale = head_dim**-0.5 sm_scale = head_dim**-0.5
# q_scale folds exactly into sm_scale: it multiplies every Q-side logit —
# the QK dot AND the sink logit — unlike k_scale, which must not touch the
# sink term and therefore stays a separate kernel argument.
sm_scale = sm_scale * unit_scale(q_scale)
# NUM_KV_CHUNKS controls how many parallel chunks each (batch, kv_head) gets. # NUM_KV_CHUNKS controls how many parallel chunks each (batch, kv_head) gets.
# Total CTAs = batch_size * NUM_KV_CHUNKS * num_kv_heads. # Total CTAs = batch_size * NUM_KV_CHUNKS * num_kv_heads.
# TARGET_GRID is the desired total CTA count; NUM_KV_CHUNKS is derived by: # TARGET_GRID is the desired total CTA count; NUM_KV_CHUNKS is derived by:
@@ -848,6 +887,7 @@ def flash_decode_with_topk_idx(
block_size, block_size,
topk, topk,
sm_scale, sm_scale,
k_scale,
init_blocks, init_blocks,
local_blocks, local_blocks,
q.stride(0), q.stride(0),
@@ -863,6 +903,7 @@ def flash_decode_with_topk_idx(
NUM_KV_CHUNKS=NUM_KV_CHUNKS, NUM_KV_CHUNKS=NUM_KV_CHUNKS,
SCORE_TYPE=score_type, SCORE_TYPE=score_type,
SKIP_TRIVIAL_TOPK_SCORE=skip_trivial_topk_score, SKIP_TRIVIAL_TOPK_SCORE=skip_trivial_topk_score,
IS_FP8=is_fp8,
) )
else: else:
assert v_cache is not None assert v_cache is not None
@@ -871,7 +912,7 @@ def flash_decode_with_topk_idx(
batch_size, batch_size,
num_q_heads, num_q_heads,
head_dim, head_dim,
dtype=q.dtype, dtype=sparse_out_dtype(q),
device=q.device, device=q.device,
) )
lse = torch.empty( lse = torch.empty(
@@ -895,6 +936,8 @@ def flash_decode_with_topk_idx(
block_size, block_size,
topk, topk,
sm_scale, sm_scale,
k_scale,
v_scale,
init_blocks, init_blocks,
local_blocks, local_blocks,
q.stride(0), q.stride(0),
@@ -922,6 +965,7 @@ def flash_decode_with_topk_idx(
NUM_KV_CHUNKS=NUM_KV_CHUNKS, NUM_KV_CHUNKS=NUM_KV_CHUNKS,
SCORE_TYPE=score_type, SCORE_TYPE=score_type,
SKIP_TRIVIAL_TOPK_SCORE=skip_trivial_topk_score, SKIP_TRIVIAL_TOPK_SCORE=skip_trivial_topk_score,
IS_FP8=is_fp8,
) )
# Fused top-k + page-table transform: emit the dense backend's page table # Fused top-k + page-table transform: emit the dense backend's page table
# directly (page-size-aware) instead of block ids, skipping a separate gather. # directly (page-size-aware) instead of block ids, skipping a separate gather.
@@ -6,7 +6,12 @@ import torch
import triton import triton
import triton.language as tl import triton.language as tl
from ..common.utils import check_sparse_kv_fp8, robust_allocator from ..common.utils import (
check_sparse_kv_fp8,
robust_allocator,
sparse_out_dtype,
unit_scale,
)
@triton.heuristics( @triton.heuristics(
@@ -49,6 +54,9 @@ def _gqa_share_sparse_decode_kernel(
max_kv_len, max_kv_len,
# sm_scale # sm_scale
sm_scale, sm_scale,
# per-tensor KV dequant scales (1.0 when the cache is unit-scaled)
k_scale,
v_scale,
# stride # stride
stride_q_b, stride_q_b,
stride_q_h, stride_q_h,
@@ -178,10 +186,12 @@ def _gqa_share_sparse_decode_kernel(
other=0.0, other=0.0,
) )
if IS_FP8: if IS_FP8:
# fp8 KV cache is unit-scaled (set_kv_buffer casts bf16->fp8 with no # fp8 KV cache: with bf16/fp16 Q this widens K to the compute dtype
# scale), so dequant is just a widening cast to the Q compute dtype # (unit-scaled cache -> exact inverse dequant; k_scale covers
# before the tl.dot. Matches the bf16 path bit-for-bit when the cache # calibrated caches). With fp8 Q (fp8 attn-GEMM mode) the cast is a
# is bf16 (IS_FP8 False -> this branch is compiled out). # no-op and tl.dot below runs fp8x8 on tensor cores. Matches the
# bf16 path bit-for-bit when the cache is bf16 (IS_FP8 False ->
# this branch is compiled out).
k = k.to(q.dtype) k = k.to(q.dtype)
# load V as (BLOCK_SIZE_N, head_dim) via indirect addressing # load V as (BLOCK_SIZE_N, head_dim) via indirect addressing
v_off = ( v_off = (
@@ -195,15 +205,16 @@ def _gqa_share_sparse_decode_kernel(
other=0.0, other=0.0,
) )
if IS_FP8: if IS_FP8:
# Widen V before the P@V dot. This also makes the `p.to(v.dtype)` # Cast V to the compute dtype. With bf16/fp16 Q this widens (so the
# below cast P to the compute dtype (not to fp8, which would be # `p.to(v.dtype)` below keeps P in the compute dtype); with fp8 Q it
# catastrophic precision loss on the attention weights). # is a no-op and P is quantized to e4m3 for the fp8 PV MMA — the
# same accuracy contract as fmha_sm100's fp8 kernel.
v = v.to(q.dtype) v = v.to(q.dtype)
# compute qk # compute qk
qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32) qk = tl.zeros((BLOCK_SIZE_H, BLOCK_SIZE_N), dtype=tl.float32)
qk += tl.where(off_n[None, :] < seq_len - c, 0, float("-inf")) qk += tl.where(off_n[None, :] < seq_len - c, 0, float("-inf"))
# [H, D], [D, N] -> [H, N] # [H, D], [D, N] -> [H, N]
qk += tl.dot(q, k) * sm_scale qk += tl.dot(q, k) * (sm_scale * k_scale)
# compute m_ij and l_ij # compute m_ij and l_ij
m_ij = tl.maximum(m_i, tl.max(qk, axis=1)) m_ij = tl.maximum(m_i, tl.max(qk, axis=1))
p = tl.exp(qk - m_ij[:, None]) p = tl.exp(qk - m_ij[:, None])
@@ -212,9 +223,8 @@ def _gqa_share_sparse_decode_kernel(
acc_o_scale = tl.exp(m_i - m_ij) acc_o_scale = tl.exp(m_i - m_ij)
acc_o = acc_o * acc_o_scale[:, None] acc_o = acc_o * acc_o_scale[:, None]
# load v and update acc_o # load v and update acc_o
p = p.to(v.dtype)
# [H, N], [N, D] -> [H, D] # [H, N], [N, D] -> [H, D]
acc_o += tl.dot(p.to(v.dtype), v) acc_o += tl.dot(p.to(v.dtype), v) * v_scale
# update statistics # update statistics
m_i = m_ij m_i = m_ij
lse_i = m_ij + tl.log(tl.exp(lse_i - m_ij) + l_ij) lse_i = m_ij + tl.log(tl.exp(lse_i - m_ij) + l_ij)
@@ -308,9 +318,14 @@ def flash_decode_with_gqa_share_sparse(
topk_idx: torch.Tensor, # [num_kv_heads, batch_size, topk] topk_idx: torch.Tensor, # [num_kv_heads, batch_size, topk]
sm_scale: Optional[float] = None, sm_scale: Optional[float] = None,
use_tma: bool = True, use_tma: bool = True,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
) -> torch.Tensor: ) -> torch.Tensor:
triton.set_allocator(robust_allocator) triton.set_allocator(robust_allocator)
is_fp8 = check_sparse_kv_fp8(q, k_cache, v_cache, label="decode") is_fp8 = check_sparse_kv_fp8(q, k_cache, v_cache, label="decode")
k_scale = unit_scale(k_scale)
v_scale = unit_scale(v_scale)
# shape # shape
batch_size, num_q_heads, head_dim = q.shape batch_size, num_q_heads, head_dim = q.shape
max_slots, num_kv_heads, _ = k_cache.shape max_slots, num_kv_heads, _ = k_cache.shape
@@ -328,6 +343,9 @@ def flash_decode_with_gqa_share_sparse(
# sm scale # sm scale
if sm_scale is None: if sm_scale is None:
sm_scale = head_dim**-0.5 sm_scale = head_dim**-0.5
# q_scale multiplies every Q-side logit (QK dot and sink), so it folds into
# sm_scale; k_scale must not touch the sink term and stays a kernel arg.
sm_scale = sm_scale * unit_scale(q_scale)
# Pick NUM_TOPK_CHUNKS so total grid ≈ TARGET_GRID. Same constraints as # Pick NUM_TOPK_CHUNKS so total grid ≈ TARGET_GRID. Same constraints as
# flash_decode_with_topk_idx: must be power of 2 (Triton arange) and must # flash_decode_with_topk_idx: must be power of 2 (Triton arange) and must
# only depend on shape constants (so grid is fixed within a cuda graph). # only depend on shape constants (so grid is fixed within a cuda graph).
@@ -345,7 +363,7 @@ def flash_decode_with_gqa_share_sparse(
batch_size, batch_size,
num_q_heads, num_q_heads,
head_dim, head_dim,
dtype=q.dtype, dtype=sparse_out_dtype(q),
device=q.device, device=q.device,
) )
lse_partial = torch.empty( lse_partial = torch.empty(
@@ -375,6 +393,8 @@ def flash_decode_with_gqa_share_sparse(
max_topk, max_topk,
max_kv_len, max_kv_len,
sm_scale, sm_scale,
k_scale,
v_scale,
q.stride(0), q.stride(0),
q.stride(1), q.stride(1),
q.stride(2), q.stride(2),
@@ -6,7 +6,15 @@ import torch
import triton import triton
import triton.language as tl import triton.language as tl
from ..common.utils import _bitonic_merge, get_cu_seqblocks, robust_allocator from ..common.utils import (
_bitonic_merge,
_sort_ids_ascending,
check_sparse_kv_fp8,
get_cu_seqblocks,
robust_allocator,
sparse_out_dtype,
unit_scale,
)
@triton.heuristics( @triton.heuristics(
@@ -81,6 +89,9 @@ def _flash_attn_fwd_with_block_score_kernel(
block_size: tl.constexpr, block_size: tl.constexpr,
# sm_scale # sm_scale
sm_scale, sm_scale,
# per-tensor KV dequant scales (1.0 when the cache is unit-scaled)
k_scale,
v_scale,
# gumbel topk # gumbel topk
use_gumbel_topk: tl.constexpr, use_gumbel_topk: tl.constexpr,
gumbel_seed, gumbel_seed,
@@ -112,6 +123,7 @@ def _flash_attn_fwd_with_block_score_kernel(
HAS_SINK: tl.constexpr, HAS_SINK: tl.constexpr,
SCORE_TYPE: tl.constexpr, SCORE_TYPE: tl.constexpr,
DISABLE_INDEX_VALUE: tl.constexpr, DISABLE_INDEX_VALUE: tl.constexpr,
IS_FP8: tl.constexpr,
): ):
tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse") tl.static_assert(SCORE_TYPE == "max" or SCORE_TYPE == "lse")
sm_scale_log2e = sm_scale * 1.4426950409 sm_scale_log2e = sm_scale * 1.4426950409
@@ -170,7 +182,10 @@ def _flash_attn_fwd_with_block_score_kernel(
if HAS_SINK: if HAS_SINK:
m_i = tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32) m_i = tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32)
lse_i = tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32) lse_i = tl.zeros((BLOCK_SIZE_Q,), dtype=tl.float32)
qsink = tl.sum(q * sink[None, :], axis=1) * sm_scale_log2e # (BLOCK_SIZE_Q,) qsink = (
tl.sum(q.to(tl.float32) * sink[None, :].to(tl.float32), axis=1)
* sm_scale_log2e
) # (BLOCK_SIZE_Q,)
m_i += qsink m_i += qsink
lse_i += qsink lse_i += qsink
else: else:
@@ -199,8 +214,12 @@ def _flash_attn_fwd_with_block_score_kernel(
mask=kd_mask[:, None] & pos_mask[None, :], mask=kd_mask[:, None] & pos_mask[None, :],
other=0.0, other=0.0,
) )
if IS_FP8:
# fp8 index K cache: widening cast with bf16/fp16 Q, no-op with fp8
# Q (fp8 attn-GEMM mode; tl.dot runs fp8x8). Compiled out for bf16.
k = k.to(q.dtype)
# compute qk # compute qk
qk = tl.dot(q, k) * sm_scale_log2e qk = tl.dot(q, k) * (sm_scale_log2e * k_scale)
if i >= diag_start: if i >= diag_start:
qk = tl.where(off_q[:, None] >= (i + off_k)[None, :], qk, float("-inf")) qk = tl.where(off_q[:, None] >= (i + off_k)[None, :], qk, float("-inf"))
# K boundary mask: positions beyond seq_len contribute -inf # K boundary mask: positions beyond seq_len contribute -inf
@@ -251,8 +270,12 @@ def _flash_attn_fwd_with_block_score_kernel(
mask=pos_mask[:, None] & vd_mask[None, :], mask=pos_mask[:, None] & vd_mask[None, :],
other=0.0, other=0.0,
) )
if IS_FP8:
# Cast V to the compute dtype (widening for bf16/fp16 Q; no-op
# for fp8 Q where P is quantized to e4m3 for the fp8 PV MMA).
v = v.to(q.dtype)
p = p.to(v.dtype) p = p.to(v.dtype)
acc_o += tl.dot(p, v) acc_o += tl.dot(p, v) * v_scale
# update statistics # update statistics
m_i = m_ij m_i = m_ij
lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij) lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
@@ -402,6 +425,9 @@ def _topk_index_kernel(
* tl.reshape(topk_idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]), * tl.reshape(topk_idx - 1, [BLOCK_SIZE_K // BLOCK_SIZE_T, BLOCK_SIZE_T]),
axis=0, axis=0,
) )
# Ascending by block id, -1 tail: the MSA fmha_sm100 consumer requires
# sorted kv_block_indexes (the bitonic pass above orders by score).
topk_idx = _sort_ids_ascending(topk_idx, min(topk, valid_blocks), BLOCK_SIZE_T)
# save topk # save topk
ti_ptrs = ( ti_ptrs = (
ti_ptr ti_ptr
@@ -438,15 +464,21 @@ def flash_prefill_with_topk_index(
cu_seqblocks_q: Optional[torch.Tensor] = None, cu_seqblocks_q: Optional[torch.Tensor] = None,
max_seqblock_q: Optional[int] = None, max_seqblock_q: Optional[int] = None,
all_seqblock_q: Optional[int] = None, all_seqblock_q: Optional[int] = None,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
): ):
assert score_type in ( assert score_type in (
"max", "max",
"lse", "lse",
), f"score_type must be 'max' or 'lse', got {score_type!r}" ), f"score_type must be 'max' or 'lse', got {score_type!r}"
triton.set_allocator(robust_allocator) triton.set_allocator(robust_allocator)
# dtype check # dtype check (v_cache is None under disable_index_value)
assert q.dtype == torch.bfloat16 or q.dtype == torch.float16 is_fp8 = check_sparse_kv_fp8(
assert k_cache.dtype == q.dtype q, k_cache, None if disable_index_value else v_cache, label="prefill indexer"
)
k_scale = unit_scale(k_scale)
v_scale = unit_scale(v_scale)
assert cu_seqlens.dtype == torch.int32 assert cu_seqlens.dtype == torch.int32
# shape # shape
total_q, num_heads, qk_head_dim = q.shape total_q, num_heads, qk_head_dim = q.shape
@@ -455,7 +487,7 @@ def flash_prefill_with_topk_index(
# placeholder for BLOCK_SIZE_VD; V is never loaded # placeholder for BLOCK_SIZE_VD; V is never loaded
v_head_dim = qk_head_dim v_head_dim = qk_head_dim
else: else:
assert v_cache is not None and v_cache.dtype == q.dtype assert v_cache is not None
assert v_cache.shape[1] == k_cache.shape[1] assert v_cache.shape[1] == k_cache.shape[1]
v_head_dim = v_cache.shape[-1] v_head_dim = v_cache.shape[-1]
gqa_group_size = num_heads // num_kv_heads gqa_group_size = num_heads // num_kv_heads
@@ -468,6 +500,9 @@ def flash_prefill_with_topk_index(
), "init_blocks + local_blocks must be less than topk" ), "init_blocks + local_blocks must be less than topk"
if sm_scale is None: if sm_scale is None:
sm_scale = qk_head_dim**-0.5 sm_scale = qk_head_dim**-0.5
# q_scale multiplies every Q-side logit (QK dot and sink), so it folds into
# sm_scale; k_scale must not touch the sink term and stays a kernel arg.
sm_scale = sm_scale * unit_scale(q_scale)
if cu_seqblocks_q is None or max_seqblock_q is None or all_seqblock_q is None: if cu_seqblocks_q is None or max_seqblock_q is None or all_seqblock_q is None:
cu_seqblocks_q, max_seqblock_q, all_seqblock_q, _, _, _ = get_cu_seqblocks( cu_seqblocks_q, max_seqblock_q, all_seqblock_q, _, _, _ = get_cu_seqblocks(
cu_seqlens, max_seqlen_q, block_size_q, block_size_k cu_seqlens, max_seqlen_q, block_size_q, block_size_k
@@ -476,7 +511,9 @@ def flash_prefill_with_topk_index(
if disable_index_value: if disable_index_value:
o = None o = None
else: else:
o = torch.empty(total_q, num_heads, v_head_dim, dtype=q.dtype, device=q.device) o = torch.empty(
total_q, num_heads, v_head_dim, dtype=sparse_out_dtype(q), device=q.device
)
score = torch.full( score = torch.full(
(num_heads, total_q, max_seqblock_k), (num_heads, total_q, max_seqblock_k),
float("-inf"), float("-inf"),
@@ -507,6 +544,8 @@ def flash_prefill_with_topk_index(
v_head_dim, v_head_dim,
block_size_k, block_size_k,
sm_scale, sm_scale,
k_scale,
v_scale,
False, False,
1, 1,
q.stride(0), q.stride(0),
@@ -529,6 +568,7 @@ def flash_prefill_with_topk_index(
req_to_token.stride(0), req_to_token.stride(0),
SCORE_TYPE=score_type, SCORE_TYPE=score_type,
DISABLE_INDEX_VALUE=disable_index_value, DISABLE_INDEX_VALUE=disable_index_value,
IS_FP8=is_fp8,
) )
# topk extraction kernel # topk extraction kernel
@@ -6,7 +6,13 @@ import torch
import triton import triton
import triton.language as tl import triton.language as tl
from ..common.utils import check_sparse_kv_fp8, get_cu_seqblocks, robust_allocator from ..common.utils import (
check_sparse_kv_fp8,
get_cu_seqblocks,
robust_allocator,
sparse_out_dtype,
unit_scale,
)
@triton.heuristics( @triton.heuristics(
@@ -66,6 +72,9 @@ def _gqa_share_sparse_fwd_kernel(
num_q_loop, num_q_loop,
# sm_scale # sm_scale
sm_scale, sm_scale,
# per-tensor KV dequant scales (1.0 when the cache is unit-scaled)
k_scale,
v_scale,
# stride # stride
stride_qn, stride_qn,
stride_qh, stride_qh,
@@ -201,8 +210,10 @@ def _gqa_share_sparse_fwd_kernel(
other=0.0, other=0.0,
) )
if IS_FP8: if IS_FP8:
# fp8 main K cache is unit-scaled; widen to the Q compute dtype # fp8 main K cache: widening cast with bf16/fp16 Q (unit-scaled
# before the tl.dot (compiled out when the cache is bf16). # cache -> exact inverse dequant; k_scale covers calibrated
# caches), no-op with fp8 Q (fp8 attn-GEMM mode) so tl.dot runs
# fp8x8. Compiled out when the cache is bf16.
k = k.to(q.dtype) k = k.to(q.dtype)
# compute qk # compute qk
qk = tl.zeros((BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32) qk = tl.zeros((BLOCK_SIZE_Q, BLOCK_SIZE_H, BLOCK_SIZE_K), dtype=tl.float32)
@@ -211,7 +222,7 @@ def _gqa_share_sparse_fwd_kernel(
qk = tl.reshape(qk, BLOCK_SIZE_QH, BLOCK_SIZE_K) qk = tl.reshape(qk, BLOCK_SIZE_QH, BLOCK_SIZE_K)
# [BLOCK_SIZE_QH, qk_head_dim] @ [qk_head_dim, BLOCK_SIZE_K] # [BLOCK_SIZE_QH, qk_head_dim] @ [qk_head_dim, BLOCK_SIZE_K]
# -> [BLOCK_SIZE_QH, BLOCK_SIZE_K] # -> [BLOCK_SIZE_QH, BLOCK_SIZE_K]
qk += tl.dot(q, k) * sm_scale_log2e qk += tl.dot(q, k) * (sm_scale_log2e * k_scale)
# K boundary mask: positions beyond seq_len contribute -inf # K boundary mask: positions beyond seq_len contribute -inf
qk += tl.where(pos_mask[None, :], 0, float("-inf")) qk += tl.where(pos_mask[None, :], 0, float("-inf"))
# compute m_ij and l_ij # compute m_ij and l_ij
@@ -231,11 +242,13 @@ def _gqa_share_sparse_fwd_kernel(
other=0.0, other=0.0,
) )
if IS_FP8: if IS_FP8:
# Widen V so `p.to(v.dtype)` casts P to the compute dtype rather # Cast V to the compute dtype: widening with bf16/fp16 Q (so
# than to fp8 (which would wreck attention-weight precision). # `p.to(v.dtype)` keeps P in the compute dtype), no-op with fp8
# Q where P is quantized to e4m3 for the fp8 PV MMA — the same
# accuracy contract as fmha_sm100's fp8 kernel.
v = v.to(q.dtype) v = v.to(q.dtype)
p = p.to(v.dtype) p = p.to(v.dtype)
acc_o += tl.dot(p, v) acc_o += tl.dot(p, v) * v_scale
# update statistics # update statistics
m_i = m_ij m_i = m_ij
lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij) lse_i = m_ij + tl.log2(tl.exp2(lse_i - m_ij) + l_ij)
@@ -273,9 +286,14 @@ def flash_prefill_with_gqa_share_sparse(
use_tma: bool = True, use_tma: bool = True,
cu_seqblocks_q: Optional[torch.Tensor] = None, cu_seqblocks_q: Optional[torch.Tensor] = None,
max_seqblock_q: Optional[int] = None, max_seqblock_q: Optional[int] = None,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
) -> torch.Tensor: ) -> torch.Tensor:
triton.set_allocator(robust_allocator) triton.set_allocator(robust_allocator)
is_fp8 = check_sparse_kv_fp8(q, k_cache, v_cache, label="prefill") is_fp8 = check_sparse_kv_fp8(q, k_cache, v_cache, label="prefill")
k_scale = unit_scale(k_scale)
v_scale = unit_scale(v_scale)
assert block_size_q in {1, 2, 4, 8, 16, 32, 64} assert block_size_q in {1, 2, 4, 8, 16, 32, 64}
assert block_size_k in {16, 32, 64, 128} assert block_size_k in {16, 32, 64, 128}
# shape # shape
@@ -292,12 +310,17 @@ def flash_prefill_with_gqa_share_sparse(
assert gqa_group_size * block_size_q <= 128 assert gqa_group_size * block_size_q <= 128
if sm_scale is None: if sm_scale is None:
sm_scale = qk_head_dim**-0.5 sm_scale = qk_head_dim**-0.5
# q_scale multiplies every Q-side logit (QK dot and sink), so it folds into
# sm_scale; k_scale must not touch the sink term and stays a kernel arg.
sm_scale = sm_scale * unit_scale(q_scale)
if cu_seqblocks_q is None or max_seqblock_q is None: if cu_seqblocks_q is None or max_seqblock_q is None:
cu_seqblocks_q, max_seqblock_q, _, _, _, _ = get_cu_seqblocks( cu_seqblocks_q, max_seqblock_q, _, _, _, _ = get_cu_seqblocks(
cu_seqlens, max_seqlen_q, block_size_q, block_size_k cu_seqlens, max_seqlen_q, block_size_q, block_size_k
) )
# output tensor # output tensor
o = torch.empty(total_q, num_q_heads, v_head_dim, device=q.device, dtype=q.dtype) o = torch.empty(
total_q, num_q_heads, v_head_dim, device=q.device, dtype=sparse_out_dtype(q)
)
# launch kernel # launch kernel
num_q_loop = ( num_q_loop = (
max_seqblock_q // 131072 + 1 max_seqblock_q // 131072 + 1
@@ -330,6 +353,8 @@ def flash_prefill_with_gqa_share_sparse(
topk, topk,
num_q_loop, num_q_loop,
sm_scale, sm_scale,
k_scale,
v_scale,
q.stride(0), q.stride(0),
q.stride(1), q.stride(1),
q.stride(2), q.stride(2),
+58 -7
View File
@@ -512,13 +512,26 @@ def _minimax_m3_overrides(server_args: Any, hf_config: Any) -> dict:
overrides["disable_custom_all_reduce"] = True overrides["disable_custom_all_reduce"] = True
elif is_sm100_supported(): elif is_sm100_supported():
if server_args.is_attention_backend_not_set(): if server_args.is_attention_backend_not_set():
overrides["attention_backend"] = "fa4"
page_resolved = server_args.page_size
if ( if (
page_resolved is None server_args.kv_cache_dtype == "fp8_e4m3"
and overrides.get("attention_backend", server_args.attention_backend) and not envs.SGLANG_DISABLE_M3_FP8_ATTN_GEMM.get()
== "fa4"
): ):
# fp8 attention GEMMs activate whenever possible
# (m3_fp8_attn_gemm_enabled); only trtllm_mha serves the dense
# fp8-q path, so prefer it over fa4 for fp8 KV. The
# SGLANG_DISABLE_M3_FP8_ATTN_GEMM kill switch keeps the fa4
# default (pre-fp8 behavior).
overrides["attention_backend"] = "trtllm_mha"
else:
overrides["attention_backend"] = "fa4"
backend_resolved = overrides.get(
"attention_backend", server_args.attention_backend
)
page_resolved = server_args.page_size
# fa4 (fmha_sm100) and trtllm_mha both allow the page_size == 128
# sparse block MSA needs (trtllm_mha via trtllm-gen's dynamic
# tokens-per-page kernels).
if page_resolved is None and backend_resolved in ("fa4", "trtllm_mha"):
overrides["page_size"] = 128 overrides["page_size"] = 128
page_resolved = 128 page_resolved = 128
if server_args.moe_runner_backend == "auto" and quant_resolved == "mxfp8": if server_args.moe_runner_backend == "auto" and quant_resolved == "mxfp8":
@@ -550,6 +563,41 @@ def _minimax_m3_overrides(server_args: Any, hf_config: Any) -> dict:
"(MSA is SM100-only; sparse attention runs on the Triton path)." "(MSA is SM100-only; sparse attention runs on the Triton path)."
) )
# fp8 attention GEMMs have no opt-in flag: m3_fp8_attn_gemm_enabled
# (server_args.py) derives the mode from kv_cache_dtype (fp8_e4m3) +
# attention_backend (trtllm_mha) + SM100 at runtime. Surface the
# resolution here: warn on fp8_e5m2 (fmha_sm100's variant lookup would
# silently dispatch the e4m3 kernel, so e5m2 stays on the widening Triton
# path), log when the fp8 GEMM mode is active, and log when the
# SGLANG_DISABLE_M3_FP8_ATTN_GEMM kill switch suppresses it.
if server_args.kv_cache_dtype == "fp8_e5m2":
logger.warning(
"MiniMax-M3 with kv_cache_dtype fp8_e5m2: fp8 attention GEMMs stay "
"DISABLED (fmha_sm100's variant lookup would silently dispatch the "
"e4m3 kernel for e5m2); sparse attention runs on the widening "
"Triton path. Use --kv-cache-dtype fp8_e4m3 for fp8 attention GEMMs."
)
elif (
server_args.kv_cache_dtype == "fp8_e4m3"
and overrides.get("attention_backend", server_args.attention_backend)
== "trtllm_mha"
and is_sm100_supported()
):
if envs.SGLANG_DISABLE_M3_FP8_ATTN_GEMM.get():
logger.info(
"MiniMax-M3 fp8 attention GEMMs DISABLED by "
"SGLANG_DISABLE_M3_FP8_ATTN_GEMM: bf16 indexer + widening "
"Triton sparse path, bf16 q; dense layers keep trtllm_mha's "
"fp8 KV cache."
)
else:
logger.info(
"MiniMax-M3 fp8 attention GEMMs active (kv_cache_dtype fp8_e4m3 + "
"trtllm_mha on SM100): fp8 main/index KV, fp8-cast q, fp8 "
"sparse/MSA kernels. Set SGLANG_DISABLE_M3_FP8_ATTN_GEMM=1 to "
"force the pre-fp8 numerics."
)
moe_runner_resolved = overrides.get( moe_runner_resolved = overrides.get(
"moe_runner_backend", server_args.moe_runner_backend "moe_runner_backend", server_args.moe_runner_backend
) )
@@ -1794,9 +1842,12 @@ def _mla_backend_page_constraints(view: Any) -> dict:
or view.decode_attention_backend == "trtllm_mha" or view.decode_attention_backend == "trtllm_mha"
or view.prefill_attention_backend == "trtllm_mha" or view.prefill_attention_backend == "trtllm_mha"
): ):
if page_size not in [16, 32, 64]: # 128 runs on trtllm-gen's dynamic tokens-per-page kernels (flashinfer
# >= 0.6.12), which require GQA and equal QK/V head dims — validated at
# TRTLLMHAAttnBackend init where the model config is known.
if page_size not in [16, 32, 64, 128]:
logger.warning( logger.warning(
f"TensorRT-LLM MHA only supports page_size of 16, 32 or 64, changing page_size from {page_size} to 64." f"TensorRT-LLM MHA only supports page_size of 16, 32, 64 or 128, changing page_size from {page_size} to 64."
) )
page_size = 64 page_size = 64
if ( if (
+4
View File
@@ -1153,6 +1153,10 @@ class Envs:
SGLANG_OPT_USE_MINIMAX_DENSE_SPARSE_DECODE = EnvBool(False) SGLANG_OPT_USE_MINIMAX_DENSE_SPARSE_DECODE = EnvBool(False)
SGLANG_DISABLE_MSA = EnvBool(False) SGLANG_DISABLE_MSA = EnvBool(False)
SGLANG_OPT_USE_MSA_DECODE_UNDER_GRAPH = EnvBool(False) SGLANG_OPT_USE_MSA_DECODE_UNDER_GRAPH = EnvBool(False)
# Kill switch for the derived fp8 attention-GEMM mode (m3_fp8_attn_gemm_enabled):
# forces the pre-fp8 behavior (bf16 indexer + widening sparse path, bf16 q)
# even when kv_cache_dtype fp8_e4m3 + trtllm_mha + SM100 would activate it.
SGLANG_DISABLE_M3_FP8_ATTN_GEMM = EnvBool(False)
# MiniMax-M3 sparse decode indexer: single JIT radix-select kernel replaces the 2-stage split-K Triton topk. # MiniMax-M3 sparse decode indexer: single JIT radix-select kernel replaces the 2-stage split-K Triton topk.
SGLANG_OPT_USE_MINIMAX_DECODE_TOPK_RADIX = EnvBool(True) SGLANG_OPT_USE_MINIMAX_DECODE_TOPK_RADIX = EnvBool(True)
@@ -18,6 +18,7 @@ from sglang.srt.layers.attention.minimax_sparse_ops.minimax_sparse import (
) )
from sglang.srt.mem_cache.memory_pool import MiniMaxSparseKVPool from sglang.srt.mem_cache.memory_pool import MiniMaxSparseKVPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch from sglang.srt.model_executor.forward_batch_info import ForwardBatch
from sglang.srt.server_args import m3_fp8_attn_gemm_enabled
if TYPE_CHECKING: if TYPE_CHECKING:
from sglang.srt.model_executor.model_runner import ModelRunner from sglang.srt.model_executor.model_runner import ModelRunner
@@ -25,12 +26,26 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _quant_q_fp8(q: torch.Tensor, q_scale: Optional[float]) -> torch.Tensor:
# Same convention as the KV pools: the fp8 tensor stores value/scale and
# the attention kernels multiply the logits back by the scale (None = unit).
if q_scale is not None:
q = q / q_scale
return q.to(torch.float8_e4m3fn)
class MiniMaxSparseAttnBackend(AttentionBackend): class MiniMaxSparseAttnBackend(AttentionBackend):
def __init__(self, runner: ModelRunner): def __init__(self, runner: ModelRunner):
assert isinstance(runner.token_to_kv_pool, MiniMaxSparseKVPool) assert isinstance(runner.token_to_kv_pool, MiniMaxSparseKVPool)
self.kv_pool = runner.token_to_kv_pool self.kv_pool = runner.token_to_kv_pool
self.req_to_token = runner.req_to_token_pool.req_to_token self.req_to_token = runner.req_to_token_pool.req_to_token
self.max_context_len = int(runner.model_config.context_len) self.max_context_len = int(runner.model_config.context_len)
self.fp8_attn_gemm = m3_fp8_attn_gemm_enabled(runner.server_args)
if self.fp8_attn_gemm:
assert self.kv_pool.main_pool.dtype == torch.float8_e4m3fn, (
"fp8 attn-GEMM mode requires an fp8_e4m3fn main KV pool, got "
f"{self.kv_pool.main_pool.dtype}"
)
hf_config = runner.model_config.hf_config hf_config = runner.model_config.hf_config
sparse_cfg = get_minimax_sparse_attention_config(hf_config) sparse_cfg = get_minimax_sparse_attention_config(hf_config)
@@ -72,19 +87,26 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
msa_available, msa_available,
) )
# MSA (fmha_sm100) is bf16/fp16-only; an fp8 main KV cache must stay on the # MSA (fmha_sm100) runs bf16, or uniform fp8_e4m3 under fp8 attn-GEMM mode
# Triton sparse path (it dequants fp8 on load). # (which also casts q to fp8). An fp8 main KV cache WITHOUT the flag
# would pair a bf16 q with fp8 K/V — unsupported by fmha_sm100's
# uniform-dtype kernels — so it stays on the Triton sparse path (which
# dequants fp8 on load). e5m2 is never allowed into MSA (fmha_sm100's
# variant lookup would silently dispatch the e4m3 kernel).
_main_kv_is_fp8 = self.kv_pool.main_pool.dtype in ( _main_kv_is_fp8 = self.kv_pool.main_pool.dtype in (
torch.float8_e4m3fn, torch.float8_e4m3fn,
torch.float8_e5m2, torch.float8_e5m2,
) )
_msa_fp8_ok = (
self.fp8_attn_gemm and self.kv_pool.main_pool.dtype == torch.float8_e4m3fn
)
self.use_msa = ( self.use_msa = (
not envs.SGLANG_DISABLE_MSA.get() not envs.SGLANG_DISABLE_MSA.get()
and msa_available() and msa_available()
and self.block_size_k == 128 and self.block_size_k == 128
and self.kv_pool.page_size == self.block_size_k and self.kv_pool.page_size == self.block_size_k
and self.topk_blocks in (4, 8, 16, 32) and self.topk_blocks in (4, 8, 16, 32)
and not _main_kv_is_fp8 and (not _main_kv_is_fp8 or _msa_fp8_ok)
) )
if ( if (
not self.use_msa not self.use_msa
@@ -96,8 +118,8 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
logger.warning( logger.warning(
"MiniMax-M3 MSA decode disabled: page_size=%d != sparse block size " "MiniMax-M3 MSA decode disabled: page_size=%d != sparse block size "
"%d. Pass --page-size 128 (with an attention backend that allows it, " "%d. Pass --page-size 128 (with an attention backend that allows it, "
"e.g. fa4) to enable the faster MSA kernel; falling back to the " "e.g. fa4 or trtllm_mha) to enable the faster MSA kernel; falling "
"Triton sparse path.", "back to the Triton sparse path.",
self.kv_pool.page_size, self.kv_pool.page_size,
self.block_size_k, self.block_size_k,
) )
@@ -118,11 +140,10 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
self.use_dense_sparse_decode = ( self.use_dense_sparse_decode = (
envs.SGLANG_OPT_USE_MINIMAX_DENSE_SPARSE_DECODE.get() envs.SGLANG_OPT_USE_MINIMAX_DENSE_SPARSE_DECODE.get()
and self.block_size_k % self.page_size == 0 and self.block_size_k % self.page_size == 0
# _dense_sparse_main_decode calls trtllm decode with a bf16 q and
# unit bmm scales — no fp8 handling yet (follow-up).
and not self.fp8_attn_gemm
) )
# MSA fmha_sm100 decode is NOT cuda-graph-safe: captured/replayed it returns
# wrong results (~14% GSM8K loss on B200). Gate capture via cuda_graph_config,
# not legacy disable_* flags — they disagree under config-native flags and would
# capture the unsafe MSA decode kernel.
from sglang.srt.model_executor.cuda_graph_config import ( from sglang.srt.model_executor.cuda_graph_config import (
Backend, Backend,
Phase, Phase,
@@ -158,8 +179,18 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
f"[MiniMaxSparse] Backend initialized " f"[MiniMaxSparse] Backend initialized "
f"(score_type={self.score_type!r}, " f"(score_type={self.score_type!r}, "
f"main_attn={'MSA' if self.use_msa else 'triton'}, " f"main_attn={'MSA' if self.use_msa else 'triton'}, "
f"msa_decode={self._use_msa_decode}, "
f"msa_owns_decode={self._msa_owns_decode}, "
f"decode_cuda_graph={_decode_cuda_graph}, "
f"fp8_attn_gemm={self.fp8_attn_gemm}, "
f"disable_value_layers={sorted(self.disable_value_layer_ids)})" f"disable_value_layers={sorted(self.disable_value_layer_ids)})"
) )
if self.fp8_attn_gemm and self.use_msa:
logger.info(
"[MiniMaxSparse] fp8 MSA active: the first forward may "
"JIT-compile fmha_sm100 fp8 kernel variants (cold cache can "
"take minutes; compiles serialize across TP ranks)."
)
def init_forward_metadata_out_graph( def init_forward_metadata_out_graph(
self, forward_batch: ForwardBatch, in_capture: bool = False self, forward_batch: ForwardBatch, in_capture: bool = False
@@ -201,6 +232,7 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
self.topk_blocks, self.topk_blocks,
bs, bs,
device=device, device=device,
is_fp8=self.fp8_attn_gemm,
) )
kv_indices_buf = torch.zeros( kv_indices_buf = torch.zeros(
bs * self._msa_nb_max, dtype=torch.int32, device=device bs * self._msa_nb_max, dtype=torch.int32, device=device
@@ -288,6 +320,10 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
v, v,
idx_k, idx_k,
None if disable_value else idx_v, None if disable_value else idx_v,
layer.k_scale_float,
layer.v_scale_float,
layer.idx_k_scale_float,
layer.idx_v_scale_float,
) )
k_cache, v_cache = self.kv_pool.get_kv_buffer(layer.layer_id) k_cache, v_cache = self.kv_pool.get_kv_buffer(layer.layer_id)
if disable_value: if disable_value:
@@ -321,6 +357,12 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
q = q[:actual_num_tokens] q = q[:actual_num_tokens]
idx_q = idx_q[:actual_num_tokens] idx_q = idx_q[:actual_num_tokens]
# fp8 attention GEMMs: quantize q/idx_q AFTER the KV store (which reads
# the bf16 k/v) and the DP trim.
if self.fp8_attn_gemm:
q = _quant_q_fp8(q, layer.q_scale_float)
idx_q = _quant_q_fp8(idx_q, layer.idx_q_scale_float)
idx_o, o = minimax_sparse_prefill( idx_o, o = minimax_sparse_prefill(
q, q,
k_cache, k_cache,
@@ -346,6 +388,12 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
disable_index_value=disable_value, disable_index_value=disable_value,
use_msa=self.use_msa, use_msa=self.use_msa,
seqlens_cpu=forward_batch.extend_seq_lens_cpu, seqlens_cpu=forward_batch.extend_seq_lens_cpu,
q_scale=layer.q_scale_float,
k_scale=layer.k_scale_float,
v_scale=layer.v_scale_float,
idx_q_scale=layer.idx_q_scale_float,
idx_k_scale=layer.idx_k_scale_float,
idx_v_scale=layer.idx_v_scale_float,
) )
if actual_num_tokens < original_num_tokens: if actual_num_tokens < original_num_tokens:
@@ -424,6 +472,10 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
v, v,
idx_k, idx_k,
None if disable_value else idx_v, None if disable_value else idx_v,
layer.k_scale_float,
layer.v_scale_float,
layer.idx_k_scale_float,
layer.idx_v_scale_float,
) )
k_cache, v_cache = self.kv_pool.get_kv_buffer(layer.layer_id) k_cache, v_cache = self.kv_pool.get_kv_buffer(layer.layer_id)
if disable_value: if disable_value:
@@ -458,6 +510,12 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
"did not prepare the plan for this forward (gate mismatch)." "did not prepare the plan for this forward (gate mismatch)."
) )
# fp8 attention GEMMs: quantize q/idx_q AFTER the KV store (which reads
# the bf16 k/v).
if self.fp8_attn_gemm:
q = _quant_q_fp8(q, layer.q_scale_float)
idx_q = _quant_q_fp8(idx_q, layer.idx_q_scale_float)
idx_o, o = minimax_sparse_decode( idx_o, o = minimax_sparse_decode(
q, q,
None, None,
@@ -483,6 +541,12 @@ class MiniMaxSparseAttnBackend(AttentionBackend):
use_msa=self._use_msa_decode, use_msa=self._use_msa_decode,
msa_kv_indices=msa_kv_indices, msa_kv_indices=msa_kv_indices,
msa_plan=msa_plan, msa_plan=msa_plan,
q_scale=layer.q_scale_float,
k_scale=layer.k_scale_float,
v_scale=layer.v_scale_float,
idx_q_scale=layer.idx_q_scale_float,
idx_k_scale=layer.idx_k_scale_float,
idx_v_scale=layer.idx_v_scale_float,
) )
return ( return (
None if idx_o is None else idx_o.reshape(q.shape[0], -1).contiguous(), None if idx_o is None else idx_o.reshape(q.shape[0], -1).contiguous(),
@@ -67,6 +67,12 @@ def minimax_sparse_prefill(
max_seqblock_q: Optional[int] = None, max_seqblock_q: Optional[int] = None,
all_seqblock_q: Optional[int] = None, all_seqblock_q: Optional[int] = None,
seqlens_cpu: Optional[List[int]] = None, seqlens_cpu: Optional[List[int]] = None,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
idx_q_scale: Optional[float] = None,
idx_k_scale: Optional[float] = None,
idx_v_scale: Optional[float] = None,
): ):
"""Run MiniMax-M3 sparse prefill. """Run MiniMax-M3 sparse prefill.
@@ -106,6 +112,9 @@ def minimax_sparse_prefill(
cu_seqblocks_q=cu_seqblocks_q, cu_seqblocks_q=cu_seqblocks_q,
max_seqblock_q=max_seqblock_q, max_seqblock_q=max_seqblock_q,
all_seqblock_q=all_seqblock_q, all_seqblock_q=all_seqblock_q,
q_scale=idx_q_scale,
k_scale=idx_k_scale,
v_scale=idx_v_scale,
) )
# Step 2: Reduce topk idx if num_idx_heads > num_kv_heads # Step 2: Reduce topk idx if num_idx_heads > num_kv_heads
num_idx_heads = idx_q.shape[1] num_idx_heads = idx_q.shape[1]
@@ -134,6 +143,9 @@ def minimax_sparse_prefill(
prefix_lens=prefix_lens, prefix_lens=prefix_lens,
block_size_k=block_size_k, block_size_k=block_size_k,
sm_scale=sm_scale, sm_scale=sm_scale,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
except MSAUnavailableError as err: except MSAUnavailableError as err:
_warn_msa_fallback(err) _warn_msa_fallback(err)
@@ -154,6 +166,9 @@ def minimax_sparse_prefill(
sm_scale=sm_scale, sm_scale=sm_scale,
cu_seqblocks_q=cu_seqblocks_q, cu_seqblocks_q=cu_seqblocks_q,
max_seqblock_q=max_seqblock_q, max_seqblock_q=max_seqblock_q,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
else: else:
o = flash_prefill_with_gqa_share_sparse( o = flash_prefill_with_gqa_share_sparse(
@@ -173,6 +188,9 @@ def minimax_sparse_prefill(
sm_scale=sm_scale, sm_scale=sm_scale,
cu_seqblocks_q=cu_seqblocks_q, cu_seqblocks_q=cu_seqblocks_q,
max_seqblock_q=max_seqblock_q, max_seqblock_q=max_seqblock_q,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
return idx_o, o return idx_o, o
@@ -208,6 +226,12 @@ def minimax_sparse_decode(
torch.Tensor torch.Tensor
] = None, # per-forward MSA page table (cached) ] = None, # per-forward MSA page table (cached)
msa_plan=None, # per-forward MSA fmha_sm100 plan (cached) msa_plan=None, # per-forward MSA fmha_sm100 plan (cached)
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
idx_q_scale: Optional[float] = None,
idx_k_scale: Optional[float] = None,
idx_v_scale: Optional[float] = None,
) -> Tuple[torch.Tensor, torch.Tensor]: ) -> Tuple[torch.Tensor, torch.Tensor]:
# Step 1: Flash decode with topk index (using index head). When the dense main # Step 1: Flash decode with topk index (using index head). When the dense main
# attention is used, the indexer emits the page table directly (fused # attention is used, the indexer emits the page table directly (fused
@@ -230,6 +254,9 @@ def minimax_sparse_decode(
disable_index_value=disable_index_value, disable_index_value=disable_index_value,
use_dense_main_attn=dense_main_attn_fn is not None, use_dense_main_attn=dense_main_attn_fn is not None,
page_size=page_size, page_size=page_size,
q_scale=idx_q_scale,
k_scale=idx_k_scale,
v_scale=idx_v_scale,
) )
num_idx_heads = idx_q.shape[1] num_idx_heads = idx_q.shape[1]
num_kv_heads = k_cache.shape[1] num_kv_heads = k_cache.shape[1]
@@ -262,6 +289,9 @@ def minimax_sparse_decode(
sm_scale=sm_scale, sm_scale=sm_scale,
kv_indices=msa_kv_indices, kv_indices=msa_kv_indices,
plan=msa_plan, plan=msa_plan,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
except MSAUnavailableError as err: except MSAUnavailableError as err:
_warn_msa_fallback(err) _warn_msa_fallback(err)
@@ -276,6 +306,9 @@ def minimax_sparse_decode(
block_size=block_size_k, block_size=block_size_k,
topk_idx=topk_idx, topk_idx=topk_idx,
sm_scale=sm_scale, sm_scale=sm_scale,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
else: else:
o = flash_decode_with_gqa_share_sparse( o = flash_decode_with_gqa_share_sparse(
@@ -289,5 +322,8 @@ def minimax_sparse_decode(
block_size=block_size_k, block_size=block_size_k,
topk_idx=topk_idx, topk_idx=topk_idx,
sm_scale=sm_scale, sm_scale=sm_scale,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
) )
return idx_o, o return idx_o, o
@@ -3,6 +3,14 @@
# Replaces only step 3 of MiniMax sparse prefill/decode. The lightning indexer # Replaces only step 3 of MiniMax sparse prefill/decode. The lightning indexer
# (steps 1-2) is unchanged and still produces `topk_idx`. # (steps 1-2) is unchanged and still produces `topk_idx`.
# NVIDIA Blackwell (SM100/sm_103) only; callers gate on `msa_available()`. # NVIDIA Blackwell (SM100/sm_103) only; callers gate on `msa_available()`.
#
# Dtypes: bf16 end-to-end, or uniform fp8_e4m3fn Q/K/V under fp8 attn-GEMM mode
# (output bf16). fmha_sm100 selects its kernel variant from q.dtype alone and
# casts k/v pointers to the same element type, so mixed bf16-q/fp8-KV is NOT
# possible on the cutlass path and e5m2 would silently dispatch the e4m3
# kernel — `_check_msa_dtypes` enforces uniformity here. The fp8 kernel
# quantizes the unnormalized softmax P to e4m3 before the PV MMA (same
# contract as the Triton fp8 path).
from __future__ import annotations from __future__ import annotations
@@ -11,11 +19,32 @@ from typing import Optional
import torch import torch
from sglang.kernels.ops.attention.minimax_sparse.common.utils import unit_scale
class MSAUnavailableError(RuntimeError): class MSAUnavailableError(RuntimeError):
"""Raised when fmha_sm100 cannot serve the MiniMax MSA path.""" """Raised when fmha_sm100 cannot serve the MiniMax MSA path."""
def _check_msa_dtypes(q: torch.Tensor, k_cache: torch.Tensor, v_cache: torch.Tensor):
# Uniform dtype required in BOTH modes: fmha_sm100 keys its kernel variant
# on q.dtype alone and casts the k/v pointers to the same element type, so
# a mismatched cache would be silently reinterpreted.
if q.dtype == torch.bfloat16:
assert (
k_cache.dtype == torch.bfloat16
), f"MSA bf16 requires a bf16 K cache, got {k_cache.dtype}"
elif q.dtype == torch.float8_e4m3fn:
# e5m2 is rejected here too: fmha_sm100's variant lookup falls back to
# the e4m3 kernel for unknown dtype codes.
assert (
k_cache.dtype == torch.float8_e4m3fn
), f"MSA fp8 requires an fp8_e4m3fn K cache, got {k_cache.dtype}"
else:
raise AssertionError(f"MSA supports bf16 or fp8_e4m3fn Q, got {q.dtype}")
assert v_cache.dtype == k_cache.dtype
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def _load_fmha_sm100(): def _load_fmha_sm100():
try: try:
@@ -101,12 +130,23 @@ def msa_sparse_prefill_main(
prefix_lens: torch.Tensor, # [batch] prefix_lens: torch.Tensor, # [batch]
block_size_k: int, # == page_size == 128 for M3 block_size_k: int, # == page_size == 128 for M3
sm_scale: Optional[float] = None, sm_scale: Optional[float] = None,
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
) -> torch.Tensor: ) -> torch.Tensor:
"""Drop-in for flash_prefill_with_gqa_share_sparse using MSA fmha_sm100. """Drop-in for flash_prefill_with_gqa_share_sparse using MSA fmha_sm100.
Returns o [total_q, num_q_heads, head_dim]. Returns o [total_q, num_q_heads, head_dim] (bf16 for fp8 inputs).
Scale semantics (per-tensor, None = unit): attention runs on Q*q_scale,
K*k_scale, V*v_scale. The long-q cute path honors only sm_scale, so
q_scale*k_scale is folded into sm_scale (exact for softmax) and v_scale is
applied on the output; the short-q cutlass path gets the same folded values.
""" """
fmha_sm100, _ = _load_fmha_sm100() fmha_sm100, _ = _load_fmha_sm100()
_check_msa_dtypes(q, k_cache, v_cache)
is_fp8 = q.dtype == torch.float8_e4m3fn
v_scale = unit_scale(v_scale)
max_slots, num_kv_heads, head_dim = k_cache.shape max_slots, num_kv_heads, head_dim = k_cache.shape
num_q_heads = q.shape[1] num_q_heads = q.shape[1]
@@ -116,6 +156,7 @@ def msa_sparse_prefill_main(
raise ValueError(f"max_slots={max_slots} not divisible by page_size={P}") raise ValueError(f"max_slots={max_slots} not divisible by page_size={P}")
if sm_scale is None: if sm_scale is None:
sm_scale = head_dim**-0.5 sm_scale = head_dim**-0.5
sm_scale = sm_scale * unit_scale(q_scale) * unit_scale(k_scale)
# Whole pool as MSA paged KV: [num_phys_pages, num_kv_heads, P, head_dim]. # Whole pool as MSA paged KV: [num_phys_pages, num_kv_heads, P, head_dim].
n_phys_pages = max_slots // P n_phys_pages = max_slots // P
@@ -138,6 +179,7 @@ def msa_sparse_prefill_main(
kv_block_num=topk, kv_block_num=topk,
causal=True, causal=True,
qo_offset=prefix_lens.to(torch.int32), qo_offset=prefix_lens.to(torch.int32),
use_fp8_kvcache=is_fp8,
) )
o, _ = fmha_sm100( o, _ = fmha_sm100(
q, q,
@@ -148,6 +190,10 @@ def msa_sparse_prefill_main(
kv_indices=kv_indices, kv_indices=kv_indices,
kv_block_indexes=kv_block_indexes, kv_block_indexes=kv_block_indexes,
) )
# The cute (long-q) sparse prefill backend honors sm_scale only; apply the
# V dequant scale on the output (exact: softmax normalization excludes V).
if v_scale != 1.0:
o = o * v_scale
return o return o
@@ -159,6 +205,7 @@ def build_msa_decode_meta(
num_q_heads: int, num_q_heads: int,
block_size_k: int, block_size_k: int,
topk: int, topk: int,
is_fp8: bool = False,
): ):
"""Per-forward MSA decode metadata (page table + fmha plan), shared across layers. """Per-forward MSA decode metadata (page table + fmha plan), shared across layers.
@@ -187,25 +234,28 @@ def build_msa_decode_meta(
kv_block_num=topk, kv_block_num=topk,
causal=False, causal=False,
qo_offset=seq_lens_i32 - 1, # decode query sits at the last cached position qo_offset=seq_lens_i32 - 1, # decode query sits at the last cached position
use_fp8_kvcache=is_fp8,
) )
return kv_indices, plan return kv_indices, plan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Eager-only MSA decode plan (NOT used under CUDA graph) # MSA decode plan (persistent per batch size; used by eager decode AND under
# CUDA graph)
# #
# WARNING: the fmha_sm100 sparse decode kernel is NOT cuda-graph-safe — captured # History: MSA decode under CUDA graph was disabled after silently wrong
# and replayed it returns silently wrong results that compound across replays # results (~14% GSM8K on B200). Root cause (2026-07): the topk producers
# (~14% GSM8K loss on B200). The backend routes decode to the cuda-graph-safe # emitted block ids in score order, violating fmha_sm100's strictly-ascending
# Triton sparse path whenever decode runs under a CUDA graph (see # kv_block_indexes contract — its sorted-order early-exit then mis-masked the
# MiniMaxSparseAttnBackend._use_msa_decode); this plan is reachable ONLY in eager # partial last block for any row with seq_len > topk*block_size. The producers
# decode (no decode CUDA graph), where there is no capture/replay. Do NOT wire it # now sort ascending (minimax_decode_topk.cuh, _topk_index_merge_kernel,
# back into a captured graph — that reintroduces the ~14% regression. # prefill _topk_index_kernel), and capture/replay of the full pipeline is
# bit-exact vs eager (see tests/repro_msa_decode_degenerate.py).
# #
# The build-once / replay-update structure below (refreshing the four length # The build-once / update-in-place structure below refreshes the four length
# tensors ``{kv_segment_lens, kv_segment_offsets, kv_page_indptr, qo_offset}`` and # tensors ``{kv_segment_lens, kv_segment_offsets, kv_page_indptr, qo_offset}``
# the page table in place) is a leftover from the abandoned capture-once attempt; # and the page table each forward; the captured graph reads the same tensor
# it is kept only because eager decode reuses one per-forward plan across layers. # addresses on replay.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_MSA_CG_LEN_KEYS = ( _MSA_CG_LEN_KEYS = (
@@ -242,6 +292,7 @@ def build_msa_decode_cg_plan(
topk: int, topk: int,
batch_size: int, batch_size: int,
device: Optional[torch.device] = None, device: Optional[torch.device] = None,
is_fp8: bool = False,
): ):
"""Persistent fmha_sm100 decode plan for one batch size (CUDA-graph stable). """Persistent fmha_sm100 decode plan for one batch size (CUDA-graph stable).
@@ -264,6 +315,7 @@ def build_msa_decode_cg_plan(
causal=False, causal=False,
qo_offset=kv - 1, qo_offset=kv - 1,
device=device, device=device,
use_fp8_kvcache=is_fp8,
) )
_check_cg_plan_layout(plan) _check_cg_plan_layout(plan)
return plan return plan
@@ -280,37 +332,53 @@ def update_msa_decode_cg_meta(
num_q_heads: int, num_q_heads: int,
num_kv_heads: int, num_kv_heads: int,
): ):
"""Refresh the persistent decode plan's length-dependent tensors + page table IN PLACE. """Refresh the persistent decode plan's length-dependent tensors + page table
IN PLACE, entirely with device-side ops (no host<->device sync).
Host-side (calls fmha_sm100_plan and one ``.item()``); MUST run outside CUDA-graph Runs once per decode forward from ``init_forward_metadata_out_graph``; the
capture — i.e. only from ``init_forward_metadata_out_graph``. The captured graph then captured graph then reads the same plan-tensor and ``kv_indices_buf``
reads the same plan-tensor and ``kv_indices_buf`` addresses on replay. addresses on replay. A device sync here stalls the overlap scheduler, so the
previous implementation — a throwaway ``fmha_sm100_plan`` build per step
(``.tolist()``/``.item()`` syncs + a plan-kernel launch) just to copy four
length tensors — is replaced by computing their contents directly, matching
``_fmha_sm100_plan``'s sparse-decode (qo_len==1, causal=False) layout:
kv_segment_lens = seq_lens
kv_segment_offsets = [0, cumsum(seq_lens)]
kv_page_indptr = [0, cumsum(ceil(seq_lens / P))]
qo_offset = broadcast max(seq_lens) (causal=False planner quirk)
The worklist tensors stay untouched: the plan schedules from the constant
``topk * P`` per request, never the real lengths (see build_msa_decode_cg_plan).
""" """
P = block_size_k P = block_size_k
B = seq_lens.shape[0] B = seq_lens.shape[0]
if B == 0: # idle batch: serving guards this, but keep the helper total
return
pd = plan[3]
seq_lens_i32 = seq_lens.to(torch.int32) seq_lens_i32 = seq_lens.to(torch.int32)
# Fresh plan for the real lengths; copy only its four length-dependent tensors into the pd["kv_segment_lens"].copy_(seq_lens_i32)
# persistent plan (same shapes — they depend on batch size, not length). The fresh kv_off = pd["kv_segment_offsets"]
# worklist is identical to the persistent one (topk*P based) and is discarded. kv_off[0].zero_()
# qo_offset is clamped: graph replay pads the batch with seq_len==0 slots torch.cumsum(seq_lens_i32, 0, out=kv_off[1:])
# (masked via kv_segment_lens==0, but seq_len-1 would be -1). n_pages = torch.div(seq_lens_i32 + (P - 1), P, rounding_mode="floor")
fresh = _run_fmha_sm100_plan( indptr = pd["kv_page_indptr"]
torch.ones(B, dtype=torch.int32), indptr[0].zero_()
seq_lens_i32, torch.cumsum(n_pages, 0, out=indptr[1:])
num_q_heads, pd["qo_offset"].copy_(seq_lens_i32.max().expand(B))
num_kv_heads=num_kv_heads,
page_size=P, # Page table, sync-free: fill the WHOLE persistent buffer (fixed size, no
kv_block_num=topk, # host-side total-page count). Packed slot -> (request, logical page) via
causal=False, # searchsorted; slots beyond the live page count land on clamped reads and
qo_offset=(seq_lens_i32 - 1).clamp_min(0), # are never dereferenced (the kernel walks kv_page_indptr ranges only).
device=seq_lens.device, n = kv_indices_buf.numel()
) idx = torch.arange(n, device=kv_indices_buf.device)
_check_cg_plan_layout(fresh) ends = torch.cumsum(n_pages.to(torch.int64), 0)
pd, fd = plan[3], fresh[3] req = torch.searchsorted(ends, idx, right=True).clamp_max_(B - 1)
for k in _MSA_CG_LEN_KEYS: starts = ends - n_pages
pd[k].copy_(fd[k]) logical_first = ((idx - starts[req]) * P).clamp_(0, req_to_token.shape[1] - 1)
table = _build_page_table(req_to_token, slot_ids, seq_lens, P) rows = slot_ids[req].to(torch.int64)
kv_indices_buf[: table.numel()].copy_(table) kv_indices_buf.copy_((req_to_token[rows, logical_first] // P).to(torch.int32))
def msa_sparse_decode_main( def msa_sparse_decode_main(
@@ -327,18 +395,27 @@ def msa_sparse_decode_main(
torch.Tensor torch.Tensor
] = None, # precomputed page table (per-forward cache) ] = None, # precomputed page table (per-forward cache)
plan=None, # precomputed fmha_sm100 plan (per-forward cache) plan=None, # precomputed fmha_sm100 plan (per-forward cache)
q_scale: Optional[float] = None,
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
) -> torch.Tensor: ) -> torch.Tensor:
"""Drop-in for flash_decode_with_gqa_share_sparse using MSA fmha_sm100. """Drop-in for flash_decode_with_gqa_share_sparse using MSA fmha_sm100.
Each request is one decode query at absolute position seq_len-1 attending to its Each request is one decode query at absolute position seq_len-1 attending to its
cached KV through the topk selected 128-blocks. Returns o [batch, num_q_heads, head_dim]. cached KV through the topk selected 128-blocks. Returns o [batch, num_q_heads,
head_dim] (bf16 for fp8 inputs).
``kv_indices`` / ``plan`` are shared across all sparse layers of a forward; the serving ``kv_indices`` / ``plan`` are shared across all sparse layers of a forward; the serving
backend builds them once via ``build_msa_decode_cg_plan`` + ``update_msa_decode_cg_meta`` backend builds them once via ``build_msa_decode_cg_plan`` + ``update_msa_decode_cg_meta``
(eager decode only) and passes them in. When omitted (only the standalone parity (eager decode only) and passes them in. When omitted (only the standalone parity
harnesses) they are built here via ``build_msa_decode_meta``. harnesses) they are built here via ``build_msa_decode_meta``.
Scales (None = unit) are passed natively: the cutlass decode path folds
q_scale*k_scale into the softmax scale and applies v_scale on the output
in-kernel.
""" """
fmha_sm100, _ = _load_fmha_sm100() fmha_sm100, _ = _load_fmha_sm100()
_check_msa_dtypes(q, k_cache, v_cache)
max_slots, num_kv_heads, head_dim = k_cache.shape max_slots, num_kv_heads, head_dim = k_cache.shape
H = q.shape[1] H = q.shape[1]
@@ -355,7 +432,14 @@ def msa_sparse_decode_main(
if kv_indices is None or plan is None: if kv_indices is None or plan is None:
kv_indices, plan = build_msa_decode_meta( kv_indices, plan = build_msa_decode_meta(
k_cache, req_to_token, slot_ids, seq_lens, H, P, topk k_cache,
req_to_token,
slot_ids,
seq_lens,
H,
P,
topk,
is_fp8=q.dtype == torch.float8_e4m3fn,
) )
kv_block_indexes = topk_idx.permute(1, 0, 2).contiguous().to(torch.int32) kv_block_indexes = topk_idx.permute(1, 0, 2).contiguous().to(torch.int32)
@@ -365,6 +449,9 @@ def msa_sparse_decode_main(
v_paged, v_paged,
plan, plan,
sm_scale=sm_scale, sm_scale=sm_scale,
q_scale=unit_scale(q_scale),
k_scale=unit_scale(k_scale),
v_scale=unit_scale(v_scale),
kv_indices=kv_indices, kv_indices=kv_indices,
kv_block_indexes=kv_block_indexes, kv_block_indexes=kv_block_indexes,
) )
@@ -0,0 +1,455 @@
"""Unit tests for fp8 (fp8 attn-GEMM mode) support in the M3 sparse Triton kernels.
Strategy: quantize random bf16 tensors to fp8_e4m3fn, then compare the fp8
kernel run against the SAME kernel run in bf16 on the *dequantized* tensors.
Both runs see numerically identical Q/K values, so the QK GEMMs match closely
and top-k selection is stable; the only intended divergence is the fp8 PV MMA
(P quantized to e4m3), which the tolerances cover. This isolates kernel
arithmetic from quantization error.
Covers: step-3 decode/prefill (gqa-share sparse), step-1 decode/prefill
indexer, non-unit k_scale/v_scale semantics, and bf16-path regression.
"""
import pytest
import torch
from sglang.srt.layers.attention.minimax_sparse_ops.decode.flash_with_topk_idx import (
flash_decode_with_topk_idx,
)
from sglang.srt.layers.attention.minimax_sparse_ops.decode.topk_sparse import (
flash_decode_with_gqa_share_sparse,
)
from sglang.srt.layers.attention.minimax_sparse_ops.prefill.flash_with_topk_idx import (
flash_prefill_with_topk_index,
)
from sglang.srt.layers.attention.minimax_sparse_ops.prefill.topk_sparse import (
flash_prefill_with_gqa_share_sparse,
)
DEVICE = "cuda"
FP8 = torch.float8_e4m3fn
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="requires CUDA (Triton fp8 kernels)"
)
# fp8 PV (P quantized to e4m3, ~3-bit mantissa on [0,1] weights) dominates the
# fp8-vs-dequantized-ref error; QK matches to fp32-accumulation noise.
FP8_ATOL = 6e-2
FP8_RTOL = 6e-2
# widening mode (bf16 Q, fp8 KV) computes on exactly the dequantized values.
WIDEN_ATOL = 1e-3
WIDEN_RTOL = 1e-3
def qdq(x: torch.Tensor):
"""Quantize to e4m3 and return (fp8, dequantized-bf16) views of it."""
x8 = x.to(FP8)
return x8, x8.to(torch.bfloat16)
def build_decode_inputs(
batch_size=4,
num_q_heads=8,
num_kv_heads=1,
head_dim=128,
block_size=128,
topk=8,
seq_lens_list=(513, 1023, 257, 769),
):
seq_lens_list = list(seq_lens_list)[:batch_size]
max_kv_len = max(seq_lens_list)
max_slots = batch_size * max_kv_len
q = torch.randn(
batch_size, num_q_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
k = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
v = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
req_to_token = torch.zeros(batch_size, max_kv_len, dtype=torch.int32, device=DEVICE)
slot_ids = torch.arange(batch_size, dtype=torch.int64, device=DEVICE)
seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=DEVICE)
for i in range(batch_size):
base = i * max_kv_len
req_to_token[i] = (torch.randperm(max_kv_len, device=DEVICE) + base).to(
torch.int32
)
topk_idx = torch.full(
(num_kv_heads, batch_size, topk), -1, dtype=torch.int32, device=DEVICE
)
for kh in range(num_kv_heads):
for b in range(batch_size):
nb = (seq_lens_list[b] + block_size - 1) // block_size
ak = min(topk, nb)
# sorted ascending, matching the production topk contract
sel = torch.randperm(nb, device=DEVICE)[:ak].sort().values
topk_idx[kh, b, :ak] = sel.to(torch.int32)
return q, k, v, req_to_token, seq_lens, slot_ids, topk_idx
def build_prefill_inputs(
batch_size=2,
num_q_heads=8,
num_kv_heads=1,
head_dim=128,
seq_lens_list=(513, 769),
prefix_lens_list=(0, 257),
):
seq_lens_list = list(seq_lens_list)[:batch_size]
prefix_lens_list = list(prefix_lens_list)[:batch_size]
q_lens = [s - p for s, p in zip(seq_lens_list, prefix_lens_list)]
total_q = sum(q_lens)
max_kv_len = max(seq_lens_list)
max_slots = batch_size * max_kv_len
q = torch.randn(total_q, num_q_heads, head_dim, dtype=torch.bfloat16, device=DEVICE)
k = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
v = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
req_to_token = torch.zeros(batch_size, max_kv_len, dtype=torch.int32, device=DEVICE)
slot_ids = torch.arange(batch_size, dtype=torch.int64, device=DEVICE)
for i in range(batch_size):
base = i * max_kv_len
req_to_token[i] = (torch.randperm(max_kv_len, device=DEVICE) + base).to(
torch.int32
)
cu_seqlens = torch.zeros(batch_size + 1, dtype=torch.int32, device=DEVICE)
cu_seqlens[1:] = torch.tensor(q_lens, device=DEVICE).cumsum(0)
seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=DEVICE)
prefix_lens = torch.tensor(prefix_lens_list, dtype=torch.int32, device=DEVICE)
return (
q,
k,
v,
req_to_token,
slot_ids,
cu_seqlens,
seq_lens,
prefix_lens,
max(q_lens),
max(seq_lens_list),
)
def run_step3_decode(q, k, v, req_to_token, seq_lens, slot_ids, topk_idx, **kw):
return flash_decode_with_gqa_share_sparse(
q, None, k, v, req_to_token, seq_lens, slot_ids, 128, topk_idx, **kw
)
# ---------------------------------------------------------------------------
# step-3 decode (gqa-share sparse)
# ---------------------------------------------------------------------------
def test_step3_decode_all_fp8_vs_dequant_ref():
torch.manual_seed(0)
q, k, v, r2t, seq_lens, sids, tidx = build_decode_inputs()
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
out8 = run_step3_decode(q8, k8, v8, r2t, seq_lens, sids, tidx)
ref = run_step3_decode(qr, kr, vr, r2t, seq_lens, sids, tidx)
assert out8.dtype == torch.bfloat16
torch.testing.assert_close(out8.float(), ref.float(), atol=FP8_ATOL, rtol=FP8_RTOL)
def test_step3_decode_widening_mode():
torch.manual_seed(1)
q, k, v, r2t, seq_lens, sids, tidx = build_decode_inputs()
k8, kr = qdq(k)
v8, vr = qdq(v)
out = run_step3_decode(q, k8, v8, r2t, seq_lens, sids, tidx)
ref = run_step3_decode(q, kr, vr, r2t, seq_lens, sids, tidx)
torch.testing.assert_close(
out.float(), ref.float(), atol=WIDEN_ATOL, rtol=WIDEN_RTOL
)
def test_step3_decode_scales():
torch.manual_seed(2)
q, k, v, r2t, seq_lens, sids, tidx = build_decode_inputs()
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
q_scale, k_scale, v_scale = 1.5, 0.5, 2.0
out8 = run_step3_decode(
q8,
k8,
v8,
r2t,
seq_lens,
sids,
tidx,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
)
# reference: bf16 kernel on pre-scaled dequantized Q/K/V (scale semantics:
# the tensor stores value/scale; attention runs on value = stored * scale)
ref = run_step3_decode(
(qr * q_scale).to(torch.bfloat16),
(kr * k_scale).to(torch.bfloat16),
(vr * v_scale).to(torch.bfloat16),
r2t,
seq_lens,
sids,
tidx,
)
torch.testing.assert_close(
out8.float(), ref.float(), atol=FP8_ATOL * v_scale, rtol=FP8_RTOL * v_scale
)
def test_step3_decode_bf16_regression():
torch.manual_seed(3)
q, k, v, r2t, seq_lens, sids, tidx = build_decode_inputs()
out = run_step3_decode(q, k, v, r2t, seq_lens, sids, tidx)
out_scaled = run_step3_decode(
q, k, v, r2t, seq_lens, sids, tidx, q_scale=1.0, k_scale=1.0, v_scale=1.0
)
assert out.dtype == q.dtype
torch.testing.assert_close(out, out_scaled, atol=0, rtol=0)
# ---------------------------------------------------------------------------
# step-3 prefill (gqa-share sparse)
# ---------------------------------------------------------------------------
def run_step3_prefill(q, k, v, r2t, sids, tidx, cu, seq_lens, prefix, max_q, **kw):
return flash_prefill_with_gqa_share_sparse(
q=q,
k_cache=k,
v_cache=v,
sink=None,
req_to_token=r2t,
slot_ids=sids,
topk_idx=tidx,
block_size_q=1,
block_size_k=128,
cu_seqlens=cu,
seq_lens=seq_lens,
prefix_lens=prefix,
max_seqlen_q=max_q,
**kw,
)
def _prefill_topk_idx(cu_seqlens, seq_lens, prefix_lens, num_kv_heads, topk, block):
# per-token (block_size_q=1) causal topk: for query at absolute position p,
# any blocks with start <= p, sorted ascending, -1 padded.
total_q = int(cu_seqlens[-1].item())
tidx = torch.full(
(num_kv_heads, total_q, topk), -1, dtype=torch.int32, device=DEVICE
)
row = 0
for b in range(len(seq_lens)):
q_len = int(cu_seqlens[b + 1] - cu_seqlens[b])
prefix = int(prefix_lens[b])
for j in range(q_len):
nb = (prefix + j) // block + 1 # blocks fully/partially before pos
ak = min(topk, nb)
sel = torch.randperm(nb, device=DEVICE)[:ak].sort().values
for kh in range(num_kv_heads):
tidx[kh, row, :ak] = sel.to(torch.int32)
row += 1
return tidx
def test_step3_prefill_all_fp8_vs_dequant_ref():
torch.manual_seed(4)
q, k, v, r2t, sids, cu, seq_lens, prefix, max_q, _ = build_prefill_inputs()
tidx = _prefill_topk_idx(cu.cpu(), seq_lens.cpu(), prefix.cpu(), 1, 8, 128)
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
out8 = run_step3_prefill(q8, k8, v8, r2t, sids, tidx, cu, seq_lens, prefix, max_q)
ref = run_step3_prefill(qr, kr, vr, r2t, sids, tidx, cu, seq_lens, prefix, max_q)
assert out8.dtype == torch.bfloat16
torch.testing.assert_close(out8.float(), ref.float(), atol=FP8_ATOL, rtol=FP8_RTOL)
def test_step3_prefill_scales():
torch.manual_seed(5)
q, k, v, r2t, sids, cu, seq_lens, prefix, max_q, _ = build_prefill_inputs()
tidx = _prefill_topk_idx(cu.cpu(), seq_lens.cpu(), prefix.cpu(), 1, 8, 128)
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
q_scale, k_scale, v_scale = 1.5, 0.5, 2.0
out8 = run_step3_prefill(
q8,
k8,
v8,
r2t,
sids,
tidx,
cu,
seq_lens,
prefix,
max_q,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
)
ref = run_step3_prefill(
(qr * q_scale).to(torch.bfloat16),
(kr * k_scale).to(torch.bfloat16),
(vr * v_scale).to(torch.bfloat16),
r2t,
sids,
tidx,
cu,
seq_lens,
prefix,
max_q,
)
# v_scale multiplies the fp8 kernel's quantized-P PV output, amplifying
# the P-quantization error by the same factor vs the pre-scaled bf16 ref.
torch.testing.assert_close(
out8.float(), ref.float(), atol=FP8_ATOL * v_scale, rtol=FP8_RTOL * v_scale
)
# ---------------------------------------------------------------------------
# step-1 decode indexer
# ---------------------------------------------------------------------------
def run_indexer_decode(q, k, v, r2t, seq_lens, sids, **kw):
return flash_decode_with_topk_idx(
q=q,
sink=None,
k_cache=k,
v_cache=v,
req_to_token=r2t,
seq_lens=seq_lens,
slot_ids=sids,
max_seqlen=int(seq_lens.max().item()),
block_size=128,
topk=4,
init_blocks=1,
local_blocks=1,
**kw,
)
def _topk_overlap(a: torch.Tensor, b: torch.Tensor) -> float:
"""Mean per-row overlap of the selected (non-negative) block-id sets."""
total, hit = 0, 0
af, bf = a.reshape(-1, a.shape[-1]), b.reshape(-1, b.shape[-1])
for i in range(af.shape[0]):
sa = set(af[i][af[i] >= 0].tolist())
sb = set(bf[i][bf[i] >= 0].tolist())
if not sa and not sb:
continue
total += max(len(sa), len(sb))
hit += len(sa & sb)
return hit / max(total, 1)
def test_indexer_decode_all_fp8_vs_dequant_ref():
torch.manual_seed(6)
q, k, v, r2t, seq_lens, sids, _ = build_decode_inputs(
num_q_heads=1, num_kv_heads=1, seq_lens_list=(1023, 769, 513, 257)
)
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
o8, tidx8, _ = run_indexer_decode(q8, k8, v8, r2t, seq_lens, sids)
oref, tidxref, _ = run_indexer_decode(qr, kr, vr, r2t, seq_lens, sids)
assert o8.dtype == torch.bfloat16
# o is the full (non-sparse) indexer attention output — independent of the
# topk side-channel — so it must track the dequantized reference.
torch.testing.assert_close(o8.float(), oref.float(), atol=FP8_ATOL, rtol=FP8_RTOL)
# topk selection runs on QK scores that only differ by fp32-accumulation
# noise; require near-total agreement (ties may flip an occasional block).
assert _topk_overlap(tidx8, tidxref) >= 0.9
def test_indexer_decode_score_only_fp8():
torch.manual_seed(7)
q, k, _, r2t, seq_lens, sids, _ = build_decode_inputs(
batch_size=2, num_q_heads=1, num_kv_heads=1, seq_lens_list=(1023, 769)
)
q8, qr = qdq(q)
k8, kr = qdq(k)
o8, tidx8, _ = run_indexer_decode(
q8, k8, None, r2t, seq_lens, sids, disable_index_value=True
)
oref, tidxref, _ = run_indexer_decode(
qr, kr, None, r2t, seq_lens, sids, disable_index_value=True
)
assert o8 is None and oref is None
assert _topk_overlap(tidx8, tidxref) >= 0.9
# ---------------------------------------------------------------------------
# step-1 prefill indexer
# ---------------------------------------------------------------------------
def run_indexer_prefill(q, k, v, r2t, sids, cu, seq_lens, prefix, max_q, max_k, **kw):
return flash_prefill_with_topk_index(
q=q,
k_cache=k,
v_cache=v,
sink=None,
req_to_token=r2t,
slot_ids=sids,
cu_seqlens=cu,
seq_lens=seq_lens,
prefix_lens=prefix,
max_seqlen_q=max_q,
max_seqlen_k=max_k,
block_size_q=1,
block_size_k=128,
topk=4,
init_blocks=1,
local_blocks=1,
**kw,
)
def test_indexer_prefill_all_fp8_vs_dequant_ref():
torch.manual_seed(8)
q, k, v, r2t, sids, cu, seq_lens, prefix, max_q, max_k = build_prefill_inputs(
num_q_heads=1, num_kv_heads=1
)
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
o8, tidx8 = run_indexer_prefill(
q8, k8, v8, r2t, sids, cu, seq_lens, prefix, max_q, max_k
)
oref, tidxref = run_indexer_prefill(
qr, kr, vr, r2t, sids, cu, seq_lens, prefix, max_q, max_k
)
assert o8.dtype == torch.bfloat16
torch.testing.assert_close(o8.float(), oref.float(), atol=FP8_ATOL, rtol=FP8_RTOL)
assert _topk_overlap(tidx8, tidxref) >= 0.9
def test_dtype_contract_rejects_e5m2_q():
q = torch.randn(2, 8, 128, dtype=torch.bfloat16, device=DEVICE).to(
torch.float8_e5m2
)
k = torch.randn(256, 1, 128, dtype=torch.bfloat16, device=DEVICE).to(FP8)
from sglang.kernels.ops.attention.minimax_sparse.common.utils import (
check_sparse_kv_fp8,
)
with pytest.raises(AssertionError):
check_sparse_kv_fp8(q, k, None, label="test")
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,286 @@
"""Parity tests for MSA (fmha_sm100) all-fp8 sparse attention (fp8 attn-GEMM mode).
No upstream fp8 test exists for fmha_sm100's cutlass sparse-decode path, so
this is the reference check: MSA fp8 vs the Triton fp8 sparse kernels on the
same quantized tensors, plus fp8-vs-bf16 error bounds and CUDA-graph
capture/replay bit-exactness of the fp8 decode.
Both fp8 paths quantize the unnormalized softmax P to e4m3 before the PV MMA,
but their QK/accumulation orders differ, so MSA-fp8 vs Triton-fp8 tolerances
cover two independent P-quantization errors (~1e-1 worst-case elementwise).
Requires SM100 + fmha_sm100 (first run JIT-compiles the fp8 variants).
Run: pytest python/sglang/srt/layers/attention/minimax_sparse_ops/tests/test_msa_fp8_parity.py -v
"""
import pytest
import torch
from sglang.srt.layers.attention.minimax_sparse_ops.decode.topk_sparse import (
flash_decode_with_gqa_share_sparse,
)
from sglang.srt.layers.attention.minimax_sparse_ops.msa import (
build_msa_decode_cg_plan,
msa_available,
msa_sparse_decode_main,
msa_sparse_prefill_main,
update_msa_decode_cg_meta,
)
from sglang.srt.layers.attention.minimax_sparse_ops.prefill.topk_sparse import (
flash_prefill_with_gqa_share_sparse,
)
DEVICE = "cuda"
FP8 = torch.float8_e4m3fn
P = 128 # sparse block == page size
# two independent e4m3-P quantizations (MSA + Triton)
X_ATOL = 1e-1
X_RTOL = 1e-1
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available() or not msa_available(),
reason="requires SM100 + fmha_sm100",
)
def qdq(x):
x8 = x.to(FP8)
return x8, x8.to(torch.bfloat16)
def build_paged_inputs(seq_lens_list, num_q_heads=16, num_kv_heads=1, head_dim=128):
"""Page-aligned pool: each logical 128-token page maps to one physical page
(contiguous 128 slots), as MSA's page-table builder requires."""
batch = len(seq_lens_list)
pages_per_req = [(s + P - 1) // P for s in seq_lens_list]
max_pages = max(pages_per_req)
total_pages = batch * max_pages
max_slots = total_pages * P
page_perm = torch.randperm(total_pages, device=DEVICE)
req_to_token = torch.zeros(batch, max_pages * P, dtype=torch.int32, device=DEVICE)
for b in range(batch):
for p in range(max_pages):
phys = page_perm[b * max_pages + p]
req_to_token[b, p * P : (p + 1) * P] = phys * P + torch.arange(
P, device=DEVICE
)
k = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
v = torch.randn(
max_slots, num_kv_heads, head_dim, dtype=torch.bfloat16, device=DEVICE
)
slot_ids = torch.arange(batch, dtype=torch.int64, device=DEVICE)
seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=DEVICE)
return k, v, req_to_token, slot_ids, seq_lens
def make_topk_idx(seq_lens_list, num_kv_heads, rows_per_req, topk):
"""Sorted-ascending causal-safe topk over each request's block count.
rows_per_req[b] = number of query rows for request b (1 for decode)."""
total_rows = sum(rows_per_req)
tidx = torch.full(
(num_kv_heads, total_rows, topk), -1, dtype=torch.int32, device=DEVICE
)
row = 0
for b, s in enumerate(seq_lens_list):
nb = (s + P - 1) // P
for _ in range(rows_per_req[b]):
ak = min(topk, nb)
sel = torch.randperm(nb, device=DEVICE)[:ak].sort().values
for kh in range(num_kv_heads):
tidx[kh, row, :ak] = sel.to(torch.int32)
row += 1
return tidx
# ---------------------------------------------------------------------------
# decode
# ---------------------------------------------------------------------------
def _decode_setup(seq_lens_list=(1023, 769, 513, 130), topk=4):
"""topk*P = 512 < 1023/769: exercises the degenerate partial-last-block
masking (the historical unsorted-topk failure shape)."""
torch.manual_seed(0)
batch = len(seq_lens_list)
k, v, r2t, sids, seq_lens = build_paged_inputs(seq_lens_list)
q = torch.randn(batch, 16, 128, dtype=torch.bfloat16, device=DEVICE)
tidx = make_topk_idx(seq_lens_list, 1, [1] * batch, topk)
return q, k, v, r2t, sids, seq_lens, tidx
def test_msa_fp8_decode_vs_triton_fp8():
q, k, v, r2t, sids, seq_lens, tidx = _decode_setup()
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
o_msa = msa_sparse_decode_main(
q8, k8, v8, tidx, r2t, sids, seq_lens, block_size_k=P
)
o_triton = flash_decode_with_gqa_share_sparse(
q8, None, k8, v8, r2t, seq_lens, sids, P, tidx
)
assert o_msa.dtype == torch.bfloat16
torch.testing.assert_close(
o_msa.float(), o_triton.float(), atol=X_ATOL, rtol=X_RTOL
)
# bf16 MSA on the dequantized tensors bounds the pure fp8-kernel error
o_bf16 = msa_sparse_decode_main(
qr, kr, vr, tidx, r2t, sids, seq_lens, block_size_k=P
)
err = (o_msa.float() - o_bf16.float()).abs().mean()
ref = o_bf16.float().abs().mean()
assert err / ref < 0.06, f"mean rel err {err/ref:.4f} too high vs bf16 MSA"
def test_msa_fp8_decode_scales():
q, k, v, r2t, sids, seq_lens, tidx = _decode_setup()
q8, qr = qdq(q)
k8, kr = qdq(k)
v8, vr = qdq(v)
q_scale, k_scale, v_scale = 1.5, 0.5, 2.0
o = msa_sparse_decode_main(
q8,
k8,
v8,
tidx,
r2t,
sids,
seq_lens,
block_size_k=P,
q_scale=q_scale,
k_scale=k_scale,
v_scale=v_scale,
)
# reference: bf16 MSA on pre-scaled dequantized Q/K/V
ref = msa_sparse_decode_main(
(qr * q_scale).to(torch.bfloat16),
(kr * k_scale).to(torch.bfloat16),
(vr * v_scale).to(torch.bfloat16),
tidx,
r2t,
sids,
seq_lens,
block_size_k=P,
)
torch.testing.assert_close(
o.float(), ref.float(), atol=X_ATOL * v_scale, rtol=X_RTOL * v_scale
)
def test_msa_fp8_decode_capture_replay_bitexact():
q, k, v, r2t, sids, seq_lens, tidx = _decode_setup()
q8, _ = qdq(q)
k8, _ = qdq(k)
v8, _ = qdq(v)
bs = q8.shape[0]
nb_max = r2t.shape[1] // P
plan = build_msa_decode_cg_plan(
16, 1, P, tidx.shape[-1], bs, device=q8.device, is_fp8=True
)
kv_indices = torch.zeros(bs * nb_max, dtype=torch.int32, device=DEVICE)
update_msa_decode_cg_meta(
plan, kv_indices, r2t, sids, seq_lens, P, tidx.shape[-1], 16, 1
)
def run():
return msa_sparse_decode_main(
q8,
k8,
v8,
tidx,
r2t,
sids,
seq_lens,
block_size_k=P,
kv_indices=kv_indices,
plan=plan,
)
# eager warmups (also pays the fp8 JIT) on a side stream, as capture does
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(s):
for _ in range(2):
o_eager = run()
torch.cuda.current_stream().wait_stream(s)
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
o_graph = run()
g.replay()
torch.cuda.synchronize()
assert torch.equal(o_graph, o_eager), "fp8 MSA decode capture/replay not bit-exact"
# ---------------------------------------------------------------------------
# prefill (cutlass short-q and cute long-q branches)
# ---------------------------------------------------------------------------
def _prefill_setup(seq_lens_list, prefix_lens_list, topk=4):
torch.manual_seed(1)
k, v, r2t, sids, seq_lens = build_paged_inputs(seq_lens_list)
q_lens = [s - p for s, p in zip(seq_lens_list, prefix_lens_list)]
total_q = sum(q_lens)
q = torch.randn(total_q, 16, 128, dtype=torch.bfloat16, device=DEVICE)
cu = torch.zeros(len(q_lens) + 1, dtype=torch.int32, device=DEVICE)
cu[1:] = torch.tensor(q_lens, device=DEVICE).cumsum(0)
prefix = torch.tensor(prefix_lens_list, dtype=torch.int32, device=DEVICE)
# causal-valid per-token topk (block_size_q == 1)
tidx = torch.full((1, total_q, topk), -1, dtype=torch.int32, device=DEVICE)
row = 0
for b, (s, p) in enumerate(zip(seq_lens_list, prefix_lens_list)):
for j in range(s - p):
nb = (p + j) // P + 1
ak = min(topk, nb)
sel = torch.randperm(nb, device=DEVICE)[:ak].sort().values
tidx[0, row, :ak] = sel.to(torch.int32)
row += 1
return q, k, v, r2t, sids, cu, seq_lens, prefix, tidx, max(q_lens)
@pytest.mark.parametrize(
"seq_lens,prefix_lens,branch",
[
((530, 700), (500, 680), "cutlass_short_q"), # qo <= 32
((513, 769), (0, 257), "cute_long_q"), # qo > 32
],
ids=["cutlass_short_q", "cute_long_q"],
)
def test_msa_fp8_prefill_vs_triton_fp8(seq_lens, prefix_lens, branch):
q, k, v, r2t, sids, cu, seq_lens_t, prefix, tidx, max_q = _prefill_setup(
list(seq_lens), list(prefix_lens)
)
q8, _ = qdq(q)
k8, _ = qdq(k)
v8, _ = qdq(v)
o_msa = msa_sparse_prefill_main(
q8, k8, v8, tidx, r2t, sids, cu, seq_lens_t, prefix, block_size_k=P
)
o_triton = flash_prefill_with_gqa_share_sparse(
q=q8,
k_cache=k8,
v_cache=v8,
sink=None,
req_to_token=r2t,
slot_ids=sids,
topk_idx=tidx,
block_size_q=1,
block_size_k=P,
cu_seqlens=cu,
seq_lens=seq_lens_t,
prefix_lens=prefix,
max_seqlen_q=max_q,
)
assert o_msa.dtype == torch.bfloat16
torch.testing.assert_close(
o_msa.float(), o_triton.float(), atol=X_ATOL, rtol=X_RTOL
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v"]))
@@ -204,6 +204,32 @@ class TRTLLMHAAttnBackend(FlashInferAttnBackend):
# KV fp8: q_type = fp8, out_type=model_runner.dtype # KV fp8: q_type = fp8, out_type=model_runner.dtype
self.is_xqa_impl = is_sm90_supported() or is_sm120_supported() self.is_xqa_impl = is_sm90_supported() or is_sm120_supported()
# trtllm-gen serves page_size >= 128 only through its dynamic
# tokens-per-page kernels, which exist solely for GQA with equal QK/V
# head dims (power-of-2 pages). Mirror that precondition here so an
# unsupported combo fails at construction instead of as a
# "Missing TRTLLM-GEN kernel" error during CUDA-graph capture.
# XQA (SM90/SM120 decode) has native page-128 kernels; no check needed.
if self.page_size >= 128 and not self.is_xqa_impl:
from sglang.srt.runtime_context import get_parallel
attn_tp_size = get_parallel().attn_tp_size
num_q_heads = config.num_attention_heads // attn_tp_size
num_kv_heads = config.get_num_kv_heads(attn_tp_size)
if (
num_q_heads // num_kv_heads <= 1
or config.head_dim != config.v_head_dim
or self.page_size & (self.page_size - 1) != 0
):
raise ValueError(
f"trtllm_mha with page_size={self.page_size} requires "
f"trtllm-gen's dynamic tokens-per-page kernels, which only "
f"support GQA (q heads per kv head > 1, got "
f"{num_q_heads}/{num_kv_heads}) with equal QK/V head dims "
f"(got {config.head_dim}/{config.v_head_dim}) and a "
f"power-of-2 page size. Use --page-size 64 instead."
)
def _check_decode_kv_access(self) -> None: def _check_decode_kv_access(self) -> None:
supported_kinds = { supported_kinds = {
KVCacheAttentionAccessKind.PLAIN, KVCacheAttentionAccessKind.PLAIN,
@@ -128,6 +128,13 @@ class RadixAttention(nn.Module):
self.v_scale = None self.v_scale = None
self.k_scale_float = None self.k_scale_float = None
self.v_scale_float = None self.v_scale_float = None
# MiniMax-M3 fp8 attention-GEMM scales (fp8 attn-GEMM mode): main q and
# lightning-indexer q/k/v. No checkpoint loader populates them yet;
# None means unit scale.
self.q_scale_float = None
self.idx_q_scale_float = None
self.idx_k_scale_float = None
self.idx_v_scale_float = None
self.quant_method = None self.quant_method = None
if quant_config is not None: if quant_config is not None:
@@ -397,11 +397,18 @@ class HybridCacheController(BaseHiCacheController):
if not self.write_queue: if not self.write_queue:
return return
op = CacheOperation.merge_ops(self.write_queue) op = CacheOperation.merge_ops(self.write_queue)
# Page-first write-back JIT kernels can keep destination host indices on CPU. # Page-first staged write-back kernels need CPU destination host indices.
# A HostPoolGroup may mix staged and non-staged child pools, so let it
# normalize indices per child instead of moving the whole operation here.
if ( if (
self.io_backend == "kernel" self.io_backend == "kernel"
and self.mem_pool_host.layout == "page_first" and self.mem_pool_host.layout == "page_first"
and getattr(self.mem_pool_host, "can_use_write_back_jit", False) and (
getattr(self.mem_pool_host, "can_use_write_back_jit", False)
or getattr(
self.mem_pool_host, "supports_per_pool_backup_indices", False
)
)
): ):
host_indices = op.host_indices host_indices = op.host_indices
device_indices = op.device_indices device_indices = op.device_indices
@@ -1271,6 +1271,8 @@ class KVCacheConfigurator:
return token_to_kv_pool return token_to_kv_pool
def _build_minimax_sparse_kv_pool(self, *, max_total_num_tokens: int) -> KVCache: def _build_minimax_sparse_kv_pool(self, *, max_total_num_tokens: int) -> KVCache:
from sglang.srt.server_args import m3_fp8_attn_gemm_enabled
_hf_config = self.model_config.hf_config _hf_config = self.model_config.hf_config
sparse_cfg = get_minimax_sparse_attention_config(_hf_config) sparse_cfg = get_minimax_sparse_attention_config(_hf_config)
dense_layer_ids, sparse_layer_ids = get_minimax_sparse_layer_ids(sparse_cfg) dense_layer_ids, sparse_layer_ids = get_minimax_sparse_layer_ids(sparse_cfg)
@@ -1281,7 +1283,15 @@ class KVCacheConfigurator:
size=max_total_num_tokens, size=max_total_num_tokens,
page_size=get_schedule().page_size, page_size=get_schedule().page_size,
dtype=self.kv_cache_dtype, dtype=self.kv_cache_dtype,
index_dtype=self.model_dtype, # fp8 attn-GEMM mode opts the lightning-indexer cache into
# fp8 too (fp8 indexer GEMMs); fp8 KV without the mode
# (e5m2 or non-trtllm_mha backend) keeps the indexer bf16
# with the widening-dequant contract.
index_dtype=(
self.kv_cache_dtype
if m3_fp8_attn_gemm_enabled(self.server_args)
else self.model_dtype
),
head_num=self.model_config.get_num_kv_heads(get_parallel().attn_tp_size), head_num=self.model_config.get_num_kv_heads(get_parallel().attn_tp_size),
head_dim=self.model_config.head_dim, head_dim=self.model_config.head_dim,
idx_head_dim=sparse_cfg["sparse_index_dim"], idx_head_dim=sparse_cfg["sparse_index_dim"],
+37 -9
View File
@@ -4810,6 +4810,11 @@ class MiniMaxSparseKVPool(KVCache):
) -> None: ) -> None:
self.layer_transfer_counter = layer_transfer_counter self.layer_transfer_counter = layer_transfer_counter
def get_kv_cache_quant_method(self) -> Any:
# The base unwrap chain only knows full_kv_pool/swa_kv_pool; the dense
# KV (what attention backends quantize against) lives in main_pool here.
return self.main_pool.get_kv_cache_quant_method()
def _wait_for_layer(self, layer_id: int) -> None: def _wait_for_layer(self, layer_id: int) -> None:
if self.layer_transfer_counter is not None: if self.layer_transfer_counter is not None:
self.layer_transfer_counter.wait_until(layer_id - self.start_layer) self.layer_transfer_counter.wait_until(layer_id - self.start_layer)
@@ -4858,10 +4863,14 @@ class MiniMaxSparseKVPool(KVCache):
loc: torch.Tensor, loc: torch.Tensor,
cache_k: torch.Tensor, cache_k: torch.Tensor,
cache_v: torch.Tensor, cache_v: torch.Tensor,
k_scale: float = 1.0, k_scale: Optional[float] = None,
v_scale: float = 1.0, v_scale: Optional[float] = None,
) -> None: ) -> None:
"""Write main K/V at `loc`. Works for any layer (dense or sparse).""" """Write main K/V at `loc`. Works for any layer (dense or sparse).
Scale semantics follow MHATokenToKVPool: None means unit scale;
a non-None scale is applied with an in-place div_ before the fp8 cast.
"""
self.main_pool.set_kv_buffer( self.main_pool.set_kv_buffer(
layer, layer,
loc, loc,
@@ -4877,8 +4886,8 @@ class MiniMaxSparseKVPool(KVCache):
loc: torch.Tensor, loc: torch.Tensor,
cache_idx_k: torch.Tensor, cache_idx_k: torch.Tensor,
cache_idx_v: torch.Tensor, cache_idx_v: torch.Tensor,
k_scale: float = 1.0, k_scale: Optional[float] = None,
v_scale: float = 1.0, v_scale: Optional[float] = None,
) -> None: ) -> None:
mapped_id = self.index_kv_layer_id_mapping.get(layer.layer_id) mapped_id = self.index_kv_layer_id_mapping.get(layer.layer_id)
if mapped_id is None: if mapped_id is None:
@@ -4902,6 +4911,7 @@ class MiniMaxSparseKVPool(KVCache):
layer: RadixAttention, layer: RadixAttention,
loc: torch.Tensor, loc: torch.Tensor,
cache_idx_k: torch.Tensor, cache_idx_k: torch.Tensor,
k_scale: Optional[float] = None,
) -> None: ) -> None:
mapped_id = self.index_k_layer_id_mapping.get(layer.layer_id) mapped_id = self.index_k_layer_id_mapping.get(layer.layer_id)
if mapped_id is None: if mapped_id is None:
@@ -4912,6 +4922,8 @@ class MiniMaxSparseKVPool(KVCache):
) )
sub_pool = self.index_k_pool sub_pool = self.index_k_pool
if cache_idx_k.dtype != sub_pool.dtype: if cache_idx_k.dtype != sub_pool.dtype:
if k_scale is not None:
cache_idx_k = cache_idx_k / k_scale
cache_idx_k = cache_idx_k.to(sub_pool.dtype) cache_idx_k = cache_idx_k.to(sub_pool.dtype)
if sub_pool.store_dtype != sub_pool.dtype: if sub_pool.store_dtype != sub_pool.dtype:
cache_idx_k = cache_idx_k.view(sub_pool.store_dtype) cache_idx_k = cache_idx_k.view(sub_pool.store_dtype)
@@ -4950,6 +4962,10 @@ class MiniMaxSparseKVPool(KVCache):
cache_v: torch.Tensor, cache_v: torch.Tensor,
cache_idx_k: torch.Tensor, cache_idx_k: torch.Tensor,
cache_idx_v: Optional[torch.Tensor], cache_idx_v: Optional[torch.Tensor],
k_scale: Optional[float] = None,
v_scale: Optional[float] = None,
idx_k_scale: Optional[float] = None,
idx_v_scale: Optional[float] = None,
) -> None: ) -> None:
"""Store main K/V + index K (+ optional index V) for a sparse layer in """Store main K/V + index K (+ optional index V) for a sparse layer in
one fused JIT launch, falling back to separate stores when not applicable.""" one fused JIT launch, falling back to separate stores when not applicable."""
@@ -4984,12 +5000,24 @@ class MiniMaxSparseKVPool(KVCache):
) )
return return
# Fallback: separate stores (identical semantics). # Fallback: separate stores (identical semantics; quantizes for fp8
self.set_kv_buffer(layer, loc, cache_k, cache_v) # pools — the fused raw-byte path is disqualified there by
# _can_fuse_kv_index_store's dtype-equality checks). Scales use the
# None-means-unit convention throughout: MHATokenToKVPool.set_kv_buffer
# applies any non-None scale with an IN-PLACE div_ (extra kernel +
# caller-tensor mutation), which must not fire for unit scale.
self.set_kv_buffer(layer, loc, cache_k, cache_v, k_scale, v_scale)
if disable_value: if disable_value:
self.set_index_k_buffer(layer, loc, cache_idx_k) self.set_index_k_buffer(layer, loc, cache_idx_k, idx_k_scale)
else: else:
self.set_index_kv_buffer(layer, loc, cache_idx_k, cache_idx_v) self.set_index_kv_buffer(
layer,
loc,
cache_idx_k,
cache_idx_v,
idx_k_scale,
idx_v_scale,
)
def get_kv_size_bytes(self): def get_kv_size_bytes(self):
sub_pools = [self.main_pool, self.index_kv_pool, self.index_k_pool] sub_pools = [self.main_pool, self.index_kv_pool, self.index_k_pool]
@@ -1528,10 +1528,12 @@ class HostPoolGroup:
self.page_size = self.anchor_entry.host_pool.page_size self.page_size = self.anchor_entry.host_pool.page_size
self.device = self.anchor_entry.host_pool.device self.device = self.anchor_entry.host_pool.device
self.size = self.anchor_entry.host_pool.size self.size = self.anchor_entry.host_pool.size
self.can_use_write_back_jit = all( child_write_back_jit = [
getattr(entry.host_pool, "can_use_write_back_jit", False) getattr(entry.host_pool, "can_use_write_back_jit", False)
for entry in entries for entry in entries
) ]
self.can_use_write_back_jit = all(child_write_back_jit)
self.supports_per_pool_backup_indices = any(child_write_back_jit)
@property @property
def kv_buffer(self): def kv_buffer(self):
@@ -1632,6 +1634,39 @@ class HostPoolGroup:
io_backend, io_backend,
) )
def _backup_uses_cpu_host_indices(self, host_pool, io_backend) -> bool:
return (
io_backend == "kernel"
and getattr(host_pool, "layout", None) == "page_first"
and getattr(host_pool, "can_use_write_back_jit", False)
)
def _kernel_index_device(self, entry, device_indices):
if device_indices is not None and device_indices.is_cuda:
return device_indices.device
return getattr(entry.device_pool, "device", None)
def _normalize_backup_indices(
self, entry, host_indices, device_indices, io_backend
):
if io_backend != "kernel":
return host_indices, device_indices
if self._backup_uses_cpu_host_indices(entry.host_pool, io_backend):
if host_indices.is_cuda:
host_indices = host_indices.cpu()
return host_indices, device_indices
if not host_indices.is_cuda:
target_device = self._kernel_index_device(entry, device_indices)
if target_device is not None:
host_indices = host_indices.to(target_device, non_blocking=True)
if host_indices.is_cuda:
host_indices.record_stream(
torch.cuda.current_stream(host_indices.device)
)
return host_indices, device_indices
def backup_from_device_all_layer( def backup_from_device_all_layer(
self, self,
device_pool, device_pool,
@@ -1641,10 +1676,13 @@ class HostPoolGroup:
pool_transfers: Optional[list] = None, pool_transfers: Optional[list] = None,
) -> None: ) -> None:
# 1. Anchor (KV) backup # 1. Anchor (KV) backup
anchor_host_indices, anchor_device_indices = self._normalize_backup_indices(
self.anchor_entry, host_indices, device_indices, io_backend
)
self.anchor_entry.host_pool.backup_from_device_all_layer( self.anchor_entry.host_pool.backup_from_device_all_layer(
self.anchor_entry.device_pool, self.anchor_entry.device_pool,
host_indices, anchor_host_indices,
device_indices, anchor_device_indices,
io_backend, io_backend,
) )
# 2. Extra pool backup # 2. Extra pool backup
@@ -1652,12 +1690,20 @@ class HostPoolGroup:
entry = self.entry_map.get(transfer.name) entry = self.entry_map.get(transfer.name)
if entry is None or transfer.host_indices is None: if entry is None or transfer.host_indices is None:
continue continue
entry.host_pool.backup_from_device_all_layer( transfer_host_indices, transfer_device_indices = (
entry.device_pool, self._normalize_backup_indices(
entry,
transfer.host_indices, transfer.host_indices,
transfer.device_indices, transfer.device_indices,
io_backend, io_backend,
) )
)
entry.host_pool.backup_from_device_all_layer(
entry.device_pool,
transfer_host_indices,
transfer_device_indices,
io_backend,
)
class DSAIndexerPoolHost(HostKVCache): class DSAIndexerPoolHost(HostKVCache):
+22
View File
@@ -9025,6 +9025,28 @@ class ServerArgs:
} }
def m3_fp8_attn_gemm_enabled(args) -> bool:
"""Whether MiniMax-M3 attention GEMMs run in fp8 (no opt-in flag; active
whenever possible): fp8_e4m3 main + index KV caches, fp8-cast q, fp8
sparse/MSA kernels, with dense layers on trtllm_mha's fp8-q path. Needs
kv_cache_dtype fp8_e4m3 (e5m2 would silently mis-dispatch fmha_sm100's
e4m3 kernel), the trtllm_mha backend (the only dense backend with fp8-q
GEMMs), and SM100 (MSA fp8 variants and trtllm-gen fp8 dense kernels are
sm100-only). SGLANG_DISABLE_M3_FP8_ATTN_GEMM=1 is the kill switch:
it forces the pre-fp8 numerics (bf16 indexer + widening sparse path,
bf16 q) without having to move off trtllm_mha.
"""
from sglang.srt.environ import envs
from sglang.srt.utils.common import is_sm100_supported
return (
args.kv_cache_dtype == "fp8_e4m3"
and args.attention_backend == "trtllm_mha"
and is_sm100_supported()
and not envs.SGLANG_DISABLE_M3_FP8_ATTN_GEMM.get()
)
# NOTE: The process-wide ServerArgs is owned by the runtime context # NOTE: The process-wide ServerArgs is owned by the runtime context
# (sglang.srt.runtime_context). The two functions below are LEGACY shims kept # (sglang.srt.runtime_context). The two functions below are LEGACY shims kept
# for the existing call-sites; they publish/read the same live object by # for the existing call-sites; they publish/read the same live object by
@@ -6,7 +6,11 @@ import torch
from sglang.srt.model_executor.forward_batch_info import ForwardMode from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.utils import is_flashinfer_available from sglang.srt.utils import is_flashinfer_available
from sglang.srt.utils.common import is_sm90_supported, is_sm120_supported from sglang.srt.utils.common import (
is_sm90_supported,
is_sm100_supported,
is_sm120_supported,
)
from sglang.test.test_utils import CustomTestCase from sglang.test.test_utils import CustomTestCase
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -14,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sglang.test.ci.ci_register import register_cuda_ci from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.kits.attention_unittest.attention_methods.dense_attention import ( from sglang.test.kits.attention_unittest.attention_methods.dense_attention import (
DenseAttentionCase, DenseAttentionCase,
build_dense_attention_fixture,
run_dense_attention_case, run_dense_attention_case,
) )
from sglang.test.kits.attention_unittest.runner_modes.cuda_graph_decode_runner import ( from sglang.test.kits.attention_unittest.runner_modes.cuda_graph_decode_runner import (
@@ -200,6 +205,148 @@ class TestTRTLLMMHADenseAttentionBackendCorrectness(CustomTestCase):
hidden_size=self.HIDDEN_SIZE, hidden_size=self.HIDDEN_SIZE,
) )
# XQA has native page-128 kernels (any head layout). max_context_len must
# be a page multiple so the kit's per-request slot ranges stay page-aligned.
def test_page128_decode(self):
case = DenseAttentionCase(
name="trtllm_mha_xqa_decode_page128_boundary",
backend="trtllm_mha",
forward_mode=ForwardMode.DECODE,
num_heads=4,
num_kv_heads=2,
page_size=128,
prefix_lens=(127, 128, 200),
)
run_dense_attention_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
max_context_len=512,
)
@unittest.skipIf(
not torch.cuda.is_available()
or not is_flashinfer_available()
or not is_sm100_supported(),
"CUDA + FlashInfer TRT-LLM-GEN (SM100) are required",
)
class TestTRTLLMMHAPage128TrtllmGen(CustomTestCase):
"""page_size=128 on trtllm-gen (SM100) via dynamic tokens-per-page kernels.
Those kernels only exist for GQA (q heads per kv head > 1) with equal QK/V
head dims, so every positive case here is GQA; the MHA layout must fail at
backend construction (see test_page128_mha_rejected_at_init). All cases
pass max_context_len=512: the kit's per-request slot ranges start at
``page_size + req_idx * max_context_len``, so it must be a page multiple.
"""
HEAD_DIM = 64
HIDDEN_SIZE = 256
MAX_CONTEXT_LEN = 512
DECODE_CASES = (
DenseAttentionCase(
name="trtllm_gen_gqa_decode_page128_boundary",
backend="trtllm_mha",
forward_mode=ForwardMode.DECODE,
num_heads=4,
num_kv_heads=2,
page_size=128,
prefix_lens=(127, 128, 200),
),
DenseAttentionCase(
name="trtllm_gen_gqa4_decode_page128_bsz1",
backend="trtllm_mha",
forward_mode=ForwardMode.DECODE,
num_heads=8,
num_kv_heads=2,
page_size=128,
prefix_lens=(300,),
),
)
EXTEND_CASES = (
DenseAttentionCase(
name="trtllm_gen_gqa_extend_page128",
backend="trtllm_mha",
forward_mode=ForwardMode.EXTEND,
num_heads=4,
num_kv_heads=2,
page_size=128,
prefix_lens=(0, 128),
extend_lens=(130, 5),
),
)
CUDA_GRAPH_DECODE_CASES = (
DenseAttentionCase(
name="runner_cuda_graph_trtllm_gen_gqa_decode_page128",
backend="trtllm_mha",
forward_mode=ForwardMode.DECODE,
num_heads=4,
num_kv_heads=2,
page_size=128,
prefix_lens=(127, 128, 200),
),
)
def test_page128_decode_cases(self):
for case in self.DECODE_CASES:
with self.subTest(case=case.name):
run_dense_attention_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
max_context_len=self.MAX_CONTEXT_LEN,
)
def test_page128_extend_cases(self):
for case in self.EXTEND_CASES:
with self.subTest(case=case.name):
run_dense_attention_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
max_context_len=self.MAX_CONTEXT_LEN,
)
def test_page128_cuda_graph_decode_cases(self):
for case in self.CUDA_GRAPH_DECODE_CASES:
with self.subTest(case=case.name):
run_dense_cuda_graph_decode_case(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
max_context_len=self.MAX_CONTEXT_LEN,
)
def test_page128_mha_rejected_at_init(self):
# heads_per_kv == 1 has no page-128 trtllm-gen kernel; the backend must
# refuse at construction (not fail mid-capture with a missing-kernel
# RuntimeError from flashinfer).
case = DenseAttentionCase(
name="trtllm_gen_mha_decode_page128_rejected",
backend="trtllm_mha",
forward_mode=ForwardMode.DECODE,
num_heads=4,
num_kv_heads=4,
page_size=128,
prefix_lens=(7,),
)
with self.assertRaisesRegex(ValueError, "dynamic tokens-per-page"):
build_dense_attention_fixture(
self,
case,
head_dim=self.HEAD_DIM,
hidden_size=self.HIDDEN_SIZE,
max_context_len=self.MAX_CONTEXT_LEN,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -22,6 +22,7 @@ from sglang.srt.arg_groups.overrides import (
register_model_override, register_model_override,
validate_declarations, validate_declarations,
) )
from sglang.srt.environ import envs
from sglang.srt.runtime_context import ( from sglang.srt.runtime_context import (
get_context, get_context,
get_server_args, get_server_args,
@@ -1642,6 +1643,87 @@ class TestGoldenModelOverrides(_IsolatedPublish):
{}, {},
) )
def test_m3_fp8_attn_gemm_resolution(self):
from sglang.srt.arg_groups.overrides import _minimax_m3_overrides
from sglang.srt.server_args import m3_fp8_attn_gemm_enabled
def _args(**kw):
defaults = dict(
attention_backend="trtllm_mha",
kv_cache_dtype="fp8_e4m3",
)
defaults.update(kw)
return SimpleNamespace(**defaults)
with patch("sglang.srt.utils.common.is_sm100_supported", return_value=True):
# e4m3 + trtllm_mha + SM100: mode active
self.assertTrue(m3_fp8_attn_gemm_enabled(_args()))
# fa4 dense backend: mode inactive (no fp8-q GEMM path)
self.assertFalse(m3_fp8_attn_gemm_enabled(_args(attention_backend="fa4")))
# bf16 KV: mode inactive
self.assertFalse(m3_fp8_attn_gemm_enabled(_args(kv_cache_dtype="auto")))
# e5m2: mode inactive (fmha_sm100's variant lookup would silently
# dispatch the e4m3 kernel)
self.assertFalse(m3_fp8_attn_gemm_enabled(_args(kv_cache_dtype="fp8_e5m2")))
# SGLANG_DISABLE_M3_FP8_ATTN_GEMM kill switch wins over an
# otherwise-active config
with envs.SGLANG_DISABLE_M3_FP8_ATTN_GEMM.override(True):
self.assertFalse(m3_fp8_attn_gemm_enabled(_args()))
with patch("sglang.srt.utils.common.is_sm100_supported", return_value=False):
# non-SM100: mode inactive
self.assertFalse(m3_fp8_attn_gemm_enabled(_args()))
def _m3_args(**kw):
defaults = dict(
quantization=None,
_quantization_explicitly_unset=True,
attention_backend=None,
prefill_attention_backend=None,
decode_attention_backend=None,
page_size=None,
moe_runner_backend="auto",
kv_cache_dtype="auto",
)
defaults.update(kw)
ns = SimpleNamespace(**defaults)
ns.is_attention_backend_not_set = lambda: (
ns.attention_backend is None
and ns.prefill_attention_backend is None
and ns.decode_attention_backend is None
)
return ns
hf = SimpleNamespace()
with patch.object(overrides_module, "is_hip", return_value=False), patch.object(
overrides_module, "is_sm100_supported", return_value=True
), patch.object(overrides_module, "get_quantization_config", return_value=None):
# fp8_e4m3 KV: SM100 backend default flips to trtllm_mha (the only
# dense backend with the fp8-q GEMM path); page snaps to 128
ov = _minimax_m3_overrides(_m3_args(kv_cache_dtype="fp8_e4m3"), hf)
self.assertEqual(ov["attention_backend"], "trtllm_mha")
self.assertEqual(ov["page_size"], 128)
# auto KV: fa4 stays the SM100 default
ov = _minimax_m3_overrides(_m3_args(), hf)
self.assertEqual(ov["attention_backend"], "fa4")
self.assertEqual(ov["page_size"], 128)
# e5m2 KV: stays on fa4 + the widening Triton path, and warns
with self.assertLogs(
"sglang.srt.arg_groups.overrides", level="WARNING"
) as logs:
ov = _minimax_m3_overrides(_m3_args(kv_cache_dtype="fp8_e5m2"), hf)
self.assertEqual(ov["attention_backend"], "fa4")
self.assertIn("fp8_e5m2", "\n".join(logs.output))
# explicit backend choice is never overridden
ov = _minimax_m3_overrides(
_m3_args(kv_cache_dtype="fp8_e4m3", attention_backend="fa4"), hf
)
self.assertNotIn("attention_backend", ov)
# kill switch also reverts the SM100 backend default to fa4
with envs.SGLANG_DISABLE_M3_FP8_ATTN_GEMM.override(True):
ov = _minimax_m3_overrides(_m3_args(kv_cache_dtype="fp8_e4m3"), hf)
self.assertEqual(ov["attention_backend"], "fa4")
self.assertEqual(ov["page_size"], 128)
def test_page_constraint_passes_at_callable_level(self): def test_page_constraint_passes_at_callable_level(self):
from sglang.srt.arg_groups.overrides import ( from sglang.srt.arg_groups.overrides import (
ResolvedView, ResolvedView,
@@ -1682,6 +1764,30 @@ class TestGoldenModelOverrides(_IsolatedPublish):
), ),
{"page_size": 64}, {"page_size": 64},
) )
# trtllm_mha accepts 128 (trtllm-gen dynamic tokens-per-page kernels)
self.assertEqual(
_mla_backend_page_constraints(
_view(attention_backend="trtllm_mha", page_size=128)
),
{},
)
# trtllm_mha with an unsupported page still snaps to 64
self.assertEqual(
_mla_backend_page_constraints(
_view(attention_backend="trtllm_mha", page_size=256)
),
{"page_size": 64},
)
# chained: cutlass_mla decode -> 128, then trtllm_mha prefill keeps 128
self.assertEqual(
_mla_backend_page_constraints(
_view(
decode_attention_backend="cutlass_mla",
prefill_attention_backend="trtllm_mha",
)
),
{"page_size": 128},
)
# no matching backend: nothing declared # no matching backend: nothing declared
self.assertEqual(_mla_backend_page_constraints(_view()), {}) self.assertEqual(_mla_backend_page_constraints(_view()), {})