Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
058c4d0
Calibrate a distributed model on per-rank data
shengliangxu Oct 10, 2026
2480a7c
Test the distributed calibration loop
shengliangxu Oct 11, 2026
76f6d92
Split a streaming shard's groups across DataLoader workers
shengliangxu Oct 11, 2026
28abda6
Agree on the calibration batch size across ranks
shengliangxu Oct 13, 2026
a155a05
Agree on the calibration batch size without keeping it on the session
shengliangxu Oct 14, 2026
0db5189
Reduce the calibration batch size through the renamed all_reduce
shengliangxu Oct 14, 2026
76519ee
Calibrate distributed without the session: pass the device and EP mes…
shengliangxu Oct 14, 2026
9dfa99d
Size the calibration batch and check the EP mesh by the model's distr…
shengliangxu Oct 14, 2026
7051c01
State that a streaming calibration dataset must give every worker its…
shengliangxu Oct 15, 2026
da13f6b
Free the CUDA cache around calibration only in a process that started…
shengliangxu Oct 16, 2026
ccd7943
Test a streaming shard's split across DataLoader workers in one process
shengliangxu Oct 16, 2026
ab8d114
Say why shard_dataloader strides streams with its own shard class
shengliangxu Oct 16, 2026
f9ac6a0
Agree on the calibration batch size over two real gloo ranks in its test
shengliangxu Oct 16, 2026
65a4e35
Trim the distributed calibration tests: one per behavior
shengliangxu Oct 16, 2026
b97bbad
Refuse a calibration stream shorter than the world size
shengliangxu Oct 16, 2026
7ecd5ed
Keep the calibration loader's drop_last in its per-rank shard
shengliangxu Oct 16, 2026
ff6e7e4
Refuse rank-local calibration on a model distributed over several ranks
shengliangxu Oct 23, 2026
9f69dd3
Refuse rank-local calibration that another algorithm runs for its scales
shengliangxu Oct 23, 2026
9d48ba0
Refuse max and MSE told not to sync, on a model distributed over seve…
shengliangxu Oct 23, 2026
c7043a6
Import the quantization package at the top of the distributed calibra…
shengliangxu Oct 23, 2026
fbfbb2f
Record the plan hf_ptq's FSDP2 load distributes the model by
shengliangxu Oct 23, 2026
f71d67e
Destroy the group the batch-size test's pooled job creates
shengliangxu Oct 24, 2026
76d99ff
Refuse a loader that picks its own samples, and calibrate without gra…
shengliangxu Oct 24, 2026
6c2dce4
Carry a plain BatchSampler's batching into each rank's calibration shard
shengliangxu Oct 24, 2026
cb8a5b5
Shard a loader with auto-batching off instead of refusing it
shengliangxu Oct 24, 2026
930e083
Refuse expert-parallel calibration that would merge experts' scales
shengliangxu Oct 25, 2026
3834ead
Check each routed expert's groups and re-plant a2a forwards when rebound
shengliangxu Oct 25, 2026
e9809df
Keep a lone rank's calibration loader as is; document FSDP2 refusals
shengliangxu Oct 25, 2026
60cc68c
Let modules without routed experts calibrate under an EP plan
shengliangxu Oct 25, 2026
594fc56
Re-plant only the a2a forward wrappers quantizer insertion dropped
shengliangxu Oct 27, 2026
f323696
Measure a distributed batch size on the GPU the rank computes on
shengliangxu Oct 27, 2026
b7241a7
Refuse a streaming calibration loader that has workers
shengliangxu Oct 27, 2026
502e2bb
Drop the worker clauses a refused streaming loader makes moot
shengliangxu Oct 27, 2026
cf0121b
Refuse a random sampler that draws its own number of samples
shengliangxu Oct 27, 2026
1be0ca2
Name the calibration defaults the rank-local refusal relies on
shengliangxu Oct 27, 2026
90c543e
Size the two-rank batch test's group with bounded_collectives
shengliangxu Oct 29, 2026
dca7db4
Import the batch sizing's distributed helpers at module top
shengliangxu Oct 29, 2026
4c5e779
Read the calibration options through each mode's schema before refusing
shengliangxu Oct 29, 2026
d341853
Check the experts' groups by the ranks they span, not only the data-p…
shengliangxu Oct 29, 2026
8575e0b
Average awq_clip's per-tensor clip losses over the ranks, and allow i…
shengliangxu Oct 29, 2026
e004198
Refuse a calibration whose distributed_sync is None, as when it is False
shengliangxu Oct 29, 2026
aaa6c85
Check the experts' groups against the expert-parallel mesh's rows and…
shengliangxu Oct 29, 2026
77a8f05
Give AWQ-Clip's data-parallel clip its own changelog entry, under Bug…
shengliangxu Oct 29, 2026
ea39549
Average awq_clip's clip search over the ranks once per module, after …
shengliangxu Oct 29, 2026
5cdabaf
Accept the experts' groups of an (edp, ep) mesh with either axis inne…
shengliangxu Oct 29, 2026
05300f9
Skip awq_clip's clip search for an input with no tokens
shengliangxu Oct 29, 2026
6e87c9d
Leave hf_ptq's FSDP2 load recording its plan to the PR whose export r…
shengliangxu Oct 29, 2026
47cdb2a
Size an FSDP2 model's calibration batch by its plan, then calibrate i…
shengliangxu Oct 29, 2026
a57740d
Calibrate a real expert-parallel model through the forward loop, on GPUs
shengliangxu Oct 29, 2026
b5ef9f3
Check the experts' groups against the ep-innermost mesh only
shengliangxu Oct 29, 2026
7aaab86
Reduce awq_clip's clip search in one all-reduce per module, before an…
shengliangxu Oct 29, 2026
c157dc9
Reduce awq_clip's clip-search losses in place, with no copy of them
shengliangxu Oct 29, 2026
41021e5
Name distributed_sync only for the calibrations that have it
shengliangxu Oct 29, 2026
4a5e389
Say that an expert-parallel caller seeds the experts' groups before q…
shengliangxu Oct 29, 2026
ef112db
Say that every rank still decodes every sample of a sharded stream
shengliangxu Oct 29, 2026
067246c
Say that nvfp4_awq_clip still works where multi-rank FSDP2 refuses AWQ
shengliangxu Oct 30, 2026
d763804
Refuse auto_quantize on an expert-parallel model before converting it
shengliangxu Oct 30, 2026
d728d20
Refuse a sampler over another source than the loader's dataset
shengliangxu Oct 30, 2026
727b880
Give mypy a sized default for the sampler's source
shengliangxu Oct 30, 2026
a756f0c
Re-plant only a forward that quantizer insertion rebound
shengliangxu Oct 31, 2026
e23b208
Check that every refused rank-local calibration names a registered mode
shengliangxu Oct 31, 2026
4c7ca05
Drop the local Qwen3MoeConfig import the module now imports at the top
shengliangxu Oct 31, 2026
ff372fb
Trim four docstrings and a comment that restated the code
shengliangxu Oct 31, 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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Changelog
**Bug Fixes**

