Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Changelog
- Add support for quantizing and calibrating enabled operators outside the transformer layers, such as ``lm_head``, when using layerwise calibration.
- Add an end-to-end BEVFormer ONNX PTQ example with temporal calibration data generation, INT8 and FP8 quantization, TensorRT engine building, and nuScenes accuracy evaluation. See `examples/onnx_ptq/bevformer/README.md <https://github.com/NVIDIA/Model-Optimizer/tree/main/examples/onnx_ptq/bevformer>`_ for details.
- Add a reusable local-Hessian NVFP4 PTQ recipe and the quantization recipe used for ``nvidia/Qwen3.8-27B-NVFP4``.
- Add ``four_over_six`` as a named calibration algorithm for NVFP4 Four-Over-Six, replacing the hand-written MSE stanza it is bit-identical to. Configs that set only one half of 4/6 -- the ``four_over_six`` block_sizes flag without a weight-scale search, or the algorithm without the flag -- are now rejected instead of silently producing a wrongly scaled checkpoint.

*Megatron Framework (M-LM / M-Bridge)*

Expand Down
7 changes: 4 additions & 3 deletions docs/source/guides/10_recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,10 @@ PTQ recipes contain a ``quantize`` mapping with:
specification of entries, ordering semantics, and atomicity rules.
* - ``algorithm``
- No
- The calibration algorithm: ``"max"`` (default), ``"mse"``, ``"smoothquant"``,
``"awq_lite"``, ``"awq_full"``, ``"awq_clip"``, ``"gptq"``, or ``null`` for
formats that need no calibration (e.g. MX formats).
- The calibration algorithm: ``"max"`` (default), ``"mse"``, ``"four_over_six"``,
``"local_hessian"``, ``"nvfp4_act_headroom"``, ``"smoothquant"``, ``"awq_lite"``,
``"awq_full"``, ``"awq_clip"``, ``"gptq"``, ``"svdquant"``, ``"lsq"``, or ``null``
for formats that need no calibration (e.g. MX formats).


ExMy floating-point notation
Expand Down
28 changes: 25 additions & 3 deletions modelopt/torch/quantization/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
get_auto_quantize_cost_model,
normalize_auto_quantize_constraints,
)
from .config import QuantizeConfig, QuantizerAttributeConfig, QuantizerCfgEntry
from .config import QuantizeConfig, QuantizerAttributeConfig, QuantizerCfgEntry, has_four_over_six
from .conversion import set_quantizer_by_cfg
from .nn import QuantLinearConvBase, QuantModule, SequentialQuantizer, TensorQuantizer
from .utils import is_quantized_linear
Expand Down Expand Up @@ -2169,12 +2169,34 @@ def _cfg_to_dict(v):
# modules override default disables such as ``*lm_head*``.
quant_cfg.extend(global_entries.values())
quant_cfg.extend(per_module_entries)
# For 4/6, "max" is not a downgrade but wrong -- the flag normalizes the FP8 scales by
# 256 on the assumption something picks M=4 -- and QuantizeConfig rejects the pairing.
algorithm = "four_over_six" if _has_four_over_six(quant_cfg) else "max"
Comment thread
Fridah-nv marked this conversation as resolved.
note = (
" four_over_six runs its two-point MSE weight-amax search on every weight quantizer,"
" including the non-4/6 layers this search assigned other formats; set"
" config['algorithm'] explicitly to avoid that."
if algorithm == "four_over_six"
else ""
)
warnings.warn(
"get_auto_quantize_config: returned config uses algorithm='max'. "
f"get_auto_quantize_config: returned config uses algorithm={algorithm!r}. "
"Per-recipe calibration algorithms (e.g. smoothquant, awq) are not preserved. "
"Update config['algorithm'] if a different calibration algorithm is needed (e.g. 'gptq')."
+ note
)
return {"quant_cfg": quant_cfg, "algorithm": "max"}
return {"quant_cfg": quant_cfg, "algorithm": algorithm}


def _has_four_over_six(quant_cfg: list[dict]) -> bool:
"""True if any enabled entry sets the NVFP4 Four-Over-Six block_sizes flag."""
for entry in quant_cfg:
if entry.get("enable") is False:
continue
cfg = entry.get("cfg")
if any(has_four_over_six(level) for level in (cfg if isinstance(cfg, list) else [cfg])):
return True
return False


