Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Changelog
*Quantization*

- On Linux x86-64 with Python 3.11 and later, the ONNX extra now selects ONNX Runtime 1.30.x and CUDA 13 packages by default. GPU calibration requires a compatible CUDA 13 runtime and driver; Python 3.10 and other platforms retain their existing dependency selections.
- Add imatrix calibration (``algorithm: {method: imatrix}``) for the GGML formats with a weighted search: each weight is encoded once, its search weighted by the square root of its inputs' per-column ``E[x^2]`` (llama.cpp's imatrix statistic), and HF fused-MoE experts get one statistic per expert. A weight that already holds final codes is left alone, so ``[{method: gptq}, {method: imatrix}]`` runs GPTQ on the dense linears and imatrix on the experts GPTQ skips; the built-in IQ1 and IQ2 recipes now do this, and new ``<format>_experts_only`` recipes apply imatrix to the routed MoE experts alone.
- Add imatrix calibration (``algorithm: {method: imatrix}``) for the GGML formats with a weighted search: each weight is encoded once, its search weighted by the square root of its inputs' per-column ``E[x^2]`` (llama.cpp's imatrix statistic), and HF fused-MoE experts get one statistic per expert. A weight that already holds final codes is left alone, so ``[{method: gptq}, {method: imatrix}]`` runs GPTQ where it applies and imatrix on the rest; the built-in IQ1 and IQ2 recipes now do this, and new ``<format>_experts_only`` recipes apply imatrix to the routed MoE experts alone.
- GPTQ now also quantizes HuggingFace fused MoE experts, each with its own Hessian from the tokens routed to it; they were previously skipped. The built-in IQ1 and IQ2 recipes therefore run GPTQ on the experts too, and large MoEs need layerwise calibration to hold one layer's expert Hessians at a time.
- Add a checkpoint-specific NVIDIA Nemotron 3.5 Super VL AutoQuantize recipe with a 4.9-effective-bit NVFP4-MSE/FP8/BF16 search and fixed NVFP4 MTP experts. Select it under ``models/nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-BF16/auto_quantize/``.

*Speculative Decoding*
Expand Down
6 changes: 3 additions & 3 deletions modelopt/torch/quantization/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,9 +1258,9 @@ class ImatrixCalibConfig(QuantizeAlgorithmConfig):
Encodes each weight once, its codebook search weighted by the square root of its inputs'
per-column second moment ``E[x_j^2]`` -- llama.cpp's importance matrix. Covers the GGML formats
with a weighted search,
on dense linears and on each expert of HF fused-MoE experts, which GPTQ does not reach. A
weight that already holds final codes, such as GPTQ's, is left alone, so
``[{method: gptq}, {method: imatrix}]`` runs GPTQ where it applies and imatrix on the rest.
on dense linears and on each expert of HF fused-MoE experts. A weight that already holds final
codes, such as GPTQ's, is left alone, so ``[{method: gptq}, {method: imatrix}]`` runs GPTQ
where it applies and imatrix on any weight it leaves.
By default the statistics come from a single forward pass, as llama.cpp collects them; with
``layerwise`` enabled, each layer's are collected after the earlier layers are quantized.
"""
Expand Down
8 changes: 4 additions & 4 deletions modelopt/torch/quantization/ggml/gptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ class GGMLGPTQHelper(GPTQHelper):
those exact codes rather than encoding the GPTQ'd weight again, which would not return them.
"""

def update_weights(self, block_size, perc_damp):
def update_weights(self, block_size, perc_damp, weight=None):
"""Run the GPTQ update, then pin the chosen payload to the weight quantizer."""
super().update_weights(block_size, perc_damp)
quantizer = self.module.weight_quantizer
super().update_weights(block_size, perc_damp, weight)
quantizer = self.weight_quantizer
pin_packed_weight(quantizer, quantizer.num_bits, self._packed)
self._packed = None

def _blockwise_update(self, block_size):
quantizer = self.module.weight_quantizer
quantizer = self.weight_quantizer
ggml_format = GGML_FORMAT_REGISTRY[quantizer.num_bits]
extra_args = quantizer.backend_extra_args or {}
block_chunk_size = extra_args.get("block_chunk_size", ggml_format.block_chunk_size)
Expand Down
168 changes: 138 additions & 30 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,45 @@ def _is_quant_fused_experts(module: nn.Module) -> bool:
)