- Megatron-Core fake quantization of Gated DeltaNet (Qwen3.5 / Qwen3.6) with a Transformer Engine ``in_proj`` now keeps its beta / alpha gates in BF16 during evaluation, QAD, ``fold_weight`` and the vLLM fake-quant export, matching deployment. Other ``in_proj`` layers stay fully quantized and warn.
- AWQ-Clip's per-tensor clip, which ``nvfp4_awq_clip`` uses, is now searched over every data-parallel rank's data, as its block-wise clip already was, so the replicas of a weight take the same clip (exported amax values of data-parallel runs may change) and ``nvfp4_awq_clip`` works with ``examples/hf_ptq/hf_ptq.py --use_fsdp2`` on several GPUs.
- ``hf_quant_config.json`` now declares ``pre_quant_scale`` only for INT4 / W4A8 layers that have the tensor; loading INT4 / W4A8 fused-MoE checkpoints in TensorRT-LLM also needs a release whose MoE loader treats it as optional. AWQ, SmoothQuant and SVDQuant now warn when they skip quantized fused-MoE experts.
- Hugging Face fused MoE experts stored transposed, ``(num_experts, in_dim, out_dim)``, without a quantized wrapper of their own (for example Aria's) are now left unquantized with a warning. They were quantized with gate and up split along the wrong axis, so their exported weights were wrong; the Hugging Face export now refuses such a model's MoE block instead.
- Calibrating a Megatron-Core MLA model now gives ``linear_q_down_proj`` and ``linear_kv_down_proj`` one per-tensor input and weight range, as vLLM and TensorRT-LLM fuse them into one GEMM with one scale per tensor. Re-calibrate existing MLA checkpoints to get the shared ranges.
Expand Down
2 changes: 2 additions & 0 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,8 @@ ModelOpt enables quantization of LLMs across multiple GPU nodes using FSDP2 for

> *AWQ calibration (`awq_lite`, `awq_full`), as the AWQ formats and recipes use, AutoQuantize candidates included, is not supported with `--use_fsdp2` on more than one rank and is rejected before distributed model loading: each rank would fold its own smoothing scale into its weight shard. Drop `--use_fsdp2` (the single-process `device_map` still spreads the model over all the GPUs), run on one rank, or use a non-AWQ format.*

> *`nvfp4_awq_clip` still works with `--use_fsdp2` on more than one GPU: it calibrates with AWQ-Clip alone, which folds no smoothing scale into the weights, and its clip search averages each candidate clip's loss over the ranks, so every rank takes the same clip.*

### Usage

#### Slurm (recommended)
Expand Down
41 changes: 26 additions & 15 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2029,16 +2029,10 @@ def __init__(self, module):
clip_ratios = [
round(float(k), 2) for k in torch.arange(min_clip_ratio, 1.0, shrink_step)
] + [1.0]
if self.is_per_tensor_clip(module):
self.loss = {k: torch.tensor(0.0, device=module.weight.device) for k in clip_ratios}
else:
self.loss = {
k: torch.zeros(
(co, math.ceil(ci / self.block_size)),
device=module.weight.device,
)
for k in clip_ratios
}
shape = () if self.is_per_tensor_clip(module) else (co, math.ceil(ci / self.block_size))
# One buffer for every candidate's loss, which the data-parallel sync reduces in place.
self.loss_buffer = torch.zeros(len(clip_ratios), *shape, device=module.weight.device)
self.loss = dict(zip(clip_ratios, self.loss_buffer))
self.best_clip_val = None
self.best_loss = None

Expand Down Expand Up @@ -2067,7 +2061,24 @@ def update_best_params(self):
indices, self.awq_clip.w_amax * shrink, self.awq_clip.best_clip_val
)

def sync_across_dp(module):
"""Average the clip search over the data-parallel ranks, so every replica picks one clip.

Once, after the forwards: a module's forward count, as an MoE expert's, can differ by rank.
"""
group = module.parallel_state.data_parallel_group
if not group.is_initialized():
return
losses = module.awq_clip.loss_buffer
dist.all_reduce(losses, op=dist.ReduceOp.SUM, group=group.group)
losses /= group.world_size()
tokens = torch.tensor(module.awq_clip.num_tokens, device=losses.device)
dist.all_reduce(tokens, op=dist.ReduceOp.SUM, group=group.group)
module.awq_clip.num_tokens = int(tokens.item())
Comment on lines +2064 to +2077

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.

[IMPORTANT Algorithm] num_tokens is reduced over the data-parallel group only, so a module no token reached on any rank of its DP column ends with num_tokens == 0, postprocess is skipped at line 2213, and AWQClipHelper.__init__'s module.weight_quantizer.disable() is never undone — the module silently exports unquantized (and its input_quantizer stays disabled too). Nothing catches it: _check_weight_quantization_took_effect runs before calibration, and the new inputs.numel() == 0 early return in _clip_search turns what used to be a NaN/garbage loss into a clean, silent skip.

