| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """ROCm kernel correctness tests for AITER MLA decode. |
| 4 | |
| 5 | Compares ``rocm_aiter_ops.mla_decode_fwd`` against a pure PyTorch reference |
| 6 | under the absorbed MLA formulation (DeepSeek-V3/V4) with varied batch sizes, |
| 7 | head counts, and sequence lengths. |
| 8 | """ |
| 9 | |
| 10 | import pytest |
| 11 | import torch |
| 12 | |
| 13 | from vllm.platforms import current_platform |
| 14 | from vllm.utils.torch_utils import set_random_seed |
| 15 | |
| 16 | _SKIP_NON_MI3XX = True |
| 17 | if current_platform.is_rocm(): |
| 18 | from vllm.platforms.rocm import on_mi3xx |
| 19 | |
| 20 | _SKIP_NON_MI3XX = not on_mi3xx() |
| 21 | |
| 22 | pytestmark = [ |
| 23 | pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm-specific tests"), |
| 24 | pytest.mark.skipif(_SKIP_NON_MI3XX, reason="MI300/MI350 ROCm only"), |
| 25 | ] |
| 26 | |
| 27 | # DeepSeek-V3/V4 MLA config. |
| 28 | Q_HEAD_DIM = 576 # kv_lora_rank + qk_rope_head_dim |
| 29 | V_HEAD_DIM = 512 # kv_lora_rank |
| 30 | SM_SCALE = Q_HEAD_DIM**-0.5 |
| 31 | |
| 32 | NUM_HEADS = [16, 128] |
| 33 | BATCH_SIZES = [1, 4, 16] |
| 34 | KV_SEQ_LENS = [16, 256] |
| 35 | |
| 36 | ATOL, RTOL = 1.5e-2, 1e-2 |
| 37 | |
| 38 | |
| 39 | def _require_aiter(): |
| 40 | from vllm._aiter_ops import is_aiter_found_and_supported |
| 41 | |
| 42 | if not is_aiter_found_and_supported(): |
| 43 | pytest.skip("aiter is required on supported ROCm hardware for this test") |
| 44 | |
| 45 | |
| 46 | def _ref_mla_decode( |
| 47 | q: torch.Tensor, |
| 48 | kv_buffer: torch.Tensor, |
| 49 | kv_indptr: torch.Tensor, |
| 50 | kv_indices: torch.Tensor, |
| 51 | ) -> torch.Tensor: |
| 52 | """Pure PyTorch reference for absorbed MLA decode. |
| 53 | |
| 54 | K uses full q_head_dim (576) for scoring, V uses first v_head_dim (512) |
| 55 | for output. This mirrors the production kernel's absorbed formulation. |
| 56 | """ |
| 57 | batch_size, num_heads, _ = q.shape |
| 58 | output = torch.zeros( |
| 59 | batch_size, num_heads, V_HEAD_DIM, dtype=q.dtype, device=q.device |
| 60 | ) |
| 61 | |
| 62 | for b in range(batch_size): |
| 63 | start = kv_indptr[b].item() |
| 64 | end = kv_indptr[b + 1].item() |
| 65 | token_indices = kv_indices[start:end] |
| 66 | |
| 67 | k = kv_buffer[token_indices].float() |
| 68 | v = kv_buffer[token_indices, :V_HEAD_DIM].float() |
| 69 | |
| 70 | for h in range(num_heads): |
| 71 | q_h = q[b, h, :].float() |
| 72 | scores = torch.mv(k, q_h) * SM_SCALE |
| 73 | weights = torch.softmax(scores, dim=0) |
| 74 | output[b, h, :] = torch.mv(v.t(), weights).to(q.dtype) |
| 75 | |
| 76 | return output |
| 77 | |
| 78 | |
| 79 | def _make_inputs( |
| 80 | batch_size: int, |
| 81 | nhead: int, |
| 82 | kv_seq_len: int, |
| 83 | *, |
| 84 | contiguous_indices: bool = True, |
| 85 | ): |
| 86 | """Build valid MLA decode inputs on the current CUDA device. |
| 87 | |
| 88 | When contiguous_indices=False, tokens are randomly scattered in a 2x pool |
| 89 | to simulate real paged allocation. |
| 90 | """ |
| 91 | from vllm.v1.attention.backends.mla.rocm_aiter_mla import AiterMLAHelper |
| 92 | |
| 93 | actual_nhead = AiterMLAHelper.get_actual_mla_num_heads(nhead) |
| 94 | total_kv_tokens = batch_size * kv_seq_len |
| 95 | |
| 96 | pool_size = total_kv_tokens if contiguous_indices else total_kv_tokens * 2 |
| 97 | |
| 98 | q = torch.randn( |
| 99 | batch_size, actual_nhead, Q_HEAD_DIM, dtype=torch.bfloat16, device="cuda" |
| 100 | ) |
| 101 | kv_buffer = torch.randn( |
| 102 | pool_size, 1, Q_HEAD_DIM, dtype=torch.bfloat16, device="cuda" |
| 103 | ) |
| 104 | o = torch.zeros( |
| 105 | batch_size, actual_nhead, V_HEAD_DIM, dtype=torch.bfloat16, device="cuda" |
| 106 | ) |
| 107 | |
| 108 | qo_indptr = torch.arange(0, batch_size + 1, dtype=torch.int32, device="cuda") |
| 109 | kv_indptr = ( |
| 110 | torch.arange(0, batch_size + 1, dtype=torch.int32, device="cuda") * kv_seq_len |
| 111 | ) |
| 112 | kv_last_page_lens = torch.ones(batch_size, dtype=torch.int32, device="cuda") |
| 113 | |
| 114 | if contiguous_indices: |
| 115 | kv_indices = torch.arange(0, total_kv_tokens, dtype=torch.int32, device="cuda") |
| 116 | else: |
| 117 | perm = torch.randperm(pool_size, device="cuda")[:total_kv_tokens] |
| 118 | kv_indices = perm.to(torch.int32) |
| 119 | |
| 120 | return { |
| 121 | "q": q, |
| 122 | "kv_buffer": kv_buffer, |
| 123 | "o": o, |
| 124 | "qo_indptr": qo_indptr, |
| 125 | "kv_indptr": kv_indptr, |
| 126 | "kv_indices": kv_indices, |
| 127 | "kv_last_page_lens": kv_last_page_lens, |
| 128 | "actual_nhead": actual_nhead, |
| 129 | } |
| 130 | |
| 131 | |
| 132 | def _run_kernel(inputs: dict) -> torch.Tensor: |
| 133 | """Run decode through the production rocm_aiter_ops path.""" |
| 134 | from vllm._aiter_ops import rocm_aiter_ops |
| 135 | |
| 136 | rocm_aiter_ops.mla_decode_fwd( |
| 137 | inputs["q"], |
| 138 | inputs["kv_buffer"], |
| 139 | inputs["o"], |
| 140 | SM_SCALE, |
| 141 | inputs["qo_indptr"], |
| 142 | 1, |
| 143 | inputs["kv_indptr"], |
| 144 | inputs["kv_indices"], |
| 145 | inputs["kv_last_page_lens"], |
| 146 | ) |
| 147 | return inputs["o"] |
| 148 | |
| 149 | |
| 150 | def _ref_output(inputs: dict) -> torch.Tensor: |
| 151 | """Compute reference output from the same inputs.""" |
| 152 | kv_flat = inputs["kv_buffer"].squeeze(1) |
| 153 | return _ref_mla_decode( |
| 154 | inputs["q"], kv_flat, inputs["kv_indptr"], inputs["kv_indices"] |
| 155 | ) |
| 156 | |
| 157 | |
| 158 | @pytest.mark.parametrize("nhead", NUM_HEADS) |
| 159 | @pytest.mark.parametrize("batch_size", BATCH_SIZES) |
| 160 | @pytest.mark.parametrize("kv_seq_len", KV_SEQ_LENS) |
| 161 | @torch.inference_mode() |
| 162 | def test_mla_decode_accuracy( |
| 163 | nhead: int, |
| 164 | batch_size: int, |
| 165 | kv_seq_len: int, |
| 166 | ) -> None: |
| 167 | """BF16 decode accuracy vs PyTorch reference (contiguous indices).""" |
| 168 | _require_aiter() |
| 169 | set_random_seed(0) |
| 170 | |
| 171 | inputs = _make_inputs(batch_size, nhead, kv_seq_len) |
| 172 | output = _run_kernel(inputs) |
| 173 | output_ref = _ref_output(inputs) |
| 174 | |
| 175 | torch.testing.assert_close(output, output_ref, atol=ATOL, rtol=RTOL) |
| 176 | |
| 177 | |
| 178 | @pytest.mark.parametrize("nhead", NUM_HEADS) |
| 179 | @pytest.mark.parametrize("batch_size", [1, 4, 8]) |
| 180 | @pytest.mark.parametrize("kv_seq_len", [16, 128]) |
| 181 | @torch.inference_mode() |
| 182 | def test_mla_decode_noncontiguous_indices( |
| 183 | nhead: int, |
| 184 | batch_size: int, |
| 185 | kv_seq_len: int, |
| 186 | ) -> None: |
| 187 | """BF16 decode accuracy with shuffled kv_indices (paged allocation).""" |
| 188 | _require_aiter() |
| 189 | set_random_seed(0) |
| 190 | |
| 191 | inputs = _make_inputs(batch_size, nhead, kv_seq_len, contiguous_indices=False) |
| 192 | output = _run_kernel(inputs) |
| 193 | output_ref = _ref_output(inputs) |
| 194 | |
| 195 | torch.testing.assert_close(output, output_ref, atol=ATOL, rtol=RTOL) |
| 196 | |
| 197 | |
| 198 | @torch.inference_mode() |
| 199 | def test_mla_decode_determinism() -> None: |
| 200 | """Repeated decode calls produce bitwise-identical results.""" |
| 201 | _require_aiter() |
| 202 | set_random_seed(0) |
| 203 | |
| 204 | inputs = _make_inputs(batch_size=4, nhead=128, kv_seq_len=16) |
| 205 | out_first = _run_kernel(inputs).clone() |
| 206 | |
| 207 | for _ in range(3): |
| 208 | inputs["o"] = torch.zeros_like(inputs["o"]) |
| 209 | out = _run_kernel(inputs) |
| 210 | torch.testing.assert_close(out, out_first, atol=0, rtol=0) |
| 211 | |
| 212 | |
| 213 | @torch.inference_mode() |
| 214 | def test_mla_decode_smoke() -> None: |
| 215 | """Basic sanity: correct shape, dtype, finite, non-zero.""" |
| 216 | _require_aiter() |
| 217 | set_random_seed(0) |
| 218 | |
| 219 | inputs = _make_inputs(batch_size=4, nhead=128, kv_seq_len=64) |
| 220 | output = _run_kernel(inputs) |
| 221 | |
| 222 | assert output.shape == (4, 128, V_HEAD_DIM) |
| 223 | assert output.dtype == torch.bfloat16 |
| 224 | assert torch.isfinite(output).all() |
| 225 | assert not torch.all(output == 0) |