def _fused_expert_projections(module: nn.Module):
"""Yield ``(weight_name, weight, weight_quantizers, input_quantizer)`` per fused-experts projection.

``weight`` is the 3-D ``(num_experts, out, in)`` parameter, ``weight_quantizers`` holds one
quantizer per expert, and ``input_quantizer`` is shared by the experts and runs once per expert
forward with ``module._current_expert_idx`` already set to that expert.
"""
first_proj_attr = getattr(module, "_first_proj_attr", "gate_up_proj")
for weight_name, quantizers_name, input_q_name in (
(
first_proj_attr,
f"{first_proj_attr}_weight_quantizers",
f"{first_proj_attr}_input_quantizer",
),
("down_proj", "down_proj_weight_quantizers", "down_proj_input_quantizer"),
):
weight = getattr(module, weight_name, None)
quantizers = getattr(module, quantizers_name, None)
input_quantizer = getattr(module, input_q_name, None)
if weight is not None and quantizers is not None and input_quantizer is not None:
yield weight_name, weight, quantizers, input_quantizer


def _refuse_packed_fused_expert_weights(name, module, method, updates):
"""Refuse fused experts that store a projection ``method`` updates in 8 bits.

Such experts (native FP8 experts) hand calibration dequantized copies of their weights, so an
in-place update would land in a temporary and be lost. ``updates(quantizer)`` says whether
``method`` writes the weight that quantizer belongs to.
"""
for weight_name, weight, quantizers, _ in _fused_expert_projections(module):
if weight.element_size() == 1 and any(updates(q) for q in quantizers):
raise ValueError(
f"{method} updates {name}.{weight_name} in place, but it is stored in 8 bits "
"(native FP8 experts), so the update would be lost. Call unpack_weight() on the "
"module before calibrating."
)


class _WeightInputHook:
"""Identify active input collectors so auxiliary modules can request a calibration forward."""

