Skip to content
Draft
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
37 changes: 37 additions & 0 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import time
import warnings
from collections.abc import Callable, Mapping, Sequence
from contextlib import ExitStack
from functools import partial
from typing import Any, TypeAlias

Expand All @@ -34,6 +35,7 @@
from modelopt.torch.quantization.utils.layerwise_calib import (
LayerActivationCollector,
_CheckpointState,
_hide_modules_from_traversal,
_reconcile_export_with_resume,
)
from modelopt.torch.utils import print_rank_0, warn_rank_0
Expand Down Expand Up @@ -2084,6 +2086,20 @@ def layerwise_calibrate(
"Layerwise calibration requires a model with identifiable transformer layers."
)

decoder_owned_ids = {id(module) for layer in transformer_layers for module in layer.modules()}
has_enabled_outside_quantizer = any(
isinstance(module, TensorQuantizer)
and module.is_enabled
and id(module) not in decoder_owned_ids
for module in model.modules()
)

if export_dir is not None and has_enabled_outside_quantizer:
raise ValueError(
"Layerwise export does not support enabled quantizers outside transformer layers. "
"Calibrate without export_dir, then export the completed model separately."
)
Comment thread
realAsma marked this conversation as resolved.
Comment on lines +2089 to +2101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Performance] The gate is module.is_enabled, not "this quantizer still needs data-driven calibration". Any enabled non-decoder quantizer — including a weight-only quantizer, a top-level type: dynamic quantizer, an MX (MXFP4/MXFP8) quantizer, or one pinned via constant_amax — flips has_enabled_outside_quantizer to True.

Why it matters: for a weight-only recipe that enables lm_head (e.g. INT8_WEIGHT_ONLY_CFG, INT4_BLOCKWISE_WEIGHT_ONLY_CFG, W4A16 AWQ), the only outside quantizer is lm_head.weight_quantizer, which max_calibrate calibrates directly on the weight tensor via weight_only_quantize() — the forward_loop contributes nothing. But the block at line 2232 unconditionally runs calib_func(model, forward_loop, ...), i.e. a full extra pass of the entire calibration dataset through the whole model. On the exact models layerwise calibration exists for (large, accelerate/disk-offloaded), that is the single most expensive thing in the run, and for these recipes it is pure waste. The same applies to a fully-dynamic activation quantizer outside the decoder, which needs no amax at all.

This file already has the precise predicate for the forward question — _needs_activation_forward_for_max_calib() (line 268) — and max_calibrate already accepts skip_forward_without_activation_calib.

Suggested shape: keep the calib_func invocation gated on "some outside quantizer needs any calibration" (weight amax counts), but gate the forward on whether an outside activation quantizer needs data — e.g. compute the flag over the hidden-decoder view and pass skip_forward_without_activation_calib=True for this extra pass when calib_func supports it:

if has_enabled_outside_quantizer:
    ...
    with _hide_modules_from_traversal(model, transformer_layers):
        extra_kwargs = dict(calib_kwargs)
        if calib_func is max_calibrate:
            # Outside quantizers may be weight-only / dynamic / MX; let max_calibrate
            # skip the (full-model, full-dataset) forward when no activation stats are needed.
            extra_kwargs.setdefault("skip_forward_without_activation_calib", True)
        ...

At minimum, please make the export_dir rejection at line 2097 use the narrower predicate too, so recipes whose outside quantizers need nothing data-driven don't lose layerwise export for no reason.

Comment on lines +2097 to +2101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The error message tells the user to drop export_dir, but most users hitting this actually want the other fix — keep progressive export and stop quantizing the outside module. Worth naming both options, and naming the offending quantizers so the user doesn't have to go find them:

raise ValueError(
    "Layerwise export does not support enabled quantizers outside transformer layers "
    f"(e.g. {sorted(outside_quantizer_names)[:5]}). Either disable them (e.g. add "
    '{"quantizer_name": "*lm_head*", "enable": False} to quant_cfg), or calibrate '
    "without export_dir and export the completed model separately."
)

That needs has_enabled_outside_quantizer to become a name list instead of a bool, which the any(...) above can be turned into cheaply since it already walks named_modules-equivalent state.


num_layers = len(transformer_layers)
print_rank_0(f"Layerwise calibration: Found {num_layers} transformer layers")

