From 197bd2d3667ae4f923e110c92f962cd60a7ebdf3 Mon Sep 17 00:00:00 2001 From: DefTruth Date: Fri, 10 Jul 2026 06:32:38 +0000 Subject: [PATCH 1/2] feat: support torchao nvfp4 dynamic quantization --- docs/user_guide/QUANTIZATION.md | 45 +++++- pyproject.toml | 2 +- src/cache_dit/_utils/utils.py | 76 ++++++----- src/cache_dit/quantization/config.py | 2 +- .../quantization/torchao/quantize_ao.py | 44 ++++++ tests/quantization/test_torchao_nvfp4.py | 128 ++++++++++++++++++ 6 files changed, 256 insertions(+), 41 deletions(-) create mode 100644 tests/quantization/test_torchao_nvfp4.py diff --git a/docs/user_guide/QUANTIZATION.md b/docs/user_guide/QUANTIZATION.md index e0ddae8a..46e93386 100644 --- a/docs/user_guide/QUANTIZATION.md +++ b/docs/user_guide/QUANTIZATION.md @@ -16,6 +16,8 @@ Quantization is a powerful technique to reduce the memory footprint and computat |int8_per_tensor|quantize weights and activations to int8 (dynamic quantization) with tensorwise method.|>=sm80, Ampere or newer| |int8_weight_only|quantize only weights to int8, keep activations in full precision|>=sm80, Ampere or newer| |int4_weight_only|quantize only weights to int4, keep activations in full precision|>=sm90, Hopper or newer, TMA required| +|nvfp4|TorchAO dynamic activation and NVFP4 weight quantization. The current integration uses Triton activation quantization and dynamic per-tensor scaling.|>=sm100, Blackwell or newer| +|nvfp4_weight_only|TorchAO NVFP4 weight-only quantization with dynamic per-tensor weight scaling; activations remain in their original precision.|>=sm100, Blackwell or newer| |svdq_int4_r{32...}|post-training SVDQuant (W4A4) with calibration and checkpoint serialization for users who want the higher-accuracy PTQ workflow.|>=sm80, Ampere or newer, excluded Hopper (NO INT4 MMA)| |svdq_nvfp4_r{32...}|post-training SVDQuant (W4A4) with NVFP4 packed weights/activations, calibration, and checkpoint serialization. Only svdq_kwargs["runtime_kernel"]="v1" is currently supported.|>=sm120, Blackwell or newer| |svdq_int4_r{32...}_dq|quantize weights and activations to int4 with SVDQuant dynamic quantization (W4A4) without any calibration.|>=sm80, Ampere or newer, excluded Hopper (NO INT4 MMA)| @@ -289,13 +291,42 @@ INT4 quantization can provide even better memory reduction compared to FP8 or IN Please note that users should also install mslk kernel library to enable INT8/INT4 quantization features. The int4_weight_only w4a16 compute kennel requires architectures >= sm90 (Hopper or newer, TMA required). For older architectures, users can use int8_weight_only quantization for better compatibility. ```bash -# stable: mslk (change cu130 to cu129 if using CUDA 12.9), required torch>=2.11.0 -uv pip install torch==2.11.0 mslk --index-url https://download.pytorch.org/whl/cu130 --upgrade -# nightly: mslk (change cu130 to cu129 if using CUDA 12.9), required torch>=2.11.0 -uv pip install --pre torch mslk --index-url https://download.pytorch.org/whl/nightly/cu130 --upgrade +# stable: mslk (change cu13x to cu129 if using CUDA 12.9), torch>=2.11.0 +# mslk version matching: torch==2.11.x mslk==1.1.0; torch==2.12.x mslk==1.2.0 +pip install torch==2.11.0 mslk==1.1.0 --index-url https://download.pytorch.org/whl/cu130 +pip install torch==2.12.0 mslk==1.2.0 --index-url https://download.pytorch.org/whl/cu132 +# nightly: mslk (change cu13x to cu129 if using CUDA 12.9), required torch>=2.11.0 +pip install --pre torch mslk --index-url https://download.pytorch.org/whl/nightly/cu132 ``` + In the case of distributed inference (context parallelism or tensor parallelism), we recommend users to use float8 quantization to avoid potential compatibility issues. + +## TorchAO NVFP4 Quantization + +nvfp4 uses NVFP4DynamicActivationNVFP4WeightConfig from TorchAO's MX formats prototype. Cache-DiT currently fixes both use_triton_kernel=True and use_dynamic_per_tensor_scale=True. It is an online dynamic quantization path and is separate from the svdq_nvfp4_* SVDQuant path. (Required mslk kernels library) + +For example: + +```python +import cache_dit +from cache_dit import QuantizeConfig + +cache_dit.enable_cache( + pipe, cache_config=..., quantize_config=QuantizeConfig(quant_type="nvfp4"), +) +``` + +The current implementation requires Blackwell or newer GPUs (SM100+) and skips Linear layers whose two weight dimensions are not divisible by 16 or whose output dimensions are too small for the NVFP4 kernel. The supported CLI shortcut is: + +```bash +python3 -m cache_dit.generate flux --nvfp4 +python3 -m cache_dit.generate flux --nvfp4 --compile +python3 -m cache_dit.generate flux --nvfp4-weight-only +``` + +The model must use bfloat16 Linear weights. The Triton activation kernel can fall back internally when a runtime shape does not satisfy its kernel constraints. + ## SVDQuant (W4A4) PTQ Cache-DiT provides a native SVDQuant PTQ workflow for W4A4 quantization (with high performance W4A4 GEMM kernels and an easy-to-use PTQ interface). The public API is intentionally small: build a QuantizeConfig, quantize with cache_dit.quantize(...), then reload with cache_dit.load(...). Cache-DiT now supports both INT4 and NVFP4 SVDQuant PTQ flows. We highly recommend using native SVDQuant support in Cache-DiT for W4A4 quantization, as it can provide high performance and better usability compared to other third-party low-bit quantization libraries. @@ -824,7 +855,7 @@ Conversion complete. Load the quantized model with: **Experimental weight-only smooth strategy:** ```bash -# INT4 +# SVDQ INT4 cache-dit-convert \ --model-path black-forest-labs/FLUX.2-klein-4B \ --save-dir ./FLUX.2-klein-4B-svdq \ @@ -832,7 +863,7 @@ cache-dit-convert \ --svdq-smooth-strategy weight \ --svdq-calibrate-precision medium -# NVFP4 +# SVDQ NVFP4 cache-dit-convert \ --model-path black-forest-labs/FLUX.2-klein-4B \ --save-dir ./FLUX.2-klein-4B-svdq-nvfp4 \ @@ -844,7 +875,7 @@ cache-dit-convert \ **With v2 runtime kernel and verbose logging:** ```bash -# INT4 only, NVFP4 DQ is not supported with v2 runtime kernel for now. +# SVDQ INT4 only, SVDQ NVFP4 DQ is not supported with v2 runtime kernel for now. cache-dit-convert \ --model-path /path/to/FLUX.1-dev \ --save-dir ./FLUX.1-dev-svdq \ diff --git a/pyproject.toml b/pyproject.toml index afe2e45b..7d3b4713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ ray = [ ] quantization = [ - "torchao>=0.14.1", + "torchao>=0.17.0", "bitsandbytes>=0.48.1", "safetensors>=0.5.3", ] diff --git a/src/cache_dit/_utils/utils.py b/src/cache_dit/_utils/utils.py index 1501391c..130d1601 100644 --- a/src/cache_dit/_utils/utils.py +++ b/src/cache_dit/_utils/utils.py @@ -425,6 +425,8 @@ def get_args(parse: bool = True, ) -> argparse.ArgumentParser | argparse.Namespa "float8_per_tensor", "float8_per_block", "float8_weight_only", + "nvfp4", + "nvfp4_weight_only", "int8_per_row", "int8_per_tensor", "int8_weight_only", @@ -516,6 +518,18 @@ def get_args(parse: bool = True, ) -> argparse.ArgumentParser | argparse.Namespa default=False, help="Enable int4 weight-only quantization for transformer", ) + parser.add_argument( + "--nvfp4", + action="store_true", + default=False, + help="Enable TorchAO NVFP4 dynamic activation and weight quantization for transformer", + ) + parser.add_argument( + "--nvfp4-weight-only", + action="store_true", + default=False, + help="Enable TorchAO NVFP4 weight-only quantization for transformer", + ) parser.add_argument( "--svdq-int4-r32-dq", "--svdq-r32", @@ -1183,38 +1197,36 @@ def get_base_args(parse: bool = True) -> argparse.Namespace | argparse.ArgumentP def maybe_postprocess_args(args: argparse.Namespace) -> argparse.Namespace: # Force enable quantization if quantize_type is specified - if args.float8_per_row: - args.quantize_type = "float8_per_row" - elif args.float8_per_tensor: - args.quantize_type = "float8_per_tensor" - elif args.float8_per_block: - args.quantize_type = "float8_per_block" - elif args.float8_weight_only: - args.quantize_type = "float8_weight_only" - elif args.int8_per_row: - args.quantize_type = "int8_per_row" - elif args.int8_per_tensor: - args.quantize_type = "int8_per_tensor" - elif args.int8_weight_only: - args.quantize_type = "int8_weight_only" - elif args.int4_weight_only: - args.quantize_type = "int4_weight_only" - elif args.svdq_int4_r32_dq: - args.quantize_type = "svdq_int4_r32_dq" - elif args.svdq_int4_r64_dq: - args.quantize_type = "svdq_int4_r64_dq" - elif args.svdq_int4_r128_dq: - args.quantize_type = "svdq_int4_r128_dq" - elif args.svdq_int4_r256_dq: - args.quantize_type = "svdq_int4_r256_dq" - elif args.svdq_nvfp4_r32_dq: - args.quantize_type = "svdq_nvfp4_r32_dq" - elif args.svdq_nvfp4_r64_dq: - args.quantize_type = "svdq_nvfp4_r64_dq" - elif args.svdq_nvfp4_r128_dq: - args.quantize_type = "svdq_nvfp4_r128_dq" - elif args.svdq_nvfp4_r256_dq: - args.quantize_type = "svdq_nvfp4_r256_dq" + quantize_shortcuts = [ + ("float8_per_row", args.float8_per_row), + ("float8_per_tensor", args.float8_per_tensor), + ("float8_per_block", args.float8_per_block), + ("float8_weight_only", args.float8_weight_only), + ("int8_per_row", args.int8_per_row), + ("int8_per_tensor", args.int8_per_tensor), + ("int8_weight_only", args.int8_weight_only), + ("int4_weight_only", args.int4_weight_only), + ("nvfp4", args.nvfp4), + ("nvfp4_weight_only", args.nvfp4_weight_only), + ("svdq_int4_r32_dq", args.svdq_int4_r32_dq), + ("svdq_int4_r64_dq", args.svdq_int4_r64_dq), + ("svdq_int4_r128_dq", args.svdq_int4_r128_dq), + ("svdq_int4_r256_dq", args.svdq_int4_r256_dq), + ("svdq_nvfp4_r32_dq", args.svdq_nvfp4_r32_dq), + ("svdq_nvfp4_r64_dq", args.svdq_nvfp4_r64_dq), + ("svdq_nvfp4_r128_dq", args.svdq_nvfp4_r128_dq), + ("svdq_nvfp4_r256_dq", args.svdq_nvfp4_r256_dq), + ] + selected_shortcuts = [quant_type for quant_type, enabled in quantize_shortcuts if enabled] + if len(selected_shortcuts) > 1: + raise ValueError(f"Quantization shortcuts are mutually exclusive, got {selected_shortcuts}.") + if selected_shortcuts: + selected_quant_type = selected_shortcuts[0] + if args.quantize_type is not None and args.quantize_type != selected_quant_type: + raise ValueError( + f"--quantize-type {args.quantize_type} conflicts with --{selected_quant_type.replace('_', '-')}." + ) + args.quantize_type = selected_quant_type if args.quantize_type is not None: args.quantize = True diff --git a/src/cache_dit/quantization/config.py b/src/cache_dit/quantization/config.py index 54dc2baf..148aff8b 100644 --- a/src/cache_dit/quantization/config.py +++ b/src/cache_dit/quantization/config.py @@ -333,7 +333,7 @@ class QuantizeConfig: backend: str | QuantizeBackend = QuantizeBackend.AUTO # Quantization type, currently support "float8_weight_only" and "float8_per_row", # "float8_per_tensor", "float8_per_block", "int8_per_row", "int8_per_tensor", - # "int8_weight_only", "int4_weight_only", etc. + # "int8_weight_only", "int4_weight_only", "nvfp4", "nvfp4_weight_only", etc. quant_type: str = "float8_per_row" # The layers specified in this variable will be excluded from quantization, # even if they are in the repeated blocks or not filtered out by filter_fn. diff --git a/src/cache_dit/quantization/torchao/quantize_ao.py b/src/cache_dit/quantization/torchao/quantize_ao.py index 7a50b316..95d381df 100644 --- a/src/cache_dit/quantization/torchao/quantize_ao.py +++ b/src/cache_dit/quantization/torchao/quantize_ao.py @@ -18,6 +18,8 @@ "float8_per_tensor", "float8_per_block", "float8_weight_only", + "nvfp4", + "nvfp4_weight_only", "int8_per_row", "int8_per_tensor", "int8_weight_only", @@ -29,6 +31,8 @@ "float8_per_row", "float8_per_block", "float8_weight_only", + "nvfp4", + "nvfp4_weight_only", "int8_per_tensor", "int8_per_row", "int8_weight_only", @@ -198,6 +202,12 @@ def __post_init__(self): 9, ), "FP8 requires Ada or newer GPUs (>=sm89), but got " + str( current_platform.get_device_capability()) + if self.is_nvfp4(): + assert current_platform.get_device_capability() >= ( + 10, + 0, + ), "NVFP4 requires Blackwell or newer GPUs (>=sm100), but got " + str( + current_platform.get_device_capability()) @staticmethod def from_config( @@ -411,6 +421,12 @@ def is_float8_per_block(self) -> bool: def is_float8_weight_only(self) -> bool: return self.quant_type == "float8_weight_only" + def is_nvfp4(self) -> bool: + return self.quant_type in ("nvfp4", "nvfp4_weight_only") + + def is_nvfp4_weight_only(self) -> bool: + return self.quant_type == "nvfp4_weight_only" + def required_fallback(self) -> bool: # Currently, only support float8 per-tensor fallback for rowwise layers if # regional quantiztion is enabled. Not support fallback for int8/int4/weight-only @@ -550,6 +566,20 @@ def _get_torchao_config(quant_type: str, **kwargs) -> AOBaseConfig: torch.float8_e4m3fn, ), ) + elif quant_type == "nvfp4": + from torchao.prototype.mx_formats.inference_workflow import ( + NVFP4DynamicActivationNVFP4WeightConfig, ) + + quant_config = NVFP4DynamicActivationNVFP4WeightConfig( + use_triton_kernel=True, + use_dynamic_per_tensor_scale=True, + ) + + elif quant_type == "nvfp4_weight_only": + from torchao.prototype.mx_formats.inference_workflow import NVFP4WeightOnlyConfig + + quant_config = NVFP4WeightOnlyConfig(use_dynamic_per_tensor_scale=True) + elif quant_type == "int8_per_row": from torchao.quantization import ( Int8DynamicActivationInt8WeightConfig, @@ -658,6 +688,20 @@ def _is_curr_plan_allow_to_quantize(name: str) -> bool: # precision plan logger.debug(skip_reason) return False + if curr_quant_type in ("nvfp4", "nvfp4_weight_only"): + weight_shape = tuple(m.weight.shape) + if weight_shape[-2] % 16 != 0 or weight_shape[-1] % 16 != 0: + skip_reason = _skip_reason(f"weight_shape{weight_shape}%16!=0") + quant_ctx.skipped_map[curr_quant_type].append(skip_reason) + logger.debug(skip_reason) + return False + if (curr_quant_type == "nvfp4" + and (weight_shape[-2] <= 64 or (weight_shape[-1] <= 1024 and weight_shape[-2] <= 1024))): + skip_reason = _skip_reason(f"weight_shape{weight_shape} is too small") + quant_ctx.skipped_map[curr_quant_type].append(skip_reason) + logger.debug(skip_reason) + return False + # check blockwise fp8 support for linear layers, if not supported, # skip quantization for that layer. if curr_quant_type in [ diff --git a/tests/quantization/test_torchao_nvfp4.py b/tests/quantization/test_torchao_nvfp4.py new file mode 100644 index 00000000..a1a5ca53 --- /dev/null +++ b/tests/quantization/test_torchao_nvfp4.py @@ -0,0 +1,128 @@ +import importlib.util + +import pytest +import torch + +from cache_dit._utils.utils import get_args, maybe_postprocess_args +from cache_dit.quantization import QuantizeConfig, quantize +from cache_dit.quantization.torchao.quantize_ao import _get_torchao_config + +torchao = pytest.importorskip("torchao") +nvfp4_workflow = pytest.importorskip("torchao.prototype.mx_formats.inference_workflow") +NVFP4Tensor = pytest.importorskip("torchao.prototype.mx_formats.nvfp4_tensor").NVFP4Tensor + + +def test_nvfp4_config_and_cli() -> None: + config = QuantizeConfig(quant_type="nvfp4") + assert config.strify() == "nvfp4" + assert config.backend.value == "TORCHAO" + + torchao_config = _get_torchao_config("nvfp4") + assert isinstance(torchao_config, nvfp4_workflow.NVFP4DynamicActivationNVFP4WeightConfig) + assert torchao_config.use_triton_kernel is True + assert torchao_config.use_dynamic_per_tensor_scale is True + + weight_only_config = QuantizeConfig(quant_type="nvfp4_weight_only") + assert weight_only_config.strify() == "nvfp4_weight_only" + weight_only_torchao_config = _get_torchao_config("nvfp4_weight_only") + assert isinstance(weight_only_torchao_config, nvfp4_workflow.NVFP4WeightOnlyConfig) + assert weight_only_torchao_config.use_dynamic_per_tensor_scale is True + + parser = get_args(parse=False) + args = maybe_postprocess_args(parser.parse_args(["--nvfp4"])) + assert args.quantize is True + assert args.quantize_type == "nvfp4" + + args = maybe_postprocess_args(parser.parse_args(["--quantize-type", "nvfp4"])) + assert args.quantize is True + assert args.quantize_type == "nvfp4" + + args = maybe_postprocess_args(parser.parse_args(["--nvfp4-weight-only"])) + assert args.quantize is True + assert args.quantize_type == "nvfp4_weight_only" + + with pytest.raises(ValueError, match="mutually exclusive"): + maybe_postprocess_args(parser.parse_args(["--nvfp4", "--svdq-nvfp4-r128-dq"])) + + +def _require_nvfp4_device() -> None: + if not torch.cuda.is_available(): + pytest.skip("TorchAO NVFP4 tests require CUDA.") + if torch.cuda.get_device_capability() < (10, 0): + pytest.skip("TorchAO dynamic NVFP4 requires SM100 or newer.") + if importlib.util.find_spec("mslk") is None: + pytest.skip("TorchAO dynamic NVFP4 tests require MSLK.") + + +def test_nvfp4_quantizes_supported_linears_and_skips_unsupported_linears() -> None: + _require_nvfp4_device() + + class ToyModel(torch.nn.Module): + + def __init__(self) -> None: + super().__init__() + self.large = torch.nn.Linear(2048, 2048, bias=False, dtype=torch.bfloat16) + self.small = torch.nn.Linear(1024, 64, bias=False, dtype=torch.bfloat16) + self.unaligned = torch.nn.Linear(1025, 2048, bias=False, dtype=torch.bfloat16) + + def forward(self, input: torch.Tensor) -> torch.Tensor: + return self.large(input) + + model = ToyModel().cuda() + quantize( + model, + QuantizeConfig(quant_type="nvfp4", regional_quantize=False), + ) + + assert isinstance(model.large.weight, NVFP4Tensor) + assert not isinstance(model.small.weight, NVFP4Tensor) + assert not isinstance(model.unaligned.weight, NVFP4Tensor) + assert model.large.weight.act_quant_kwargs.use_triton_kernel is True + assert model.large.weight.act_quant_kwargs.use_dynamic_per_tensor_scale is True + + output = model(torch.randn(128, 2048, device="cuda", dtype=torch.bfloat16)) + assert output.shape == (128, 2048) + assert torch.isfinite(output).all() + + +def test_nvfp4_compiles_and_runs() -> None: + _require_nvfp4_device() + + model = torch.nn.Linear(2048, 2048, bias=False, dtype=torch.bfloat16, device="cuda") + quantize( + model, + QuantizeConfig(quant_type="nvfp4", regional_quantize=False), + ) + compiled_model = torch.compile(model, fullgraph=True) + output = compiled_model(torch.randn(128, 2048, device="cuda", dtype=torch.bfloat16)) + assert output.shape == (128, 2048) + assert torch.isfinite(output).all() + + +def test_nvfp4_weight_only_quantizes_and_compiles() -> None: + _require_nvfp4_device() + + class ToyModel(torch.nn.Module): + + def __init__(self) -> None: + super().__init__() + self.large = torch.nn.Linear(2048, 2048, bias=False, dtype=torch.bfloat16) + self.unaligned = torch.nn.Linear(1025, 2048, bias=False, dtype=torch.bfloat16) + + def forward(self, input: torch.Tensor) -> torch.Tensor: + return self.large(input) + + model = ToyModel().cuda() + quantize( + model, + QuantizeConfig(quant_type="nvfp4_weight_only", regional_quantize=False), + ) + + assert isinstance(model.large.weight, NVFP4Tensor) + assert model.large.weight.act_quant_kwargs is None + assert not isinstance(model.unaligned.weight, NVFP4Tensor) + + compiled_model = torch.compile(model, fullgraph=True) + output = compiled_model(torch.randn(128, 2048, device="cuda", dtype=torch.bfloat16)) + assert output.shape == (128, 2048) + assert torch.isfinite(output).all() From 33f10039ca81df6566f8f99cb5f776c761f81459 Mon Sep 17 00:00:00 2001 From: DefTruth Date: Fri, 10 Jul 2026 06:56:49 +0000 Subject: [PATCH 2/2] feat: support torchao nvfp4 dynamic quantization --- src/cache_dit/quantization/torchao/quantize_ao.py | 2 ++ tests/quantization/test_torchao_nvfp4.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/cache_dit/quantization/torchao/quantize_ao.py b/src/cache_dit/quantization/torchao/quantize_ao.py index 95d381df..1f470c89 100644 --- a/src/cache_dit/quantization/torchao/quantize_ao.py +++ b/src/cache_dit/quantization/torchao/quantize_ao.py @@ -501,6 +501,7 @@ def _get_torchao_config(quant_type: str, **kwargs) -> AOBaseConfig: Float8DynamicActivationFloat8WeightConfig, PerRow, ) + from torchao.quantization.quantize_.common import KernelPreference quant_config = Float8DynamicActivationFloat8WeightConfig( weight_dtype=kwargs.get( @@ -512,6 +513,7 @@ def _get_torchao_config(quant_type: str, **kwargs) -> AOBaseConfig: torch.float8_e4m3fn, ), granularity=(PerRow(), PerRow()), + kernel_preference=KernelPreference.TORCH, ) elif quant_type == "float8_per_tensor": from torchao.quantization import ( diff --git a/tests/quantization/test_torchao_nvfp4.py b/tests/quantization/test_torchao_nvfp4.py index a1a5ca53..bdadac52 100644 --- a/tests/quantization/test_torchao_nvfp4.py +++ b/tests/quantization/test_torchao_nvfp4.py @@ -8,11 +8,16 @@ from cache_dit.quantization.torchao.quantize_ao import _get_torchao_config torchao = pytest.importorskip("torchao") +from torchao.quantization.quantize_.common import KernelPreference + nvfp4_workflow = pytest.importorskip("torchao.prototype.mx_formats.inference_workflow") NVFP4Tensor = pytest.importorskip("torchao.prototype.mx_formats.nvfp4_tensor").NVFP4Tensor def test_nvfp4_config_and_cli() -> None: + float8_config = _get_torchao_config("float8_per_row") + assert float8_config.kernel_preference is KernelPreference.TORCH + config = QuantizeConfig(quant_type="nvfp4") assert config.strify() == "nvfp4" assert config.backend.value == "TORCHAO"