Files
Model-Optimizer/examples/llm_eval/quantization_utils.py
Shengliang Xu 1cceb950d6 [OMNIML-3689] PTQ quant_cfg semantic correction. Design in doc _quant_cfg.rst (#1094)
### What does this PR do?

#### Summary

Redesigns the `quant_cfg` configuration format in ModelOpt's PyTorch
quantization stack, replacing the previous dict-based format with an
**ordered list of typed `QuantizerCfgEntry` dicts**.

##### Motivation

The old `quant_cfg` dict had several pain points:
- **Ambiguous precedence**: no explicit way to reason about which entry
wins when multiple keys match a quantizer
- **Mixed key namespaces**: wildcard paths and PyTorch class names lived
in the same dict level, requiring ad-hoc dispatch
- **Magic `"default"` key**: an implicit, undocumented catch-all that
was easy to misuse
- **Poor composability**: merging two configs required dict updates that
silently discarded keys
- **No YAML round-trip fidelity**: the nested structure couldn't be
expressed cleanly in YAML

##### New format

`quant_cfg` is now an ordered list of `QuantizerCfgEntry` TypedDicts.
Each entry has:
- `quantizer_name` *(required)*: `fnmatch` wildcard matched against
quantizer module names
- `cfg` *(optional)*: dict (or list of dicts) of
`QuantizerAttributeConfig` fields
- `enable` *(optional)*: toggles quantizer on/off independently of `cfg`
- `parent_class` *(optional)*: restricts match to quantizers whose
parent module is of the given PyTorch class (e.g. `"nn.BatchNorm2d"`)

Entries are applied in list order; later entries override earlier ones.
The canonical pattern is deny-all first (`_base_disable_all`), then
selectively re-enable and configure, then apply standard exclusions
(`_default_disabled_quantizer_cfg`).

##### Changes

**Core library (`modelopt/torch/quantization/`)**

- **`config.py`**:
- Added `QuantizerCfgEntry` TypedDict (line 163) and
`find_quant_cfg_entry_by_path()` helper for exact-match lookup of
entries by path.
- Added `normalize_quant_cfg_list()` (line 1539) that converts legacy
formats (flat dict, single-key dicts, `nn.*`-scoped dicts, `"default"`
key) to canonical `QuantizerCfgEntry` lists. After normalization every
entry is guaranteed to have explicit `quantizer_name`, `enable`, and
`cfg` keys.
- Converted `_default_disabled_quantizer_cfg` and
`_mamba_moe_disabled_quantizer_cfg` from dicts to lists of
`QuantizerCfgEntry`.
- Added `_base_disable_all` (line 205): canonical deny-all entry
(`[{"quantizer_name": "*", "enable": False}]`).
- Converted all ~30 built-in config constants (`INT8_DEFAULT_CFG`,
`FP8_DEFAULT_CFG`, `NVFP4_DEFAULT_CFG`, etc.) to list format using
`*_base_disable_all` and `*_default_disabled_quantizer_cfg` unpacking.
- KV-cache configs (`FP8_KV_CFG`, `NVFP4_KV_CFG`, etc.) are now minimal
lists designed to be concatenated with a primary config — they
intentionally omit `_base_disable_all` and `"algorithm"`.
- Added two `QuantizeConfig` Pydantic field validators: a
`mode="before"` validator that calls `normalize_quant_cfg_list()`, and a
`mode="after"` validator that validates `cfg` dicts against
`QuantizerAttributeConfig`.
- Updated `need_calibration()` to iterate the normalized list instead of
the old dict.
- Changed `QuantizeQuantCfgType` alias from `dict[str | Callable, ...]`
to `list[QuantizerCfgEntry]`.

- **`conversion.py`**:
- Rewrote `set_quantizer_by_cfg()` (line 217) to iterate the list
directly. Each entry's `parent_class` is resolved via
`QuantModuleRegistry[parent_class_name]` (the existing `_DMRegistryCls`
registry).
- Added `set_quantizer_attributes_full()` (line 314): full replacement
of quantizer attributes from a `QuantizerAttributeConfig`. Unspecified
fields revert to defaults, enforcing entry atomicity. Can also upgrade
`TensorQuantizer` → `SequentialQuantizer` or downgrade the reverse.
- Added `set_quantizer_attributes_partial()` (line 384): merges a
partial `dict` of attributes into existing quantizer state. Does NOT
change quantizer structure. Used for enable-only entries.
- Added `set_quantizer_by_cfg_context()` context manager (line 447) that
temporarily applies a `quant_cfg` list and restores original quantizer
state on exit.
- Deprecated `set_quantizer_attribute()` (line 525) with a
`DeprecationWarning` pointing to the new functions.

- **`tensor_quantizer.py`**:
- `TensorQuantizer.set_from_attribute_config()`: narrowed type hint from
`dict` to `dict[str, Any]`.
- Added `_axis_setter` and `_block_sizes_setter` custom setters so that
`axis` and `block_sizes` changes properly propagate to the calibrator
and maintain mutual exclusivity.
- `SequentialQuantizer.set_from_attribute_config()`: narrowed signature
to `list[QuantizerAttributeConfig] | list[dict[str, Any]]` (removed the
old union with single values).

- **`algorithms.py`**:
- Updated `_match_quantizer_cfg()` to iterate the list and return
`(matched_cfg, matched_enable)` tuple with last-match-wins.
- Updated `_cfg_to_dict()`, `estimate_quant_compression()`, and
`QuantRecipe` to work with the list-based format.
- Updated `get_auto_quantize_config()` to emit list-format `quant_cfg`.

- **`model_quant.py`**: `disable_quantizer()` / `enable_quantizer()` now
call `set_quantizer_attributes_partial()` directly instead of the
deprecated `set_quantizer_attribute()`. Updated docstrings and code
examples to show the list format.

- **`utils/core_utils.py`**: `disable_lora_quantizers_in_config()` and
`update_quant_cfg_with_kv_cache_quant()` updated to append
`QuantizerCfgEntry` dicts to the list.

- **Other**: minor updates to `backends/fp8_per_tensor_gemm.py`,
`backends/nvfp4_gemm.py`, `compress.py`, `model_calib.py`,
`export/unified_export_hf.py`, and
`sparsity/attention_sparsity/conversion.py` to use the list format.

- **`onnx/llm_export_utils/quantization_utils.py`**: Updated
quantization config construction to use list format.

**YAML recipes (`modelopt_recipes/`)**

- Converted all 5 general PTQ recipes to the new list format:
  - `general/ptq/fp8_default-fp8_kv.yml`
  - `general/ptq/nvfp4_default-fp8_kv.yml`
  - `general/ptq/nvfp4_experts_only-fp8_kv.yml`
  - `general/ptq/nvfp4_mlp_only-fp8_kv.yml`
  - `general/ptq/nvfp4_omlp_only-fp8_kv.yml`
- Converted model-specific recipe:
`models/Step3.5-Flash/nvfp4-mlp-only.yaml`

**Documentation (`docs/`)**

- New guide: `docs/source/guides/_quant_cfg.rst` — comprehensive
reference covering entry format, ordering semantics, entry atomicity,
`enable` vs `cfg` independence, `parent_class` filtering, and common
patterns (deny-all-then-enable, customizing a built-in config, building
from scratch).
- Updated `_pytorch_quantization.rst` code examples to show the list
format with `copy.deepcopy` and `.append()`.
- Added `_quant_cfg.rst` to the quantization guide table of contents.

**Examples**

- Updated all quantization examples to use the list format:
`deepseek/ptq.py`, `diffusers/quantization/config.py`,
`llm_ptq/hf_ptq.py`, `llm_qat/main.py`, `vllm_serve/vllm_ptq_utils.py`,
`llm_autodeploy/run_auto_quantize.py`, `llm_eval/quantization_utils.py`,
`llm_ptq/example_utils.py`,
`windows/torch_onnx/diffusers/qad_example/sample_example_qad_diffusers.py`,
and 2 notebooks.

**Tests**

- New test file:
`tests/unit/torch/quantization/test_config_validation.py` — unit tests
for `need_calibration()`, `normalize_quant_cfg_list()` (new format,
legacy format conversions, error cases),
`find_quant_cfg_entry_by_path()`, `_match_quantizer_cfg()`, and
`QuantizeConfig` Pydantic validators.
- Extended `tests/unit/torch/quantization/test_quantize_cpu.py` with
tests for `set_quantizer_attributes_full()` (atomicity, parent_class
filtering, SequentialQuantizer creation), list ordering, enable-only
entry behavior, and end-to-end legacy dict format.
- Updated 20+ existing test files across `tests/unit/`, `tests/gpu/`,
`tests/gpu_megatron/`, and `tests/_test_utils/` to use the list format.

##### Backward compatibility

`normalize_quant_cfg_list()` is called automatically by the
`QuantizeConfig` Pydantic `mode="before"` validator, so existing code
passing the old dict-based format (flat dict like `{"*weight_quantizer":
{"num_bits": 8}}`, single-key dict lists, or `nn.*`-scoped dicts with
`parent_class` semantics) continues to work without modification. The
legacy `"default"` key is converted to `quantizer_name: "*"`.

`set_quantizer_attribute()` is preserved as a deprecated wrapper around
`set_quantizer_attributes_partial()`.

#### Test coverage

- **Unit tests**: new `test_config_validation.py` with tests for
normalization, validation, path lookup, and cfg matching. Extended
`test_quantize_cpu.py` with tests for full/partial attribute setting,
ordering, atomicity, and legacy backward compatibility.
- **System testing**:

```
python examples/llm_ptq/hf_ptq.py \
      --model Qwen/Qwen3-8B  \
      --recipe general/ptq/fp8_default-fp8_kv \
      --export_path=build/fp8_default-fp8_kv42  \
      --calib_size=16 \
      --batch_size=0 \
      --trust_remote_code \
      --export_fmt=hf
```

### Additional Information
<!-- E.g. related issue. -->

---------

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
2026-04-06 15:38:44 -07:00

256 lines
9.1 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from transformers import AutoTokenizer
import modelopt.torch.quantization as mtq
from modelopt.torch.quantization.config import need_calibration
from modelopt.torch.quantization.plugins import register_hf_attentions_on_the_fly
from modelopt.torch.utils.dataset_utils import (
create_forward_loop,
get_dataset_dataloader,
get_max_batch_size,
)
MAX_SEQ_LEN = 4096
MAX_OUTPUT_LEN = 512
# This is an example to customize the quantization config.
# Modify your custom config for debugging or research purposes.
CUSTOM_CONFIG = {
"MY_QUANT_CONFIG": {
"quant_cfg": [
*mtq.config._base_disable_all,
{
"quantizer_name": "*weight_quantizer",
"cfg": {"num_bits": 4, "block_sizes": {-1: 128}},
"enable": True,
},
{
"quantizer_name": "*input_quantizer",
"cfg": {"num_bits": 8, "type": "dynamic", "block_sizes": {-1: None}},
},
# Disable sensitive layers such as `lm_head`, gate layers in MoE etc.
*mtq.config._default_disabled_quantizer_cfg,
],
"algorithm": "max",
},
}
def get_tokenizer(ckpt_path, max_seq_len=MAX_SEQ_LEN, trust_remote_code=False):
"""Returns the tokenizer from the model ckpt_path."""
print(f"Initializing tokenizer from {ckpt_path}")
tokenizer = AutoTokenizer.from_pretrained(
ckpt_path,
model_max_length=max_seq_len,
padding_side="left",
trust_remote_code=trust_remote_code,
)
# can't set attribute 'pad_token' for "<unk>"
if tokenizer.pad_token != "<unk>":
tokenizer.pad_token = tokenizer.eos_token
return tokenizer
def _quantize_model_with_dataset(
lm,
quant_cfg: str | list[str],
calib_dataset,
auto_quantize_bits=None,
auto_quantize_method="gradient",
auto_quantize_score_size=128,
batch_size=1,
compress=False,
auto_quantize_checkpoint=None,
):
if hasattr(lm, "gpt2"):
net = lm.gpt2
elif hasattr(lm, "model"):
net = lm.model
else:
net = lm
if auto_quantize_bits is not None:
quant_cfg_for_search = [
getattr(mtq, quant_fmt) for quant_fmt in quant_cfg if quant_fmt != "NONE"
]
# Configure forward_step and loss_func based on method
if auto_quantize_method == "gradient":
# For gradient-based method, return full output with loss
def forward_step(model, batch):
return model(**batch)
def loss_func(output, data):
# For transformers AutoModelForCausalLM models, the outputs are wrapped in `CausalLMOutputWithPast`
# which contains the loss attribute.
return output.loss
elif auto_quantize_method == "kl_div":
# For KL divergence method, return only logits
def forward_step(model, batch):
return model(**batch).logits
loss_func = None # KL divergence doesn't need a custom loss function
else:
raise ValueError(
f"Invalid auto_quantize_method: {auto_quantize_method}. "
"Must be 'gradient' or 'kl_div'"
)
net, _ = mtq.auto_quantize(
net,
constraints={"effective_bits": auto_quantize_bits},
quantization_formats=quant_cfg_for_search,
data_loader=calib_dataset,
forward_step=forward_step,
loss_func=loss_func,
num_calib_steps=len(calib_dataset),
# Most time is spent on score estimation; fewer samples speed it up with little accuracy impact.
num_score_steps=min(len(calib_dataset), max(auto_quantize_score_size // batch_size, 1)),
verbose=True,
method=auto_quantize_method,
# disabled_layers=["*lm_head*", "*mlp.gate.*"],
checkpoint=auto_quantize_checkpoint,
)
else:
mtq_cfg = CUSTOM_CONFIG.get(quant_cfg) # type: ignore [arg-type]
if mtq_cfg is None:
mtq_cfg = getattr(mtq, quant_cfg) # type: ignore [arg-type]
calibrate_loop = None
use_calibration = need_calibration(mtq_cfg)
if not use_calibration:
print("Dynamic quantization. Calibration skipped.")
else:
# The calibrate_loop is a custom defined method to run the model with the input data.
# The basic version looks like:
#
# def calibrate_loop(model, dataloader):
# for data in dataloader:
# model(**data)
#
# We also provided a util method to generate the forward_loop with additional error handlings.
calibrate_loop = create_forward_loop(dataloader=calib_dataset)
quantize_bmm_attention = False
for key in mtq_cfg["quant_cfg"]:
if "bmm_quantizer" in key:
quantize_bmm_attention = True
if quantize_bmm_attention:
register_hf_attentions_on_the_fly(net)
net = mtq.quantize(net, mtq_cfg, calibrate_loop)
mtq.print_quant_summary(net)
# Compress or fold weights for faster evaluation.
if compress:
mtq.compress(net)
else:
mtq.fold_weight(net)
def quantize_model(
model,
quant_cfg: str | list[str],
tokenizer,
batch_size,
calib_size,
data="cnn_dailymail",
test_generated=True,
compress=False,
auto_quantize_bits=None,
auto_quantize_method="gradient",
auto_quantize_score_size=128,
auto_quantize_checkpoint=None,
):
"""Quantizes the model with the provided calibration dataset.
Args:
model: the model to be quantized.
quant_cfg: the quantization algorithm config name if simple quantization is used.
the list of quantization algorithm config names if auto quantization is used.
tokenizer: the tokenizer.
batch_size: the calibration batch size for each calibration inference run.
calib_size: the total calibration dataset size.
data: the name of the calibration dataset.
test_generated: If ``True``, test the generated text before and after quantization.
compress: If ``True``, compress the model after quantization.
auto_quantize_bits: The effective bits constraint for auto_quantize.
auto_quantize_method: The method for auto_quantize ('gradient' or 'kl_div').
auto_quantize_score_size: Number of samples used for auto_quantize scoring.
auto_quantize_checkpoint: Path to checkpoint file for saving/restoring auto_quantize search state
(sensitivity scores, costs, etc.). Only used when auto_quantize_bits is specified.
"""
if "AWQ" in quant_cfg:
print(
"\n####\nAWQ calibration could take longer than other calibration methods. "
"Consider reducing calib_size to reduce calibration time.\n####\n"
)
device = model.device
if hasattr(model, "model"):
device = model.model.device
is_gradient_based = auto_quantize_bits is not None and auto_quantize_method == "gradient"
if batch_size == 0:
if is_gradient_based or torch.distributed.is_initialized():
raise ValueError("We dont support automatic batch size inference for this case.")
net = model.gpt2 if hasattr(model, "gpt2") else model.model
# We let the system to determine the max data batch for each forward.
batch_size = get_max_batch_size(net)
print(f"Update calib batch {batch_size}")
calib_dataloader = get_dataset_dataloader(
dataset_name=data,
tokenizer=tokenizer,
batch_size=batch_size,
num_samples=calib_size,
device=device,
include_labels=is_gradient_based,
)
if test_generated:
input_str = tokenizer.decode(next(iter(calib_dataloader))["input_ids"][0])
generated_str_before_ptq = model.run(input_str)
_quantize_model_with_dataset(
model,
quant_cfg,
calib_dataloader,
auto_quantize_bits,
auto_quantize_method,
auto_quantize_score_size,
batch_size,
compress,
auto_quantize_checkpoint,
)
if test_generated:
generated_str_after_ptq = model.run(input_str)
print("--------")
print(f"example test input: {input_str}")
print("--------")
print(f"example outputs before ptq: {generated_str_before_ptq}")
print("--------")
print(f"example outputs after ptq: {generated_str_after_ptq}")