Files
Model-Optimizer/tests/_test_utils/torch/quantization/quantize_common.py
T
realAsma ac30686c82 Track global_amax for weight FP4 MSE sweep; Refactor to NVFP4StaticQantizer, NVFP4MSECalibrator (#849)
## What does this PR do?

**Type of change:** ? <!-- Use one of the following: Bug fix, new
feature, new example, new tests, documentation. -->

**Overview:** ?

## Usage
<!-- You can potentially add a usage example below. -->

```python
# Add a code snippet demonstrating how to use this
```

## Testing
<!-- Mention how have you tested your change if applicable. -->

## Before your PR is "*Ready for review*"
<!-- If you haven't finished some of the above items you can still open
`Draft` PR. -->

- **Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)**
and your commits are signed.
- **Is this change backward compatible?**: Yes/No <!--- If No, explain
why. -->
- **Did you write any new necessary tests?**: Yes/No
- **Did you add or update any necessary documentation?**: Yes/No
- **Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?**:
Yes/No <!--- Only for new features, API changes, critical bug fixes or
bw breaking changes. -->

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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added NVFP4StaticQuantizer for improved 4-bit quantization with
enhanced precision control
* Introduced NVFP4MSECalibrator with flexible candidate generation for
calibration optimization

* **Improvements**
* Optimized GPU kernels for Hopper+ graphics cards with better
performance
  * Extended Triton support to broader GPU compatibility
* Enhanced backward compatibility for restoring previously quantized
models

* **Tests**
* Added comprehensive test coverage for new quantizers and calibration
methods

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: realAsma <akuriparambi@nvidia.com>
2026-02-06 19:47:36 +00:00