Why it matters more now: this PR's whole premise is that each rank calibrates on its own slice, so each module sees ~1/world_size of the tokens and a zero-token expert becomes a realistic outcome rather than a pathological one. A fused-MoE checkpoint with one expert missing its weight_scale is rejected by the TRT-LLM / vLLM MoE loaders, and under EP the skip is rank-dependent (same module path, different experts), so the ranks disagree on whether that module is quantized at all.

max_calibrate already solves the analogous problem for input amax and is worth reusing rather than paralleling: lines 459-482 share an expert's input amax over the expert-parallel group precisely "so experts that received no tokens take the layer's value", and _check_moe_calibration_complete (line 483-487) then fails loudly on what is still missing. Suggest either reducing num_tokens over expert_model_parallel_group as well, so a row agrees, or — at minimum, per CONTRIBUTING's "fail loudly rather than silently ignore" — emitting a warn_rank_0 naming the modules awq_clip left disabled, so the user does not discover it at deploy time.

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.

The premise that per-rank data makes a zero-token module more likely does not hold. Each module's summed count covers the whole calibration set, as in a single process:

  • A dense module sees tokens on every rank.
  • Under FSDP2 without expert parallelism, every rank holds every expert, and the count is summed over the world.
  • Under expert parallelism, the all-to-all sends every token routed to an expert to the rank that holds it. With edp > 1, the count is summed over the replicas of that shard.

So an expert ends at num_tokens == 0 only if no token of the whole calibration set was routed to it. That is the same condition as in a single-process run on the same data, and main's awq_clip skips such a module the same way.

Summing the count over the expert-parallel group would be wrong here, because that group holds different experts. An expert that no token reached would then run postprocess on all-zero losses and take the first candidate, the smallest clip. max_calibrate can share input amax over that group because a layer's experts see the same inputs, but a clip search scores each expert's own weight.

A warning for the modules awq_clip leaves disabled would help with that pre-existing case, but it is a separate change, so I'm leaving it out of this PR. Note that warn_rank_0 would miss experts held on other ranks.

Comment on lines +2070 to +2077

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] Two smaller things in the same block:

  1. Collective device. The rest of this file picks the device for a quantizer-state collective with _quantizer_state_device(module) (used at lines 471, 489, 513) rather than reading it off a tensor, because a quantizer's state is not always on the module's compute device (CPU-offloaded FSDP2, meta/offloaded weights). losses.device here comes from module.weight.device as it was inside enable_weight_access_and_writeback during AWQClipHelper.__init__, while sync_across_dp deliberately runs outside that context. Reusing the existing helper keeps one rule for all of them.

  2. Per-module cost. This issues two separate all_reduces plus an int(tokens.item()) host sync for every quantized linear — for a large MoE that is thousands of tiny NCCL ops each serialized behind a device→host sync. Folding num_tokens into the same reduction (e.g. allocate one extra element in loss_buffer and carry the count there) halves the collectives and removes the per-module sync; collecting every module's count into one tensor per distinct DP group would remove all but one.

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.

  1. losses.device is the device the losses were accumulated on. The buffer is allocated on module.weight.device inside enable_weight_access_and_writeback, the same place the clip search computes its losses, so this all-reduce runs where the base's per-batch reduction ran. The token count goes on the same device, so a module's two collectives share one device. _quantizer_state_device picks the device for a quantizer's own state, and these buffers are not that state.
  2. The previous round asked for the count in its own int64 tensor, so that it is exact, and I'm keeping it. Only modules whose data-parallel group is set issue the two all-reduces and the host sync. They run once per module, after the forwards and ahead of the weight gathers.


def _clip_search(self, inputs, co_bsz=256, max_tokens=16):
if inputs.numel() == 0: # no tokens to search with; their loss, a mean over none, is NaN
return
weight = self.weight
self.weight_quantizer.enable()

Expand Down Expand Up @@ -2126,11 +2137,6 @@ def _clip_search(self, inputs, co_bsz=256, max_tokens=16):
# co_bsz, n_block
loss = (cur_out - org_out).float().pow(2).mean(dim=1)

parallel_group = self.parallel_state.data_parallel_group
if parallel_group.is_initialized():
dist.all_reduce(loss, op=dist.ReduceOp.SUM, group=parallel_group.group)
loss /= parallel_group.world_size()

del cur_out, cur_w
self.awq_clip.loss[shrink][
co_batch * co_bsz : min((co_batch + 1) * co_bsz, co)
Expand Down Expand Up @@ -2198,6 +2204,11 @@ def postprocess(module):
if module.awq_clip.is_input_quantized:
module.input_quantizer.enable()

# Every module's reduction before any weight gather, which an FSDP2 model's postprocess runs.
for module in model.modules():
if is_quantized_linear(module) and hasattr(module, "awq_clip"):
sync_across_dp(module)
Comment on lines +2207 to +2210

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] Worth a comment here on what this loop does not cover under expert parallelism, since the new examples/hf_ptq/README.md note reads as a blanket "awq_clip works on several GPUs".

Per this PR's own contract (tests/gpu/torch/utils/test_moe_a2a.py, and the _EXPERT_LAYOUTS["seeded"] layout), a routed expert carries ParallelState(data_parallel_group=-1, expert_model_parallel_group=...). For those modules sync_across_dp returns at the is_initialized() guard, so the num_tokens > 0 gate just below stays rank-local — which is right when edp == 1, because no peer rank holds that expert.

The consequence is that an expert no token reaches at all on its owning rank keeps num_tokens == 0, postprocess is skipped, and the module is left exactly as AWQClipHelper.__init__ left it: weight_quantizer.amax = the unclipped w_amax and, more importantly, weight_quantizer still disabled. That expert then exports unquantized, with no weight scale, while its siblings on the same rank have one — a layout the HF / TRT-LLM fused-MoE loaders are unlikely to accept.

This is pre-existing (the num_tokens > 0 gate is not new), and the new inputs.numel() == 0 guard at line 2086 is a clear improvement on what came before: without it the per-tensor path accumulated NaN into every candidate's loss, update_best_params then found no loss < inf, and the module ended with best_clip_val = 0, i.e. amax = 0. So nothing to fix in the reduction itself — just either note the EP gap in the docstring / README, or make the skip loud (warn_rank_0 naming the module) so it is not silent until export.

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 this out of this PR. The behavior is not specific to expert parallelism or to this change. In a single process, a module that no calibration token reaches also keeps num_tokens == 0, skips postprocess and keeps its weight quantizer disabled. Main's awq_clip does the same, and this PR changes neither that gate nor its outcome. Under data parallelism, the summed token count makes every replica reach the same outcome.

