Files
Jenny ChenandClaude Opus 4.8 9038b71f09 Autoquant and GPTQ in support in Megatron-Core [OMNIML-3151] (#1562)
### What does this PR do?

Type of change: New Feature

Autoquant and GPTQ in support in Megatron-Core

- Add EP support to AutoQuantize
- Register MCore support in AutoQuantize
- Add decoder `output_layer` (lm head) to layerwise hook so that GPTQ
can register all decoder layers & lm head
- Split dataloader helper function out of megatron calibration utils so
that AutoQuantize in Megatron-LM can reuse the same dataloader

### Usage
See https://github.com/NVIDIA/Megatron-LM/pull/4821 for Autoquant usage
in Megatron
```python
# For GPTQ pick a recipe that uses gptq algorithm and run mtq.quantize
# e.g. general/ptq/nvfp4_default-kv_none-gptq
```

### Testing
Tested AutoQuant on Nemotron Nano and Ultra.
Tested GPTQ on Nano 3.
Added unit tests for both AutoQuant and GPTQ

### 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?: ✅ / ❌ / N/A <!--- If ❌, explain
why. -->
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: ✅ / ❌ / N/A
<!--- Mandatory -->
- Did you write any new necessary tests?: ✅ / ❌ / N/A <!--- Mandatory
for new features or examples. -->
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅ / ❌ / N/A <!--- Only for new features, API changes, critical bug fixes
or backward incompatible changes. -->
- Did you get Claude approval on this PR?: ✅ / ❌ / N/A <!--- Run
`/claude review`. NVIDIA org members can self-trigger for complex
changes; orthogonal to CodeRabbit. -->

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


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

## Summary by CodeRabbit

* **New Features**
* Added lazy Megatron-Core AutoQuant integration with Megatron-specific
quantization hooks and better decoder-layer discovery for layerwise
calibration.
* Improved AutoQuantize for expert-parallel (EP) models, including
consistent per-layer recipe selection across DP/TP/EP.
* Extended quant-layer grouping for NemotronH MCore fused
“local_experts” linear layers.

* **Bug Fixes**
* Prevented division-by-zero when calibration inputs are empty during
Hessian updates.

* **Tests**
* Added/extended unit and GPU coverage for EP AutoQuant, decoder-layer
calibration discovery behavior, and zero-token Hessian no-op.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Jennifer Chen <jennifchen@nvidia.com>
Signed-off-by: Jenny Chen <jennifchen@nvidia.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:12:06 -07:00

327 lines
14 KiB
Python

# Adapted from https://github.com/IST-DASLab/FP-Quant/blob/d2e3092/src/quantization/gptq.py
# with minor modifications to the original forms to accommodate minor architectural differences
# to be reused in the Model-Optimizer pipeline.
# Copyright (c) Andrei Panferov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 AND MIT
#
# 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.
"""GPTQ helper and Hessian utilities for calibration."""
import math
import torch
from modelopt.torch.utils import print_rank_0
from modelopt.torch.utils.network import bind_forward_method, unpatch_forward_method
from modelopt.torch.utils.perf import get_used_gpu_mem_fraction
def update_hessian(input, hessian, n_samples):
"""Update hessian matrix with new input samples using incremental formula.
Args:
input: Input tensor (batch_size, ..., features)
hessian: Current Hessian matrix to update in-place
n_samples: Number of samples already processed
Returns:
Tuple of (updated_hessian, new_sample_count)
Note: input must be non-empty (batch_size > 0); a zero-sized input causes division by zero.
"""
# Flatten to 2D (total_tokens, features) first, so batch_size counts tokens
input_flat = input.reshape(-1, input.shape[-1]).t().float()
batch_size = input_flat.shape[1]
if batch_size == 0: # in MOEs some experts receive no tokens
return hessian, n_samples
# Incremental averaging: scale down old hessian
hessian *= n_samples / (n_samples + batch_size)
n_samples += batch_size
# Compute outer product: H += (2/n_samples) * X @ X^T
scaled_input = math.sqrt(2 / n_samples) * input_flat
hessian.add_((scaled_input @ scaled_input.t()).to(hessian.device))
return hessian, n_samples
def compute_hessian_inverse(hessian, weight, perc_damp):
"""Compute damped upper-Cholesky inverse Hessian.
Dead-neuron columns (all-zero in ``weight``) are zeroed in the
Hessian before inversion, matching the FP-Quant reference:
https://github.com/IST-DASLab/FP-Quant/blob/d2e3092f968262c4de5fb050e1aef568a280dadd/src/quantization/gptq.py#L200
Args:
hessian: Hessian matrix ``[in_features, in_features]``.
weight: Weight matrix ``[out_features, in_features]`` for dead-neuron detection.
perc_damp: Percentage of average Hessian diagonal for damping.
Returns:
Upper-triangular Cholesky factor of the damped inverse Hessian
``[in_features, in_features]``. Falls back to the identity matrix
when the Hessian is not positive definite.
"""
h = hessian.clone()
zero_cols = torch.nonzero(weight.eq(0).all(dim=0)).unsqueeze(-1)
h[zero_cols, :] = 0
h[:, zero_cols] = 0
h[zero_cols, zero_cols] = 1
damp = perc_damp * torch.mean(torch.diag(h))
diag_indices = torch.arange(h.shape[0], device=h.device)
h[diag_indices, diag_indices] += damp
try:
h = torch.cholesky_inverse(torch.linalg.cholesky(h))
return torch.linalg.cholesky(h, upper=True)
except (RuntimeError, torch.linalg.LinAlgError):
print_rank_0("Warning: Hessian is not positive definite, using identity matrix")
return torch.eye(h.shape[0], device=h.device, dtype=h.dtype)
class GPTQHelper:
"""Encapsulates per-module GPTQ state and operations.
Owns the Hessian, patches the forward during collection, and contains
the blockwise weight-update logic.
Instance attributes set during ``__init__``:
module, name, hessian, n_samples
Instance attributes set during ``update_weights``:
weight: float working copy of module weights (mutated in-place by update methods)
h_inv: upper-triangular Cholesky factor of the damped inverse Hessian
"""
CACHE_NAME = "_forward_no_gptq_hessian"
def __init__(self, module, name, offload_to_cpu=False, fused=False):
"""Initialize GPTQHelper with module state and Hessian storage."""
self.module = module
self.name = name
self.fused = fused
in_features = module.weight.shape[-1]
device = module.weight.device
if device.type == "meta" or (offload_to_cpu and get_used_gpu_mem_fraction(device) > 0.65):
device = "cpu"
self.hessian = torch.zeros(in_features, in_features, dtype=torch.float32, device=device)
self.n_samples = 0
# Set by update_weights(); listed here for documentation.
self.weight: torch.Tensor | None = None
self.h_inv: torch.Tensor | None = None
def setup(self):
"""Patch the module's forward to accumulate Hessian during the collection pass."""
gptq_helper = self
def hessian_forward(self, input, *args, **kwargs):
inp = input.to_local() if hasattr(input, "to_local") else input
if self.input_quantizer is not None and self.input_quantizer.is_enabled:
hessian_input = self.input_quantizer(inp)
else:
hessian_input = inp
gptq_helper.hessian, gptq_helper.n_samples = update_hessian(
hessian_input, gptq_helper.hessian, gptq_helper.n_samples
)
out = self._forward_no_gptq_hessian(input, *args, **kwargs)
return out
bind_forward_method(self.module, hessian_forward, self.CACHE_NAME)
def cleanup(self):
"""Unpatch the module's forward method."""
unpatch_forward_method(self.module, self.CACHE_NAME)
def free(self):
"""Release Hessian and working tensors to reclaim memory."""
self.hessian = None
self.weight = None
self.h_inv = None
def update_weights(self, block_size, perc_damp):
"""Run GPTQ blockwise weight update on this module.
Populates ``self.weight`` and ``self.h_inv``, runs the blockwise update,
logs MSE, and writes the result back to the module.
"""
hessian = self.hessian.to(self.module.weight.device)
self.weight = self.module.weight.data.float().clone()
self._prepare_hessian_inverse(hessian, perc_damp)
self._blockwise_update(block_size)
self._print_mse_error(hessian)
self.module.weight.data = self.weight.reshape(self.module.weight.shape).to(
self.module.weight.data.dtype
)
# ------------------------------------------------------------------
# Quantize helpers — all read from self.module, self.weight, self.h_inv
# ------------------------------------------------------------------
def _prepare_hessian_inverse(self, hessian, perc_damp):
"""Compute damped inverse Hessian and store as ``self.h_inv``."""
assert self.weight is not None, "_prepare_hessian_inverse called before update_weights()"
self.h_inv = compute_hessian_inverse(hessian, self.weight, perc_damp)
def _blockwise_update(self, block_size):
"""Column-wise GPTQ update.
When ``self.fused`` is True and the weight quantizer is an
``NVFP4StaticQuantizer``, uses :func:`gptq_blockwise_update_fused_scalar`
(a fused Triton kernel). Otherwise falls back to
:func:`gptq_blockwise_update` (unfused column-by-column loop).
"""
assert self.weight is not None and self.h_inv is not None, (
"_blockwise_update called before _prepare_hessian_inverse()"
)
quantizer = self.module.weight_quantizer
if self.fused and getattr(quantizer, "_is_nvfp4_static_quantizer", False):
block_sizes = quantizer.block_sizes
quant_block_size = block_sizes.get(-1) or block_sizes.get(1)
if quant_block_size is not None and block_size % quant_block_size != 0:
raise ValueError(
f"GPTQ block_size ({block_size}) must be divisible by the quantizer"
f" group_size ({quant_block_size})"
)
out_features, num_cols = self.weight.shape
n_blocks = num_cols // quant_block_size
block_amax = quantizer.amax.reshape(out_features, n_blocks).float()
global_scale = quantizer.global_amax.float().item() / (6.0 * 448.0)
gptq_blockwise_update_fused_scalar(
self.weight, block_amax, global_scale, self.h_inv, block_size, quant_block_size
)
else:
gptq_blockwise_update(self.weight, self.h_inv, block_size, quantizer)
def _print_mse_error(self, hessian):
"""Log Hessian-weighted relative MSE between ``self.weight`` and original weights."""
w_orig = self.module.weight.float()
delta = self.weight - w_orig
mse = (delta).mm(hessian).mul(delta).mean() / (w_orig.mm(hessian).mul(w_orig).mean() + 1e-6)
suffix = f", n_hessian_samples: {self.n_samples}" if self.n_samples else ""
print_rank_0(f"[{self.name}] Relative MSE error: {mse.item():.2e}{suffix}")
def gptq_blockwise_update(weight, h_inv, block_size, quantize_fn):
"""Column-wise GPTQ update using full-matrix fake quantization.
For each column, quantizes the full weight matrix via ``quantize_fn`` and
extracts the quantized column. Error is propagated to remaining columns
within the block and then to all subsequent columns via the inverse Hessian.
Args:
weight: Weight tensor ``[out_features, in_features]``, modified **in-place**
with fake-quantized values.
h_inv: Upper-triangular Cholesky factor of the damped inverse Hessian
``[in_features, in_features]``.
block_size: Number of columns to process per GPTQ block.
quantize_fn: Callable ``(weight) -> qdq_weight`` that fake-quantizes
the full weight matrix.
"""
num_cols = weight.shape[1]
for block_start in range(0, num_cols, block_size):
block_end = min(block_start + block_size, num_cols)
n_cols_blk = block_end - block_start
h_inv_cho_blk = h_inv[block_start:block_end, block_start:block_end]
wblk = weight.clone()
errs = torch.zeros_like(weight[:, block_start:block_end])
for i in range(n_cols_blk):
w_ci = wblk[:, block_start + i]
d = h_inv_cho_blk[i, i]
qdq = quantize_fn(wblk)
weight[:, block_start + i] = qdq[:, block_start + i]
err = (w_ci - qdq[:, block_start + i]) / d
wblk[:, block_start + i : block_end].addr_(err, h_inv_cho_blk[i, i:], alpha=-1)
errs[:, i] = err
weight[:, block_end:].addmm_(errs, h_inv[block_start:block_end, block_end:], alpha=-1)
def gptq_blockwise_update_fused_scalar(
weight, block_amax, global_scale, h_inv, block_size, quant_block_size
):
"""Fused GPTQ blockwise update for NVFP4 scalar quantization.
Uses a fused Triton kernel that combines scale computation, quantization,
and per-column error propagation into one launch per GPTQ block, avoiding
the Python-level per-column loop in :func:`gptq_blockwise_update`.
Args:
weight: Weight tensor ``[out_features, in_features]``, modified **in-place**
with fake-quantized values.
block_amax: Per-block amax values ``[out_features, n_amax_blocks]``.
global_scale: Pre-computed ``global_amax / (6.0 * 448.0)`` (scalar).
h_inv: Upper-triangular Cholesky factor of the damped inverse Hessian
``[in_features, in_features]``.
block_size: Number of columns to process per GPTQ block.
quant_block_size: Number of elements sharing one quantization scale factor.
"""
from modelopt.torch.kernels.quantization.gemm.gptq_fused_kernel import gptq_fused_block_scalar
num_cols = weight.shape[1]
for bs in range(0, num_cols, block_size):
be = min(bs + block_size, num_cols)
qw, err = gptq_fused_block_scalar(
weight[:, bs:be].clone().contiguous(),
block_amax,
global_scale,
h_inv[bs:be, bs:be].contiguous(),
quant_block_size,
bs,
)
weight[:, bs:be] = qw
if be < num_cols:
weight[:, be:].addmm_(err, h_inv[bs:be, be:], alpha=-1)
_GPTQ_HELPER_REGISTRY: dict[str, type[GPTQHelper]] = {}
def register_gptq_helper(backend: str, factory: type[GPTQHelper]) -> None:
"""Register a :class:`GPTQHelper` subclass for a quantizer backend.
When :func:`modelopt.torch.quantization.model_calib.gptq` encounters a
module whose ``weight_quantizer.backend`` matches ``backend``, it will
construct ``factory`` instead of the default ``GPTQHelper``.
"""
_GPTQ_HELPER_REGISTRY[backend] = factory