[ROCm][CI] Extend ROCm AITER MHA (FA) coverage (#40958)

Signed-off-by: Andreas Karatzas <akaratza@amd.com> Signed-off-by: Micah Williamson <micah.williamson@amd.com> Co-authored-by: Micah Williamson <micah.williamson@amd.com>

Andreas Karatzas committed Aug 10, 2026 at 02:35 UTC 31cd109f18f1ff49cef8dfd25329e7c49d0f2310
2 files changed +906 -258
tests/kernels/attention/test_aiter_flash_attn.py deleted
-258
@@ -1,258 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -
5 -import pytest
6 -import torch
7 -
8 -from vllm.platforms import current_platform
9 -from vllm.utils.torch_utils import set_random_seed
10 -
11 -# Import AITER backend if on ROCm and aiter is available
12 -if current_platform.is_rocm():
13 - from vllm._aiter_ops import is_aiter_found_and_supported
14 -
15 - if is_aiter_found_and_supported():
16 - import aiter
17 -
18 - from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
19 -
20 -NUM_HEADS = [(4, 4), (8, 2)]
21 -HEAD_SIZES = [128, 256]
22 -BLOCK_SIZES = [16]
23 -DTYPES = [torch.bfloat16]
24 -QDTYPES = [None]
25 -# one value large enough to test overflow in index calculation.
26 -# one value small enough to test the schema op check
27 -NUM_BLOCKS = [32768, 2048]
28 -
29 -
30 -def ref_paged_attn(
31 - query: torch.Tensor,
32 - key_cache: torch.Tensor,
33 - value_cache: torch.Tensor,
34 - query_lens: list[int],
35 - kv_lens: list[int],
36 - block_tables: torch.Tensor,
37 - scale: float,
38 - sliding_window: int | None = None,
39 - soft_cap: float | None = None,
40 -) -> torch.Tensor:
41 - num_seqs = len(query_lens)
42 - block_tables = block_tables.cpu().numpy()
43 - _, block_size, num_kv_heads, head_size = key_cache.shape
44 -
45 - outputs: list[torch.Tensor] = []
46 - start_idx = 0
47 - for i in range(num_seqs):
48 - query_len = query_lens[i]
49 - kv_len = kv_lens[i]
50 - q = query[start_idx : start_idx + query_len]
51 - q *= scale
52 -
53 - num_kv_blocks = (kv_len + block_size - 1) // block_size
54 - block_indices = block_tables[i, :num_kv_blocks]
55 -
56 - k = key_cache[block_indices].view(-1, num_kv_heads, head_size)
57 - k = k[:kv_len]
58 - v = value_cache[block_indices].view(-1, num_kv_heads, head_size)
59 - v = v[:kv_len]
60 -
61 - if q.shape[1] != k.shape[1]:
62 - k = torch.repeat_interleave(k, q.shape[1] // k.shape[1], dim=1)
63 - v = torch.repeat_interleave(v, q.shape[1] // v.shape[1], dim=1)
64 - attn = torch.einsum("qhd,khd->hqk", q, k).float()
65 - empty_mask = torch.ones(query_len, kv_len)
66 - mask = torch.triu(empty_mask, diagonal=kv_len - query_len + 1).bool()
67 - if sliding_window is not None:
68 - sliding_window_mask = (
69 - torch.triu(
70 - empty_mask, diagonal=kv_len - (query_len + sliding_window) + 1
71 - )
72 - .bool()
73 - .logical_not()
74 - )
75 - mask |= sliding_window_mask
76 - if soft_cap is not None:
77 - attn = soft_cap * torch.tanh(attn / soft_cap)
78 - attn.masked_fill_(mask, float("-inf"))
79 - attn = torch.softmax(attn, dim=-1).to(v.dtype)
80 - out = torch.einsum("hqk,khd->qhd", attn, v)
81 -
82 - outputs.append(out)
83 - start_idx += query_len
84 -
85 - return torch.cat(outputs, dim=0)
86 -
87 -
88 -@pytest.mark.skipif(not current_platform.is_rocm(), reason="Only ROCm is supported")
89 -@pytest.mark.parametrize(
90 - "seq_lens", [[(10, 1328), (5, 18), (129, 463)], [(8, 523), (24, 37), (3, 2011)]]
91 -)
92 -@pytest.mark.parametrize("num_heads", NUM_HEADS)
93 -@pytest.mark.parametrize("head_size", HEAD_SIZES)
94 -@pytest.mark.parametrize("block_size", BLOCK_SIZES)
95 -@pytest.mark.parametrize("sliding_window", [None, 256])
96 -@pytest.mark.parametrize("dtype", DTYPES)
97 -@pytest.mark.parametrize("soft_cap", [None])
98 -@pytest.mark.parametrize("num_blocks", NUM_BLOCKS)
99 -@pytest.mark.parametrize("q_dtype", QDTYPES)
100 -@torch.inference_mode()
101 -def test_varlen_with_paged_kv(
102 - seq_lens: list[tuple[int, int]],
103 - num_heads: tuple[int, int],
104 - head_size: int,
105 - sliding_window: int | None,
106 - dtype: torch.dtype,
107 - block_size: int,
108 - soft_cap: float | None,
109 - num_blocks: int,
110 - q_dtype: torch.dtype | None,
111 -) -> None:
112 - from vllm._aiter_ops import is_aiter_found_and_supported
113 -
114 - if not is_aiter_found_and_supported():
115 - pytest.skip("aiter package required for this test.")
116 -
117 - torch.set_default_device("cuda")
118 - set_random_seed(0)
119 - num_seqs = len(seq_lens)
120 - query_lens = [x[0] for x in seq_lens]
121 - kv_lens = [x[1] for x in seq_lens]
122 - num_query_heads = num_heads[0]
123 - num_kv_heads = num_heads[1]
124 - assert num_query_heads % num_kv_heads == 0
125 - max_query_len = max(query_lens)
126 - max_kv_len = max(kv_lens)
127 - window_size = (sliding_window - 1, 0) if sliding_window is not None else (-1, -1)
128 - scale = head_size**-0.5
129 -
130 - query = torch.randn(sum(query_lens), num_query_heads, head_size, dtype=dtype)
131 - key_cache = torch.randn(
132 - num_blocks, block_size, num_kv_heads, head_size, dtype=dtype
133 - )
134 - value_cache = torch.randn_like(key_cache)
135 - cu_query_lens = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum(
136 - dim=0, dtype=torch.int32
137 - )
138 -
139 - cu_seq_lens = torch.tensor([0] + kv_lens, dtype=torch.int32).cumsum(
140 - dim=0, dtype=torch.int32
141 - )
142 - # Save kv_lens as list before converting to tensor
143 - kv_lens_list = kv_lens
144 - kv_lens = torch.tensor(kv_lens, dtype=torch.int32)
145 -
146 - max_num_blocks_per_seq = (max_kv_len + block_size - 1) // block_size
147 - block_tables = torch.randint(
148 - 0, num_blocks, (num_seqs, max_num_blocks_per_seq), dtype=torch.int32
149 - )
150 -
151 - output = torch.empty_like(query)
152 -
153 - maybe_quantized_query = query
154 - maybe_quantized_key_cache = key_cache
155 - maybe_quantized_value_cache = value_cache
156 - k_scale_tensor = None
157 - v_scale_tensor = None
158 - dequant = False
159 -
160 - if q_dtype is not None:
161 - # QKV are drawn from N(0, 1): no need for a fp8 scaling factor
162 - maybe_quantized_query = query.to(q_dtype)
163 - maybe_quantized_key_cache = key_cache.to(q_dtype)
164 - maybe_quantized_value_cache = value_cache.to(q_dtype)
165 - dequant = True
166 - scale_shape = (num_seqs, num_kv_heads)
167 -
168 - # For per-seq-per-head scales (matching AITER backend expectation)
169 - k_scale_tensor = torch.ones(scale_shape, dtype=torch.float32)
170 - v_scale_tensor = torch.ones(scale_shape, dtype=torch.float32)
171 -
172 - # Prepare metadata for cp_mha_gather_cache
173 - # token_to_batch: maps each token to its batch index
174 - token_to_batch = torch.zeros(sum(kv_lens_list), dtype=torch.int32)
175 - seq_starts = torch.zeros(num_seqs, dtype=torch.int32)
176 -
177 - token_idx = 0
178 - for batch_idx, kv_len in enumerate(kv_lens_list):
179 - token_to_batch[token_idx : token_idx + kv_len] = batch_idx
180 - seq_starts[batch_idx] = 0 # Assuming all sequences start at 0 in their blocks
181 - token_idx += kv_len
182 -
183 - # Allocate buffers for gathered KV
184 - total_kv_tokens = sum(kv_lens_list)
185 - gathered_key = torch.empty(
186 - total_kv_tokens, num_kv_heads, head_size, dtype=maybe_quantized_key_cache.dtype
187 - )
188 - gathered_value = torch.empty(
189 - total_kv_tokens,
190 - num_kv_heads,
191 - head_size,
192 - dtype=maybe_quantized_value_cache.dtype,
193 - )
194 -
195 - # Gather paged KV cache into contiguous tensors using triton kernel
196 - cp_mha_gather_cache(
197 - key_cache=maybe_quantized_key_cache,
198 - value_cache=maybe_quantized_value_cache,
199 - key=gathered_key,
200 - value=gathered_value,
201 - block_tables=block_tables,
202 - k_scales=k_scale_tensor
203 - if k_scale_tensor is not None
204 - else torch.ones(1, dtype=torch.float32),
205 - v_scales=v_scale_tensor
206 - if v_scale_tensor is not None
207 - else torch.ones(1, dtype=torch.float32),
208 - cu_seqlens_kv=cu_seq_lens,
209 - token_to_batch=token_to_batch,
210 - seq_starts=seq_starts,
211 - dequant=dequant,
212 - kv_cache_layout="NHD",
213 - total_tokens=total_kv_tokens,
214 - )
215 -
216 - # Call aiter flash attention with gathered KV
217 - aiter.flash_attn_varlen_func(
218 - q=maybe_quantized_query,
219 - k=gathered_key,
220 - v=gathered_value,
221 - cu_seqlens_q=cu_query_lens,
222 - cu_seqlens_k=cu_seq_lens,
223 - max_seqlen_q=max_query_len,
224 - max_seqlen_k=max_kv_len,
225 - min_seqlen_q=1,
226 - dropout_p=0.0,
227 - softmax_scale=scale,
228 - causal=True,
229 - window_size=window_size,
230 - alibi_slopes=None,
231 - return_lse=False,
232 - out=output,
233 - )
234 -
235 - ref_output = ref_paged_attn(
236 - query=query,
237 - key_cache=key_cache,
238 - value_cache=value_cache,
239 - query_lens=query_lens,
240 - kv_lens=kv_lens_list,
241 - block_tables=block_tables,
242 - scale=scale,
243 - sliding_window=sliding_window,
244 - soft_cap=soft_cap,
245 - )
246 -
247 - atol, rtol = 2e-2, 2e-2
248 - if q_dtype is not None:
249 - atol, rtol = 1.5e-1, 1.5e-1
250 - (
251 - torch.testing.assert_close(output, ref_output, atol=atol, rtol=rtol),
252 - f"{torch.max(torch.abs(output - ref_output))}",
253 - )
254 -
255 - # Log diff stats for tracking changes
256 - print(f"Max abs diff: {torch.max(torch.abs(output - ref_output))}")
257 - print(f"Mean diff: {torch.mean(torch.abs(output - ref_output))}")
258 - print(f"Min diff: {torch.std(torch.abs(output - ref_output))}")
tests/kernels/attention/test_rocm_aiter_fa.py new
+906
@@ -0,0 +1,906 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""ROCm-specific tests for the AITER Flash Attention backend.
4 +
5 +This file owns the ROCm backend contract and the ROCm-specific execution paths
6 +wired through ``vllm.v1.attention.backends.rocm_aiter_fa``:
7 +- backend contract and env gating
8 +- representative prefill, multi-batch, decode, and FP8-KV execution
9 +- direct kernel stress for large block tables and sliding-window masking
10 +"""
11 +
12 +import importlib
13 +
14 +import pytest
15 +import torch
16 +
17 +from vllm.platforms import current_platform
18 +from vllm.platforms.rocm import on_mi3xx
19 +from vllm.utils.torch_utils import set_random_seed
20 +
21 +pytestmark = pytest.mark.skipif(
22 + not current_platform.is_rocm(), reason="ROCm-specific tests"
23 +)
24 +
25 +
26 +@pytest.fixture(autouse=True)
27 +def _cuda_default_device():
28 + torch.set_default_device("cuda")
29 + try:
30 + yield
31 + finally:
32 + torch.set_default_device("cpu")
33 +
34 +
35 +DTYPES = [torch.bfloat16, torch.float16]
36 +HEAD_SIZES = [64, 128, 256]
37 +NUM_HEADS_PAIRS = [(8, 8), (16, 4)] # (num_q_heads, num_kv_heads) - tests GQA
38 +BLOCK_SIZE = 16
39 +NUM_BLOCKS = 2048
40 +DIRECT_NUM_BLOCKS = [2048, 32768]
41 +DIRECT_NUM_HEADS = (8, 2)
42 +DIRECT_HEAD_SIZE = 128
43 +DIRECT_DTYPE = torch.bfloat16
44 +HEAD_SIZE_TEST_NUM_HEADS_PAIRS = [(16, 16), (16, 4)]
45 +# Prefill seq lens: (query_len, kv_len). Exclude single-token decode (q=1)
46 +# because flash_attn_varlen_func is a prefill kernel; q_len=1 with short kv
47 +# triggers kernel limitations (MAE > 0.1 for head != 128 in BF16, all heads in FP16).
48 +# Single-token decode is covered separately in test_aiter_mha_decode_single_token.
49 +SEQ_LENS = [(8, 512), (32, 1024)]
50 +DIRECT_SEQ_LENS = [(10, 1328), (5, 18), (129, 463)]
51 +
52 +
53 +# Reference implementation -------------------------------------------------
54 +def ref_paged_attn(
55 + query: torch.Tensor,
56 + key_cache: torch.Tensor,
57 + value_cache: torch.Tensor,
58 + query_lens: list[int],
59 + kv_lens: list[int],
60 + block_tables: torch.Tensor,
61 + scale: float,
62 + sliding_window: int | None = None,
63 +) -> torch.Tensor:
64 + """Naive reference paged attention using einsum."""
65 + num_seqs = len(query_lens)
66 + block_tables_np = block_tables.cpu().numpy()
67 + _, block_size, num_kv_heads, head_size = key_cache.shape
68 +
69 + outputs = []
70 + start_idx = 0
71 + for i in range(num_seqs):
72 + query_len = query_lens[i]
73 + kv_len = kv_lens[i]
74 + q = query[start_idx : start_idx + query_len] * scale
75 +
76 + num_kv_blocks = (kv_len + block_size - 1) // block_size
77 + block_indices = block_tables_np[i, :num_kv_blocks]
78 +
79 + k = key_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
80 + v = value_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
81 +
82 + if q.shape[1] != k.shape[1]:
83 + k = torch.repeat_interleave(k, q.shape[1] // k.shape[1], dim=1)
84 + v = torch.repeat_interleave(v, q.shape[1] // v.shape[1], dim=1)
85 +
86 + attn = torch.einsum("qhd,khd->hqk", q, k).float()
87 + mask = torch.triu(
88 + torch.ones(query_len, kv_len), diagonal=kv_len - query_len + 1
89 + ).bool()
90 + if sliding_window is not None:
91 + window_mask = (
92 + torch.triu(
93 + torch.ones(query_len, kv_len),
94 + diagonal=kv_len - (query_len + sliding_window) + 1,
95 + )
96 + .bool()
97 + .logical_not()
98 + )
99 + mask |= window_mask
100 + attn.masked_fill_(mask, float("-inf"))
101 + attn = torch.softmax(attn, dim=-1).to(v.dtype)
102 + out = torch.einsum("hqk,khd->qhd", attn, v)
103 + outputs.append(out)
104 + start_idx += query_len
105 +
106 + return torch.cat(outputs, dim=0)
107 +
108 +
109 +# Small test helpers ------------------------------------------------------
110 +def _reload_envs():
111 + import vllm.envs as envs
112 +
113 + return importlib.reload(envs)
114 +
115 +
116 +def _assert_aiter_supported() -> None:
117 + from vllm._aiter_ops import is_aiter_found_and_supported
118 +
119 + assert is_aiter_found_and_supported(), (
120 + "aiter is required on supported ROCm hardware for this test"
121 + )
122 +
123 +
124 +def _format_observed_rate(count: int, total: int) -> str:
125 + return f"{count / total:.4%} ({count}/{total})"
126 +
127 +
128 +def _format_allowed_rate(rate: float, total: int) -> str:
129 + allowed_count = int(rate * total)
130 + return f"{rate:.4%} (<= {allowed_count}/{total})"
131 +
132 +
133 +def _print_close_stats(
134 + label: str,
135 + actual: torch.Tensor,
136 + expected: torch.Tensor,
137 + *,
138 + atol: float,
139 + rtol: float,
140 +) -> None:
141 + abs_diff = (actual - expected).abs().float().flatten()
142 + expected_abs = expected.abs().float().flatten()
143 + allowed = atol + rtol * expected_abs
144 + within = abs_diff <= allowed
145 +
146 + total = abs_diff.numel()
147 + passed = int(within.sum().item())
148 + failed = total - passed
149 +
150 + max_abs = abs_diff.max().item()
151 + mean_abs = abs_diff.mean().item()
152 + p99_abs = torch.quantile(abs_diff, 0.99).item()
153 + p999_abs = torch.quantile(abs_diff, 0.999).item()
154 + worst_ratio = (abs_diff / allowed.clamp_min(1e-12)).max().item()
155 +
156 + print(
157 + "[rocm_aiter_fa] "
158 + f"{label}: "
159 + f"pass={passed / total:.4%} ({passed}/{total}) "
160 + f"fail={_format_observed_rate(failed, total)} "
161 + f"allowed_fail={_format_allowed_rate(0.0, total)} "
162 + f"atol={atol:g} "
163 + f"rtol={rtol:g} "
164 + f"max_abs={max_abs:.6g} "
165 + f"mean_abs={mean_abs:.6g} "
166 + f"p99_abs={p99_abs:.6g} "
167 + f"p999_abs={p999_abs:.6g} "
168 + f"worst_ratio={worst_ratio:.6g}"
169 + )
170 +
171 +
172 +# Direct kernel helper ----------------------------------------------------
173 +def _run_direct_flash_attn_case(
174 + *,
175 + seq_lens: list[tuple[int, int]],
176 + num_blocks: int,
177 + sliding_window: int | None = None,
178 +) -> tuple[torch.Tensor, torch.Tensor]:
179 + import aiter
180 +
181 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
182 +
183 + set_random_seed(0)
184 +
185 + query_lens = [query_len for query_len, _ in seq_lens]
186 + kv_lens = [kv_len for _, kv_len in seq_lens]
187 + num_query_heads, num_kv_heads = DIRECT_NUM_HEADS
188 + total_query_tokens = sum(query_lens)
189 + total_kv_tokens = sum(kv_lens)
190 + max_query_len = max(query_lens)
191 + max_kv_len = max(kv_lens)
192 + scale = DIRECT_HEAD_SIZE**-0.5
193 +
194 + query = torch.randn(
195 + total_query_tokens,
196 + num_query_heads,
197 + DIRECT_HEAD_SIZE,
198 + dtype=DIRECT_DTYPE,
199 + )
200 + key_cache = torch.randn(
201 + num_blocks,
202 + BLOCK_SIZE,
203 + num_kv_heads,
204 + DIRECT_HEAD_SIZE,
205 + dtype=DIRECT_DTYPE,
206 + )
207 + value_cache = torch.randn_like(key_cache)
208 +
209 + cu_query_lens = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum(
210 + dim=0, dtype=torch.int32
211 + )
212 + cu_seq_lens = torch.tensor([0] + kv_lens, dtype=torch.int32).cumsum(
213 + dim=0, dtype=torch.int32
214 + )
215 + max_num_blocks_per_seq = (max_kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE
216 + block_tables = torch.randint(
217 + 0, num_blocks, (len(seq_lens), max_num_blocks_per_seq), dtype=torch.int32
218 + )
219 +
220 + token_to_batch = torch.empty(total_kv_tokens, dtype=torch.int32)
221 + seq_starts = torch.zeros(len(seq_lens), dtype=torch.int32)
222 + token_index = 0
223 + for batch_index, kv_len in enumerate(kv_lens):
224 + token_to_batch[token_index : token_index + kv_len] = batch_index
225 + token_index += kv_len
226 +
227 + gathered_key = torch.empty(
228 + total_kv_tokens,
229 + num_kv_heads,
230 + DIRECT_HEAD_SIZE,
231 + dtype=DIRECT_DTYPE,
232 + )
233 + gathered_value = torch.empty_like(gathered_key)
234 + cp_mha_gather_cache(
235 + key_cache=key_cache,
236 + value_cache=value_cache,
237 + key=gathered_key,
238 + value=gathered_value,
239 + block_tables=block_tables,
240 + k_scales=torch.ones(1, dtype=torch.float32),
241 + v_scales=torch.ones(1, dtype=torch.float32),
242 + cu_seqlens_kv=cu_seq_lens,
243 + token_to_batch=token_to_batch,
244 + seq_starts=seq_starts,
245 + dequant=False,
246 + kv_cache_layout="NHD",
247 + total_tokens=total_kv_tokens,
248 + )
249 +
250 + window_size = (sliding_window - 1, 0) if sliding_window is not None else (-1, -1)
251 + output = torch.empty_like(query)
252 + aiter.flash_attn_varlen_func(
253 + q=query,
254 + k=gathered_key,
255 + v=gathered_value,
256 + cu_seqlens_q=cu_query_lens,
257 + cu_seqlens_k=cu_seq_lens,
258 + max_seqlen_q=max_query_len,
259 + max_seqlen_k=max_kv_len,
260 + min_seqlen_q=1,
261 + dropout_p=0.0,
262 + softmax_scale=scale,
263 + causal=True,
264 + window_size=window_size,
265 + alibi_slopes=None,
266 + return_lse=False,
267 + out=output,
268 + )
269 +
270 + ref = ref_paged_attn(
271 + query=query,
272 + key_cache=key_cache,
273 + value_cache=value_cache,
274 + query_lens=query_lens,
275 + kv_lens=kv_lens,
276 + block_tables=block_tables,
277 + scale=scale,
278 + sliding_window=sliding_window,
279 + )
280 + return output, ref
281 +
282 +
283 +# Backend contract tests --------------------------------------------------
284 +def test_aiter_mha_backend_contract():
285 + """The ROCm backend advertises the dtypes, shapes, and attention types it
286 + is designed to handle."""
287 + from vllm.v1.attention.backend import AttentionType
288 + from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
289 +
290 + assert AiterFlashAttentionBackend.get_name() == "FLASH_ATTN"
291 + assert AiterFlashAttentionBackend.supported_dtypes == [
292 + torch.float16,
293 + torch.bfloat16,
294 + ]
295 + assert AiterFlashAttentionBackend.supported_kv_cache_dtypes == [
296 + "auto",
297 + "float16",
298 + "bfloat16",
299 + "fp8",
300 + "fp8_e4m3",
301 + "fp8_e5m2",
302 + ]
303 + assert AiterFlashAttentionBackend.get_supported_kernel_block_sizes() == [16, 32]
304 + assert AiterFlashAttentionBackend.get_supported_head_sizes() == [64, 128, 256]
305 + assert AiterFlashAttentionBackend.supports_attn_type(AttentionType.DECODER)
306 + assert not AiterFlashAttentionBackend.supports_attn_type(AttentionType.ENCODER)
307 + assert not AiterFlashAttentionBackend.supports_attn_type(
308 + AttentionType.ENCODER_DECODER
309 + )
310 +
311 +
312 +def test_aiter_mha_backend_validates_kv_cache_block_size():
313 + """The backend should reject KV cache shapes that cannot be gathered
314 + correctly by the ROCm kernel."""
315 + from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
316 +
317 + assert AiterFlashAttentionBackend.get_kv_cache_shape(8, 16, 8, 128) == (
318 + 8,
319 + 8,
320 + 16,
321 + 256,
322 + )
323 + with pytest.raises(ValueError, match="Block size must be a multiple of 16"):
324 + AiterFlashAttentionBackend.get_kv_cache_shape(8, 15, 8, 128)
325 +
326 +
327 +def test_aiter_mha_backend_supports_compute_capability_matches_mi3xx_probe():
328 + """The backend should trust the ROCm MI3xx probe instead of the raw torch
329 + capability tuple."""
330 + from vllm.platforms.interface import DeviceCapability
331 + from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
332 +
333 + assert (
334 + AiterFlashAttentionBackend.supports_compute_capability(DeviceCapability(0, 0))
335 + is on_mi3xx()
336 + )
337 +
338 +
339 +# Env and platform gate tests ---------------------------------------------
340 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
341 +@pytest.mark.parametrize(
342 + ("use_aiter", "use_mha", "expected"),
343 + [
344 + (True, True, True),
345 + (True, False, False),
346 + (False, True, False),
347 + (False, False, False),
348 + ],
349 +)
350 +def test_aiter_mha_env_flags_control_enablement(
351 + use_aiter, use_mha, expected, monkeypatch
352 +):
353 + """Both the global AITER flag and the MHA-specific flag must be enabled
354 + before the ROCm MHA path is considered active."""
355 + from vllm._aiter_ops import rocm_aiter_ops
356 +
357 + _assert_aiter_supported()
358 +
359 + with monkeypatch.context() as mp:
360 + mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
361 + mp.setenv("VLLM_ROCM_USE_AITER_MHA", "1" if use_mha else "0")
362 + _reload_envs()
363 + rocm_aiter_ops.refresh_env_variables()
364 + assert rocm_aiter_ops.is_mha_enabled() is expected
365 +
366 + _reload_envs()
367 + rocm_aiter_ops.refresh_env_variables()
368 +
369 +
370 +def test_aiter_mha_platform_gate_matches_install_and_arch():
371 + """The global AITER availability check should only open on ROCm MI3xx when
372 + the aiter package is installed."""
373 + from vllm._aiter_ops import IS_AITER_FOUND, is_aiter_found_and_supported
374 +
375 + assert is_aiter_found_and_supported() is (
376 + current_platform.is_rocm() and on_mi3xx() and IS_AITER_FOUND
377 + )
378 +
379 +
380 +# Kernel path tests -------------------------------------------------------
381 +def _run_single_seq_flash_attn_case(
382 + *,
383 + head_size: int,
384 + num_heads: tuple[int, int],
385 + seq_lens: tuple[int, int],
386 + dtype: torch.dtype,
387 +) -> tuple[torch.Tensor, torch.Tensor]:
388 + import aiter
389 +
390 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
391 +
392 + set_random_seed(0)
393 +
394 + num_q_heads, num_kv_heads = num_heads
395 + query_len, kv_len = seq_lens
396 + scale = head_size**-0.5
397 +
398 + query = torch.randn(query_len, num_q_heads, head_size, dtype=dtype)
399 + key_cache = torch.randn(
400 + NUM_BLOCKS, BLOCK_SIZE, num_kv_heads, head_size, dtype=dtype
401 + )
402 + value_cache = torch.randn_like(key_cache)
403 +
404 + cu_query_lens = torch.tensor([0, query_len], dtype=torch.int32).cumsum(
405 + dim=0, dtype=torch.int32
406 + )
407 + cu_seq_lens = torch.tensor([0, kv_len], dtype=torch.int32).cumsum(
408 + dim=0, dtype=torch.int32
409 + )
410 + max_num_blocks = (kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE
411 + block_tables = torch.randint(0, NUM_BLOCKS, (1, max_num_blocks), dtype=torch.int32)
412 +
413 + token_to_batch = torch.zeros(kv_len, dtype=torch.int32)
414 + seq_starts = torch.zeros(1, dtype=torch.int32)
415 + gathered_key = torch.empty(kv_len, num_kv_heads, head_size, dtype=dtype)
416 + gathered_value = torch.empty_like(gathered_key)
417 +
418 + cp_mha_gather_cache(
419 + key_cache=key_cache,
420 + value_cache=value_cache,
421 + key=gathered_key,
422 + value=gathered_value,
423 + block_tables=block_tables,
424 + k_scales=torch.ones(1, dtype=torch.float32),
425 + v_scales=torch.ones(1, dtype=torch.float32),
426 + cu_seqlens_kv=cu_seq_lens,
427 + token_to_batch=token_to_batch,
428 + seq_starts=seq_starts,
429 + dequant=False,
430 + kv_cache_layout="NHD",
431 + total_tokens=kv_len,
432 + )
433 +
434 + output = torch.empty_like(query)
435 + aiter.flash_attn_varlen_func(
436 + q=query,
437 + k=gathered_key,
438 + v=gathered_value,
439 + cu_seqlens_q=cu_query_lens,
440 + cu_seqlens_k=cu_seq_lens,
441 + max_seqlen_q=query_len,
442 + max_seqlen_k=kv_len,
443 + min_seqlen_q=1,
444 + dropout_p=0.0,
445 + softmax_scale=scale,
446 + causal=True,
447 + window_size=(-1, -1),
448 + alibi_slopes=None,
449 + return_lse=False,
450 + out=output,
451 + )
452 +
453 + ref = ref_paged_attn(
454 + query=query,
455 + key_cache=key_cache,
456 + value_cache=value_cache,
457 + query_lens=[query_len],
458 + kv_lens=[kv_len],
459 + block_tables=block_tables,
460 + scale=scale,
461 + )
462 + return output, ref
463 +
464 +
465 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
466 +@pytest.mark.parametrize("head_size", HEAD_SIZES)
467 +@pytest.mark.parametrize("dtype", DTYPES)
468 +@pytest.mark.parametrize("num_heads", HEAD_SIZE_TEST_NUM_HEADS_PAIRS)
469 +@pytest.mark.parametrize("seq_lens", SEQ_LENS)
470 +def test_aiter_fa_head_sizes(head_size, dtype, num_heads, seq_lens):
471 + """AITER flash attention should stay accurate across supported head sizes."""
472 + atol = 1.5e-2
473 + rtol = 1e-2
474 + _assert_aiter_supported()
475 +
476 + output, ref = _run_single_seq_flash_attn_case(
477 + head_size=head_size,
478 + num_heads=num_heads,
479 + seq_lens=seq_lens,
480 + dtype=dtype,
481 + )
482 +
483 + _print_close_stats(
484 + "head_sizes "
485 + f"dtype={dtype} head_size={head_size} "
486 + f"num_heads={num_heads} seq_lens={seq_lens}",
487 + output,
488 + ref,
489 + atol=atol,
490 + rtol=rtol,
491 + )
492 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
493 +
494 +
495 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
496 +@pytest.mark.parametrize("head_size", HEAD_SIZES)
497 +@pytest.mark.parametrize("num_heads", NUM_HEADS_PAIRS)
498 +@pytest.mark.parametrize("seq_lens", SEQ_LENS)
499 +@pytest.mark.parametrize("dtype", DTYPES)
500 +def test_aiter_mha_varlen_paged_kv(head_size, num_heads, seq_lens, dtype):
501 + """Test AITER flash attention varlen with paged KV cache.
502 +
503 + Exercises: VLLM_ROCM_USE_AITER, VLLM_ROCM_USE_AITER_MHA
504 + """
505 + atol = 1.5e-2
506 + rtol = 1e-2
507 + _assert_aiter_supported()
508 +
509 + output, ref = _run_single_seq_flash_attn_case(
510 + head_size=head_size,
511 + num_heads=num_heads,
512 + seq_lens=seq_lens,
513 + dtype=dtype,
514 + )
515 +
516 + _print_close_stats(
517 + "varlen_paged_kv "
518 + f"dtype={dtype} head_size={head_size} "
519 + f"num_heads={num_heads} seq_lens={seq_lens}",
520 + output,
521 + ref,
522 + atol=atol,
523 + rtol=rtol,
524 + )
525 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
526 +
527 +
528 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
529 +@pytest.mark.parametrize("num_heads", NUM_HEADS_PAIRS)
530 +@pytest.mark.parametrize("head_size", HEAD_SIZES)
531 +@pytest.mark.parametrize("dtype", DTYPES)
532 +def test_aiter_mha_multi_batch(num_heads, head_size, dtype):
533 + """Test AITER flash attention with multiple sequences in a batch."""
534 + atol = 1.5e-2
535 + rtol = 1e-2
536 + _assert_aiter_supported()
537 + import aiter
538 +
539 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
540 +
541 + set_random_seed(42)
542 +
543 + num_q_heads, num_kv_heads = num_heads
544 + seq_lens = [(4, 128), (2, 256), (8, 64)]
545 + query_lens = [q for q, _ in seq_lens]
546 + kv_lens = [k for _, k in seq_lens]
547 + num_seqs = len(seq_lens)
548 + scale = head_size**-0.5
549 +
550 + total_q = sum(query_lens)
551 + total_kv = sum(kv_lens)
552 +
553 + query = torch.randn(total_q, num_q_heads, head_size, dtype=dtype)
554 + key_cache = torch.randn(
555 + NUM_BLOCKS, BLOCK_SIZE, num_kv_heads, head_size, dtype=dtype
556 + )
557 + value_cache = torch.randn_like(key_cache)
558 +
559 + cu_query_lens = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum(
560 + dim=0, dtype=torch.int32
561 + )
562 + cu_seq_lens = torch.tensor([0] + kv_lens, dtype=torch.int32).cumsum(
563 + dim=0, dtype=torch.int32
564 + )
565 +
566 + max_kv_len = max(kv_lens)
567 + max_num_blocks = (max_kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE
568 + block_tables = torch.randint(
569 + 0, NUM_BLOCKS, (num_seqs, max_num_blocks), dtype=torch.int32
570 + )
571 +
572 + token_to_batch = torch.empty(total_kv, dtype=torch.int32)
573 + seq_starts = torch.zeros(num_seqs, dtype=torch.int32)
574 + tok_idx = 0
575 + for b, kl in enumerate(kv_lens):
576 + token_to_batch[tok_idx : tok_idx + kl] = b
577 + tok_idx += kl
578 +
579 + gathered_key = torch.empty(total_kv, num_kv_heads, head_size, dtype=dtype)
580 + gathered_value = torch.empty_like(gathered_key)
581 +
582 + cp_mha_gather_cache(
583 + key_cache=key_cache,
584 + value_cache=value_cache,
585 + key=gathered_key,
586 + value=gathered_value,
587 + block_tables=block_tables,
588 + k_scales=torch.ones(1, dtype=torch.float32),
589 + v_scales=torch.ones(1, dtype=torch.float32),
590 + cu_seqlens_kv=cu_seq_lens,
591 + token_to_batch=token_to_batch,
592 + seq_starts=seq_starts,
593 + dequant=False,
594 + kv_cache_layout="NHD",
595 + total_tokens=total_kv,
596 + )
597 +
598 + output = torch.empty_like(query)
599 + aiter.flash_attn_varlen_func(
600 + q=query,
601 + k=gathered_key,
602 + v=gathered_value,
603 + cu_seqlens_q=cu_query_lens,
604 + cu_seqlens_k=cu_seq_lens,
605 + max_seqlen_q=max(query_lens),
606 + max_seqlen_k=max_kv_len,
607 + min_seqlen_q=1,
608 + dropout_p=0.0,
609 + softmax_scale=scale,
610 + causal=True,
611 + window_size=(-1, -1),
612 + alibi_slopes=None,
613 + return_lse=False,
614 + out=output,
615 + )
616 +
617 + ref = ref_paged_attn(
618 + query=query,
619 + key_cache=key_cache,
620 + value_cache=value_cache,
621 + query_lens=query_lens,
622 + kv_lens=kv_lens,
623 + block_tables=block_tables,
624 + scale=scale,
625 + )
626 +
627 + _print_close_stats(
628 + "multi_batch "
629 + f"dtype={dtype} head_size={head_size} "
630 + f"num_heads={num_heads} seq_lens={seq_lens}",
631 + output,
632 + ref,
633 + atol=atol,
634 + rtol=rtol,
635 + )
636 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
637 +
638 +
639 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
640 +@pytest.mark.parametrize("num_blocks", DIRECT_NUM_BLOCKS)
641 +def test_aiter_fa_large_block_table_matches_reference(num_blocks):
642 + """The direct paged-KV path should stay stable for both normal and very
643 + large block tables."""
644 + atol = 2e-2
645 + rtol = 2e-2
646 + _assert_aiter_supported()
647 +
648 + output, ref = _run_direct_flash_attn_case(
649 + seq_lens=DIRECT_SEQ_LENS,
650 + num_blocks=num_blocks,
651 + )
652 + _print_close_stats(
653 + f"direct_varlen_paged_kv num_blocks={num_blocks}",
654 + output,
655 + ref,
656 + atol=atol,
657 + rtol=rtol,
658 + )
659 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
660 +
661 +
662 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
663 +def test_aiter_fa_sliding_window_matches_reference():
664 + """The direct kernel should respect the same sliding-window causal mask as
665 + the naive reference implementation."""
666 + atol = 2e-2
667 + rtol = 2e-2
668 + _assert_aiter_supported()
669 +
670 + output, ref = _run_direct_flash_attn_case(
671 + seq_lens=[(8, 523), (24, 37), (3, 2011)],
672 + num_blocks=2048,
673 + sliding_window=256,
674 + )
675 + _print_close_stats(
676 + "sliding_window num_blocks=2048 window=256",
677 + output,
678 + ref,
679 + atol=atol,
680 + rtol=rtol,
681 + )
682 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
683 +
684 +
685 +# Decode path test --------------------------------------------------------
686 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
687 +@pytest.mark.parametrize(
688 + "dtype",
689 + [
690 + torch.bfloat16,
691 + pytest.param(
692 + torch.float16,
693 + marks=pytest.mark.xfail(
694 + reason=(
695 + "aiter bug #2229: flash_attn_varlen_func currently "
696 + "miscomputes FP16 single-token decode on MI3xx "
697 + "(validated on gfx950; max abs diff 1.95 vs atol 1.5e-2). "
698 + "Remove xfail when the AITER decode kernel is fixed. "
699 + "https://github.com/ROCm/aiter/issues/2229"
700 + ),
701 + ),
702 + ),
703 + ],
704 +)
705 +def test_aiter_mha_decode_single_token(dtype):
706 + """Test AITER MHA for decode (single query token per sequence).
707 +
708 + BF16 is the working reference configuration here. FP16 remains xfail until
709 + the upstream AITER single-token decode bug is fixed.
710 + """
711 + atol = 1.5e-2
712 + rtol = 1e-2
713 + _assert_aiter_supported()
714 + import aiter
715 +
716 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
717 +
718 + set_random_seed(0)
719 +
720 + num_q_heads, num_kv_heads = 8, 8
721 + head_size = 128
722 + kv_len = 512
723 + scale = head_size**-0.5
724 +
725 + query = torch.randn(1, num_q_heads, head_size, dtype=dtype)
726 + key_cache = torch.randn(
727 + NUM_BLOCKS, BLOCK_SIZE, num_kv_heads, head_size, dtype=dtype
728 + )
729 + value_cache = torch.randn_like(key_cache)
730 +
731 + cu_query_lens = torch.tensor([0, 1], dtype=torch.int32)
732 + cu_seq_lens = torch.tensor([0, kv_len], dtype=torch.int32)
733 +
734 + max_num_blocks = (kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE
735 + block_tables = torch.randint(0, NUM_BLOCKS, (1, max_num_blocks), dtype=torch.int32)
736 +
737 + token_to_batch = torch.zeros(kv_len, dtype=torch.int32)
738 + seq_starts = torch.zeros(1, dtype=torch.int32)
739 + gathered_key = torch.empty(kv_len, num_kv_heads, head_size, dtype=dtype)
740 + gathered_value = torch.empty_like(gathered_key)
741 +
742 + cp_mha_gather_cache(
743 + key_cache=key_cache,
744 + value_cache=value_cache,
745 + key=gathered_key,
746 + value=gathered_value,
747 + block_tables=block_tables,
748 + k_scales=torch.ones(1, dtype=torch.float32),
749 + v_scales=torch.ones(1, dtype=torch.float32),
750 + cu_seqlens_kv=cu_seq_lens,
751 + token_to_batch=token_to_batch,
752 + seq_starts=seq_starts,
753 + dequant=False,
754 + kv_cache_layout="NHD",
755 + total_tokens=kv_len,
756 + )
757 +
758 + output = torch.empty_like(query)
759 + aiter.flash_attn_varlen_func(
760 + q=query,
761 + k=gathered_key,
762 + v=gathered_value,
763 + cu_seqlens_q=cu_query_lens,
764 + cu_seqlens_k=cu_seq_lens,
765 + max_seqlen_q=1,
766 + max_seqlen_k=kv_len,
767 + min_seqlen_q=1,
768 + dropout_p=0.0,
769 + softmax_scale=scale,
770 + causal=True,
771 + window_size=(-1, -1),
772 + alibi_slopes=None,
773 + return_lse=False,
774 + out=output,
775 + )
776 +
777 + ref = ref_paged_attn(
778 + query=query,
779 + key_cache=key_cache,
780 + value_cache=value_cache,
781 + query_lens=[1],
782 + kv_lens=[kv_len],
783 + block_tables=block_tables,
784 + scale=scale,
785 + )
786 +
787 + _print_close_stats(
788 + f"decode_single_token dtype={dtype} kv_len={kv_len}",
789 + output,
790 + ref,
791 + atol=atol,
792 + rtol=rtol,
793 + )
794 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)
795 +
796 +
797 +# FP8 KV cache test -------------------------------------------------------
798 +@pytest.mark.skipif(not on_mi3xx(), reason="MI300/MI350 ROCm only")
799 +@pytest.mark.parametrize("dtype", DTYPES)
800 +def test_aiter_mha_varlen_fp8_kv(dtype):
801 + """AITER flash attention with FP8 KV cache matches reference on BF16-cast KV.
802 +
803 + cp_mha_gather_cache is called with dequant=True to dequantize FP8 to dtype
804 + before passing to flash_attn_varlen_func. We compare to ref_paged_attn on
805 + the dtype-cast KV cache with FP8 quantization tolerance.
806 +
807 + Exercises: VLLM_ROCM_USE_AITER, VLLM_ROCM_USE_AITER_MHA, FP8 KV path.
808 + """
809 + atol = 0.15
810 + rtol = 0.05
811 + _assert_aiter_supported()
812 + if not current_platform.supports_fp8():
813 + pytest.skip("FP8 not supported on this hardware")
814 +
815 + import aiter
816 +
817 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
818 +
819 + FP8_DTYPE = current_platform.fp8_dtype()
820 +
821 + set_random_seed(10)
822 +
823 + num_q_heads, num_kv_heads = 8, 8
824 + head_size = 128
825 + query_len, kv_len = 4, 128
826 + scale = head_size**-0.5
827 +
828 + query = torch.randn(query_len, num_q_heads, head_size, dtype=dtype)
829 + # FP8 KV cache; clamp to stay in FP8 representable range
830 + key_cache_fp8 = torch.clamp(
831 + torch.randn(NUM_BLOCKS, BLOCK_SIZE, num_kv_heads, head_size), -1.0, 1.0
832 + ).to(FP8_DTYPE)
833 + value_cache_fp8 = torch.clamp(
834 + torch.randn(NUM_BLOCKS, BLOCK_SIZE, num_kv_heads, head_size), -1.0, 1.0
835 + ).to(FP8_DTYPE)
836 +
837 + cu_query_lens = torch.tensor([0, query_len], dtype=torch.int32)
838 + cu_seq_lens = torch.tensor([0, kv_len], dtype=torch.int32)
839 + max_num_blocks = (kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE
840 + block_tables = torch.randint(0, NUM_BLOCKS, (1, max_num_blocks), dtype=torch.int32)
841 + token_to_batch = torch.zeros(kv_len, dtype=torch.int32)
842 + seq_starts = torch.zeros(1, dtype=torch.int32)
843 +
844 + # Gather and dequantize FP8 KV to dtype
845 + gathered_key = torch.empty(kv_len, num_kv_heads, head_size, dtype=dtype)
846 + gathered_value = torch.empty_like(gathered_key)
847 + k_scales = torch.ones(1, dtype=torch.float32)
848 + v_scales = torch.ones(1, dtype=torch.float32)
849 +
850 + cp_mha_gather_cache(
851 + key_cache=key_cache_fp8,
852 + value_cache=value_cache_fp8,
853 + key=gathered_key,
854 + value=gathered_value,
855 + block_tables=block_tables,
856 + k_scales=k_scales,
857 + v_scales=v_scales,
858 + cu_seqlens_kv=cu_seq_lens,
859 + token_to_batch=token_to_batch,
860 + seq_starts=seq_starts,
861 + dequant=True,
862 + kv_cache_layout="NHD",
863 + total_tokens=kv_len,
864 + )
865 +
866 + output = torch.empty_like(query)
867 + aiter.flash_attn_varlen_func(
868 + q=query,
869 + k=gathered_key,
870 + v=gathered_value,
871 + cu_seqlens_q=cu_query_lens,
872 + cu_seqlens_k=cu_seq_lens,
873 + max_seqlen_q=query_len,
874 + max_seqlen_k=kv_len,
875 + min_seqlen_q=1,
876 + dropout_p=0.0,
877 + softmax_scale=scale,
878 + causal=True,
879 + window_size=(-1, -1),
880 + alibi_slopes=None,
881 + return_lse=False,
882 + out=output,
883 + )
884 +
885 + # Reference: ref_paged_attn on dtype-cast KV (simulates perfect dequant)
886 + key_cache_ref = key_cache_fp8.to(dtype)
887 + value_cache_ref = value_cache_fp8.to(dtype)
888 + ref = ref_paged_attn(
889 + query=query,
890 + key_cache=key_cache_ref,
891 + value_cache=value_cache_ref,
892 + query_lens=[query_len],
893 + kv_lens=[kv_len],
894 + block_tables=block_tables,
895 + scale=scale,
896 + )
897 +
898 + # FP8 quantization + dequantization introduces noise
899 + _print_close_stats(
900 + f"varlen_fp8_kv dtype={dtype} query_len={query_len} kv_len={kv_len}",
901 + output,
902 + ref,
903 + atol=atol,
904 + rtol=rtol,
905 + )
906 + torch.testing.assert_close(output, ref, atol=atol, rtol=rtol)