The README line is about --use_fsdp2, and at this point in the series hf_ptq runs no expert parallelism. A warn_rank_0 would also stay silent for an expert that only another rank holds. A warning, or a fallback like awq_lite's for uncalibrated experts, would change awq_clip's behavior, and belongs in a separate change.


for name, module in model.named_modules():
if is_quantized_linear(module) and hasattr(module, "awq_clip"):
if module.awq_clip.num_tokens > 0:
Expand Down
120 changes: 119 additions & 1 deletion modelopt/torch/quantization/model_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
get_kv_cache_auto_quantize_config,
)
from .kv_cache_auto_quant import _validate_search_inputs as _validate_kv_cache_search_inputs
from .mode import QuantizeModeRegistry, get_modelike_from_algo_cfg
from .mode import CalibrateModeRegistry, QuantizeModeRegistry, get_modelike_from_algo_cfg
from .nn import AnyQuantizer, QuantModule, SequentialQuantizer, TensorQuantizer
from .utils import is_quantized
from .utils.core_utils import _get_fsdp2_mesh
Expand All @@ -72,6 +72,113 @@
"temporarily_fold_weights",
]

# Calibration algorithms whose statistics stay on the rank that gathered them, listed by hand: a new
# one must be added. Any other keeps its own there too when its config turns distributed_sync off.
_RANK_LOCAL_CALIBRATION = (
"awq_lite",
"awq_full",
"svdquant",
"local_hessian", # its distributed_sync syncs only the max amax it starts from
"imatrix",
Comment thread
shengliangxu marked this conversation as resolved.
Resolved
)

Comment on lines +77 to +84

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] _RANK_LOCAL_CALIBRATION is a hand-maintained list of bare strings that nothing cross-checks against CalibrateModeRegistry, so the refusal can stop firing silently.

_rank_local_calibrations derives method from a registered mode name, then tests membership here. If any of these algorithms is ever renamed (or a mode name gains/loses a suffix), the membership test just stops matching: the refusal disappears and the ranks go back to calibrating their replicas of a weight differently — the exact silent-wrong-result this guard was added for, with no test failing unless someone also remembers to update this tuple. A name that was never right in the first place (a typo) has the same effect today.

Cheap to make it fail loudly instead, since every entry must name a registered calibrate mode:

_RANK_LOCAL_CALIBRATION = (
    "awq_lite",
    "awq_full",
    "svdquant",
    "local_hessian",  # its distributed_sync syncs only the max amax it starts from
    "imatrix",
)
assert all(f"{m}_calibrate" in CalibrateModeRegistry for m in _RANK_LOCAL_CALIBRATION), (
    f"{_RANK_LOCAL_CALIBRATION} must name registered calibrate modes."
)

That turns a rename into an import-time error rather than a quietly dropped refusal. (Worth a line in the comment above the tuple too: a new rank-local algorithm still has to be added here by hand — nothing can detect that automatically.)

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.

Taken in d10be56, as a unit test rather than an import-time assert. test_the_refused_calibrations_name_registered_modes checks every name in _RANK_LOCAL_CALIBRATION against CalibrateModeRegistry, so a rename or a misspelling fails CI instead of silently dropping the refusal. With "imatrix" misspelled, the test fails. The comment above the tuple now also says that a new rank-local algorithm has to be added to the list by hand.


