Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3d9f398
Bring in the synthetic experts' torch_a2a dispatch with the tests tha…
shengliangxu Oct 28, 2026
08297f8
Calibrate fused experts on grouped_mm and torch_a2a instead of forcin…
shengliangxu Oct 27, 2026
e474021
Raise when a fused-experts forward bypasses both interception hooks
shengliangxu Oct 27, 2026
d169ad5
Keep grouped_mm, and redirect to it, only where _grouped_linear can b…
shengliangxu Oct 27, 2026
c95a5c3
Count an eager experts forward only when it routes a row to this rank…
shengliangxu Oct 27, 2026
a598785
Require interception for expert weight quantizers only when their inp…
shengliangxu Oct 27, 2026
6b69d6b
Stop counting experts forwards once an interception hook has fired
shengliangxu Oct 28, 2026
9477292
Wrap the grouped calibration test's quantize calls at 100 columns
shengliangxu Oct 28, 2026
d02b3e3
Capture each expert's inputs on the path that ran, not the one config…
shengliangxu Oct 28, 2026
717e9b7
Say that the interception check is rank-local
shengliangxu Oct 28, 2026
7323814
Drop the note that grouped_mm's weight-input captures skip its batch
shengliangxu Oct 28, 2026
272e67c
Pin that forwards after a hook fired are not counted
shengliangxu Oct 28, 2026
5122528
Clear the grouped offsets once the experts forward returns
shengliangxu Oct 28, 2026
4fc2ed0
Say where a rank-local interception error leaves the other ranks
shengliangxu Oct 28, 2026
86d774b
Name normalize_experts_impl_on_the_fly in the FP8 experts wrapper's c…
shengliangxu Oct 28, 2026
9f2b8b9
Check fused-experts interception through the loaded HF plugin only
shengliangxu Oct 28, 2026
55dd0ed
Refuse grouped offsets the per-expert split cannot read
shengliangxu Oct 28, 2026
3292256
Refuse grouped offsets whose ends go back
shengliangxu Oct 28, 2026
4076d23
Name the inclusive offsets form the split refuses, not a leading zero
shengliangxu Oct 28, 2026
a3b1816
Add --experts_implementation to hf_ptq.py
shengliangxu Oct 28, 2026
538d8dd
Name calibrate's new fused-experts error in the changelog
shengliangxu Oct 28, 2026
19fe775
Set --experts_implementation on the FSDP2 config directly; pass it on…
shengliangxu Oct 28, 2026
39ffe7d
Warn when quantization moves the fused experts to another backend
shengliangxu Oct 28, 2026
730f1b7
Do not warn about pinning a None experts backend to eager
shengliangxu Oct 28, 2026
afa4ad7
Say a fused-experts backend redirect on rank 0 only
shengliangxu Oct 28, 2026
40c2c85
Pin that --experts_implementation reaches a VLM's text config on FSDP2
shengliangxu Oct 28, 2026
5ab9038
Name the ways forward in the fused-experts interception error
shengliangxu Oct 28, 2026
4386dab
Give the vision-calibration test's arguments the new experts_implemen…
shengliangxu Oct 28, 2026
a69068e
Offer --experts_implementation's two useful values as argparse choices
shengliangxu Oct 29, 2026
8108562
Pin that transformers' experts take the routing indices second
shengliangxu Oct 29, 2026
da24de9
Reduce the interception check over the experts' expert-parallel group
shengliangxu Oct 29, 2026
6ed89f8
Say the interception check's reduction needs transformers 5.16's experts
shengliangxu Oct 29, 2026
9f0d37f
Say the interception check's gates must agree across ranks
shengliangxu Oct 29, 2026
9e02ecc
Give the FSDP2 KV AutoQuantize test's arguments experts_implementation
shengliangxu Oct 29, 2026
baf18d1
Return the experts' weight itself when its quantizers only collect st…
shengliangxu Oct 30, 2026
4d130ad
Say the experts stay on grouped_mm for every forward after quantize o…
shengliangxu Oct 30, 2026
ef69ee3
Drop the local-expert indexing the grouped weight quantization never …
shengliangxu Oct 30, 2026
f7c22a1
Call only calibrating quantizers on the grouped calibration pass-through
shengliangxu Oct 30, 2026
5b82e72
Pin that a grouped experts forward counts whatever its routing says
shengliangxu Oct 30, 2026
5ef19b9
Pin that a capture after a grouped forward does not use its offsets
shengliangxu Oct 30, 2026
f1e5e1b
Read transformers' modeling sources as UTF-8 in the routing-order test
shengliangxu Oct 30, 2026
a6758d7
Calibrate grouped inputs on the rows the grouped GEMM computes
shengliangxu Oct 30, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Changelog
- ``hf_quant_config.json`` of an INT4 or W4A8 AWQ checkpoint now sets ``pre_quant_scale`` per layer, only for layers that have the tensor. A model whose fused-MoE experts have none (AWQ never applies to them) is now exported as ``MIXED_PRECISION`` with per-layer entries instead of a uniform algorithm.
- The Windows FVD example rejects I3D checkpoints requiring unrestricted pickle loading. Custom TorchScript archives now require ``--weights-sha256`` with a trusted checksum; tensor state dictionaries continue to load directly.
- A ``modelopt_state`` saved by an earlier release for a Hugging Face model whose fused MoE experts are stored transposed without a quantized wrapper of their own (for example Aria's) no longer restores, since those experts are now left unquantized. The checkpoints such a state exported had wrong expert weights, so there is nothing to migrate.
- ``mtq.quantize`` and ``mto.restore`` no longer force a Hugging Face model's fused MoE experts onto the ``eager`` backend: calibration and every later forward (QAT/QAD training, AutoQuantize scoring, fake-quant evaluation) run on the ``grouped_mm`` backend Transformers selects by default, so calibrated activation scales (some of Qwen3-30B-A3B's moved by up to about 10%), step time and memory can differ from earlier releases. Load the model with ``experts_implementation="eager"`` (``--experts_implementation eager`` in ``hf_ptq.py``) to run as earlier releases did.
- ``mtq.calibrate`` now raises an error, naming the modules, when a Hugging Face fused-experts forward reaches neither quantizer hook (a hand-written ``bmm`` forward that transformers cannot redirect, say), instead of exporting weight-derived input scales with a warning.

