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()