def _rank_local_calibrations(algorithm: QuantizeAlgoCfgType) -> list[str]:
"""The rank-local calibrations ``algorithm`` runs, with those it runs for its scales."""
found = []
for mode, options in get_modelike_from_algo_cfg(algorithm):
method = mode.removesuffix("_calibrate")
# The options as the calibration reads them, through its mode's schema: a string such as
# "false" is coerced, and a default counts as if spelled out.
config = CalibrateModeRegistry[mode].config_class.model_validate(options)
if method in _RANK_LOCAL_CALIBRATION:
found.append(method)
elif not getattr(config, "distributed_sync", True): # None skips the sync too
found.append(f"{method} with distributed_sync={config.distributed_sync}")
for key in ("weight_scale_algorithm", "scale_algorithm"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

[P2] Schema validation fixes the boolean-string case, but changing the sync check to is False lets distributed_sync=None through. Both MaxCalibConfig and MseCalibConfig explicitly accept bool | None; wrapped_calib_func passes that None to max_calibrate, whose if not distributed_sync: branch returns before cross-rank amax synchronization. Thus {method: 'max', distributed_sync: None} now bypasses the safety guard and leaves replicas calibrated on different data with different scales. Reject None as well as False after validation, including nested scale algorithms, and add refusal/state-preservation cases for this accepted schema value.

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.

Right: the is False I added let None through. Fixed in 15cc5cc. Any falsy distributed_sync is now refused after validation (not config.distributed_sync), nested scale algorithms included, and the message names the value. The refusal and state-preservation table gains nvfp4_act_headroom with an MSE weight-scale algorithm whose distributed_sync is None; that case fails with is False.

if getattr(config, key, None) is not None:
found += _rank_local_calibrations(getattr(config, key))
return found


def _group_ranks(group: dist.DistributedProcessGroup) -> list[int]:
"""The ranks ``group`` spans, in its rank order; just this rank for a group not set."""
if not group.is_initialized():
return [dist.rank()]
return torch.distributed.get_process_group_ranks(group.group or torch.distributed.group.WORLD)


def _names_the_experts_groups(
state: "dist.ParallelState | None", plan: dist.DistributionPlan
) -> bool:
"""Whether ``state`` names the groups a routed expert of ``plan`` syncs its scales over.

Those of the ``(edp, ep)`` mesh the expert-parallel load lays the ranks out in: as its
expert-parallel group, this rank's row, whose ranks hold the experts' shards in row order, and
as its data-parallel group, its column, the ranks holding the same shard.
"""
if state is None or not state.expert_model_parallel_group.is_initialized():
return False
row, column = divmod(dist.rank(), plan.ep_size)
expert_parallel = range(row * plan.ep_size, (row + 1) * plan.ep_size)
data_parallel = range(column, plan.world_size, plan.ep_size)
return _group_ranks(state.expert_model_parallel_group) == list(expert_parallel) and sorted(
_group_ranks(state.data_parallel_group)
) == list(data_parallel)


def _refuse_unsupported_distributed_calibration(
model: nn.Module, algorithm: QuantizeAlgoCfgType
) -> None:
"""Refuse a calibration that would come out wrong on a model distributed over several ranks.

Each rank calibrates on its own data: an algorithm that keeps its statistics on the rank would
calibrate the ranks' copies or shards of a weight differently, and a module holding routed
experts must name the experts' expert-parallel group and, for data parallelism, only the ranks
holding the same experts, or calibration would merge different experts' scales. Called before
anything changes, so the caller seeds the experts' ``ParallelState`` before quantizing, as the
expert-parallel load does; their quantized modules are checked again before they calibrate.
"""
plan = dist.recorded_distribution_plan(model)
if plan is None or plan.world_size == 1:
return
refused = _rank_local_calibrations(algorithm)
if refused:
raise NotImplementedError(
f"Calibrating with {', '.join(refused)} is not supported on a model distributed over "
f"{plan.world_size} ranks. Each rank calibrates on its own data and this calibration "
"keeps what it gathers on the rank, so the ranks would calibrate their copies, or their "
"shards, of a weight differently. Calibrate with max or MSE with distributed_sync on, "
"GPTQ, SmoothQuant or awq_clip, or in a single process."
)
if algorithm is None or not plan.is_expert_parallel:
Comment on lines +151 to +154

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.

[IMPORTANT ModeState] The guard checks the experts' groups but never checks that every other quantized module actually has a data-parallel group to sync over — so the exact failure this function exists to prevent still slips through for TP-sharded dense layers.

Issue. The premise stated in the docstring ("Each rank calibrates on its own data, so an algorithm that keeps its statistics on the rank would calibrate the ranks' copies, or shards, of a weight differently") applies to any quantized module whose parallel_state.data_parallel_group is not initialized, not just to routed experts. max_calibrate's sync skips every group where group.is_initialized() is false (model_calib.py:302-306, _check_moe_calibration_complete does the same), and HFParallelLinear._setup sets exactly that:

# modelopt/torch/quantization/plugins/huggingface.py:507
self._parallel_state = ParallelState(data_parallel_group=-1, tensor_parallel_group=tp_group)

-1 was correct before this PR, because every rank of a TP run replayed the same calibration batches, so there was nothing to reduce. Once shard_dataloader / distributed_forward_loop give each rank its own batches, it is no longer correct — and because conversion overwrites _parallel_state, a caller that seeds a data-parallel group on those modules before mtq.quantize (as tests/gpu/torch/utils/test_moe_a2a.py does for module._parallel_state) has it silently replaced during apply_mode.

Failure scenario. HF model TP-sharded over 2 ranks, set_distribution_plan(model, DistributionPlan(2, ep_size=2)), per-rank batches from shard_dataloader, mtq.quantize(model, mtq.FP8_DEFAULT_CFG, distributed_forward_loop(...)), i.e. algorithm="max" with distributed_sync=True. _rank_local_calibrations("max") returns [], the experts carry their groups, so the guard passes. The routed experts then sync correctly, but self_attn.q_proj.input_quantizer.amax is the max over rank r's batches only on each rank — the two ranks hold different per-tensor activation scales for a GEMM whose input is replicated. Export writes one of them, and the activations the other rank saw are clipped. No error, no warning: a silently mis-scaled checkpoint, which is precisely what this refusal is for.

Suggested fix. Extend the check: under a recorded plan with world_size > 1, require every quantized module that is not a routed expert to have parallel_state.data_parallel_group.is_initialized() (or a tensor/expert group that covers the replication), and refuse with the same style of message otherwise. Alternatively, pass the plan's data-parallel group down so HFParallelLinear._setup keeps it instead of hard-coding -1 — but that belongs in whichever PR of the series wires the loader up; the refusal here should at least fail loudly rather than let max through.

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.

Not changing this. The scenario needs a tensor-parallel dense module in a model with a recorded plan. No plan describes such a module, and no load in the series builds one:

  • DistributionPlan.dense is "replicate" or "fsdp", and its docstring says every rank calibrates on its own data, with data parallelism over the whole world. There is no tensor-parallel dense layout to record.
  • Before loading, the series' expert-parallel load ([OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741) drops the tensor-parallel styles from the model's base_model_ep_plan and keeps only the router and expert entries ("DROP the TP styles (a2a builds no TP group for them -> would shard attention across the wrong mesh)"). No dense module becomes an HFParallelLinear there: the dense modules stay plain linears and keep the whole-world data-parallel group that the load seeds and conversion keeps. hf_ptq.py --use_fsdp2 shards with FSDP2, not tensor parallelism.
  • With a different batch on each rank, a tensor-parallel linear computes a wrong forward in the first place. Transformers' colwise and rowwise styles assume the same input on every rank of the group, so the rowwise all-reduce would sum partial outputs of different samples. Refusing max would not make that model right, and recording a plan on it misdescribes the model.

HFParallelLinear's data_parallel_group=-1 stays correct for the tensor-parallel runs it serves, where every rank replays the same batches.

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.

Evidence for the reply above, from a run of this PR's expert-parallel test job on two CPU ranks (transformers 5.18):

  • After mtq.quantize, the dense linears are QuantLinear, not HFParallelLinear.
  • They keep the whole-world data-parallel group seeded before quantizing, and have no tensor-parallel group.
  • Seeding them without a data-parallel group fails the test's amax comparison: o_proj.input_quantizer is off by 0.293.

The description also records the series' 4-GPU run at EP 2 (edp 2). It exported every scale bit-identical to EP 4, dense input scales included. The loads in this series do not build the layout this finding describes.

return
# Routed experts: the modules carrying an expert-parallel group, and the quantized modules under
# a container named ``experts``, as every expert-parallel plan in transformers names it.
holders = {}
for name, module in model.named_modules():
state = getattr(module, "_parallel_state", None)
if (state is not None and state.expert_model_parallel_group.is_initialized()) or (
isinstance(module, QuantModule) and "experts" in name.split(".")
):
holders[name] = state
if not holders and not any("experts" in name.split(".") for name, _ in model.named_modules()):
Comment on lines +156 to +165

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] This walks model.named_modules() twice — once to build holders, then again inside the any(...) on line 137 — and quantize() reaches this function twice per call (once directly, once via calibrate()), so a 600B-class MoE's module tree is traversed four times to answer a question one pass can answer. The experts membership is already computed in the first loop; hoisting it keeps the logic identical and drops two walks:

    holders, has_experts = {}, False
    for name, module in model.named_modules():
        under_experts = "experts" in name.split(".")
        has_experts |= under_experts
        state = getattr(module, "_parallel_state", None)
        if (state is not None and state.expert_model_parallel_group.is_initialized()) or (
            isinstance(module, QuantModule) and under_experts
        ):
            holders[name] = state
    if not holders and not has_experts:
        return  # no routed experts here, as in a VLM's vision tower: no experts' scales to merge