def _resolve_best_recipe(search_state, constraints, verbose=False):
Expand Down
44 changes: 44 additions & 0 deletions modelopt/torch/quantization/compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .config import CompressCfgType, CompressConfig
from .conversion import _replace_quant_module, set_quantizer_attributes_partial
from .nn.modules.quant_linear import RealQuantLinear
from .nn.modules.tensor_quantizer import SequentialQuantizer, TensorQuantizer
from .qtensor import QTensorWrapper, pack_real_quantize_weight
from .utils import is_quantized_linear

Expand All @@ -50,6 +51,48 @@
RealQuantModuleRegistry = _DMRegistryCls("RealQuant")


def _reject_unsupported_real_quant_formats(model: nn.Module) -> None:
"""Refuse formats real quantization cannot represent, before any weight is packed.

``_real_quantize`` asserts the same predicate, but only once
:func:`pack_real_quantize_weight` is already walking layers. Screening under the same
view and gate reports every offender up front without rejecting layers that would have
been skipped anyway.
"""
offenders, any_four_over_six = [], False
with SequentialQuantizer.convert_to_single_quantizer(model):
for name, module in model.named_modules():
weight = getattr(module, "weight", None)
quantizer = getattr(module, "weight_quantizer", None)
if (
name == "" # pack_real_quantize_weight skips the root module
or weight is None
or weight.is_meta
or weight.numel() == 0
or weight.element_size() <= 1
or not isinstance(quantizer, TensorQuantizer)
or not quantizer.is_enabled
or not quantizer._if_quant
or quantizer._fake_quant
or quantizer._is_real_quantize_support()
):
continue
offenders.append(f"{name}.weight_quantizer")
any_four_over_six |= quantizer.is_four_over_six
if offenders:
raise NotImplementedError(
"mtq.compress does not support the quantization format of these weight "
Comment thread
Fridah-nv marked this conversation as resolved.
f"quantizers: {offenders}. Use mtq.quantize + export instead, or exclude them "
"with the compress config."
+ (
" NVFP4 Four-Over-Six is one such format: the per-block M=4/M=6 choice "
"baked into amax is not preserved by real quantization."
if any_four_over_six
else ""
)
)


def compress_convert(
model,
config: CompressConfig,
Expand Down Expand Up @@ -106,6 +149,7 @@ def filter_func(name):
)
# If real quant quantizer is present, real quantize the weights.
if not skip_real_quantize_weight:
_reject_unsupported_real_quant_formats(model)
pack_real_quantize_weight(model)

def _has_qtensorwrapper(module):
Expand Down
192 changes: 187 additions & 5 deletions modelopt/torch/quantization/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@
from pydantic import Field, ValidationInfo, field_serializer, field_validator, model_validator

from modelopt.torch.opt.config import ModeloptBaseConfig, ModeloptField
from modelopt.torch.opt.config_loader import load_config
from modelopt.torch.opt.config_loader import _parse_exmy, load_config
from modelopt.torch.utils.network import ConstructorLike


Expand Down Expand Up @@ -319,6 +319,20 @@ def validate_block_size(cls, v):
return v


def _as_exmy(value: Any) -> tuple[int, int] | None:
"""Normalize a ``num_bits`` / ``scale_bits`` value to an ``(E, M)`` tuple, else None.

YAML arrives already parsed into a tuple; the Python API keeps whatever the caller
wrote, commonly the ``"e2m1"`` string.
"""
if isinstance(value, str):
parsed = _parse_exmy(value)
return parsed if isinstance(parsed, tuple) else None
if isinstance(value, (tuple, list)) and len(value) == 2:
return (value[0], value[1]) if all(isinstance(v, int) for v in value) else None
return None


class QuantizerAttributeConfig(ModeloptBaseConfig):
"""Quantizer attribute type."""

Expand Down Expand Up @@ -999,6 +1013,36 @@ class MseCalibConfig(_SharedStatesConfig, QuantizeAlgorithmConfig):
)