Expand Down Expand Up @@ -2205,6 +2221,27 @@ def _layer_forward_loop(m, _inputs=layer_inputs):
if ckpt:
ckpt.full_restore(transformer_layers, model)

if has_enabled_outside_quantizer:
if any(device == "disk" for device in getattr(model, "hf_device_map", {}).values()):
Comment thread
realAsma marked this conversation as resolved.
warn_rank_0(
"Layerwise calibration found enabled quantizers outside transformer layers. "
"The required full-model calibration pass may be slow because disk-offloaded "
"decoder weights can be streamed for every batch."
)

with _hide_modules_from_traversal(model, transformer_layers):
Comment on lines +2224 to +2232

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] has_enabled_outside_quantizer is a purely local decision, but the body it guards runs a full model forward plus max_calibrate's cross-rank amax syncs (sync_amax_across_distributed_group over DP/EP/TP groups). If two ranks disagree on the flag, one enters collectives the other never reaches and the job hangs rather than failing.

Today the common paths are safe: HF layerwise is effectively single-process, and get_mcore_layerwise_calibration_layers (plugins/megatron.py:997) appends model.output_layer to the layer list, so MCore's usual outside-quantizer candidate is already decoder-owned, and DP/TP replicas have identical module trees. The exposure is pipeline parallelism with a recipe that re-enables a stage-local module (first-stage embedding, mtp.*), where the flag differs by stage.

Cheap insurance: all-reduce the flag (logical OR / max) across the model's parallel groups before branching, so every rank takes the same path. A comment stating the single-process/replica-symmetry assumption would also be enough if you'd rather not add the collective.

if qdq_from_prev:
calib_func(model, forward_loop, **calib_kwargs)
else:
with ExitStack() as stack:
Comment thread
realAsma marked this conversation as resolved.
for layer in transformer_layers:
stack.enter_context(
set_quantizer_by_cfg_context(
layer, [{"quantizer_name": "*", "enable": False}]
)
)
calib_func(model, forward_loop, **calib_kwargs)

if exporter is not None:
exporter.finalize()
print_rank_0(f"Layerwise export: wrote quantized checkpoint to {export_dir}")
Expand Down
45 changes: 44 additions & 1 deletion modelopt/torch/quantization/utils/layerwise_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import os
import shutil
from collections import deque
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

Expand All @@ -42,7 +43,7 @@
)

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Sequence

from modelopt.torch.opt.searcher import ForwardLoop

Expand Down Expand Up @@ -101,6 +102,48 @@ def forward(self, *args, **kwargs):
)


class _ForwardOnlyLayer(nn.Module):
"""Hide a layer from module traversal while preserving its forward execution."""

_PROXY_BLOCKLIST = _SkipLayer._PROXY_BLOCKLIST

def __init__(self, original: nn.Module):
super().__init__()
object.__setattr__(self, "_original", original)

def __getattr__(self, name: str):
try:
return super().__getattr__(name)
except AttributeError:
if name in self._PROXY_BLOCKLIST:
raise
return getattr(object.__getattribute__(self, "_original"), name)

def forward(self, *args, **kwargs):
return self._original(*args, **kwargs)

Comment on lines +105 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] Two small things on the proxy:

  1. _ForwardOnlyLayer duplicates _SkipLayer's __getattr__ delegation verbatim and reaches into _SkipLayer._PROXY_BLOCKLIST (line 108) — a private-attribute dependency between two sibling classes. Consider a shared private base that owns _PROXY_BLOCKLIST, __init__, and __getattr__, leaving each subclass with only its forward. That keeps the accelerate blocklist a single source of truth if it ever grows.

  2. There's no __setattr__ override, so reads delegate to _original but writes don't. Anything that assigns onto the layer slot during the extra pass — a parent model caching per-layer state (layer.foo = ...), or a calib_func attaching a hook/shared state to what it believes is the layer — lands on the throwaway proxy and is silently dropped when finally swaps the originals back. Nothing in the current callers does this (SharedWeightGlobalAmaxState.attach matches linear names inside the hidden subtree, so it finds nothing), but a docstring note like "writes to the proxy are discarded on exit; only forward behavior is preserved" would stop a future caller from being surprised.


