[ROCm][MLA] Enable sparse MLA Gluon kernel from Aiter (#53492)

Signed-off-by: Mehmet Cagri Kaymak <mehmet.kaymak@amd.com> Signed-off-by: Mehmet Cagri <mehmet.kaymak@amd.com>

Mehmet Cagri committed Sep 28, 2026 at 20:08 UTC 70ae0b7435bcb1ceef051df28c50fb7ee7d1dfec
7 files changed +989 -22
tests/v1/attention/test_rocm_aiter_triton_sparse_mla.py new
+326
@@ -0,0 +1,326 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +import torch
8 +
9 +from vllm.platforms import current_platform
10 +
11 +pytestmark = pytest.mark.skipif(
12 + not current_platform.is_rocm(), reason="ROCm-only aiter path"
13 +)
14 +
15 +
16 +def _rows_from_ragged(indices: torch.Tensor, indptr: torch.Tensor) -> list[list[int]]:
17 + ends = indptr.tolist()
18 + return [indices[s:e].tolist() for s, e in zip(ends, ends[1:])]
19 +
20 +
21 +def test_triton_sparse_mla_gate(monkeypatch) -> None:
22 + import vllm._aiter_ops as aiter_ops
23 + from vllm._aiter_ops import rocm_aiter_ops
24 + from vllm.platforms.rocm import on_gfx950
25 +
26 + warnings: list[str] = []
27 + monkeypatch.setattr(
28 + aiter_ops.logger,
29 + "warning_once",
30 + lambda msg, *args, **kwargs: warnings.append(msg % args),
31 + )
32 +
33 + def enabled(aiter: bool, flag: bool) -> bool:
34 + monkeypatch.setenv("VLLM_ROCM_USE_AITER", str(int(aiter)))
35 + monkeypatch.setenv("VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA", str(int(flag)))
36 + rocm_aiter_ops.refresh_env_variables()
37 + return bool(rocm_aiter_ops.is_triton_sparse_mla_enabled())
38 +
39 + try:
40 + assert not enabled(aiter=True, flag=False) and not warnings
41 + assert not enabled(aiter=False, flag=True)
42 + assert "VLLM_ROCM_USE_AITER is off" in warnings.pop()
43 + # On gfx950 the flag takes effect; elsewhere it warns and falls back.
44 + assert enabled(aiter=True, flag=True) == on_gfx950()
45 + if not on_gfx950():
46 + assert "gfx950" in warnings.pop()
47 + finally:
48 + monkeypatch.undo()
49 + rocm_aiter_ops.refresh_env_variables()
50 +
51 +
52 +@pytest.mark.parametrize(
53 + "rope_dim",
54 + [0, 64],
55 + ids=["rope_free", "appended_rope"], # GLM-5.3-Flash; GLM-5.1/5.2, V3.2
56 +)
57 +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
58 +@torch.inference_mode()
59 +def test_forward_mqa_prepares_triton_sparse_mla_inputs(
60 + monkeypatch, rope_dim: int, kv_cache_dtype: str
61 +) -> None:
62 + """What forward_mqa hands aiter: global slots built from the request-local
63 + top-k rows, q quantized with the layer's scale, and the cache as stored."""
64 + from vllm._aiter_ops import rocm_aiter_ops
65 + from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import (
66 + ROCMAiterMLASparseImpl,
67 + )
68 +
69 + device = torch.device("cuda")
70 + gen = torch.Generator().manual_seed(0)
71 + kv_lora_rank, num_heads, topk, block_size = 512, 16, 128, 16
72 + head_size = kv_lora_rank + rope_dim
73 + fp8 = kv_cache_dtype == "fp8"
74 +
75 + # A decode row, then a short prefill.
76 + query_lens, seq_lens = [1, 5], [40, 20]
77 + num_tokens = sum(query_lens)
78 + block_table = torch.randperm(8, generator=gen).view(2, 4).to(device, torch.int32)
79 + topk_indices = torch.full((num_tokens, topk), -1, dtype=torch.int32)
80 + req_id_per_token: list[int] = []
81 + rows: list[list[int]] = []
82 + for req, (query_len, seq_len) in enumerate(zip(query_lens, seq_lens)):
83 + for pos in range(seq_len - query_len, seq_len):
84 + local = torch.randperm(pos + 1, generator=gen)
85 + topk_indices[len(rows), : pos + 1] = local.int()
86 + slots = block_table[req, local // block_size].cpu() * block_size
87 + rows.append((slots + local % block_size).tolist())
88 + req_id_per_token.append(req)
89 + indptr = torch.tensor([0] + [len(row) for row in rows]).cumsum(0)
90 +
91 + impl = ROCMAiterMLASparseImpl.__new__(ROCMAiterMLASparseImpl)
92 + impl.num_heads = num_heads
93 + impl.kv_lora_rank = kv_lora_rank
94 + impl.qk_rope_head_dim = rope_dim
95 + impl.scale = head_size**-0.5
96 + impl.sinks = torch.randn(num_heads, generator=gen).to(device)
97 + impl.kv_cache_dtype = kv_cache_dtype
98 + impl.topk_indices_buffer = topk_indices.to(device)
99 + impl.use_aiter_sparse_mla = True
100 + layer = SimpleNamespace(
101 + _q_scale=torch.tensor([0.02], device=device),
102 + _k_scale=torch.tensor([0.01], device=device),
103 + )
104 + metadata = SimpleNamespace(
105 + num_actual_tokens=num_tokens,
106 + topk_tokens=topk,
107 + req_id_per_token=torch.tensor(req_id_per_token, dtype=torch.int32).to(device),
108 + block_table=block_table,
109 + block_size=block_size,
110 + paged_kv_indptr=indptr.to(device, torch.int32),
111 + paged_kv_indices=torch.zeros(num_tokens * topk, dtype=torch.int32).to(device),
112 + attn_out_dtype=torch.bfloat16,
113 + )
114 + cache_dtype = current_platform.fp8_dtype() if fp8 else torch.bfloat16
115 + kv_cache = torch.zeros(8, block_size, head_size, device=device).to(cache_dtype)
116 + # Two rows of cudagraph padding past the real tokens.
117 + q = torch.randn(num_tokens + 2, num_heads, head_size, generator=gen).to(
118 + device, torch.bfloat16
119 + )
120 + calls = []
121 + monkeypatch.setattr(
122 + rocm_aiter_ops, "triton_sparse_mla_fwd", lambda *a, **k: calls.append((a, k))
123 + )
124 +
125 + out, lse = impl.forward_mqa(q, kv_cache, metadata, layer)
126 +
127 + assert len(calls) == 1
128 + (q_in, kv_in, o, sm_scale, kv_indptr, kv_indices), kwargs = calls[0]
129 + assert _rows_from_ragged(kv_indices, kv_indptr) == rows
130 + assert q_in.shape[0] == num_tokens
131 + if fp8:
132 + assert q_in.dtype == cache_dtype
133 + torch.testing.assert_close(
134 + q_in.float() * layer._q_scale, q[:num_tokens].float(), atol=1e-3, rtol=0.07
135 + )
136 + else:
137 + assert q_in.data_ptr() == q.data_ptr()
138 + assert kv_in.shape == (8 * block_size, 1, 1, head_size)
139 + assert kv_in.data_ptr() == kv_cache.data_ptr()
140 + assert o.data_ptr() == out.data_ptr() and o.shape[0] == num_tokens
141 + assert sm_scale == impl.scale
142 + assert kwargs["kv_lora_rank"] == kv_lora_rank
143 + assert kwargs["qk_rope_head_dim"] == rope_dim
144 + assert kwargs["q_scale"] is layer._q_scale
145 + assert kwargs["kv_scale"] is layer._k_scale
146 + assert kwargs["attn_sink"] is impl.sinks
147 + assert lse is None
148 +
149 +
150 +@pytest.mark.parametrize(
151 + ("model", "compress_ratio"),
152 + [("deepseek_v41", 1), ("deepseek_v41", 2), ("deepseek_v4", 4)],
153 +)
154 +@torch.inference_mode()
155 +def test_paged_prefill_indices_match_gathered_prefill(
156 + monkeypatch, model: str, compress_ratio: int
157 +) -> None:
158 + """The aiter prefill reads the paged caches through the SWA builder's rows
159 + and build_prefill_topk_ragged_indices, and must attend exactly the keys the
160 + dequantize-and-gather prefill does."""
161 + import importlib
162 +
163 + from tests.v1.attention.utils import create_vllm_config
164 + from vllm._aiter_ops import rocm_aiter_ops
165 + from vllm.v1.attention.backend import CommonAttentionMetadata
166 + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
167 + build_prefill_topk_ragged_indices,
168 + )
169 + from vllm.v1.kv_cache_interface import SlidingWindowMLASpec
170 +
171 + rocm = importlib.import_module(f"vllm.models.{model}.amd.rocm")
172 + device = torch.device("cuda")
173 + gen = torch.Generator().manual_seed(compress_ratio)
174 + window, width, block_size, max_model_len = 16, 32, 16, 128
175 + comp_block = block_size // compress_ratio
176 + num_compressed = max_model_len // compress_ratio
177 +
178 + # A decode row, then prefills with and without a prefix.
179 + prefix_lens, query_lens = [20, 0, 13, 40], [1, 7, 20, 9]
180 + seq_lens = [p + q for p, q in zip(prefix_lens, query_lens)]
181 + num_decodes = num_decode_tokens = 1
182 + num_tokens = sum(query_lens)
183 + qsl = torch.tensor([0] + query_lens).cumsum(0).to(torch.int32)
184 + positions = torch.cat([torch.arange(p, s) for p, s in zip(prefix_lens, seq_lens)])
185 + token_to_req = torch.repeat_interleave(
186 + torch.arange(len(query_lens)), torch.tensor(query_lens)
187 + )
188 + swa_bt = torch.randperm(32, generator=gen)[:16].view(4, 4).to(device, torch.int32)
189 + comp_bt = torch.randperm(32, generator=gen)[:16].view(4, 4).to(device, torch.int32)
190 +
191 + vllm_config = create_vllm_config(
192 + model_name="facebook/opt-125m",
193 + max_model_len=max_model_len,
194 + block_size=block_size,
195 + max_num_seqs=4,
196 + max_num_batched_tokens=64,
197 + hf_config_override={
198 + "compress_ratios": [compress_ratio],
199 + "index_topk": width,
200 + "sliding_window": window,
201 + },
202 + )
203 + swa_spec = SlidingWindowMLASpec(
204 + block_size=block_size,
205 + num_kv_heads=1,
206 + head_size=512,
207 + dtype=torch.bfloat16,
208 + sliding_window=window,
209 + cache_dtype_str="fp8_ds_mla",
210 + model_version="deepseek_v4",
211 + )
212 + slots = swa_bt.cpu()[token_to_req, positions // block_size] * block_size + (
213 + positions % block_size
214 + )
215 + common = CommonAttentionMetadata(
216 + query_start_loc=qsl.to(device),
217 + query_start_loc_cpu=qsl,
218 + seq_lens=torch.tensor(seq_lens, dtype=torch.int32).to(device),
219 + seq_lens_cpu_upper_bound=torch.tensor(seq_lens, dtype=torch.int32),
220 + num_reqs=len(query_lens),
221 + num_actual_tokens=num_tokens,
222 + max_query_len=max(query_lens),
223 + max_seq_len=max(seq_lens),
224 + block_table_tensor=swa_bt,
225 + slot_mapping=slots.to(device, torch.int64),
226 + positions=positions.to(device),
227 + causal=True,
228 + )
229 +
230 + def build(enabled: bool):
231 + monkeypatch.setattr(
232 + rocm_aiter_ops, "is_triton_sparse_mla_enabled", lambda: enabled
233 + )
234 + return rocm.DeepseekV4ROCMAiterSparseSWAMetadataBuilder(
235 + swa_spec, ["swa"], vllm_config, device
236 + ).build(0, common)
237 +
238 + assert build(enabled=False).prefill_swa_ragged_indices is None
239 + md = build(enabled=True)
240 + assert md.num_decode_tokens == num_decode_tokens
241 +
242 + # Local top-k rows. Under each row's causal cap sit a failed candidate and
243 + # one past the pool; past the cap, stale values neither path may read.
244 + num_prefill_tokens = num_tokens - num_decode_tokens
245 + topk = torch.full((num_prefill_tokens, width), -1, dtype=torch.int32)
246 + for row in range(num_prefill_tokens):
247 + visible = (int(positions[row + num_decode_tokens]) + 1) // compress_ratio
248 + cap = min(visible, width)
249 + topk[row, :cap] = torch.randperm(visible, generator=gen)[:cap].int()
250 + if cap >= 2:
251 + topk[row, :2] = torch.tensor([-1, num_compressed + 3])
252 + topk[row, cap:] = torch.randint(
253 + 0, num_compressed, (width - cap,), generator=gen
254 + ).int()
255 + topk = topk.to(device)
256 +
257 + topk_indices, topk_indptr = build_prefill_topk_ragged_indices(
258 + topk,
259 + md.token_to_req_indices,
260 + md.query_start_loc,
261 + md.seq_lens,
262 + md.is_valid_token,
263 + comp_bt,
264 + block_size=comp_block,
265 + compress_ratio=compress_ratio,
266 + num_compressed=num_compressed,
267 + token_offset=num_decode_tokens,
268 + )
269 + paged_keys = [
270 + sorted([("s", x) for x in swa if x >= 0] + [("c", x) for x in top if x >= 0])
271 + for swa, top in zip(
272 + _rows_from_ragged(
273 + md.prefill_swa_ragged_indices, md.prefill_swa_ragged_indptr
274 + ),
275 + _rows_from_ragged(topk_indices, topk_indptr),
276 + )
277 + ]
278 +
279 + # The gathered path's combine, mapped from workspace offsets to cache slots.
280 + M = num_compressed + window + num_tokens
281 + combined, combined_lens = rocm.combine_topk_swa_indices(
282 + topk,
283 + md.query_start_loc[num_decodes:],
284 + md.prefill_seq_lens,
285 + md.prefill_gather_lens,
286 + window,
287 + compress_ratio,
288 + width,
289 + M,
290 + num_compressed,
291 + )
292 + gather_start = (md.prefill_seq_lens - md.prefill_gather_lens).tolist()
293 + swa_bt_cpu, comp_bt_cpu = swa_bt.cpu(), comp_bt.cpu()
294 +
295 + def slot(block_table, req, pos, size):
296 + return int(block_table[req, pos // size]) * size + pos % size
297 +
298 + gathered_keys = []
299 + for row, (values, n) in enumerate(zip(combined.tolist(), combined_lens.tolist())):
300 + req = int(token_to_req[row + num_decode_tokens])
301 + chunk_req = req - num_decodes
302 + keys = []
303 + for value in values[:n]:
304 + if value < 0:
305 + continue
306 + offset = value - chunk_req * M
307 + if offset < num_compressed:
308 + keys.append(("c", slot(comp_bt_cpu, req, offset, comp_block)))
309 + else:
310 + pos = gather_start[chunk_req] + offset - num_compressed
311 + keys.append(("s", slot(swa_bt_cpu, req, pos, block_size)))
312 + gathered_keys.append(sorted(keys))
313 +
314 + assert paged_keys == gathered_keys
315 +
316 +
317 +@pytest.mark.parametrize("model", ["deepseek_v4", "deepseek_v41"])
318 +def test_aiter_sparse_mla_stays_off_with_kv_connector(monkeypatch, model) -> None:
319 + import importlib
320 +
321 + from vllm._aiter_ops import rocm_aiter_ops
322 +
323 + rocm = importlib.import_module(f"vllm.models.{model}.amd.rocm")
324 + monkeypatch.setattr(rocm_aiter_ops, "is_triton_sparse_mla_enabled", lambda: True)
325 + assert rocm._aiter_sparse_mla_enabled(has_kv_transfer=False)
326 + assert not rocm._aiter_sparse_mla_enabled(has_kv_transfer=True)
vllm/_aiter_ops.py
+73
@@ -1927,6 +1927,7 @@ class rocm_aiter_ops:
1927 VLLM_ROCM_USE_AITER_FP8BMM: Controls FP8 batched matrix multiply.
1928 VLLM_ROCM_USE_AITER_FP4_ASM_GEMM: Controls FP4 assembly GEMM.
1929 VLLM_ROCM_USE_AITER_TRITON_ROPE: Controls Triton rotary embeddings.
1930 + VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA: Controls Triton sparse MLA (gfx950).
1931 VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS: Controls shared expert fusion.
1932 VLLM_ROCM_USE_AITER_MOE_SITUV2: SiTUv2 FlyDSL MoE activation
1933 dtype (a16w4 | a8w4 | a4w4).
@@ -1998,6 +1999,7 @@ class rocm_aiter_ops:
1999 _FP4_GEMM_DYNAMIC_QUANT_ASM = envs.VLLM_ROCM_USE_AITER_FP4_ASM_GEMM
2000 # TODO: Consolidate under VLLM_ROCM_USE_AITER_ROPE
2001 _TRITON_ROTARY_EMBED = envs.VLLM_ROCM_USE_AITER_TRITON_ROPE
2002 + _TRITON_SPARSE_MLA = envs.VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA
2003 _MOE_SHARED_EXPERTS_ENABLED = envs.VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS
2004 _MOE_SITUV2 = _resolve_situv2_activation()
2005 # TODO: Consolidate under _LINEAR_ENABLED
@@ -2027,6 +2029,7 @@ class rocm_aiter_ops:
2029 cls._LINEAR_HIPBMM_ENABLED = envs.VLLM_ROCM_USE_AITER_LINEAR_HIPBMM
2030 cls._FP4_GEMM_DYNAMIC_QUANT_ASM = envs.VLLM_ROCM_USE_AITER_FP4_ASM_GEMM
2031 cls._TRITON_ROTARY_EMBED = envs.VLLM_ROCM_USE_AITER_TRITON_ROPE
2032 + cls._TRITON_SPARSE_MLA = envs.VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA
2033 cls._MOE_SHARED_EXPERTS_ENABLED = envs.VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS
2034 cls._MOE_SITUV2 = _resolve_situv2_activation()
2035 _sync_aiter_situv2_moe_env()
@@ -2391,6 +2394,25 @@ class rocm_aiter_ops:
2394 def is_triton_rotary_embed_enabled(cls) -> bool:
2395 return cls._AITER_ENABLED and cls._TRITON_ROTARY_EMBED
2396
2397 + @classmethod
2398 + @if_aiter_supported
2399 + def is_triton_sparse_mla_enabled(cls) -> bool:
2400 + if not cls._TRITON_SPARSE_MLA:
2401 + return False
2402 + from vllm.platforms.rocm import on_gfx950
2403 +
2404 + if not cls._AITER_ENABLED:
2405 + reason = "VLLM_ROCM_USE_AITER is off"
2406 + elif not on_gfx950():
2407 + reason = "the kernel is gfx950-only"
2408 + else:
2409 + return True
2410 + logger.warning_once(
2411 + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA is set, but %s; ignoring it.",
2412 + reason,
2413 + )
2414 + return False
2415 +
2416 @classmethod
2417 @if_aiter_supported
2418 def is_triton_gemm_enabled(cls) -> bool:
@@ -3137,6 +3159,57 @@ class rocm_aiter_ops:
3159 ) -> tuple[torch.Tensor, torch.Tensor]:
3160 return torch.ops.vllm.rocm_aiter_fused_topk(x, router_logits, top_k, gate_up)
3161
3162 + @staticmethod
3163 + def triton_sparse_mla_fwd(
3164 + q: torch.Tensor,
3165 + kv_buffer: torch.Tensor,
3166 + o: torch.Tensor,
3167 + sm_scale: float,
3168 + kv_indptr: torch.Tensor,
3169 + kv_indices: torch.Tensor,
3170 + kv_lora_rank: int = 512,
3171 + qk_rope_head_dim: int = 64,
3172 + q_scale: torch.Tensor | None = None,
3173 + kv_scale: torch.Tensor | None = None,
3174 + attn_sink: torch.Tensor | None = None,
3175 + extra_kv_buffer: torch.Tensor | None = None,
3176 + extra_kv_indptr: torch.Tensor | None = None,
3177 + extra_kv_indices: torch.Tensor | None = None,
3178 + has_invalid: bool = True,
3179 + ) -> None:
3180 + """Sparse MLA read straight from the KV cache, for prefill and decode.
3181 +
3182 + kv_indices are ragged global slot ids. With has_invalid they may hold
3183 + -1, which the kernel masks; a caller whose stream never holds a
3184 + negative slot passes False, which lets the fp8 kernel stage K straight
3185 + into LDS. The cache format (bf16, per-tensor fp8, or DeepSeek V4's
3186 + paged fp8_ds_mla) is inferred from kv_buffer and kv_scale. An fp8 q
3187 + must come with its q_scale and runs both dots in fp8. The extra segment
3188 + is DeepSeek V4's second cache: SWA window in kv_buffer, top-k
3189 + compressed tokens here.
3190 + """
3191 + from aiter.ops.triton.attention.sparse_mla import sparse_mla_fwd
3192 +
3193 + fp8_q = q.dtype == FP8_DTYPE
3194 + sparse_mla_fwd(
3195 + q,
3196 + kv_buffer,
3197 + kv_indptr,
3198 + kv_indices,
3199 + sm_scale,
3200 + kv_scale=kv_scale,
3201 + kv_lora_rank=kv_lora_rank,
3202 + qk_rope_head_dim=qk_rope_head_dim,
3203 + has_invalid=has_invalid,
3204 + dot_precision="fp8" if fp8_q else "bf16",
3205 + q_scale=q_scale if fp8_q else None,
3206 + out=o,
3207 + attn_sink=attn_sink,
3208 + extra_kv=extra_kv_buffer,
3209 + extra_indptr=extra_kv_indptr,
3210 + extra_indices=extra_kv_indices,
3211 + )
3212 +
3213 @staticmethod
3214 def mla_decode_fwd(
3215 q: torch.Tensor,
vllm/envs.py
+9
@@ -148,6 +148,7 @@ if TYPE_CHECKING:
148 VLLM_ROCM_AITER_MLA_DCP_VERIFY: Literal["asm", "segmented"] = "segmented"
149 VLLM_ROCM_USE_AITER_MHA: bool = True
150 VLLM_ROCM_USE_AITER_FP4_ASM_GEMM: bool = False
151 + VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA: bool = False
152 VLLM_ROCM_USE_AITER_TRITON_ROPE: bool = False
153 VLLM_ROCM_USE_AITER_FP8BMM: bool = True
154 VLLM_ROCM_USE_AITER_FP4BMM: bool = True
@@ -1343,6 +1344,14 @@ environment_variables: dict[str, Callable[[], Any]] = {
1344 "VLLM_ROCM_USE_AITER_FP4_ASM_GEMM": lambda: (
1345 os.getenv("VLLM_ROCM_USE_AITER_FP4_ASM_GEMM", "False").lower() in ("true", "1")
1346 ),
1347 + # Whether sparse MLA prefill and decode run on aiter's Triton kernel, which
1348 + # reads the KV cache as stored (bf16 or fp8, paged or flat). Used by the
1349 + # ROCM_AITER_MLA_SPARSE backend (DeepSeek V3.2, GLM-5.x) and DeepSeek
1350 + # V4 / V4.1. gfx950 only. By default is disabled.
1351 + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA": lambda: (
1352 + os.getenv("VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA", "False").lower()
1353 + in ("true", "1")
1354 + ),
1355 # Whether to use aiter rope.
1356 # By default is disabled.
1357 "VLLM_ROCM_USE_AITER_TRITON_ROPE": lambda: (
vllm/models/deepseek_v4/amd/rocm.py
+172 -12
@@ -35,6 +35,7 @@ from vllm.v1.attention.backends.mla.sparse_swa import (
35 DeepseekSparseSWAMetadataBuilder,
36 )
37 from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
38 + build_prefill_topk_ragged_indices,
39 build_ragged_indices_from_dense,
40 rocm_inv_rope_einsum,
41 rocm_inverse_rope_rows_,
@@ -103,6 +104,22 @@ def _trust_dsv4_extra_cache_nan_free(
104 )
105
106
107 +def _aiter_sparse_mla_enabled(has_kv_transfer: bool) -> bool:
108 + from vllm._aiter_ops import rocm_aiter_ops
109 +
110 + if not rocm_aiter_ops.is_triton_sparse_mla_enabled():
111 + return False
112 + # The aiter kernel does not scrub NaNs, so like the trust check above it
113 + # needs a cache only the canonical writer has touched.
114 + if has_kv_transfer:
115 + logger.warning_once(
116 + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA is ignored with a KV "
117 + "connector; DeepSeek V4 keeps the Triton sparse attention."
118 + )
119 + return False
120 + return True
121 +
122 +
123 def _build_indptr_from_lengths(lengths: torch.Tensor) -> torch.Tensor:
124 lengths = lengths.to(dtype=torch.int32).contiguous()
125 indptr = torch.zeros(lengths.shape[0] + 1, dtype=torch.int32, device=lengths.device)
@@ -446,6 +463,9 @@ class DeepseekV4ROCMAiterMLASparseMetadata(DeepseekV4FlashMLAMetadata):
463 class DeepseekV4ROCMAiterSparseSWAMetadata(DeepseekSparseSWAMetadata):
464 decode_swa_ragged_indices: torch.Tensor | None = None
465 decode_swa_ragged_indptr: torch.Tensor | None = None
466 + # Built only for the aiter prefill, which reads the SWA cache in place.
467 + prefill_swa_ragged_indices: torch.Tensor | None = None
468 + prefill_swa_ragged_indptr: torch.Tensor | None = None
469
470
471 class DeepseekV4ROCMAiterMLASparseMetadataBuilder(DeepseekV4SparseMLAMetadataBuilder):
@@ -565,6 +585,9 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
585 dtype=torch.int32,
586 device=self.device,
587 )
588 + self._use_aiter_sparse_mla = _aiter_sparse_mla_enabled(
589 + self.vllm_config.kv_transfer_config is not None
590 + )
591
592 def build(
593 self,
@@ -580,6 +603,23 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
603 replay_start=replay_start,
604 )
605
606 + prefill_ragged_indices = None
607 + prefill_ragged_indptr = None
608 + if (
609 + self._use_aiter_sparse_mla
610 + and base.num_prefill_tokens > 0
611 + and base.prefill_swa_indices is not None
612 + and base.prefill_swa_lens is not None
613 + ):
614 + # Paged-coordinate rows (image visibility included), so every
615 + # layer's prefill shares one build.
616 + prefill_ragged_indices, prefill_ragged_indptr = (
617 + build_ragged_indices_from_dense(
618 + base.prefill_swa_indices.reshape(base.num_prefill_tokens, -1),
619 + base.prefill_swa_lens,
620 + )
621 + )
622 +
623 ragged_indices = None
624 ragged_indptr = None
625 if (
@@ -606,6 +646,8 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
646 **vars(base),
647 decode_swa_ragged_indices=ragged_indices,
648 decode_swa_ragged_indptr=ragged_indptr,
649 + prefill_swa_ragged_indices=prefill_ragged_indices,
650 + prefill_swa_ragged_indptr=prefill_ragged_indptr,
651 )
652
653
@@ -623,6 +665,7 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
665 """ROCm sparse MLA attention layer for DeepSeek V4."""
666
667 backend_cls = DeepseekV4ROCMAiterMLASparseBackend
668 + _use_aiter_sparse_mla = False
669
670 def __init__(self, *args, **kwargs):
671 vllm_config = args[0] if args else kwargs["vllm_config"]
@@ -648,6 +691,7 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
691 else:
692 # Disable indexer inner overlap.
693 self.indexer.aux_stream = None
694 + self._use_aiter_sparse_mla = _aiter_sparse_mla_enabled(self._has_kv_transfer)
695
696 def _run_sequential_pipeline(
697 self,
@@ -1190,18 +1234,20 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
1234 if attn_metadata is None:
1235 # Warmup dummy run: no real metadata. Reserve the same bf16
1236 # gather workspace _forward_prefill would; the dequantize / topk
1193 - # / sparse_fwd kernels are skipped this step.
1194 - swa_only = self.compress_ratio <= 1
1195 - N = (
1196 - 0
1197 - if swa_only
1198 - else (self.max_model_len + self.compress_ratio - 1)
1199 - // self.compress_ratio
1200 - )
1201 - M = N + self.window_size + self.max_num_batched_tokens
1202 - current_workspace_manager().get_simultaneous(
1203 - ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
1204 - )
1237 + # / sparse_fwd kernels are skipped this step. The aiter prefill
1238 + # reads the caches in place and never asks for it.
1239 + if not self._use_aiter_sparse_mla:
1240 + swa_only = self.compress_ratio <= 1
1241 + N = (
1242 + 0
1243 + if swa_only
1244 + else (self.max_model_len + self.compress_ratio - 1)
1245 + // self.compress_ratio
1246 + )
1247 + M = N + self.window_size + self.max_num_batched_tokens
1248 + current_workspace_manager().get_simultaneous(
1249 + ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16),
1250 + )
1251 output.zero_()
1252 return
1253
@@ -1312,6 +1358,22 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
1358 topk_ragged_indices = attn_metadata.c128a_decode_topk_ragged_indices
1359 topk_ragged_indptr = attn_metadata.c128a_decode_topk_ragged_indptr
1360
1361 + if self._use_aiter_sparse_mla:
1362 + assert swa_metadata.decode_swa_ragged_indices is not None
1363 + assert swa_metadata.decode_swa_ragged_indptr is not None
1364 + self._aiter_sparse_mla(
1365 + q,
1366 + output,
1367 + self.swa_cache_layer.kv_cache,
1368 + swa_metadata.decode_swa_ragged_indices,
1369 + swa_metadata.decode_swa_ragged_indptr,
1370 + kv_cache,
1371 + topk_ragged_indices,
1372 + topk_ragged_indptr,
1373 + )
1374 + # The aiter kernel leaves the inverse RoPE to the caller.
1375 + return 0
1376 +
1377 return rocm_sparse_attn_decode(
1378 q=q,
1379 kv_cache=kv_cache,
@@ -1351,6 +1413,17 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
1413 attn_metadata: DeepseekV4ROCMAiterMLASparseMetadata | None,
1414 swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
1415 ) -> None:
1416 + if self._use_aiter_sparse_mla:
1417 + self._forward_prefill_aiter(
1418 + q=q,
1419 + compressed_k_cache=compressed_k_cache,
1420 + swa_k_cache=swa_k_cache,
1421 + output=output,
1422 + attn_metadata=attn_metadata,
1423 + swa_metadata=swa_metadata,
1424 + )
1425 + return
1426 +
1427 swa_only = attn_metadata is None
1428
1429 num_prefills = swa_metadata.num_prefills
@@ -1476,3 +1549,90 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
1549 attn_sink=self.attn_sink,
1550 output=output[query_start:query_end],
1551 )
1552 +
1553 + def _forward_prefill_aiter(
1554 + self,
1555 + q: torch.Tensor,
1556 + compressed_k_cache: torch.Tensor | None,
1557 + swa_k_cache: torch.Tensor,
1558 + output: torch.Tensor,
1559 + attn_metadata: DeepseekV4ROCMAiterMLASparseMetadata | None,
1560 + swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
1561 + ) -> None:
1562 + """Prefill straight off the paged caches, with no bf16 gather."""
1563 + num_decode_tokens = swa_metadata.num_decode_tokens
1564 + num_prefill_tokens = swa_metadata.num_prefill_tokens
1565 + assert swa_metadata.prefill_swa_ragged_indices is not None
1566 + assert swa_metadata.prefill_swa_ragged_indptr is not None
1567 +
1568 + topk_ragged_indices = None
1569 + topk_ragged_indptr = None
1570 + if attn_metadata is None:
1571 + compressed_k_cache = None
1572 + else:
1573 + assert compressed_k_cache is not None
1574 + assert swa_metadata.token_to_req_indices is not None
1575 + assert swa_metadata.query_start_loc is not None
1576 + assert swa_metadata.seq_lens is not None
1577 + assert swa_metadata.is_valid_token is not None
1578 + if self.compress_ratio == 4:
1579 + assert self.topk_indices_buffer is not None
1580 + topk_indices = self.topk_indices_buffer[
1581 + num_decode_tokens : num_decode_tokens + num_prefill_tokens
1582 + ]
1583 + else:
1584 + topk_indices = attn_metadata.c128a_prefill_topk_indices
1585 + assert topk_indices is not None
1586 + topk_ragged_indices, topk_ragged_indptr = build_prefill_topk_ragged_indices(
1587 + topk_indices,
1588 + swa_metadata.token_to_req_indices,
1589 + swa_metadata.query_start_loc,
1590 + swa_metadata.seq_lens,
1591 + swa_metadata.is_valid_token,
1592 + attn_metadata.block_table,
1593 + block_size=attn_metadata.block_size // self.compress_ratio,
1594 + compress_ratio=self.compress_ratio,
1595 + num_compressed=(self.max_model_len + self.compress_ratio - 1)
1596 + // self.compress_ratio,
1597 + token_offset=num_decode_tokens,
1598 + num_rows=compressed_k_cache.shape[0] * compressed_k_cache.shape[1],
1599 + )
1600 +
1601 + self._aiter_sparse_mla(
1602 + q[:num_prefill_tokens],
1603 + output[:num_prefill_tokens],
1604 + swa_k_cache,
1605 + swa_metadata.prefill_swa_ragged_indices,
1606 + swa_metadata.prefill_swa_ragged_indptr,
1607 + compressed_k_cache,
1608 + topk_ragged_indices,
1609 + topk_ragged_indptr,
1610 + )
1611 +
1612 + def _aiter_sparse_mla(
1613 + self,
1614 + q: torch.Tensor,
1615 + output: torch.Tensor,
1616 + swa_k_cache: torch.Tensor,
1617 + swa_indices: torch.Tensor,
1618 + swa_indptr: torch.Tensor,
1619 + compressed_k_cache: torch.Tensor | None,
1620 + topk_indices: torch.Tensor | None,
1621 + topk_indptr: torch.Tensor | None,
1622 + ) -> None:
1623 + from vllm._aiter_ops import rocm_aiter_ops
1624 +
1625 + # Both fp8 caches are read in place: the SWA window as the main segment,
1626 + # the top-k compressed tokens as the extra one.
1627 + rocm_aiter_ops.triton_sparse_mla_fwd(
1628 + q,
1629 + swa_k_cache,
1630 + output,
1631 + self.scale,
1632 + swa_indptr,
1633 + swa_indices,
1634 + attn_sink=self.attn_sink[: q.shape[1]],
1635 + extra_kv_buffer=compressed_k_cache,
1636 + extra_kv_indptr=topk_indptr,
1637 + extra_kv_indices=topk_indices,
1638 + )
vllm/models/deepseek_v41/amd/rocm.py
+241 -9
@@ -38,6 +38,7 @@ from vllm.v1.attention.backends.mla.sparse_swa import (
38 DeepseekSparseSWAMetadata,
39 )
40 from vllm.v1.attention.ops.rocm_aiter_mla_sparse import (
41 + build_prefill_topk_ragged_indices,
42 build_ragged_indices_from_dense,
43 rocm_inv_rope_einsum,
44 rocm_inverse_rope_mxfp8_rows,
@@ -64,6 +65,22 @@ def _trust_dsv4_extra_cache_nan_free(
65 )
66
67
68 +def _aiter_sparse_mla_enabled(has_kv_transfer: bool) -> bool:
69 + from vllm._aiter_ops import rocm_aiter_ops
70 +
71 + if not rocm_aiter_ops.is_triton_sparse_mla_enabled():
72 + return False
73 + # The aiter kernel does not scrub NaNs, so like the trust check above it
74 + # needs a cache only the canonical writer has touched.
75 + if has_kv_transfer:
76 + logger.warning_once(
77 + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA is ignored with a KV "
78 + "connector; DeepSeek V4 keeps the Triton sparse attention."
79 + )
80 + return False
81 + return True
82 +
83 +
84 def _build_indptr_from_lengths(lengths: torch.Tensor) -> torch.Tensor:
85 lengths = lengths.to(dtype=torch.int32).contiguous()
86 indptr = torch.zeros(lengths.shape[0] + 1, dtype=torch.int32, device=lengths.device)
@@ -408,6 +425,9 @@ _TopkRagged = tuple[torch.Tensor, torch.Tensor, torch.Tensor]
425 class DeepseekV4ROCMAiterSparseSWAMetadata(DeepseekSparseSWAMetadata):
426 decode_swa_ragged_indices: torch.Tensor | None = None
427 decode_swa_ragged_indptr: torch.Tensor | None = None
428 + # Built only for the aiter prefill, which reads the SWA cache in place.
429 + prefill_swa_ragged_indices: torch.Tensor | None = None
430 + prefill_swa_ragged_indptr: torch.Tensor | None = None
431
432
433 class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBuilder):
@@ -432,6 +452,9 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBu
452 dtype=torch.int32,
453 device=self.device,
454 )
455 + self._use_aiter_sparse_mla = _aiter_sparse_mla_enabled(
456 + self.vllm_config.kv_transfer_config is not None
457 + )
458
459 def build(
460 self,
@@ -447,6 +470,22 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBu
470 replay_start=replay_start,
471 )
472
473 + prefill_ragged_indices = None
474 + prefill_ragged_indptr = None
475 + if (
476 + self._use_aiter_sparse_mla
477 + and base.num_prefill_tokens > 0
478 + and base.prefill_swa_indices is not None
479 + and base.prefill_swa_lens is not None
480 + ):
481 + # Paged-coordinate rows, so every layer's prefill shares one build.
482 + prefill_ragged_indices, prefill_ragged_indptr = (
483 + build_ragged_indices_from_dense(
484 + base.prefill_swa_indices.reshape(base.num_prefill_tokens, -1),
485 + base.prefill_swa_lens,
486 + )
487 + )
488 +
489 ragged_indices = None
490 ragged_indptr = None
491 if (
@@ -473,6 +512,8 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekV41SparseSWAMetadataBu
512 **vars(base),
513 decode_swa_ragged_indices=ragged_indices,
514 decode_swa_ragged_indptr=ragged_indptr,
515 + prefill_swa_ragged_indices=prefill_ragged_indices,
516 + prefill_swa_ragged_indptr=prefill_ragged_indptr,
517 )
518
519
@@ -497,6 +538,7 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
538
539 backend_cls = DeepseekV4ROCMAiterMLASparseBackend
540 swa_backend_cls = DeepseekV41ROCMAiterSparseSWABackend
541 + _use_aiter_sparse_mla = False
542
543 def __init__(self, *args, **kwargs):
544 vllm_config = args[0] if args else kwargs["vllm_config"]
@@ -518,6 +560,9 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
560 # same thing. The source owns one cache per compress ratio, refreshed
561 # when it runs; consumers below it read through.
562 self._topk_ragged_cache: dict[int, _TopkRagged] = {}
563 + self._prefill_topk_ragged_cache: dict[
564 + str, tuple[torch.Tensor, torch.Tensor]
565 + ] = {}
566 self._index_source_prefix: str | None = None
567 if self.compress_ratio > 0:
568 assert self.index_source_layer_id is not None
@@ -530,6 +575,7 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
575 "this rank; PP splits inside a v4.1 index-sharing group "
576 "are not supported."
577 )
578 + self._use_aiter_sparse_mla = _aiter_sparse_mla_enabled(self._has_kv_transfer)
579 if self.compressor is None and self.indexer is None:
580 # Dense layers have nothing to overlap; keep the base serial path.
581 self.aux_stream_list = None
@@ -915,9 +961,10 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
961 attn_metadata = forward_context.attn_metadata
962
963 if attn_metadata is None:
918 - # Warmup dummy run: no real metadata. Reserve the same bf16
919 - # gather workspace _forward_prefill would; the dequantize / topk
920 - # / sparse_fwd kernels are skipped this step.
964 + # Warmup dummy run: no real metadata. Reserve the workspace
965 + # _forward_prefill would; the dequantize / topk / sparse_fwd kernels
966 + # are skipped this step. The aiter prefill reads the caches in place,
967 + # so it asks only for the bf16 rows of the MXFP8 output.
968 swa_only = self.compress_ratio == 0
969 N = (
970 0
@@ -926,9 +973,14 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
973 // self.compress_ratio
974 )
975 M = N + self.window_size + self.max_num_batched_tokens
929 - current_workspace_manager().get_simultaneous(
930 - *self._prefill_workspace_shapes(M, self.max_num_batched_tokens, q)
976 + shapes = self._prefill_workspace_shapes(
977 + M,
978 + self.max_num_batched_tokens,
979 + q,
980 + gather=not self._use_aiter_sparse_mla,
981 )
982 + if shapes:
983 + current_workspace_manager().get_simultaneous(*shapes)
984 if mxfp8_out is None:
985 output.zero_()
986 else:
@@ -1087,6 +1139,38 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
1139 num_decode_tokens=num_decode_tokens,
1140 )
1141
1142 + if self._use_aiter_sparse_mla:
1143 + assert swa_metadata.decode_swa_ragged_indices is not None
1144 + assert swa_metadata.decode_swa_ragged_indptr is not None
1145 + out = output
1146 + if output_mxfp8 is not None:
1147 + # The aiter kernel has no MXFP8 epilogue: write bf16 rows, then
1148 + # rotate and quantize them as the decode reduce would have.
1149 + out = self._mxfp8_bf16_rows(q.shape[0], q)
1150 + assert out is not None
1151 + self._aiter_sparse_mla(
1152 + q,
1153 + out,
1154 + self.swa_cache_layer.kv_cache,
1155 + swa_metadata.decode_swa_ragged_indices,
1156 + swa_metadata.decode_swa_ragged_indptr,
1157 + kv_cache,
1158 + topk_ragged_indices,
1159 + topk_ragged_indptr,
1160 + )
1161 + if output_mxfp8 is not None:
1162 + rocm_inverse_rope_mxfp8_rows(
1163 + out,
1164 + positions,
1165 + self.rotary_emb.cos_sin_cache,
1166 + self.rope_head_dim,
1167 + output_mxfp8[0],
1168 + output_mxfp8[1],
1169 + )
1170 + return q.shape[0]
1171 + # The aiter kernel leaves the inverse RoPE to the caller.
1172 + return 0
1173 +
1174 return rocm_sparse_attn_decode(
1175 q=q,
1176 kv_cache=kv_cache,
@@ -1132,6 +1216,19 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
1216 With ``mxfp8_out`` the rows go to a bf16 workspace first and are then
1217 inverse-RoPE'd and quantized into it in one pass.
1218 """
1219 + if self._use_aiter_sparse_mla:
1220 + self._forward_prefill_aiter(
1221 + q=q,
1222 + positions=positions,
1223 + compressed_k_cache=compressed_k_cache,
1224 + swa_k_cache=swa_k_cache,
1225 + output=output,
1226 + attn_metadata=attn_metadata,
1227 + swa_metadata=swa_metadata,
1228 + mxfp8_out=mxfp8_out,
1229 + )
1230 + return
1231 +
1232 swa_only = attn_metadata is None
1233
1234 num_prefills = swa_metadata.num_prefills
@@ -1249,15 +1346,17 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
1346 )
1347
1348 def _prefill_workspace_shapes(
1252 - self, M: int, num_prefill_tokens: int, q: torch.Tensor
1349 + self, M: int, num_prefill_tokens: int, q: torch.Tensor, gather: bool = True
1350 ) -> list[tuple[tuple[int, ...], torch.dtype]]:
1351 """The prefill gather buffer, plus bf16 rows for the MXFP8 output.
1352
1353 One ``get_simultaneous`` call: separate calls alias the same memory.
1354 + ``gather=False`` leaves out the gather buffer, which the aiter kernel,
1355 + reading the caches in place, never needs.
1356 """
1258 - shapes: list[tuple[tuple[int, ...], torch.dtype]] = [
1259 - ((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16)
1260 - ]
1357 + shapes: list[tuple[tuple[int, ...], torch.dtype]] = []
1358 + if gather:
1359 + shapes.append(((self.PREFILL_CHUNK_SIZE, M, q.shape[-1]), torch.bfloat16))
1360 if _ON_GFX950:
1361 shapes.append(
1362 (
@@ -1266,3 +1365,136 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
1365 )
1366 )
1367 return shapes
1368 +
1369 + def _prefill_topk_ragged(
1370 + self,
1371 + swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
1372 + attn_metadata: DeepseekV4FlashMLAMetadata,
1373 + compressed_k_cache: torch.Tensor,
1374 + ) -> tuple[torch.Tensor, torch.Tensor]:
1375 + """Prefill counterpart of ``_decode_topk_ragged``: layers that share an
1376 + index source and a compressed cache reuse one build per step."""
1377 + assert swa_metadata.token_to_req_indices is not None
1378 + assert swa_metadata.query_start_loc is not None
1379 + assert swa_metadata.seq_lens is not None
1380 + assert swa_metadata.is_valid_token is not None
1381 + assert self.topk_indices_buffer is not None
1382 + assert self._index_source_prefix is not None
1383 + assert self.compressed_cache_prefix is not None
1384 +
1385 + source = self._static_forward_context[self._index_source_prefix]
1386 + if source is self:
1387 + source._prefill_topk_ragged_cache = {}
1388 + cached = source._prefill_topk_ragged_cache.get(self.compressed_cache_prefix)
1389 + if cached is not None:
1390 + return cached
1391 +
1392 + num_decode_tokens = swa_metadata.num_decode_tokens
1393 + num_prefill_tokens = swa_metadata.num_prefill_tokens
1394 + built = build_prefill_topk_ragged_indices(
1395 + self.topk_indices_buffer[
1396 + num_decode_tokens : num_decode_tokens + num_prefill_tokens
1397 + ],
1398 + swa_metadata.token_to_req_indices,
1399 + swa_metadata.query_start_loc,
1400 + swa_metadata.seq_lens,
1401 + swa_metadata.is_valid_token,
1402 + attn_metadata.block_table,
1403 + block_size=attn_metadata.block_size // self.compress_ratio,
1404 + compress_ratio=self.compress_ratio,
1405 + num_compressed=(self.max_model_len + self.compress_ratio - 1)
1406 + // self.compress_ratio,
1407 + token_offset=num_decode_tokens,
1408 + num_rows=compressed_k_cache.shape[0] * compressed_k_cache.shape[1],
1409 + )
1410 + source._prefill_topk_ragged_cache[self.compressed_cache_prefix] = built
1411 + return built
1412 +
1413 + def _forward_prefill_aiter(
1414 + self,
1415 + q: torch.Tensor,
1416 + positions: torch.Tensor,
1417 + compressed_k_cache: torch.Tensor | None,
1418 + swa_k_cache: torch.Tensor,
1419 + output: torch.Tensor | None,
1420 + attn_metadata: DeepseekV4FlashMLAMetadata | None,
1421 + swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
1422 + mxfp8_out: tuple[torch.Tensor, torch.Tensor] | None = None,
1423 + ) -> None:
1424 + """Prefill straight off the paged caches, with no bf16 gather.
1425 +
1426 + With ``mxfp8_out`` the kernel writes bf16 rows to the workspace, which
1427 + are then inverse-RoPE'd and quantized into it as ``_forward_prefill``
1428 + does.
1429 + """
1430 + num_prefill_tokens = swa_metadata.num_prefill_tokens
1431 + assert swa_metadata.prefill_swa_ragged_indices is not None
1432 + assert swa_metadata.prefill_swa_ragged_indptr is not None
1433 +
1434 + topk_indices = None
1435 + topk_indptr = None
1436 + if attn_metadata is None:
1437 + compressed_k_cache = None
1438 + else:
1439 + assert compressed_k_cache is not None
1440 + topk_indices, topk_indptr = self._prefill_topk_ragged(
1441 + swa_metadata, attn_metadata, compressed_k_cache
1442 + )
1443 +
1444 + if mxfp8_out is not None:
1445 + output = self._mxfp8_bf16_rows(num_prefill_tokens, q)
1446 + assert output is not None
1447 + self._aiter_sparse_mla(
1448 + q[:num_prefill_tokens],
1449 + output[:num_prefill_tokens],
1450 + swa_k_cache,
1451 + swa_metadata.prefill_swa_ragged_indices,
1452 + swa_metadata.prefill_swa_ragged_indptr,
1453 + compressed_k_cache,
1454 + topk_indices,
1455 + topk_indptr,
1456 + )
1457 + if mxfp8_out is not None:
1458 + rocm_inverse_rope_mxfp8_rows(
1459 + output[:num_prefill_tokens],
1460 + positions[:num_prefill_tokens],
1461 + self.rotary_emb.cos_sin_cache,
1462 + self.rope_head_dim,
1463 + mxfp8_out[0][:num_prefill_tokens],
1464 + mxfp8_out[1][:num_prefill_tokens],
1465 + )
1466 +
1467 + def _mxfp8_bf16_rows(self, num_tokens: int, q: torch.Tensor) -> torch.Tensor:
1468 + """bf16 attention rows for the aiter kernel to write when the layer's
1469 + output is MXFP8, taken from the workspace the warmup reserved."""
1470 + shapes = self._prefill_workspace_shapes(0, num_tokens, q, gather=False)
1471 + assert shapes, "an MXFP8 attention output is gfx950-only"
1472 + return current_workspace_manager().get_simultaneous(*shapes)[0]
1473 +
1474 + def _aiter_sparse_mla(
1475 + self,
1476 + q: torch.Tensor,
1477 + output: torch.Tensor,
1478 + swa_k_cache: torch.Tensor,
1479 + swa_indices: torch.Tensor,
1480 + swa_indptr: torch.Tensor,
1481 + compressed_k_cache: torch.Tensor | None,
1482 + topk_indices: torch.Tensor | None,
1483 + topk_indptr: torch.Tensor | None,
1484 + ) -> None:
1485 + from vllm._aiter_ops import rocm_aiter_ops
1486 +
1487 + # Both fp8 caches are read in place: the SWA window as the main segment,
1488 + # the top-k compressed tokens as the extra one.
1489 + rocm_aiter_ops.triton_sparse_mla_fwd(
1490 + q,
1491 + swa_k_cache,
1492 + output,
1493 + self.scale,
1494 + swa_indptr,
1495 + swa_indices,
1496 + attn_sink=self.attn_sink[: q.shape[1]],
1497 + extra_kv_buffer=compressed_k_cache,
1498 + extra_kv_indptr=topk_indptr,
1499 + extra_kv_indices=topk_indices,
1500 + )
vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py
+72 -1
@@ -396,9 +396,11 @@ class ROCMAiterMLASparseMetadataBuilder(
396 attention_context = vllm_config.compilation_config.static_forward_context
397 # Sink decode must use AITER's nonpersistent path. In particular,
398 # gfx942 has no persistent+LSE kernel, and its metadata heuristic
399 - # terminates for HY-V4's TP1 H64 shape.
399 + # terminates for HY-V4's TP1 H64 shape. The Triton sparse MLA kernel
400 + # never reads the persistent metadata either.
401 self._use_persistent_metadata = all(
402 getattr(attention_context[name].impl, "sinks", None) is None
403 + and not getattr(attention_context[name].impl, "use_aiter_sparse_mla", False)
404 for name in layer_names
405 )
406 # Bounds the KV-split heuristic (see `_sparse_decode_max_split`).
@@ -716,6 +718,7 @@ class ROCMAiterMLASparseImpl(
718 is_sparse = True
719 supports_dense_mha_prefill = False
720 supports_dcp = False
721 + use_aiter_sparse_mla = False
722
723 def __init__(
724 self,
@@ -765,6 +768,32 @@ class ROCMAiterMLASparseImpl(
768 (self.q_concat_buffer,) = current_workspace_manager().get_simultaneous(
769 (q_concat_shape, vllm_config.model_config.dtype),
770 )
771 + self.qk_rope_head_dim: int = mla_args["qk_rope_head_dim"]
772 +
773 + if rocm_aiter_ops.is_triton_sparse_mla_enabled():
774 + reason = self._aiter_sparse_mla_unsupported_reason(vllm_config)
775 + if reason is None:
776 + self.use_aiter_sparse_mla = True
777 + else:
778 + logger.warning_once(
779 + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA is set, but %s; "
780 + "using the default sparse MLA path instead.",
781 + reason,
782 + )
783 +
784 + def _aiter_sparse_mla_unsupported_reason(
785 + self, vllm_config: VllmConfig
786 + ) -> str | None:
787 + model_dtype = vllm_config.model_config.dtype
788 + if model_dtype != torch.bfloat16:
789 + return f"the model dtype is {model_dtype}, not bfloat16"
790 + cache_dtype = self.kv_cache_dtype
791 + if cache_dtype not in ("auto", "bfloat16", "fp8", "fp8_e4m3"):
792 + return f"kv-cache dtype {cache_dtype!r} is neither bfloat16 nor e4m3 fp8"
793 + # Context parallelism merges each rank's partials with a per-token LSE.
794 + if self.dcp_world_size > 1 or self.pcp_world_size > 1:
795 + return "context parallelism is not supported"
796 + return None
797
798 def record_logical_topk_ready(self) -> None:
799 # This impl shares the top-k indices buffer via SharedTopkIndicesBuffer
@@ -975,6 +1004,43 @@ class ROCMAiterMLASparseImpl(
1004
1005 return output, lse
1006
1007 + def _forward_mla_aiter(
1008 + self,
1009 + layer: AttentionLayer,
1010 + q: torch.Tensor, # [sq, heads, d_qk], not head-padded
1011 + kv_c_and_k_pe_cache: torch.Tensor,
1012 + attn_metadata: ROCMAiterMLASparseMetadata,
1013 + ) -> torch.Tensor:
1014 + """_forward_mla on aiter's Triton sparse MLA kernel.
1015 +
1016 + Reads the same index stream, but needs no q head padding and no
1017 + persistent metadata, and its launch depends on shapes only, so it is
1018 + CUDA-graph capturable.
1019 + """
1020 + num_actual_tokens = attn_metadata.num_actual_tokens
1021 + output = torch.empty(
1022 + [q.shape[0], self.num_heads, self.kv_lora_rank],
1023 + dtype=attn_metadata.attn_out_dtype,
1024 + device=q.device,
1025 + )
1026 + rocm_aiter_ops.triton_sparse_mla_fwd(
1027 + q[:num_actual_tokens],
1028 + kv_c_and_k_pe_cache.view(-1, 1, 1, q.shape[-1]),
1029 + output[:num_actual_tokens],
1030 + self.scale,
1031 + attn_metadata.paged_kv_indptr,
1032 + attn_metadata.paged_kv_indices,
1033 + kv_lora_rank=self.kv_lora_rank,
1034 + qk_rope_head_dim=self.qk_rope_head_dim,
1035 + q_scale=layer._q_scale,
1036 + kv_scale=layer._k_scale,
1037 + attn_sink=self.sinks,
1038 + # triton_convert_req_index_to_global_index writes 0, never -1,
1039 + # for an invalid top-k entry, so no slot in the stream is negative.
1040 + has_invalid=False,
1041 + )
1042 + return output
1043 +
1044 def forward_mqa(
1045 self,
1046 q: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
@@ -1026,6 +1092,11 @@ class ROCMAiterMLASparseImpl(
1092 original_q_shape = q.shape
1093 q, _ = ops.scaled_fp8_quant(q.view(q.shape[0], -1), layer._q_scale)
1094 q = q.view(original_q_shape)
1095 + if self.use_aiter_sparse_mla:
1096 + output = self._forward_mla_aiter(
1097 + layer, q, kv_c_and_k_pe_cache, attn_metadata
1098 + )
1099 + return output, None
1100 mla_padded_q = AiterMLAHelper.get_mla_padded_q(self.num_heads, q)
1101 return self._forward_mla(
1102 layer, mla_padded_q, kv_c_and_k_pe_cache, attn_metadata
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py
+96
@@ -4306,3 +4306,99 @@ def rocm_sparse_attn_decode(
4306 if direct_out is None:
4307 output.copy_(attn_out.to(output.dtype))
4308 return output.shape[0] if inv_rope_positions is not None else 0
4309 +
4310 +
4311 +@triton.jit(do_not_specialize=["token_offset"])
4312 +def _prefill_topk_global_slots_kernel(
4313 + out_ptr,
4314 + lens_ptr,
4315 + topk_ptr,
4316 + topk_stride,
4317 + token_to_req_ptr,
4318 + query_start_loc_ptr,
4319 + seq_lens_ptr,
4320 + is_valid_token_ptr,
4321 + block_table_ptr,
4322 + block_table_stride,
4323 + token_offset,
4324 + num_compressed,
4325 + TOPK: tl.constexpr,
4326 + COMPRESS_RATIO: tl.constexpr,
4327 + BLOCK_SIZE: tl.constexpr,
4328 + BLOCK_W: tl.constexpr,
4329 +):
4330 + row = tl.program_id(0)
4331 + token = row + token_offset
4332 + req = tl.load(token_to_req_ptr + token)
4333 + query_start = tl.load(query_start_loc_ptr + req)
4334 + query_len = tl.load(query_start_loc_ptr + req + 1) - query_start
4335 + pos = tl.load(seq_lens_ptr + req) - query_len + token - query_start
4336 + # Only (pos + 1) // ratio compressed tokens exist yet; the rest of the row
4337 + # can hold stale indices.
4338 + row_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOPK)
4339 + row_len = tl.where(tl.load(is_valid_token_ptr + token), row_len, 0)
4340 +
4341 + for start in range(0, TOPK, BLOCK_W):
4342 + cols = start + tl.arange(0, BLOCK_W)
4343 + live = cols < row_len
4344 + local = tl.load(topk_ptr + row * topk_stride + cols, mask=live, other=-1)
4345 + valid = live & (local >= 0) & (local < num_compressed)
4346 + local = tl.where(valid, local, 0)
4347 + block = tl.load(
4348 + block_table_ptr + req * block_table_stride + local // BLOCK_SIZE,
4349 + mask=valid,
4350 + other=0,
4351 + )
4352 + slot = tl.where(valid, block * BLOCK_SIZE + local % BLOCK_SIZE, -1)
4353 + tl.store(out_ptr + row * TOPK + cols, slot, mask=cols < TOPK)
4354 + tl.store(lens_ptr + row, row_len)
4355 +
4356 +
4357 +def build_prefill_topk_ragged_indices(
4358 + topk_indices: torch.Tensor,
4359 + token_to_req_indices: torch.Tensor,
4360 + query_start_loc: torch.Tensor,
4361 + seq_lens: torch.Tensor,
4362 + is_valid_token: torch.Tensor,
4363 + block_table: torch.Tensor,
4364 + block_size: int,
4365 + compress_ratio: int,
4366 + num_compressed: int,
4367 + token_offset: int,
4368 + num_rows: int = -1,
4369 +) -> tuple[torch.Tensor, torch.Tensor]:
4370 + """Map prefill top-k rows to a ragged stream of compressed-cache slots.
4371 +
4372 + ``topk_indices`` holds local compressed positions for the prefill tokens,
4373 + which sit at ``token_offset`` in the batch; ``token_to_req_indices``,
4374 + ``query_start_loc``, ``seq_lens`` and ``block_table`` are batch-wide.
4375 + ``block_size`` is the compressed cache's, i.e. already divided by the ratio.
4376 + """
4377 + topk_indices = topk_indices.reshape(topk_indices.shape[0], -1)
4378 + num_tokens, width = topk_indices.shape
4379 + dense = torch.empty(
4380 + (num_tokens, width), dtype=torch.int32, device=topk_indices.device
4381 + )
4382 + lens = torch.empty(num_tokens, dtype=torch.int32, device=topk_indices.device)
4383 + if num_tokens > 0 and width > 0:
4384 + _prefill_topk_global_slots_kernel[(num_tokens,)](
4385 + dense,
4386 + lens,
4387 + topk_indices,
4388 + topk_indices.stride(0),
4389 + token_to_req_indices,
4390 + query_start_loc,
4391 + seq_lens,
4392 + is_valid_token,
4393 + block_table,
4394 + block_table.stride(0),
4395 + token_offset,
4396 + num_compressed,
4397 + TOPK=width,
4398 + COMPRESS_RATIO=compress_ratio,
4399 + BLOCK_SIZE=block_size,
4400 + BLOCK_W=min(triton.next_power_of_2(width), 1024),
4401 + )
4402 + else:
4403 + lens.zero_()
4404 + return build_ragged_indices_from_dense(dense, lens, num_rows=num_rows)