Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Changelog

**New Features**

- Add opt-in ``torch.compile`` execution for Transformer Engine grouped-linear per-expert weight quantizers while preserving their native checkpoint amax shapes. Set ``MODELOPT_TEGROUPED_COMPILE_WEIGHT_LOOP=1`` before quantized-module conversion; the default path remains eager.
- Add the **D-PACE** loss objective for DFlash speculative-decoding training (`arXiv:2605.18810 <https://arxiv.org/abs/2605.18810>`_) and make it the default (``dflash_loss_objective: dpace``). It replaces the static exponential position decay with dynamic, confidence-derived per-position weights that adapt to whichever block positions currently limit acceptance. Smoothing is controlled by ``dflash_dpace_alpha`` (default 0.5); set ``dflash_loss_objective: decay`` to restore the previous static schedule. Training-only and detached from the gradient (no architecture or inference change).
- Add the ``day0-release`` agent skill (``.agents/skills/day0-release/``), a deterministic end-to-end driver that chains the PTQ → evaluation → comparison skills (the evaluation stage deploys the checkpoint itself) with an enforced gate after each stage and returns a publish decision (ACCEPT / REGRESSION / ANOMALOUS / INFEASIBLE). Ships three GPU-free, unit-tested gate scripts (``gate_ptq.py``, ``gate_run.py``, ``gate_compare.py``) that validate checkpoint coverage, evaluation-run completeness, and baseline-vs-candidate accuracy threshold. v1 reports and stops on regression; the recipe-search loop is deferred.
- Add **streaming** speculative-decoding training (EAGLE3 / DFlash): the draft trains on base-model hidden states produced on the fly by a co-located ``vllm serve`` (no disk dump), moved trainer-side over NIXL RDMA, scaling to multi-node (dedicated serve replicas + DDP trainers). New launcher examples for NVFP4 Kimi-K2.5 / K2.6 on GB200/aarch64 under ``tools/launcher/examples/moonshotai/``.
Expand Down
4 changes: 2 additions & 2 deletions modelopt/torch/quantization/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,8 +890,8 @@ class MaxCalibConfig(_SharedStatesConfig, QuantizeAlgorithmConfig):
description=(
"If True, max-calibration synchronizes the weight quantizer amax across local "
"experts within each SequentialMLP layer, so all experts in that layer share "
"one effective weight amax. TEGroupedMLP already fuses experts into a single "
"GEMM with one weight quantizer, so this flag is irrelevant there."
"one effective weight amax. TEGroupedMLP keeps a per-expert weight quantizer "
"(GroupedQuantizer) whose amax follows the same expert-parallel sync rule."
),
)

Expand Down
18 changes: 12 additions & 6 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@

from .calib import MseCalibrator, NVFP4MSECalibrator, _Calibrator
from .conversion import create_and_replace_svdquant_linear_on_the_fly, set_quantizer_by_cfg_context
from .nn import NVFP4StaticQuantizer, QuantModule, SequentialQuantizer, TensorQuantizer
from .nn import (
GroupedQuantizer,
NVFP4StaticQuantizer,
QuantModule,
SequentialQuantizer,
TensorQuantizer,
)
from .utils import (
SHARED_PATTERNS,
SharedWeightGlobalAmaxState,
Expand Down Expand Up @@ -205,7 +211,7 @@ def _has_expert_parallelism(module: nn.Module) -> bool:


def _iter_leaf_quantizers(quantizer):
if isinstance(quantizer, SequentialQuantizer):
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer)):

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.

nit:
Create QuantizerContainerBase base class and use that here to avoid explicitly listing like this.

see https://github.com/NVIDIA/Model-Optimizer/pull/1550/changes#r3553373837