class FourOverSixCalibConfig(_SharedStatesConfig, QuantizeAlgorithmConfig):
"""Configuration for NVFP4 Four-Over-Six (4/6) weight calibration.

4/6 lets each weight block use either the full E2M1 dynamic range (M=6) or a reduced
one (M=4), whichever quantizes that block better. Choosing M=4 is the same as
multiplying the block's amax by 6/4, so the choice is an MSE amax search over exactly
two candidates, folded into the quantizer amax. The grid is derived from the format
rather than configured, so it cannot drift from the numerics.

Pairs with the ``four_over_six: true`` flag in a weight quantizer's ``block_sizes``,
which normalizes the per-block FP8 scales by 256 instead of 448 to leave headroom for
the M=4 blocks. :class:`QuantizeConfig` rejects a config that has one half without the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] This public docstring is stale relative to the final design of the coordination check.

QuantizeConfig no longer rejects a half-configured 4/6 config — _warn_on_four_over_six_mismatch (config.py:1783) only warnings.warns, precisely so the restore path can still reconstruct a stored config. The rejection lives in mtq.quantize (model_quant.py:336). Since FourOverSixCalibConfig is rendered in the published API docs, a user reading this will expect a ValidationError from QuantizeConfig(**cfg) and get a warning instead.

Suggested change
the M=4 blocks. :class:`QuantizeConfig` rejects a config that has one half without the
the M=4 blocks. :func:`mtq.quantize` rejects a config that has one half without the

(and the following line becomes other; constructing :class:QuantizeConfig directly only warns, so an existing… — adjust the wrap as you prefer.)

other.

.. note::
Supported via ``mtq.quantize`` plus HF / Megatron export only, not
``mtq.compress``, which does not preserve the per-block M=4/M=6 choice.
"""

_mutates_weights: ClassVar[bool] = False

method: Literal["four_over_six"] = ModeloptField("four_over_six")

distributed_sync: bool | None = ModeloptField(
default=True,
title="Whether to sync the amax across the distributed processes.",
description="If True, the amax will be synced across the distributed processes.",
)


class LocalHessianCalibConfig(_SharedStatesConfig, QuantizeAlgorithmConfig):
"""Configuration for local Hessian-weighted MSE calibration.

Expand Down Expand Up @@ -1244,7 +1288,9 @@ class GPTQCalibConfig(QuantizeAlgorithmConfig):
)


_ScaleCalibConfig: TypeAlias = MaxCalibConfig | MseCalibConfig | LocalHessianCalibConfig
_ScaleCalibConfig: TypeAlias = (
MaxCalibConfig | MseCalibConfig | FourOverSixCalibConfig | LocalHessianCalibConfig
)


class NVFP4ActHeadroomCalibConfig(QuantizeAlgorithmConfig):
Expand Down Expand Up @@ -1307,8 +1353,9 @@ class NVFP4ActHeadroomCalibConfig(QuantizeAlgorithmConfig):
default={"method": "max"},
title="Algorithm used to calibrate the weight scales.",
description=(
"Weight scales are set by an independent algorithm -- ``max`` (default), ``mse`` or "
"``local_hessian`` -- because this algorithm only decides the NVFP4 *activation* "
"Weight scales are set by an independent algorithm -- ``max`` (default), ``mse``, "
"``four_over_six`` or ``local_hessian`` -- because this algorithm only decides the "
"NVFP4 *activation* "
"global scale. Give the chosen algorithm's own options alongside ``method`` (for "
"example ``{'method': 'mse', 'fp8_scale_sweep': true}``); ``distributed_sync`` and "
"``shared_states`` belong to that weight calibration pass and are set there."
Expand Down Expand Up @@ -1388,7 +1435,7 @@ class LSQConfig(QuantizeAlgorithmConfig):
default=None,
title="Scale calibration algorithm to run first.",
description=(
"Dict with 'method' key: 'mse', 'local_hessian', or 'max'. "
"Dict with 'method' key: 'mse', 'four_over_six', 'local_hessian', or 'max'. "
"Optional keys include 'fp8_scale_sweep' for FP4 formats. "
"Defaults to {'method': 'mse'} if None."
),
Expand Down Expand Up @@ -1438,6 +1485,129 @@ def _validate_tied_amax(self):
QuantizeAlgoCfgType = _QuantizeAlgoCfgType | list[_QuantizeAlgoCfgType] | None


# Algorithms that search weight amax, and so can make the per-block M=6/M=4 choice. `mse`
# and `local_hessian` search a configurable grid; `four_over_six` fixes it to {1.0, 1.5}.
_FOUR_OVER_SIX_CAPABLE_ALGORITHMS = frozenset({"four_over_six", "mse", "local_hessian"})


# Algorithms whose weight-scale search sits one level down:
# {method: (field, what the call site substitutes when the field is unset)}.
_BUNDLED_SCALE_ALGORITHMS = {
"lsq": ("scale_algorithm", "mse"),
"nvfp4_act_headroom": ("weight_scale_algorithm", "max"),
}


def _algorithm_methods(algorithm: QuantizeAlgoCfgType) -> list[str | None]:
"""Return the method name of every algorithm an ``algorithm`` value runs.