299 lines
11 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 copy
from unittest.mock import patch
import pytest
import torch
import torch.distributed
import torch.distributed as dist
from packaging.version import Version
import modelopt.torch.opt as mto
import modelopt.torch.quantization as mtq
import modelopt.torch.quantization.model_calib as model_calib_module # needed for patching awq_lite
from modelopt.torch.quantization.backends.gemm_registry import enable_real_quant_gemm
from modelopt.torch.quantization.nn.modules.tensor_quantizer import SequentialQuantizer
from modelopt.torch.quantization.utils import is_quantized_linear
from modelopt.torch.utils import torch_to
INT4_AWQ_FULL_CFG = mtq.INT4_AWQ_CFG.copy()
INT4_AWQ_FULL_CFG["algorithm"] = "awq_full"
INT4_AWQ_CLIP_CFG = mtq.INT4_AWQ_CFG.copy()
INT4_AWQ_CLIP_CFG["algorithm"] = "awq_clip"
# SVDQuant test cfg
INT4_SVDQUANT_CFG = mtq.INT4_AWQ_CFG.copy()
INT4_SVDQUANT_CFG["algorithm"] = {"method": "svdquant", "lowrank": 8}
# SVDQuant test cfg
FP4_SVDQUANT_CFG = mtq.NVFP4_AWQ_LITE_CFG.copy()
FP4_SVDQUANT_CFG["algorithm"] = {"method": "svdquant", "lowrank": 8}
def get_awq_config(algorithm="awq_lite", block_size=8):
config = copy.deepcopy(mtq.INT4_AWQ_CFG)
config["quant_cfg"]["*weight_quantizer"]["block_sizes"] = {-1: block_size}
config["algorithm"]["method"] = algorithm
config["algorithm"]["debug"] = True
if algorithm == "awq_clip":
config["algorithm"].pop("alpha_step")
return config
def quantize_model_and_forward(model, config, calib_data, compress=False):
def forward_loop(model, run_backward=False):
for batch in calib_data:
output = model(batch)
if run_backward:
output.sum().backward()
model = mtq.quantize(model, config, forward_loop=forward_loop)
if compress:
mtq.compress(model)
for module in model.modules():
assert not isinstance(module, torch.nn.Linear) or is_quantized_linear(module)
model.train()
# make sure we can run the forward and backward on the quantized model
forward_loop(model, run_backward=True)
def save_restore_test(
model_cls,
device,
quant_config,
compress=False,
version=None,
test_cpu_restore: bool = False,
):
# test restoring to an unquantized model
model_quant = model_cls().to(device)
model_ref = model_cls().to(device)
calib_data = [model_quant.get_input().to(device) for _ in range(2)]
quantize_model_and_forward(model_quant.to(device), quant_config, calib_data, compress)
state_dict = mto.modelopt_state(model_quant)
mto.restore_from_modelopt_state(model_ref, state_dict)
model_ref.load_state_dict(model_quant.state_dict())
assert torch.allclose(model_quant(calib_data[0]), model_ref(calib_data[0]))
# Verify that TensorQuantizer subclass types are preserved after restore
for name_q, mod_q in model_quant.named_modules():
if name_q.endswith("quantizer"):
mod_r = dict(model_ref.named_modules())[name_q]
assert type(mod_q) is type(mod_r), (
f"Quantizer class mismatch for '{name_q}': "
f"expected {type(mod_q).__name__}, got {type(mod_r).__name__}"
)
if version is not None and Version(version) < Version("0.29"):
# Rest of the tests are not needed for version < 0.29
return
if test_cpu_restore:
# gpu: test restoring to a model on cpu. If the quantizer states are not initialized correctly,
# the buffers will be created on cuda and this test will fail
model_ref = model_cls().to("cpu")
state_dict = torch_to(state_dict, device="cuda" if torch.cuda.is_available() else "cpu")
mto.restore_from_modelopt_state(model_ref, state_dict)
model_ref.load_state_dict(model_quant.state_dict())
model_ref(
calib_data[0].to("cpu")
) # make sure all the buffers are created in the right device
model_ref.to(device)
assert torch.allclose(model_quant(calib_data[0]), model_ref(calib_data[0]))
# Test that smoothquant is restored correctly
if quant_config == mtq.INT8_SMOOTHQUANT_CFG:
for name, module in model_ref.named_modules():
if isinstance(module, torch.nn.Linear):
assert module.input_quantizer.axis is None
# test restoring to a quantized model
model_ref = model_cls().to(device)
quantize_model_and_forward(model_ref, mtq.INT4_BLOCKWISE_WEIGHT_ONLY_CFG, calib_data)
with pytest.raises(AssertionError, match="Model already has modelopt state!"):
mto.restore_from_modelopt_state(model_ref, state_dict)
def _distributed_attr_check(quantizer, attr: str, op=dist.ReduceOp.MAX, groups=[]):
quantizer_attr = getattr(quantizer, attr).clone()
for group in groups:
if group is not None:
dist.all_reduce(quantizer_attr, op=op, group=group)
assert torch.allclose(quantizer_attr, getattr(quantizer, attr))
def verify_kv_cache_amax_sync(model, group=None):
kv_quantizers_found = False
for name, module in model.named_modules():
if hasattr(module, "k_bmm_quantizer") and hasattr(module, "v_bmm_quantizer"):
kv_quantizers_found = True
for quantizer in [module.k_bmm_quantizer, module.v_bmm_quantizer]:
if quantizer.amax is not None:
quantizer_amax = quantizer.amax.clone()
dist.all_reduce(quantizer_amax, op=dist.ReduceOp.MAX, group=group)
assert torch.allclose(quantizer_amax, quantizer.amax), (
f"KV cache quantizer amax not synced across distributed group for {name}"
)
return kv_quantizers_found
original_awq_lite = model_calib_module.awq_lite
def _debug_awq_lite(model, forward_loop, alpha_step=0.1, debug=True, **kwargs):
"""Function to mock awq_lite function to always use debug=True for testing"""
return original_awq_lite(model, forward_loop, alpha_step, debug=True, **kwargs)
@patch("modelopt.torch.quantization.model_calib.awq_lite", side_effect=_debug_awq_lite)
def data_tensor_context_parallel_test_helper(
model, config, mock_awq_lite, dp_group=None, tp_group=None, test_pre_quant_scale=True
):
# Calib data should be different across each DP rank
dp_rank = dist.get_rank(group=dp_group)
calib_data = model.get_dummy_input(seed=dp_rank).cuda()
if tp_group is not None:
# The input to first layer, the column parallel should be the same across all tp ranks
dist.all_reduce(calib_data, op=dist.ReduceOp.AVG, group=tp_group)
def forward_loop(model):
model(calib_data)
model = mtq.quantize(model, config, forward_loop)
# Input quantizer amax
if config not in [mtq.INT4_BLOCKWISE_WEIGHT_ONLY_CFG, mtq.INT4_AWQ_CFG]:
_distributed_attr_check(
model.fc1.input_quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
_distributed_attr_check(
model.fc2.input_quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
# Per-tensor quantization (FP8/NVFP4) expects same amax across row and column parallel ranks
# Channel-wise (INT8) only expects same amax across row parallel ranks
# Block-wise quantization does not expect same amax across row and column parallel ranks
if config in [mtq.FP8_DEFAULT_CFG, mtq.NVFP4_DEFAULT_CFG]:
if isinstance(model.fc1.weight_quantizer, SequentialQuantizer):
for quantizer in model.fc1.weight_quantizer:
_distributed_attr_check(
quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
else:
_distributed_attr_check(
model.fc1.weight_quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
if config in [
mtq.FP8_DEFAULT_CFG,
mtq.NVFP4_DEFAULT_CFG,
mtq.INT8_DEFAULT_CFG,
mtq.INT8_SMOOTHQUANT_CFG,
]:
if isinstance(model.fc2.weight_quantizer, SequentialQuantizer):
for quantizer in model.fc2.weight_quantizer:
_distributed_attr_check(
quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
else:
_distributed_attr_check(
model.fc2.weight_quantizer, "amax", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
# Lets check the column parallel pre_quant_scale; it should be the same across all tp ranks
# It is different across DP/CP ranks since the input is different
if (
test_pre_quant_scale
and tp_group
and config in [mtq.INT8_SMOOTHQUANT_CFG, mtq.INT4_AWQ_CFG, mtq.W4A8_AWQ_BETA_CFG]
):
input_quantizer = model.fc1.input_quantizer
_distributed_attr_check(
input_quantizer, "pre_quant_scale", dist.ReduceOp.MAX, groups=[dp_group, tp_group]
)
# Check act scale
if config in [mtq.INT4_AWQ_CFG, mtq.W4A8_AWQ_BETA_CFG]:
_distributed_attr_check(
model.fc1.awq_lite, "act_scale", dist.ReduceOp.AVG, groups=[dp_group, tp_group]
)
def auto_quantize_helper(model):
model, search_state = mtq.auto_quantize(
model,
constraints={"effective_bits": 8.0},
quantization_formats=[mtq.INT4_BLOCKWISE_WEIGHT_ONLY_CFG, mtq.INT8_DEFAULT_CFG],
data_loader=[model.get_dummy_input().cuda() for _ in range(2)],
forward_step=lambda model, batch: model(batch),
forward_backward_step=lambda model, batch: model(batch).sum().backward(),
num_calib_steps=2,
num_score_steps=2,
verbose=True,
)
search_state_list = [None] * torch.distributed.get_world_size()
torch.distributed.all_gather_object(search_state_list, search_state)
search_state_rank0 = search_state_list[0]
for search_state in search_state_list[1:]:
assert search_state == search_state_rank0
def compute_backward_grad(
model,
input_tensor,
config=None,
quantize: bool = False,
enable_real_quant: bool = False,
compress: bool = False,
):
if quantize:
def forward_loop(model, run_backward=False):
calib_data = [model.get_input().to(torch.float16).cuda() for _ in range(8)]
for batch in calib_data:
output = model(batch)
if run_backward:
output.sum().backward()
mtq.quantize(model, config, forward_loop)
if enable_real_quant:
enable_real_quant_gemm(model)
if compress:
mtq.compress(model)
output = model(input_tensor).sum()
model.zero_grad()
output.backward()
weight_grads = []
bias_grads = []
for layer in model.net:
if isinstance(layer, torch.nn.Linear):
weight_grads.append(layer.weight.grad)
bias_grads.append(layer.bias.grad if layer.bias is not None else None)
return weight_grads, bias_grads