| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import pytest |
| 5 | import torch |
| 6 | |
| 7 | from vllm.platforms import current_platform |
| 8 | from vllm.utils.math_utils import cdiv |
| 9 | from vllm.v1.attention.ops.triton_decode_attention import decode_attention_fwd |
| 10 | |
| 11 | pytestmark = pytest.mark.skip_global_cleanup |
| 12 | |
| 13 | DEVICE_TYPE = current_platform.device_type |
| 14 | |
| 15 | |
| 16 | @pytest.mark.parametrize("B", [3, 5]) |
| 17 | @pytest.mark.parametrize("L", [1027, 1025]) |
| 18 | @pytest.mark.parametrize("H_Q", [32]) |
| 19 | @pytest.mark.parametrize("H_KV", [32, 8]) |
| 20 | @pytest.mark.parametrize("D_QK", [128, 192, 576]) |
| 21 | @pytest.mark.parametrize("D_V", [128, 512]) |
| 22 | @pytest.mark.parametrize("CACHE_SIZE", [16384]) |
| 23 | @pytest.mark.parametrize("PAGE_SIZE", [1, 16]) |
| 24 | def test_decode_attention(B, L, H_Q, H_KV, D_QK, D_V, CACHE_SIZE, PAGE_SIZE): |
| 25 | assert CACHE_SIZE % PAGE_SIZE == 0 |
| 26 | dtype = torch.bfloat16 |
| 27 | seq_len = L # This represents the number of tokens already in the sequence |
| 28 | sm_scale = 1.0 / (D_QK**0.5) |
| 29 | num_kv_splits = 8 |
| 30 | |
| 31 | num_pages_per_batch = cdiv(seq_len, PAGE_SIZE) |
| 32 | req_to_page = torch.randint( |
| 33 | 0, CACHE_SIZE // PAGE_SIZE, (B, num_pages_per_batch, 1), device=DEVICE_TYPE |
| 34 | ) |
| 35 | req_to_token = req_to_page * PAGE_SIZE |
| 36 | req_to_token = req_to_token.expand(B, num_pages_per_batch, PAGE_SIZE) |
| 37 | req_to_token = req_to_token + torch.arange(PAGE_SIZE, device=DEVICE_TYPE).view( |
| 38 | 1, 1, -1 |
| 39 | ) |
| 40 | req_to_token = req_to_token.view(B, -1) |
| 41 | req_to_token = req_to_token[:, :seq_len].contiguous() |
| 42 | |
| 43 | # q represents the new token being generated, one per batch |
| 44 | q = torch.randn(B, H_Q, D_QK, dtype=dtype, device=DEVICE_TYPE) |
| 45 | |
| 46 | # k_buffer and v_buffer represent all previous tokens |
| 47 | # Page size is 1. |
| 48 | k_buffer = torch.randn(CACHE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE) |
| 49 | v_buffer = torch.randn(CACHE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 50 | |
| 51 | # o will have the same shape as q |
| 52 | o = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 53 | |
| 54 | lse = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE) |
| 55 | |
| 56 | b_seq_len = torch.full((B,), seq_len, device=DEVICE_TYPE) |
| 57 | |
| 58 | attn_logits = torch.empty( |
| 59 | (B, H_Q, num_kv_splits, D_V + 1), |
| 60 | dtype=torch.float32, |
| 61 | device=DEVICE_TYPE, |
| 62 | ) |
| 63 | |
| 64 | # Call the original implementation. |
| 65 | decode_attention_fwd( |
| 66 | q, |
| 67 | k_buffer, |
| 68 | v_buffer, |
| 69 | o, |
| 70 | lse, |
| 71 | req_to_token, |
| 72 | b_seq_len, |
| 73 | attn_logits, |
| 74 | num_kv_splits, |
| 75 | sm_scale, |
| 76 | ) |
| 77 | |
| 78 | # Page size can be larger than 1. |
| 79 | k_buffer = k_buffer.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_QK) |
| 80 | v_buffer = v_buffer.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_V) |
| 81 | |
| 82 | o1 = torch.zeros_like(o) |
| 83 | lse1 = torch.zeros_like(lse) |
| 84 | |
| 85 | decode_attention_fwd( |
| 86 | q, |
| 87 | k_buffer, |
| 88 | v_buffer, |
| 89 | o1, |
| 90 | lse1, |
| 91 | req_to_page, |
| 92 | b_seq_len, |
| 93 | attn_logits, |
| 94 | num_kv_splits, |
| 95 | sm_scale, |
| 96 | PAGE_SIZE, |
| 97 | ) |
| 98 | |
| 99 | assert torch.allclose(o, o1) |
| 100 | |
| 101 | |
| 102 | def _quantize_to_fp8(tensor: torch.Tensor): |
| 103 | """Quantize a BF16 tensor to FP8 e4m3fn with per-tensor scale. |
| 104 | |
| 105 | Returns (fp8_tensor, scale) where: |
| 106 | fp8_tensor ≈ tensor / scale (stored as float8_e4m3fn) |
| 107 | tensor ≈ fp8_tensor.to(float32) * scale (dequantized) |
| 108 | """ |
| 109 | amax = tensor.abs().amax() |
| 110 | # float8_e4m3fn max representable value is 448.0 |
| 111 | scale = (amax / 448.0).clamp(min=1e-12).to(torch.float32) |
| 112 | fp8_tensor = ( |
| 113 | (tensor.to(torch.float32) / scale).clamp(-448.0, 448.0).to(torch.float8_e4m3fn) |
| 114 | ) |
| 115 | return fp8_tensor, scale |
| 116 | |
| 117 | |
| 118 | @pytest.mark.parametrize("B", [3]) |
| 119 | @pytest.mark.parametrize("L", [1025]) |
| 120 | @pytest.mark.parametrize("H_Q", [32]) |
| 121 | @pytest.mark.parametrize("H_KV", [32, 8]) |
| 122 | @pytest.mark.parametrize("D_QK", [128, 576]) |
| 123 | @pytest.mark.parametrize("D_V", [128, 512]) |
| 124 | @pytest.mark.parametrize("CACHE_SIZE", [16384]) |
| 125 | @pytest.mark.parametrize("PAGE_SIZE", [1, 16]) |
| 126 | def test_decode_attention_fp8(B, L, H_Q, H_KV, D_QK, D_V, CACHE_SIZE, PAGE_SIZE): |
| 127 | """Test FP8 KV cache path: quantize K/V to FP8, run kernel with scales, |
| 128 | and compare against BF16 reference output.""" |
| 129 | assert CACHE_SIZE % PAGE_SIZE == 0 |
| 130 | dtype = torch.bfloat16 |
| 131 | seq_len = L |
| 132 | sm_scale = 1.0 / (D_QK**0.5) |
| 133 | num_kv_splits = 8 |
| 134 | |
| 135 | num_pages_per_batch = cdiv(seq_len, PAGE_SIZE) |
| 136 | req_to_page = torch.randint( |
| 137 | 0, CACHE_SIZE // PAGE_SIZE, (B, num_pages_per_batch, 1), device=DEVICE_TYPE |
| 138 | ) |
| 139 | req_to_token = req_to_page * PAGE_SIZE |
| 140 | req_to_token = req_to_token.expand(B, num_pages_per_batch, PAGE_SIZE) |
| 141 | req_to_token = req_to_token + torch.arange(PAGE_SIZE, device=DEVICE_TYPE).view( |
| 142 | 1, 1, -1 |
| 143 | ) |
| 144 | req_to_token = req_to_token.view(B, -1) |
| 145 | req_to_token = req_to_token[:, :seq_len].contiguous() |
| 146 | |
| 147 | q = torch.randn(B, H_Q, D_QK, dtype=dtype, device=DEVICE_TYPE) |
| 148 | |
| 149 | # Create BF16 K/V as reference |
| 150 | k_bf16 = torch.randn(CACHE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE) |
| 151 | v_bf16 = torch.randn(CACHE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 152 | |
| 153 | # --- BF16 reference --- |
| 154 | o_ref = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 155 | lse_ref = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE) |
| 156 | attn_logits = torch.empty( |
| 157 | (B, H_Q, num_kv_splits, D_V + 1), dtype=torch.float32, device=DEVICE_TYPE |
| 158 | ) |
| 159 | |
| 160 | if PAGE_SIZE == 1: |
| 161 | decode_attention_fwd( |
| 162 | q, |
| 163 | k_bf16, |
| 164 | v_bf16, |
| 165 | o_ref, |
| 166 | lse_ref, |
| 167 | req_to_token, |
| 168 | b_seq_len=torch.full((B,), seq_len, device=DEVICE_TYPE), |
| 169 | attn_logits=attn_logits, |
| 170 | num_kv_splits=num_kv_splits, |
| 171 | sm_scale=sm_scale, |
| 172 | ) |
| 173 | else: |
| 174 | k_paged = k_bf16.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_QK) |
| 175 | v_paged = v_bf16.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_V) |
| 176 | decode_attention_fwd( |
| 177 | q, |
| 178 | k_paged, |
| 179 | v_paged, |
| 180 | o_ref, |
| 181 | lse_ref, |
| 182 | req_to_page, |
| 183 | b_seq_len=torch.full((B,), seq_len, device=DEVICE_TYPE), |
| 184 | attn_logits=attn_logits, |
| 185 | num_kv_splits=num_kv_splits, |
| 186 | sm_scale=sm_scale, |
| 187 | page_size=PAGE_SIZE, |
| 188 | ) |
| 189 | |
| 190 | # --- FP8 path --- |
| 191 | k_fp8, k_scale = _quantize_to_fp8(k_bf16) |
| 192 | v_fp8, v_scale = _quantize_to_fp8(v_bf16) |
| 193 | |
| 194 | o_fp8 = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 195 | lse_fp8 = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE) |
| 196 | attn_logits_fp8 = torch.empty( |
| 197 | (B, H_Q, num_kv_splits, D_V + 1), dtype=torch.float32, device=DEVICE_TYPE |
| 198 | ) |
| 199 | |
| 200 | if PAGE_SIZE == 1: |
| 201 | decode_attention_fwd( |
| 202 | q, |
| 203 | k_fp8, |
| 204 | v_fp8, |
| 205 | o_fp8, |
| 206 | lse_fp8, |
| 207 | req_to_token, |
| 208 | b_seq_len=torch.full((B,), seq_len, device=DEVICE_TYPE), |
| 209 | attn_logits=attn_logits_fp8, |
| 210 | num_kv_splits=num_kv_splits, |
| 211 | sm_scale=sm_scale, |
| 212 | k_scale=k_scale, |
| 213 | v_scale=v_scale, |
| 214 | ) |
| 215 | else: |
| 216 | k_fp8_paged = k_fp8.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_QK) |
| 217 | v_fp8_paged = v_fp8.view(CACHE_SIZE // PAGE_SIZE, PAGE_SIZE, H_KV, D_V) |
| 218 | decode_attention_fwd( |
| 219 | q, |
| 220 | k_fp8_paged, |
| 221 | v_fp8_paged, |
| 222 | o_fp8, |
| 223 | lse_fp8, |
| 224 | req_to_page, |
| 225 | b_seq_len=torch.full((B,), seq_len, device=DEVICE_TYPE), |
| 226 | attn_logits=attn_logits_fp8, |
| 227 | num_kv_splits=num_kv_splits, |
| 228 | sm_scale=sm_scale, |
| 229 | page_size=PAGE_SIZE, |
| 230 | k_scale=k_scale, |
| 231 | v_scale=v_scale, |
| 232 | ) |
| 233 | |
| 234 | # FP8 tolerances match test_mla_backends.py test_backend_correctness. |
| 235 | torch.testing.assert_close(o_ref, o_fp8, atol=5e-1, rtol=1e-2) |
| 236 | |
| 237 | |
| 238 | @pytest.mark.parametrize( |
| 239 | "H_Q,H_KV,D_QK,D_V,is_mla", |
| 240 | [ |
| 241 | (16, 1, 576, 512, True), # MLA path (grouped kernel, v = trans(k)) |
| 242 | (32, 8, 128, 128, False), # GQA path (grouped kernel) |
| 243 | (32, 32, 128, 128, False), # MHA path (normal kernel) |
| 244 | ], |
| 245 | ) |
| 246 | @pytest.mark.parametrize("PAGE_SIZE", [16]) |
| 247 | def test_decode_attention_cross_layer_view(H_Q, H_KV, D_QK, D_V, is_mla, PAGE_SIZE): |
| 248 | """The kernel must honor the cache's page-dim stride, not assume pages are |
| 249 | packed back-to-back. A per-layer view into a cross-layer (block-major) |
| 250 | cache has stride(0) inflated by num_layers; outputs must match a |
| 251 | contiguous cache holding the same data exactly.""" |
| 252 | B = 3 |
| 253 | seq_len = 1027 |
| 254 | CACHE_SIZE = 16384 |
| 255 | NUM_LAYERS = 3 |
| 256 | LAYER_IDX = 1 |
| 257 | dtype = torch.bfloat16 |
| 258 | sm_scale = 1.0 / (D_QK**0.5) |
| 259 | num_kv_splits = 8 |
| 260 | num_pages = CACHE_SIZE // PAGE_SIZE |
| 261 | |
| 262 | num_pages_per_batch = cdiv(seq_len, PAGE_SIZE) |
| 263 | req_to_page = torch.randint( |
| 264 | 0, num_pages, (B, num_pages_per_batch), device=DEVICE_TYPE |
| 265 | ) |
| 266 | |
| 267 | q = torch.randn(B, H_Q, D_QK, dtype=dtype, device=DEVICE_TYPE) |
| 268 | b_seq_len = torch.full((B,), seq_len, device=DEVICE_TYPE) |
| 269 | |
| 270 | # Reference: contiguous paged cache. |
| 271 | k_ref = torch.randn( |
| 272 | num_pages, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE |
| 273 | ) |
| 274 | if is_mla: |
| 275 | v_ref = k_ref[..., :D_V] |
| 276 | else: |
| 277 | v_ref = torch.randn( |
| 278 | num_pages, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE |
| 279 | ) |
| 280 | |
| 281 | # Cross-layer cache: all layers' pages for a block are adjacent. The |
| 282 | # per-layer view has the same shape as the contiguous cache but |
| 283 | # stride(0) is NUM_LAYERS x larger. Neighbor layers hold random data so |
| 284 | # any packed-pages addressing reads garbage rather than zeros. |
| 285 | k_xl = torch.randn( |
| 286 | num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE |
| 287 | ) |
| 288 | k_view = k_xl[:, LAYER_IDX] |
| 289 | k_view.copy_(k_ref) |
| 290 | assert k_view.stride(0) == NUM_LAYERS * PAGE_SIZE * H_KV * D_QK |
| 291 | if is_mla: |
| 292 | v_view = k_view[..., :D_V] |
| 293 | else: |
| 294 | v_xl = torch.randn( |
| 295 | num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE |
| 296 | ) |
| 297 | v_view = v_xl[:, LAYER_IDX] |
| 298 | v_view.copy_(v_ref) |
| 299 | |
| 300 | def run(k_buffer, v_buffer): |
| 301 | o = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE) |
| 302 | lse = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE) |
| 303 | attn_logits = torch.empty( |
| 304 | (B, H_Q, num_kv_splits, D_V + 1), dtype=torch.float32, device=DEVICE_TYPE |
| 305 | ) |
| 306 | decode_attention_fwd( |
| 307 | q, |
| 308 | k_buffer, |
| 309 | v_buffer, |
| 310 | o, |
| 311 | lse, |
| 312 | req_to_page, |
| 313 | b_seq_len, |
| 314 | attn_logits, |
| 315 | num_kv_splits, |
| 316 | sm_scale, |
| 317 | PAGE_SIZE, |
| 318 | is_mla=is_mla, |
| 319 | ) |
| 320 | return o, lse |
| 321 | |
| 322 | o_ref, lse_ref = run(k_ref, v_ref) |
| 323 | o_xl, lse_xl = run(k_view, v_view) |
| 324 | |
| 325 | # Same data and same compute order; only addressing differs. |
| 326 | assert torch.equal(o_ref, o_xl) |
| 327 | assert torch.equal(lse_ref, lse_xl) |