mirror of
https://github.com/NVIDIA/Model-Optimizer.git
synced 2026-11-08 09:03:20 +08:00
## What does this PR do? **Type of change:** Fix MCore KV Cache Quantization: Amax Device Placement Bug; Code clean up; Distributed Sync of KVCache Quantizer params; unittest expansion to hybrid models **Overview:** Fixes bugs preventing MCore KV Cache quantization from working during checkpoint restore. ### Bug Chain **Bug 1:** `is_enabled = self.weight_quantizer.is_enabled if hasattr(self, "weight_quantizer") else False` No `weight_quantizer` for KV-cache-only quant → `is_enabled=False` → metadata not saved → `modelopt_post_restore()` never called. *(Thanks to @jenchen13 )* **Bug 2:** After fixing Bug 1, `_amax` restored on CPU (via `_reset_pytorch_state_from_metadata`). Fallback `_calibrate_quantizers()` never called because `_amax` exists. **Bug 3:** Even if called, `_calibrate_quantizers()` fails — `core_attention` has no parameters → can't determine device/dtype. ### The Fix 1. Remove `is_enabled` check entirely — disabled modules may still need metadata restore. Explicitly skip `output_layer` from extra state callbacks (never quantized) 2. Set `dtype`/`device` on `core_attention` from parent Attention module, `modelopt_post_restore()` calls `self.to(device, dtype)` 3. Remove dead `_calibrate_quantizers()` code (will bring back similar logic for KV cache affine quantization) ### Previous Unit Test Was Wrong `model_test` was `mtq.quantize()`'d, not `mto.restore()`'d. Never tested actual restore path. ### Additional Fixes - Amax sync across DP/TP for KV cache quantizers - `flash_decode` auto-disabled ### Code Cleanup Removed ~100 lines of dead code. ## Testing 1. MCore KV Cache QAD with Nano V3 + Context Parallel works 2. Unit tests: hybrid models, KV+GEMM configs, correct restore workflow, backward pass validation ## Before your PR is "*Ready for review*" - **Is this change backward compatible?**: Yes - **Did you write any new necessary tests?**: Yes - **Did you add or update any necessary documentation?**: No - **Did you update Changelog?**: Yes --------- Signed-off-by: realAsma <akuriparambi@nvidia.com> Co-authored-by: Asma Thekkumpate <akuriparambi@cw-dfw-cs-001-vscode-02.cm.cluster>
283 lines
11 KiB
Python
283 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 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]))
|
|
|
|
if version is not None and Version(version) < Version("0.29"):
|
|
# Rest of the tests are not needed for version < 0.29
|
|
return
|
|
|
|
if not compress:
|
|
# 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
|