[AMD][Minimax-M3][perf] Enable packed LBHNC AITER QK-norm fusion for MiniMax-M3 on ROCm (#54535)
Signed-off-by: Wei-Ting Liao <wei-ting.liao@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Shanshan Shen <467638484@qq.com>
weitliao committed
Sep 21, 2026 at 01:38 UTC
b8cf2753825d05d7f41eb4747480b78934953aac
3 files changed
+636
-91
tests/kernels/attention/test_minimax_m3.py
+361
-10
@@ -19,7 +19,10 @@ from vllm.models.minimax_m3.common.ops.sparse_attn import (
19
minimax_m3_sparse_attn,
20
minimax_m3_sparse_attn_decode,
21
)
22
-from vllm.models.minimax_m3.common.sparse_attention import MiniMaxM3SparseTritonImpl
22
+from vllm.models.minimax_m3.common.sparse_attention import (
23
+ MiniMaxM3SparseTritonImpl,
24
+ minimax_m3_rebase_slots_to_page16,
25
+)
26
from vllm.platforms import current_platform
27
from vllm.utils.torch_utils import set_random_seed
28
from vllm.v1.attention.backends.utils import record_kv_cache_layout
@@ -28,6 +31,7 @@ from vllm.v1.kv_cache_interface import (
31
KVCacheLayout,
32
MLAAttentionSpec,
33
compute_layer_kv_cache_shape_bytes,
34
+ get_kv_quant_mode,
35
)
36
37
if not (current_platform.is_cuda() or current_platform.is_rocm()):
@@ -1672,8 +1676,9 @@ def test_main_cache_layout_contract():
1676
assert set(order) == set(range(len(order)))
1677
1678
1675
-def test_aiter_sparse_pa_layout_contract(monkeypatch):
1676
- """The shuffle-only AITER path retains separately contiguous K/V storage."""
1679
+@pytest.mark.parametrize("layout", [KVCacheLayout.LHBNC, KVCacheLayout.LBHNC])
1680
+def test_aiter_sparse_pa_layout_contract(monkeypatch, layout):
1681
+ """Build the exact separate and packed page-16 views used by the model."""
1682
import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
1683
1684
monkeypatch.setattr(sparse_attn_mod.rocm_aiter_ops, "is_enabled", lambda: True)
@@ -1685,21 +1690,43 @@ def test_aiter_sparse_pa_layout_contract(monkeypatch):
1690
1691
backend = sparse_attn_mod.MiniMaxM3SparseBackend
1692
assert KVCacheLayout.LHBNC in backend.supported_kv_cache_layouts()
1693
+ assert KVCacheLayout.LBHNC in backend.supported_kv_cache_layouts()
1694
1689
- nb, h = 7, 1
1695
+ nb, h = 3, 1
1696
+ cache_dtype = current_platform.fp8_dtype()
1697
spec = FullAttentionSpec(
1698
block_size=BLOCK_SIZE,
1699
num_kv_heads=h,
1700
head_size=HEAD_DIM,
1694
- dtype=DTYPE,
1701
+ dtype=cache_dtype,
1702
+ kv_quant_mode=get_kv_quant_mode("fp8"),
1703
num_head_slots=2,
1696
- state_content_bytes=h * HEAD_DIM * DTYPE.itemsize,
1704
+ state_content_bytes=h * HEAD_DIM * cache_dtype.itemsize,
1705
)
1706
raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
1699
- view = dense_kv_cache_views(raw, spec, nb, 1, KVCacheLayout.LHBNC)[0]
1700
- key_cache, value_cache = view.unbind(1)
1701
- assert key_cache.is_contiguous()
1702
- assert value_cache.is_contiguous()
1707
+ kv_cache = dense_kv_cache_views(raw, spec, nb, 1, layout)[0].view(cache_dtype)
1708
+ key_cache, value_cache = kv_cache.unbind(1)
1709
+ x = 16 // cache_dtype.itemsize
1710
+ pages_per_side = BLOCK_SIZE // 16
1711
+ if key_cache.is_contiguous() and value_cache.is_contiguous():
1712
+ k_src, v_src = key_cache, value_cache
1713
+ block_pages, v_page_offset = pages_per_side, 0
1714
+ else:
1715
+ assert kv_cache.is_contiguous()
1716
+ k_src = v_src = kv_cache
1717
+ block_pages, v_page_offset = 2 * pages_per_side, pages_per_side
1718
+
1719
+ num_phys16 = nb * block_pages
1720
+ k_view = k_src.view(num_phys16, h, HEAD_DIM // x, 16, x)
1721
+ v_view = v_src.view(num_phys16, h, 16 // x, HEAD_DIM, x)[v_page_offset:]
1722
+ assert k_view.is_contiguous()
1723
+ assert v_view.is_contiguous()
1724
+ if layout is KVCacheLayout.LHBNC:
1725
+ assert k_view.shape == (24, 1, 8, 16, 16)
1726
+ assert v_view.shape == (24, 1, 1, 128, 16)
1727
+ else:
1728
+ assert k_view.shape == (48, 1, 8, 16, 16)
1729
+ assert v_view.shape == (40, 1, 1, 128, 16)
1730
1731
1732
def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
@@ -1769,6 +1796,330 @@ def test_aiter_indexer_requires_the_aiter_attend(monkeypatch):
1796
assert reason is None or "AITER sparse PA attend" not in reason
1797
1798
1799
+def test_aiter_consolidated_qknorm_enabled(monkeypatch):
1800
+ from vllm._aiter_ops import rocm_aiter_ops
1801
+
1802
+ monkeypatch.setattr("vllm._aiter_ops.is_aiter_found_and_supported", lambda: True)
1803
+ monkeypatch.setattr(rocm_aiter_ops, "_AITER_ENABLED", True)
1804
+
1805
+ scale = torch.tensor([0.25])
1806
+ assert rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("auto")
1807
+ assert rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8", scale, scale)
1808
+ assert rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8_e4m3", scale, scale)
1809
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8")
1810
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8", scale, None)
1811
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8_e5m2", scale, scale)
1812
+
1813
+ monkeypatch.setattr(rocm_aiter_ops, "_AITER_ENABLED", False)
1814
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("auto")
1815
+
1816
+ monkeypatch.setattr(rocm_aiter_ops, "_AITER_ENABLED", True)
1817
+ monkeypatch.setattr("vllm._aiter_ops.is_aiter_found_and_supported", lambda: False)
1818
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("auto")
1819
+
1820
+
1821
+def _fake_aiter_qknorm_args(kv_cache_dtype: str = "auto") -> dict:
1822
+ tensor = torch.empty(1)
1823
+ return {
1824
+ "qkv": tensor,
1825
+ "q_norm_weight": tensor,
1826
+ "k_norm_weight": tensor,
1827
+ "cos_sin_cache": tensor,
1828
+ "positions": tensor,
1829
+ "num_heads": 16,
1830
+ "num_kv_heads": 1,
1831
+ "rotary_dim": 64,
1832
+ "eps": 1e-6,
1833
+ "slot_mapping": tensor,
1834
+ "kv_cache_k": tensor,
1835
+ "kv_cache_v": tensor,
1836
+ "q_out": tensor,
1837
+ "kv_cache_dtype": kv_cache_dtype,
1838
+ }
1839
+
1840
+
1841
+def _install_fake_aiter_qknorm(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
1842
+ """Stub ``aiter.fused_qknorm_idxrqknorm`` so mapping tests run without AITER."""
1843
+ import sys
1844
+ import types
1845
+ from typing import Any
1846
+
1847
+ calls: list[dict] = []
1848
+
1849
+ def fake_op(*args, **kwargs):
1850
+ calls.append(kwargs)
1851
+
1852
+ existing = sys.modules.get("aiter")
1853
+ if existing is not None:
1854
+ monkeypatch.setattr(existing, "fused_qknorm_idxrqknorm", fake_op, raising=False)
1855
+ else:
1856
+ fake_mod: Any = types.ModuleType("aiter")
1857
+ fake_mod.fused_qknorm_idxrqknorm = fake_op
1858
+ monkeypatch.setitem(sys.modules, "aiter", fake_mod)
1859
+ return calls
1860
+
1861
+
1862
+def test_aiter_consolidated_qknorm_packed_shuffle_unequal_spans(monkeypatch):
1863
+ from vllm._aiter_ops import rocm_aiter_ops
1864
+
1865
+ calls = _install_fake_aiter_qknorm(monkeypatch)
1866
+ call_args = _fake_aiter_qknorm_args()
1867
+ call_args["kv_cache_k"] = torch.empty(2, 1)
1868
+ call_args["kv_cache_v"] = torch.empty(1, 1)
1869
+
1870
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(**call_args)
1871
+ assert len(calls) == 1
1872
+
1873
+
1874
+@pytest.mark.parametrize(
1875
+ ("kv_cache_dtype", "aiter_dtype", "passes_scales"),
1876
+ [
1877
+ ("auto", "auto", False),
1878
+ ("fp8", "fp8_e4m3_static", True),
1879
+ ("fp8_e4m3", "fp8_e4m3_static", True),
1880
+ ],
1881
+)
1882
+def test_aiter_consolidated_qknorm_dtype_and_layout_mapping(
1883
+ monkeypatch, kv_cache_dtype, aiter_dtype, passes_scales
1884
+):
1885
+ from vllm._aiter_ops import rocm_aiter_ops
1886
+
1887
+ calls = _install_fake_aiter_qknorm(monkeypatch)
1888
+ call_args = _fake_aiter_qknorm_args(kv_cache_dtype)
1889
+ k_scale = torch.tensor([0.25])
1890
+ v_scale = torch.tensor([0.5])
1891
+ call_args.update(k_scale=k_scale, v_scale=v_scale)
1892
+
1893
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(**call_args)
1894
+ assert len(calls) == 1
1895
+ kwargs = calls[0]
1896
+ assert kwargs["kv_cache_dtype"] == aiter_dtype
1897
+ assert kwargs["index_cache_dtype"] == "auto"
1898
+ assert kwargs["block_size"] == 16
1899
+ assert kwargs["asm_layout"] is True
1900
+ if passes_scales:
1901
+ assert kwargs["k_scale"] is k_scale
1902
+ assert kwargs["v_scale"] is v_scale
1903
+ else:
1904
+ assert kwargs["k_scale"] is None
1905
+ assert kwargs["v_scale"] is None
1906
+
1907
+
1908
+def test_aiter_consolidated_qknorm_fp8_index_dtype_mapping(monkeypatch):
1909
+ from vllm._aiter_ops import rocm_aiter_ops
1910
+
1911
+ calls = _install_fake_aiter_qknorm(monkeypatch)
1912
+ fp8 = current_platform.fp8_dtype()
1913
+ call_args = _fake_aiter_qknorm_args("fp8")
1914
+ call_args.update(
1915
+ k_scale=torch.tensor([0.25]),
1916
+ v_scale=torch.tensor([0.5]),
1917
+ index_cache=torch.empty(1, dtype=fp8),
1918
+ index_q_out=torch.empty(1, dtype=fp8),
1919
+ num_index_heads=1,
1920
+ )
1921
+
1922
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(**call_args)
1923
+ assert calls[0]["index_cache_dtype"] == "fp8"
1924
+ assert calls[0]["kv_cache_dtype"] == "fp8_e4m3_static"
1925
+
1926
+
1927
+@pytest.mark.parametrize("skip_index_branch", [False, True])
1928
+def test_aiter_consolidated_qknorm_full_and_skip_index_args(
1929
+ monkeypatch, skip_index_branch
1930
+):
1931
+ from vllm._aiter_ops import rocm_aiter_ops
1932
+
1933
+ calls = _install_fake_aiter_qknorm(monkeypatch)
1934
+ call_args = _fake_aiter_qknorm_args()
1935
+ if skip_index_branch:
1936
+ call_args.update(skip_index_branch=True)
1937
+ else:
1938
+ tensor = torch.empty(1)
1939
+ call_args.update(
1940
+ index_q_norm_weight=tensor,
1941
+ index_k_norm_weight=tensor,
1942
+ num_index_heads=1,
1943
+ index_cache=tensor,
1944
+ index_q_out=tensor,
1945
+ index_slot_mapping=tensor,
1946
+ )
1947
+
1948
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(**call_args)
1949
+ kwargs = calls[0]
1950
+ assert kwargs["skip_index_branch"] is skip_index_branch
1951
+ if skip_index_branch:
1952
+ assert kwargs["index_q_norm_weight"] is None
1953
+ assert kwargs["index_k_norm_weight"] is None
1954
+ assert kwargs["index_cache"] is None
1955
+ assert kwargs["index_q_out"] is None
1956
+ assert kwargs["index_slot_mapping"] is None
1957
+ else:
1958
+ assert kwargs["index_q_norm_weight"] is not None
1959
+ assert kwargs["index_k_norm_weight"] is not None
1960
+ assert kwargs["index_cache"] is not None
1961
+ assert kwargs["index_q_out"] is not None
1962
+ assert kwargs["index_slot_mapping"] is not None
1963
+
1964
+
1965
+def test_aiter_consolidated_qknorm_rejects_fp8_e5m2(monkeypatch):
1966
+ from vllm._aiter_ops import rocm_aiter_ops
1967
+
1968
+ monkeypatch.setattr("vllm._aiter_ops.is_aiter_found_and_supported", lambda: True)
1969
+ monkeypatch.setattr(rocm_aiter_ops, "_AITER_ENABLED", True)
1970
+ scale = torch.tensor([0.25])
1971
+ assert not rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled("fp8_e5m2", scale, scale)
1972
+
1973
+
1974
+def test_aiter_sparse_pa_slot_rebase_preserves_page_offsets():
1975
+ slots = torch.tensor([0, 15, 16, 127, 128, 129, -1])
1976
+ actual = minimax_m3_rebase_slots_to_page16(slots, block_size=128)
1977
+ expected = torch.tensor([0, 15, 16, 127, 256, 257, -1])
1978
+ assert torch.equal(actual, expected)
1979
+
1980
+
1981
+@pytest.mark.skipif(
1982
+ not current_platform.is_rocm(),
1983
+ reason="The consolidated packed-SHUFFLE AITER op is ROCm-only",
1984
+)
1985
+@pytest.mark.parametrize("skip_index_branch", [False, True])
1986
+@pytest.mark.parametrize("fp8_index", [False, True])
1987
+def test_aiter_consolidated_qknorm_real_packed_shuffle(skip_index_branch, fp8_index):
1988
+ from vllm._aiter_ops import rocm_aiter_ops
1989
+
1990
+ torch.manual_seed(20260831)
1991
+ num_tokens = 4
1992
+ num_heads = 16
1993
+ num_kv_heads = 1
1994
+ num_index_heads = 1
1995
+ rotary_dim = 64
1996
+ head_dim = 128
1997
+ logical_block_size = 128
1998
+ num_blocks = 3
1999
+ fp8_dtype = current_platform.fp8_dtype()
2000
+
2001
+ qkv = torch.randn(
2002
+ num_tokens,
2003
+ (num_heads + 2 * num_kv_heads + num_index_heads + 1) * head_dim,
2004
+ dtype=torch.bfloat16,
2005
+ device="cuda",
2006
+ )
2007
+ weights = [
2008
+ torch.randn(head_dim, dtype=torch.bfloat16, device="cuda") for _ in range(4)
2009
+ ]
2010
+ cos_sin_cache = torch.randn(16, rotary_dim, dtype=torch.bfloat16, device="cuda")
2011
+ positions = torch.arange(num_tokens, dtype=torch.int64, device="cuda")
2012
+ logical_slots = torch.tensor([0, 127, 128, -1], dtype=torch.int64, device="cuda")
2013
+ slot_mapping = minimax_m3_rebase_slots_to_page16(logical_slots, logical_block_size)
2014
+ index_slot_mapping = logical_slots.clone()
2015
+
2016
+ packed = torch.zeros(
2017
+ num_blocks,
2018
+ 2,
2019
+ logical_block_size,
2020
+ head_dim,
2021
+ dtype=fp8_dtype,
2022
+ device="cuda",
2023
+ )
2024
+ x = 16 // packed.element_size()
2025
+ pages_per_side = logical_block_size // 16
2026
+ num_phys16 = num_blocks * 2 * pages_per_side
2027
+ kv_cache_k = packed.view(num_phys16, num_kv_heads, head_dim // x, 16, x)
2028
+ kv_cache_v = packed.view(num_phys16, num_kv_heads, 16 // x, head_dim, x)[
2029
+ pages_per_side:
2030
+ ]
2031
+ assert kv_cache_k.shape == (48, 1, 8, 16, 16)
2032
+ assert kv_cache_v.shape == (40, 1, 1, 128, 16)
2033
+
2034
+ q_out = torch.zeros(
2035
+ num_tokens, num_heads * head_dim, dtype=torch.bfloat16, device="cuda"
2036
+ )
2037
+ index_dtype = fp8_dtype if fp8_index else torch.bfloat16
2038
+ if fp8_index:
2039
+ index_q_out = torch.empty(
2040
+ (num_tokens, num_index_heads * head_dim),
2041
+ dtype=index_dtype,
2042
+ device="cuda",
2043
+ )
2044
+ index_cache = torch.empty(
2045
+ (num_blocks, logical_block_size, head_dim),
2046
+ dtype=index_dtype,
2047
+ device="cuda",
2048
+ )
2049
+ index_q_out.view(torch.uint8).fill_(7)
2050
+ index_cache.view(torch.uint8).fill_(7)
2051
+ else:
2052
+ index_q_out = torch.full(
2053
+ (num_tokens, num_index_heads * head_dim),
2054
+ 7,
2055
+ dtype=torch.bfloat16,
2056
+ device="cuda",
2057
+ )
2058
+ index_cache = torch.full(
2059
+ (num_blocks, logical_block_size, head_dim),
2060
+ 7,
2061
+ dtype=torch.bfloat16,
2062
+ device="cuda",
2063
+ )
2064
+ scale = torch.tensor([0.02], dtype=torch.float32, device="cuda")
2065
+
2066
+ kwargs = {}
2067
+ if not skip_index_branch:
2068
+ kwargs = {
2069
+ "index_q_norm_weight": weights[2],
2070
+ "index_k_norm_weight": weights[3],
2071
+ "index_cache": index_cache,
2072
+ "index_q_out": index_q_out,
2073
+ "index_slot_mapping": index_slot_mapping,
2074
+ }
2075
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(
2076
+ qkv,
2077
+ weights[0],
2078
+ weights[1],
2079
+ cos_sin_cache,
2080
+ positions,
2081
+ num_heads,
2082
+ num_kv_heads,
2083
+ rotary_dim,
2084
+ 1e-6,
2085
+ slot_mapping,
2086
+ kv_cache_k,
2087
+ kv_cache_v,
2088
+ q_out,
2089
+ "fp8",
2090
+ scale,
2091
+ scale,
2092
+ num_index_heads=num_index_heads,
2093
+ skip_index_branch=skip_index_branch,
2094
+ **kwargs,
2095
+ )
2096
+ torch.accelerator.synchronize()
2097
+
2098
+ assert torch.isfinite(q_out).all()
2099
+ assert torch.count_nonzero(q_out) > 0
2100
+ assert torch.count_nonzero(packed) > 0
2101
+ if fp8_index:
2102
+ index_q_untouched = torch.all(index_q_out.view(torch.uint8) == 7)
2103
+ index_cache_untouched = torch.all(index_cache.view(torch.uint8) == 7)
2104
+ index_cache_t0 = torch.all(index_cache[0, 0].view(torch.uint8) == 7)
2105
+ index_cache_t127 = torch.all(index_cache[0, 127].view(torch.uint8) == 7)
2106
+ index_cache_t128 = torch.all(index_cache[1, 0].view(torch.uint8) == 7)
2107
+ else:
2108
+ index_q_untouched = torch.all(index_q_out == 7)
2109
+ index_cache_untouched = torch.all(index_cache == 7)
2110
+ index_cache_t0 = torch.all(index_cache[0, 0] == 7)
2111
+ index_cache_t127 = torch.all(index_cache[0, 127] == 7)
2112
+ index_cache_t128 = torch.all(index_cache[1, 0] == 7)
2113
+ if skip_index_branch:
2114
+ assert index_q_untouched
2115
+ assert index_cache_untouched
2116
+ else:
2117
+ assert not index_q_untouched
2118
+ assert not index_cache_t0
2119
+ assert not index_cache_t127
2120
+ assert not index_cache_t128
2121
+
2122
+
2123
def test_indexer_cache_squeezes_to_contiguous_3d():
2124
"""The indexer side cache is standardized 4D with H=1: under both layouts
2125
the allocator's logical view stays contiguous and squeezes (as
vllm/_aiter_ops.py
+112
@@ -35,10 +35,22 @@ except ImportError:
35
# which is a host op, so we cache it once here.
36
FP8_DTYPE = current_platform.fp8_dtype()
37
_HIPB_MM_INITIALIZED_DEVICES: set[int] = set()
38
+_FP8_E4M3_DTYPES = {
39
+ dtype
40
+ for dtype in (
41
+ getattr(torch, "float8_e4m3fn", None),
42
+ getattr(torch, "float8_e4m3fnuz", None),
43
+ )
44
+ if dtype is not None
45
+}
46
KB = 1024
47
MB = 1024 * KB
48
49
50
+def _is_fp8_e4m3_tensor(t: torch.Tensor | None) -> bool:
51
+ return t is not None and t.dtype in _FP8_E4M3_DTYPES
52
+
53
+
54
def _get_or_create_aiter_qr_rmsnorm_comm(
55
device_comm: Any,
56
) -> tuple[int, int, bool] | None:
@@ -2200,6 +2212,106 @@ class rocm_aiter_ops:
2212
def is_shuffle_kv_cache_enabled(cls) -> bool:
2213
return cls._SHUFFLE_KV_CACHE_ENABLED
2214
2215
+ @classmethod
2216
+ @if_aiter_supported
2217
+ def fused_qknorm_idxrqknorm_enabled(
2218
+ cls,
2219
+ kv_cache_dtype: str,
2220
+ k_scale: torch.Tensor | None = None,
2221
+ v_scale: torch.Tensor | None = None,
2222
+ ) -> bool:
2223
+ """Whether MiniMax-M3 can use AITER's consolidated fused QK-norm.
2224
+
2225
+ Requires AITER to be installed and enabled. bf16 (``auto``) always
2226
+ qualifies; fp8 e4m3 also needs K/V scales. Other cache dtypes fall
2227
+ back to vLLM's fused kernel.
2228
+ """
2229
+ if not cls._AITER_ENABLED:
2230
+ return False
2231
+ if kv_cache_dtype == "auto":
2232
+ return True
2233
+ if kv_cache_dtype in ("fp8", "fp8_e4m3"):
2234
+ return k_scale is not None and v_scale is not None
2235
+ return False
2236
+
2237
+ @classmethod
2238
+ def fused_qknorm_idxrqknorm(
2239
+ cls,
2240
+ qkv: torch.Tensor,
2241
+ q_norm_weight: torch.Tensor,
2242
+ k_norm_weight: torch.Tensor,
2243
+ cos_sin_cache: torch.Tensor,
2244
+ positions: torch.Tensor,
2245
+ num_heads: int,
2246
+ num_kv_heads: int,
2247
+ rotary_dim: int,
2248
+ eps: float,
2249
+ slot_mapping: torch.Tensor,
2250
+ kv_cache_k: torch.Tensor,
2251
+ kv_cache_v: torch.Tensor,
2252
+ q_out: torch.Tensor,
2253
+ kv_cache_dtype: str,
2254
+ k_scale: torch.Tensor | None = None,
2255
+ v_scale: torch.Tensor | None = None,
2256
+ index_q_norm_weight: torch.Tensor | None = None,
2257
+ index_k_norm_weight: torch.Tensor | None = None,
2258
+ num_index_heads: int = 0,
2259
+ index_cache: torch.Tensor | None = None,
2260
+ index_q_out: torch.Tensor | None = None,
2261
+ index_slot_mapping: torch.Tensor | None = None,
2262
+ skip_index_branch: bool = False,
2263
+ ) -> None:
2264
+ """Run consolidated MiniMax-M3 QK-norm fusion.
2265
+
2266
+ Callers must check ``fused_qknorm_idxrqknorm_enabled`` first. Runtime
2267
+ failures from the AITER op are deliberately propagated.
2268
+ """
2269
+ if kv_cache_dtype == "auto":
2270
+ aiter_kv_cache_dtype = "auto"
2271
+ aiter_k_scale = None
2272
+ aiter_v_scale = None
2273
+ elif kv_cache_dtype in ("fp8", "fp8_e4m3"):
2274
+ aiter_kv_cache_dtype = "fp8_e4m3_static"
2275
+ aiter_k_scale = k_scale
2276
+ aiter_v_scale = v_scale
2277
+ else:
2278
+ raise ValueError(
2279
+ "AITER fused QK-norm requires kv_cache_dtype 'auto', 'fp8', "
2280
+ f"or 'fp8_e4m3', got {kv_cache_dtype!r}"
2281
+ )
2282
+ aiter_index_cache_dtype = "fp8" if _is_fp8_e4m3_tensor(index_cache) else "auto"
2283
+
2284
+ from aiter import fused_qknorm_idxrqknorm
2285
+
2286
+ fused_qknorm_idxrqknorm(
2287
+ qkv,
2288
+ q_norm_weight,
2289
+ k_norm_weight,
2290
+ cos_sin_cache,
2291
+ positions,
2292
+ num_heads,
2293
+ num_kv_heads,
2294
+ rotary_dim,
2295
+ eps,
2296
+ index_q_norm_weight=index_q_norm_weight,
2297
+ index_k_norm_weight=index_k_norm_weight,
2298
+ num_index_heads=num_index_heads,
2299
+ slot_mapping=slot_mapping,
2300
+ kv_cache_k=kv_cache_k,
2301
+ kv_cache_v=kv_cache_v,
2302
+ index_cache=index_cache,
2303
+ block_size=16,
2304
+ q_out=q_out,
2305
+ index_q_out=index_q_out,
2306
+ index_slot_mapping=index_slot_mapping,
2307
+ kv_cache_dtype=aiter_kv_cache_dtype,
2308
+ index_cache_dtype=aiter_index_cache_dtype,
2309
+ k_scale=aiter_k_scale,
2310
+ v_scale=aiter_v_scale,
2311
+ asm_layout=True,
2312
+ skip_index_branch=skip_index_branch,
2313
+ )
2314
+
2315
@classmethod
2316
@if_aiter_supported
2317
def is_triton_unified_attn_enabled(cls) -> bool:
vllm/models/minimax_m3/amd/model.py
+163
-81
@@ -890,6 +890,30 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
890
self._ensure_aiter_sparse_pa_kv_cache()
891
return self.kv_cache_k, self.kv_cache_v
892
893
+ def _get_aiter_sparse_pa_slot_mapping(
894
+ self,
895
+ slot_mapping: torch.Tensor,
896
+ key_cache: torch.Tensor,
897
+ value_cache: torch.Tensor,
898
+ ) -> torch.Tensor:
899
+ if value_cache.shape[0] == key_cache.shape[0]:
900
+ return slot_mapping
901
+
902
+ attn_metadata = get_forward_context().attn_metadata
903
+ page16_slot_mapping = None
904
+ if isinstance(attn_metadata, dict):
905
+ main_md = attn_metadata[self.layer_name]
906
+ assert isinstance(main_md, MiniMaxM3SparseMetadata)
907
+ page16_slot_mapping = main_md.page16_slot_mapping
908
+ if (
909
+ page16_slot_mapping is None
910
+ or page16_slot_mapping.shape != slot_mapping.shape
911
+ ):
912
+ page16_slot_mapping = minimax_m3_rebase_slots_to_page16(
913
+ slot_mapping, self.kv_cache.shape[2]
914
+ )
915
+ return page16_slot_mapping
916
+
917
def _insert_aiter_sparse_pa_kv(
918
self,
919
k: torch.Tensor,
@@ -897,6 +921,8 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
921
index_k: torch.Tensor | None,
922
slot_mapping: torch.Tensor,
923
index_slot_mapping: torch.Tensor | None,
924
+ key_cache: torch.Tensor,
925
+ value_cache: torch.Tensor,
926
) -> None:
927
if self.kv_cache.numel() == 0:
928
return
@@ -906,22 +932,6 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
932
minimax_m3_insert_index_cache,
933
)
934
909
- key_cache, value_cache = self.get_aiter_sparse_pa_kv_cache()
910
- if value_cache.shape[0] != key_cache.shape[0]:
911
- attn_metadata = get_forward_context().attn_metadata
912
- page16_slot_mapping = None
913
- if isinstance(attn_metadata, dict):
914
- main_md = attn_metadata[self.layer_name]
915
- assert isinstance(main_md, MiniMaxM3SparseMetadata)
916
- page16_slot_mapping = main_md.page16_slot_mapping
917
- if (
918
- page16_slot_mapping is None
919
- or page16_slot_mapping.shape != slot_mapping.shape
920
- ):
921
- page16_slot_mapping = minimax_m3_rebase_slots_to_page16(
922
- slot_mapping, self.kv_cache.shape[2]
923
- )
924
- slot_mapping = page16_slot_mapping
935
kv_cache_dtype = (
936
self.kv_cache_dtype
937
if is_quantized_kv_cache(self.kv_cache_dtype)
@@ -976,38 +986,78 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
986
987
main_slot_mapping = fwd_slot_mapping[self.layer_name]
988
q = qkv.new_empty((num_tokens, self.q_size))
989
+ key_cache = value_cache = page16_slot_mapping = None
990
+ k_scale = v_scale = None
991
+ use_fused_qknorm = False
992
+ if self.use_aiter_sparse_pa:
993
+ key_cache, value_cache = self.get_aiter_sparse_pa_kv_cache()
994
+ page16_slot_mapping = self._get_aiter_sparse_pa_slot_mapping(
995
+ main_slot_mapping, key_cache, value_cache
996
+ )
997
+ k_scale = getattr(self, "_k_scale", None)
998
+ v_scale = getattr(self, "_v_scale", None)
999
+ use_fused_qknorm = rocm_aiter_ops.fused_qknorm_idxrqknorm_enabled(
1000
+ self.kv_cache_dtype, k_scale, v_scale
1001
+ )
1002
if self.skip_index_topk:
1003
index_q = None
1004
if self.use_aiter_sparse_pa:
982
- ops.fused_minimax_m3_qknorm_rope_kv_insert(
983
- qkv,
984
- self.q_norm.weight,
985
- self.k_norm.weight,
986
- cos_sin_cache,
987
- positions,
988
- self.num_heads,
989
- self.num_kv_heads,
990
- rotary_dim,
991
- eps,
992
- num_index_heads=self.num_idx_heads,
993
- q_out=q,
994
- skip_index_branch=True,
995
- )
996
- k_start = self.q_size
997
- v_start = k_start + self.kv_size
998
- k = qkv[:, k_start:v_start].view(
999
- num_tokens, self.num_kv_heads, self.head_dim
1000
- )
1001
- v = qkv[:, v_start : v_start + self.kv_size].view(
1002
- num_tokens, self.num_kv_heads, self.head_dim
1003
- )
1004
- self._insert_aiter_sparse_pa_kv(
1005
- k,
1006
- v,
1007
- None,
1008
- main_slot_mapping,
1009
- None,
1010
- )
1005
+ assert key_cache is not None
1006
+ assert value_cache is not None
1007
+ assert page16_slot_mapping is not None
1008
+ if use_fused_qknorm:
1009
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(
1010
+ qkv,
1011
+ self.q_norm.weight,
1012
+ self.k_norm.weight,
1013
+ cos_sin_cache,
1014
+ positions,
1015
+ self.num_heads,
1016
+ self.num_kv_heads,
1017
+ rotary_dim,
1018
+ eps,
1019
+ page16_slot_mapping,
1020
+ key_cache,
1021
+ value_cache,
1022
+ q,
1023
+ self.kv_cache_dtype,
1024
+ k_scale,
1025
+ v_scale,
1026
+ num_index_heads=self.num_idx_heads,
1027
+ skip_index_branch=True,
1028
+ )
1029
+ else:
1030
+ ops.fused_minimax_m3_qknorm_rope_kv_insert(
1031
+ qkv,
1032
+ self.q_norm.weight,
1033
+ self.k_norm.weight,
1034
+ cos_sin_cache,
1035
+ positions,
1036
+ self.num_heads,
1037
+ self.num_kv_heads,
1038
+ rotary_dim,
1039
+ eps,
1040
+ num_index_heads=self.num_idx_heads,
1041
+ q_out=q,
1042
+ skip_index_branch=True,
1043
+ )
1044
+ k_start = self.q_size
1045
+ v_start = k_start + self.kv_size
1046
+ k = qkv[:, k_start:v_start].view(
1047
+ num_tokens, self.num_kv_heads, self.head_dim
1048
+ )
1049
+ v = qkv[:, v_start : v_start + self.kv_size].view(
1050
+ num_tokens, self.num_kv_heads, self.head_dim
1051
+ )
1052
+ self._insert_aiter_sparse_pa_kv(
1053
+ k,
1054
+ v,
1055
+ None,
1056
+ page16_slot_mapping,
1057
+ None,
1058
+ key_cache,
1059
+ value_cache,
1060
+ )
1061
else:
1062
ops.fused_minimax_m3_qknorm_rope_kv_insert(
1063
qkv,
@@ -1037,42 +1087,74 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
1087
dtype=self.indexer.index_cache.dtype,
1088
)
1089
if self.use_aiter_sparse_pa:
1040
- ops.fused_minimax_m3_qknorm_rope_kv_insert(
1041
- qkv,
1042
- self.q_norm.weight,
1043
- self.k_norm.weight,
1044
- cos_sin_cache,
1045
- positions,
1046
- self.num_heads,
1047
- self.num_kv_heads,
1048
- rotary_dim,
1049
- eps,
1050
- self.index_q_norm.weight,
1051
- self.index_k_norm.weight,
1052
- self.num_idx_heads,
1053
- q_out=q,
1054
- index_q_out=index_q,
1055
- kv_cache_dtype=self.kv_cache_dtype,
1056
- )
1057
- k_start = self.q_size
1058
- v_start = k_start + self.kv_size
1059
- index_k_start = v_start + self.kv_size + self.index_q_size
1060
- k = qkv[:, k_start:v_start].view(
1061
- num_tokens, self.num_kv_heads, self.head_dim
1062
- )
1063
- v = qkv[:, v_start : v_start + self.kv_size].view(
1064
- num_tokens, self.num_kv_heads, self.head_dim
1065
- )
1066
- index_k = qkv[
1067
- :, index_k_start : index_k_start + self.idx_head_dim
1068
- ].view(num_tokens, self.idx_head_dim)
1069
- self._insert_aiter_sparse_pa_kv(
1070
- k,
1071
- v,
1072
- index_k,
1073
- main_slot_mapping,
1074
- index_slot_mapping,
1075
- )
1090
+ assert key_cache is not None
1091
+ assert value_cache is not None
1092
+ assert page16_slot_mapping is not None
1093
+ index_cache = self.indexer.index_cache.kv_cache
1094
+ if use_fused_qknorm:
1095
+ rocm_aiter_ops.fused_qknorm_idxrqknorm(
1096
+ qkv,
1097
+ self.q_norm.weight,
1098
+ self.k_norm.weight,
1099
+ cos_sin_cache,
1100
+ positions,
1101
+ self.num_heads,
1102
+ self.num_kv_heads,
1103
+ rotary_dim,
1104
+ eps,
1105
+ page16_slot_mapping,
1106
+ key_cache,
1107
+ value_cache,
1108
+ q,
1109
+ self.kv_cache_dtype,
1110
+ k_scale,
1111
+ v_scale,
1112
+ self.index_q_norm.weight,
1113
+ self.index_k_norm.weight,
1114
+ self.num_idx_heads,
1115
+ index_cache,
1116
+ index_q,
1117
+ index_slot_mapping,
1118
+ )
1119
+ else:
1120
+ ops.fused_minimax_m3_qknorm_rope_kv_insert(
1121
+ qkv,
1122
+ self.q_norm.weight,
1123
+ self.k_norm.weight,
1124
+ cos_sin_cache,
1125
+ positions,
1126
+ self.num_heads,
1127
+ self.num_kv_heads,
1128
+ rotary_dim,
1129
+ eps,
1130
+ self.index_q_norm.weight,
1131
+ self.index_k_norm.weight,
1132
+ self.num_idx_heads,
1133
+ q_out=q,
1134
+ index_q_out=index_q,
1135
+ kv_cache_dtype=self.kv_cache_dtype,
1136
+ )
1137
+ k_start = self.q_size
1138
+ v_start = k_start + self.kv_size
1139
+ index_k_start = v_start + self.kv_size + self.index_q_size
1140
+ k = qkv[:, k_start:v_start].view(
1141
+ num_tokens, self.num_kv_heads, self.head_dim
1142
+ )
1143
+ v = qkv[:, v_start : v_start + self.kv_size].view(
1144
+ num_tokens, self.num_kv_heads, self.head_dim
1145
+ )
1146
+ index_k = qkv[
1147
+ :, index_k_start : index_k_start + self.idx_head_dim
1148
+ ].view(num_tokens, self.idx_head_dim)
1149
+ self._insert_aiter_sparse_pa_kv(
1150
+ k,
1151
+ v,
1152
+ index_k,
1153
+ page16_slot_mapping,
1154
+ index_slot_mapping,
1155
+ key_cache,
1156
+ value_cache,
1157
+ )
1158
else:
1159
ops.fused_minimax_m3_qknorm_rope_kv_insert(
1160
qkv,