208 lines
8.0 KiB
Python
208 lines
8.0 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Tests for MLA prefill backend fused-quant-output support.
|
|
|
|
Covers two things:
|
|
* `MLAPrefillBackend.supports_quant_output`, the capability gate that decides
|
|
whether the prefill kernel writes quantized output directly (FA4 native
|
|
fused FP8, see flash-attention#135) instead of the post-quant path.
|
|
* The numerical equivalence of that fused FP8 write versus the bf16-attention
|
|
+ standalone static-FP8-quant path it replaces (GPU-only, SM100/SM110).
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.model_executor.layers.quantization.utils.quant_utils import (
|
|
kFp8Dynamic128Sym,
|
|
kFp8StaticTensorSym,
|
|
kNvfp4Dynamic,
|
|
)
|
|
from vllm.platforms.interface import DeviceCapability
|
|
from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend
|
|
from vllm.v1.attention.backends.mla.prefill.flash_attn import (
|
|
FlashAttnPrefillBackend,
|
|
)
|
|
|
|
_FA_MODULE = "vllm.v1.attention.backends.mla.prefill.flash_attn"
|
|
|
|
|
|
class _DummyPrefillBackend(MLAPrefillBackend):
|
|
"""Concrete backend that does NOT override supports_quant_output."""
|
|
|
|
@staticmethod
|
|
def get_name() -> str:
|
|
return "DUMMY"
|
|
|
|
def run_prefill_new_tokens(self, *args, **kwargs): # pragma: no cover
|
|
raise NotImplementedError
|
|
|
|
def run_prefill_context_chunk(self, *args, **kwargs): # pragma: no cover
|
|
raise NotImplementedError
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"quant_key", [kFp8StaticTensorSym, kFp8Dynamic128Sym, kNvfp4Dynamic, None]
|
|
)
|
|
def test_base_backend_never_supports_quant_output(quant_key):
|
|
"""The base default opts every backend out unless it overrides."""
|
|
backend = object.__new__(_DummyPrefillBackend)
|
|
assert backend.supports_quant_output(quant_key) is False
|
|
|
|
|
|
def _make_fa_backend(version: int | None, is_vllm_fa: bool):
|
|
"""Build a FlashAttnPrefillBackend without running its heavy __init__."""
|
|
backend = object.__new__(FlashAttnPrefillBackend)
|
|
backend.vllm_flash_attn_version = version
|
|
backend._is_vllm_fa = is_vllm_fa
|
|
return backend
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("version", "is_vllm_fa", "dc_major", "quant_key", "expected"),
|
|
[
|
|
# FA4 + vLLM-FA + Blackwell SM100/SM110 + static FP8 -> fused.
|
|
(4, True, 10, kFp8StaticTensorSym, True),
|
|
(4, True, 11, kFp8StaticTensorSym, True),
|
|
# Wrong compute capability (SM90 / SM120) -> not supported (#135).
|
|
(4, True, 9, kFp8StaticTensorSym, False),
|
|
(4, True, 12, kFp8StaticTensorSym, False),
|
|
# Not FA4.
|
|
(3, True, 10, kFp8StaticTensorSym, False),
|
|
(2, True, 10, kFp8StaticTensorSym, False),
|
|
(None, True, 10, kFp8StaticTensorSym, False),
|
|
# Upstream (ROCm) flash-attn, not vLLM-FA.
|
|
(4, False, 10, kFp8StaticTensorSym, False),
|
|
# Quant keys not wired through FA4 yet.
|
|
(4, True, 10, kFp8Dynamic128Sym, False),
|
|
(4, True, 10, kNvfp4Dynamic, False),
|
|
],
|
|
)
|
|
def test_flash_attn_supports_quant_output(
|
|
version, is_vllm_fa, dc_major, quant_key, expected
|
|
):
|
|
backend = _make_fa_backend(version, is_vllm_fa)
|
|
with patch(f"{_FA_MODULE}.current_platform") as plat:
|
|
plat.get_device_capability.return_value = DeviceCapability(
|
|
major=dc_major, minor=0
|
|
)
|
|
assert backend.supports_quant_output(quant_key) is expected
|
|
|
|
|
|
def test_flash_attn_supports_quant_output_unknown_device():
|
|
"""A None device capability (e.g. capability probe failed) is safe."""
|
|
backend = _make_fa_backend(version=4, is_vllm_fa=True)
|
|
with patch(f"{_FA_MODULE}.current_platform") as plat:
|
|
plat.get_device_capability.return_value = None
|
|
assert backend.supports_quant_output(kFp8StaticTensorSym) is False
|
|
|
|
|
|
def test_flash_attn_prefill_backend_signature_accepts_fused_kwargs():
|
|
"""run_prefill_new_tokens must accept out/output_scale so the direct
|
|
(non-**kwargs) call in forward_mha type- and runtime-checks."""
|
|
import inspect
|
|
|
|
params = inspect.signature(
|
|
FlashAttnPrefillBackend.run_prefill_new_tokens
|
|
).parameters
|
|
assert "out" in params
|
|
assert "output_scale" in params
|
|
# The base contract must expose them too (Liskov / direct call site).
|
|
base_params = inspect.signature(MLAPrefillBackend.run_prefill_new_tokens).parameters
|
|
assert "out" in base_params
|
|
assert "output_scale" in base_params
|
|
|
|
|
|
def test_mla_impl_forward_mha_accepts_output_scale():
|
|
"""The abstract MLA impl forward_mha must carry output_scale so every
|
|
override (and the unconditional forward_impl call) stays compatible."""
|
|
import inspect
|
|
|
|
from vllm.v1.attention.backend import MLAAttentionImpl
|
|
|
|
params = inspect.signature(MLAAttentionImpl.forward_mha).parameters
|
|
assert "output_scale" in params
|
|
assert params["output_scale"].default is None
|
|
|
|
|
|
def _fused_fp8_skip_reason() -> str | None:
|
|
"""FA4 fused FP8 output needs a real Blackwell SM100/SM110 GPU."""
|
|
if not torch.cuda.is_available():
|
|
return "requires CUDA"
|
|
major = torch.cuda.get_device_capability()[0]
|
|
if major not in (10, 11):
|
|
return f"FA4 fused FP8 output requires SM100/SM110, got SM{major}x"
|
|
return None
|
|
|
|
|
|
_FUSED_FP8_SKIP = _fused_fp8_skip_reason()
|
|
|
|
|
|
@pytest.mark.skipif(_FUSED_FP8_SKIP is not None, reason=_FUSED_FP8_SKIP or "")
|
|
def test_fa4_fused_fp8_output_matches_post_quant(default_vllm_config):
|
|
"""FA4's fused FP8 write (output_scale, flash-attention#135) must match the
|
|
bf16-attention + standalone static-FP8-quant path it replaces, since
|
|
production uses the same output_scale for both."""
|
|
from vllm.model_executor.layers.quantization.input_quant_fp8 import QuantFP8
|
|
from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape
|
|
from vllm.platforms import current_platform
|
|
from vllm.vllm_flash_attn import flash_attn_varlen_func
|
|
|
|
torch.manual_seed(0)
|
|
device = torch.device("cuda")
|
|
fp8_dtype = current_platform.fp8_dtype()
|
|
|
|
# MLA prefill head dims (post kv_b_proj): q/k = qk_nope(128)+qk_rope(64),
|
|
# v = v_head_dim(128); DeepSeek-V2-Lite has 16 query heads.
|
|
num_heads, qk_head_dim, v_head_dim, seqlen = 16, 192, 128, 512
|
|
cu_seqlens = torch.tensor([0, seqlen], dtype=torch.int32, device=device)
|
|
q = torch.randn(seqlen, num_heads, qk_head_dim, dtype=torch.bfloat16, device=device)
|
|
k = torch.randn(seqlen, num_heads, qk_head_dim, dtype=torch.bfloat16, device=device)
|
|
v = torch.randn(seqlen, num_heads, v_head_dim, dtype=torch.bfloat16, device=device)
|
|
|
|
fa_kwargs = dict(
|
|
cu_seqlens_q=cu_seqlens,
|
|
cu_seqlens_k=cu_seqlens,
|
|
max_seqlen_q=seqlen,
|
|
max_seqlen_k=seqlen,
|
|
causal=True,
|
|
fa_version=4,
|
|
)
|
|
|
|
# Reference: bf16 attention, then standalone static per-tensor FP8 quant.
|
|
out_bf16 = flash_attn_varlen_func(q=q, k=k, v=v, **fa_kwargs)
|
|
out_2d = out_bf16.reshape(seqlen, num_heads * v_head_dim)
|
|
# Scale the amax near e4m3 max so the check uses the representable range.
|
|
finfo = torch.finfo(fp8_dtype)
|
|
scale = (out_2d.abs().max() / finfo.max).to(torch.float32).reshape(1)
|
|
quant_op = QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR)
|
|
ref_fp8, _ = quant_op(out_2d, scale)
|
|
|
|
# Feature: FA4 writes e4m3 into the (tokens, heads*dim) buffer directly.
|
|
fused_fp8 = torch.empty(
|
|
seqlen, num_heads * v_head_dim, dtype=fp8_dtype, device=device
|
|
)
|
|
flash_attn_varlen_func(
|
|
q=q,
|
|
k=k,
|
|
v=v,
|
|
out=fused_fp8.view(seqlen, num_heads, v_head_dim),
|
|
output_scale=scale,
|
|
**fa_kwargs,
|
|
)
|
|
|
|
# Non-degenerate (catches a no-op / all-zero write).
|
|
assert torch.isfinite(fused_fp8.float()).all()
|
|
assert fused_fp8.float().abs().any()
|
|
|
|
# e4m3 has 3 mantissa bits, so allow ~1 mantissa step of rounding slack.
|
|
ref = ref_fp8.float() * scale
|
|
got = fused_fp8.float() * scale
|
|
torch.testing.assert_close(got, ref, rtol=0.125, atol=float(scale) * 2)
|
|
|
|
# ...and most elements land in the exact same fp8 bucket.
|
|
exact = (fused_fp8.view(torch.uint8) == ref_fp8.view(torch.uint8)).float().mean()
|
|
assert exact > 0.9, f"only {exact:.1%} of fused FP8 outputs matched the baseline"
|