Expand Down Expand Up @@ -991,20 +1030,9 @@ def _dense_hook(linear, args):
handles.append(module.register_forward_pre_hook(_WeightInputHook(_dense_hook)))
elif _is_quant_fused_experts(module):
with enable_weight_access_and_writeback(module, model, names):
first_proj_attr = getattr(module, "_first_proj_attr", "gate_up_proj")
for weight_name, quantizers_name, input_q_name in (
(
first_proj_attr,
f"{first_proj_attr}_weight_quantizers",
f"{first_proj_attr}_input_quantizer",
),
("down_proj", "down_proj_weight_quantizers", "down_proj_input_quantizer"),
for weight_name, weight, quantizers, input_quantizer in _fused_expert_projections(
module
):
weight = getattr(module, weight_name, None)
quantizers = getattr(module, quantizers_name, None)
input_quantizer = getattr(module, input_q_name, None)
if weight is None or quantizers is None or input_quantizer is None:
continue
if on_weight is not None:
on_weight(f"{name}.{weight_name}", weight[0], quantizers[0])
# Snapshot which experts are enabled now, before the caching forward silences
Expand Down Expand Up @@ -2299,9 +2327,14 @@ def gptq(

1. ``max_calibrate`` to set amax values from the current activations.
2. Promote eligible quantizers to ``StaticBlockScaleQuantizer`` (two-level scaling).
3. Collect per-linear-layer Hessian matrices via forward hooks.
3. Collect Hessian matrices via forward hooks: one per quantized linear layer, and one per
expert and projection of HF fused-MoE experts, over the tokens routed to that expert.
4. Blockwise weight updates using the inverse Hessian to compensate for
rounding error (the core GPTQ column-wise update).
rounding error (the core GPTQ column-wise update). An expert no calibration token
reached has a zero Hessian and gets the plain search, as a dense layer would.

Each expert Hessian is ``in_features x in_features`` float32, so a large MoE needs
layerwise calibration to hold only one layer's expert Hessians at a time.

Args:
model: The module to quantize — either the full model or a single decoder
Expand All @@ -2315,6 +2348,10 @@ def gptq(
"""
total_start = time.time()

fused_experts = [(n, m) for n, m in model.named_modules() if _is_quant_fused_experts(m)]
for name, module in fused_experts:
_refuse_packed_fused_expert_weights(name, module, "GPTQ", lambda q: q.is_enabled)

# TODO: Add support for other scale setting strateiges like weight-mse or local-hessian
max_calibrate(model, forward_loop=forward_loop)

Expand All @@ -2323,42 +2360,111 @@ def gptq(
for n, m in model.named_modules()
if is_quantized_linear(m) and m.weight_quantizer.is_enabled
]
if not quantized_layers:
print_rank_0("No quantized linear layers found, skipping GPTQ")
if not quantized_layers and not fused_experts:
print_rank_0("No quantized linear layers or fused experts found, skipping GPTQ")
return

def _make_gptq_handle(name, m):
backend = getattr(m.weight_quantizer, "backend", None)
# Every expert Hessian lives from now until its update; a whole large MoE's does not fit.
expert_hessian_bytes = 0
expert_devices = set()
for _, module in fused_experts:
for _, weight, quantizers, _ in _fused_expert_projections(module):
expert_hessian_bytes += (
4 * weight.shape[-1] ** 2 * sum(q.is_enabled for q in quantizers)
)
expert_devices.add(weight.device)
gpus = [d for d in expert_devices if d.type == "cuda"]
if gpus and expert_hessian_bytes > sum(torch.cuda.mem_get_info(d)[0] for d in gpus):
warn_rank_0(
f"GPTQ: the fused-MoE expert Hessians need {expert_hessian_bytes / 2**30:.1f} GiB, more "
"than the GPUs holding the experts have free, so they spill to host memory and can "
"exhaust it. Enable layerwise calibration to hold one layer's Hessians at a time."
)

def _make_gptq_handle(name, m, weight_quantizer=None, weight=None):
quantizer = m.weight_quantizer if weight_quantizer is None else weight_quantizer
backend = getattr(quantizer, "backend", None)
if backend is None:
cls = GPTQHelper
else:
cls = _GPTQ_HELPER_REGISTRY.get(backend, GPTQHelper)
return cls(
m, name, offload_to_cpu=True, fused=fused, importance_weighted=importance_weighted
m,
name,
offload_to_cpu=True,
fused=fused,
importance_weighted=importance_weighted,
weight_quantizer=weight_quantizer,
weight=weight,
)

gptq_handles = {name: _make_gptq_handle(name, m) for name, m in quantized_layers}
for handle in gptq_handles.values():
handle.setup()

print_rank_0(f"Computing Hessians for {len(gptq_handles)} linear layers...")
# HF fused-MoE experts: one helper per expert and projection, fed only the tokens routed to that
# expert. The projection's shared input quantizer runs once per expert forward, so its output is
# exactly what the expert's F.linear consumes, the input-quantized input dense GPTQ also uses.
names = module_name_maps(model)
expert_handles: dict[int, GPTQHelper] = {} # id(expert weight quantizer) -> helper
hooks = []

with set_quantizer_by_cfg_context(
model, [{"quantizer_name": "*weight_quantizer", "enable": False}]
):
forward_loop(model)
def _make_expert_hook(experts, per_expert):
def _expert_hook(_input_quantizer, _args, output):
handle = per_expert.get(experts._current_expert_idx)
if handle is not None:
handle.accumulate(output)

return _expert_hook

for name, module in fused_experts:
with enable_weight_access_and_writeback(module, model, names):
for weight_name, weight, quantizers, input_quantizer in _fused_expert_projections(
module
):
per_expert = {
idx: _make_gptq_handle(f"{name}.{weight_name}.{idx}", module, q, weight[idx])
for idx, q in enumerate(quantizers)
if q.is_enabled
}
if per_expert:
expert_handles.update((id(quantizers[i]), h) for i, h in per_expert.items())
hooks.append(
input_quantizer.register_forward_hook(_make_expert_hook(module, per_expert))
)

print_rank_0(
f"Computing Hessians for {len(gptq_handles)} linear layers and "
f"{len(expert_handles)} fused-expert weights ({expert_hessian_bytes / 2**30:.1f} GiB)..."
)

try:
with set_quantizer_by_cfg_context(
model, [{"quantizer_name": "*weight_quantizer", "enable": False}]
):
forward_loop(model)
finally:
for hook in hooks:
hook.remove()

for handle in gptq_handles.values():
handle.cleanup()
for handle in [*gptq_handles.values(), *expert_handles.values()]:
handle.sync_hessian_across_dp()

print_rank_0("Updating weights using GPTQ algorithm...")
names = module_name_maps(model)
for handle in gptq_handles.values():
with enable_weight_access_and_writeback(handle.module, model, names):
handle.update_weights(block_size, perc_damp)
handle.free()
del gptq_handles
for _, module in fused_experts:
with enable_weight_access_and_writeback(module, model, names):
for weight, quantizer in module.iter_weights_for_calibration():
handle = expert_handles.pop(id(quantizer), None)
if handle is not None:
handle.update_weights(block_size, perc_damp, weight)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Persist GPTQ updates for native FP8 experts

Could we ensure the expert weights passed to update_weights() are persistent, writable model weights? Native _QuantFP8Experts also enters this path, but its inherited iter_weights_for_calibration() calls get_expert_weight(), which returns a newly allocated, scaled/dequantized tensor for FP8-backed parameters. update_weights() copies the GPTQ result into that temporary; neither the stored FP8 parameter nor its scales are updated. The next forward/export dequantizes the original weights again, silently losing the GPTQ result.

I reproduced this on CPU with Transformers 5.18 FP8Experts (two experts, hidden/intermediate size 256, native FP8 blocks 128x128) and INT4 GPTQ (128-column groups). Starting from max calibration, then running mtq.calibrate(..., algorithm={"method": "gptq", "block_size": 128, "perc_damp": 0.01}, forward_loop=...), all four projections report successful GPTQ updates with 96 routed tokens each, but their dequantized weights and model predictions remain exactly unchanged. Calling unpack_weight() before the same GPTQ calibration makes the updates persist and changes predictions.

Please materialize persistent floating-point weights under the appropriate weight-access/writeback context, or otherwise explicitly persist the optimized result in the representation used by subsequent inference/export. A regression test using native FP8Experts and comparing against its unpacked equivalent would catch this; the current floating-point toy experts always return views and cannot exercise the issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced. With Transformers 5.18 FP8Experts, the stored weights stay unchanged after GPTQ, and after imatrix too, since imatrix makes the same in-place write through iter_weights_for_calibration(). After unpack_weight(), both updates persist.

cedb19e makes GPTQ and imatrix refuse fused experts that store a projection they would update in 8 bits. They raise a ValueError asking for unpack_weight() before any calibration state changes, the same contract fold_weight already enforces. test_native_fp8_experts_reject_packed_weight_updates checks both methods: the refusal leaves the state untouched, and the updates persist once the experts are unpacked.

I chose refusing over unpacking inside calibration because unpack_weight() swaps the parameters for float copies. I'd rather not do that implicitly where layerwise and offload contexts manage those parameters.

handle.free()
del gptq_handles, expert_handles
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if torch.cuda.is_available():
torch.cuda.empty_cache()
Expand All @@ -2379,9 +2485,9 @@ def imatrix_calibrate(model: nn.Module, forward_loop: ForwardLoop | None = None)
encoder (see :func:`~modelopt.torch.quantization.utils.calib_utils.register_imatrix_encoder`)
and accepts the quantizer. Each such weight is then encoded once, its search weighted by that
statistic, and replaced by its fake-quantized value. Dense quantized linears and HF fused-MoE
experts (one statistic per expert, over the tokens routed to it) are both covered, so this
reaches the experts GPTQ skips. Encoders skip a quantizer that already holds final codes, so
``[gptq, imatrix]`` runs GPTQ where it applies and imatrix on the rest.
experts (one statistic per expert, over the tokens routed to it) are both covered. Encoders
skip a quantizer that already holds final codes, so ``[gptq, imatrix]`` runs GPTQ where it
applies and imatrix on any weight it leaves.
"""
if forward_loop is None:
warn_rank_0("imatrix: no forward_loop given; skipping imatrix calibration")
Expand All @@ -2407,6 +2513,8 @@ def _accepted(quantizer):
# Refuse a weight the encoder cannot take before any forward or calibration state changes;
# otherwise it would fail only after calibration, with the earlier weights already encoded.
for name, module in target_modules:
if _is_quant_fused_experts(module):
_refuse_packed_fused_expert_weights(name, module, "imatrix", lambda q: id(q) in targets)
for weight, quantizer in module.iter_weights_for_calibration():
if id(quantizer) in targets:
try:
Expand Down
Loading
Loading