| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """ROCm correctness tests for the strided DSA candidate mask. |
| 4 | |
| 5 | ``_apply_candidate_mask_strided`` is a decode-path variant of the shared |
| 6 | ``apply_candidate_mask``. It differs in two ways that need pinning down: |
| 7 | |
| 8 | * it launches a fixed number of programs that stride the column axis, rather |
| 9 | than one program per 1024-column tile, so at ``max_model_len`` widths the |
| 10 | grid no longer scales with a workspace that is mostly padding; |
| 11 | * it stops at each row's ``end`` instead of sanitizing the whole width. |
| 12 | |
| 13 | The second is only sound because every consumer bounds its scan by the same |
| 14 | ends -- ``top_k_per_row_decode`` takes ``rowEnd`` from ``seq_lens``. So the |
| 15 | contract is agreement with the shared kernel over ``[0, end)`` and nothing |
| 16 | beyond it, which is what these tests assert rather than comparing whole rows. |
| 17 | """ |
| 18 | |
| 19 | import pytest |
| 20 | import torch |
| 21 | |
| 22 | from vllm.platforms import current_platform |
| 23 | |
| 24 | pytestmark = pytest.mark.skipif( |
| 25 | not current_platform.is_rocm(), reason="ROCm-specific tests" |
| 26 | ) |
| 27 | |
| 28 | BLOCK_SIZE = 8 |
| 29 | TOPK_BLOCKS = 64 |
| 30 | # Above 128 tiles the grid saturates and programs start striding; below it the |
| 31 | # min() clamp applies instead. Both regimes need covering, so the widths here |
| 32 | # deliberately straddle 128 * 1024. |
| 33 | NARROW = 8192 |
| 34 | WIDE = 262144 |
| 35 | |
| 36 | |
| 37 | def _inputs(rows, width, ends, starts, *, block_size, col_stride, dtype): |
| 38 | """Logits, bounds and candidate blocks shared by both kernels.""" |
| 39 | torch.manual_seed(0) |
| 40 | # A column stride > 1 keeps the kernels honest about stride_col; the decode |
| 41 | # caller slices a workspace, so contiguous rows are not guaranteed. |
| 42 | logits = torch.randn(rows, width * col_stride, device="cuda")[:, ::col_stride] |
| 43 | logits[0, : min(16, width)] = 0.0 |
| 44 | if rows > 2: |
| 45 | logits[2, 5] = float("nan") |
| 46 | row_ke = torch.tensor(ends, device="cuda", dtype=dtype) |
| 47 | row_ks = torch.tensor(starts, device="cuda", dtype=dtype) if starts else None |
| 48 | nblocks = (width + block_size - 1) // block_size |
| 49 | candidates = torch.randint( |
| 50 | 0, nblocks, (rows, TOPK_BLOCKS), device="cuda", dtype=torch.int32 |
| 51 | ) |
| 52 | # -1 is the "no block" sentinel the selector emits for short rows. |
| 53 | candidates[:, ::7] = -1 |
| 54 | return logits, row_ks, row_ke, candidates |
| 55 | |
| 56 | |
| 57 | def _run_both(logits, row_ks, row_ke, candidates, block_size, row_repeat): |
| 58 | from vllm.model_executor.kernels.attention.dsa.candidate_blocks import ( |
| 59 | apply_candidate_mask, |
| 60 | ) |
| 61 | from vllm.v1.attention.ops.rocm_aiter_mla_sparse import ( |
| 62 | _apply_candidate_mask_strided, |
| 63 | ) |
| 64 | |
| 65 | ref = logits.clone() |
| 66 | got = logits.clone() |
| 67 | apply_candidate_mask(ref, row_ks, row_ke, candidates, block_size, row_repeat) |
| 68 | _apply_candidate_mask_strided( |
| 69 | got, row_ks, row_ke, candidates, block_size, row_repeat |
| 70 | ) |
| 71 | return ref, got |
| 72 | |
| 73 | |
| 74 | def _assert_agrees_within_ends(ref, got, row_ke, row_repeat): |
| 75 | """Compare only the region a consumer can read.""" |
| 76 | for row in range(ref.shape[0]): |
| 77 | end = int(row_ke[row // row_repeat]) |
| 78 | if end <= 0: |
| 79 | continue |
| 80 | torch.testing.assert_close( |
| 81 | got[row, :end], |
| 82 | ref[row, :end], |
| 83 | rtol=0, |
| 84 | atol=0, |
| 85 | equal_nan=True, |
| 86 | msg=lambda m, r=row, e=end: f"row {r} differs within [0, {e}): {m}", |
| 87 | ) |
| 88 | |
| 89 | |
| 90 | @pytest.mark.parametrize("width", [NARROW, WIDE]) |
| 91 | @pytest.mark.parametrize("col_stride", [1, 2]) |
| 92 | def test_matches_shared_kernel_ragged_ends(width, col_stride): |
| 93 | """Ends on and around tile boundaries, plus the empty and full rows.""" |
| 94 | ends = [0, 1, 1023, 1024, 1025, width - 1, width, width // 2] |
| 95 | logits, row_ks, row_ke, candidates = _inputs( |
| 96 | len(ends), |
| 97 | width, |
| 98 | ends, |
| 99 | None, |
| 100 | block_size=BLOCK_SIZE, |
| 101 | col_stride=col_stride, |
| 102 | dtype=torch.int32, |
| 103 | ) |
| 104 | ref, got = _run_both(logits, row_ks, row_ke, candidates, BLOCK_SIZE, 1) |
| 105 | _assert_agrees_within_ends(ref, got, row_ke, 1) |
| 106 | |
| 107 | |
| 108 | @pytest.mark.parametrize("block_size", [8, 16]) |
| 109 | def test_matches_shared_kernel_with_starts(block_size): |
| 110 | """Non-zero starts: the decode caller passes zeros, prefill does not.""" |
| 111 | ends = [0, 64, 4096, 8192] |
| 112 | starts = [0, 7, 1024, 3] |
| 113 | logits, row_ks, row_ke, candidates = _inputs( |
| 114 | len(ends), |
| 115 | NARROW, |
| 116 | ends, |
| 117 | starts, |
| 118 | block_size=block_size, |
| 119 | col_stride=1, |
| 120 | dtype=torch.int64, |
| 121 | ) |
| 122 | ref, got = _run_both(logits, row_ks, row_ke, candidates, block_size, 1) |
| 123 | _assert_agrees_within_ends(ref, got, row_ke, 1) |
| 124 | |
| 125 | |
| 126 | def test_matches_shared_kernel_row_repeat(): |
| 127 | """Speculative decode: next_n logit rows share one bound.""" |
| 128 | row_repeat = 3 |
| 129 | ends = [0, 1025, WIDE] |
| 130 | logits, row_ks, row_ke, candidates = _inputs( |
| 131 | len(ends) * row_repeat, |
| 132 | WIDE, |
| 133 | ends, |
| 134 | None, |
| 135 | block_size=BLOCK_SIZE, |
| 136 | col_stride=1, |
| 137 | dtype=torch.int32, |
| 138 | ) |
| 139 | ref, got = _run_both(logits, row_ks, row_ke, candidates, BLOCK_SIZE, row_repeat) |
| 140 | _assert_agrees_within_ends(ref, got, row_ke, row_repeat) |
| 141 | |
| 142 | |
| 143 | def test_leaves_columns_past_end_untouched(): |
| 144 | """The deliberate difference from the shared kernel, pinned explicitly. |
| 145 | |
| 146 | Skipping the tail is where the speedup comes from, so a change that |
| 147 | quietly restores full-width sanitizing should fail here rather than just |
| 148 | get slower. Work stops at the tile boundary containing ``end``, not at |
| 149 | ``end`` itself, so that is the bound asserted. |
| 150 | """ |
| 151 | from vllm.v1.attention.ops.rocm_aiter_mla_sparse import ( |
| 152 | _MASK_TILE, |
| 153 | _apply_candidate_mask_strided, |
| 154 | ) |
| 155 | |
| 156 | ends = [0, 1, 1025, WIDE // 2] |
| 157 | logits, row_ks, row_ke, candidates = _inputs( |
| 158 | len(ends), |
| 159 | WIDE, |
| 160 | ends, |
| 161 | None, |
| 162 | block_size=BLOCK_SIZE, |
| 163 | col_stride=1, |
| 164 | dtype=torch.int32, |
| 165 | ) |
| 166 | sentinel = 1.5 |
| 167 | logits.fill_(sentinel) |
| 168 | _apply_candidate_mask_strided(logits, row_ks, row_ke, candidates, BLOCK_SIZE, 1) |
| 169 | for row, end in enumerate(ends): |
| 170 | touched = min(-(-end // _MASK_TILE) * _MASK_TILE, WIDE) |
| 171 | tail = logits[row, touched:] |
| 172 | assert torch.equal(tail, torch.full_like(tail, sentinel)), ( |
| 173 | f"row {row} (end {end}) was written past column {touched}" |
| 174 | ) |
| 175 | # And the bound is tight: a row that ends early must not have cost a |
| 176 | # full-width pass, which is the whole point of the kernel. |
| 177 | assert touched <= end + _MASK_TILE |
| 178 | |
| 179 | |
| 180 | def test_cudagraph_replay_tracks_changing_ends(): |
| 181 | """The grid is static so a FULL capture stays valid when ends change. |
| 182 | |
| 183 | Capturing bakes the grid, so a data-dependent program count would freeze |
| 184 | whatever lengths were resident at capture. Replaying against longer and |
| 185 | shorter contexts than the captured ones is the property that matters. |
| 186 | """ |
| 187 | from vllm.model_executor.kernels.attention.dsa.candidate_blocks import ( |
| 188 | apply_candidate_mask, |
| 189 | ) |
| 190 | from vllm.v1.attention.ops.rocm_aiter_mla_sparse import ( |
| 191 | _apply_candidate_mask_strided, |
| 192 | ) |
| 193 | |
| 194 | rows = 4 |
| 195 | captured_ends = [1024, 4096, 8192, 2048] |
| 196 | logits, row_ks, row_ke, candidates = _inputs( |
| 197 | rows, |
| 198 | WIDE, |
| 199 | captured_ends, |
| 200 | None, |
| 201 | block_size=BLOCK_SIZE, |
| 202 | col_stride=1, |
| 203 | dtype=torch.int32, |
| 204 | ) |
| 205 | base = logits.clone() |
| 206 | |
| 207 | # Warm up outside the capture: Triton compiles on first call, and autotune |
| 208 | # launches would otherwise happen inside the graph. |
| 209 | _apply_candidate_mask_strided( |
| 210 | logits.clone(), row_ks, row_ke, candidates, BLOCK_SIZE, 1 |
| 211 | ) |
| 212 | torch.accelerator.synchronize() |
| 213 | |
| 214 | graph = torch.cuda.CUDAGraph() |
| 215 | with torch.cuda.graph(graph): |
| 216 | _apply_candidate_mask_strided(logits, row_ks, row_ke, candidates, BLOCK_SIZE, 1) |
| 217 | |
| 218 | for replay_ends in ([1, 131072, WIDE, 0], [WIDE, 1025, 3, 65536]): |
| 219 | row_ke.copy_(torch.tensor(replay_ends, device="cuda", dtype=row_ke.dtype)) |
| 220 | logits.copy_(base) |
| 221 | graph.replay() |
| 222 | torch.accelerator.synchronize() |
| 223 | |
| 224 | ref = base.clone() |
| 225 | apply_candidate_mask(ref, row_ks, row_ke, candidates, BLOCK_SIZE, 1) |
| 226 | _assert_agrees_within_ends(ref, logits, row_ke, 1) |