for _q in quantizer:
yield from _iter_leaf_quantizers(_q)
return
Expand Down Expand Up @@ -314,12 +320,12 @@ def max_calibrate(
for name, module in model.named_modules():
if isinstance(module, QuantModule) and _has_expert_parallelism(module):
for child in module.children():
if isinstance(child, (TensorQuantizer, SequentialQuantizer)):
if isinstance(child, (TensorQuantizer, SequentialQuantizer, GroupedQuantizer)):
_check_moe_calibration_complete(child, module.parallel_state)

def sync_quantizer_amax_across_dp_ep(quantizer, parallel_state, parent_name, child_name):
"""Sync amax across DP (always) and EP (filtered — see _should_sync_amax_across_ep)."""
if isinstance(quantizer, SequentialQuantizer):
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer)):
for _q in quantizer:
sync_quantizer_amax_across_dp_ep(_q, parallel_state, parent_name, child_name)
return
Expand All @@ -333,7 +339,7 @@ def sync_quantizer_amax_across_dp_ep(quantizer, parallel_state, parent_name, chi
for name, module in model.named_modules():
if isinstance(module, QuantModule):
for child_name, child in module.named_children():
if isinstance(child, (TensorQuantizer, SequentialQuantizer)):
if isinstance(child, (TensorQuantizer, SequentialQuantizer, GroupedQuantizer)):
sync_quantizer_amax_across_dp_ep(child, module.parallel_state, name, child_name)
# Step 3: TP sync
# Objective: the quantization parameters when TP = 8 then changed to TP=4 then back to TP=8 should be the same
Expand All @@ -355,7 +361,7 @@ def sync_quantizer_amax_across_tp(
parallel_state: ParallelState,
):
# Syncing amax across TP for sequential quantizer
if isinstance(quantizer, SequentialQuantizer):
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer)):
for _q in quantizer:
sync_quantizer_amax_across_tp(
_q, linear_name, quantizer_type, axes_for_sync, parallel_state
Expand Down
69 changes: 69 additions & 0 deletions modelopt/torch/quantization/nn/modules/tensor_quantizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from ..functional import normalized_hadamard_transform

__all__ = [
"GroupedQuantizer",
"HardDisabledTensorQuantizer",
"NVFP4StaticQuantizer",
"SequentialQuantizer",
Expand Down Expand Up @@ -1641,3 +1642,71 @@ def convert_to_single_quantizer(model, indx: int = 0):
) in original_sequential_quantizers.items():
for name, sequential_quantizer in sequential_quantizers_list:
setattr(parent_module, name, sequential_quantizer)


class GroupedQuantizer(nn.ModuleList):

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.

nit: SequentialQuantizer and GroupedQuantizer share sever overlapping pieces (such delegation, attribute setter etc). Can we create a shared base-class which implements these overlapping pieces and subclass SequentialQuantizer and GroupedQuantizer from it to avoid code level redundancy?

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.

RB: Can you create a plan on how to do this and share in this thread?

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.

🤖 Bot comment.

🐝 I’m reviewing the overlapping behavior and will share a concrete refactoring plan here shortly.

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.

🤖 Bot comment.

Plan:

  1. Add a private _QuantizerContainerBase mixin before the two containers. It will own the common representative-property reads, property writes broadcast to every member, delegated lifecycle-method calls, attribute-config fan-out, and the common get_modelopt_state() shape (using a subclass-provided state-key).
  2. Make SequentialQuantizer inherit from the mixin plus nn.Sequential, and GroupedQuantizer from the mixin plus nn.ModuleList. Keep PyTorch container initialization in each subclass so module registration/state-dict keys remain unchanged.
  3. Keep only semantic differences in the subclasses: accepted member types, sequential chaining vs. grouped representative forward, disable_rotate delegation for sequential only, delegated-method return behavior, and convert_to_single_quantizer for sequential only.
  4. Add parameterized CPU tests for the shared contract (representative reads, broadcast writes/methods, scalar/list config application, and exact modelopt-state output), plus regression tests that sequential chaining, grouped first-member forward, nested SequentialQuantizer members, and checkpoint/state-dict layout are unchanged.

This keeps the refactor local to tensor_quantizer.py and its focused tests; downstream (SequentialQuantizer, GroupedQuantizer) handling does not need to change.

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.

@jenchen13 could you take a look at this comment?

"""A container for per-group :class:`TensorQuantizer` modules.

Used when a single linear holds several independently-quantized weights — e.g. the
fused experts of a TEGroupedLinear, where each of the ``num_gemms`` weights needs its
own ``amax``. Unlike :class:`SequentialQuantizer` (an ``nn.Sequential`` that *chains*
quantizers over one tensor), the contained quantizers act on *different* tensors, so
there is no inherent forward path: index in with ``grouped[i](weight_i)``.

Property reads (``amax``, ``is_enabled``) delegate to the first quantizer — all members
share one config, so the first is representative for "is this calibrated/enabled"
checks; the real per-group values live on the members and are used via indexing.
Lifecycle/config methods broadcast to every member.
"""

_delegated_properties = ["fake_quant", "is_enabled", "amax"]
_delegated_methods = [
"reset_amax",
"disable",
"enable",
"load_calib_amax",
"load_calib_bias",
]

def __init__(self, *quantizers: "TensorQuantizer | SequentialQuantizer"):
"""Initialize GroupedQuantizer module."""
super().__init__(quantizers)
assert all(isinstance(q, (TensorQuantizer, SequentialQuantizer)) for q in self), (
"All quantizers must be a TensorQuantizer or SequentialQuantizer."
)

def forward(self, inputs):
"""Apply the representative quantizer for single-weight compatibility paths."""
return self[0](inputs)

def __getattr__(self, name):
"""Delegate property reads to the first member and method calls to all members."""
if name in self._delegated_properties:
return getattr(self[0], name)

if name in self._delegated_methods:

def method_wrapper(*args, **kwargs):
return [getattr(quantizer, name)(*args, **kwargs) for quantizer in self]

return method_wrapper

return super().__getattr__(name)

def __setattr__(self, name, value):
if name in self._delegated_properties:
for quantizer in self:
setattr(quantizer, name, value)
else:
super().__setattr__(name, value)

def set_from_attribute_config(self, attributes):
"""Set the attributes of contained quantizers; a single config broadcasts to all."""
if not isinstance(attributes, (list, tuple)):
attributes = [attributes] * len(self)
for attribute, quantizer in zip(attributes, self):
quantizer.set_from_attribute_config(attribute)

def get_modelopt_state(self) -> dict[str, Any]:
"""Get meta state to be saved in checkpoint."""
return {"num_quantizers": len(self), "is_grouped_quantizer": True}

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] GroupedQuantizer.get_modelopt_state() and its is_grouped_quantizer flag appear to be dead code. quantizer_state() (conversion.py:202-208) and quant_module_get_extra_state() (megatron.py:145-148) only collect TensorQuantizer/SequentialQuantizer instances — a GroupedQuantizer itself is never serialized, only its leaf members are — and no restore-side code reads is_grouped_quantizer (unlike is_sequential_quantizer, which conversion.py:559 branches on). Either wire this into save/restore (which would also give you a natural home for the legacy-checkpoint migration noted in the megatron comment) or drop the method to avoid implying a round-trip that doesn't exist.

