From 999efbd600e8183512d9ab957e932febc25f87f7 Mon Sep 17 00:00:00 2001 From: Zhiyu Cheng Date: Mon, 17 Aug 2026 13:30:00 -0700 Subject: [PATCH] feat(quantization): fail fast when a quant config matches no weight quantizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quant_cfg whose module patterns do not match the model is not an error to `set_quantizer_by_cfg` — every pattern simply matches nothing — so the run calibrates, exports, and hands back a checkpoint with `"quant_algo": null` and an empty `quantized_layers`. Nothing in the run says so; it has only ever been caught by someone reading the exported `hf_quant_config.json` afterwards, and it has now bitten several MoE architectures whose module naming differs from the wildcards in the general recipes. `mtq.quantize` now checks the config's own intent against the outcome and raises before calibration when a config asks for weight quantization but none of its weight-quantizer patterns match anything in the model. Configs that quantize only activations or the KV cache declare no weight intent and are unaffected, as are weight patterns that match and are then switched off by a later entry (a deliberate choice, not a mismatch), and configs refining a model that already has weight quantizers enabled. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Zhiyu Cheng --- CHANGELOG.rst | 1 + modelopt/torch/quantization/model_quant.py | 60 ++++++++++++++++++- .../torch/quantization/test_quantize_cpu.py | 53 ++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ec9e7ffdf5e..64f51912a20 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,7 @@ Changelog - Move the Mistral Medium 3.5 checkpoint-mirror recipe from ``huggingface/models/nvidia/Mistral-Medium-3.5-128B-NVFP4/ptq/nvfp4-max-calib`` to ``huggingface/models/mistralai/Mistral-Medium-3.5-128B/ptq/nvfp4-max-calib``, keying it by the canonical Hugging Face base model. Update any saved ``--recipe`` paths to the new location. - Transformer Engine ``TEGroupedMLP`` (fused MoE experts) now uses **per-expert** weight quantization (one ``amax`` per expert) instead of a single shared ``amax``, so ModelOpt checkpoints containing quantized ``TEGroupedMLP`` modules saved before 0.47 are **not compatible** with 0.47. Re-run PTQ to regenerate compatible checkpoints. +- ``mtq.quantize`` now raises when a config asks for weight quantization but none of its weight-quantizer patterns match the model, instead of calibrating and exporting a silently unquantized checkpoint (``"quant_algo": null``). Configs that quantize activations or the KV cache only are unaffected, as are patterns that match and are then disabled by a later entry. If this fires, use the recipe for that architecture under ``modelopt_recipes/huggingface//`` or fix the module patterns. **Deprecations** diff --git a/modelopt/torch/quantization/model_quant.py b/modelopt/torch/quantization/model_quant.py index 966a3643fe3..42b2dc2364d 100644 --- a/modelopt/torch/quantization/model_quant.py +++ b/modelopt/torch/quantization/model_quant.py @@ -141,6 +141,60 @@ def postprocess_amax(model: nn.Module, key: str, post_process_fn) -> nn.Module: return model +def _check_weight_quantization_took_effect(model: nn.Module, config: QuantizeConfig) -> None: + """Raise when a config asks for weight quantization but enables no weight quantizer. + + A config whose module patterns do not match the model is not an error to + :func:`set_quantizer_by_cfg` — every pattern simply matches nothing — so the run + proceeds through calibration and export and produces a checkpoint that is silently + unquantized (``"quant_algo": null`` with an empty ``quantized_layers``). That has + bitten several MoE architectures whose module naming differs from the wildcards in + the general recipes, and it is only noticed when someone reads the exported config. + + The mismatch is reported against the config's own intent: only configs that ask for + weight quantization are checked, so activation-only or KV-cache-only configs are + unaffected. A config whose weight patterns do match but are then switched off by a + later entry is a deliberate choice, not a mismatch, so only entries matching nothing + at all count. + """ + weight_entries = [ + entry + for entry in config.quant_cfg + if entry.enable and "weight_quantizer" in entry.quantizer_name + ] + if not weight_entries: + return + + quantizers = [ + (name, module) + for name, module in model.named_modules() + if isinstance(module, TensorQuantizer) + ] + if any( + fnmatch.fnmatch(name, entry.quantizer_name) + for entry in weight_entries + for name, _ in quantizers + ): + return + # Nothing this config asked to quantize exists. Anything already enabled came from an + # earlier `mtq.quantize` call on this model, which this config is refining rather than + # establishing — leave those alone. + if any(module.is_enabled for name, module in quantizers if "weight_quantizer" in name): + return + + patterns = "\n ".join(sorted({entry.quantizer_name for entry in weight_entries})) + raise RuntimeError( + "The quantization config asks for weight quantization but no weight quantizer was " + f"enabled, so nothing would be quantized ({len(quantizers)} quantizer(s) inserted). " + "These patterns matched no weight quantizer:\n " + f"{patterns}\n" + "Either the patterns do not match this architecture's module names (check the " + "model-specific recipes under modelopt_recipes/huggingface//), or the " + "modules holding the weights were never converted to quantized modules (an " + "unsupported custom module, e.g. a trust_remote_code MoE layout)." + ) + + def quantize( model: nn.Module, config: dict[str, Any | QuantizeConfig], @@ -238,12 +292,14 @@ def forward_loop(model) -> None: Returns: A pytorch model which has been quantized and calibrated. """ + quantize_config = QuantizeConfig(**dict(config)) if not is_quantized(model): model = apply_mode(model, mode=[("quantize", dict(config))], registry=QuantizeModeRegistry) else: # Already quantized, so lets apply the quant_cfg from the config - quant_cfg = QuantizeConfig(**dict(config)).quant_cfg - set_quantizer_by_cfg(model, quant_cfg) + set_quantizer_by_cfg(model, quantize_config.quant_cfg) + # Fail before calibration rather than after exporting an unquantized checkpoint. + _check_weight_quantization_took_effect(model, quantize_config) return calibrate(model, config.get("algorithm"), forward_loop=forward_loop) diff --git a/tests/unit/torch/quantization/test_quantize_cpu.py b/tests/unit/torch/quantization/test_quantize_cpu.py index 3e4925e7b63..e1cf709e75d 100644 --- a/tests/unit/torch/quantization/test_quantize_cpu.py +++ b/tests/unit/torch/quantization/test_quantize_cpu.py @@ -441,6 +441,59 @@ def test_enable_only_entry_preserves_attributes(): assert module.axis == 0, "axis should be preserved by enable-only entry" +def test_weight_patterns_matching_nothing_raise(): + """A config whose weight patterns match no module must fail, not quantize nothing. + + Otherwise calibration and export run to completion and produce a checkpoint that is + silently unquantized (``"quant_algo": null``). + """ + model = SimpleLinear() + config = { + "quant_cfg": [ + {"quantizer_name": "*", "enable": False}, + # No module in this model is named `experts`. + {"quantizer_name": "*.experts.*weight_quantizer", "cfg": {"num_bits": 8, "axis": 0}}, + ], + "algorithm": "max", + } + with pytest.raises(RuntimeError, match="no weight quantizer was enabled"): + mtq.quantize(model, config, lambda m: m(m.get_input())) + + +def test_config_without_weight_quantization_is_allowed(): + """Activation-only configs quantize no weight on purpose and must still run.""" + model = SimpleLinear() + config = { + "quant_cfg": [ + {"quantizer_name": "*", "enable": False}, + {"quantizer_name": "*input_quantizer", "cfg": {"num_bits": 8, "axis": None}}, + ], + "algorithm": "max", + } + model = mtq.quantize(model, config, lambda m: m(m.get_input())) + + for name, module in model.named_modules(): + if name.endswith("weight_quantizer"): + assert not module.is_enabled + + +def test_weight_quantizers_disabled_by_a_later_entry_are_allowed(): + """Patterns that match and are then switched off are a choice, not a mismatch.""" + model = SimpleLinear() + config = { + "quant_cfg": [ + {"quantizer_name": "*weight_quantizer", "cfg": {"num_bits": 4, "axis": 0}}, + {"quantizer_name": "*weight_quantizer", "enable": False}, + ], + "algorithm": "max", + } + model = mtq.quantize(model, config, lambda m: m(m.get_input())) + + for name, module in model.named_modules(): + if name.endswith("weight_quantizer"): + assert not module.is_enabled + + def test_atomicity_later_cfg_entry_does_not_inherit_earlier(): """When two cfg-bearing entries match the same quantizer, the second fully replaces the first.""" model = SimpleLinear()