Also, if not holders or wrong on line 145: when holders is empty, wrong is necessarily empty too, so the not holders arm is the only one that can fire there — the two conditions are not independent, and the ternary below already branches on wrong. if wrong or not holders: reads the same but makes it obvious which message you get.

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: the second walk runs only when the first finds no holder, since not holders and any(...) stops at not holders otherwise. An expert-parallel model has holders at both calls (before conversion, the ParallelState the expert-parallel load seeds on its experts; after it, their quantized modules under experts), so a 600B MoE is walked once per call. The extra walk runs only on a model with no holder, where it tells a model without routed experts (a VLM's vision tower) from experts left without their group. The refusal's message is chosen by wrong, so the order of not holders or wrong changes nothing.

return # no routed experts here, as in a VLM's vision tower: no experts' scales to merge
Comment on lines +158 to +166

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] The "no routed experts here" escape hatch, and the refusal it guards, both key off the module names model.named_modules() yields — and those names are relative to whatever module the caller passed in, not to the top-level model. Two consequences worth a line of code or a comment:

  1. False refusal on a fully-disabled subtree. hf_ptq.py:156 runs mtq.quantize(module, disabled_quant_cfg, forward_loop=None) over each non-language-model child of a VLM, with "quant_cfg": [{"quantizer_name": "*", "enable": False}] and "algorithm": "max". If one of those children happens to contain an experts container (an MoE vision encoder, say) that the EP loader did not seed — it wouldn't, the plan only shards the LM's routed experts — then holders is empty pre-conversion while any("experts" in name.split(".")) is true, so not holders fires and the call raises. Nothing was going to calibrate there (every quantizer is disabled, forward_loop=None), so there are no scales to merge. test_a_module_without_routed_experts_calibrates_under_an_expert_parallel_plan pins only the no-experts shape, so this isn't covered either way.

  2. Silent escape on an experts subtree. Conversely, calibrate(model.layers[0].mlp.experts, "max") yields names "", "0.gate_proj", … — the segment experts never appears. With the experts unseeded, holders is empty and the name probe is false, so the guard returns and the per-expert amaxes get MAX-reduced over the world group: exactly the case the guard exists to catch. No in-tree caller does this today, which is why it's a suggestion rather than a finding, but it is the guard's load-bearing claim.

Both shapes come from the same root cause. The cheapest fix for (1) is to skip the whole EP block when no quantizer is enabled (there is nothing to sync); for (2), noting in the docstring that the check assumes it is handed the model that carries the routed experts, not a slice of it, would at least make the assumption explicit.

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.

Postponed: neither shape arises in the stack's flows. The VLMs given expert-parallel specs (Kimi-VL, Kimi-K2.5, Kimi-K3) keep their routed experts in the language model, so hf_ptq's all-disabled quantize of the other towers passes the name probe, and the EP loader in [9/21] seeds every routed expert's ParallelState (_seed_parallel_states), so experts handed in as a subtree land in holders rather than escape. Skipping the check when no quantizer is enabled, and stating the whole-model assumption in the docstring, are left for a follow-up.

Comment on lines +165 to +166

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] The "no routed experts here" escape hatch keys on the literal name segment experts, so a MoE whose expert container is named otherwise silently passes the guard instead of being refused. GraniteMoeSparseMoeBlock, for instance, holds its experts as block_sparse_moe.input_linear / output_linear — no experts segment anywhere — so under an ep_size > 1 plan with no ParallelState seeded, holders is empty, the any(...) is False, and max calibration proceeds merging every rank's per-expert amax over the whole world. That is precisely the silent mis-calibration the check exists to prevent, and it fails open rather than closed.

In practice #2741's loader is what sets both the plan and the groups, so the gap is narrow today, but the fallback inverts the safe default. Consider keying the escape on something structural instead — e.g. refuse when the plan is expert-parallel and no module carries an expert-parallel group, unless the caller opted out explicitly (the vision-tower case hf_ptq needs) — so an unrecognised MoE naming is refused rather than quietly calibrated wrong.

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.

