| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from types import SimpleNamespace |
| 5 | |
| 6 | import pytest |
| 7 | import torch |
| 8 | |
| 9 | from vllm.platforms import current_platform |
| 10 | from vllm.triton_utils import tl, triton |
| 11 | from vllm.utils.torch_utils import set_random_seed |
| 12 | from vllm.v1.attention.backends.mla import rocm_aiter_mla_sparse as sparse_mod |
| 13 | from vllm.v1.attention.backends.mla.rocm_aiter_mla_sparse import ( |
| 14 | _use_rocm_sparse_triton, |
| 15 | fit_kpool_indices_to_aiter, |
| 16 | ) |
| 17 | from vllm.v1.attention.ops.rocm_aiter_mla_sparse import ( |
| 18 | _sparse_kv_row_offset, |
| 19 | _validate_dsv4_sparse_dims, |
| 20 | _validate_sparse_dims, |
| 21 | ) |
| 22 | |
| 23 | |
| 24 | @triton.jit |
| 25 | def _store_sparse_kv_row_offset_kernel(slot_ptr, output_ptr, stride: tl.constexpr): |
| 26 | slot = tl.load(slot_ptr) |
| 27 | tl.store(output_ptr, _sparse_kv_row_offset(slot, stride)) |
| 28 | |
| 29 | |
| 30 | def _fit_kpool_indices_reference( |
| 31 | token_indices: torch.Tensor, topk_tokens: int |
| 32 | ) -> torch.Tensor: |
| 33 | history = token_indices[:, :topk_tokens] |
| 34 | tail = token_indices[:, topk_tokens:] |
| 35 | valid_history = (history >= 0).sum(dim=1) |
| 36 | valid_tail = (tail >= 0).sum(dim=1) |
| 37 | keep_history = torch.minimum(valid_history, topk_tokens - valid_tail) |
| 38 | |
| 39 | columns = torch.arange(topk_tokens, device=token_indices.device).unsqueeze(0) |
| 40 | tail_offsets = columns - keep_history.unsqueeze(1) |
| 41 | tail_values = torch.gather( |
| 42 | tail, 1, tail_offsets.clamp(min=0, max=tail.shape[1] - 1) |
| 43 | ) |
| 44 | output = torch.where(columns < keep_history.unsqueeze(1), history, tail_values) |
| 45 | valid_output = columns < (keep_history + valid_tail).unsqueeze(1) |
| 46 | return torch.where(valid_output, output, -1) |
| 47 | |
| 48 | |
| 49 | @pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm required") |
| 50 | def test_fit_kpool_indices_preserves_tail_and_best_history(): |
| 51 | token_indices = torch.tensor( |
| 52 | [ |
| 53 | [10, 9, 8, 7, 6, 5, 100, 101], |
| 54 | [10, 9, 8, -1, -1, -1, 100, -1], |
| 55 | [-1, -1, -1, -1, -1, -1, -1, -1], |
| 56 | ], |
| 57 | dtype=torch.int32, |
| 58 | device="cuda", |
| 59 | ) |
| 60 | |
| 61 | fitted = fit_kpool_indices_to_aiter(token_indices, topk_tokens=6) |
| 62 | |
| 63 | assert fitted.tolist() == [ |
| 64 | [10, 9, 8, 7, 100, 101], |
| 65 | [10, 9, 8, 100, -1, -1], |
| 66 | [-1, -1, -1, -1, -1, -1], |
| 67 | ] |
| 68 | |
| 69 | |
| 70 | @pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm required") |
| 71 | @pytest.mark.parametrize( |
| 72 | ("num_tokens", "topk_tokens", "tail_width"), |
| 73 | [(1, 4, 1), (7, 6, 2), (13, 128, 128), (3, 2048, 128)], |
| 74 | ) |
| 75 | def test_fit_kpool_indices_matches_eager_reference(num_tokens, topk_tokens, tail_width): |
| 76 | """The Triton fit must reproduce the eager packing rule exactly.""" |
| 77 | set_random_seed(topk_tokens + num_tokens) |
| 78 | width = topk_tokens + tail_width |
| 79 | token_indices = torch.randint(0, 4096, (num_tokens, width), dtype=torch.int32) |
| 80 | valid = torch.rand((num_tokens, width)) < 0.5 |
| 81 | valid[0] = False |
| 82 | valid[-1] = True |
| 83 | token_indices = torch.where(valid, token_indices, -1).cuda() |
| 84 | |
| 85 | fitted = fit_kpool_indices_to_aiter(token_indices, topk_tokens=topk_tokens) |
| 86 | |
| 87 | torch.testing.assert_close( |
| 88 | fitted, _fit_kpool_indices_reference(token_indices, topk_tokens) |
| 89 | ) |
| 90 | |
| 91 | |
| 92 | def test_fit_kpool_indices_exact_width_is_noop(): |
| 93 | token_indices = torch.tensor([[3, 2, 1, -1]], dtype=torch.int32) |
| 94 | |
| 95 | fitted = fit_kpool_indices_to_aiter(token_indices, topk_tokens=4) |
| 96 | |
| 97 | assert fitted.data_ptr() == token_indices.data_ptr() |
| 98 | |
| 99 | |
| 100 | def test_fit_kpool_indices_rejects_narrow_input(): |
| 101 | with pytest.raises(ValueError, match="at least topk_tokens"): |
| 102 | fit_kpool_indices_to_aiter( |
| 103 | torch.zeros((1, 3), dtype=torch.int32), topk_tokens=4 |
| 104 | ) |
| 105 | |
| 106 | |
| 107 | @pytest.mark.parametrize( |
| 108 | ( |
| 109 | "kv_cache_dtype", |
| 110 | "head_size", |
| 111 | "num_prefills", |
| 112 | "num_decodes", |
| 113 | "num_decode_tokens", |
| 114 | "max_query_len", |
| 115 | "expected", |
| 116 | ), |
| 117 | [ |
| 118 | ("auto", 512, 1, 0, 0, 32, True), |
| 119 | ("auto", 512, 1, 2, 2, 32, True), |
| 120 | ("auto", 512, 0, 2, 2, 1, True), |
| 121 | ("fp8", 512, 1, 0, 0, 32, False), |
| 122 | ("auto", 576, 1, 0, 0, 32, False), |
| 123 | ("auto", 512, 0, 2, 4, 2, True), |
| 124 | ("auto", 512, 0, 2, 12, 6, True), |
| 125 | ("auto", 512, 0, 0, 0, 0, False), |
| 126 | ], |
| 127 | ) |
| 128 | def test_rocm_sparse_triton_route( |
| 129 | kv_cache_dtype, |
| 130 | head_size, |
| 131 | num_prefills, |
| 132 | num_decodes, |
| 133 | num_decode_tokens, |
| 134 | max_query_len, |
| 135 | expected, |
| 136 | ): |
| 137 | """Validate Triton routing for prefill, decode, and MTP verification.""" |
| 138 | assert ( |
| 139 | _use_rocm_sparse_triton( |
| 140 | kv_cache_dtype=kv_cache_dtype, |
| 141 | head_size=head_size, |
| 142 | kv_lora_rank=512, |
| 143 | num_prefills=num_prefills, |
| 144 | num_decodes=num_decodes, |
| 145 | num_decode_tokens=num_decode_tokens, |
| 146 | max_query_len=max_query_len, |
| 147 | ) |
| 148 | is expected |
| 149 | ) |
| 150 | |
| 151 | |
| 152 | @pytest.mark.parametrize("num_heads", [8, 12]) |
| 153 | def test_rocm_sparse_triton_route_preserves_padded_sinks(monkeypatch, num_heads): |
| 154 | captured = {} |
| 155 | |
| 156 | def fake_rocm_sparse_attn_prefill(**kwargs): |
| 157 | output = kwargs["output"] |
| 158 | captured["attn_sink"] = kwargs["attn_sink"] |
| 159 | output.copy_( |
| 160 | captured["attn_sink"].to(output.dtype).view(1, -1, 1).expand_as(output) |
| 161 | ) |
| 162 | |
| 163 | monkeypatch.setattr( |
| 164 | sparse_mod, "rocm_sparse_attn_prefill", fake_rocm_sparse_attn_prefill |
| 165 | ) |
| 166 | |
| 167 | impl = object.__new__(sparse_mod.ROCMAiterMLASparseImpl) |
| 168 | impl.num_heads = num_heads |
| 169 | impl.kv_lora_rank = 512 |
| 170 | impl.kv_cache_dtype = "auto" |
| 171 | impl.scale = 512**-0.5 |
| 172 | impl.sinks = torch.arange(num_heads, dtype=torch.float32) |
| 173 | |
| 174 | q = torch.zeros(2, 16, 512, dtype=torch.bfloat16) |
| 175 | kv = torch.zeros(4, 1, 512, dtype=torch.bfloat16) |
| 176 | metadata = SimpleNamespace( |
| 177 | attn_out_dtype=torch.bfloat16, |
| 178 | num_prefills=1, |
| 179 | num_decodes=0, |
| 180 | num_decode_tokens=0, |
| 181 | max_query_len=2, |
| 182 | paged_kv_indices=torch.empty(0, dtype=torch.int32), |
| 183 | paged_kv_indptr=torch.zeros(3, dtype=torch.int32), |
| 184 | ) |
| 185 | |
| 186 | output, lse = impl._forward_mla(SimpleNamespace(), q, kv, metadata) |
| 187 | |
| 188 | if num_heads == 8: |
| 189 | expected_sinks = impl.sinks.repeat_interleave(2) |
| 190 | else: |
| 191 | expected_sinks = torch.cat((impl.sinks, impl.sinks[:4])) |
| 192 | torch.testing.assert_close(captured["attn_sink"], expected_sinks) |
| 193 | assert output.shape == (2, num_heads, 512) |
| 194 | torch.testing.assert_close( |
| 195 | output[:, :, 0].float(), |
| 196 | impl.sinks.expand(2, -1), |
| 197 | ) |
| 198 | assert lse is None |
| 199 | |
| 200 | |
| 201 | def test_rocm_sparse_attention_accepts_glm_nope_dimensions(): |
| 202 | _validate_sparse_dims(512, 512, 0, "test") |
| 203 | |
| 204 | |
| 205 | def test_rocm_sparse_attention_rejects_inconsistent_dimensions(): |
| 206 | with pytest.raises(AssertionError, match="expected head_dim"): |
| 207 | _validate_sparse_dims(511, 512, 0, "test") |
| 208 | |
| 209 | |
| 210 | def test_dsv4_sparse_attention_keeps_layout_constraint(): |
| 211 | _validate_dsv4_sparse_dims(512, 448, 64, "test") |
| 212 | with pytest.raises(AssertionError, match="expects 448 NoPE dims"): |
| 213 | _validate_dsv4_sparse_dims(512, 512, 0, "test") |
| 214 | |
| 215 | |
| 216 | @pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm required") |
| 217 | def test_sparse_prefill_kv_row_offset_does_not_overflow_int32(): |
| 218 | # GLM's 640-token pages cross the signed-int32 address boundary at block |
| 219 | # 6554 for a 512-element KV row. The production kernel must promote the |
| 220 | # slot before multiplying by the row stride. |
| 221 | slot = torch.tensor([6554 * 640], dtype=torch.int32, device="cuda") |
| 222 | output = torch.empty(1, dtype=torch.int64, device="cuda") |
| 223 | |
| 224 | _store_sparse_kv_row_offset_kernel[(1,)](slot, output, stride=512) |
| 225 | |
| 226 | assert output.item() == 6554 * 640 * 512 |