Handles all four shapes ``algorithm`` accepts (``str``, ``dict``,
:class:`QuantizeAlgorithmConfig`, or a list run in sequence), and descends into
bundled weight-scale algorithms, which search just as much as the outer one.
"""
if isinstance(algorithm, list):
return [method for stage in algorithm for method in _algorithm_methods(stage)]
if algorithm is None:
return [None]

def field(name):
if isinstance(algorithm, str):
# A bare name still has to reach the bundled-default lookup below.
return algorithm if name == "method" else None
return (
algorithm.get(name)
if isinstance(algorithm, Mapping)
else getattr(algorithm, name, None)
)

method = field("method")
methods = [method]
if method in _BUNDLED_SCALE_ALGORITHMS:
nested_field, fallback = _BUNDLED_SCALE_ALGORITHMS[method]
nested = field(nested_field)
methods += _algorithm_methods(fallback if nested is None else nested)
return methods


def has_four_over_six(cfg: Any) -> bool:
"""True if a quantizer attribute config (object or mapping) sets the 4/6 flag."""
if cfg is None:
return False
block_sizes = cfg.get("block_sizes") if isinstance(cfg, Mapping) else cfg.block_sizes
return bool(block_sizes and block_sizes.get("four_over_six"))


def _four_over_six_numerics_problem(cfg: Any) -> str | None:
"""Describe why the 4/6 flag is inert on this quantizer's numerics, or None if it isn't.

Its whole effect -- normalizing the per-block FP8 scales by 256 instead of 448 -- is
Comment on lines +1536 to +1543

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] has_four_over_six (and four_over_six_config_problems below) become part of the mtq.* public namespace as a side effect.

modelopt/torch/quantization/__init__.py:23 does from .config import *, and config.py declares no __all__, so every non-underscore module-level name here is re-exported — this PR therefore adds mtq.has_four_over_six and mtq.four_over_six_config_problems to the public surface, undocumented and untested as public API. Both have exactly one caller each (algorithms.py and model_quant.py), both import by explicit name, so prefixing with _ costs nothing and keeps them internal — consistent with _algorithm_methods / _four_over_six_numerics_problem right next to them, and with _has_four_over_six in algorithms.py (the near-identical name for a different-shaped input is itself a bit of a trap).

read only on the static NVFP4 fake-quant and export paths.
"""
if isinstance(cfg, Mapping):
num_bits, block_sizes = cfg.get("num_bits"), cfg.get("block_sizes") or {}
else:
num_bits, block_sizes = cfg.num_bits, cfg.block_sizes or {}
# TensorQuantizer.is_nvfp4_static minus its `_fake_quant` term, which is a runtime
# state compress flips rather than a property of the config. An absent `type` is static
# there too, since is_static_block_quant tests != "dynamic".
scale_bits, block_type = block_sizes.get("scale_bits"), block_sizes.get("type")
is_static = block_type in (None, "static")
if (_as_exmy(num_bits), is_static, _as_exmy(scale_bits)) == ((2, 1), True, (4, 3)):
Comment on lines +1549 to +1555

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] _as_exmy makes rule 1 strictly more permissive than the runtime predicate the comment says it mirrors, so it blesses one config where the flag really is inert.

QuantizerAttributeConfig.num_bits is int | tuple[int, int] | str with no exmy-normalizing field validator — only the YAML path normalizes (config_loader._parse_exmy_num_bits). So a hand-written Python config keeps num_bits="e2m1" / block_sizes["scale_bits"]="e4m3" verbatim, and _as_exmy maps both back to (2, 1) / (4, 3) → this returns None (no problem). But TensorQuantizer.is_nvfp4_static (tensor_quantizer.py:576-581) compares self._num_bits == (2, 1) and self._block_sizes.get("scale_bits") == (4, 3) against the unnormalized strings, so it is False, the static-NVFP4 fake-quant path is never taken, and four_over_six stays inert — exactly the silent half-configuration this PR exists to reject.

Not blocking: a string num_bits is documented as "current used only for custom backends" and such a quantizer is broken for bigger reasons than the flag, and no shipped recipe uses that spelling. But since the comment above claims this is "the config-level spelling of TensorQuantizer.is_nvfp4_static", the two should agree. Either drop the string branch from _as_exmy (making rule 1 reject the string spelling, matching the runtime), or normalize num_bits/scale_bits on QuantizerAttributeConfig so the runtime predicate sees tuples too — the latter fixes the root cause.

return None
return (
"block_sizes['four_over_six'] is only supported on static NVFP4 weight quantizers "
"(num_bits e2m1, type 'static', scale_bits e4m3), because its only effect is to "
"normalize the per-block FP8 scales by 256 instead of 448 on that path. Got "
f"num_bits={num_bits!r}, type={block_type!r}, scale_bits={scale_bits!r}."
)


def four_over_six_config_problems(quant_cfg, algorithm: QuantizeAlgoCfgType) -> list[str]:
"""Report every way a config has one half of NVFP4 4/6 without the other.