Keeping the name-based escape. Every expert-parallel plan in transformers 5.18 (52 of them, granitemoe_swa's layers.*.block_sparse_moe.experts included) names the routed-expert container experts, and the expert-parallel loader seeds an expert-parallel group on each routed-expert module it shards, so holders is non-empty for any expert-parallel load. An opt-out flag would add API for a layout no supported path produces.

Comment on lines +156 to +166

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] The "experts" in name.split(".") fallback is deliberately conservative, which is the right default, but it is worth noting two edges in the comment so the next reader does not have to re-derive them:

  • Any QuantModule that merely sits under a container segment named experts must now name the experts' groups — including things that are not routed experts at all, e.g. a router/gate or a per-expert bias projection a model places inside its experts container. Those get refused with a message about "routed expert's ParallelState", which will read as a bug to the user. A shared expert is safe only because shared_experts != experts under split("."), which is a fairly thin thing to rely on.
  • On the pre-conversion call (from quantize, line 584) the isinstance(module, QuantModule) half can never match, so that pass depends entirely on the loader having seeded _parallel_state. That is what the docstring says, and it is fine — but it means the only producer today is a later PR in the series ([OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741), so this path is unexercised outside the new tests until that lands. Worth stating in the PR description under Testing.

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.

  1. The series' expert-parallel load seeds every module under a routed-experts container with the experts' ParallelState. In [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741, _seed_parallel_states gives expert_state to the container and everything under it. A gate or bias projection that a model places inside experts therefore carries the experts' groups and passes. This check should refuse a module there that lacks them. A shared expert, named shared_experts or shared_expert in transformers' MoE models, is not under that segment, and the load seeds it with the dense state.
  2. Added to the Testing section: [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741's load is the first to seed these groups. Until it lands, the unit tests and the expert-parallel GPU test, which seed them by hand, exercise the check that runs before conversion.

wrong = [name for name, state in holders.items() if not _names_the_experts_groups(state, plan)]
Comment on lines +158 to +167

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] Under an expert-parallel plan with edp > 1 the guard validates the experts' groups but leaves the dense modules' data-parallel group unchecked, where getting it wrong is just as silent.

holders collects only modules that carry an initialized expert_model_parallel_group or are a QuantModule under an experts container. Every other module — q_proj, o_proj, the router, the shared expert — is replicated on all plan.world_size ranks, so its amax has to sync over the whole world. Nothing here says so. A loader that seeds those modules with the expert column group (the plan.edp ranks of _names_the_experts_groups's data_parallel range) instead of the world passes this guard, and then max_calibrate's DP sync gives the two rows of the mesh different dense amaxes, with nothing reported — the same class of bug the expert half of this function exists to refuse.

That this is a live foot-gun rather than a hypothetical is recorded in the PR description itself: the tenth-round note says the new GPU test's dense linears need the seeded data-parallel group and that "seeding them without that group fails the test: o_proj's input amax is off by 0.293 (relative)". The test found it by comparing against a single-process reference; the guard would name it.

Cheap to close in the same pass over named_modules() — collect the complement and require the full world of it:

    holders, dense = {}, []
    for name, module in model.named_modules():
        state = getattr(module, "_parallel_state", None)
        if (state is not None and state.expert_model_parallel_group.is_initialized()) or (
            isinstance(module, QuantModule) and "experts" in name.split(".")
        ):
            holders[name] = state
        elif isinstance(module, QuantModule) and state is not None:
            dense.append((name, state))
    ...
    # A module replicated on every rank syncs its scales over all of them, not over an expert column.
    misplaced = [
        name
        for name, state in dense
        if state.data_parallel_group.is_initialized()
        and len(_group_ranks(state.data_parallel_group)) != plan.world_size
    ]
    if misplaced:
        raise NotImplementedError(
            f"Calibrating a model whose experts are sharded over {plan.ep_size} ranks needs every "
            f"module outside the routed experts to name all {plan.world_size} ranks as its "
            "data-parallel group, since every rank holds a copy of it, or calibration would give "
            f"the mesh's rows different scales. These modules do not: {misplaced[:3]}."
        )

Non-blocking: no in-tree loader seeds an expert-parallel plan on this base yet (#2741 is the first), so only the hand-seeded tests reach it today — which is also why it is cheaper to pin now than to find later.

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.

This check is tracked as a follow-up, because it does not fit this PR's size budget. The series' load already seeds the layout that the check would require:

The tenth-round note records a mutation I made by hand to show that the test catches a missing dense sync. No loader produces that layout.

if not holders or wrong:
raise NotImplementedError(
f"Calibrating a model whose experts are sharded over {plan.ep_size} ranks needs each "
"routed expert's ParallelState to name, as its expert-parallel group, this rank's row "
f"of the ({plan.edp}, {plan.ep_size}) expert-parallel mesh and, as its data-parallel "
f"group, its column, the {plan.edp} rank(s) holding the same experts, or calibration "
"would sync their scales over the wrong ranks. "
+ (
Comment on lines +154 to +175

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] The expert-group branch is sound when it runs from calibrate() on a converted model, but from quantize() it runs before apply_mode, where no module can carry a _parallel_state yet unless the caller seeded it by hand. On an unconverted EP model holders is empty, any("experts" in name.split(".")) is true, and if not holders or wrong raises "None of the model's modules carries an expert-parallel group."

That is deliberate and tested (test_expert_parallel_calibration_needs_the_experts_expert_parallel_group[without-the-group]), but it bakes in a contract that is the opposite of how every in-tree plugin seeds parallel state: HFParallelLinear._setup (plugins/huggingface.py:507) and the Megatron MoE wrappers (plugins/megatron.py:801-802, :1033-1034) all assign _parallel_state during DynamicModule conversion. A future HF expert-parallel plugin that follows that pattern — seeding the experts' EP/DP groups in _setup, which is the natural place for it — would be refused by quantize() even though conversion would have produced exactly the layout this function wants. Megatron escapes only because it records no DistributionPlan.

Since the post-conversion check in calibrate() is strictly more accurate (the on-the-router-only case already relies on it) and quantize() reaches it on every path, the pre-conversion pass costs little if the EP half is skipped there:

def _refuse_unsupported_distributed_calibration(model, algorithm, check_experts=True):
    ...
    if algorithm is None or not plan.is_expert_parallel or not check_experts:
        return

with quantize() passing check_experts=is_quantized(model). The config-only _rank_local_calibrations refusal, which is the part that genuinely benefits from running before anything changes, keeps its early placement.

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.

Keeping the check before conversion. quantize() runs it before apply_mode so that a refused model is left unconverted. If only calibrate() checked, the refusal would come after quantizers were inserted, and the model would be left converted but uncalibrated.

On this series' path, the early check reads the real groups:

No in-tree Hugging Face plugin seeds routed experts in _setup. Megatron's MoE wrappers do, but Megatron records no plan, so the check returns before it reaches them. A plugin that seeded routed experts in _setup would need this check moved, and its own PR would be the place to move it.

f"These modules do not: {wrong[:3]}."
if wrong
else "None of the model's modules carries an expert-parallel group."
)
)


def _refuse_awq_on_a_sharded_fsdp2_model(
model: nn.Module, algorithm: QuantizeAlgoCfgType, candidate: str | None = None
Expand Down Expand Up @@ -128,6 +235,7 @@ def calibrate(
Returns: The calibrated pytorch model.
"""
_refuse_awq_on_a_sharded_fsdp2_model(model, algorithm)
_refuse_unsupported_distributed_calibration(model, algorithm)
if forward_loop is not None:
# get the number of arguments of forward_loop
num_args = len(inspect.signature(forward_loop).parameters)
Expand Down Expand Up @@ -502,6 +610,7 @@ def forward_loop(model) -> None:
"""
quantize_config = QuantizeConfig(**dict(config))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

[P2] The allowed EP flow still loses its transport during the conversion immediately below. apply_mode → convert_to_quantized_model → replace_quant_module runs the registered force_eager_experts_impl_on_the_fly callback in plugins/huggingface.py. For real HF fused experts, that callback unconditionally changes the root and expert configs' _experts_implementation from torch_a2a to eager. distributed_forward_loop reinstalls TP wrappers but never restores this backend selection, so the intended torch_a2a_experts_forward transport is no longer selected. Preserve the EP implementation through conversion, or make this slice depend on the enabling change before merging. Cover it with actual mtq.quantize on real HF experts; the fake _Experts fixture has no implementation config and cannot expose this.

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.

The enabling change is #3074, and it is already underneath every PR that can produce an expert-parallel model.

This PR's loop and guard take whatever implementation the load selected and set none, so its own code and tests do not need #3074. Re-basing it on #3074 would put #3074's diff in this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

The reply specifies #3074-first merge order, but the enabling fix is not present at this head. apply_mode reaches conversion.replace_quant_module and the registered huggingface.force_eager_experts_impl_on_the_fly callback, which still unconditionally changes _experts_implementation to eager. Replanting TP wrappers does not restore backend dispatch, so a real sharded HF expert no longer selects torch_a2a_experts_forward. Please land #3074 and rebase before merging this PR, or include the preservation here. The later loader's dependency graph does not make the documented public EP calibration flow work at this revision.

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.

This PR now sits on #3074 (shengliangx/hfep-8a-grouped-mm-calibration), which keeps torch_a2a through conversion: its normalize_experts_impl_on_the_fly replaces the forced eager. It merges after #3074, and it retargets to main once #3074 merges. The new GPU test below fails with the conversion forcing eager, and passes on #3074.

_refuse_awq_on_a_sharded_fsdp2_model(model, config.get("algorithm"))
_refuse_unsupported_distributed_calibration(model, config.get("algorithm"))
if not is_quantized(model):
model = apply_mode(model, mode=[("quantize", dict(config))], registry=QuantizeModeRegistry)
else:
Expand Down Expand Up @@ -940,6 +1049,14 @@ def forward_backward_step(model, batch) -> None:
if isinstance(quantization_formats, str) or not isinstance(quantization_formats, Sequence):
raise TypeError("`quantization_formats` must be a sequence of formats.")
quantization_formats = list(quantization_formats)
plan = dist.recorded_distribution_plan(model)
if plan is not None and plan.is_expert_parallel: # before anything converts the model
raise NotImplementedError(
f"auto_quantize does not support a model whose experts are sharded over {plan.ep_size} "
"ranks yet: its forwards would run the experts without the expert-parallel forwards "
"that quantization replaces and distributed_forward_loop restores. Quantize it with "
"mtq.quantize through distributed_forward_loop, or AutoQuantize in a single process."
)

method_name, method_config = _parse_auto_quantize_method(method)
is_kv_search = constraints is not None and constraints.get("cost_model") == COST_MODEL_KV_CACHE
Expand Down Expand Up @@ -1061,6 +1178,7 @@ def forward_backward_step(model, batch) -> None:
)
# Every candidate, before the search calibrates any: calibrate() would refuse mid-search.
_refuse_awq_on_a_sharded_fsdp2_model(model, algo, candidate=name)
_refuse_unsupported_distributed_calibration(model, algo)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

[P2] The acknowledged EP AutoQuantize issue remains open despite being tracked as a follow-up. A correctly seeded EP model with an FP8/max candidate passes this check, then auto_quantize converts it. DynamicModule.convert replaces the load-time wrapper, while _AutoQuantizeBaseSearcher.before_search builds its own loop through _run_func(forward_step) and never calls _reinstall_a2a_forwards. The first fresh-model calibration forward therefore raises the missing EP process_group error, after the model has been converted. The future #2754 CLI refusal does not protect this public library entry point.

Could wrapper preservation be handled at conversion using the existing _forward_pre_dm mechanism? If that remains a separate change, please add an EP-specific refusal before conversion here and a regression asserting that module types and state remain unchanged. Documenting the downstream crash does not provide the early refusal this PR establishes elsewhere.

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.

Taken in fbbe227. auto_quantize now refuses a model whose recorded plan shards its experts. The check runs first: before the KV-cache branch and before anything converts the model. The message reads "auto_quantize does not support a model whose experts are sharded over N ranks yet: ...".

Preserving the wrapper at conversion would change how DynamicModule conversion keeps transformers' instance forwards, which reaches beyond this PR. The refusal can be lifted once that lands.

The new two-rank test, test_auto_quantize_refuses_an_expert_parallel_model_before_changing_it:

  • seeds the experts' groups so that the calibration guard passes,
  • offers an INT8 (max) candidate,
  • checks the refusal, and that every module's type and the state dict are unchanged.

It fails without the refusal.


# Select the appropriate searcher based on method
if method_name not in AUTO_QUANTIZE_SEARCHERS:
Expand Down
Loading
Loading