| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import random |
| 5 | |
| 6 | import pytest |
| 7 | import torch |
| 8 | |
| 9 | from tests.kernels.allclose_default import get_default_atol, get_default_rtol |
| 10 | from tests.kernels.utils import opcheck |
| 11 | from vllm import _custom_ops as ops |
| 12 | from vllm.model_executor.layers.attention import Attention, MMEncoderAttention |
| 13 | from vllm.platforms import current_platform |
| 14 | from vllm.utils.mem_utils import get_max_shared_memory_bytes |
| 15 | from vllm.utils.torch_utils import set_random_seed |
| 16 | |
| 17 | pytestmark = pytest.mark.skip_global_cleanup |
| 18 | |
| 19 | FLOAT32_BYTES = torch.finfo(torch.float).bits // 8 |
| 20 | # This will change depending on the compute capability. |
| 21 | # - 512 as a buffer |
| 22 | MAX_SEQ_LEN = get_max_shared_memory_bytes() // FLOAT32_BYTES - 512 |
| 23 | # There may not be enough gpu memory due to large NUM_BLOCKS. |
| 24 | # Reduce NUM_BLOCKS when it happens. |
| 25 | NUM_BLOCKS = 4321 # Arbitrary values for testing |
| 26 | PARTITION_SIZE_ROCM = 256 |
| 27 | DTYPES = [torch.bfloat16, torch.float16] |
| 28 | NUM_GEN_SEQS = [7] # Arbitrary values for testing |
| 29 | NUM_HEADS = [(32, 8), (40, 40), (64, 8)] # Arbitrary values for testing |
| 30 | |
| 31 | # Head sizes supported by the ROCm paged attention kernel. |
| 32 | HEAD_SIZES = [64, 128] |
| 33 | |
| 34 | BLOCK_SIZES = [16, 32] |
| 35 | USE_ALIBI = [False, True] |
| 36 | KV_CACHE_DTYPE = ["auto", "fp8"] |
| 37 | SEEDS = [0] |
| 38 | CUDA_DEVICES = [ |
| 39 | f"cuda:{i}" for i in range(1 if torch.accelerator.device_count() == 1 else 2) |
| 40 | ] |
| 41 | |
| 42 | |
| 43 | def ref_masked_attention( |
| 44 | query: torch.Tensor, |
| 45 | key: torch.Tensor, |
| 46 | value: torch.Tensor, |
| 47 | scale: float, |
| 48 | attn_mask: torch.Tensor | None = None, |
| 49 | ) -> torch.Tensor: |
| 50 | attn_weights = scale * torch.einsum("qhd,khd->hqk", query, key).float() |
| 51 | if attn_mask is not None: |
| 52 | attn_weights = attn_weights + attn_mask.float() |
| 53 | attn_weights = torch.softmax(attn_weights, dim=-1).to(value.dtype) |
| 54 | out = torch.einsum("hqk,khd->qhd", attn_weights, value) |
| 55 | return out |
| 56 | |
| 57 | |
| 58 | def ref_single_query_cached_kv_attention( |
| 59 | output: torch.Tensor, |
| 60 | query: torch.Tensor, |
| 61 | num_queries_per_kv: int, |
| 62 | key_cache: torch.Tensor, |
| 63 | value_cache: torch.Tensor, |
| 64 | block_tables: torch.Tensor, |
| 65 | seq_lens: torch.Tensor, |
| 66 | scale: float, |
| 67 | alibi_slopes: torch.Tensor | None, |
| 68 | ) -> None: |
| 69 | num_query_heads = query.shape[1] |
| 70 | num_kv_heads = value_cache.shape[1] |
| 71 | head_size = value_cache.shape[2] |
| 72 | block_size = value_cache.shape[3] |
| 73 | num_seqs = query.shape[0] |
| 74 | |
| 75 | block_tables_lst = block_tables.cpu().tolist() |
| 76 | seq_lens_lst = seq_lens.cpu().tolist() |
| 77 | for i in range(num_seqs): |
| 78 | q = query[i].unsqueeze(0) |
| 79 | block_table = block_tables_lst[i] |
| 80 | seq_len = int(seq_lens_lst[i]) |
| 81 | |
| 82 | keys_lst: list[torch.Tensor] = [] |
| 83 | values_lst: list[torch.Tensor] = [] |
| 84 | for j in range(seq_len): |
| 85 | block_number = int(block_table[j // block_size]) |
| 86 | block_offset = j % block_size |
| 87 | |
| 88 | k = key_cache[block_number, :, :, block_offset, :] |
| 89 | k = k.reshape(num_kv_heads, head_size) |
| 90 | keys_lst.append(k) |
| 91 | |
| 92 | v = value_cache[block_number, :, :, block_offset] |
| 93 | values_lst.append(v) |
| 94 | keys = torch.stack(keys_lst, dim=0) |
| 95 | values = torch.stack(values_lst, dim=0) |
| 96 | if num_queries_per_kv > 1: |
| 97 | # Handle MQA and GQA |
| 98 | keys = torch.repeat_interleave(keys, num_queries_per_kv, dim=1) |
| 99 | values = torch.repeat_interleave(values, num_queries_per_kv, dim=1) |
| 100 | |
| 101 | alibi_bias = None |
| 102 | if alibi_slopes is not None: |
| 103 | # Create the ALiBi bias used in the paged attention kernel. |
| 104 | position_ids = torch.arange(seq_len).int() |
| 105 | alibi_bias = (position_ids - seq_len + 1).float() |
| 106 | alibi_bias = alibi_slopes.view(-1, 1, 1) * alibi_bias.view(1, 1, -1) |
| 107 | |
| 108 | out = ref_masked_attention(q, keys, values, scale, alibi_bias) |
| 109 | out = out.view(num_query_heads, head_size) |
| 110 | output[i].copy_(out, non_blocking=True) |
| 111 | |
| 112 | |
| 113 | @pytest.mark.skipif( |
| 114 | not current_platform.is_rocm(), reason="ROCm-only paged attention kernel" |
| 115 | ) |
| 116 | @pytest.mark.parametrize("num_seqs", NUM_GEN_SEQS) |
| 117 | @pytest.mark.parametrize("num_heads", NUM_HEADS) |
| 118 | @pytest.mark.parametrize("head_size", HEAD_SIZES) |
| 119 | @pytest.mark.parametrize("use_alibi", USE_ALIBI) |
| 120 | @pytest.mark.parametrize("block_size", BLOCK_SIZES) |
| 121 | @pytest.mark.parametrize("dtype", DTYPES) |
| 122 | @pytest.mark.parametrize("kv_cache_dtype", KV_CACHE_DTYPE) |
| 123 | @pytest.mark.parametrize("seed", SEEDS) |
| 124 | @pytest.mark.parametrize("device", CUDA_DEVICES) |
| 125 | def test_paged_attention( |
| 126 | kv_cache_factory, |
| 127 | num_seqs: int, |
| 128 | num_heads: tuple[int, int], |
| 129 | head_size: int, |
| 130 | use_alibi: bool, |
| 131 | block_size: int, |
| 132 | dtype: torch.dtype, |
| 133 | kv_cache_dtype: str, |
| 134 | seed: int, |
| 135 | device: str, |
| 136 | ) -> None: |
| 137 | if current_platform.is_navi() and ( |
| 138 | kv_cache_dtype == "fp8" or head_size != 128 or block_size != 16 or use_alibi |
| 139 | ): |
| 140 | pytest.skip() |
| 141 | |
| 142 | set_random_seed(seed) |
| 143 | torch.set_default_device(device) |
| 144 | scale = float(1.0 / (head_size**0.5)) |
| 145 | num_query_heads, num_kv_heads = num_heads |
| 146 | query = torch.empty(num_seqs, num_query_heads, head_size, dtype=dtype) |
| 147 | query.uniform_(-scale, scale) |
| 148 | |
| 149 | assert num_query_heads % num_kv_heads == 0 |
| 150 | num_queries_per_kv = num_query_heads // num_kv_heads |
| 151 | alibi_slopes = None |
| 152 | if use_alibi: |
| 153 | alibi_slopes = torch.randn(num_query_heads, dtype=torch.float) |
| 154 | |
| 155 | seq_lens_lst = [random.randint(1, MAX_SEQ_LEN) for _ in range(num_seqs)] |
| 156 | seq_lens_lst[-1] = MAX_SEQ_LEN |
| 157 | max_seq_len = max(seq_lens_lst) |
| 158 | seq_lens = torch.tensor(seq_lens_lst, dtype=torch.int) |
| 159 | |
| 160 | # Create the block tables. |
| 161 | max_num_blocks_per_seq = (max_seq_len + block_size - 1) // block_size |
| 162 | block_tables_lst: list[list[int]] = [] |
| 163 | for seq_idx, seq_len in enumerate(seq_lens_lst): |
| 164 | block_table = [ |
| 165 | random.randint(num_seqs, NUM_BLOCKS - 1) |
| 166 | for _ in range(max_num_blocks_per_seq) |
| 167 | ] |
| 168 | # Keep the last block private to this sequence so its padding can be |
| 169 | # poisoned without corrupting valid tokens from another sequence. |
| 170 | block_table[(seq_len - 1) // block_size] = seq_idx |
| 171 | block_tables_lst.append(block_table) |
| 172 | |
| 173 | block_tables = torch.tensor(block_tables_lst, dtype=torch.int) |
| 174 | |
| 175 | # Create the KV caches. |
| 176 | key_caches, value_caches = kv_cache_factory( |
| 177 | NUM_BLOCKS, |
| 178 | block_size, |
| 179 | 1, |
| 180 | num_kv_heads, |
| 181 | head_size, |
| 182 | kv_cache_dtype, |
| 183 | dtype, |
| 184 | seed, |
| 185 | device, |
| 186 | ) |
| 187 | key_cache, value_cache = key_caches[0], value_caches[0] |
| 188 | |
| 189 | k_scale_value = 0.5 if kv_cache_dtype == "fp8" else 1.0 |
| 190 | v_scale_value = 0.25 if kv_cache_dtype == "fp8" else 1.0 |
| 191 | k_scale = torch.tensor(k_scale_value, dtype=torch.float32, device=device) |
| 192 | v_scale = torch.tensor(v_scale_value, dtype=torch.float32, device=device) |
| 193 | |
| 194 | if kv_cache_dtype == "fp8": |
| 195 | padding_nan = 0x80 if current_platform.is_fp8_fnuz() else 0x7F |
| 196 | else: |
| 197 | padding_nan = torch.nan |
| 198 | for seq_idx, seq_len in enumerate(seq_lens_lst): |
| 199 | padding_start = seq_len % block_size |
| 200 | if padding_start: |
| 201 | last_block_idx = block_tables_lst[seq_idx][seq_len // block_size] |
| 202 | value_cache[last_block_idx, :, :, padding_start:] = padding_nan |
| 203 | |
| 204 | # Call the paged attention kernel. |
| 205 | output = torch.empty_like(query) |
| 206 | num_partitions = (max_seq_len + PARTITION_SIZE_ROCM - 1) // PARTITION_SIZE_ROCM |
| 207 | assert PARTITION_SIZE_ROCM % block_size == 0 |
| 208 | num_seqs, num_heads, head_size = output.shape |
| 209 | tmp_output = torch.empty( |
| 210 | size=(num_seqs, num_heads, num_partitions, head_size), |
| 211 | dtype=output.dtype, |
| 212 | ) |
| 213 | exp_sums = torch.empty( |
| 214 | size=(num_seqs, num_heads, num_partitions), |
| 215 | dtype=torch.float32, |
| 216 | ) |
| 217 | max_logits = torch.empty_like(exp_sums) |
| 218 | ops.paged_attention_rocm( |
| 219 | output, |
| 220 | exp_sums, |
| 221 | max_logits, |
| 222 | tmp_output, |
| 223 | query, |
| 224 | key_cache, |
| 225 | value_cache, |
| 226 | num_kv_heads, |
| 227 | scale, |
| 228 | block_tables, |
| 229 | seq_lens, |
| 230 | None, |
| 231 | block_size, |
| 232 | max_seq_len, |
| 233 | alibi_slopes, |
| 234 | kv_cache_dtype, |
| 235 | k_scale, |
| 236 | v_scale, |
| 237 | ) |
| 238 | |
| 239 | opcheck( |
| 240 | torch.ops._rocm_C.paged_attention, |
| 241 | ( |
| 242 | output, |
| 243 | exp_sums, |
| 244 | max_logits, |
| 245 | tmp_output, |
| 246 | query, |
| 247 | key_cache, |
| 248 | value_cache, |
| 249 | num_kv_heads, |
| 250 | scale, |
| 251 | block_tables, |
| 252 | seq_lens, |
| 253 | None, |
| 254 | block_size, |
| 255 | max_seq_len, |
| 256 | alibi_slopes, |
| 257 | kv_cache_dtype, |
| 258 | k_scale, |
| 259 | v_scale, |
| 260 | None, |
| 261 | "f16", |
| 262 | ), |
| 263 | cond=(head_size == 64 and block_size == BLOCK_SIZES[0]), |
| 264 | ) |
| 265 | |
| 266 | # Run the reference implementation. |
| 267 | if kv_cache_dtype == "fp8": |
| 268 | # Convert cache data back to dtype. |
| 269 | x = 16 // torch.tensor([], dtype=dtype).element_size() |
| 270 | key_cache_shape = (NUM_BLOCKS, num_kv_heads, head_size // x, block_size, x) |
| 271 | dequantized_key_cache = torch.empty( |
| 272 | size=key_cache_shape, dtype=dtype, device=device |
| 273 | ) |
| 274 | ops.convert_fp8(dequantized_key_cache, key_cache, k_scale_value) |
| 275 | key_cache = dequantized_key_cache |
| 276 | |
| 277 | value_cache_shape = value_cache.shape |
| 278 | dequantized_value_cache = torch.empty( |
| 279 | size=value_cache_shape, dtype=dtype, device=device |
| 280 | ) |
| 281 | ops.convert_fp8(dequantized_value_cache, value_cache, v_scale_value) |
| 282 | value_cache = dequantized_value_cache |
| 283 | |
| 284 | ref_output = torch.empty_like(query) |
| 285 | ref_single_query_cached_kv_attention( |
| 286 | ref_output, |
| 287 | query, |
| 288 | num_queries_per_kv, |
| 289 | key_cache, |
| 290 | value_cache, |
| 291 | block_tables, |
| 292 | seq_lens, |
| 293 | scale, |
| 294 | alibi_slopes, |
| 295 | ) |
| 296 | |
| 297 | # NOTE(woosuk): Due to the kernel-level differences in the two |
| 298 | # implementations, there is a small numerical difference in the two |
| 299 | # outputs. Thus, we use a relaxed tolerance for the test. |
| 300 | atol = get_default_atol(output) if current_platform.is_rocm() else 1e-3 |
| 301 | rtol = get_default_rtol(output) if current_platform.is_rocm() else 1e-5 |
| 302 | |
| 303 | # NOTE(zhaoyang): FP8 KV Cache will introduce quantization error, |
| 304 | # so we use a relaxed tolerance for the test. |
| 305 | atol, rtol = 1e-3, 1e-5 |
| 306 | if kv_cache_dtype == "fp8": |
| 307 | atol, rtol = 1e-2, 1e-5 |
| 308 | torch.testing.assert_close(output, ref_output, atol=atol, rtol=rtol) |
| 309 | |
| 310 | |
| 311 | @pytest.mark.parametrize("attention_cls", [Attention, MMEncoderAttention]) |
| 312 | def test_num_heads_not_divisible_by_num_kv_heads(attention_cls: type) -> None: |
| 313 | head_size = 64 |
| 314 | scale = float(1.0 / (head_size**0.5)) |
| 315 | num_heads = 16 |
| 316 | num_kv_heads = 5 |
| 317 | with pytest.raises(AssertionError): |
| 318 | _ = attention_cls( |
| 319 | num_heads=num_heads, |
| 320 | head_size=head_size, |
| 321 | scale=scale, |
| 322 | num_kv_heads=num_kv_heads, |
| 323 | ) |
| 324 | |
| 325 | |
| 326 | UNSUPPORTED_HEAD_SIZES = [32, 80, 96, 160, 192, 224, 256] |
| 327 | |
| 328 | |
| 329 | @pytest.mark.skipif( |
| 330 | not current_platform.is_rocm(), reason="ROCm-only paged attention kernel" |
| 331 | ) |
| 332 | @pytest.mark.parametrize("head_size", UNSUPPORTED_HEAD_SIZES) |
| 333 | def test_paged_attention_unsupported_head_sizes( |
| 334 | kv_cache_factory, head_size: int |
| 335 | ) -> None: |
| 336 | """Verify ROCm paged attention rejects unsupported head sizes.""" |
| 337 | torch.set_default_device("cuda") |
| 338 | num_seqs, num_kv_heads, block_size, max_seq_len = 1, 8, 16, 128 |
| 339 | scale = head_size**-0.5 |
| 340 | |
| 341 | query = torch.empty(num_seqs, num_kv_heads, head_size, dtype=torch.bfloat16) |
| 342 | key_caches, value_caches = kv_cache_factory( |
| 343 | NUM_BLOCKS, block_size, 1, num_kv_heads, head_size, "auto", torch.bfloat16, 0 |
| 344 | ) |
| 345 | block_tables = torch.zeros(num_seqs, 1, dtype=torch.int32) |
| 346 | seq_lens = torch.tensor([max_seq_len], dtype=torch.int32) |
| 347 | |
| 348 | output = torch.empty_like(query) |
| 349 | num_partitions = (max_seq_len + PARTITION_SIZE_ROCM - 1) // PARTITION_SIZE_ROCM |
| 350 | tmp_output = torch.empty(num_seqs, num_kv_heads, num_partitions, head_size) |
| 351 | exp_sums = torch.empty(num_seqs, num_kv_heads, num_partitions, dtype=torch.float32) |
| 352 | k_scale = v_scale = torch.tensor(1.0, dtype=torch.float32, device="cuda") |
| 353 | |
| 354 | with pytest.raises(RuntimeError, match="Unsupported head size"): |
| 355 | ops.paged_attention_rocm( |
| 356 | output, |
| 357 | exp_sums, |
| 358 | torch.empty_like(exp_sums), |
| 359 | tmp_output, |
| 360 | query, |
| 361 | key_caches[0], |
| 362 | value_caches[0], |
| 363 | num_kv_heads, |
| 364 | scale, |
| 365 | block_tables, |
| 366 | seq_lens, |
| 367 | None, |
| 368 | block_size, |
| 369 | max_seq_len, |
| 370 | None, |
| 371 | "auto", |
| 372 | k_scale, |
| 373 | v_scale, |
| 374 | ) |