20 changes: 17 additions & 3 deletions modelopt/torch/quantization/plugins/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@

from modelopt.torch.utils.distributed import ParallelState

from ..nn import NVFP4StaticQuantizer, QuantModule, SequentialQuantizer, TensorQuantizer
from ..nn import (
GroupedQuantizer,
NVFP4StaticQuantizer,
QuantModule,
SequentialQuantizer,
TensorQuantizer,
)
from ..nn.modules.quant_linear import _QuantLinear
from ..utils import multi_context, replace_function

Expand Down Expand Up @@ -134,11 +140,19 @@ def _check_unsupported_states(quantizer: TensorQuantizer):

def _has_state(quantizer, name):
# Handling for SequentialQuantizer
quantizer = quantizer[0] if isinstance(quantizer, SequentialQuantizer) else quantizer
quantizer = (
quantizer[0]
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer))
else quantizer
)
return hasattr(quantizer, name)

def _has_complete_static_nvfp4_weight_state(quantizer, weight):
quantizer = quantizer[0] if isinstance(quantizer, SequentialQuantizer) else quantizer
quantizer = (
quantizer[0]
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer))
else quantizer
)
if not isinstance(quantizer, NVFP4StaticQuantizer):
return False
amax = getattr(quantizer, "_amax", None)
Expand Down
20 changes: 16 additions & 4 deletions modelopt/torch/quantization/plugins/megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

"""Support quantization for megatron linear layers."""

import re
import types
from contextlib import contextmanager
from typing import Any
Expand Down Expand Up @@ -42,7 +43,13 @@