Either alone is silently wrong: the flag without a search pays for headroom nothing
uses, and the search without the flag encodes the M=4 blocks against the wrong
normalization.

``quant_cfg`` is last-wins layered, so without a model we cannot tell which entry owns
a given quantizer; the flag scan is presence-based over entries that are not explicitly
disabled.
"""
problems, flagged = [], []
for entry in quant_cfg:
cfg = entry.get("cfg") if isinstance(entry, Mapping) else entry.cfg
enabled = entry.get("enable", True) if isinstance(entry, Mapping) else entry.enable
name = entry.get("quantizer_name") if isinstance(entry, Mapping) else entry.quantizer_name
if not enabled or cfg is None:
continue
# A SequentialQuantizer entry carries one attribute config per level.
for level in cfg if isinstance(cfg, list) else [cfg]:
if not has_four_over_six(level):
continue
flagged.append(name)
problem = _four_over_six_numerics_problem(level)
if problem:
problems.append(f"{name}: {problem}")

methods = _algorithm_methods(algorithm)
if flagged and not (set(methods) & _FOUR_OVER_SIX_CAPABLE_ALGORITHMS):
problems.append(
f"quant_cfg enables four_over_six on {flagged}, but algorithm {algorithm!r} "
"never searches weight scales, so the per-block M=6/M=4 choice is never made. "
"The quantizer pays the 256 FP8 normalization and gets nothing for it. Use "
f"algorithm 'four_over_six' (or one of {sorted(_FOUR_OVER_SIX_CAPABLE_ALGORITHMS)}), "
"or drop the four_over_six flag."
)
if "four_over_six" in methods and not flagged:
problems.append(
"algorithm 'four_over_six' searches the per-block M=6/M=4 choice, but no enabled "
"quant_cfg entry sets block_sizes['four_over_six'], so the resulting per-block FP8 "
"scales would be normalized by 448 and the M=4 blocks encoded wrongly. Add "
"four_over_six: true to the static NVFP4 weight quantizers, or use algorithm 'mse'."
)
return problems


def normalize_quant_cfg_list(
v: RawQuantizeQuantCfgType | DeprecatedQuantCfgType,
) -> list[QuantizerCfgEntry]:
Expand Down Expand Up @@ -1610,6 +1780,18 @@ def normalize_quant_cfg(
"""
return normalize_quant_cfg_list(v)

@model_validator(mode="after")
def _warn_on_four_over_six_mismatch(self):
"""Warn here, raise in :func:`mtq.quantize`.

This validator also runs when a stored config is reconstructed on the restore path,
where the mismatch is not actionable and raising would make an already-saved
checkpoint unloadable. Enforcement lives at the quantize boundary instead.
"""
for problem in four_over_six_config_problems(self.quant_cfg, self.algorithm):
warnings.warn(f"NVFP4 four_over_six: {problem}")
return self


class CompressConfig(ModeloptBaseConfig):
"""Default configuration for ``compress`` mode."""
Expand Down
Loading
Loading