- Layerwise-calibration resume (``checkpoint_dir``) now loads checkpoint files with ``weights_only=True``. A checkpoint directory written by an earlier release whose layer inputs hold objects other than tensors, plain containers or Hugging Face dynamic caches can no longer be resumed; recalibrate in a fresh directory, or disable checkpointing.

Expand Down
7 changes: 7 additions & 0 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ def get_model(
trust_remote_code=False,
use_seq_device_map=False,
attn_implementation=None,
experts_implementation=None,
offload_folder=None,
max_cpu_memory_gb=None,
max_gpu_memory_gb=None,
Expand Down Expand Up @@ -641,6 +642,11 @@ def get_model(
# Note: Forcibly converting the model precision between bf16 and fp16 may introduce accuracy drop
model_kwargs = config_kwargs.copy()
model_kwargs.setdefault("dtype", "auto")
# Only when asked, on every load below: an explicit None is a value transformers stores.
experts_kwargs = (
{} if experts_implementation is None else {"experts_implementation": experts_implementation}
)
model_kwargs.update(experts_kwargs)

# DiffusionGemma ties encoder/decoder weights. device_map "auto" (balanced) can split
# a tied pair across GPUs, leaving one side on the meta device and breaking generation.
Expand Down Expand Up @@ -720,6 +726,7 @@ def has_pack_quantized_config(config):
device_map="auto",
trust_remote_code=trust_remote_code,
dtype="auto",
**experts_kwargs,
)
elif get_original_hf_quant_method(hf_config) == "mxfp4":
# Native MXFP4 checkpoints (e.g. openai/gpt-oss-*) must be dequantized to
Expand Down
19 changes: 19 additions & 0 deletions examples/hf_ptq/hf_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ def load_model(args: argparse.Namespace):
hf_config = AutoConfig.from_pretrained(
args.local_checkpoint_path, trust_remote_code=args.trust_remote_code
)
if args.experts_implementation is not None:
# Set on the config, as build_meta_causal_lm sets a pre-fetched config's
# attn_implementation: the meta model is built from it, with no kwarg of its own. The
# setter carries it to the sub-configs, such as the text config a VLM's experts read.
hf_config._experts_implementation = args.experts_implementation
Comment thread
shengliangxu marked this conversation as resolved.
Resolved
validate_fsdp2_supported(args, hf_config)
full_model = parallel_load_and_prepare_fsdp2(
args.local_checkpoint_path,
Expand All @@ -342,6 +347,7 @@ def load_model(args: argparse.Namespace):
trust_remote_code=args.trust_remote_code,
use_seq_device_map=args.use_seq_device_map,
attn_implementation=args.attn_implementation,
experts_implementation=args.experts_implementation,
offload_folder=args.offload_folder,
max_cpu_memory_gb=args.max_cpu_memory_gb,
max_gpu_memory_gb=args.max_gpu_memory_gb,
Expand All @@ -367,6 +373,8 @@ def load_model(args: argparse.Namespace):
model_kwargs = {"trust_remote_code": args.trust_remote_code}
if args.attn_implementation is not None:
model_kwargs["attn_implementation"] = args.attn_implementation
if args.experts_implementation is not None:
model_kwargs["experts_implementation"] = args.experts_implementation
full_model = load_pretrained(
args.local_checkpoint_path,
AutoModelForCausalLM,
Expand Down Expand Up @@ -1346,6 +1354,17 @@ def parse_args() -> argparse.Namespace:
default=None,
type=str,
)
parser.add_argument(
"--experts_implementation",
help=(
"Fused MoE experts backend to load the model with, passed to the HF model loading as "
"experts_implementation. By default transformers picks grouped_mm where the model "
"supports it; eager calibrates exactly as earlier releases did. Quantization redirects "
"every other backend to one of these two."
),
default=None,
choices=["eager", "grouped_mm"],
)
parser.add_argument(
"--auto_quantize_checkpoint",
type=str,
Expand Down
80 changes: 67 additions & 13 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,9 +1075,8 @@ def _fused_expert_projections(module: nn.Module):
``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. Under eager it runs once
per expert forward with ``module._current_expert_idx`` set to that expert; under grouped_mm once
per projection, on every expert's rows, with the index cleared (``None``), so a capture keyed on
it skips that batch.
per expert forward with ``module._current_expert_idx`` set to that expert; under grouped_mm
and torch_a2a once per projection, on a batch sorted by expert (``module._grouped_offs``).
"""
first_proj_attr = getattr(module, "_first_proj_attr", "gate_up_proj")
for weight_name, quantizers_name, input_q_name in (
Expand Down Expand Up @@ -1131,6 +1130,31 @@ def _refuse_packed_fused_expert_weights(name, module, method, updates):
)


def _expert_slices(offs: torch.Tensor, num_experts: int, rows: int):
"""``(expert, start, end)`` for the rows of each expert in a batch sorted by expert.
``offs`` is the cumulative token count per expert (``_grouped_offs``): exclusive ends, one per
expert, as transformers' grouped_mm forward and ``a2a_moe`` build them. An expert without rows is
skipped, and rows beyond ``offs[-1]`` (expert-parallel sentinels) belong to no expert. Offsets of
another form raise (the inclusive ``[0, c0, c0 + c1, ...]`` form, which carries one end too many,
or ends out of order), since they would credit experts with other experts' rows.
"""
ends = offs.tolist()

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.

[SUGGESTION Performance] offs.tolist() is a device→host copy, and _expert_slices is re-entered from each capture hook on every grouped forward: once for the first projection and once for down_proj, so two syncs per experts module per calibration batch under local_hessian / imatrix / gptq. On a 48-layer MoE with a few hundred calibration batches that is tens of thousands of syncs on an otherwise async path.

Doing the tolist() eagerly in _quantized_grouped_linear would be worse — it would sync on the max path too, where no capture is registered and the offsets are never read. A lazy, per-forward cache keeps the current "only sync when a capture needs it" property while paying once:

def _expert_slices(offs: torch.Tensor, num_experts: int, rows: int):
    ...
    ends = _ENDS_CACHE.get(offs)          # keyed on the tensor object the forward set
    if ends is None:
        ends = _ENDS_CACHE[offs] = offs.tolist()

with the entry dropped where forward() already clears _grouped_offs. Non-blocking: gptq and local_hessian dominate this cost with their per-expert matmuls, so it is a constant-factor win rather than a correctness issue — worth doing only if it stays as small as the sketch.

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.

Leaving it as is, as you framed it, optional. The second tolist() happens only under the capture algorithms (GPTQ, local Hessian, imatrix), once per experts module per batch. Each of those captures also runs one matmul per expert per projection, which dominates. A cache keyed on the offsets tensor would add state whose lifetime has to match the forward.

If a profile of a large MoE under GPTQ shows the sync, it's a small follow-up.

if (
len(ends) != num_experts
or (ends and ends[-1] > rows)
or any(end < prev for prev, end in zip([0, *ends], ends))
):
raise RuntimeError(
f"Grouped experts offsets {ends} do not split {rows} rows over {num_experts} experts."
)
Comment thread
shengliangxu marked this conversation as resolved.
Resolved
start = 0
for idx, end in enumerate(ends):
if end > start:
yield idx, start, end
start = end


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

Expand All @@ -1146,23 +1170,41 @@ def _register_weight_input_hooks(model, names, capture, on_weight=None):
``capture(weight_quantizer, weight, inputs)`` runs on every forward of a weight whose quantizer
is enabled: dense quantized linears hook the layer input; HF fused-MoE experts hook the shared
input quantizers, keyed by the active expert (``_current_expert_idx``). Weights without a hook
(conv, SequentialQuantizer, non-eager experts) are never captured. ``on_weight(name, weight,
weight_quantizer)``, if given, is called once per hooked weight. Returns removable handles.
input quantizers, keyed by the active expert (``_current_expert_idx``) when eager, or split per
expert by ``_grouped_offs`` under ``grouped_mm``. Weights without a hook (conv,
SequentialQuantizer) are never captured. ``on_weight(name, weight, weight_quantizer)``, if
given, is called once per hooked weight. Returns removable handles.
"""
handles: list = []

def _make_expert_hook(expert_module, weight_name, quantizers, enabled):
"""Per-expert capture for whichever forward ran.
The eager forward feeds the input quantizer one call per expert (``_current_expert_idx``);
the grouped ones (``grouped_mm``, ``torch_a2a``) feed it one expert-sorted batch per
projection, which ``_grouped_offs`` (set by the grouped interceptor, cleared by the eager one)
splits back per expert. Rows beyond ``offs[-1]`` (EP sentinels) belong to no expert.
"""

def _expert_hook(_input_quantizer, args):
if not args:
return
idx = expert_module._current_expert_idx
if idx in enabled:
# Read the weight fresh (valid under accelerate/FSDP re-materialization), indexed by
# this rank's own experts: an expert-parallel weight is a DTensor whose index names a
# global expert (see ``_local_experts``).
weight = expert_module._local_experts(getattr(expert_module, weight_name))
capture(quantizers[idx], weight[idx], args[0])
# Read the weight fresh (valid under accelerate/FSDP re-materialization), indexed by this
# rank's own experts: an expert-parallel weight is a DTensor whose index names a global
# expert (see ``_local_experts``).
offs = expert_module._grouped_offs
if offs is None:
idx = expert_module._current_expert_idx
if idx in enabled:
weight = expert_module._local_experts(getattr(expert_module, weight_name))
capture(quantizers[idx], weight[idx], args[0])
return
weight = expert_module._local_experts(getattr(expert_module, weight_name))
for idx, start, end in _expert_slices(
offs, expert_module.num_experts, args[0].shape[0]
):
if idx in enabled:
capture(quantizers[idx], weight[idx], args[0][start:end])

return _expert_hook

Expand Down Expand Up @@ -1198,6 +1240,7 @@ def _dense_hook(linear, args):
enabled = {i for i, q in enumerate(quantizers) if q.is_enabled}
if not enabled:
continue
module._weight_input_capture = True
handles.append(
input_quantizer.register_forward_pre_hook(
_WeightInputHook(
Expand Down Expand Up @@ -2588,6 +2631,16 @@ def _make_gptq_handle(name, m, weight_quantizer=None, weight=None, log_mse=True)

def _make_expert_hook(experts, per_expert):
def _expert_hook(_input_quantizer, _args, output):
if experts._grouped_offs is not None:
# grouped_mm and torch_a2a quantize one expert-sorted batch per projection: split
# it back per expert by the cumulative token counts, as the eager loop feeds it.
for idx, start, end in _expert_slices(
experts._grouped_offs, experts.num_experts, output.shape[0]
):
handle = per_expert.get(idx)
if handle is not None:
handle.accumulate(output[start:end])
return
handle = per_expert.get(experts._current_expert_idx)
if handle is not None:
handle.accumulate(output)
Expand All @@ -2611,6 +2664,7 @@ def _expert_hook(_input_quantizer, _args, output):
if q.is_enabled
}
if per_expert:
module._weight_input_capture = True
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))
Expand Down
34 changes: 34 additions & 0 deletions modelopt/torch/quantization/model_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import fnmatch
import inspect
import os
import sys
import warnings
from collections.abc import Callable, Iterable, Sequence
from contextlib import contextmanager
Expand Down Expand Up @@ -143,6 +144,25 @@ def calibrate(
def forward_loop(model):
return original_forward_loop() # type: ignore[call-arg]

# Whether the algorithm actually invoked the loop. `max_calibrate` may skip it
# (`skip_forward_without_activation_calib`) when no enabled quantizer needs activation
# statistics, and a post-calibration check that assumed it ran would fire on every
# weight-only calibration.
forward_loop_ran = False
if forward_loop is not None:
_requested_forward_loop = forward_loop

def forward_loop(model):
nonlocal forward_loop_ran
forward_loop_ran = True
return _requested_forward_loop(model)

# The fused-experts interception check below judges this calibration's forwards alone.
for module in model.modules():
if hasattr(module, "_intercept_count"):
module._intercept_count = module._forward_count = 0
module._weight_input_capture = False

# move the model to eval mode, remembering each module's mode; quantizers, and plain containers of them,
# follow the module they quantize instead
training_modes = {
Expand All @@ -169,6 +189,20 @@ def forward_loop(model):

_restore_training_modes(model, training_modes)

if forward_loop_ran:
# A fused-experts module whose forward ran past both interception hooks collected
# nothing. Surface it here rather than let the export emit weight-derived fallback scales
# under a warning that blames the size of the calibration set. Raised once the model is
# back in its mode, for a caller that catches it to calibrate again. Only a loaded HF plugin
# installs fused-experts wrappers; importing it here would rerun one that failed to load,
# which import_plugin only warns about, and fail every calibration with its error. Both
# conditions must come out the same on every rank: under expert parallelism the check
# all-reduces over the experts' group, and a rank that skipped it would leave its peers
# waiting there.
huggingface = sys.modules.get("modelopt.torch.quantization.plugins.huggingface")
if huggingface is not None:
huggingface.assert_fused_experts_intercepted(model)

return model


Expand Down
Loading
Loading