from ..algorithms import AutoQuantizeGradientSearcher
from ..conversion import maybe_promote_nvfp4_static_quantizer
from ..nn import QuantModule, QuantModuleRegistry, SequentialQuantizer, TensorQuantizer
from ..nn import (
GroupedQuantizer,
QuantModule,
QuantModuleRegistry,
SequentialQuantizer,
TensorQuantizer,
)
from ..nn.modules.quant_linear import RealQuantLinear
from ..qtensor import QTensorWrapper
from ..utils import sync_moe_expert_amax
Expand Down Expand Up @@ -90,7 +97,7 @@ def _check_nvfp4_static_tp_supported(model: torch.nn.Module) -> None:
continue
leaves = (
list(weight_quantizer)
if isinstance(weight_quantizer, SequentialQuantizer)
if isinstance(weight_quantizer, (SequentialQuantizer, GroupedQuantizer))
else [weight_quantizer]
)
if any(leaf.is_nvfp4_static for leaf in leaves):
Expand Down Expand Up @@ -697,8 +704,13 @@ def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs):
return super()._load_from_state_dict(filtered_state_dict, prefix, *args, **kwargs)

def _process_quantizer_amax(self, k, v, quantizer_state_dict):
assert v.numel() == 1, "TEGroupedLinear only supports per-tensor quantization"
quantizer_state_dict[k] = v.view(-1)
# Per-expert quantizers have independent checkpoint keys. Preserve their native
# scalar, channel, or block shape instead of flattening them through the legacy
# single-quantizer path.
if re.match(r"weight_quantizer\.\d+\..+_amax$", k):
quantizer_state_dict[k] = v

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.

Should we do

Suggested change
quantizer_state_dict[k] = v
quantizer_state_dict[k] = v.view(-1) if v.numel() == 1 else v

else:
quantizer_state_dict[k] = v.view(-1) if v.numel() == 1 else v

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 Compatibility] This change alters the modelopt_state/quantizer-state key schema for TEGroupedLinear with no migration path.

Before: a single weight_quantizer (TensorQuantizer) → one key ...weight_quantizer._amax.
After: a GroupedQuantizer (ModuleList) → per-member keys ...weight_quantizer.0._amax, ...weight_quantizer.1._amax, …

quantizer_state() (conversion.py:202-208) only collects the leaf TensorQuantizer/SequentialQuantizer members, so a checkpoint saved before this PR has key ...weight_quantizer while the restored model now exposes ...weight_quantizer.0/.1/.... On restore:

  • Non-sharded path: restore_quantizer_state computes unmatched_keys = state.keys() - model.keys() and raises ApplyModeError on the mismatch (conversion.py:144-148).
  • Megatron torch-dist path: quant_module_set_extra_state does quantizer_state[name] for each weight_quantizer.i and hits a KeyError.

Either way, existing quantized TEGroupedMLP checkpoints become unloadable after this change. Please add a migration/back-compat shim (e.g. map a legacy scalar weight_quantizer._amax onto all weight_quantizer.{i} members during restore) or a state-version bump with an explicit remap, and document the break in the CHANGELOG if intentional.