@contextmanager
def _hide_modules_from_traversal(model: nn.Module, modules: Sequence[nn.Module]):
"""Temporarily hide registered modules while retaining their forward behavior."""
target_ids = {id(module) for module in modules}
slots = [
(parent, child_name, child)
for parent in tuple(model.modules())
for child_name, child in tuple(parent._modules.items())
if child is not None and id(child) in target_ids
]
proxies = {id(child): _ForwardOnlyLayer(child) for _, _, child in slots}

try:
for parent, child_name, child in slots:
parent._modules[child_name] = proxies[id(child)]
yield
finally:
for parent, child_name, child in slots:
parent._modules[child_name] = child


class LayerActivationCollector:
"""Collects layer activations for layerwise (layer-by-layer) calibration.

Expand Down
194 changes: 193 additions & 1 deletion tests/unit/torch/quantization/test_layerwise_calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import copy
import json
from collections import deque
from contextlib import nullcontext

import pytest
import torch
Expand All @@ -26,7 +27,12 @@
import modelopt.torch.quantization as mtq
from modelopt.torch.quantization.model_calib import layerwise_calibrate
from modelopt.torch.quantization.nn import TensorQuantizer
from modelopt.torch.quantization.utils.layerwise_calib import LayerActivationCollector, _SkipLayer
from modelopt.torch.quantization.utils.layerwise_calib import (
LayerActivationCollector,
_ForwardOnlyLayer,
_hide_modules_from_traversal,
_SkipLayer,
)


class _DecoderBlock(nn.Module):
Expand Down Expand Up @@ -63,6 +69,15 @@ def forward(self, x, **kwargs):
return x


class _TransformerWithLMHead(_SimpleTransformerModel):
def __init__(self, n_layers=3, dim=16):
super().__init__(n_layers=n_layers, dim=dim)
self.lm_head = nn.Linear(dim, 32, bias=False)

def forward(self, x, **kwargs):
return self.lm_head(super().forward(x, **kwargs))


class _FlatMLP(nn.Module):
"""No decoder-layer structure -- should be rejected by layerwise_calibrate."""

Expand All @@ -74,6 +89,48 @@ def forward(self, x):
return self.net(x)


class _TrackingQuantizer(TensorQuantizer):
"""Deterministic quantizer used to distinguish QDQ and FP activations."""

def __init__(self):
super().__init__(amax=3.0)
self.calls = 0

def forward(self, x):
self.calls += 1
return x / 2 if self.is_enabled else x


class _QuantizedLayer(nn.Module):
def __init__(self):
super().__init__()
self.quantizer = _TrackingQuantizer()

def forward(self, x):
return self.quantizer(x)


class _QuantizedTail(nn.Module):
def __init__(self):
super().__init__()
self.quantizer = _TrackingQuantizer()
self.inputs = []

def forward(self, x):
self.inputs.append(x.detach().clone())
return self.quantizer(x)


class _ModelWithQuantizedTail(nn.Module):
def __init__(self, with_tail=True):
super().__init__()
self.layers = nn.ModuleList([_QuantizedLayer()])
self.tail = _QuantizedTail() if with_tail else nn.Identity()

def forward(self, x):
return self.tail(self.layers[0](x))


class _SimpleTwoLayerModel(nn.Module):
"""Minimal model with explicit layers for activation-collection tests."""

Expand Down Expand Up @@ -243,6 +300,122 @@ def test_layerwise_calib_empty_forward_loop_raises(monkeypatch):
)


@pytest.mark.parametrize("raises", [False, True])
def test_hide_modules_from_traversal_restores_aliases(raises):
model = _ModelWithQuantizedTail()
original = model.layers[0]
model.layer_alias = original

error_context = pytest.raises(RuntimeError, match="injected") if raises else nullcontext()
with error_context, _hide_modules_from_traversal(model, [original]):
assert isinstance(model.layers[0], _ForwardOnlyLayer)
assert model.layer_alias is model.layers[0]
assert original not in model.modules()
assert original.quantizer not in model.modules()
if raises:
raise RuntimeError("injected")

assert model.layers[0] is original
assert model.layer_alias is original


@pytest.mark.parametrize(
("qdq_from_prev", "expected_tail_input"),
[(True, 1.0), (False, 2.0)],
)
def test_layerwise_calibrates_only_outside_quantizers_with_full_model_forward(
monkeypatch, qdq_from_prev, expected_tail_input
):
monkeypatch.setattr(
LayerActivationCollector,
"_decoder_layer_support",
[(lambda m: hasattr(m, "layers"), lambda m: list(m.layers))],
)
model = _ModelWithQuantizedTail()
decoder_quantizer = model.layers[0].quantizer
calibrated_quantizers = []
calibrated_targets = []
decoder_amax_before_extra_pass = []

def calib_func(target, target_forward_loop):
calibrated_targets.append(target)
if target is model:
decoder_amax_before_extra_pass.append(decoder_quantizer._amax.clone())
calibrated_quantizers.append(
{id(module) for module in target.modules() if isinstance(module, TensorQuantizer)}
)
target_forward_loop(target)

layerwise_calibrate(
model,
lambda m: m(torch.tensor([2.0])),
calib_func,
get_qdq_activations_from_prev_layer=qdq_from_prev,
)

assert calibrated_targets == [model.layers[0], model]
assert calibrated_quantizers == [{id(decoder_quantizer)}, {id(model.tail.quantizer)}]
torch.testing.assert_close(model.tail.inputs[-1], torch.tensor([expected_tail_input]))
torch.testing.assert_close(decoder_quantizer._amax, decoder_amax_before_extra_pass[0])
assert decoder_quantizer.calls == (2 if qdq_from_prev else 1)
assert decoder_quantizer.is_enabled


def test_layerwise_skips_full_model_pass_without_outside_quantizer(monkeypatch):
_register_test_discoverer(monkeypatch)
model = _ModelWithQuantizedTail(with_tail=False)
calibrated_targets = []

def calib_func(target, target_forward_loop):
calibrated_targets.append(target)
target_forward_loop(target)

layerwise_calibrate(model, lambda m: m(torch.tensor([2.0])), calib_func)

assert calibrated_targets == [model.layers[0]]


@pytest.mark.parametrize(
("device_map", "with_tail", "warns"),
[
({"layers.0": "disk"}, True, True),
({"layers.0": "cpu"}, True, False),
({}, True, False),
({"layers.0": "disk"}, False, False),
],
)
def test_layerwise_disk_offload_warning_gating(monkeypatch, device_map, with_tail, warns):
_register_test_discoverer(monkeypatch)
model = _ModelWithQuantizedTail(with_tail=with_tail)
model.hf_device_map = device_map
warnings = []
monkeypatch.setattr("modelopt.torch.quantization.model_calib.warn_rank_0", warnings.append)

def calib_func(target, target_forward_loop):
target_forward_loop(target)

layerwise_calibrate(model, lambda m: m(torch.tensor([2.0])), calib_func)

assert bool(warnings) is warns
if warns:
assert "disk-offloaded" in warnings[0]


def test_layerwise_export_rejects_enabled_outside_quantizer(monkeypatch, tmp_path):
_register_test_discoverer(monkeypatch)
model = _ModelWithQuantizedTail()

with pytest.raises(ValueError, match="outside transformer layers"):
layerwise_calibrate(
model,
lambda m: m(torch.tensor([2.0])),
lambda *_args, **_kwargs: None,
export_dir=str(tmp_path / "export"),
)

assert not (tmp_path / "export").exists()


# ---------------------------------------------------------------------------
# Skip / run / capture path verification tests
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -725,6 +898,25 @@ def forward_loop(m):
model(calib_data[0])


def test_mtq_quantize_layerwise_calibrates_lm_head(monkeypatch):
_register_test_discoverer(monkeypatch)
config = _int8_cfg_with_algorithm(
{
"method": "max",
"layerwise": {"enable": True, "get_qdq_activations_from_prev_layer": True},
}
)
config["quant_cfg"].append({"quantizer_name": "*lm_head*", "enable": True})
model = _TransformerWithLMHead(n_layers=2, dim=16)
calib_data = [torch.randint(0, 32, (2, 8))]

mtq.quantize(model, config, forward_loop=lambda m: [m(batch) for batch in calib_data])

assert model.lm_head.input_quantizer._amax is not None
with torch.no_grad():
model(calib_data[0])


@pytest.mark.parametrize(
"algorithm",
["gptq", "awq_lite", "smoothquant", "mse"],
Expand Down
Loading