mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-10-02 03:14:52 +08:00
### What does this PR do?
Type of change: Bug fix
The Torch ONNX example tests can exceed their 300-second deadline while
building a ResNet50 INT8 TensorRT engine at optimization level 4. Add
`--trt_builder_optimization_level` to the vision example and select
level 0 in the existing integration tests. The example and helper retain
level 4 by default. Quantization, ONNX export, residual Q/DQ assertions,
engine execution, and test timeout limits are unchanged.
Document the build-time versus inference-performance tradeoff in the
example README.
### Usage
```bash
cd examples/torch_onnx
python torch_quant_to_onnx.py \
--timm_model_name resnet50 \
--recipe timm/resnet/ptq/int8 \
--onnx_save_path resnet50.int8.onnx \
--calibration_data_size 1 --no_pretrained \
--trt_build --trt_builder_optimization_level 0
```
### Testing
Validation used the TensorRT 26.05 container, TensorRT 10.16.1.11, and
PyTorch 2.13.0, with the existing 300-second per-test deadline.
- RTX 6000 Ada: **8 passed**, covering FP8 and INT8 on ViT, Swin,
SwinV2, and ResNet50. ResNet50 INT8 passed in 96.65 seconds.
- RTX PRO 6000 Blackwell Max-Q: **20 passed, 3 existing skips**,
covering the complete test file. ResNet50 INT8 passed in 81.27 seconds;
the baseline timed out at 300 seconds.
```bash
# RTX 6000 Ada: supported FP8/INT8 cases
python -m pytest tests/examples/torch_onnx/test_torch_quant_to_onnx.py \
-k '(fp8 or int8) and not mxfp8' --cov
# RTX PRO 6000 Blackwell: complete example test file
python -m pytest tests/examples/torch_onnx/test_torch_quant_to_onnx.py --cov
```
On each GPU, levels 4 and 0 used the same exported ResNet50 INT8 graph
with TensorRT 10.16.1.11:
- RTX 6000 Ada: TensorRT-reported engine build time decreased from 122.6
seconds at level 4 to 27.7 seconds at level 0. Both builds and inference
runs succeeded.
- RTX PRO 6000 Blackwell Max-Q: the original level-4 test hit its
300-second deadline during the engine build; level 0 built that saved
graph in 10.9 seconds and completed inference successfully.
All pre-commit checks passed for the changed files. The existing
integration tests exercise the real engine build; no redundant mocked
tests were added.
### Before your PR is "*Ready for review*"
Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)
and your commits are signed (`git commit -s -S`).
Make sure you read and follow the [Security Best
Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors)
(e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(...,
weights_only=False)`, `pickle`, etc.).
- Is this change backward compatible?: ✅
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: N/A — the existing integration
tests were updated to exercise level 0; no new test cases were needed.
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
N/A — minor example/CI fix; library behavior and example defaults are
preserved.
- Did you get Claude approval on this PR?: N/A — not requested for this
focused change.
### Additional Information
Example timeout: [ResNet50 INT8 CI
failure](https://github.com/NVIDIA/Model-Optimizer/actions/runs/36560176214/job/109381161169).
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added a configurable TensorRT builder optimization level for engine
builds, with a default of 4 and support for values from 0 to 5.
* Documented that level 0 can speed up builds, while lower optimization
levels may reduce inference performance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Signed-off-by: ajrasane <131806219+ajrasane@users.noreply.github.com>
777 lines
28 KiB
Python
777 lines
28 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 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 argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import warnings
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
|
|
# Add onnx_ptq to path for shared modules
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "onnx_ptq"))
|
|
|
|
import timm
|
|
import torch
|
|
import torch.multiprocessing as mp
|
|
import torch.nn.functional as F
|
|
from datasets import load_dataset
|
|
from download_example_onnx import export_to_onnx
|
|
from evaluation import evaluate
|
|
|
|
import modelopt.torch.quantization as mtq
|
|
from modelopt.recipe import ModelOptAutoQuantizeRecipe, ModelOptPTQRecipe, load_recipe
|
|
from modelopt.recipe.presets import QUANT_CFG_CHOICES, RecipeSupersededAction
|
|
from modelopt.torch.quantization.nn import TensorQuantizer
|
|
from modelopt.torch.quantization.plugins.custom import CUSTOM_POST_CONVERSION_PLUGINS
|
|
|
|
"""
|
|
Quantize a timm vision model and export to ONNX for TensorRT deployment.
|
|
|
|
Supports FP8, INT8, MXFP8, NVFP4, and AUTO (mixed-precision) quantization modes end-to-end
|
|
(quantize + ONNX export + TRT build). INT4_AWQ is quantize/export-only; it is not compatible
|
|
with ``--trt_build``.
|
|
|
|
The script will:
|
|
1. Load a pretrained timm model (e.g., ViT, Swin, ResNet).
|
|
2. Quantize the model using the specified mode. For models with Conv2d layers,
|
|
Conv2d quantization is automatically overridden for TensorRT compatibility
|
|
(FP8 for MXFP8/NVFP4, INT8 for INT4_AWQ).
|
|
3. Export the quantized model to ONNX with FP16 weights.
|
|
4. Optionally evaluate accuracy on ImageNet-1k before and after quantization.
|
|
"""
|
|
|
|
|
|
mp.set_start_method("spawn", force=True) # Needed for data loader with multiple workers
|
|
|
|
|
|
_FP8_CONV_OVERRIDE: list = [
|
|
{
|
|
"parent_class": "nn.Conv2d",
|
|
"quantizer_name": "*weight_quantizer",
|
|
"cfg": {"num_bits": (4, 3), "axis": None},
|
|
},
|
|
{
|
|
"parent_class": "nn.Conv2d",
|
|
"quantizer_name": "*input_quantizer",
|
|
"cfg": {"num_bits": (4, 3), "axis": None},
|
|
},
|
|
]
|
|
|
|
_INT8_CONV_OVERRIDE: list = [
|
|
{
|
|
"parent_class": "nn.Conv2d",
|
|
"quantizer_name": "*weight_quantizer",
|
|
"cfg": {"num_bits": 8, "axis": 0},
|
|
},
|
|
{
|
|
"parent_class": "nn.Conv2d",
|
|
"quantizer_name": "*input_quantizer",
|
|
"cfg": {"num_bits": 8, "axis": None},
|
|
},
|
|
]
|
|
|
|
# FP8 MHA-aware config entries: quantize LayerNorm output so TRT can fuse the shared
|
|
# Q/DQ across all downstream Q/K/V/FC consumers. Softmax-output Q/DQ is handled by the
|
|
# FP8 ONNX exporter's post-processing pass (fixed 1/448 scale, data-independent).
|
|
_FP8_MHA_OVERRIDE: list = [
|
|
{
|
|
"parent_class": "nn.LayerNorm",
|
|
"quantizer_name": "*output_quantizer",
|
|
"cfg": {"num_bits": (4, 3), "axis": None},
|
|
},
|
|
{
|
|
"parent_class": "nn.LayerNorm",
|
|
"quantizer_name": "*input_quantizer",
|
|
"enable": False,
|
|
},
|
|
]
|
|
|
|
# Auto-quantize format presets that use block quantization and need Conv2d overrides for TRT.
|
|
# TRT DynamicQuantize requires 2D/3D input, but Conv2d operates on 4D tensors.
|
|
_NEEDS_FP8_CONV_OVERRIDE: set[str] = {
|
|
"nvfp4_awq_lite",
|
|
"nvfp4",
|
|
"mxfp8",
|
|
}
|
|
_NEEDS_INT8_CONV_OVERRIDE: set[str] = {"int4_awq"}
|
|
|
|
|
|
def get_quant_config(qformat):
|
|
"""Get quantization config, overriding Conv2d for TRT compatibility.
|
|
|
|
The config is loaded from the preset YAML matching ``qformat``. TensorRT only supports FP8
|
|
and INT8 for Conv layers.
|
|
- For FP8: add MHA-aware LayerNorm output quantizer so TRT fuses shared Q/DQ into
|
|
downstream attention matmuls. Softmax-output Q/DQ is inserted by the FP8 ONNX
|
|
exporter's post-processing (fixed 1/448 scale, no calibration needed).
|
|
- For MXFP8, NVFP4: override Conv2d to FP8
|
|
- For INT4_AWQ: override Conv2d to INT8
|
|
"""
|
|
config = deepcopy(QUANT_CFG_CHOICES[qformat])
|
|
if qformat == "fp8":
|
|
config["quant_cfg"].extend(_FP8_MHA_OVERRIDE)
|
|
elif qformat in ("mxfp8", "nvfp4"):
|
|
warnings.warn(
|
|
f"TensorRT only supports FP8/INT8 for Conv layers. "
|
|
f"Overriding Conv2d quantization to FP8 for '{qformat}' format."
|
|
)
|
|
config["quant_cfg"].extend(_FP8_CONV_OVERRIDE)
|
|
config["algorithm"] = "max"
|
|
elif qformat == "int4_awq":
|
|
warnings.warn(
|
|
"TensorRT only supports FP8/INT8 for Conv layers. "
|
|
"Overriding Conv2d quantization to INT8 for 'int4_awq' mode."
|
|
)
|
|
config["quant_cfg"].extend(_INT8_CONV_OVERRIDE)
|
|
return config
|
|
|
|
|
|
def _prepare_auto_quantize_format(fmt):
|
|
config = deepcopy(QUANT_CFG_CHOICES[fmt]) if isinstance(fmt, str) else fmt.model_dump()
|
|
block_num_bits = {
|
|
entry["cfg"]["num_bits"]
|
|
for entry in config["quant_cfg"]
|
|
if isinstance(entry.get("cfg"), dict) and entry["cfg"].get("block_sizes")
|
|
}
|
|
if (isinstance(fmt, str) and fmt in _NEEDS_FP8_CONV_OVERRIDE) or block_num_bits & {
|
|
(4, 3),
|
|
(2, 1),
|
|
}:
|
|
config["quant_cfg"].extend(_FP8_CONV_OVERRIDE)
|
|
elif (isinstance(fmt, str) and fmt in _NEEDS_INT8_CONV_OVERRIDE) or 4 in block_num_bits:
|
|
config["quant_cfg"].extend(_INT8_CONV_OVERRIDE)
|
|
return config
|
|
|
|
|
|
def _add_resnet_residual_quantizers(model):
|
|
"""Add disabled quantizers immediately before each ResNet residual addition.
|
|
|
|
Appending to ``downsample`` places the quantizer on the shortcut immediately before the
|
|
residual addition. Identity shortcuts use an empty ``Sequential`` so the placement is the
|
|
same for every block. Quantizers start disabled and are enabled only by an explicit recipe.
|
|
"""
|
|
block_types = (timm.models.resnet.BasicBlock, timm.models.resnet.Bottleneck)
|
|
for block in (module for module in model.modules() if isinstance(module, block_types)):
|
|
if block.downsample is None:
|
|
block.downsample = torch.nn.Sequential()
|
|
elif not isinstance(block.downsample, torch.nn.Sequential):
|
|
block.downsample = torch.nn.Sequential(block.downsample)
|
|
if hasattr(block.downsample, "residual_quantizer"):
|
|
continue
|
|
residual_quantizer = TensorQuantizer()
|
|
residual_quantizer.disable()
|
|
block.downsample.add_module("residual_quantizer", residual_quantizer)
|
|
|
|
|
|
def _enables_resnet_residual_quantization(recipe):
|
|
if not isinstance(recipe, ModelOptPTQRecipe):
|
|
return False
|
|
for entry in recipe.quantize.quant_cfg:
|
|
config = entry.model_dump(exclude_unset=True)
|
|
patterns = config.get("quantizer_name", [])
|
|
patterns = [patterns] if isinstance(patterns, str) else patterns
|
|
if any("residual_quantizer" in pattern for pattern in patterns) and config.get(
|
|
"enable", True
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def filter_func(name):
|
|
"""Filter function to exclude certain layers from quantization.
|
|
|
|
``downsample.reduction`` (Swin/SwinV2) is excluded because it operates on 4D tensors
|
|
and TRT's DynamicQuantize layer (used for MXFP8/NVFP4) requires 2D/3D input.
|
|
Other 4D-input layers (e.g. Swin's ``norm1``, ``downsample.norm``, top-level ``norm``)
|
|
are handled dynamically by ``_disable_high_rank_input_quantizers`` via a forward-pass
|
|
rank probe — that avoids false positives on ViT, whose same-named ``norm`` sees 3D input.
|
|
"""
|
|
pattern = re.compile(
|
|
r".*(time_emb_proj|time_embedding|conv_in|conv_out|conv_shortcut|add_embedding|"
|
|
r"pos_embed|time_text_embed|context_embedder|norm_out|x_embedder|patch_embed|cpb_mlp|"
|
|
r"maxpool|global_pool|downsample\.reduction).*"
|
|
)
|
|
return pattern.match(name) is not None
|
|
|
|
|
|
def _disable_high_rank_input_quantizers(model, input_shape, device):
|
|
"""Disable quantizers on Linear/LayerNorm modules that receive 4D+ input.
|
|
|
|
TRT's MXFP8/NVFP4 ``DynamicQuantize`` op only supports 2D/3D input, so Swin's
|
|
per-block ``norm1``, ``downsample.norm``, and top-level ``norm`` (all 4D in Swin
|
|
but 3D in ViT) must be skipped. A forward pass with hooks identifies them at
|
|
runtime, so this works across architectures without hardcoded paths.
|
|
"""
|
|
if not any(
|
|
isinstance(quantizer, TensorQuantizer)
|
|
and quantizer.is_enabled
|
|
and quantizer.block_sizes
|
|
and name.endswith("input_quantizer")
|
|
for name, quantizer in model.named_modules()
|
|
):
|
|
return
|
|
|
|
high_rank: set[str] = set()
|
|
handles = []
|
|
for name, mod in model.named_modules():
|
|
if isinstance(mod, (torch.nn.Linear, torch.nn.LayerNorm)):
|
|
|
|
def hook(m, inp, out, _n=name):
|
|
if inp and hasattr(inp[0], "ndim") and inp[0].ndim > 3:
|
|
high_rank.add(_n)
|
|
|
|
handles.append(mod.register_forward_hook(hook))
|
|
|
|
was_training = model.training
|
|
model.eval()
|
|
try:
|
|
with torch.no_grad():
|
|
model(torch.randn(input_shape, device=device))
|
|
finally:
|
|
for h in handles:
|
|
h.remove()
|
|
model.train(was_training)
|
|
|
|
modules = dict(model.named_modules())
|
|
for name in high_rank:
|
|
quantizer = getattr(modules[name], "input_quantizer", None)
|
|
if quantizer is not None and quantizer.is_enabled and quantizer.block_sizes:
|
|
quantizer.disable()
|
|
|
|
|
|
def _disable_low_channel_fp8_conv_input_quantizers(model):
|
|
"""Disable FP8 ``input_quantizer`` on Conv2d modules whose ``in_channels <= 3``.
|
|
|
|
The first Conv2d of an image backbone (e.g. ResNet50's ``conv1``) consumes raw
|
|
RGB input, so ``in_channels == 3``. On Blackwell (compute capability 12.0) TRT
|
|
fails to find an FP8/MXFP8/NVFP4 tactic for this first-layer Q→Conv fusion:
|
|
|
|
Error Code 10: Could not find any implementation for node
|
|
/conv1/input_quantizer/TRT_FP8QuantizeLinear ... [ElementWise]
|
|
|
|
Ada (8.9) happens to have a tactic, which is why local runs pass. Disabling the
|
|
input quantizer on the raw-RGB conv is also standard quantization practice —
|
|
first/last layers are typically left in higher precision. Weight quantization
|
|
still applies. Swin/ViT's ``patch_embed.proj`` is already excluded via
|
|
``filter_func``'s ``patch_embed`` pattern, so this helper is effectively the
|
|
ResNet-shaped analogue.
|
|
"""
|
|
for _, mod in model.named_modules():
|
|
if isinstance(mod, torch.nn.Conv2d) and mod.in_channels <= 3:
|
|
q = getattr(mod, "input_quantizer", None)
|
|
if q is not None and q.is_enabled and q.num_bits == (4, 3):
|
|
q.disable()
|
|
|
|
|
|
def _validate_resnet_quantizers(model):
|
|
"""Reject enabled ResNet quantizers other than per-tensor FP8 or INT8."""
|
|
for name, quantizer in model.named_modules():
|
|
if not isinstance(quantizer, TensorQuantizer) or not quantizer.is_enabled:
|
|
continue
|
|
if quantizer.num_bits not in ((4, 3), 8) or quantizer.block_sizes:
|
|
raise ValueError(
|
|
f"ResNet quantizer '{name}' uses an unsupported format; only FP8 and INT8 "
|
|
"are supported for convolutional models."
|
|
)
|
|
|
|
|
|
def load_calibration_data(model, data_size, batch_size, device, with_labels=False):
|
|
"""Load and prepare calibration data.
|
|
|
|
Args:
|
|
model: The timm model being quantized; used to derive the calibration transforms so the
|
|
data pipeline matches the exact model config (respects --no_pretrained and
|
|
--model_kwargs).
|
|
data_size: Number of samples to load
|
|
batch_size: Batch size for data loader
|
|
device: Device to load data to
|
|
with_labels: If True, return dict with 'image' and 'label' keys (for auto_quantize)
|
|
If False, return just the images (for standard quantize)
|
|
"""
|
|
dataset = load_dataset("zh-plus/tiny-imagenet")
|
|
data_config = timm.data.resolve_model_data_config(model)
|
|
transforms = timm.data.create_transform(**data_config, is_training=False)
|
|
|
|
images = dataset["train"][:data_size]["image"]
|
|
calib_tensor = [transforms(img) for img in images]
|
|
calib_tensor = [t.to(device) for t in calib_tensor]
|
|
|
|
if with_labels:
|
|
labels = dataset["train"][:data_size]["label"]
|
|
labels = torch.tensor(labels, device=device)
|
|
calib_dataset = [{"image": img, "label": lbl} for img, lbl in zip(calib_tensor, labels)]
|
|
return torch.utils.data.DataLoader(
|
|
calib_dataset, batch_size=batch_size, shuffle=True, num_workers=4
|
|
)
|
|
else:
|
|
return torch.utils.data.DataLoader(
|
|
calib_tensor, batch_size=batch_size, shuffle=True, num_workers=4
|
|
)
|
|
|
|
|
|
def _disable_dead_quantizers(model):
|
|
"""Disable quantizers whose calibrated ``amax`` is non-positive or NaN.
|
|
|
|
``export_fp8`` computes ``scale = 448 / amax`` and blows up on ``amax == 0``.
|
|
This shows up on SwinV2 with ``--no_pretrained``: timm's ``res-post-norm`` scheme
|
|
zero-inits each block's ``norm1``/``norm2`` weight and bias, so those LayerNorm
|
|
outputs are exactly zero at init and the MHA override's output_quantizer
|
|
calibrates to ``amax == 0``. Disable such dead quantizers — they have nothing
|
|
meaningful to quantize and would otherwise break ONNX export.
|
|
"""
|
|
for _, mod in model.named_modules():
|
|
for attr in (
|
|
"input_quantizer",
|
|
"output_quantizer",
|
|
"weight_quantizer",
|
|
"residual_quantizer",
|
|
):
|
|
q = getattr(mod, attr, None)
|
|
if q is None or not q.is_enabled:
|
|
continue
|
|
amax = q.amax
|
|
if amax is None or not torch.is_tensor(amax):
|
|
continue
|
|
if torch.any(torch.isnan(amax)) or torch.all(amax <= 0):
|
|
q.disable()
|
|
|
|
|
|
def quantize_model(model, config, data_loader=None):
|
|
"""Quantize the model using the given config and calibration data."""
|
|
if data_loader is not None:
|
|
|
|
def forward_loop(model):
|
|
for batch in data_loader:
|
|
model(batch)
|
|
|
|
quantized_model = mtq.quantize(model, config, forward_loop=forward_loop)
|
|
else:
|
|
quantized_model = mtq.quantize(model, config)
|
|
|
|
mtq.disable_quantizer(quantized_model, filter_func)
|
|
|
|
# Drop quantizers whose calibration saw only zeros (e.g. SwinV2 zero-init norm1/norm2)
|
|
# so ``export_fp8`` doesn't divide by zero.
|
|
_disable_dead_quantizers(quantized_model)
|
|
|
|
return quantized_model
|
|
|
|
|
|
def forward_step(model, batch):
|
|
"""Forward step function for auto_quantize scoring."""
|
|
return model(batch["image"])
|
|
|
|
|
|
def loss_func(output, batch):
|
|
"""Loss function for auto_quantize gradient computation."""
|
|
return F.cross_entropy(output, batch["label"])
|
|
|
|
|
|
def _disable_inplace_relu(model):
|
|
"""Replace inplace ReLU with non-inplace ReLU throughout the model.
|
|
|
|
This is needed for auto_quantize which uses backward hooks for gradient-based
|
|
sensitivity scoring. Inplace ReLU on views created by custom Functions causes
|
|
PyTorch autograd errors.
|
|
"""
|
|
for module in model.modules():
|
|
if isinstance(module, torch.nn.ReLU) and module.inplace:
|
|
module.inplace = False
|
|
|
|
|
|
def _mtq_inputs_from_auto_quantize_config(auto_config, fixed_quantize_config=None):
|
|
"""Map a resolved AutoQuantizeConfig to mtq.auto_quantize inputs."""
|
|
constraints = auto_config.constraints.model_dump(exclude_none=True)
|
|
if auto_config.cost_excluded_layers:
|
|
constraints.setdefault("cost", {})["excluded_module_name_patterns"] = (
|
|
auto_config.cost_excluded_layers
|
|
)
|
|
return {
|
|
"constraints": constraints,
|
|
"quantization_formats": [
|
|
_prepare_auto_quantize_format(fmt) for fmt in auto_config.candidate_formats
|
|
],
|
|
"fixed_quantization_config": (
|
|
_prepare_auto_quantize_format(fixed_quantize_config)
|
|
if fixed_quantize_config is not None
|
|
else None
|
|
),
|
|
"module_search_spaces": [
|
|
{
|
|
"module_name_patterns": search_space.module_name_patterns,
|
|
"quantization_formats": [
|
|
_prepare_auto_quantize_format(candidate)
|
|
for candidate in search_space.candidate_formats
|
|
],
|
|
"allow_no_quant": search_space.allow_no_quant,
|
|
}
|
|
for search_space in auto_config.module_search_spaces
|
|
],
|
|
"disabled_layers": auto_config.disabled_layers,
|
|
"method": auto_config.auto_quantize_method,
|
|
"num_score_steps": auto_config.score_size,
|
|
}
|
|
|
|
|
|
def auto_quantize_model(
|
|
model,
|
|
data_loader,
|
|
quantization_formats,
|
|
effective_bits=None,
|
|
num_calib_steps=512,
|
|
num_score_steps=128,
|
|
recipe=None,
|
|
):
|
|
"""Auto-quantize the model using optimal per-layer quantization search.
|
|
|
|
Args:
|
|
model: PyTorch model to quantize
|
|
data_loader: DataLoader with image-label dict batches
|
|
quantization_formats: List of quantization recipe names (preset basenames,
|
|
e.g. ``nvfp4_awq_lite``) or config dicts
|
|
effective_bits: Target effective bits constraint
|
|
num_calib_steps: Number of calibration steps
|
|
num_score_steps: Number of scoring steps for sensitivity analysis
|
|
|
|
Returns:
|
|
Tuple of (quantized_model, search_state_dict)
|
|
"""
|
|
_disable_inplace_relu(model)
|
|
if recipe is None:
|
|
inputs = {
|
|
"constraints": {"effective_bits": 4.8 if effective_bits is None else effective_bits},
|
|
"quantization_formats": [
|
|
_prepare_auto_quantize_format(fmt) for fmt in quantization_formats
|
|
],
|
|
"fixed_quantization_config": None,
|
|
"module_search_spaces": None,
|
|
"disabled_layers": None,
|
|
"method": "gradient",
|
|
"num_score_steps": num_score_steps,
|
|
}
|
|
else:
|
|
inputs = _mtq_inputs_from_auto_quantize_config(recipe.auto_quantize, recipe.quantize)
|
|
|
|
format_count = len(inputs["quantization_formats"]) or sum(
|
|
len(search_space["quantization_formats"])
|
|
for search_space in inputs["module_search_spaces"] or []
|
|
)
|
|
print(f"Starting auto-quantization search with {format_count} formats...")
|
|
print(f"Effective bits constraint: {inputs['constraints']['effective_bits']}")
|
|
print(f"Calibration steps: {num_calib_steps}, Scoring steps: {inputs['num_score_steps']}")
|
|
|
|
quantized_model, search_state = mtq.auto_quantize(
|
|
model,
|
|
data_loader=data_loader,
|
|
forward_step=forward_step,
|
|
loss_func=loss_func,
|
|
num_calib_steps=num_calib_steps,
|
|
verbose=True,
|
|
**inputs,
|
|
)
|
|
|
|
# Disable quantization for specified layers
|
|
mtq.disable_quantizer(quantized_model, filter_func)
|
|
|
|
_disable_dead_quantizers(quantized_model)
|
|
|
|
return quantized_model, search_state
|
|
|
|
|
|
def get_model_input_shape(model):
|
|
"""Get the input shape from timm model configuration."""
|
|
data_config = timm.data.resolve_model_data_config(model)
|
|
input_size = data_config["input_size"]
|
|
return tuple(input_size)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Quantize timm models to FP8, MXFP8, INT8, NVFP4, or use AUTO quantization. "
|
|
"INT4_AWQ is supported for quantize/export only and is not compatible with --trt_build."
|
|
)
|
|
)
|
|
|
|
# Model hyperparameters
|
|
parser.add_argument(
|
|
"--timm_model_name",
|
|
default="vit_base_patch16_224",
|
|
help="The timm model name to quantize.",
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--qformat",
|
|
action=RecipeSupersededAction,
|
|
choices=["fp8", "mxfp8", "int8", "nvfp4", "int4_awq", "auto"],
|
|
default="mxfp8",
|
|
help=(
|
|
"(deprecated: use --recipe) Quantization format to apply when --recipe is not "
|
|
"provided. Default is MXFP8."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--recipe",
|
|
type=str,
|
|
default=None,
|
|
help=(
|
|
"PTQ or AutoQuantize recipe YAML file or built-in recipe name. The recipe is "
|
|
"authoritative when provided; --qformat is used only without a recipe."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--onnx_save_path",
|
|
required=True,
|
|
help="The save path to save the ONNX model.",
|
|
type=str,
|
|
)
|
|
parser.add_argument(
|
|
"--calibration_data_size",
|
|
type=int,
|
|
default=512,
|
|
help="Number of images to use in calibration [1-512]",
|
|
)
|
|
parser.add_argument(
|
|
"--batch_size",
|
|
type=int,
|
|
default=1,
|
|
help="Batch size for calibration and ONNX model export.",
|
|
)
|
|
parser.add_argument(
|
|
"--evaluate",
|
|
action="store_true",
|
|
help="Evaluate the base and quantized models on ImageNet validation set.",
|
|
)
|
|
parser.add_argument(
|
|
"--eval_data_size",
|
|
type=int,
|
|
default=None,
|
|
help="Number of samples to use for evaluation. If None, use entire validation set.",
|
|
)
|
|
|
|
# Auto quantization specific arguments
|
|
parser.add_argument(
|
|
"--auto_quantization_formats",
|
|
nargs="+",
|
|
choices=[
|
|
"nvfp4_awq_lite",
|
|
"fp8",
|
|
"mxfp8",
|
|
"int8",
|
|
"int4_awq",
|
|
],
|
|
default=["nvfp4_awq_lite", "fp8"],
|
|
help="Quantization preset recipes to search from for auto mode (e.g., nvfp4_awq_lite fp8)",
|
|
)
|
|
parser.add_argument(
|
|
"--effective_bits",
|
|
type=float,
|
|
default=None,
|
|
help=(
|
|
"Target effective bits for --qformat=auto. Defaults to 4.8 and is ignored when "
|
|
"an AutoQuantize recipe is provided."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--num_score_steps",
|
|
type=int,
|
|
default=128,
|
|
help="Number of scoring steps for auto quantization. Default is 128.",
|
|
)
|
|
parser.add_argument(
|
|
"--trt_build",
|
|
action="store_true",
|
|
help="Build a TensorRT engine from the exported ONNX model using trtexec.",
|
|
)
|
|
parser.add_argument(
|
|
"--trt_builder_optimization_level",
|
|
type=int,
|
|
choices=range(6),
|
|
default=4,
|
|
help=(
|
|
"TensorRT builder optimization level. Lower levels build faster but may reduce "
|
|
"inference performance."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--no_pretrained",
|
|
action="store_true",
|
|
help="Don't load pretrained weights (useful for testing with random weights).",
|
|
)
|
|
parser.add_argument(
|
|
"--model_kwargs",
|
|
type=str,
|
|
default=None,
|
|
help="JSON string of extra model kwargs (e.g., '{\"depth\": 1}').",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
recipe = load_recipe(args.recipe) if args.recipe is not None else None
|
|
if recipe is not None and not isinstance(
|
|
recipe, (ModelOptPTQRecipe, ModelOptAutoQuantizeRecipe)
|
|
):
|
|
parser.error(
|
|
f"Expected a PTQ or AutoQuantize recipe, got {type(recipe).__name__} from {args.recipe}."
|
|
)
|
|
|
|
# Create model and move to appropriate device
|
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
model_kwargs = json.loads(args.model_kwargs) if args.model_kwargs else {}
|
|
model = timm.create_model(
|
|
args.timm_model_name,
|
|
pretrained=not args.no_pretrained,
|
|
num_classes=1000,
|
|
**model_kwargs,
|
|
).to(device)
|
|
|
|
# Get input shape from model config
|
|
input_size = get_model_input_shape(model)
|
|
input_shape = (args.batch_size, *input_size)
|
|
|
|
# Evaluate base model if requested
|
|
if args.evaluate:
|
|
print("\n=== Evaluating Base Model ===")
|
|
data_config = timm.data.resolve_model_data_config(model)
|
|
transforms = timm.data.create_transform(**data_config, is_training=False)
|
|
top1, top5 = evaluate(
|
|
model, transforms, batch_size=args.batch_size, num_examples=args.eval_data_size
|
|
)
|
|
print(f"Base Model - Top-1 Accuracy: {top1:.2f}%, Top-5 Accuracy: {top5:.2f}%")
|
|
|
|
is_resnet = isinstance(model, timm.models.resnet.ResNet)
|
|
run_auto_quantize = isinstance(recipe, ModelOptAutoQuantizeRecipe) or (
|
|
recipe is None and args.qformat == "auto"
|
|
)
|
|
if is_resnet and run_auto_quantize:
|
|
raise ValueError("AutoQuantize is not supported for convolutional models such as ResNet.")
|
|
if is_resnet and recipe is None and args.qformat not in {"fp8", "int8"}:
|
|
raise ValueError(
|
|
f"ResNet does not support qformat '{args.qformat}'; only FP8 and INT8 are supported."
|
|
)
|
|
if is_resnet and _enables_resnet_residual_quantization(recipe):
|
|
CUSTOM_POST_CONVERSION_PLUGINS.add(_add_resnet_residual_quantizers)
|
|
|
|
if run_auto_quantize:
|
|
# Auto quantization requires labels for loss computation
|
|
data_loader = load_calibration_data(
|
|
model,
|
|
args.calibration_data_size,
|
|
args.batch_size,
|
|
device,
|
|
with_labels=True,
|
|
)
|
|
|
|
quantized_model, _ = auto_quantize_model(
|
|
model,
|
|
data_loader,
|
|
args.auto_quantization_formats,
|
|
args.effective_bits,
|
|
args.calibration_data_size,
|
|
args.num_score_steps,
|
|
recipe=recipe,
|
|
)
|
|
else:
|
|
# Standard quantization - load calibration data
|
|
# Note: MXFP8 is dynamic and does not need calibration itself, but when
|
|
# Conv2d layers are overridden to FP8 (for TRT compatibility), those FP8
|
|
# quantizers require calibration data.
|
|
config = (
|
|
recipe.quantize.model_dump()
|
|
if isinstance(recipe, ModelOptPTQRecipe)
|
|
else get_quant_config(args.qformat)
|
|
)
|
|
|
|
data_loader = load_calibration_data(
|
|
model,
|
|
args.calibration_data_size,
|
|
args.batch_size,
|
|
device,
|
|
with_labels=False,
|
|
)
|
|
|
|
quantized_model = quantize_model(model, config, data_loader)
|
|
|
|
if is_resnet:
|
|
_validate_resnet_quantizers(quantized_model)
|
|
|
|
# Disable block quantizers on 4D-input layers, which TRT DynamicQuantize does not support.
|
|
_disable_high_rank_input_quantizers(quantized_model, input_shape, device)
|
|
|
|
# Blackwell has no tactic for an FP8 Q→Conv fusion on the first RGB layer.
|
|
_disable_low_channel_fp8_conv_input_quantizers(quantized_model)
|
|
|
|
# Print quantization summary
|
|
print("\nQuantization Summary:")
|
|
mtq.print_quant_summary(quantized_model)
|
|
|
|
# Evaluate quantized model if requested
|
|
if args.evaluate:
|
|
print("\n=== Evaluating Quantized Model ===")
|
|
data_config = timm.data.resolve_model_data_config(quantized_model)
|
|
transforms = timm.data.create_transform(**data_config, is_training=False)
|
|
top1, top5 = evaluate(
|
|
quantized_model,
|
|
transforms,
|
|
batch_size=args.batch_size,
|
|
num_examples=args.eval_data_size,
|
|
)
|
|
print(f"Quantized Model - Top-1 Accuracy: {top1:.2f}%, Top-5 Accuracy: {top5:.2f}%")
|
|
|
|
# Export to ONNX
|
|
export_to_onnx(
|
|
quantized_model,
|
|
input_shape,
|
|
args.onnx_save_path,
|
|
device,
|
|
weights_dtype="fp16",
|
|
)
|
|
|
|
print(f"Quantized ONNX model is saved to {args.onnx_save_path}")
|
|
|
|
if args.trt_build:
|
|
build_trt_engine(args.onnx_save_path, args.trt_builder_optimization_level)
|
|
|
|
|
|
def build_trt_engine(onnx_path, optimization_level=4):
|
|
"""Build a TensorRT engine from the exported ONNX model using trtexec."""
|
|
cmd = [
|
|
"trtexec",
|
|
f"--onnx={onnx_path}",
|
|
"--stronglyTyped",
|
|
f"--builderOptimizationLevel={optimization_level}",
|
|
]
|
|
print(f"\nBuilding TensorRT engine: {' '.join(cmd)}")
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
except FileNotFoundError as e:
|
|
raise RuntimeError(
|
|
"trtexec not found on PATH; install TensorRT or drop --trt_build."
|
|
) from e
|
|
except subprocess.TimeoutExpired as e:
|
|
raise RuntimeError(f"trtexec timed out building {onnx_path} after 600s.") from e
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"TensorRT engine build failed for {onnx_path}:\n{result.stdout}\n{result.stderr}"
|
|
)
|
|
print("TensorRT engine build succeeded.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|