@QuantModuleRegistry.register(
{TEColumnParallelGroupedLinear: "megatron_TEColumnParallelGroupedLinear"}
Expand Down
70 changes: 64 additions & 6 deletions modelopt/torch/quantization/plugins/transformer_engine.py

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.

Design looks good to me overall, left a comment here - https://github.com/NVIDIA/Model-Optimizer/pull/1550/changes#r3468773327

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@

"""Support quantization for Transformer Engine layers."""

import copy
import inspect
import os
import warnings

import torch
Expand All @@ -27,11 +29,13 @@

from modelopt.torch.quantization.utils import replace_function

from ..nn import QuantModuleRegistry
from ..nn import GroupedQuantizer, QuantModuleRegistry, SequentialQuantizer
from .custom import _ParallelLinear

_TE_VERSION = Version(te.__version__)

_COMPILE_TEGROUPED_WEIGHT_LOOP_ENV = "MODELOPT_TEGROUPED_COMPILE_WEIGHT_LOOP"


def _assert_te_fp8_enabled():
"""Check if Transformer Engine FP8 autocast is enabled and raise error if so."""
Expand All @@ -48,6 +52,13 @@ def _assert_te_fp8_enabled():
pass # Older TE versions may not have this API


def _is_calibrating(quantizer):
"""Return whether a tensor or sequential quantizer is collecting calibration stats."""
if isinstance(quantizer, SequentialQuantizer):
return any(getattr(q, "_if_calib", False) for q in quantizer)
return getattr(quantizer, "_if_calib", False)


@QuantModuleRegistry.register({te.pytorch.Linear: "te_Linear"})
class _QuantTELinear(_ParallelLinear):
@property
Expand Down Expand Up @@ -137,8 +148,27 @@ def _setup(self):
# Remove self.weight after setup.
delattr(self, "weight")

# TODO: GroupedLinear supports weights split by `num_gemms`, to support quantization
# with static parameters beyond per-tensor, we need to support a unique quantizer for each gemm.
# Each fused expert gets its own weight quantizer (independent amax). Storing them in a
# GroupedQuantizer (an nn.ModuleList) surfaces them as ``weight_quantizer.{i}``, which the
# fused-experts name normalizer maps to ``*weight_quantizer`` so the stock configs apply.
self.weight_quantizer = GroupedQuantizer(
*(copy.deepcopy(self.weight_quantizer) for _ in range(self.num_gemms))

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.

We dont need this deep copy here.

Suggested change
*(copy.deepcopy(self.weight_quantizer) for _ in range(self.num_gemms))
TensorQuantizer() for _ in range(self.num_gemms))

The TensorQuantizers are always initialized with default args; they are configured after _setup -> so we can safely initialize new TensorQuantizers here. deepcopy seems unwarranted to me.

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.

RB: Can you please explain concisely "The TensorQuantizers are always initialized with default args; they are configured after _setup -> so we can safely initialize new TensorQuantizers here. deepcopy seems unwarranted to me." with code pointers ?

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.

🤖 Bot comment.

🐝 I’m tracing the initialization and post-_setup configuration paths now; I’ll reply here shortly with exact code pointers.

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.

🤖 Bot comment.

Yes—the key is the conversion order:

  1. convert_to_quantized_model() first calls replace_quant_module(), then set_quantizer_by_cfg().
  2. Conversion invokes the module's _setup(). The base setup creates weight_quantizer with the standard 8-bit per-tensor descriptor (custom.py:113-117); that descriptor is just QuantizerAttributeConfig(num_bits=8) (tensor_quant.py:279), equivalent to TensorQuantizer()'s schema defaults.
  3. _QuantTEGroupedLinear._setup() builds the group immediately after that base setup (lines 147-156), still before user config is applied.
  4. Afterwards, set_quantizer_by_cfg() configures the registered weight_quantizer.0, .1, … children; the fused-expert name normalization makes them match stock *weight_quantizer rules (conversion.py:319-355).

So deepcopy is only cloning a pristine default quantizer (including an empty/default calibrator). Fresh TensorQuantizer() instances give each expert independent state and are configured identically in the next phase. deepcopy would matter only if setup had already attached non-default config/state, which this path does not.

)

# Compile only the per-expert quantizer loop. The surrounding TE grouped GEMM remains
# eager, and the opt-in flag leaves the default execution path unchanged.
if os.getenv(_COMPILE_TEGROUPED_WEIGHT_LOOP_ENV, "0") == "1":
quantizers = tuple(self.weight_quantizer)

def quantize_weights(*weights):
return tuple(quantizer(weight) for quantizer, weight in zip(quantizers, weights))

self._compiled_weight_quantizer_loop = torch.compile(
Comment thread
realAsma marked this conversation as resolved.
quantize_weights,
backend="inductor",
fullgraph=False,
mode="reduce-overhead",
)

def modelopt_post_restore(self, prefix: str = ""):
# GroupedMLP stores the weights as weight0, weight1, etc. To run post_restore in order to
Expand All @@ -150,12 +180,29 @@ def modelopt_post_restore(self, prefix: str = ""):
# Remove self.weight after post_restore.
delattr(self, "weight")

# Base post_restore only re-calibrates the first member; the other per-expert
# quantizers also need re-calibration so a TP/EP change between save and restore
# produces correctly shaped per-channel _amax. Mirror base behavior: only
# re-calibrate quantizers whose loaded state had _amax (skip unused ones).
from modelopt.torch.quantization.model_calib import max_calibrate

