Files

242 lines
8.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""NVFP4 quantization-aware (QAD) linear method, inference path.
Quantizes every targeted linear's weight to NVFP4 once at load time and
runs each forward as a registered flashinfer-backed FP4 matmul. The
original fp16/bf16 weight is *popped* immediately after quantization so
the half-precision copy does not keep occupying GPU memory — that's
what lets a Wan-2.1 pipeline stay fully resident on a single GPU
without any CPU offloading.
The quantize / matmul custom ops are owned by
:mod:`fastvideo.layers.quantization.nvfp4_config` and registered under
the ``fastvideo_fp4::`` namespace. We reuse them here for two reasons:
1. Re-registering the same op name in a second module would raise.
2. The registered ops have ``register_fake`` shape/dtype kernels, which
is what makes the inference pipeline's per-block ``torch.compile``
trace through without graph breaks. Calling raw flashinfer functions
(the old behavior of this file, plus a ``@torch.compile`` on
``apply``) graph-breaks at every quantize and every matmul.
For QAT *training*, see ``nvfp4_qat_train_config`` which keeps the
weight trainable and fake-quantizes on the fly via a straight-through
estimator.
"""
from __future__ import annotations
import gc
import logging
from typing import Any
import torch
from torch.nn.parameter import Parameter
from fastvideo.layers.quantization.base_config import (
QuantizationConfig,
QuantizeMethodBase,
)
from fastvideo.layers.quantization.nvfp4_config import (
_mm_fp4,
_nvfp4_quantize,
_require_flashinfer,
)
from fastvideo.models.utils import set_weight_attrs
logger = logging.getLogger(__name__)
# Wan-style attention + FFN projection layers. Matched as substrings of the
# layer prefix (e.g. "blocks.0.attn1.to_q" contains "to_q"). Wan's "to_q"/
# "to_k"/"to_v" also substring-match Kandinsky5's "to_query"/"to_key"/
# "to_value", so only Kandinsky5's out-projection and FFN names (which don't
# share a substring with Wan's "to_out"/"ffn.fc_in"/"ffn.fc_out") need to be
# listed explicitly below.
DEFAULT_FP4_LAYERS = (
"ffn.fc_in",
"ffn.fc_out",
"to_q",
"to_k",
"to_v",
"to_out",
# Kandinsky5
"self_attention.out_layer",
"cross_attention.out_layer",
"feed_forward.mlp.fc_in",
"feed_forward.mlp.fc_out",
)
def _layout_128x4() -> Any:
SfLayout, _, _ = _require_flashinfer()
return SfLayout.layout_128x4
class NVFP4QATQuantizeMethod(QuantizeMethodBase):
"""Inference-only NVFP4 linear method with weight popping.
The dense ``weight`` parameter is materialized at load time only so
that :func:`convert_model_to_fp4` can read it once; the loader then
removes it via ``mod._parameters.pop('weight')``. From that point
forward, ``apply`` reads only ``_fp4_weight`` / ``_fp4_weight_scale``
/ ``_weight_global_sf``.
"""
def __init__(self) -> None:
super().__init__()
self.weight_fp4 = None
self.weight_scale = None
# Static input global scale factor. Matches the FastVideo-Quantization
# production path; recomputing it per-call via a ``.max()`` reduction
# (the previous behavior) adds a sync point, costs a kernel launch,
# and produces a data-dependent value that prevents CUDA-graph
# capture under ``torch.compile(mode='reduce-overhead')``.
self.x_global_sf = torch.tensor(1.0, device="cuda", dtype=torch.float32)
def create_weights(self, layer: torch.nn.Module, input_size_per_partition: int, output_partition_sizes: list[int],
input_size: int, output_size: int, params_dtype: torch.dtype, **extra_weight_attrs) -> None:
weight = Parameter(torch.empty(
sum(output_partition_sizes),
input_size_per_partition,
dtype=params_dtype,
),
requires_grad=False)
set_weight_attrs(weight, {"input_dim": 1, "output_dim": 0})
layer.register_parameter("weight", weight)
set_weight_attrs(weight, extra_weight_attrs)
def apply(self, layer: torch.nn.Module, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
# ``_fp4_weight`` carries the (out, in/2) packed fp4 weight, so its
# row count is the output dim even after the dense weight is popped.
out_dim = layer._fp4_weight.shape[0]
original_shape = x.shape
assert x.dtype in (torch.bfloat16, torch.float16), (f"only allow bf16/fp16 inputs to fp4 linear, got {x.dtype}")
x = x.view(-1, x.shape[-1])
x_global_sf = self.x_global_sf
x_fp4, x_scale = _nvfp4_quantize(
x,
x_global_sf,
sfLayout=_layout_128x4(),
do_shuffle=False,
)
weight_fp4 = layer._fp4_weight
weight_scale = layer._fp4_weight_scale
weight_global_sf = layer._weight_global_sf
out = _mm_fp4(
x_fp4,
weight_fp4.T,
x_scale,
weight_scale.T,
1.0 / (x_global_sf * weight_global_sf),
torch.bfloat16,
None,
backend="cutlass",
)
if bias is not None:
out = out + bias
out = out.view(*original_shape[:-1], out_dim)
return out
class NVFP4QATConfig(QuantizationConfig):
"""NVFP4 (Wan-style) linear quantization, inference.
Args:
target_layers: Substrings matched against each linear layer's
prefix. A layer is quantized if any substring is contained in
its prefix. Defaults to the standard Wan attention + FFN
projections (:data:`DEFAULT_FP4_LAYERS`).
"""
def __init__(self, target_layers: tuple[str, ...] | None = None) -> None:
super().__init__()
self.target_layers = (tuple(target_layers) if target_layers else DEFAULT_FP4_LAYERS)
def get_name(self) -> str:
return "nvfp4_qat"
def get_supported_act_dtypes(self) -> list[torch.dtype]:
return [torch.bfloat16, torch.float16]
@classmethod
def get_min_capability(cls) -> int:
return 100
@staticmethod
def get_config_filenames() -> list[str]:
return []
@classmethod
def from_config(cls, config: dict[str, Any]) -> NVFP4QATConfig:
target_layers = config.get("target_layers")
if target_layers is not None:
target_layers = tuple(target_layers)
return cls(target_layers=target_layers)
def get_quant_method(self, layer: torch.nn.Module, prefix: str):
from fastvideo.layers.linear import LinearBase
if isinstance(layer, LinearBase) and any(name in prefix for name in self.target_layers):
return NVFP4QATQuantizeMethod()
return None
def convert_model_to_fp4(model: torch.nn.Module) -> None:
"""Prequantize every FP4-tagged linear and drop its dense weight.
Walks the module tree, and for each layer whose ``quant_method`` is
an :class:`NVFP4QATQuantizeMethod`, computes the NVFP4 packed weight
/ scale / global-scale buffers, then pops the original fp16/bf16
``weight`` parameter so it no longer occupies GPU memory.
"""
SfLayout, _, _ = _require_flashinfer()
from torch.distributed.tensor import DTensor # type: ignore
with torch.no_grad():
for mod in model.modules():
qm = getattr(mod, "quant_method", None)
if not isinstance(qm, NVFP4QATQuantizeMethod):
continue
weight = getattr(mod, "weight", None)
if weight is None:
continue
weight_local = weight.to_local() if isinstance(weight, DTensor) else weight # type: ignore[arg-type]
# Only the reduced scalar needs fp32; avoid a full fp32 copy.
weight_absmax = (weight_local.detach().abs().nan_to_num().amax().to(dtype=torch.float32))
weight_global_sf = (448 * 6) / weight_absmax
fp4_w, fp4_s = _nvfp4_quantize(
weight_local,
weight_global_sf,
sfLayout=SfLayout.layout_128x4,
do_shuffle=False,
)
mod.register_buffer("_fp4_weight", fp4_w, persistent=False)
mod.register_buffer("_fp4_weight_scale", fp4_s, persistent=False)
mod.register_buffer(
"_weight_global_sf",
weight_global_sf.to(dtype=torch.bfloat16),
persistent=False,
)
# Drop the dense weight as soon as the fp4 buffers are installed
# so it cannot keep occupying GPU memory.
removed_weight = mod._parameters.pop("weight", None)
if removed_weight is not None:
removed_weight.grad = None
del removed_weight, weight, weight_local, weight_absmax
gc.collect()
torch.cuda.empty_cache()
__all__ = [
"NVFP4QATConfig",
"NVFP4QATQuantizeMethod",
"convert_model_to_fp4",
"DEFAULT_FP4_LAYERS",
]