Skip to content

Commit ab6dd3e

Browse files
committed
Add FP8 BMM2 attention recipe with per-tensor FP8 P and V
Signed-off-by: Kai Xu <kaix@nvidia.com>
1 parent cdba045 commit ab6dd3e

10 files changed

Lines changed: 210 additions & 22 deletions

File tree

examples/vllm_serve/sparse_attn_worker.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
install_vllm_sparse_attention_from_checkpoint,
2323
)
2424

25-
__all__ = ["SparseAttnWorker", "QuantSparseAttnWorker"] # noqa: RUF022
25+
__all__ = ["SparseAttnWorker", "QuantSparseAttnWorker", "QuantSparseAttnFp8Bmm2Worker"] # noqa: RUF022
2626

2727

2828
def _unwrapped_model(worker):
@@ -32,7 +32,7 @@ def _unwrapped_model(worker):
3232

3333
def _print_install_report(policy, report) -> None:
3434
if report.installed_count:
35-
if policy == "NVFP4 attention":
35+
if "NVFP4" in policy:
3636
print(
3737
"[ModelOpt] Installed NVFP4 quant+sparse attention on "
3838
f"{report.installed_count} layers: {dict(report.backend_counts)}"
@@ -85,3 +85,15 @@ def determine_available_memory(self) -> int:
8585

8686
with torch.inference_mode(), disable_compilation(_unwrapped_model(self)):
8787
return BaseWorker.determine_available_memory(self)
88+
89+
90+
class QuantSparseAttnFp8Bmm2Worker(QuantSparseAttnWorker):
91+
"""NVFP4 BMM1 (Q/K) with per-tensor FP8 E4M3 BMM2 (P/V) plus optional sparsity."""
92+
93+
def load_model(self, *args, **kwargs) -> None:
94+
"""Load the model, then install NVFP4-BMM1 + FP8-BMM2 attention."""
95+
BaseWorker.load_model(self, *args, **kwargs)
96+
report = install_vllm_nvfp4_attention(
97+
self.model_runner, sparse_cfg="checkpoint", bmm2="fp8"
98+
)
99+
_print_install_report("NVFP4-BMM1/FP8-BMM2 attention", report)

modelopt/torch/kernels/common/attention/decode_attention.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@
3232
_load_paged_v_tile,
3333
)
3434
from modelopt.torch.kernels.quantization.attention.bmm2_qdq import _p_qdq_nvfp4, _v_qdq_nvfp4
35+
from modelopt.torch.kernels.quantization.common.fp8_quant import fp8_scalar_qdq
3536

3637
__all__ = ["attention_decode"]
3738

3839
_BLOCK_N = 128
3940
_DEFAULT_KV_SPLITS = 32
4041
_MAX_KV_SPLITS = 32
41-
_QDQ_MODES = {None, "nvfp4"}
42+
_P_QDQ_MODES = {None: 0, "fp8": 1, "nvfp4": 2}
43+
_V_QDQ_MODES = {None, "nvfp4"}
4244

4345

