[bugfix] Fix FP8 Quack kernels when using Context Parallelism and reduce number of recompilations (#7458)
Signed-off-by: Maciej Bala <mbala@nvidia.com>
This commit is contained in:
1 parent
c482626450
commit
7d6e2ade6c
3 files changed
+329
-13
No files matched your search
@@ -65,7 +65,17 @@ vLLM-Omni points that cache at `~/.cache/vllm_omni/quack` (override with
|
||||
`QUACK_CACHE_DIR`) instead of quack's default under `/tmp`, so it survives restarts.
|
||||
In containers, set `QUACK_CACHE_DIR` to a mounted/persistent path — or bake it into
|
||||
the image — so the first cold start does not recompile. The engine's startup dummy
|
||||
run already exercises the kernels, so with a warm cache the first real request is fast.
|
||||
run exercises the kernels, but new shapes, layouts, dtypes, or bias settings may
|
||||
still need compilation or tuning. The warmup helper uses inference mode and
|
||||
transposed weights without bias. Daemon workers compile candidates in-process
|
||||
while retaining autotuning and caching.
|
||||
|
||||
Scale validation and Quack/FlashInfer dispatch run inside a PyTorch custom op.
|
||||
This keeps layer-specific scale addresses and validation-cache updates out of
|
||||
Dynamo tracing without introducing a graph break. Unpopulated scales and Quack
|
||||
failures still fall back to FlashInfer at runtime. CUDA graph capture additionally
|
||||
requires warming the dispatch with populated scales; Python validation and
|
||||
dispatch do not rerun during CUDA graph replay.
|
||||
|
||||
To pre-warm specific shapes (e.g. at image build time):
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""CPU regressions for Quack compilation and warmup."""
|
||||
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.quantization import quack_fp8
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_compile_pool_checks_daemon_at_tuning_time(monkeypatch):
|
||||
process = SimpleNamespace(daemon=False)
|
||||
compiler_pool = object()
|
||||
pool = SimpleNamespace(
|
||||
pool_scope=Mock(spec=[], side_effect=lambda: nullcontext(compiler_pool)),
|
||||
suppress_pool=Mock(side_effect=lambda: nullcontext(object())),
|
||||
)
|
||||
original_scope = pool.pool_scope
|
||||
monkeypatch.setattr(quack_fp8, "current_process", lambda: process)
|
||||
monkeypatch.setattr(quack_fp8, "import_module", lambda name: pool)
|
||||
quack_fp8._configure_quack_compilation()
|
||||
with pool.pool_scope() as active_pool:
|
||||
assert active_pool is compiler_pool
|
||||
pool.suppress_pool.assert_not_called()
|
||||
|
||||
# spawn installs the child's daemon flag after importing the worker module.
|
||||
process.daemon = True
|
||||
with pool.pool_scope() as active_pool:
|
||||
assert active_pool is None
|
||||
original_scope.assert_called_once_with()
|
||||
pool.suppress_pool.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quack_dispatch(monkeypatch):
|
||||
"""Exercise the installed patch with CPU substitutes for the GPU kernels."""
|
||||
|
||||
def gemm(a, b, *, out, bias, alpha, tuned):
|
||||
assert isinstance(alpha, torch.Tensor)
|
||||
assert alpha.shape == (1,)
|
||||
assert alpha.dtype == torch.float32
|
||||
assert tuned is True
|
||||
result = (a.float() @ b.float()) * alpha
|
||||
if bias is not None:
|
||||
result += bias
|
||||
out.copy_(result)
|
||||
|
||||
def flashinfer(a, b, *, out_dtype, scale_a, scale_b, bias):
|
||||
out = torch.full((a.shape[0], b.shape[1]), -7, dtype=out_dtype, device=a.device)
|
||||
return out + bias if bias is not None else out
|
||||
|
||||
class FlashInferKernel:
|
||||
def apply_scaled_mm(self, **kwargs):
|
||||
raise AssertionError("The installed custom op should handle dispatch")
|
||||
|
||||
gemm_mock = Mock(side_effect=gemm)
|
||||
fallback_mock = Mock(side_effect=flashinfer)
|
||||
monkeypatch.setattr(quack_fp8, "quack_enabled", lambda: True)
|
||||
monkeypatch.setattr(quack_fp8, "_gemm_interface", SimpleNamespace(gemm=gemm_mock))
|
||||
monkeypatch.setattr(quack_fp8, "_valid_scale_ptrs", set())
|
||||
monkeypatch.setattr(quack_fp8, "logger", Mock())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"vllm.model_executor.kernels.linear.scaled_mm.flashinfer",
|
||||
SimpleNamespace(FlashInferFP8ScaledMMLinearKernel=FlashInferKernel),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "vllm.utils.flashinfer", SimpleNamespace(flashinfer_scaled_fp8_mm=fallback_mock))
|
||||
quack_fp8.install_quack_fp8_patch()
|
||||
kernel = FlashInferKernel()
|
||||
|
||||
def forward(a, b, scale_a, scale_b, bias=None, out_dtype=torch.bfloat16):
|
||||
return kernel.apply_scaled_mm(
|
||||
A=a,
|
||||
B=b,
|
||||
As=scale_a,
|
||||
Bs=scale_b,
|
||||
out_dtype=out_dtype,
|
||||
bias=bias,
|
||||
output_shape=(2, a.shape[0] // 2, b.shape[1]),
|
||||
)
|
||||
|
||||
return SimpleNamespace(forward=forward, gemm=gemm_mock, fallback=fallback_mock)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compile_fullgraph():
|
||||
torch._dynamo.reset()
|
||||
graphs = []
|
||||
|
||||
def backend(graph, example_inputs):
|
||||
graphs.append(graph)
|
||||
return graph.forward
|
||||
|
||||
def compile_fn(fn, **kwargs):
|
||||
return torch.compile(fn, backend=backend, fullgraph=True, **kwargs)
|
||||
|
||||
try:
|
||||
yield compile_fn, graphs
|
||||
finally:
|
||||
torch._dynamo.reset()
|
||||
|
||||
|
||||
def _fp8_inputs(m=4):
|
||||
a = torch.arange(m * 8, dtype=torch.float32).reshape(m, 8).to(torch.float8_e4m3fn)
|
||||
b = torch.ones(6, 8, dtype=torch.float8_e4m3fn).t()
|
||||
return a, b
|
||||
|
||||
|
||||
@pytest.mark.parametrize("out_dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("with_bias", [False, True])
|
||||
def test_scale_validation_does_not_recompile_for_each_layer(quack_dispatch, compile_fullgraph, out_dtype, with_bias):
|
||||
compile_fn, graphs = compile_fullgraph
|
||||
compiled = compile_fn(quack_dispatch.forward)
|
||||
with torch.inference_mode():
|
||||
a, b = _fp8_inputs()
|
||||
bias = torch.arange(6, dtype=out_dtype) if with_bias else None
|
||||
# Keep all pairs alive, as separate model layers do. Both pointer
|
||||
# addresses and scale values differ; tensor metadata stays identical.
|
||||
pairs = [(torch.tensor([float(i + 1)]), torch.tensor([0.5])) for i in range(5)]
|
||||
for _ in range(2):
|
||||
for scale_a, scale_b in pairs:
|
||||
expected = (a.float() @ b.float()) * scale_a * scale_b
|
||||
if bias is not None:
|
||||
expected += bias
|
||||
out = compiled(a, b, scale_a, scale_b, bias, out_dtype)
|
||||
torch.testing.assert_close(out, expected.to(out_dtype).view(2, 2, 6))
|
||||
assert len(graphs) == 1
|
||||
assert any(node.target == torch.ops.vllm_omni.quack_fp8_scaled_mm.default for node in graphs[0].graph.nodes)
|
||||
assert quack_dispatch.gemm.call_count == 10
|
||||
quack_dispatch.fallback.assert_not_called()
|
||||
|
||||
|
||||
def test_compiled_dispatch_rechecks_invalid_scales_after_loading(quack_dispatch, compile_fullgraph):
|
||||
compile_fn, graphs = compile_fullgraph
|
||||
compiled = compile_fn(quack_dispatch.forward)
|
||||
with torch.inference_mode():
|
||||
a, b = _fp8_inputs()
|
||||
pairs = []
|
||||
for bad_scale in (torch.finfo(torch.float32).min, 0.0, -1.0, float("nan"), float("inf")):
|
||||
for scale_index in (0, 1):
|
||||
scales = [torch.ones(1), torch.ones(1)]
|
||||
scales[scale_index].fill_(bad_scale)
|
||||
pairs.append(scales)
|
||||
for scale_a, scale_b in pairs:
|
||||
out = compiled(a, b, scale_a, scale_b)
|
||||
torch.testing.assert_close(out, torch.full((2, 2, 6), -7, dtype=torch.bfloat16))
|
||||
quack_dispatch.gemm.assert_not_called()
|
||||
assert quack_dispatch.fallback.call_count == len(pairs)
|
||||
|
||||
# Populate the same buffers and switch branches without recompiling.
|
||||
for scale_a, scale_b in pairs:
|
||||
scale_a.fill_(0.25)
|
||||
scale_b.fill_(0.5)
|
||||
out = compiled(a, b, scale_a, scale_b)
|
||||
expected = ((a.float() @ b.float()) * 0.125).to(torch.bfloat16)
|
||||
torch.testing.assert_close(out, expected.view(2, 2, 6))
|
||||
assert quack_dispatch.gemm.call_count == len(pairs)
|
||||
assert quack_dispatch.fallback.call_count == len(pairs)
|
||||
assert len(graphs) == 1
|
||||
|
||||
|
||||
def test_compiled_dispatch_falls_back_on_quack_failure(monkeypatch, quack_dispatch, compile_fullgraph):
|
||||
compile_fn, graphs = compile_fullgraph
|
||||
compiled = compile_fn(quack_dispatch.forward)
|
||||
with torch.inference_mode():
|
||||
a, b = _fp8_inputs()
|
||||
scale_a, scale_b = torch.ones(1), torch.ones(1)
|
||||
bias = torch.arange(6, dtype=torch.float32)
|
||||
expected = (torch.full((2, 2, 6), -7, dtype=torch.bfloat16) + bias).to(torch.bfloat16)
|
||||
compiled(a, b, scale_a, scale_b, bias)
|
||||
quack_dispatch.fallback.assert_not_called()
|
||||
|
||||
quack_dispatch.gemm.side_effect = RuntimeError("kernel failed")
|
||||
out = compiled(a, b, scale_a, scale_b, bias)
|
||||
torch.testing.assert_close(out, expected)
|
||||
quack_fp8.logger.warning_once.assert_called_once()
|
||||
|
||||
# The helper can also return None when Quack is unavailable.
|
||||
monkeypatch.setattr(quack_fp8, "_gemm_interface", False)
|
||||
out = compiled(a, b, scale_a, scale_b, bias)
|
||||
torch.testing.assert_close(out, expected)
|
||||
assert quack_dispatch.fallback.call_count == 2
|
||||
assert len(graphs) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale_index", [0, 1])
|
||||
def test_compiled_dispatch_falls_back_for_non_scalar_scales(quack_dispatch, compile_fullgraph, scale_index):
|
||||
compile_fn, graphs = compile_fullgraph
|
||||
compiled = compile_fn(quack_dispatch.forward)
|
||||
with torch.inference_mode():
|
||||
a, b = _fp8_inputs()
|
||||
scales = [torch.ones(1), torch.ones(1)]
|
||||
scales[scale_index] = torch.ones(2)
|
||||
out = compiled(a, b, *scales)
|
||||
torch.testing.assert_close(out, torch.full((2, 2, 6), -7, dtype=torch.bfloat16))
|
||||
quack_dispatch.gemm.assert_not_called()
|
||||
quack_dispatch.fallback.assert_called_once()
|
||||
assert len(graphs) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid_scales", [False, True])
|
||||
@pytest.mark.parametrize("with_bias", [False, True])
|
||||
def test_dispatch_custom_op_contract(quack_dispatch, valid_scales, with_bias):
|
||||
a, b = _fp8_inputs()
|
||||
# opcheck's CPU mutation checker uses allclose, which does not support FP8.
|
||||
# The compile tests above exercise real FP8 inputs with these same mocks.
|
||||
a, b = a.float(), b.float()
|
||||
scale_a = torch.tensor([0.25 if valid_scales else torch.finfo(torch.float32).min])
|
||||
scale_b = torch.tensor([0.5])
|
||||
bias = torch.arange(6, dtype=torch.float32) if with_bias else None
|
||||
torch.library.opcheck(quack_fp8._quack_fp8_scaled_mm, (a, b, scale_a, scale_b, torch.bfloat16, bias))
|
||||
|
||||
|
||||
def test_warmup_matches_inference_weight_layout(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def gemm(a, b, *, out, bias, alpha, tuned):
|
||||
assert torch.is_inference_mode_enabled()
|
||||
assert a.dtype == b.dtype == torch.float8_e4m3fn
|
||||
assert a.is_contiguous()
|
||||
assert b.stride() == (1, b.shape[0])
|
||||
assert bias is None
|
||||
assert tuned is True
|
||||
calls.append((a.shape[0], a.shape[1], b.shape[1]))
|
||||
out.zero_()
|
||||
|
||||
monkeypatch.setattr(quack_fp8, "_gemm_interface", SimpleNamespace(gemm=gemm))
|
||||
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
|
||||
shapes = [(4, 8, 6), (2, 16, 8)]
|
||||
quack_fp8.warmup_quack_fp8(shapes, device="cpu")
|
||||
assert calls == shapes
|
||||
@@ -5,6 +5,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from importlib import import_module
|
||||
from multiprocessing import current_process
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
@@ -52,6 +55,41 @@ def _set_persistent_cache_dir() -> None:
|
||||
os.environ["QUACK_CACHE_DIR"] = os.path.join(root, "vllm_omni", "quack")
|
||||
|
||||
|
||||
def _configure_quack_compilation() -> None:
|
||||
"""Keep autotuning in daemon workers without starting compiler children."""
|
||||
try:
|
||||
async_compile = import_module("quack.cache.async_compile")
|
||||
except ModuleNotFoundError as exc:
|
||||
# Older Quack releases compile synchronously and have no async pool.
|
||||
if exc.name not in {"quack.cache", "quack.cache.async_compile"}:
|
||||
raise
|
||||
return
|
||||
original_pool_scope = getattr(async_compile, "pool_scope", None)
|
||||
suppress_pool = getattr(async_compile, "suppress_pool", None)
|
||||
if not callable(original_pool_scope) or not callable(suppress_pool):
|
||||
logger.warning(
|
||||
"Quack async compilation API is unsupported: pool_scope and suppress_pool must be callable. "
|
||||
"Skipping the compilation patch; autotuning in daemon workers may fail."
|
||||
)
|
||||
return
|
||||
if getattr(original_pool_scope, "_omni_daemon_safe", False):
|
||||
return
|
||||
|
||||
@contextmanager
|
||||
def daemon_safe_pool_scope():
|
||||
# spawn can import this module before installing the child's daemon flag.
|
||||
# Check at tuning time; suppress_pool keeps compilation in-process.
|
||||
if current_process().daemon:
|
||||
with suppress_pool():
|
||||
yield None
|
||||
else:
|
||||
with original_pool_scope() as pool:
|
||||
yield pool
|
||||
|
||||
daemon_safe_pool_scope._omni_daemon_safe = True
|
||||
async_compile.pool_scope = daemon_safe_pool_scope
|
||||
|
||||
|
||||
def _load_quack():
|
||||
global _gemm_interface
|
||||
if _gemm_interface is not None:
|
||||
@@ -72,6 +110,7 @@ def _load_quack():
|
||||
torch2cute_dtype_map.setdefault(torch.float8_e4m3fn, cutlass.Float8E4M3FN)
|
||||
torch2cute_dtype_map.setdefault(torch.float8_e5m2, cutlass.Float8E5M2)
|
||||
|
||||
_configure_quack_compilation()
|
||||
_gemm_interface = gemm_interface
|
||||
logger.info("Quack FP8 fused-bias GEMM enabled (CuteDSL).")
|
||||
return gemm_interface
|
||||
@@ -109,6 +148,9 @@ def _scales_valid(scale_a: torch.Tensor, scale_b: torch.Tensor) -> bool:
|
||||
make ``alpha = scale_a * scale_b`` overflow to ``+inf`` and return an all-inf tile.
|
||||
Cache only positive results: a buffer still holding the sentinel is re-checked and
|
||||
picks up the fast path once the real scale is written.
|
||||
|
||||
Called inside the dispatch custom op, so pointer checks and cache updates
|
||||
neither specialize Dynamo graphs on each layer's addresses nor break them.
|
||||
"""
|
||||
key = (scale_a.data_ptr(), scale_b.data_ptr())
|
||||
if key in _valid_scale_ptrs:
|
||||
@@ -121,6 +163,40 @@ def _scales_valid(scale_a: torch.Tensor, scale_b: torch.Tensor) -> bool:
|
||||
return ok
|
||||
|
||||
|
||||
@torch.library.custom_op("vllm_omni::quack_fp8_scaled_mm", mutates_args=())
|
||||
def _quack_fp8_scaled_mm(
|
||||
a: torch.Tensor,
|
||||
b: torch.Tensor,
|
||||
scale_a: torch.Tensor,
|
||||
scale_b: torch.Tensor,
|
||||
out_dtype: torch.dtype,
|
||||
bias: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Keep scale validation and runtime fallback inside one opaque graph node."""
|
||||
if scale_a.numel() == 1 and scale_b.numel() == 1 and _scales_valid(scale_a, scale_b):
|
||||
try:
|
||||
out = quack_scaled_fp8_mm(a, b, scale_a, scale_b, out_dtype, bias)
|
||||
if out is not None:
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning_once("Quack FP8 GEMM failed (%s); using FlashInfer.", exc)
|
||||
|
||||
# Use the same fallback as FlashInferFP8ScaledMMLinearKernel, without
|
||||
# passing its Python instance through the custom-op schema. Unpopulated
|
||||
# scales also take this path: FlashInfer tolerates the profiling sentinel.
|
||||
from vllm.utils.flashinfer import flashinfer_scaled_fp8_mm
|
||||
|
||||
out = flashinfer_scaled_fp8_mm(a, b, out_dtype=out_dtype, scale_a=scale_a, scale_b=scale_b, bias=bias)
|
||||
# FlashInfer's separate bias add may promote the dtype. Both branches must
|
||||
# match the fake kernel's output metadata, including with an FP32 bias.
|
||||
return out.to(out_dtype)
|
||||
|
||||
|
||||
@_quack_fp8_scaled_mm.register_fake
|
||||
def _quack_fp8_scaled_mm_fake(a, b, scale_a, scale_b, out_dtype, bias=None):
|
||||
return torch.empty(a.shape[0], b.shape[1], device=a.device, dtype=out_dtype)
|
||||
|
||||
|
||||
def install_quack_fp8_patch() -> None:
|
||||
if not quack_enabled():
|
||||
return
|
||||
@@ -138,34 +214,26 @@ def install_quack_fp8_patch() -> None:
|
||||
return
|
||||
|
||||
def apply_scaled_mm(self, *, A, B, out_dtype, As, Bs, bias, output_shape): # noqa: N803
|
||||
# An unpopulated scale makes alpha overflow to +inf; hand those calls to
|
||||
# FlashInfer, whose bmm_fp8 tolerates them.
|
||||
if As.numel() != 1 or Bs.numel() != 1 or not _scales_valid(As, Bs):
|
||||
return original(self, A=A, B=B, out_dtype=out_dtype, As=As, Bs=Bs, bias=bias, output_shape=output_shape)
|
||||
try:
|
||||
out = quack_scaled_fp8_mm(A, B, As, Bs, out_dtype, bias)
|
||||
if out is not None:
|
||||
return out.view(*output_shape)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning_once("Quack FP8 GEMM failed (%s); using FlashInfer.", exc)
|
||||
return original(self, A=A, B=B, out_dtype=out_dtype, As=As, Bs=Bs, bias=bias, output_shape=output_shape)
|
||||
return _quack_fp8_scaled_mm(A, B, As, Bs, out_dtype, bias).view(*output_shape)
|
||||
|
||||
apply_scaled_mm._omni_quack_patched = True
|
||||
FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm = apply_scaled_mm
|
||||
logger.info("Patched FlashInfer FP8 ScaledMM to use quack fused-bias GEMM.")
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def warmup_quack_fp8(
|
||||
shapes: list[tuple[int, int, int]],
|
||||
device: str = "cuda",
|
||||
out_dtype: torch.dtype = torch.bfloat16,
|
||||
) -> None:
|
||||
"""Warm no-bias GEMMs with the transposed weight layout used by vLLM."""
|
||||
if _load_quack() is None:
|
||||
return
|
||||
scale = torch.ones(1, device=device, dtype=torch.float32)
|
||||
for m, k, n in shapes:
|
||||
a = torch.zeros(m, k, device=device, dtype=torch.float8_e4m3fn)
|
||||
b = torch.zeros(k, n, device=device, dtype=torch.float8_e4m3fn)
|
||||
b = torch.zeros(n, k, device=device, dtype=torch.float8_e4m3fn).t()
|
||||
quack_scaled_fp8_mm(a, b, scale, scale, out_dtype)
|
||||
if torch.cuda.is_available():
|
||||
torch.accelerator.synchronize()
|
||||
Reference in new issue
Block a user