[ROCm][MLA][DCP] Support causal multi-token verification (#51705)

Signed-off-by: Yichao Zhu <Yichao.Zhu@amd.com> Signed-off-by: billishyahao <bill.he@amd.com> Co-authored-by: andyluo7 <andy.luo@amd.com> Co-authored-by: billishyahao <bill.he@amd.com>

YukioZzz committed Sep 1, 2026 at 01:42 UTC dbb7fffddbca2815d5d2339c52ca04a6715f2139
7 files changed +1105 -111
tests/kernels/attention/test_rocm_aiter_mla_causal_verify_mask.py
+20 -5
@@ -58,6 +58,13 @@ PAGE_SIZE = 1
58 MAX_MODEL_LEN = 1024
59 MAX_NUM_SEQS = 8
60
61 +# The builder resolves the verify routing and records it on the metadata, so
62 +# the gfx950 feature probe has to be forced for the build as well as for the
63 +# forward; ROCm CI also runs gfx942, where the real probe is False.
64 +_GLUON_SUPPORTED_TARGET = (
65 + "vllm.v1.attention.backends.mla.rocm_aiter_mla._gluon_mla_decode_supported"
66 +)
67 +
68
69 def _seq_lens() -> list[int]:
70 return [c + QLEN for c in CONTEXT_LENS] + [0] * PADDING_ROWS
@@ -99,6 +106,10 @@ def _run_verify_block():
106 vllm_config.speculative_config = SpeculativeConfig(
107 method="ngram", num_speculative_tokens=QLEN - 1
108 )
109 + vllm_config.model_config.get_num_attention_heads = types.MethodType(
110 + lambda self, parallel_config, arch_config=None: NUM_QUERY_HEADS,
111 + vllm_config.model_config,
112 + )
113
114 spec = MLAAttentionSpec(
115 block_size=PAGE_SIZE,
@@ -138,7 +149,10 @@ def _run_verify_block():
149 captured["page_table"] = kwargs["page_table"].detach().clone()
150 captured["seq_info"] = kwargs["seq_info"].detach().clone()
151
141 - with set_current_vllm_config(vllm_config):
152 + with (
153 + patch(_GLUON_SUPPORTED_TARGET, lambda: True),
154 + set_current_vllm_config(vllm_config),
155 + ):
156 builder = builder_cls(spec, [layer_name], vllm_config, device)
157 common_attn_metadata = create_common_attn_metadata(
158 batch_spec, PAGE_SIZE, device, arange_block_indices=True
@@ -182,10 +196,7 @@ def _run_verify_block():
196 # The Gluon kernel only reads the metadata this test is about, so a spy in
197 # its place keeps the assertions independent of the AITER build.
198 with (
185 - patch(
186 - "vllm.v1.attention.backends.mla.rocm_aiter_mla._gluon_mla_decode_supported",
187 - lambda: True,
188 - ),
199 + patch(_GLUON_SUPPORTED_TARGET, lambda: True),
200 patch(
201 "vllm.v1.attention.backends.mla.rocm_aiter_mla._get_mla_gluon",
202 lambda: spy,
@@ -211,6 +222,10 @@ def test_verify_mtp_uses_native_4d_gluon_entry():
222 f"expected a {QLEN}-token verify block, got max_qo_len="
223 f"{decode.max_qo_len}; the MTP path under test was not reached"
224 )
225 + assert decode.use_gluon_verify, (
226 + "the builder owns verify routing, so the metadata -- not the impl -- "
227 + "has to select the Gluon MTP entry for a small-head bf16 verify block"
228 + )
229 assert captured, "forward_mqa did not reach the Gluon kernel"
230
231 seq_lens = _seq_lens()
tests/kernels/attention/test_rocm_aiter_mla_head_padding.py
+2
@@ -324,7 +324,9 @@ def test_h12_aiter_mla_decode_matches_reference():
324
325 impl = object.__new__(AiterMLAImpl)
326 impl.num_heads = NUM_HEADS
327 + impl.dcp_world_size = 1
328 impl.kv_lora_rank = KV_LORA_RANK
329 + impl.qk_rope_head_dim = QK_ROPE_HEAD_DIM
330 impl.scale = SCALE
331 impl.kv_cache_dtype = "auto"
332
tests/v1/attention/test_rocm_aiter_mla_fp8_decode_routing.py
+27 -3
@@ -8,6 +8,10 @@ batch_size == 1, so it cannot serve a decode batch. Every fp8 shape therefore
8 has to land on the asm kernels, which ship real fp8 variants for gqa=16. These
9 tests pin that down at the predicates, since the failure it prevents is either a
10 batch assertion or -- worse -- a silently wrong result.
11 +
12 +Gluon is still the route for supported non-DCP decode shapes. DCP multi-token
13 +verification uses segmented MLA instead, so it depends on neither Gluon's
14 +query-head nor its long-context pipeline limits.
15 """
16
17 import pytest
@@ -41,8 +45,8 @@ def gluon_available(monkeypatch):
45 @pytest.mark.parametrize("kv_cache_dtype", FP8_DTYPES)
46 @pytest.mark.parametrize("num_heads", [1, 2, 4, 5, 6, 8, 12, 16, 32, 128])
47 @pytest.mark.parametrize("max_qo_len", [1, 2, 4, 5, 8, 15])
44 -def test_fp8_never_routes_to_gluon(kv_cache_dtype, num_heads, max_qo_len):
45 - """No fp8 shape reaches either Gluon entry point.
48 +def test_non_dcp_fp8_never_routes_to_gluon(kv_cache_dtype, num_heads, max_qo_len):
49 + """No non-DCP fp8 shape reaches either Gluon entry point.
50
51 The head count is deliberately swept across divisors of 16 as well as
52 non-divisors: the divisor case (e.g. 8 heads at TP8) is the one that stays
@@ -58,7 +62,7 @@ def test_fp8_never_routes_to_gluon(kv_cache_dtype, num_heads, max_qo_len):
62 def test_fp8_never_routes_to_gluon_under_any_mode(
63 monkeypatch, kv_cache_dtype, num_heads, mode
64 ):
61 - """VLLM_ROCM_AITER_MLA_ASM_PADDING cannot force an fp8 cache onto Gluon.
65 + """VLLM_ROCM_AITER_MLA_ASM_PADDING cannot force a non-DCP fp8 cache onto Gluon.
66
67 The dtype guard deliberately precedes the mode knob: honouring an explicit
68 "gluon" request under fp8 would hand Gluon the batch it asserts against, so
@@ -80,6 +84,26 @@ def test_large_head_counts_never_use_gluon(kv_cache_dtype, num_heads, max_qo_len
84 assert not AiterMLAHelper.use_gluon_verify(num_heads, max_qo_len, kv_cache_dtype)
85
86
87 +@pytest.mark.parametrize("kv_cache_dtype", FP8_DTYPES + UNQUANTIZED_DTYPES)
88 +@pytest.mark.parametrize("num_heads", [8, 16, 32, 96])
89 +@pytest.mark.parametrize("max_qo_len", [2, 3, 8])
90 +def test_dcp_multitoken_verify_never_uses_gluon(
91 + gluon_available, kv_cache_dtype, num_heads, max_qo_len
92 +):
93 + assert not AiterMLAHelper.use_gluon_verify(
94 + num_heads, max_qo_len, kv_cache_dtype, dcp_world_size=8
95 + )
96 +
97 +
98 +def test_segmented_dcp_verify_does_not_depend_on_gluon(monkeypatch):
99 + monkeypatch.setattr(rocm_aiter_mla, "_gluon_mla_decode_supported", lambda: False)
100 + monkeypatch.setattr(rocm_aiter_mla, "_segmented_mla_decode_supported", lambda: True)
101 +
102 + assert rocm_aiter_mla._segmented_dcp_verify_supported(8, 1)
103 + # Round-robin interleaving other than 1 is not served by this route.
104 + assert not rocm_aiter_mla._segmented_dcp_verify_supported(8, 4)
105 +
106 +
107 @pytest.mark.parametrize("kv_cache_dtype", UNQUANTIZED_DTYPES)
108 @pytest.mark.parametrize("num_heads", [1, 2, 4, 8])
109 def test_unquantized_divisor_heads_keep_gluon_decode(
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py
+380 -5
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import math
5 import sys
6 from types import SimpleNamespace
7 from unittest import mock
@@ -16,8 +17,12 @@ if not current_platform.is_rocm():
17 from vllm.v1.attention.backends.mla import rocm_aiter_mla # noqa: E402
18 from vllm.v1.attention.backends.mla.rocm_aiter_mla import ( # noqa: E402
19 AiterMLAHelper,
20 + AiterMLAImpl,
21 AiterMLAMetadataBuilder,
22 )
23 +from vllm.v1.attention.ops.rocm_aiter_mla_merge import ( # noqa: E402
24 + merge_mla_segments_triton,
25 +)
26
27
28 class _NoOpTritonKernel:
@@ -29,6 +34,19 @@ class _NoOpTritonKernel:
34 pass
35
36
37 +def _lse_combine_natural(
38 + out_a: torch.Tensor,
39 + lse_a: torch.Tensor,
40 + out_b: torch.Tensor,
41 + lse_b: torch.Tensor,
42 + out_dtype: torch.dtype,
43 +) -> tuple[torch.Tensor, torch.Tensor]:
44 + normalizer = torch.logaddexp(lse_a, lse_b)
45 + out = out_a * torch.exp(lse_a - normalizer).unsqueeze(-1)
46 + out += out_b * torch.exp(lse_b - normalizer).unsqueeze(-1)
47 + return out.to(out_dtype), normalizer
48 +
49 +
50 class _ExpandPageIndicesKernel:
51 def __getitem__(self, grid):
52 self.grid = grid
@@ -67,10 +85,27 @@ def _builder(
85 max_decode_rows: int = 32,
86 num_heads: int = 16,
87 kv_cache_dtype: str = "auto",
88 + dcp_world_size: int = 1,
89 + dcp_rank: int = 0,
90 ):
71 - return SimpleNamespace(
91 + stub = SimpleNamespace(
92 device=torch.device("cpu"),
93 num_heads=num_heads,
94 + # DCP gathers every rank's head shard before decode, and the routing
95 + # predicates read the gathered count.
96 + _decode_num_heads=num_heads * dcp_world_size,
97 + dcp_world_size=dcp_world_size,
98 + dcp_rank=dcp_rank,
99 + cp_kv_cache_interleave_size=1,
100 + # Mirrors the production constructor: configuration decides whether the
101 + # segmented route is available, query length decides per batch.
102 + _supports_segmented_dcp_verify=rocm_aiter_mla._segmented_dcp_verify_supported(
103 + dcp_world_size, 1
104 + ),
105 + # Derived once in the real constructor, so derive it once here too.
106 + _segmented_page_size=rocm_aiter_mla._segmented_mla_page_size(kernel_block_size),
107 + _dcp_verify_buffers=None,
108 + _graph_seq_lens=None,
109 _kv_cache_dtype_str=kv_cache_dtype,
110 paged_kv_last_page_len=torch.ones(max_decode_rows, dtype=torch.int32),
111 paged_kv_indices=torch.empty(1024, dtype=torch.int32),
@@ -96,6 +131,15 @@ def _builder(
131 _mla_kv_dtype=torch.bfloat16,
132 decode_attn_out_dtype=torch.bfloat16,
133 )
134 + # Bound to the stub rather than faked: the verify flatten's per-row view is
135 + # part of what _build_decode is being tested for.
136 + stub._build_dcp_verify_row_view = (
137 + AiterMLAMetadataBuilder._build_dcp_verify_row_view.__get__(stub)
138 + )
139 + stub._fill_dcp_verify_page_table = (
140 + AiterMLAMetadataBuilder._fill_dcp_verify_page_table.__get__(stub)
141 + )
142 + return stub
143
144
145 def test_backend_declares_uniform_batch_support():
@@ -111,6 +155,324 @@ def test_backend_declares_uniform_batch_support():
155 )
156
157
158 +def test_dcp_verify_row_view_is_causal_per_row():
159 + """Row t must cover this rank's share of positions [0, seq_len - qlen + t].
160 +
161 + Regression guard: giving every row of a request the committed prefix only
162 + drops the whole verify block, including each row's own token, since nothing
163 + else adds it back on the target path.
164 + """
165 + qlen = 4
166 + builder = _builder(mtp_decode_qlen=qlen, dcp_world_size=2, kernel_block_size=2)
167 + block_table = torch.tensor([[7, 8, 9], [10, 11, 12]], dtype=torch.int32)
168 +
169 + view = builder._build_dcp_verify_row_view(
170 + qlen,
171 + block_table,
172 + torch.tensor([10, 12], dtype=torch.int32),
173 + )
174 +
175 + # seq_len 10 -> rows see 7, 8, 9, 10 global positions, of which rank 0 of 2
176 + # holds ceil(n / 2); seq_len 12 -> 9, 10, 11, 12.
177 + assert view.row_lens.tolist() == [4, 4, 5, 5, 5, 5, 6, 6]
178 + assert view.qo_indptr.tolist() == list(range(2 * qlen + 1))
179 + # One query per row, so the page table is the request's ascending shard and
180 + # only the row length selects the causal prefix.
181 + assert view.block_table.tolist() == [[7, 8, 9]] * qlen + [[10, 11, 12]] * qlen
182 + assert view.max_kv_seq_len == 6
183 +
184 +
185 +def test_dcp_verify_rows_sum_to_the_global_window_across_ranks():
186 + """Every rank's rows together must cover each row's window exactly once."""
187 + qlen = 4
188 + dcp_world_size = 2
189 + seq_lens = torch.tensor([10, 12], dtype=torch.int32)
190 + per_rank = []
191 + for dcp_rank in range(dcp_world_size):
192 + builder = _builder(
193 + mtp_decode_qlen=qlen,
194 + dcp_world_size=dcp_world_size,
195 + dcp_rank=dcp_rank,
196 + kernel_block_size=2,
197 + )
198 + view = builder._build_dcp_verify_row_view(
199 + qlen,
200 + torch.tensor([[7, 8, 9], [10, 11, 12]], dtype=torch.int32),
201 + seq_lens,
202 + )
203 + per_rank.append(view.row_lens.tolist())
204 +
205 + expected = [
206 + int(seq_len) - qlen + t + 1
207 + for seq_len in seq_lens.tolist()
208 + for t in range(qlen)
209 + ]
210 + assert [sum(lens) for lens in zip(*per_rank)] == expected
211 +
212 +
213 +def test_dcp_verify_row_view_uses_static_graph_bound():
214 + """Under full graphs the bound comes from the buffers, not the batch."""
215 + qlen = 3
216 + builder = _builder(
217 + mtp_decode_qlen=qlen,
218 + dcp_world_size=8,
219 + has_full_cudagraphs=True,
220 + kernel_block_size=1536,
221 + )
222 + builder._dcp_verify_buffers = rocm_aiter_mla.AiterMLADCPVerifyMetadata(
223 + row_lens=torch.zeros(qlen, dtype=torch.int32),
224 + block_table=torch.zeros((qlen, 24), dtype=torch.int32),
225 + qo_indptr=torch.zeros(qlen + 1, dtype=torch.int32),
226 + page_size=128,
227 + max_kv_seq_len=3072,
228 + )
229 +
230 + view = builder._build_dcp_verify_row_view(
231 + qlen,
232 + torch.arange(2, dtype=torch.int32).view(1, 2),
233 + torch.tensor([1000], dtype=torch.int32),
234 + )
235 +
236 + buffers = builder._dcp_verify_buffers
237 + assert view.row_lens.tolist() == [125] * qlen
238 + assert view.block_table.data_ptr() == buffers.block_table.data_ptr()
239 + assert view.qo_indptr.data_ptr() == buffers.qo_indptr.data_ptr()
240 + assert view.block_table[0].tolist() == list(range(24))
241 + assert view.max_kv_seq_len == 3072
242 +
243 +
244 +def test_dcp_fp8_verify_build_uses_segmented(monkeypatch):
245 + qlen = 4
246 + monkeypatch.setattr(rocm_aiter_mla, "_segmented_mla_decode_supported", lambda: True)
247 +
248 + metadata = AiterMLAMetadataBuilder._build_decode(
249 + _builder(
250 + mtp_decode_qlen=qlen,
251 + dcp_world_size=2,
252 + kv_cache_dtype="fp8",
253 + kernel_block_size=2,
254 + ),
255 + block_table_tensor=torch.tensor([[0, 1, 2], [10, 11, 12]], dtype=torch.int32),
256 + seq_lens_device=torch.tensor([5, 6], dtype=torch.int32),
257 + max_seq_len=6,
258 + query_start_loc_cpu=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
259 + query_start_loc_device=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
260 + num_decode_tokens=2 * qlen,
261 + dcp_tot_seq_lens_device=torch.tensor([10, 12], dtype=torch.int32),
262 + )
263 +
264 + assert metadata.dcp_verify is not None
265 +
266 +
267 +def test_single_token_dcp_decode_returns_unpadded_lse(monkeypatch):
268 + """Single-token DCP decode must come back with an LSE the merge can use.
269 +
270 + This is the path every DCP step takes outside verification. The vLLM custom
271 + op drops aiter's LSE, so the impl calls aiter directly; without an LSE the
272 + MLA layer's cross-rank combine asserts. The head count is deliberately a
273 + non-divisor of 16 so both the output and the LSE have to be unpadded back
274 + to the gathered head count.
275 + """
276 + num_heads, dcp_world_size = 6, 2
277 + decode_heads = num_heads * dcp_world_size # 12, padded to 16 by aiter
278 + num_tokens, head_dim = 3, 576
279 + captured = {}
280 +
281 + def fake_aiter_decode(q, kv_buffer, out, *args, **kwargs):
282 + captured["q_heads"] = q.shape[1]
283 + captured["return_lse"] = kwargs.get("return_lse")
284 + return None, torch.zeros(num_tokens, q.shape[1])
285 +
286 + monkeypatch.setattr(
287 + rocm_aiter_mla, "_get_aiter_mla_decode", lambda: fake_aiter_decode
288 + )
289 +
290 + impl = object.__new__(AiterMLAImpl)
291 + impl.num_heads = num_heads
292 + impl.dcp_world_size = dcp_world_size
293 + impl.kv_cache_dtype = "auto"
294 + impl.kv_lora_rank = 512
295 + impl.qk_rope_head_dim = 64
296 + impl.scale = head_dim**-0.5
297 + decode = SimpleNamespace(
298 + max_qo_len=1,
299 + qo_indptr=torch.arange(num_tokens + 1, dtype=torch.int32),
300 + paged_kv_indptr=torch.zeros(num_tokens + 1, dtype=torch.int32),
301 + paged_kv_indices=torch.zeros(1, dtype=torch.int32),
302 + paged_kv_last_page_len=torch.ones(num_tokens, dtype=torch.int32),
303 + use_gluon_decode=False,
304 + use_gluon_verify=False,
305 + dcp_verify=None,
306 + has_persistent_metadata=False,
307 + attn_out_dtype=torch.bfloat16,
308 + )
309 + attn_metadata = SimpleNamespace(decode=decode, causal=True, work_meta_data=None)
310 + layer = SimpleNamespace(_q_scale=torch.tensor(1.0), _k_scale=torch.tensor(1.0))
311 +
312 + q = torch.zeros(num_tokens, decode_heads, head_dim, dtype=torch.bfloat16)
313 + output, lse = impl.forward_mqa(q, torch.zeros(1, 1, head_dim), attn_metadata, layer)
314 +
315 + assert captured["return_lse"] is True
316 + # aiter is handed the padded head count, but both results come back at the
317 + # gathered count the cross-rank merge expects.
318 + assert captured["q_heads"] == 16
319 + assert lse is not None
320 + assert output.shape[1] == decode_heads
321 + assert lse.shape == (num_tokens, decode_heads)
322 +
323 +
324 +def test_verify_partial_attention_merge():
325 + device = torch.device("cuda")
326 + out_a = torch.tensor([[[1.0, 2.0]], [[3.0, 4.0]]], device=device)
327 + out_b = torch.tensor([[[5.0, 6.0]], [[7.0, 8.0]]], device=device)
328 + lse_a = torch.tensor([[0.0], [1.0]], device=device)
329 + lse_b = torch.tensor([[1.0], [0.0]], device=device)
330 +
331 + out, lse = _lse_combine_natural(out_a, lse_a, out_b, lse_b, torch.float32)
332 + normalizer = torch.logaddexp(lse_a, lse_b)
333 + expected = out_a * torch.exp(lse_a - normalizer).unsqueeze(-1) + out_b * torch.exp(
334 + lse_b - normalizer
335 + ).unsqueeze(-1)
336 +
337 + torch.testing.assert_close(out, expected)
338 + torch.testing.assert_close(lse, normalizer)
339 +
340 +
341 +def test_segmented_verify_reduce_returns_natural_lse_and_masks_empty_rows():
342 + segment_output = torch.tensor(
343 + [1.0, 3.0, 99.0, 99.0],
344 + device="cuda",
345 + ).reshape(2, 1, 2, 1)
346 + segment_max = torch.tensor(
347 + [[[0.0, 1.0]], [[99.0, 99.0]]],
348 + device="cuda",
349 + )
350 + segment_expsum = torch.ones_like(segment_max)
351 +
352 + output, lse = merge_mla_segments_triton(
353 + segment_output,
354 + segment_max,
355 + segment_expsum,
356 + torch.tensor([2, 0], dtype=torch.int32, device="cuda"),
357 + tile_size=1,
358 + out_dtype=torch.float32,
359 + )
360 +
361 + torch.testing.assert_close(output[0], torch.tensor([[7.0 / 3]], device="cuda"))
362 + assert output[1].item() == 0
363 + torch.testing.assert_close(lse[0], torch.tensor([math.log(3.0)], device="cuda"))
364 + assert lse[1].item() == float("-inf")
365 +
366 +
367 +def test_segmented_dcp_verify_matches_causal_attention(monkeypatch):
368 + """Exercise the target qlen>1 path and merge two rank-local results."""
369 + monkeypatch.setattr(rocm_aiter_mla, "_segmented_mla_decode_supported", lambda: True)
370 +
371 + torch.manual_seed(0)
372 + device = torch.device("cuda")
373 + dcp_world_size = 2
374 + qlen = 3
375 + num_heads = 64
376 + kv_lora_rank = 512
377 + rope_dim = 64
378 + head_dim = kv_lora_rank + rope_dim
379 + global_seq_len = 259
380 + block_size = 128
381 + kv_scale = 0.02
382 + sm_scale = head_dim**-0.5
383 +
384 + q_nope = torch.randn(
385 + qlen, num_heads, kv_lora_rank, dtype=torch.bfloat16, device=device
386 + )
387 + q_pe = torch.randn(qlen, num_heads, rope_dim, dtype=torch.bfloat16, device=device)
388 + kv_source = torch.randn(
389 + global_seq_len, head_dim, dtype=torch.float32, device=device
390 + )
391 + kv_fp8 = (kv_source / kv_scale).to(torch.float8_e4m3fn)
392 + kv_dequant = kv_fp8.float() * kv_scale
393 +
394 + partials = []
395 + for dcp_rank in range(dcp_world_size):
396 + local_kv = kv_fp8[dcp_rank::dcp_world_size]
397 + num_blocks = math.ceil(local_kv.shape[0] / block_size)
398 + kv_cache = torch.zeros(
399 + num_blocks,
400 + block_size,
401 + head_dim,
402 + dtype=torch.float8_e4m3fn,
403 + device=device,
404 + )
405 + kv_cache.view(-1, head_dim)[: local_kv.shape[0]].copy_(local_kv)
406 +
407 + builder = _builder(
408 + mtp_decode_qlen=qlen,
409 + kernel_block_size=block_size,
410 + num_heads=num_heads // dcp_world_size,
411 + kv_cache_dtype="fp8",
412 + dcp_world_size=dcp_world_size,
413 + dcp_rank=dcp_rank,
414 + )
415 + block_table = torch.arange(
416 + num_blocks, dtype=torch.int32, device=device
417 + ).unsqueeze(0)
418 + view = builder._build_dcp_verify_row_view(
419 + qlen,
420 + block_table,
421 + torch.tensor([global_seq_len], dtype=torch.int32, device=device),
422 + )
423 + decode = SimpleNamespace(
424 + max_qo_len=qlen,
425 + paged_kv_indptr=torch.tensor(
426 + [0, local_kv.shape[0]], dtype=torch.int32, device=device
427 + ),
428 + paged_kv_indices=torch.arange(
429 + local_kv.shape[0], dtype=torch.int32, device=device
430 + ),
431 + use_gluon_decode=False,
432 + use_gluon_verify=False,
433 + dcp_verify=view,
434 + attn_out_dtype=torch.bfloat16,
435 + )
436 + attn_metadata = SimpleNamespace(decode=decode, causal=True)
437 + impl = object.__new__(AiterMLAImpl)
438 + impl.num_heads = num_heads // dcp_world_size
439 + impl.dcp_world_size = dcp_world_size
440 + impl.kv_cache_dtype = "fp8"
441 + impl.kv_lora_rank = kv_lora_rank
442 + impl.qk_rope_head_dim = rope_dim
443 + impl.scale = sm_scale
444 + layer = SimpleNamespace(_k_scale=torch.tensor(kv_scale, device=device))
445 +
446 + partials.append(
447 + impl.forward_mqa((q_nope, q_pe), kv_cache, attn_metadata, layer)
448 + )
449 +
450 + output, lse = partials[0]
451 + assert lse is not None
452 + for rank_output, rank_lse in partials[1:]:
453 + assert rank_lse is not None
454 + output, lse = _lse_combine_natural(
455 + output.float(), lse, rank_output.float(), rank_lse, torch.float32
456 + )
457 +
458 + reference = torch.empty_like(output)
459 + q_nope_fp32 = q_nope.float()
460 + q_pe_fp32 = q_pe.float()
461 + for query_pos in range(qlen):
462 + visible = global_seq_len - qlen + query_pos + 1
463 + keys = kv_dequant[:visible]
464 + scores = torch.einsum(
465 + "hd,nd->hn", q_nope_fp32[query_pos], keys[:, :kv_lora_rank]
466 + )
467 + scores += torch.einsum(
468 + "hd,nd->hn", q_pe_fp32[query_pos], keys[:, kv_lora_rank:]
469 + )
470 + probs = torch.softmax(scores * sm_scale, dim=-1)
471 + reference[query_pos] = probs @ keys[:, :kv_lora_rank]
472 +
473 + torch.testing.assert_close(output, reference, rtol=3e-2, atol=3e-2)
474 +
475 +
476 @pytest.mark.parametrize("num_heads", [8, 16, 24, 32, 64, 128])
477 @pytest.mark.parametrize(
478 "spec_method, parallel_drafting",
@@ -120,7 +482,7 @@ def test_backend_declares_uniform_batch_support():
482 # and a parallel one, so the threshold is 1 + 2 * num_spec rather than
483 # 1 + num_spec. Sizing the metadata off a method name instead leaves
484 # these at qlen=1 while the router still admits the full range.
123 - ("dspark", True),
485 + ("custom", True),
486 ("eagle", False),
487 ],
488 )
@@ -161,6 +523,8 @@ def test_mtp_builder_init_sizes_native_fp8_metadata(
523
524 def init_common_builder(self, *args, **kwargs):
525 self.num_heads = num_heads
526 + self.dcp_world_size = 1
527 + self.cp_kv_cache_interleave_size = 1
528 # Mirror what _init_reorder_batch_threshold would have left behind: the
529 # metadata is sized from the routing threshold, so a stub that skips it
530 # would size for qlen=1 and hide the very mismatch this test covers.
@@ -190,12 +554,23 @@ def test_mtp_builder_init_sizes_native_fp8_metadata(
554 num_speculative_tokens=3,
555 parallel_drafting=parallel_drafting,
556 ),
193 - parallel_config=SimpleNamespace(tensor_parallel_size=8),
194 - model_config=SimpleNamespace(max_model_len=16, dtype=torch.bfloat16),
557 + parallel_config=SimpleNamespace(
558 + tensor_parallel_size=8,
559 + decode_context_parallel_size=1,
560 + cp_kv_cache_interleave_size=1,
561 + ),
562 + model_config=SimpleNamespace(
563 + max_model_len=16,
564 + dtype=torch.bfloat16,
565 + get_num_attention_heads=lambda parallel_config: num_heads,
566 + ),
567 scheduler_config=SimpleNamespace(max_num_seqs=2),
568 cache_config=SimpleNamespace(cache_dtype="fp8_e4m3"),
569 compilation_config=SimpleNamespace(
198 - cudagraph_mode=SimpleNamespace(has_full_cudagraphs=lambda: False)
570 + cudagraph_mode=SimpleNamespace(has_full_cudagraphs=lambda: False),
571 + # Empty: the per-layer head-count probe finds no attention layer and
572 + # falls back to the model config above.
573 + static_forward_context={},
574 ),
575 )
576 builder = AiterMLAMetadataBuilder(
vllm/platforms/rocm.py
+10 -16
@@ -900,23 +900,17 @@ class RocmPlatform(Platform):
900 compilation_config = vllm_config.compilation_config
901 parallel_config = vllm_config.parallel_config
902
903 - if compilation_config.cudagraph_mode.has_full_cudagraphs():
904 - # decode context parallel does not support full cudagraphs
905 - if parallel_config.decode_context_parallel_size > 1:
906 - logger.warning_once(
907 - "Decode context parallel (DCP) is enabled, which is "
908 - "incompatible with full CUDA graphs. "
909 - "Overriding cudagraph_mode to PIECEWISE."
910 - )
911 - compilation_config.cudagraph_mode = CUDAGraphMode.PIECEWISE
903 + if (
904 + compilation_config.cudagraph_mode.has_full_cudagraphs()
905 + and parallel_config.prefill_context_parallel_size > 1
906 + ):
907 # prefill context parallel do not support full cudagraphs
913 - elif parallel_config.prefill_context_parallel_size > 1:
914 - logger.warning_once(
915 - "Prefill context parallel (PCP) is enabled, which is "
916 - "incompatible with full CUDA graphs. "
917 - "Overriding cudagraph_mode to PIECEWISE."
918 - )
919 - compilation_config.cudagraph_mode = CUDAGraphMode.PIECEWISE
908 + logger.warning_once(
909 + "Prefill context parallel (PCP) is enabled, which is "
910 + "incompatible with full CUDA graphs. "
911 + "Overriding cudagraph_mode to PIECEWISE."
912 + )
913 + compilation_config.cudagraph_mode = CUDAGraphMode.PIECEWISE
914
915 if parallel_config.worker_cls == "auto":
916 parallel_config.worker_cls = "vllm.v1.worker.gpu_worker.Worker"
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+530 -82
@@ -11,6 +11,7 @@ import torch
11 from vllm._aiter_ops import rocm_aiter_ops
12 from vllm.config import VllmConfig
13 from vllm.config.cache import CacheDType
14 +from vllm.distributed import get_dcp_group
15 from vllm.logger import init_logger
16 from vllm.model_executor.layers.attention.mla_attention import (
17 MLACommonBackend,
@@ -21,17 +22,39 @@ from vllm.model_executor.layers.attention.mla_attention import (
22 QueryLenSupport,
23 )
24 from vllm.triton_utils import tl, triton
25 +from vllm.utils.math_utils import cdiv, largest_power_of_2_divisor
26 from vllm.v1.attention.backend import (
27 AttentionCGSupport,
28 AttentionLayer,
29 CommonAttentionMetadata,
30 MultipleOf,
31 )
32 +from vllm.v1.attention.backends.utils import (
33 + get_dcp_local_seq_lens,
34 +)
35 +from vllm.v1.attention.ops.rocm_aiter_mla_merge import (
36 + merge_mla_segments_triton,
37 +)
38 from vllm.v1.kv_cache_interface import AttentionSpec, is_quantized_kv_cache
39
40 logger = init_logger(__name__)
41
42
43 +def _segmented_mla_page_size(block_size: int) -> int:
44 + """Largest supported power-of-two subpage dividing a physical KV block.
45 +
46 + The subpage size becomes the segmented kernel's ``TILE_SIZE``: the cache is
47 + reinterpreted as ``(-1, page_size, 1, head_dim)`` before the call. It must
48 + divide the physical block and be a power of two, since the kernel walks a
49 + tile with ``tl.arange(0, TILE_SIZE)``. 128 is the largest tile that tiling
50 + supports -- a tile holds ``TILE_SIZE x kv_lora_rank`` keys and its scores
51 + are ``BLOCK_M x TILE_SIZE`` -- so a larger manager block is split into
52 + several subpages instead of widening the tile.
53 + """
54 + assert block_size > 0
55 + return min(128, largest_power_of_2_divisor(block_size))
56 +
57 +
58 @functools.lru_cache(maxsize=1)
59 def _get_mla_gluon():
60 """Load the small-head Gluon MLA entry point."""
@@ -137,6 +160,43 @@ def _gluon_mla_decode_supported() -> bool:
160 return on_gfx950()
161
162
163 +@functools.lru_cache(maxsize=1)
164 +def _get_segmented_mla_decode():
165 + """Load AITER's segmented MLA decode with unreduced partial output."""
166 + from aiter.ops.triton.attention.mla import mla_decode_fwd
167 +
168 + return mla_decode_fwd
169 +
170 +
171 +@functools.lru_cache(maxsize=1)
172 +def _get_aiter_mla_decode():
173 + from aiter.mla import mla_decode_fwd
174 +
175 + return mla_decode_fwd
176 +
177 +
178 +@functools.lru_cache(maxsize=1)
179 +def _segmented_mla_decode_supported() -> bool:
180 + """Whether AITER exposes the segmented MLA decode used by DCP verify."""
181 + try:
182 + _get_segmented_mla_decode()
183 + except Exception: # noqa: BLE001
184 + return False
185 + return True
186 +
187 +
188 +def _segmented_dcp_verify_supported(dcp_world_size: int, cp_interleave: int) -> bool:
189 + """Whether this configuration can serve DCP verify on segmented MLA.
190 +
191 + Configuration only -- whether a given batch takes the route additionally
192 + depends on its query length. Round-robin interleaving other than 1 is
193 + excluded because the per-row causal window has not been validated there.
194 + """
195 + return (
196 + dcp_world_size > 1 and cp_interleave == 1 and _segmented_mla_decode_supported()
197 + )
198 +
199 +
200 def _aiter_mla_small_head_mode() -> str:
201 """Small-head (<16) MLA decode kernel selection.
202
@@ -200,6 +260,31 @@ class AiterMLABackend(MLACommonBackend):
260 return AiterMLAMetadataBuilder
261
262
263 +@dataclass
264 +class AiterMLADCPVerifyMetadata:
265 + """One paged-KV row per verify token, for segmented DCP verification.
266 +
267 + These are produced together or not at all, so they travel as one value: its
268 + presence on the decode metadata *is* the routing decision the builder made,
269 + and the impl does not re-derive it.
270 + """
271 +
272 + # Local KV length of each verify row, already causally bounded.
273 + row_lens: torch.Tensor
274 + # Subpage IDs each row reads, shape [num_rows, max_local_pages].
275 + block_table: torch.Tensor
276 + # One query per row, so this is an arange.
277 + qo_indptr: torch.Tensor
278 + # Subpage size the block table is expressed in, and the segmented kernel's
279 + # TILE_SIZE. Carried so the impl reinterprets the cache exactly the way the
280 + # block table was built, instead of re-deriving it from the cache shape.
281 + page_size: int
282 + # Kernel-visible KV bound. Whenever full graphs are enabled this is the
283 + # configuration's maximum for every batch, not just during capture, so the
284 + # page table keeps one shape across replays.
285 + max_kv_seq_len: int
286 +
287 +
288 @dataclass
289 class AiterMLADecodeMetadata(MLACommonDecodeMetadata):
290 # The indptr of the paged kv cache, shape: [batch_size + 1]
@@ -217,8 +302,12 @@ class AiterMLADecodeMetadata(MLACommonDecodeMetadata):
302 max_qo_len: int | None = None
303 # Minimum KV length used by Gluon to choose a safe split count.
304 min_kv_seq_len: int = 1
305 + # Set exactly when this batch routes to segmented DCP verification.
306 + dcp_verify: AiterMLADCPVerifyMetadata | None = None
307 # Small-head decode uses Gluon (avoids padding to 16).
308 use_gluon_decode: bool = False
309 + # Small-head non-DCP multi-token verify uses Gluon's native MTP entry.
310 + use_gluon_verify: bool = False
311 # Whether persistent MLA metadata was computed
312 has_persistent_metadata: bool = False
313
@@ -305,13 +394,27 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
394 vllm_config: VllmConfig,
395 device: torch.device,
396 ):
397 + parallel_config = vllm_config.parallel_config
398 + supports_segmented_dcp_verify = _segmented_dcp_verify_supported(
399 + parallel_config.decode_context_parallel_size,
400 + parallel_config.cp_kv_cache_interleave_size,
401 + )
402 super().__init__(
309 - kv_cache_spec, layer_names, vllm_config, device, AiterMLAMetadata
403 + kv_cache_spec,
404 + layer_names,
405 + vllm_config,
406 + device,
407 + AiterMLAMetadata,
408 + supports_dcp_with_varlen=supports_segmented_dcp_verify,
409 )
410 + self._supports_segmented_dcp_verify = supports_segmented_dcp_verify
411
412 self.compilation_config = vllm_config.compilation_config
413 self.decode_attn_out_dtype = vllm_config.model_config.dtype
414
415 + # Needed to place a verify row's causal window on this rank's KV shard.
416 + self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0
417 +
418 # reorder_batch_threshold is the largest query length decode can be
419 # handed, and already accounts for the drafting scheme. A method-name
420 # whitelist sizes unlisted drafters for qlen=1, which closes the
@@ -323,6 +426,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
426 # (e.g. 16 with Eagle3), we expand block-level indices into per-token
427 # flat indices since the aiter kernel always uses page_size=1 internally.
428 self.kernel_block_size = kv_cache_spec.block_size
429 + self._segmented_page_size = _segmented_mla_page_size(self.kernel_block_size)
430
431 # In the flat view (.view(-1,1,1,H)), each token is its own page,
432 # so max_num_pages_per_req = max_model_len regardless of
@@ -351,10 +455,12 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
455
456 from aiter import dtypes, get_mla_metadata_info_v1
457
458 + # Decode kernels consume the DCP-gathered query heads.
459 + self._decode_num_heads = self.num_heads * self.dcp_world_size
460 # Keep metadata sizing consistent with the padded tensor shape passed
355 - # to mla_decode_fwd.
461 + # to mla_decode_fwd, including native 24-head AITER builds.
462 self._num_attention_heads = AiterMLAHelper.get_actual_mla_num_heads(
357 - self.num_heads
463 + self._decode_num_heads
464 )
465 kv_cache_dtype_str = getattr(vllm_config.cache_config, "cache_dtype", "auto")
466 if kv_cache_dtype_str in ("fp8", "fp8_e4m3", "fp8_e5m2"):
@@ -437,6 +543,10 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
543 device,
544 )
545
546 + # Persistent buffers for segmented DCP verification. Captured graphs
547 + # require stable addresses while row lengths vary between replays.
548 + self._dcp_verify_buffers: AiterMLADCPVerifyMetadata | None = None
549 + self._graph_seq_lens: torch.Tensor | None = None
550 if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
551 self.paged_kv_indptr = torch.zeros(
552 max_num_reqs + 1, dtype=torch.int32, device=device
@@ -446,6 +556,39 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
556 max_num_reqs + 1, dtype=torch.int32, device=device
557 )
558
559 + # Full graphs require a stable address after uniform-MTP padding.
560 + self._graph_seq_lens = torch.zeros(
561 + max_num_reqs, dtype=torch.int32, device=device
562 + )
563 +
564 + if self._supports_segmented_dcp_verify and self._mtp_decode_qlen > 1:
565 + # A DCP rank's shard of the longest sequence bounds every verify
566 + # row, and full graphs need that bound to be constant.
567 + num_dcp_partitions = (
568 + self.dcp_world_size * self.cp_kv_cache_interleave_size
569 + )
570 + graph_max_kv_seq_len = (
571 + cdiv(vllm_config.model_config.max_model_len, num_dcp_partitions)
572 + * self.cp_kv_cache_interleave_size
573 + )
574 + max_verify_rows = max_num_reqs * self._mtp_decode_qlen
575 + max_local_pages = cdiv(graph_max_kv_seq_len, self._segmented_page_size)
576 + self._dcp_verify_buffers = AiterMLADCPVerifyMetadata(
577 + row_lens=torch.zeros(
578 + max_verify_rows, dtype=torch.int32, device=device
579 + ),
580 + block_table=torch.zeros(
581 + (max_verify_rows, max_local_pages),
582 + dtype=torch.int32,
583 + device=device,
584 + ),
585 + qo_indptr=torch.arange(
586 + max_verify_rows + 1, dtype=torch.int32, device=device
587 + ),
588 + page_size=self._segmented_page_size,
589 + max_kv_seq_len=graph_max_kv_seq_len,
590 + )
591 +
592 def _init_fp8_prefill_ps_buffers(
593 self,
594 max_num_reqs: int,
@@ -667,6 +810,99 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
810 metadata.fp8_prefill_max_q_len = prefill.max_query_len
811 metadata.fp8_prefill_num_partial_tiles = num_partial_tiles
812
813 + def _build_dcp_verify_row_view(
814 + self,
815 + qlen: int,
816 + block_table: torch.Tensor,
817 + dcp_tot_seq_lens: torch.Tensor,
818 + ) -> AiterMLADCPVerifyMetadata:
819 + """Build one paged-KV row per verify token for segmented DCP verification.
820 +
821 + Every row is a single query (``qo_indptr`` is an arange), so the whole
822 + causal structure is carried by ``dcp_local_verify_row_lens`` and the
823 + kernel applies no tail of its own.
824 + """
825 + assert self.dcp_world_size > 1
826 + num_reqs = dcp_tot_seq_lens.numel()
827 + row_lens = AiterMLAHelper.dcp_local_verify_row_lens(
828 + dcp_tot_seq_lens,
829 + qlen,
830 + self.dcp_world_size,
831 + self.dcp_rank,
832 + self.cp_kv_cache_interleave_size,
833 + )
834 + num_rows = row_lens.numel()
835 + buffers = self._dcp_verify_buffers
836 + # Persistent buffers exist exactly when full graphs are on, and they
837 + # already carry the static bound they were sized for.
838 + max_kv_seq_len = (
839 + buffers.max_kv_seq_len
840 + if buffers is not None
841 + else max(1, int(row_lens.max().item()))
842 + )
843 + page_size = self._segmented_page_size
844 + max_local_pages = cdiv(max_kv_seq_len, page_size)
845 + if buffers is not None:
846 + row_block_table = buffers.block_table[:num_rows]
847 + buffers.row_lens[:num_rows].copy_(row_lens, non_blocking=True)
848 + row_lens = buffers.row_lens[:num_rows]
849 + qo_indptr = buffers.qo_indptr[: num_rows + 1]
850 + else:
851 + row_block_table = torch.empty(
852 + (num_rows, max_local_pages),
853 + dtype=torch.int32,
854 + device=block_table.device,
855 + )
856 + qo_indptr = torch.arange(
857 + num_rows + 1,
858 + dtype=torch.int32,
859 + device=block_table.device,
860 + )
861 + self._fill_dcp_verify_page_table(row_block_table, block_table, num_reqs, qlen)
862 + return AiterMLADCPVerifyMetadata(
863 + row_lens=row_lens,
864 + block_table=row_block_table,
865 + qo_indptr=qo_indptr,
866 + page_size=page_size,
867 + max_kv_seq_len=max_kv_seq_len,
868 + )
869 +
870 + def _fill_dcp_verify_page_table(
871 + self,
872 + row_block_table: torch.Tensor,
873 + block_table: torch.Tensor,
874 + num_reqs: int,
875 + qlen: int,
876 + ) -> None:
877 + """Expand each request's blocks into the subpages one verify row reads.
878 +
879 + Every row of a request shares the request's shard, so the page list is
880 + built once per request and repeated; only ``row_lens`` distinguishes the
881 + rows. A DCP rank holds ``1/dcp_world_size`` of the sequence, so the
882 + request's block table is always wider than the pages a row can reach.
883 + """
884 + pages_per_block = self.kernel_block_size // self._segmented_page_size
885 + max_local_pages = row_block_table.shape[1]
886 + max_local_blocks = cdiv(max_local_pages, pages_per_block)
887 + assert max_local_blocks <= block_table.shape[1], (
888 + f"DCP verify needs {max_local_blocks} blocks per request but the "
889 + f"block table only has {block_table.shape[1]}"
890 + )
891 + subpage_offsets = torch.arange(
892 + pages_per_block,
893 + dtype=block_table.dtype,
894 + device=block_table.device,
895 + )
896 + per_req_page_table = (
897 + block_table[:num_reqs, :max_local_blocks, None] * pages_per_block
898 + + subpage_offsets
899 + ).flatten(1)[:, :max_local_pages]
900 + # Broadcast the request's page list across its rows instead of
901 + # materializing a repeat_interleave copy.
902 + row_block_table.unflatten(0, (num_reqs, qlen)).copy_(
903 + per_req_page_table.unsqueeze(1)
904 + )
905 +
906 def _build_decode(
907 self,
908 block_table_tensor: torch.Tensor,
@@ -701,6 +937,12 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
937 seq_lens_for_kernel.new_full((), max_qo_len),
938 )
939
940 + if self._graph_seq_lens is not None:
941 + self._graph_seq_lens[:num_kernel_reqs].copy_(
942 + seq_lens_for_kernel, non_blocking=True
943 + )
944 + seq_lens_for_kernel = self._graph_seq_lens[:num_kernel_reqs]
945 +
946 # The aiter kernel always operates with page_size=1 (the wrapper
947 # flattens kv_buffer). last_page_len is always 1.
948 paged_kv_last_page_len = self.paged_kv_last_page_len[:num_kernel_reqs]
@@ -713,26 +955,43 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
955 ]
956 )
957 use_gluon_decode = AiterMLAHelper.use_gluon_decode(
716 - self.num_heads, int(max_qo_len), self._kv_cache_dtype_str
958 + self._decode_num_heads,
959 + int(max_qo_len),
960 + self._kv_cache_dtype_str,
961 )
718 -
719 - if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
720 - self.paged_kv_indices.fill_(-1)
721 -
722 - # Expand block_table entries into per-token flat indices.
723 - # When kernel_block_size=1, this degrades to a direct copy (identical
724 - # to the original _copy_page_indices_kernel).
725 - # When kernel_block_size=K>1, block_table entry b covering K tokens
726 - # gets expanded to flat indices b*K, b*K+1, ..., b*K+(K-1).
727 - _expand_page_indices_kernel[(num_reqs,)](
728 - self.paged_kv_indices,
729 - block_table_tensor,
730 - block_table_tensor.stride(0),
731 - paged_kv_indptr,
732 - KERNEL_BLOCK_SIZE=self.kernel_block_size,
733 - BLOCK_SIZE=1024,
962 + use_gluon_verify = AiterMLAHelper.use_gluon_verify(
963 + self._decode_num_heads,
964 + int(max_qo_len),
965 + self._kv_cache_dtype_str,
966 + self.dcp_world_size,
967 )
735 - paged_kv_indices = self.paged_kv_indices
968 + use_segmented_dcp_verify = (
969 + self._supports_segmented_dcp_verify and max_qo_len > 1
970 + )
971 +
972 + # Segmented DCP verify carries its own per-row subpage table, so the
973 + # flat per-token view is dead work for it. Leave the buffer alone and
974 + # hand the metadata None, so a future reader cannot pick up whatever
975 + # the previous batch left behind.
976 + paged_kv_indices = None
977 + if not use_segmented_dcp_verify:
978 + if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
979 + self.paged_kv_indices.fill_(-1)
980 +
981 + # Expand block_table entries into per-token flat indices.
982 + # When kernel_block_size=1, this degrades to a direct copy (identical
983 + # to the original _copy_page_indices_kernel).
984 + # When kernel_block_size=K>1, block_table entry b covering K tokens
985 + # gets expanded to flat indices b*K, b*K+1, ..., b*K+(K-1).
986 + _expand_page_indices_kernel[(num_reqs,)](
987 + self.paged_kv_indices,
988 + block_table_tensor,
989 + block_table_tensor.stride(0),
990 + paged_kv_indptr,
991 + KERNEL_BLOCK_SIZE=self.kernel_block_size,
992 + BLOCK_SIZE=1024,
993 + )
994 + paged_kv_indices = self.paged_kv_indices
995
996 if self.compilation_config.cudagraph_mode.has_full_cudagraphs():
997 self.paged_kv_indptr[: 1 + num_kernel_reqs].copy_(
@@ -745,12 +1004,8 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1004 # (set above), so no copy needed - buffer is always 1s.
1005
1006 if pad_uniform_mtp:
748 - qo_indptr_src = torch.arange(
749 - 0,
750 - (num_kernel_reqs + 1) * max_qo_len,
751 - step=max_qo_len,
752 - dtype=torch.int32,
753 - device=device,
1007 + qo_indptr_src = AiterMLAHelper.qo_indptr_for_uniform_qlen(
1008 + num_kernel_reqs, int(max_qo_len), device
1009 )
1010 else:
1011 qo_indptr_src = query_start_loc_device[: 1 + num_kernel_reqs]
@@ -762,42 +1017,32 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1017
1018 else:
1019 if max_qo_len == 1:
765 - qo_indptr = torch.arange(
766 - 0,
767 - num_kernel_reqs + 1,
768 - step=1,
769 - dtype=torch.int32,
770 - device=device,
1020 + qo_indptr = AiterMLAHelper.qo_indptr_for_uniform_qlen(
1021 + num_kernel_reqs, 1, device
1022 )
1023 else:
1024 if pad_uniform_mtp:
774 - qo_indptr = torch.arange(
775 - 0,
776 - (num_kernel_reqs + 1) * max_qo_len,
777 - step=max_qo_len,
778 - dtype=torch.int32,
779 - device=device,
1025 + qo_indptr = AiterMLAHelper.qo_indptr_for_uniform_qlen(
1026 + num_kernel_reqs, int(max_qo_len), device
1027 )
1028 else:
1029 qo_indptr = query_start_loc_device[: 1 + num_kernel_reqs]
1030
1031 + has_persistent_metadata = False
1032 # Only the asm decode consumes the schedule, so gate on the routing
1033 # rather than on num_heads >= 16, which denies it to a padded rank
786 - # running the same asm kernels. The two predicates are disjoint --
787 - # decode is qlen==1, verify is qlen>1 -- and cover both Gluon entries.
788 - has_persistent_metadata = False
1034 + # running the same asm kernels. The predicates are disjoint -- decode
1035 + # is qlen==1, verify is qlen>1 -- and cover both Gluon entries plus the
1036 + # segmented DCP verify.
1037 use_persistent_metadata = (
790 - not AiterMLAHelper.use_gluon_decode(
791 - self.num_heads, max_qo_len, self._kv_cache_dtype_str
792 - )
793 - and not AiterMLAHelper.use_gluon_verify(
794 - self.num_heads, max_qo_len, self._kv_cache_dtype_str
795 - )
1038 + not use_gluon_decode
1039 + and not use_gluon_verify
1040 + and not use_segmented_dcp_verify
1041 # A padded rank has no bf16 persistent kernel past qlen 4 where the
1042 # gfx950 fold is absent; the non-persistent entry covers it. fp8
1043 # keeps the schedule -- its fold rejects non-persistent outright.
1044 and (
800 - self.num_heads >= AiterMLAHelper._AITER_MIN_MLA_HEADS
1045 + self._decode_num_heads >= AiterMLAHelper._AITER_MIN_MLA_HEADS
1046 or max_qo_len <= AiterMLAHelper._ASM_PADDED_MAX_PS_QLEN
1047 or is_quantized_kv_cache(self._kv_cache_dtype_str)
1048 )
@@ -840,11 +1085,10 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1085 # active requests, not cudagraph padding rows pinned to max_qo_len.
1086 # The .item() below runs in the builder, outside the captured region,
1087 # so it does not abort HIP graph capture the way the per-layer syncs
843 - # in forward_mqa did.
1088 + # in forward_mqa did. DCP verify never reaches here -- it carries its
1089 + # own per-row lengths instead of a single split bound.
1090 min_kv_seq_len = 1
845 - if AiterMLAHelper.use_gluon_verify(
846 - self.num_heads, int(max_qo_len), self._kv_cache_dtype_str
847 - ):
1091 + if use_gluon_verify:
1092 per_req_len = paged_kv_indptr[1:] - paged_kv_indptr[:-1]
1093 if pad_uniform_mtp:
1094 active = qo_lens_device > 0
@@ -853,6 +1097,15 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1097 else:
1098 min_kv_seq_len = int(per_req_len.min().item())
1099
1100 + dcp_verify = None
1101 + if use_segmented_dcp_verify:
1102 + assert dcp_tot_seq_lens_device is not None
1103 + dcp_verify = self._build_dcp_verify_row_view(
1104 + int(max_qo_len),
1105 + block_table_tensor,
1106 + dcp_tot_seq_lens_device,
1107 + )
1108 +
1109 attn_metadata = AiterMLADecodeMetadata(
1110 block_table=block_table_tensor,
1111 seq_lens=seq_lens_for_kernel,
@@ -863,7 +1116,9 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1116 dcp_tot_seq_lens=dcp_tot_seq_lens_device,
1117 max_qo_len=max_qo_len,
1118 min_kv_seq_len=min_kv_seq_len,
1119 + dcp_verify=dcp_verify,
1120 use_gluon_decode=use_gluon_decode,
1121 + use_gluon_verify=use_gluon_verify,
1122 attn_out_dtype=self.decode_attn_out_dtype,
1123 has_persistent_metadata=has_persistent_metadata,
1124 )
@@ -966,6 +1221,22 @@ class AiterMLAHelper:
1221 _ASM_PADDED_MAX_PS_QLEN: Final = 4
1222 _AITER_UNSUPPORTED_HEADS: ClassVar[tuple[int, ...]] = ()
1223
1224 + @staticmethod
1225 + def qo_indptr_for_uniform_qlen(
1226 + num_reqs: int,
1227 + qlen: int,
1228 + device: torch.device,
1229 + dtype: torch.dtype = torch.int32,
1230 + ) -> torch.Tensor:
1231 + """Build ``[0, qlen, 2*qlen, ..., num_reqs*qlen]``."""
1232 + return torch.arange(
1233 + 0,
1234 + (num_reqs + 1) * qlen,
1235 + step=qlen,
1236 + dtype=dtype,
1237 + device=device,
1238 + )
1239 +
1240 @staticmethod
1241 def check_num_heads_validity(num_heads: int):
1242 assert AiterMLAHelper.is_valid_num_heads(num_heads), (
@@ -1042,14 +1313,22 @@ class AiterMLAHelper:
1313
1314 @staticmethod
1315 def get_mla_unpadded_o(num_heads: int, o: torch.Tensor) -> torch.Tensor:
1316 + return AiterMLAHelper._get_mla_unpadded_heads(num_heads, o)
1317 +
1318 + @staticmethod
1319 + def _get_mla_unpadded_heads(num_heads: int, tensor: torch.Tensor) -> torch.Tensor:
1320 m = AiterMLAHelper.get_actual_mla_num_heads(num_heads)
1321 if num_heads == m:
1047 - return o
1322 + return tensor
1323 if m % num_heads == 0:
1049 - return o[:, :: m // num_heads, :]
1324 + return tensor[:, :: m // num_heads, ...]
1325 # Undo the tile-padding from get_mla_padded_q: the real heads are the
1326 # first num_heads.
1052 - return o[:, :num_heads, :]
1327 + return tensor[:, :num_heads, ...]
1328 +
1329 + @staticmethod
1330 + def get_mla_unpadded_lse(num_heads: int, lse: torch.Tensor) -> torch.Tensor:
1331 + return AiterMLAHelper._get_mla_unpadded_heads(num_heads, lse)
1332
1333 @staticmethod
1334 def use_gluon_decode(num_heads: int, max_qo_len: int, kv_cache_dtype: str) -> bool:
@@ -1074,7 +1353,12 @@ class AiterMLAHelper:
1353 return m % num_heads == 0 and gluon_supported
1354
1355 @staticmethod
1077 - def use_gluon_verify(num_heads: int, max_qo_len: int, kv_cache_dtype: str) -> bool:
1356 + def use_gluon_verify(
1357 + num_heads: int,
1358 + max_qo_len: int,
1359 + kv_cache_dtype: str,
1360 + dcp_world_size: int = 1,
1361 + ) -> bool:
1362 """Whether a small-head multi-token verify uses native Gluon MTP.
1363
1364 bf16 has no gqa<16, qseqlen>1 asm kernel, so verify goes through
@@ -1083,16 +1367,67 @@ class AiterMLAHelper:
1367 come here: the MTP path hands Gluon the batch size its fp8 regime
1368 asserts against. A predicate rather than inline in forward_mqa so the
1369 builder sees the same answer the impl acts on.
1370 +
1371 + DCP verify is excluded: its per-row causal windows are served by the
1372 + segmented path, which Gluon's MTP entry cannot express.
1373 """
1087 - if num_heads >= AiterMLAHelper._AITER_MIN_MLA_HEADS or max_qo_len <= 1:
1374 + if max_qo_len <= 1 or dcp_world_size > 1:
1375 return False
1376 if is_quantized_kv_cache(kv_cache_dtype):
1377 return False
1378 + if num_heads >= AiterMLAHelper._AITER_MIN_MLA_HEADS:
1379 + return False
1380 + if not _gluon_mla_decode_supported():
1381 + return False
1382 # Same arch and mode gating as use_gluon_decode.
1092 - return _aiter_mla_small_head_mode() != "asm" and _gluon_mla_decode_supported()
1383 + return _aiter_mla_small_head_mode() != "asm"
1384 +
1385 + @staticmethod
1386 + def dcp_local_verify_row_lens(
1387 + tot_seq_lens: torch.Tensor,
1388 + qlen: int,
1389 + dcp_world_size: int,
1390 + dcp_rank: int,
1391 + cp_interleave: int,
1392 + ) -> torch.Tensor:
1393 + """Local KV length of every verify row, in global-causal order.
1394 +
1395 + Row ``t`` of a qlen-token verify attends global positions
1396 + ``[0, seq_len - qlen + t]``, its own token included, so its local length
1397 + is the round-robin count evaluated at that bound. Truncating causally in
1398 + global coordinates has to happen before the shard mapping: the tokens a
1399 + row drops sit on ``qlen - 1 - t`` different ranks, while subtracting the
1400 + offset from a request's local length takes one from every rank.
1401 + """
1402 + offsets = torch.arange(
1403 + 1,
1404 + qlen + 1,
1405 + device=tot_seq_lens.device,
1406 + dtype=tot_seq_lens.dtype,
1407 + )
1408 + visible = (tot_seq_lens.unsqueeze(1) - qlen + offsets).clamp_(min=0)
1409 + return get_dcp_local_seq_lens(
1410 + visible,
1411 + dcp_world_size,
1412 + dcp_rank,
1413 + cp_interleave,
1414 + ).flatten()
1415
1416
1417 class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1418 + # DCP decode paths return natural-log softmax LSE for the cross-rank merge.
1419 + can_return_lse_for_decode: bool = True
1420 + # Measured on gfx950: aiter.mla.mla_decode_fwd(return_lse=True) matches
1421 + # logsumexp to fp32 exactly, and merge_mla_segments_triton converts AITER's
1422 + # base-2 segment statistics with LOGE2. Stated rather than inherited because
1423 + # the DCP combine silently corrupts the softmax denominator if it disagrees.
1424 + lse_base_on_e: bool = True
1425 +
1426 + @property
1427 + def _decode_num_heads(self) -> int:
1428 + """Return the query-head count after DCP gathering."""
1429 + return self.num_heads * self.dcp_world_size
1430 +
1431 def __init__(
1432 self,
1433 num_heads: int,
@@ -1122,6 +1457,7 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1457 **mla_args,
1458 )
1459 AiterMLAHelper.check_num_heads_validity(num_heads)
1460 + AiterMLAHelper.check_num_heads_validity(self._decode_num_heads)
1461
1462 unsupported_features = [alibi_slopes, sliding_window, logits_soft_cap]
1463 if any(unsupported_features):
@@ -1363,6 +1699,59 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1699
1700 self._mla_fp8_prefill_attn(q, k, v, attn_metadata, output)
1701
1702 + def _forward_segmented_dcp_verify(
1703 + self,
1704 + q_nope: torch.Tensor,
1705 + q_pe: torch.Tensor,
1706 + verify: AiterMLADCPVerifyMetadata,
1707 + kv_c_and_k_pe_cache: torch.Tensor,
1708 + layer: AttentionLayer,
1709 + out_dtype: torch.dtype,
1710 + ) -> tuple[torch.Tensor, torch.Tensor]:
1711 + """Run segmented attention over this rank's shard of every verify row.
1712 +
1713 + Each row's length already covers the current tokens this rank holds, so
1714 + the cross-rank LSE merge in the MLA layer completes the causal block.
1715 + """
1716 + q_mla = torch.cat([q_nope, q_pe], dim=-1)
1717 + # skip_reduce with NUM_SEGMENTS>1 returns the partials and never writes
1718 + # `out`. Pass None rather than aliasing q_mla: if that ever stops
1719 + # holding, this fails loudly instead of scribbling over the query.
1720 + segment_partials = _get_segmented_mla_decode()(
1721 + q_mla,
1722 + kv_c_and_k_pe_cache.view(
1723 + -1,
1724 + verify.page_size,
1725 + 1,
1726 + kv_c_and_k_pe_cache.shape[-1],
1727 + ),
1728 + None,
1729 + verify.qo_indptr,
1730 + verify.row_lens,
1731 + verify.max_kv_seq_len,
1732 + verify.block_table,
1733 + self.scale,
1734 + self.kv_lora_rank,
1735 + self.qk_rope_head_dim,
1736 + causal=True,
1737 + q_descale=None,
1738 + kv_descale=layer._k_scale,
1739 + skip_reduce=True,
1740 + )
1741 + assert isinstance(segment_partials, tuple) and len(segment_partials) == 3, (
1742 + "AITER segmented MLA verify must return segment partials "
1743 + "when skip_reduce=True."
1744 + )
1745 + segm_output, segm_max, segm_expsum = segment_partials
1746 + return merge_mla_segments_triton(
1747 + segm_output,
1748 + segm_max,
1749 + segm_expsum,
1750 + verify.row_lens,
1751 + verify.page_size,
1752 + out_dtype,
1753 + )
1754 +
1755 def forward_mqa(
1756 self,
1757 q: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
@@ -1375,9 +1764,9 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1764
1765 decode = attn_metadata.decode
1766 assert decode.max_qo_len is not None
1378 - assert decode.paged_kv_indptr is not None
1379 - assert decode.paged_kv_indices is not None
1767 if decode.use_gluon_decode:
1768 + assert decode.paged_kv_indptr is not None
1769 + assert decode.paged_kv_indices is not None
1770 if type(q) is tuple:
1771 q_nope, q_pe = q
1772 else:
@@ -1416,12 +1805,11 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1805 # The block is causal -- the target is checking draft tokens, so
1806 # position t must not see t+1 -- and the kernel bounds each query
1807 # position's scores itself.
1419 - # Arch, mode and dtype gating all live in use_gluon_verify, so that the
1420 - # builder -- which has to know whether the asm decode will run -- sees
1421 - # the same answer as this branch.
1422 - if AiterMLAHelper.use_gluon_verify(
1423 - self.num_heads, int(decode.max_qo_len), self.kv_cache_dtype
1424 - ):
1808 + # Arch, mode and dtype gating all live in use_gluon_verify, which the
1809 + # builder evaluates and records on the metadata, so this branch acts on
1810 + # the same answer the builder used to decide whether the asm decode
1811 + # would run. DCP verify is routed to the segmented path below instead.
1812 + if decode.use_gluon_verify:
1813 if type(q) is tuple:
1814 q_nope, q_pe = q
1815 else:
@@ -1473,14 +1861,48 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1861 )
1862 return o, None
1863
1864 + verify = decode.dcp_verify
1865 + if verify is not None:
1866 + if type(q) is tuple:
1867 + q_nope, q_pe = q
1868 + else:
1869 + q_nope, q_pe = torch.split(
1870 + q, [self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
1871 + )
1872 + if (
1873 + is_quantized_kv_cache(self.kv_cache_dtype)
1874 + and q_nope.dtype != torch.bfloat16
1875 + ):
1876 + q_nope = q_nope.to(torch.bfloat16) * layer._q_scale
1877 + q_pe = q_pe.to(torch.bfloat16) * layer._q_scale
1878 + return self._forward_segmented_dcp_verify(
1879 + q_nope,
1880 + q_pe,
1881 + verify,
1882 + kv_c_and_k_pe_cache,
1883 + layer,
1884 + decode.attn_out_dtype,
1885 + )
1886 +
1887 + if self.dcp_world_size > 1 and int(decode.max_qo_len) > 1:
1888 + raise RuntimeError(
1889 + "ROCM_AITER_MLA DCP multi-token verify requires segmented MLA."
1890 + )
1891 +
1892 if type(q) is tuple:
1893 q = torch.cat(q, dim=-1)
1894
1895 assert isinstance(q, torch.Tensor)
1896 + assert decode.paged_kv_indptr is not None
1897 + assert decode.paged_kv_indices is not None
1898 B = q.shape[0]
1899
1482 - mla_padded_q = AiterMLAHelper.get_mla_padded_q(self.num_heads, q)
1483 - mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(self.num_heads)
1900 + assert q.shape[1] == self._decode_num_heads, (
1901 + "ROCM_AITER_MLA decode expected the DCP-gathered query head count "
1902 + f"{self._decode_num_heads}, got {q.shape[1]}"
1903 + )
1904 + mla_padded_q = AiterMLAHelper.get_mla_padded_q(self._decode_num_heads, q)
1905 + mla_num_heads = AiterMLAHelper.get_actual_mla_num_heads(self._decode_num_heads)
1906 o = torch.empty(
1907 B,
1908 mla_num_heads,
@@ -1512,17 +1934,43 @@ class AiterMLAImpl(MLACommonImpl[AiterMLAMetadata]):
1934 reduce_partial_map=attn_metadata.reduce_partial_map,
1935 )
1936
1515 - rocm_aiter_ops.mla_decode_fwd(
1516 - mla_padded_q,
1517 - kv_buffer,
1518 - o,
1519 - self.scale,
1520 - decode.qo_indptr,
1521 - decode.max_qo_len,
1522 - decode.paged_kv_indptr,
1523 - decode.paged_kv_indices,
1524 - decode.paged_kv_last_page_len,
1525 - **mla_kwargs,
1526 - )
1937 + lse = None
1938 + if self.dcp_world_size > 1:
1939 + # The vLLM custom-op wrapper exposes only the in-place output and
1940 + # drops aiter's final LSE, which the cross-shard merge needs, so go
1941 + # through aiter's native entry point on the DCP path.
1942 + _, lse = _get_aiter_mla_decode()(
1943 + mla_padded_q,
1944 + kv_buffer.view(-1, 1, 1, mla_padded_q.shape[-1]),
1945 + o,
1946 + decode.qo_indptr,
1947 + decode.paged_kv_indptr,
1948 + decode.paged_kv_indices,
1949 + decode.paged_kv_last_page_len,
1950 + decode.max_qo_len,
1951 + sm_scale=self.scale,
1952 + return_lse=True,
1953 + **mla_kwargs,
1954 + )
1955 + assert lse is not None, (
1956 + "aiter mla_decode_fwd(return_lse=True) returned no LSE; upgrade "
1957 + "aiter to a build with decode LSE support."
1958 + )
1959 + else:
1960 + rocm_aiter_ops.mla_decode_fwd(
1961 + mla_padded_q,
1962 + kv_buffer,
1963 + o,
1964 + self.scale,
1965 + decode.qo_indptr,
1966 + decode.max_qo_len,
1967 + decode.paged_kv_indptr,
1968 + decode.paged_kv_indices,
1969 + decode.paged_kv_last_page_len,
1970 + **mla_kwargs,
1971 + )
1972
1528 - return AiterMLAHelper.get_mla_unpadded_o(self.num_heads, o), None
1973 + output = AiterMLAHelper.get_mla_unpadded_o(self._decode_num_heads, o)
1974 + if lse is not None:
1975 + lse = AiterMLAHelper.get_mla_unpadded_lse(self._decode_num_heads, lse)
1976 + return output, lse
vllm/v1/attention/ops/rocm_aiter_mla_merge.py new
+136
@@ -0,0 +1,136 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Merge AITER segmented MLA split-K partials into output plus natural-log LSE.
4 +
5 +This mirrors AITER's own segment reduction, including its
6 +``tiles_per_segment = cdiv(seq_len, NUM_SEGMENTS * TILE_SIZE)`` partitioning, so
7 +it has to move together with the ``skip_reduce=True`` call in the AITER MLA
8 +backend. It is a rank-local split-K merge and unrelated to any collective
9 +reduce; the natural-log LSE it returns is what the cross-rank DCP merge
10 +consumes.
11 +"""
12 +
13 +import torch
14 +
15 +from vllm.triton_utils import LOGE2, tl, triton
16 +
17 +
18 +@triton.jit
19 +def _merge_mla_segments_kernel(
20 + out_ptr,
21 + lse_ptr,
22 + segm_output_ptr,
23 + segm_max_ptr,
24 + segm_expsum_ptr,
25 + seq_lens_ptr,
26 + num_query_heads: tl.constexpr,
27 + out_stride0: tl.int64,
28 + out_stride1: tl.int64,
29 + lse_stride0: tl.int64,
30 + TILE_SIZE: tl.constexpr,
31 + KV_LORA_RANK: tl.constexpr,
32 + NUM_SEGMENTS_PER_SEQ: tl.constexpr,
33 + LOGE2: tl.constexpr,
34 +):
35 + token_idx = tl.program_id(0)
36 + head_idx = tl.program_id(1)
37 +
38 + seq_len = tl.load(seq_lens_ptr + token_idx)
39 + tiles_per_segment = tl.maximum(
40 + 1, tl.cdiv(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE)
41 + )
42 + active_segments = tl.where(
43 + seq_len > 0,
44 + tl.cdiv(seq_len, tiles_per_segment * TILE_SIZE),
45 + 0,
46 + )
47 + segment_offsets = tl.arange(0, NUM_SEGMENTS_PER_SEQ)
48 + segment_mask = segment_offsets < active_segments
49 +
50 + segment_base = (
51 + token_idx.to(tl.int64) * (num_query_heads * NUM_SEGMENTS_PER_SEQ)
52 + + head_idx * NUM_SEGMENTS_PER_SEQ
53 + )
54 + segment_indices = segment_base + segment_offsets
55 + segment_max = tl.load(
56 + segm_max_ptr + segment_indices,
57 + mask=segment_mask,
58 + other=float("-inf"),
59 + )
60 + overall_max = tl.max(segment_max)
61 +
62 + segment_expsum = tl.load(
63 + segm_expsum_ptr + segment_indices,
64 + mask=segment_mask,
65 + other=0.0,
66 + )
67 + segment_weight = tl.where(
68 + segment_mask,
69 + tl.exp2(segment_max - overall_max),
70 + 0.0,
71 + )
72 + overall_expsum = tl.sum(segment_expsum * segment_weight)
73 +
74 + output_offsets = (
75 + token_idx.to(tl.int64) * (num_query_heads * NUM_SEGMENTS_PER_SEQ * KV_LORA_RANK)
76 + + head_idx * (NUM_SEGMENTS_PER_SEQ * KV_LORA_RANK)
77 + + segment_offsets[:, None] * KV_LORA_RANK
78 + + tl.arange(0, KV_LORA_RANK)[None, :]
79 + )
80 + segment_output = tl.load(
81 + segm_output_ptr + output_offsets,
82 + mask=segment_mask[:, None],
83 + other=0.0,
84 + )
85 + accumulator = tl.sum(segment_output * segment_weight[:, None], axis=0)
86 + accumulator = tl.where(overall_expsum == 0.0, 0.0, accumulator / overall_expsum)
87 + lse = tl.where(
88 + overall_expsum == 0.0,
89 + float("-inf"),
90 + (overall_max + tl.log2(overall_expsum)) * LOGE2,
91 + )
92 +
93 + output_indices = (
94 + token_idx * out_stride0 + head_idx * out_stride1 + tl.arange(0, KV_LORA_RANK)
95 + )
96 + tl.store(out_ptr + output_indices, accumulator)
97 + tl.store(lse_ptr + token_idx * lse_stride0 + head_idx, lse)
98 +
99 +
100 +def merge_mla_segments_triton(
101 + segm_output: torch.Tensor,
102 + segm_max: torch.Tensor,
103 + segm_expsum: torch.Tensor,
104 + seq_lens: torch.Tensor,
105 + tile_size: int,
106 + out_dtype: torch.dtype,
107 +) -> tuple[torch.Tensor, torch.Tensor]:
108 + """Merge AITER base-2 segment partials into output and natural-log LSE."""
109 + num_tokens, num_heads, num_segments, kv_lora_rank = segm_output.shape
110 + output = torch.empty(
111 + (num_tokens, num_heads, kv_lora_rank),
112 + dtype=out_dtype,
113 + device=segm_output.device,
114 + )
115 + lse = torch.empty(
116 + (num_tokens, num_heads),
117 + dtype=torch.float32,
118 + device=segm_output.device,
119 + )
120 + _merge_mla_segments_kernel[(num_tokens, num_heads)](
121 + output,
122 + lse,
123 + segm_output,
124 + segm_max,
125 + segm_expsum,
126 + seq_lens,
127 + num_query_heads=num_heads,
128 + out_stride0=output.stride(0),
129 + out_stride1=output.stride(1),
130 + lse_stride0=lse.stride(0),
131 + TILE_SIZE=tile_size,
132 + KV_LORA_RANK=kv_lora_rank,
133 + NUM_SEGMENTS_PER_SEQ=num_segments,
134 + LOGE2=LOGE2,
135 + )
136 + return output, lse