4446
@triton.jit
@@ -137,7 +139,11 @@ def _decode_split_kernel(
137139
running_sum = running_sum * correction + tl.sum(p, axis=0)
138140
acc *= correction
139141

140-
if P_QDQ:
142+
if P_QDQ == 1:
143+
# FP8 E4M3 per-tensor QDQ of the split-local unnormalized P
144+
# (elementwise -> split-count- and batch-shape-invariant).
145+
p = fp8_scalar_qdq(p, p_qdq_scale)
146+
elif P_QDQ == 2:
141147
p = tl.reshape(
142148
_p_qdq_nvfp4(
143149
tl.reshape(p.to(V_cache.dtype.element_ty).to(tl.float32), (1, BLOCK_N)),
@@ -238,15 +244,20 @@ def _decode_combine_kernel(
238244

239245

240246
def _qdq_scale(mode: str | None, amax: float | None, operand: str) -> float:
241-
if mode not in _QDQ_MODES:
242-
raise ValueError(f"{operand}_qdq must be 'nvfp4' or None, got {mode!r}")
247+
allowed = _P_QDQ_MODES if operand == "p" else _V_QDQ_MODES
248+
if mode not in allowed:
249+
raise ValueError(
250+
f"{operand}_qdq must be one of {sorted(m for m in allowed if m)} or None, got {mode!r}"
251+
)
243252
if mode is None:
244253
return 1.0
245254
if amax is None:
246255
return 1.0
247256
if not (math.isfinite(amax) and amax > 0.0):
248257
raise ValueError(f"{operand}_qdq_amax must be finite and positive, got {amax}")
249-
return amax / (6.0 * 448.0)
258+
# FP8 uses the per-tensor convention ``amax / 448``; NVFP4 the two-level
259+
# global scale ``amax / (6 * 448)`` (matches the prefill kernel).
260+
return amax / 448.0 if mode == "fp8" else amax / (6.0 * 448.0)
250261

251262

252263
def attention_decode(
@@ -337,7 +348,7 @@ def attention_decode(
337348
PAGE_SIZE=page_size,
338349
max_blocks_per_seq=block_table.shape[1],
339350
NUM_KV_SPLITS=num_kv_splits,
340-
P_QDQ=p_qdq == "nvfp4",
351+
P_QDQ=_P_QDQ_MODES[p_qdq],
341352
V_QDQ=v_qdq == "nvfp4",
342353
V_CACHE_QUANTIZED=v_cache_quantized,
343354
num_warps=4,

modelopt/torch/kernels/common/attention/triton_fa.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,9 @@ def forward(
989989
"IS_CAUSAL": is_causal,
990990
"HEAD_DIM": HEAD_DIM,
991991
"STORE_LSE": True,
992-
"Q_IS_FP32": q.dtype == torch.float32 and (p_qdq_mode == 2 or v_qdq_mode == 2),
992+
# An fp32 Q (the dynamic-quant carrier) always needs the fp32 BMM1 dot;
993+
# gating on QDQ modes would miss recipes like fp8-BMM2 (P mode 1).
994+
"Q_IS_FP32": q.dtype == torch.float32,
993995
"SPARSITY_N": sparsity_n,
994996
"SPARSITY_M": sparsity_m,
995997
"DENSE_SINK_TOKENS": dense_sink_tokens,

modelopt/torch/quantization/plugins/vllm.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@
4747
for name in ("q", "k", "p", "v")
4848
),
4949
]
50+
_FP8_ATTENTION_QUANTIZER_CFG = {"num_bits": (4, 3)} # per-tensor E4M3, static scale amax/448
51+
# Hybrid recipe: NVFP4 BMM1 (Q/K) + per-tensor FP8 E4M3 BMM2 (P/V).
52+
_VLLM_NVFP4_BMM1_FP8_BMM2_QUANT_CFG = [
53+
{"quantizer_name": "*_bmm_quantizer", "enable": False},
54+
*(
55+
{
56+
"quantizer_name": f"*{name}_bmm_quantizer",
57+
"cfg": _NVFP4_ATTENTION_QUANTIZER_CFG,
58+
"enable": True,
59+
}
60+
for name in ("q", "k")
61+
),
62+
*(
63+
{
64+
"quantizer_name": f"*{name}_bmm_quantizer",
65+
"cfg": _FP8_ATTENTION_QUANTIZER_CFG,
66+
"enable": True,
67+
}
68+
for name in ("p", "v")
69+
),
70+
]
5071

5172

5273
def _import_attention_module():
@@ -224,11 +245,27 @@ def _set_vllm_attention_kv_default_amax(module, device: torch.device) -> None:
224245
quantizer.amax = torch.tensor(6.0 * 448.0, device=device, dtype=torch.float32)
225246

226247

248+
def _set_vllm_attention_fp8_bmm2_default_amax(module, device: torch.device) -> None:
249+
"""Set fixed amax on uncalibrated per-tensor FP8 BMM2 quantizers (P=1.0, V=448)."""
250+
for name, default in (("p_bmm_quantizer", 1.0), ("v_bmm_quantizer", 448.0)):
251+
quantizer = getattr(module, name, None)
252+
if (
253+
not isinstance(quantizer, TensorQuantizer)
254+
or not quantizer.is_enabled
255+
or getattr(quantizer, "num_bits", None) != (4, 3)
256+
or getattr(quantizer, "block_sizes", None)
257+
or hasattr(quantizer, "_amax")
258+
):
259+
continue
260+
quantizer.amax = torch.tensor(default, device=device, dtype=torch.float32)
261+
262+
227263
def configure_vllm_nvfp4_attention_quantizers(
228264
module: torch.nn.Module,
229265
*,
230266
device: torch.device | str,
231267
dtype: torch.dtype,
268+
cfg: list | None = None,
232269
) -> torch.nn.Module:
233270
"""Configure one vLLM Attention module for fused NVFP4 fake quantization.
234271
@@ -256,10 +293,11 @@ def configure_vllm_nvfp4_attention_quantizers(
256293
if not hasattr(module, "p_bmm_quantizer"):
257294
module.p_bmm_quantizer = TensorQuantizer()
258295

259-
set_quantizer_by_cfg(module, _VLLM_NVFP4_ATTENTION_QUANT_CFG)
296+
set_quantizer_by_cfg(module, _VLLM_NVFP4_ATTENTION_QUANT_CFG if cfg is None else cfg)
260297
for name in ("q", "k", "p", "v"):
261298
getattr(module, f"{name}_bmm_quantizer").to(device=device)
262299
_set_vllm_attention_kv_default_amax(module, device)
300+
_set_vllm_attention_fp8_bmm2_default_amax(module, device)
263301
return module
264302

265303

modelopt/torch/sparsity/attention_sparsity/plugins/vllm.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,23 +121,31 @@ def _build_sparse_kw(layer_cfg: dict) -> dict:
121121

122122

123123
def _bmm_qdq_from_layer(layer, attr: str, default_amax: float | None):
124-
"""Map an enabled BMM2 quantizer to the kernel's NVFP4 mode and scalar amax."""
124+
"""Map an enabled BMM2 quantizer to the kernel's QDQ mode and scalar amax."""
125125
quantizer = getattr(layer, attr, None)
126126
if quantizer is None or not getattr(quantizer, "is_enabled", False):
127127
return None, default_amax
128128
if (
129-
not getattr(quantizer, "is_nvfp4_dynamic", False)
130-
or (quantizer.block_sizes or {}).get(-1) != 16
129+
getattr(quantizer, "is_nvfp4_dynamic", False)
130+
and (quantizer.block_sizes or {}).get(-1) == 16
131131
):
132+
mode = "nvfp4"
133+
elif getattr(quantizer, "num_bits", None) == (4, 3) and not getattr(
134+
quantizer, "block_sizes", None
135+
):
136+
# Per-tensor FP8 E4M3 (static scale amax/448)
137+
mode = "fp8"
138+
else:
132139
raise NotImplementedError(
133-
f"{attr} is enabled with an unsupported format; only dynamic block-16 NVFP4 is supported"
140+
f"{attr} is enabled with an unsupported format; only dynamic block-16 NVFP4 "
141+
"or per-tensor FP8 E4M3 is supported"
134142
)
135143
amax = getattr(quantizer, "_amax", None)
136144
if amax is None:
137-
return "nvfp4", default_amax
145+
return mode, default_amax
138146
if getattr(amax, "numel", lambda: 1)() != 1:
139147
raise NotImplementedError(f"{attr} requires a scalar amax, got shape {tuple(amax.shape)}")
140-
return "nvfp4", float(amax)
148+
return mode, float(amax)
141149

142150

143151
def _p_qdq_from_layer(layer) -> tuple[str | None, float]:

modelopt/torch/sparsity/attention_sparsity/plugins/vllm_runtime.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class _InstallPlan:
9494
layers: tuple[_AttentionPlan, ...]
9595
quantize: bool
9696
sparse_algorithm: str | None
97+
bmm2: str = "nvfp4"
9798

9899

99100
def _unwrapped_model(model_runner):
@@ -315,7 +316,9 @@ def _raise_unsupported(errors: list[str], policy: str) -> None:
315316
)
316317

317318

318-
def _plan_vllm_attention(model_runner, *, quantize: bool, sparse_cfg) -> _InstallPlan:
319+
def _plan_vllm_attention(
320+
model_runner, *, quantize: bool, sparse_cfg, bmm2: str = "nvfp4"
321+
) -> _InstallPlan:
319322
model = _unwrapped_model(model_runner)
320323
resolved_sparse_cfg, sparse_algorithm = _resolve_sparse_config(model_runner, sparse_cfg)
321324
candidates = []
@@ -329,7 +332,7 @@ def _plan_vllm_attention(model_runner, *, quantize: bool, sparse_cfg) -> _Instal
329332
candidates.append((name, module, sparse_kw))
330333

331334
if not candidates and not quantize:
332-
return _InstallPlan(model_runner, (), False, sparse_algorithm)
335+
return _InstallPlan(model_runner, (), False, sparse_algorithm, bmm2)
333336

334337
_require_supported_vllm()
335338
errors = _global_errors(model_runner) if quantize else []
@@ -373,7 +376,7 @@ def _plan_vllm_attention(model_runner, *, quantize: bool, sparse_cfg) -> _Instal
373376
if quantize and attention_count == 0:
374377
errors.append("no regular attention layers were found")
375378
_raise_unsupported(errors, "NVFP4 attention" if quantize else "sparse attention")
376-
return _InstallPlan(model_runner, tuple(plans), quantize, sparse_algorithm)
379+
return _InstallPlan(model_runner, tuple(plans), quantize, sparse_algorithm, bmm2)
377380

378381

379382
def _build_report(plan: _InstallPlan) -> VllmAttentionInstallReport:
@@ -405,15 +408,28 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor
405408
for layer in plan.layers:
406409
layer.new_impl.sparse_kw = layer.sparse_kw
407410
if plan.quantize:
411+
# Pass cfg only for the non-default recipe: keeps the default call
412+
# signature stable for callers/fakes that predate the cfg parameter.
413+
_cfg_kwargs = (
414+
{"cfg": quant_plugin._VLLM_NVFP4_BMM1_FP8_BMM2_QUANT_CFG}
415+
if plan.bmm2 == "fp8"
416+
else {}
417+
)
408418
converted = quant_plugin.configure_vllm_nvfp4_attention_quantizers(
409419
layer.module,
410420
device=layer.device,
411421
dtype=layer.dtype,
422+
**_cfg_kwargs,
412423
)
413424
if converted is not None and converted is not layer.module:
414425
raise RuntimeError("vLLM attention quantization must convert modules in place")
415426
p_qdq, p_qdq_amax = attention_plugin._p_qdq_from_layer(layer.module)
416427
v_qdq, v_qdq_amax = attention_plugin._v_qdq_from_layer(layer.module)
428+
if plan.bmm2 == "fp8":
429+
# Per-tensor FP8 V is quantized module-level BEFORE the cache
430+
# write (each token is self-contained; no block geometry), so
431+
# the kernel sees pre-QDQ'd V and needs no V transform at all.
432+
v_qdq, v_qdq_amax = None, None
417433
layer.new_impl.quant_kw = {
418434
"p_qdq": p_qdq,
419435
"p_qdq_amax": p_qdq_amax,
@@ -426,7 +442,7 @@ def _apply_vllm_attention_plans(plan: _InstallPlan) -> VllmAttentionInstallRepor
426442
old_value_flag = getattr(layer.module, "_value_quant_in_kernel", missing)
427443
if plan.quantize:
428444
layer.module._query_quant_in_kernel = True
429-
layer.module._value_quant_in_kernel = True
445+
layer.module._value_quant_in_kernel = plan.bmm2 != "fp8"
430446
try:
431447
# Publish the adapter last so a native impl never runs with in-kernel
432448
# quantization flags that only the ModelOpt adapter understands.
@@ -463,6 +479,7 @@ def install_vllm_nvfp4_attention(
463479
model_runner,
464480
*,
465481
sparse_cfg="checkpoint",
482+
bmm2: str = "nvfp4",
466483
) -> VllmAttentionInstallReport:
467484
"""Install fixed NVFP4 attention with optional checkpoint sparsity.
468485
@@ -471,6 +488,8 @@ def install_vllm_nvfp4_attention(
471488
sparse_cfg: ``"checkpoint"`` to consume optional exported metadata, a
472489
resolved sparse config dict, or ``None`` for NVFP4-only attention.
473490
"""
491+
if bmm2 not in ("nvfp4", "fp8"):
492+
raise ValueError(f"bmm2 must be 'nvfp4' or 'fp8', got {bmm2!r}")
474493
return _apply_vllm_attention_plans(
475-
_plan_vllm_attention(model_runner, quantize=True, sparse_cfg=sparse_cfg)
494+
_plan_vllm_attention(model_runner, quantize=True, sparse_cfg=sparse_cfg, bmm2=bmm2)
476495
)

tests/gpu_vllm/torch/quantization/test_vllm_dynamic_modules.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from modelopt.torch.quantization.plugins import vllm as vllm_plugin
4949
from modelopt.torch.quantization.plugins.vllm import (
5050
_ATTENTION_TYPES,
51+
_VLLM_NVFP4_BMM1_FP8_BMM2_QUANT_CFG,
5152
VllmMLAAttention,
5253
_QuantFusedMoEBase,
5354
_QuantVLLMAttention,
@@ -447,3 +448,37 @@ def test_tiny_deepseek_mla_quantize(tiny_deepseek_llm):
447448
assert summary["moe_count"] >= 2, summary
448449

449450
_assert_quantizer_amax_is_static(summary)
451+
452+
453+
def test_configure_vllm_attention_quantizers_fp8_bmm2(monkeypatch):
454+
monkeypatch.setattr(
455+
vllm_plugin,
456+
"create_parallel_state",
457+
lambda: vllm_plugin.ParallelState(data_parallel_group=None),
458+
)
459+
attention = object.__new__(vllm_plugin.vllm_attention.Attention)
460+
torch.nn.Module.__init__(attention)
461+
462+
converted = configure_vllm_nvfp4_attention_quantizers(
463+
attention, device="cpu", dtype=torch.bfloat16, cfg=_VLLM_NVFP4_BMM1_FP8_BMM2_QUANT_CFG
464+
)
465+
466+
# BMM1 unchanged: Q/K dynamic block-16 NVFP4 (F1)
467+
for name in ("q", "k"):
468+
quantizer = getattr(converted, f"{name}_bmm_quantizer")
469+
assert quantizer.is_enabled and quantizer.is_nvfp4_dynamic
470+
assert quantizer.block_sizes[-1] == 16
471+
assert converted.k_bmm_quantizer._amax == 6.0 * 448.0
472+
# BMM2: P/V per-tensor FP8 E4M3 with fixed amax (P=1.0, V=448) (F3)
473+
for name, amax in (("p", 1.0), ("v", 448.0)):
474+
quantizer = getattr(converted, f"{name}_bmm_quantizer")
475+
assert quantizer.is_enabled
476+
assert quantizer.num_bits == (4, 3)
477+
assert not quantizer.block_sizes
478+
assert float(quantizer._amax) == amax
479+
# idempotent: calibrated amax survives reconfiguration
480+
converted.v_bmm_quantizer.amax = torch.tensor(96.0)
481+
reconfigured = configure_vllm_nvfp4_attention_quantizers(
482+
converted, device="cpu", dtype=torch.bfloat16, cfg=_VLLM_NVFP4_BMM1_FP8_BMM2_QUANT_CFG
483+
)
484+
assert float(reconfigured.v_bmm_quantizer._amax) == 96.0

tests/gpu_vllm/torch/sparsity/attention_sparsity/test_quant_sparse_attn_worker.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,22 @@ def profile(actual_worker):
8080
("exit", "compilation", model),
8181
("exit", "inference", None),
8282
]
83+
84+
85+
def test_fp8_bmm2_worker_delegates_with_fp8(monkeypatch):
86+
worker_module = _load_worker_module()
87+
calls = []
88+
monkeypatch.setattr(BaseWorker, "load_model", lambda *_a, **_k: calls.append("base"))
89+
monkeypatch.setattr(
90+
worker_module,
91+
"install_vllm_nvfp4_attention",
92+
lambda runner, **kw: (
93+
calls.append(("install", kw))
94+
or SimpleNamespace(installed_count=0, sparse_algorithm=None, backend_counts={})
95+
),
96+
)
97+
instance = object.__new__(worker_module.QuantSparseAttnFp8Bmm2Worker)
98+
instance.model_runner = SimpleNamespace()
99+
instance.load_model()
100+
assert calls[0] == "base"
101+
assert calls[1][1] == {"sparse_cfg": "checkpoint", "bmm2": "fp8"}

tests/gpu_vllm/torch/sparsity/attention_sparsity/test_sparse_attn_worker.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ def guarded_import(name, *args, **kwargs):
8080
monkeypatch.setattr(builtins, "__import__", guarded_import)
8181
worker_module = _load_worker_module("sparse_attn_worker_import_test")
8282

83-
assert worker_module.__all__ == ["SparseAttnWorker", "QuantSparseAttnWorker"]
83+
assert worker_module.__all__ == [
84+
"SparseAttnWorker",
85+
"QuantSparseAttnWorker",
86+
"QuantSparseAttnFp8Bmm2Worker",
87+
]
8488

8589

8690
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)