| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Tests for FP8 scaling (dynamic and static) in MMEncoderAttention.""" |
| 4 | |
| 5 | import contextlib |
| 6 | import json |
| 7 | from unittest.mock import MagicMock, patch |
| 8 | |
| 9 | import pytest |
| 10 | import torch |
| 11 | |
| 12 | from vllm.model_executor.layers.attention.mm_encoder_attention import ( |
| 13 | _FP8_AMAX_HISTORY_LEN, |
| 14 | _FP8_MAX, |
| 15 | ) |
| 16 | from vllm.platforms import current_platform |
| 17 | from vllm.utils.flashinfer import ( |
| 18 | is_flashinfer_cudnn_fp8_prefill_attn_supported, |
| 19 | ) |
| 20 | |
| 21 | pytestmark = pytest.mark.skipif( |
| 22 | not is_flashinfer_cudnn_fp8_prefill_attn_supported(), |
| 23 | reason="FlashInfer cuDNN FP8 prefill attention not supported", |
| 24 | ) |
| 25 | |
| 26 | LAYER_0 = "visual.blocks.0.attn.attn" |
| 27 | LAYER_1 = "visual.blocks.1.attn.attn" |
| 28 | NUM_HEADS = 16 |
| 29 | HEAD_DIM = 72 |
| 30 | |
| 31 | |
| 32 | def _is_mi3xx() -> bool: |
| 33 | if not current_platform.is_rocm(): |
| 34 | return False |
| 35 | |
| 36 | from vllm.platforms.rocm import on_mi3xx |
| 37 | |
| 38 | return on_mi3xx() |
| 39 | |
| 40 | |
| 41 | @contextlib.contextmanager |
| 42 | def _build_attention(mm_config, attn_backend=None): |
| 43 | """Yield an MMEncoderAttention with the given multimodal config. |
| 44 | |
| 45 | The VllmConfig context stays active while the test runs so that |
| 46 | ``get_multimodal_config()`` calls during the forward path resolve. Also |
| 47 | invokes ``process_weights_after_loading`` to simulate the model loader's |
| 48 | auto-scan. Yields ``None`` if FlashInfer cuDNN is not available. |
| 49 | """ |
| 50 | from vllm.config import ModelConfig, VllmConfig, set_current_vllm_config |
| 51 | from vllm.model_executor.layers.attention.mm_encoder_attention import ( |
| 52 | MMEncoderAttention, |
| 53 | ) |
| 54 | from vllm.v1.attention.backends.registry import AttentionBackendEnum |
| 55 | |
| 56 | if attn_backend is None: |
| 57 | attn_backend = AttentionBackendEnum.FLASHINFER |
| 58 | |
| 59 | if ( |
| 60 | attn_backend == AttentionBackendEnum.FLASHINFER |
| 61 | and not is_flashinfer_cudnn_fp8_prefill_attn_supported() |
| 62 | ): |
| 63 | yield None |
| 64 | return |
| 65 | |
| 66 | vllm_config = VllmConfig() |
| 67 | vllm_config.model_config = MagicMock(spec=ModelConfig, multimodal_config=mm_config) |
| 68 | |
| 69 | with ( |
| 70 | set_current_vllm_config(vllm_config), |
| 71 | patch( |
| 72 | "vllm.model_executor.layers.attention.mm_encoder_attention" |
| 73 | ".get_vit_attn_backend", |
| 74 | return_value=attn_backend, |
| 75 | ), |
| 76 | ): |
| 77 | attn = MMEncoderAttention( |
| 78 | num_heads=NUM_HEADS, |
| 79 | head_size=HEAD_DIM, |
| 80 | prefix=LAYER_0, |
| 81 | ) |
| 82 | attn.process_weights_after_loading(torch.bfloat16) |
| 83 | yield attn |
| 84 | |
| 85 | |
| 86 | @pytest.fixture |
| 87 | def _make_attention(): |
| 88 | """Create an MMEncoderAttention with dynamic FP8 scaling.""" |
| 89 | from vllm.config.multimodal import MultiModalConfig |
| 90 | |
| 91 | with _build_attention(MultiModalConfig(mm_encoder_attn_dtype="fp8")) as attn: |
| 92 | yield attn |
| 93 | |
| 94 | |
| 95 | @pytest.fixture |
| 96 | def _make_static_attention(tmp_path): |
| 97 | """Create an MMEncoderAttention with static FP8 scales from a file.""" |
| 98 | from vllm.config.multimodal import MultiModalConfig |
| 99 | |
| 100 | scale_file = tmp_path / "scales.json" |
| 101 | scale_file.write_text( |
| 102 | json.dumps( |
| 103 | { |
| 104 | LAYER_0: {"q": 224.0, "k": 198.0, "v": 210.0}, |
| 105 | LAYER_1: {"q": 100.0, "k": 110.0, "v": 120.0}, |
| 106 | } |
| 107 | ) |
| 108 | ) |
| 109 | with _build_attention( |
| 110 | MultiModalConfig( |
| 111 | mm_encoder_attn_dtype="fp8", |
| 112 | mm_encoder_fp8_scale_path=str(scale_file), |
| 113 | ) |
| 114 | ) as attn: |
| 115 | yield attn |
| 116 | |
| 117 | |
| 118 | def test_dynamic_scaling_updates_scales(_make_attention) -> None: |
| 119 | """Verify that _record_amax_and_update_scales updates scale buffers.""" |
| 120 | attn = _make_attention |
| 121 | if attn is None or not attn.fp8_enabled: |
| 122 | pytest.skip("FP8 attention not available (FlashInfer backend required)") |
| 123 | |
| 124 | attn = attn.to("cuda") |
| 125 | |
| 126 | S, H, D = 32, NUM_HEADS, HEAD_DIM |
| 127 | q = torch.full((S, H, D), 2.0, device="cuda", dtype=torch.bfloat16) |
| 128 | k = torch.full((S, H, D), 3.0, device="cuda", dtype=torch.bfloat16) |
| 129 | v = torch.full((S, H, D), 4.0, device="cuda", dtype=torch.bfloat16) |
| 130 | |
| 131 | attn._record_amax_and_update_scales(q, k, v) |
| 132 | |
| 133 | expected_q_scale = 2.0 / _FP8_MAX |
| 134 | expected_k_scale = 3.0 / _FP8_MAX |
| 135 | expected_v_scale = 4.0 / _FP8_MAX |
| 136 | |
| 137 | torch.testing.assert_close(attn._fp8_q_scale.item(), expected_q_scale) |
| 138 | torch.testing.assert_close(attn._fp8_k_scale.item(), expected_k_scale) |
| 139 | torch.testing.assert_close(attn._fp8_v_scale.item(), expected_v_scale) |
| 140 | |
| 141 | |
| 142 | def test_circular_buffer_wraps(_make_attention) -> None: |
| 143 | """Verify the amax circular buffer wraps at HISTORY_LEN.""" |
| 144 | attn = _make_attention |
| 145 | if attn is None or not attn.fp8_enabled: |
| 146 | pytest.skip("FP8 attention not available (FlashInfer backend required)") |
| 147 | |
| 148 | attn = attn.to("cuda") |
| 149 | S, H, D = 16, NUM_HEADS, HEAD_DIM |
| 150 | |
| 151 | for i in range(_FP8_AMAX_HISTORY_LEN + 2): |
| 152 | mag = float(i + 1) |
| 153 | q = torch.full((S, H, D), mag, device="cuda", dtype=torch.bfloat16) |
| 154 | k = torch.full((S, H, D), mag, device="cuda", dtype=torch.bfloat16) |
| 155 | v = torch.full((S, H, D), mag, device="cuda", dtype=torch.bfloat16) |
| 156 | attn._record_amax_and_update_scales(q, k, v) |
| 157 | |
| 158 | assert attn._fp8_amax_pos == 2 |
| 159 | |
| 160 | expected_max = float(_FP8_AMAX_HISTORY_LEN + 2) |
| 161 | expected_scale = expected_max / _FP8_MAX |
| 162 | torch.testing.assert_close(attn._fp8_q_scale.item(), expected_scale) |
| 163 | |
| 164 | |
| 165 | def test_static_scales_loaded(_make_static_attention) -> None: |
| 166 | """Verify static scales are loaded from the JSON file.""" |
| 167 | attn = _make_static_attention |
| 168 | if attn is None or not attn.fp8_enabled: |
| 169 | pytest.skip("FP8 attention not available (FlashInfer backend required)") |
| 170 | |
| 171 | assert attn.fp8_enabled |
| 172 | assert not attn._fp8_dynamic_scale |
| 173 | |
| 174 | # Layer 0 scales (the layer this attention was created with). |
| 175 | assert attn._fp8_q_scale.item() == 224.0 |
| 176 | assert attn._fp8_k_scale.item() == 198.0 |
| 177 | assert attn._fp8_v_scale.item() == 210.0 |
| 178 | |
| 179 | assert not attn.skip_scale_q |
| 180 | assert not attn.skip_scale_k |
| 181 | assert not attn.skip_scale_v |
| 182 | |
| 183 | # No amax history buffers for static scaling. |
| 184 | assert not hasattr(attn, "_fp8_q_amax") |
| 185 | |
| 186 | |
| 187 | @pytest.mark.skipif( |
| 188 | not _is_mi3xx(), |
| 189 | reason="AITER FP8 attention requires MI300/MI350", |
| 190 | ) |
| 191 | def test_aiter_static_scales_loaded(tmp_path) -> None: |
| 192 | """Verify AITER reuses the existing static FP8 scale loading path.""" |
| 193 | from vllm.config.multimodal import MultiModalConfig |
| 194 | from vllm.v1.attention.backends.registry import AttentionBackendEnum |
| 195 | |
| 196 | scale_file = tmp_path / "aiter_scales.json" |
| 197 | scale_file.write_text(json.dumps({LAYER_0: {"q": 224.0, "k": 198.0, "v": 210.0}})) |
| 198 | mm_config = MultiModalConfig( |
| 199 | mm_encoder_attn_dtype="fp8", |
| 200 | mm_encoder_fp8_scale_path=str(scale_file), |
| 201 | ) |
| 202 | |
| 203 | with _build_attention(mm_config, AttentionBackendEnum.ROCM_AITER_FA) as attn: |
| 204 | assert attn is not None |
| 205 | assert attn.fp8_enabled |
| 206 | assert not attn._fp8_dynamic_scale |
| 207 | assert attn._fp8_q_scale.item() == 224.0 |
| 208 | assert attn._fp8_k_scale.item() == 198.0 |
| 209 | assert attn._fp8_v_scale.item() == 210.0 |
| 210 | |
| 211 | |
| 212 | def test_static_scales_missing_layer(tmp_path) -> None: |
| 213 | """Verify error when requested layer is not in the scale file.""" |
| 214 | from vllm.config import ModelConfig, VllmConfig, set_current_vllm_config |
| 215 | from vllm.config.multimodal import MultiModalConfig |
| 216 | from vllm.v1.attention.backends.registry import AttentionBackendEnum |
| 217 | |
| 218 | if not is_flashinfer_cudnn_fp8_prefill_attn_supported(): |
| 219 | pytest.skip("FlashInfer cuDNN not available") |
| 220 | |
| 221 | scale_file = tmp_path / "wrong_layer.json" |
| 222 | scale_file.write_text( |
| 223 | json.dumps({"visual.blocks.99.attn": {"q": 1.0, "k": 1.0, "v": 1.0}}) |
| 224 | ) |
| 225 | mm_config = MultiModalConfig( |
| 226 | mm_encoder_attn_dtype="fp8", |
| 227 | mm_encoder_fp8_scale_path=str(scale_file), |
| 228 | ) |
| 229 | vllm_config = VllmConfig() |
| 230 | vllm_config.model_config = MagicMock(spec=ModelConfig, multimodal_config=mm_config) |
| 231 | |
| 232 | from vllm.model_executor.layers.attention.mm_encoder_attention import ( |
| 233 | MMEncoderAttention, |
| 234 | ) |
| 235 | |
| 236 | with ( |
| 237 | set_current_vllm_config(vllm_config), |
| 238 | patch( |
| 239 | "vllm.model_executor.layers.attention.mm_encoder_attention" |
| 240 | ".get_vit_attn_backend", |
| 241 | return_value=AttentionBackendEnum.FLASHINFER, |
| 242 | ), |
| 243 | ): |
| 244 | attn = MMEncoderAttention( |
| 245 | num_heads=NUM_HEADS, |
| 246 | head_size=HEAD_DIM, |
| 247 | prefix=LAYER_0, |
| 248 | ) |
| 249 | with pytest.raises(ValueError, match="scales not found for layer"): |
| 250 | attn.process_weights_after_loading(torch.bfloat16) |
| 251 | |
| 252 | |
| 253 | def test_dynamic_scales_auto_save(tmp_path) -> None: |
| 254 | """Verify scales are saved to disk after the amax buffer fills.""" |
| 255 | import vllm.model_executor.layers.attention.mm_encoder_attention as _mod |
| 256 | from vllm.config.multimodal import MultiModalConfig |
| 257 | |
| 258 | if not is_flashinfer_cudnn_fp8_prefill_attn_supported(): |
| 259 | pytest.skip("FlashInfer cuDNN not available") |
| 260 | |
| 261 | # Reset module-level state between runs (other tests may have left |
| 262 | # state behind after triggering a save). |
| 263 | _mod._fp8_scale_save_path = None |
| 264 | _mod._fp8_saved_scale_refs.clear() |
| 265 | |
| 266 | save_file = tmp_path / "auto_scales.json" |
| 267 | with _build_attention( |
| 268 | MultiModalConfig( |
| 269 | mm_encoder_attn_dtype="fp8", |
| 270 | mm_encoder_fp8_scale_save_path=str(save_file), |
| 271 | ) |
| 272 | ) as attn: |
| 273 | if attn is None or not attn.fp8_enabled: |
| 274 | pytest.skip("FP8 attention not available") |
| 275 | |
| 276 | attn = attn.to("cuda") |
| 277 | S, H, D = 16, NUM_HEADS, HEAD_DIM |
| 278 | |
| 279 | # Run exactly _FP8_AMAX_HISTORY_LEN forward passes. |
| 280 | for i in range(_FP8_AMAX_HISTORY_LEN): |
| 281 | mag = float(i + 1) |
| 282 | q = torch.full((S, H, D), mag, device="cuda", dtype=torch.bfloat16) |
| 283 | k = torch.full((S, H, D), mag * 0.5, device="cuda", dtype=torch.bfloat16) |
| 284 | v = torch.full((S, H, D), mag * 0.3, device="cuda", dtype=torch.bfloat16) |
| 285 | attn._record_amax_and_update_scales(q, k, v) |
| 286 | |
| 287 | # File should have been written on the 16th call (buffer wrap). |
| 288 | assert save_file.is_file(), "Scale file was not saved" |
| 289 | scales = json.loads(save_file.read_text()) |
| 290 | assert LAYER_0 in scales |
| 291 | assert set(scales[LAYER_0].keys()) == {"q", "k", "v"} |
| 292 | for val in scales[LAYER_0].values(): |
| 293 | assert isinstance(val, float) and val > 0 |
| 294 | |
| 295 | # Path is cleared after the one-shot save fires. |
| 296 | assert _mod._fp8_scale_save_path is None |