[ROCm][MLA] Cut ~21x host dispatch from the MLA metadata build (#58381)

Signed-off-by: lifulu <fululi12@amd.com>
Signed-off-by: lifulu <lifulu@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: lifulu <lifulu@users.noreply.github.com>
This commit is contained in:
fululi12
2026-10-12 13:11:20 -05:00
committed by GitHub
co-authored by Claude Opus 5 mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> lifulu
parent c9e62f13c2
commit 8055293353
3 changed files with 145 additions and 18 deletions
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Differential test for the AITER MLA uniform-MTP padding decision.
`_uniform_padded_mtp_qo_len` decides whether a full-CUDA-graph decode batch may
synthesize dummy query rows. It used to reach that decision through eight tensor
dispatches -- sum/item, boolean-mask index, all, eq, nonzero, item, any -- on a
CPU tensor holding one entry per request. It now reads the tensor out once and
decides on Python ints.
That rewrite also **reordered** the checks: the `num_decode_tokens <= sum(...)`
early-out moved below the divisibility and `uniform_qo_len <= 1` guards. Every
one of those paths returns 0, so the result should be unchanged -- but "should
be" is exactly the sort of claim worth checking exhaustively, so both
implementations are carried here and compared over the whole small input space.
Pure host arithmetic; no GPU required.
"""
import itertools
import pytest
import torch
from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAMetadataBuilder
def _reference(qo_len: torch.Tensor, max_qo_len: int, num_decode_tokens: int) -> int:
"""The pre-#58381 implementation, verbatim."""
num_reqs = qo_len.numel()
if num_reqs == 0 or num_decode_tokens <= 0:
return 0
if num_decode_tokens <= int(qo_len.sum().item()):
return 0
if num_decode_tokens % num_reqs != 0:
return 0
uniform_qo_len = num_decode_tokens // num_reqs
if uniform_qo_len <= 1:
return 0
positive_qo_len = qo_len[qo_len > 0]
if positive_qo_len.numel() == qo_len.numel():
return 0
if positive_qo_len.numel() > 0:
if max_qo_len != uniform_qo_len:
return 0
if not torch.all(positive_qo_len == uniform_qo_len):
return 0
zero_positions = torch.nonzero(qo_len == 0, as_tuple=False).flatten()
if zero_positions.numel() > 0:
first_zero = int(zero_positions[0].item())
if torch.any(qo_len[first_zero:] > 0):
return 0
return uniform_qo_len
_ACTUAL = AiterMLAMetadataBuilder._uniform_padded_mtp_qo_len
@pytest.mark.parametrize("num_reqs", [1, 2, 3, 4])
def test_matches_reference_exhaustively(num_reqs):
"""Every qo_len vector x decode-token count x max_qo_len in a small space."""
checked = 0
for lens in itertools.product(range(0, 4), repeat=num_reqs):
qo = torch.tensor(lens, dtype=torch.int32)
for num_decode_tokens in range(0, 13):
for max_qo_len in range(0, 5):
got = _ACTUAL(qo, max_qo_len, num_decode_tokens)
want = _reference(qo, max_qo_len, num_decode_tokens)
assert got == want, (
f"lens={lens} num_decode_tokens={num_decode_tokens} "
f"max_qo_len={max_qo_len}: got {got}, reference {want}"
)
checked += 1
assert checked > 0
def test_empty_batch():
assert _ACTUAL(torch.zeros(0, dtype=torch.int32), 4, 8) == 0
assert _reference(torch.zeros(0, dtype=torch.int32), 4, 8) == 0
def test_the_padded_mtp_case_that_should_succeed():
"""A live prefix at the uniform width, then zero-padded dummies."""
qo = torch.tensor([4, 4, 0, 0], dtype=torch.int32)
# 4 requests x 4 tokens = 16 captured, 8 live -> dummies are synthesizable.
assert _ACTUAL(qo, 4, 16) == 4
assert _reference(qo, 4, 16) == 4
def test_positive_after_zero_is_rejected():
"""A live request after a padding hole breaks the contiguous-prefix layout."""
qo = torch.tensor([4, 0, 4, 0], dtype=torch.int32)
assert _ACTUAL(qo, 4, 16) == 0
assert _reference(qo, 4, 16) == 0
def test_ragged_live_lengths_are_rejected():
qo = torch.tensor([4, 2, 0, 0], dtype=torch.int32)
assert _ACTUAL(qo, 4, 16) == 0
assert _reference(qo, 4, 16) == 0
@@ -2540,16 +2540,26 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
"Non-causal multi-token MLA requires an explicitly supported "
"attention group."
)
query_lens = query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]
num_active_reqs = int(torch.count_nonzero(query_lens > 0))
uniform_active_queries = num_active_reqs > 0 and bool(
torch.all(query_lens[:num_active_reqs] == query_lens[0])
# query_start_loc_cpu is host-side and holds one entry per
# request, so this check costs more in tensor dispatch than in
# the work it does. Read it out once and validate on Python ints:
# the block runs on every build, between decode steps.
qsl = query_start_loc_cpu.tolist()
query_lens = [qsl[i + 1] - qsl[i] for i in range(len(qsl) - 1)]
num_active_reqs = 0
for qlen in query_lens:
if qlen > 0:
num_active_reqs += 1
uniform_active_queries = num_active_reqs > 0 and all(
qlen == query_lens[0] for qlen in query_lens[:num_active_reqs]
)
trailing_graph_padding = all(
qlen == 0 for qlen in query_lens[num_active_reqs:]
)
trailing_graph_padding = bool(torch.all(query_lens[num_active_reqs:] == 0))
if not (uniform_active_queries and trailing_graph_padding):
raise ValueError(
"Non-causal MLA requires a uniform query block; got query "
f"lengths {query_lens.tolist()}."
f"lengths {query_lens}."
)
# Use exact GPU sequence lengths instead of the prefill path's CPU
# context-length upper bounds.
@@ -563,8 +563,6 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
# query_start_loc flat for dummy requests. Only synthesize dummy rows
# when every padded request maps to the same qlen and the q buffer has
# exactly that many rows.
if num_decode_tokens <= int(qo_len.sum().item()):
return 0
if num_decode_tokens % num_reqs != 0:
return 0
@@ -572,19 +570,34 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
if uniform_qo_len <= 1:
return 0
positive_qo_len = qo_len[qo_len > 0]
if positive_qo_len.numel() == qo_len.numel():
# qo_len is derived from query_start_loc_cpu, so it is already
# host-side and tiny (one entry per request). Reading it out once and
# deciding on Python ints avoids eight separate tensor dispatches --
# sum/item, boolean-mask index, all, eq, nonzero, item, any -- on the
# metadata-build path, which runs between decode steps while the GPUs
# are idle.
lens = qo_len.tolist()
if num_decode_tokens <= sum(lens):
return 0
if positive_qo_len.numel() > 0:
first_zero = -1
num_positive = 0
for i, qlen in enumerate(lens):
if qlen > 0:
num_positive += 1
# A positive entry after a zero breaks the contiguous live
# prefix that the padded layout requires.
if first_zero >= 0:
return 0
elif first_zero < 0:
first_zero = i
if num_positive == num_reqs:
return 0
if num_positive > 0:
if max_qo_len != uniform_qo_len:
return 0
if not torch.all(positive_qo_len == uniform_qo_len):
return 0
zero_positions = torch.nonzero(qo_len == 0, as_tuple=False).flatten()
if zero_positions.numel() > 0:
first_zero = int(zero_positions[0].item())
if torch.any(qo_len[first_zero:] > 0):
if any(qlen != uniform_qo_len for qlen in lens if qlen > 0):
return 0
return uniform_qo_len