main
py 541 lines 16.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """ROCm-specific tests for FP8 quantization and kernels."""
4
5 import importlib
6 import math
7 import warnings
8
9 import pytest
10 import torch
11
12 import vllm._custom_ops as ops
13 from tests.kernels.quant_utils import (
14 ref_dynamic_per_tensor_fp8_quant,
15 ref_dynamic_per_token_quant,
16 )
17 from tests.kernels.utils import _assert_deterministic
18 from vllm.platforms import current_platform
19 from vllm.utils.torch_utils import set_random_seed
20
21 pytestmark = pytest.mark.skipif(
22 not current_platform.is_rocm(), reason="ROCm-specific tests"
23 )
24
25 DEVICE = "cuda"
26 FP8_DTYPE = current_platform.fp8_dtype()
27 fp8_only = pytest.mark.skipif(
28 not current_platform.supports_fp8(), reason="ROCm FP8 only"
29 )
30
31 ENV_DEFAULTS = {
32 "VLLM_ROCM_FP8_PADDING": True,
33 "VLLM_ROCM_USE_AITER_FP8BMM": True,
34 "VLLM_ROCM_FP8_MFMA_PAGE_ATTN": False,
35 }
36
37 SCALED_MM_CASES = [
38 pytest.param((1, 128, 128), 0.05, 0.15, 0.95, id="1x128x128"),
39 pytest.param((4, 256, 512), 0.10, 0.30, 0.96, id="4x256x512"),
40 pytest.param((8, 512, 1024), 0.20, 0.50, 0.99, id="8x512x1024"),
41 ]
42
43
44 def _reload_envs():
45 import vllm.envs as envs
46
47 return importlib.reload(envs)
48
49
50 @pytest.fixture(autouse=True)
51 def _restore_rocm_env_state():
52 """Restore global env + AITER flag state after every test.
53
54 Tests here reload ``vllm.envs`` (a module singleton) and refresh the
55 cached ``rocm_aiter_ops`` env flags. Restoring in a fixture teardown
56 guarantees cleanup even when an assertion fails mid-test, preventing
57 state from leaking into other tests in the same worker.
58 """
59 yield
60 _reload_envs()
61 from vllm._aiter_ops import rocm_aiter_ops
62
63 rocm_aiter_ops.refresh_env_variables()
64
65
66 def _quantile(flat: torch.Tensor, q: float) -> float:
67 if flat.numel() == 1:
68 return flat.item()
69 return torch.quantile(flat, q).item()
70
71
72 def _format_observed_rate(count: int, total: int) -> str:
73 return f"{count / total:.4%} ({count}/{total})"
74
75
76 def _format_allowed_rate(rate: float, total: int) -> str:
77 allowed_count = math.floor(rate * total)
78 return f"{rate:.4%} (<= {allowed_count}/{total})"
79
80
81 def _assert_abs_error_budget(
82 actual: torch.Tensor,
83 expected: torch.Tensor,
84 *,
85 label: str,
86 tight_atol: float,
87 max_atol: float,
88 pass_rate: float,
89 ) -> None:
90 diff = (actual.float() - expected.float()).abs().flatten()
91 total = diff.numel()
92 within_tight_count = int((diff <= tight_atol).sum().item())
93 fail_count = total - within_tight_count
94 above_max_count = int((diff > max_atol).sum().item())
95 within_tight = within_tight_count / total
96 allowed_fail_rate = 1.0 - pass_rate
97 above_max = above_max_count / total
98 max_fail_rate = 0.0
99 max_diff = diff.max().item()
100 mean_diff = diff.mean().item()
101 p99 = _quantile(diff, 0.99)
102 p999 = _quantile(diff, 0.999)
103 msg = (
104 f"[rocm_fp8] {label}: abs<={tight_atol} pass={within_tight:.4%} "
105 f"({within_tight_count}/{total}) "
106 f"fail={_format_observed_rate(fail_count, total)} "
107 f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
108 f"abs>{max_atol}={_format_observed_rate(above_max_count, total)} "
109 f"allowed_above_max={_format_allowed_rate(max_fail_rate, total)} "
110 f"max_diff={max_diff:.6g} mean_diff={mean_diff:.6g} "
111 f"p99={p99:.6g} p999={p999:.6g}"
112 )
113 print(msg)
114 if within_tight < 1.0:
115 warnings.warn(msg, stacklevel=2)
116 assert within_tight >= pass_rate, msg
117 assert above_max <= max_fail_rate, msg
118
119
120 def _assert_rel_error_budget(
121 actual: torch.Tensor,
122 expected: torch.Tensor,
123 *,
124 label: str,
125 tight_rtol: float,
126 max_rtol: float,
127 pass_rate: float,
128 max_fail_rate: float,
129 ) -> None:
130 rel = (
131 (actual.float() - expected.float()).abs()
132 / expected.float().abs().clamp_min(1e-5)
133 ).flatten()
134 total = rel.numel()
135 within_tight_count = int((rel <= tight_rtol).sum().item())
136 fail_count = total - within_tight_count
137 above_max_count = int((rel > max_rtol).sum().item())
138 within_tight = within_tight_count / total
139 allowed_fail_rate = 1.0 - pass_rate
140 above_max = above_max_count / total
141 max_rel = rel.max().item()
142 mean_rel = rel.mean().item()
143 p99 = _quantile(rel, 0.99)
144 p999 = _quantile(rel, 0.999)
145 msg = (
146 f"[rocm_fp8] {label}: rel<={tight_rtol} pass={within_tight:.4%} "
147 f"({within_tight_count}/{total}) "
148 f"fail={_format_observed_rate(fail_count, total)} "
149 f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
150 f"rel>{max_rtol}={_format_observed_rate(above_max_count, total)} "
151 f"allowed_above_max={_format_allowed_rate(max_fail_rate, total)} "
152 f"max_rel={max_rel:.6g} mean_rel={mean_rel:.6g} "
153 f"p99={p99:.6g} p999={p999:.6g}"
154 )
155 print(msg)
156 if within_tight < 1.0:
157 warnings.warn(msg, stacklevel=2)
158 assert within_tight >= pass_rate, msg
159 assert above_max <= max_fail_rate, msg
160
161
162 def _ref_paged_attention(
163 query: torch.Tensor,
164 key_cache: torch.Tensor,
165 value_cache: torch.Tensor,
166 *,
167 query_lens: list[int],
168 kv_lens: list[int],
169 block_tables: torch.Tensor,
170 scale: float,
171 ) -> torch.Tensor:
172 block_tables_np = block_tables.cpu().numpy()
173 _, block_size, num_kv_heads, head_size = key_cache.shape
174
175 outputs = []
176 start_idx = 0
177 for i, query_len in enumerate(query_lens):
178 kv_len = kv_lens[i]
179 q = query[start_idx : start_idx + query_len] * scale
180
181 num_kv_blocks = (kv_len + block_size - 1) // block_size
182 block_indices = block_tables_np[i, :num_kv_blocks]
183
184 k = key_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
185 v = value_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
186
187 if q.shape[1] != k.shape[1]:
188 repeat = q.shape[1] // k.shape[1]
189 k = torch.repeat_interleave(k, repeat, dim=1)
190 v = torch.repeat_interleave(v, repeat, dim=1)
191
192 attn = torch.einsum("qhd,khd->hqk", q, k).float()
193 mask = torch.triu(
194 torch.ones(query_len, kv_len, device=q.device),
195 diagonal=kv_len - query_len + 1,
196 ).bool()
197 attn.masked_fill_(mask, float("-inf"))
198 attn = torch.softmax(attn, dim=-1).to(v.dtype)
199 outputs.append(torch.einsum("hqk,khd->qhd", attn, v))
200 start_idx += query_len
201
202 return torch.cat(outputs, dim=0)
203
204
205 def test_rocm_fp8_env_defaults(monkeypatch):
206 with monkeypatch.context() as mp:
207 for env_name in ENV_DEFAULTS:
208 mp.delenv(env_name, raising=False)
209 envs = _reload_envs()
210 for env_name, expected in ENV_DEFAULTS.items():
211 assert getattr(envs, env_name) is expected
212
213
214 @pytest.mark.parametrize(
215 ("env_name", "raw_value", "expected"),
216 [
217 ("VLLM_ROCM_FP8_PADDING", "0", False),
218 ("VLLM_ROCM_FP8_PADDING", "1", True),
219 ("VLLM_ROCM_USE_AITER_FP8BMM", "0", False),
220 ("VLLM_ROCM_USE_AITER_FP8BMM", "1", True),
221 ("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "0", False),
222 ("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "1", True),
223 ],
224 )
225 def test_rocm_fp8_env_overrides(env_name, raw_value, expected, monkeypatch):
226 with monkeypatch.context() as mp:
227 mp.setenv(env_name, raw_value)
228 envs = _reload_envs()
229 assert getattr(envs, env_name) is expected
230
231
232 @fp8_only
233 def test_maybe_pad_fp8_weight_respects_env(monkeypatch):
234 from vllm.model_executor.layers.quantization.utils.fp8_utils import (
235 _maybe_pad_fp8_weight,
236 )
237
238 weight = torch.randn(64, 512, device=DEVICE).to(FP8_DTYPE)
239
240 with monkeypatch.context() as mp:
241 mp.setenv("VLLM_ROCM_FP8_PADDING", "1")
242 _reload_envs()
243 padded = _maybe_pad_fp8_weight(weight)
244 assert padded.shape == weight.shape
245 assert padded.dtype == weight.dtype
246 assert padded.data_ptr() != weight.data_ptr()
247 torch.testing.assert_close(padded.float(), weight.float())
248
249 with monkeypatch.context() as mp:
250 mp.setenv("VLLM_ROCM_FP8_PADDING", "0")
251 _reload_envs()
252 unpadded = _maybe_pad_fp8_weight(weight)
253 assert unpadded.data_ptr() == weight.data_ptr()
254
255
256 @fp8_only
257 @pytest.mark.parametrize(
258 ("use_aiter", "fp8bmm_enabled", "expected"),
259 [
260 (True, True, True),
261 (True, False, False),
262 (False, True, False),
263 ],
264 )
265 def test_aiter_fp8bmm_enabled_api_respects_env(
266 use_aiter, fp8bmm_enabled, expected, monkeypatch
267 ):
268 from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
269
270 assert is_aiter_found_and_supported()
271
272 with monkeypatch.context() as mp:
273 mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
274 mp.setenv("VLLM_ROCM_USE_AITER_FP8BMM", "1" if fp8bmm_enabled else "0")
275 _reload_envs()
276 rocm_aiter_ops.refresh_env_variables()
277 assert rocm_aiter_ops.is_fp8bmm_enabled() is expected
278
279
280 @fp8_only
281 @pytest.mark.parametrize(
282 ("shape", "tight_atol", "max_atol", "pass_rate"), SCALED_MM_CASES
283 )
284 def test_fp8_scaled_mm_matches_dequantized_reference(
285 shape, tight_atol, max_atol, pass_rate
286 ):
287 torch.manual_seed(0)
288
289 batch, m_dim, k_dim = shape
290 n_dim = k_dim
291 a = torch.randn(batch * m_dim, k_dim, device=DEVICE)
292 b = torch.randn(n_dim, k_dim, device=DEVICE)
293
294 a_fp8, scale_a = ref_dynamic_per_tensor_fp8_quant(a)
295 b_fp8, scale_b = ref_dynamic_per_tensor_fp8_quant(b)
296 out = torch._scaled_mm(
297 a_fp8,
298 b_fp8.t(),
299 out_dtype=torch.bfloat16,
300 scale_a=scale_a,
301 scale_b=scale_b,
302 )
303
304 a_dq = a_fp8.float() * scale_a.float()
305 b_dq = b_fp8.float() * scale_b.float()
306 ref = torch.mm(a_dq, b_dq.t())
307
308 assert out.shape == (batch * m_dim, n_dim)
309 assert out.dtype == torch.bfloat16
310 assert not torch.isnan(out).any()
311
312 _assert_abs_error_budget(
313 out.float(),
314 ref,
315 label=f"scaled_mm shape={shape}",
316 tight_atol=tight_atol,
317 max_atol=max_atol,
318 pass_rate=pass_rate,
319 )
320
321
322 @fp8_only
323 def test_fp8_scaled_mm_determinism():
324 torch.manual_seed(5)
325
326 m_dim, k_dim, n_dim = 64, 128, 128
327 a = torch.randn(m_dim, k_dim, device=DEVICE)
328 b = torch.randn(n_dim, k_dim, device=DEVICE)
329 a_fp8, scale_a = ref_dynamic_per_tensor_fp8_quant(a)
330 b_fp8, scale_b = ref_dynamic_per_tensor_fp8_quant(b)
331
332 def run_gemm():
333 return torch._scaled_mm(
334 a_fp8,
335 b_fp8.t(),
336 out_dtype=torch.bfloat16,
337 scale_a=scale_a,
338 scale_b=scale_b,
339 )
340
341 _assert_deterministic(run_gemm, n_runs=4)
342
343
344 @fp8_only
345 def test_aiter_fp8_kv_attention_matches_bf16_reference():
346 import aiter
347
348 from vllm._aiter_ops import is_aiter_found_and_supported
349 from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
350
351 assert is_aiter_found_and_supported()
352
353 set_random_seed(10)
354
355 num_q_heads, num_kv_heads = 8, 8
356 head_size = 128
357 query_len, kv_len = 4, 128
358 block_size = 16
359 num_blocks = 2048
360 scale = head_size**-0.5
361
362 query = torch.randn(
363 query_len, num_q_heads, head_size, dtype=torch.bfloat16, device=DEVICE
364 )
365 key_cache_fp8 = torch.clamp(
366 torch.randn(num_blocks, block_size, num_kv_heads, head_size, device=DEVICE),
367 min=-1.0,
368 max=1.0,
369 ).to(FP8_DTYPE)
370 value_cache_fp8 = torch.clamp(
371 torch.randn(num_blocks, block_size, num_kv_heads, head_size, device=DEVICE),
372 min=-1.0,
373 max=1.0,
374 ).to(FP8_DTYPE)
375
376 cu_query_lens = torch.tensor([0, query_len], dtype=torch.int32, device=DEVICE)
377 cu_seq_lens = torch.tensor([0, kv_len], dtype=torch.int32, device=DEVICE)
378 block_tables = torch.randint(
379 0,
380 num_blocks,
381 (1, (kv_len + block_size - 1) // block_size),
382 dtype=torch.int32,
383 device=DEVICE,
384 )
385 token_to_batch = torch.zeros(kv_len, dtype=torch.int32, device=DEVICE)
386 seq_starts = torch.zeros(1, dtype=torch.int32, device=DEVICE)
387 gathered_key = torch.empty(
388 kv_len, num_kv_heads, head_size, dtype=torch.bfloat16, device=DEVICE
389 )
390 gathered_value = torch.empty_like(gathered_key)
391
392 cp_mha_gather_cache(
393 key_cache=key_cache_fp8,
394 value_cache=value_cache_fp8,
395 key=gathered_key,
396 value=gathered_value,
397 block_tables=block_tables,
398 k_scales=torch.ones(1, dtype=torch.float32, device=DEVICE),
399 v_scales=torch.ones(1, dtype=torch.float32, device=DEVICE),
400 cu_seqlens_kv=cu_seq_lens,
401 token_to_batch=token_to_batch,
402 seq_starts=seq_starts,
403 dequant=True,
404 kv_cache_layout="NHD",
405 total_tokens=kv_len,
406 )
407
408 output = torch.empty_like(query)
409 aiter.flash_attn_varlen_func(
410 q=query,
411 k=gathered_key,
412 v=gathered_value,
413 cu_seqlens_q=cu_query_lens,
414 cu_seqlens_k=cu_seq_lens,
415 max_seqlen_q=query_len,
416 max_seqlen_k=kv_len,
417 min_seqlen_q=1,
418 dropout_p=0.0,
419 softmax_scale=scale,
420 causal=True,
421 window_size=(-1, -1),
422 alibi_slopes=None,
423 return_lse=False,
424 out=output,
425 )
426
427 ref = _ref_paged_attention(
428 query=query,
429 key_cache=key_cache_fp8.to(torch.bfloat16),
430 value_cache=value_cache_fp8.to(torch.bfloat16),
431 query_lens=[query_len],
432 kv_lens=[kv_len],
433 block_tables=block_tables,
434 scale=scale,
435 )
436
437 _assert_abs_error_budget(
438 output.float(),
439 ref.float(),
440 label="aiter_fp8_kv_attention",
441 tight_atol=0.15,
442 max_atol=0.25,
443 pass_rate=0.98,
444 )
445
446
447 @fp8_only
448 @pytest.mark.parametrize("shape", [(128, 256), (512, 1024), (1024, 4096)])
449 def test_fp8_per_tensor_quant_matches_reference(shape):
450 torch.manual_seed(0)
451
452 x = torch.randn(*shape, dtype=torch.bfloat16, device=DEVICE)
453
454 out, scale = ops.scaled_fp8_quant(x)
455 ref_out, ref_scale = ref_dynamic_per_tensor_fp8_quant(x)
456
457 assert out.shape == x.shape
458 assert out.dtype == FP8_DTYPE
459
460 torch.testing.assert_close(scale, ref_scale, rtol=1e-3, atol=1e-4)
461 code_match_rate = (
462 (out.view(torch.uint8) == ref_out.view(torch.uint8)).float().mean().item()
463 )
464 assert code_match_rate >= 0.999, (
465 f"FP8 codes match reference only {code_match_rate:.4%} (< 99.9%)"
466 )
467
468 dequant = out.float() * scale.float()
469 _assert_rel_error_budget(
470 dequant,
471 x.float(),
472 label=f"per_tensor_quant shape={shape}",
473 tight_rtol=0.06,
474 max_rtol=0.60,
475 pass_rate=0.99,
476 max_fail_rate=1e-4,
477 )
478
479
480 @fp8_only
481 def test_aiter_group_fp8_quant_roundtrip():
482 from aiter import dtypes
483
484 from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
485
486 assert is_aiter_found_and_supported()
487
488 torch.manual_seed(0)
489
490 m_dim, n_dim = 64, 4096
491 group_size = 128
492 x = torch.randn(m_dim, n_dim, dtype=torch.bfloat16, device=DEVICE)
493
494 out, scales = rocm_aiter_ops.group_fp8_quant(x, group_size)
495 assert out.dtype == dtypes.fp8
496 assert scales.dtype == torch.float32
497
498 scales_expanded = scales.repeat_interleave(group_size, dim=1)[:, :n_dim]
499 dequant = out.float() * scales_expanded
500 _assert_rel_error_budget(
501 dequant,
502 x.float(),
503 label="aiter_group_fp8_quant",
504 tight_rtol=0.06,
505 max_rtol=0.30,
506 pass_rate=0.99,
507 max_fail_rate=1e-4,
508 )
509
510
511 @fp8_only
512 def test_fp8_per_token_quant_matches_reference():
513 torch.manual_seed(0)
514
515 x = torch.randn(32, 4096, dtype=torch.bfloat16, device=DEVICE)
516
517 out, scale = ops.scaled_fp8_quant(x, scale=None, use_per_token_if_dynamic=True)
518 ref_out, ref_scale = ref_dynamic_per_token_quant(x, FP8_DTYPE)
519
520 assert out.shape == x.shape
521 assert out.dtype == FP8_DTYPE
522 assert scale.shape == (x.shape[0], 1)
523
524 torch.testing.assert_close(scale, ref_scale, rtol=1e-3, atol=1e-4)
525 code_match_rate = (
526 (out.view(torch.uint8) == ref_out.view(torch.uint8)).float().mean().item()
527 )
528 assert code_match_rate >= 0.999, (
529 f"FP8 codes match reference only {code_match_rate:.4%} (< 99.9%)"
530 )
531
532 dequant = out.float() * scale.float()
533 _assert_rel_error_budget(
534 dequant,
535 x.float(),
536 label="per_token_quant",
537 tight_rtol=0.06,
538 max_rtol=0.30,
539 pass_rate=0.99,
540 max_fail_rate=1e-4,
541 )