for i in range(self.num_gemms):
weight_i = getattr(self, f"weight{i}", None)
if weight_i is None:
continue
wq_i = self.weight_quantizer[i]
q = wq_i[0] if isinstance(wq_i, SequentialQuantizer) else wq_i
if not hasattr(q, "_amax"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL ModeState] Per-expert restore loses _amax for every expert except expert 0.

What happens: super().modelopt_post_restore() (custom.py:177-181) runs before this loop and executes:

self.weight_quantizer.reset_amax()   # GroupedQuantizer delegated method -> broadcasts to ALL members
max_calibrate(self.weight_quantizer, lambda wq: wq(self.weight), distributed_sync=False)

reset_amax() is in GroupedQuantizer._delegated_methods, so it deletes _amax on every member. But the max_calibrate forward loop calls self.weight_quantizer(self.weight), which routes through GroupedQuantizer.forwardself[0](...), so only member 0 gets recalibrated. Members 1..num_gemms-1 are left with no _amax.

This loop is then supposed to recalibrate the rest, but the guard if not hasattr(q, "_amax"): continue sees exactly those members with the _amax that super() just deleted, and skips them.

Impact: After restore() (both the non-sharded restore_quantizer_state path and the Megatron torch-dist set_extra_state path, which both call modelopt_post_restore), experts 1..N-1 of every TEGroupedMLP have weight_quantizer[i].amax == None. For a static INT8/FP8/NVFP4 config that means broken/incorrect quantization on all but the first expert — restore is not functionally identical to the saved model. num_gemms == 1 happens to work, which is why the toy tests (which cover quantize, not save→restore) don't catch it.

Fix: determine which members had saved _amax before calling super(), e.g.:

had_amax = []
for i in range(self.num_gemms):
    wq_i = self.weight_quantizer[i]
    q = wq_i[0] if isinstance(wq_i, SequentialQuantizer) else wq_i
    had_amax.append(hasattr(q, "_amax"))

super().modelopt_post_restore(prefix=prefix)
delattr(self, "weight")

from modelopt.torch.quantization.model_calib import max_calibrate
for i in range(self.num_gemms):
    weight_i = getattr(self, f"weight{i}", None)
    if weight_i is None or not had_amax[i]:
        continue
    wq_i = self.weight_quantizer[i]
    wq_i.reset_amax()
    max_calibrate(wq_i, lambda wq, w=weight_i: wq(w), distributed_sync=False)

(Note super() deletes self.weight, so snapshotting must happen first anyway.) Please also add a save→restore test covering num_gemms > 1 — the current tests exercise quantize only.

continue
wq_i.reset_amax()
max_calibrate(wq_i, lambda wq, w=weight_i: wq(w), distributed_sync=False)

def iter_weights_for_calibration(self):
"""Yield ``(weight_i, weight_quantizer)`` for each of the ``num_gemms`` grouped weights."""
for i in range(self.num_gemms):
weight_i = getattr(self, f"weight{i}", None)
if weight_i is not None:
yield weight_i, self.weight_quantizer
yield weight_i, self.weight_quantizer[i]

@staticmethod
def te_grouped_quantized_linear_fn(package, func_name, self, *args):
Expand Down Expand Up @@ -184,8 +231,19 @@ def te_grouped_quantized_linear_fn(package, func_name, self, *args):

new_args = list(args)
new_args[inp_pos] = self.input_quantizer(args[inp_pos])
for i in range(weights_start, weights_start + num_gemms):
new_args[i] = self.weight_quantizer(args[i])
weights = tuple(args[weights_start : weights_start + num_gemms])
# Calibration mutates collector state and must stay outside Inductor/CUDAGraph capture.
use_compiled_loop = hasattr(self, "_compiled_weight_quantizer_loop") and not any(
_is_calibrating(quantizer) for quantizer in self.weight_quantizer
)
if use_compiled_loop:
quantized_weights = self._compiled_weight_quantizer_loop(*weights)
else:
quantized_weights = tuple(
self.weight_quantizer[gemm_idx](weight) for gemm_idx, weight in enumerate(weights)
)
for gemm_idx, quantized_weight in enumerate(quantized_weights):
new_args[weights_start + gemm_idx] = quantized_weight
output = getattr(package, func_name)(*new_args)
# TE 2.15+ returns `(out, new_workspaces)`; TE <= 2.14 returns just `out`.
# Only the activation tensor participates in output quantization.
Expand Down
Loading
Loading