Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
8806f91
Calibrate a distributed model on per-rank data
shengliangxu Oct 10, 2026
49f76a0
Test the distributed calibration loop
shengliangxu Oct 11, 2026
95bd7a7
Split a streaming shard's groups across DataLoader workers
shengliangxu Oct 11, 2026
47402e8
Agree on the calibration batch size across ranks
shengliangxu Oct 13, 2026
34fac65
Agree on the calibration batch size without keeping it on the session
shengliangxu Oct 14, 2026
0a2636e
Reduce the calibration batch size through the renamed all_reduce
shengliangxu Oct 14, 2026
c9e5235
Calibrate distributed without the session: pass the device and EP mes…
shengliangxu Oct 14, 2026
08bc819
Size the calibration batch and check the EP mesh by the model's distr…
shengliangxu Oct 14, 2026
b6a145f
State that a streaming calibration dataset must give every worker its…
shengliangxu Oct 15, 2026
ebfb281
Free the CUDA cache around calibration only in a process that started…
shengliangxu Oct 16, 2026
f991900
Test a streaming shard's split across DataLoader workers in one process
shengliangxu Oct 16, 2026
a360849
Say why shard_dataloader strides streams with its own shard class
shengliangxu Oct 16, 2026
0849f61
Agree on the calibration batch size over two real gloo ranks in its test
shengliangxu Oct 16, 2026
666d032
Trim the distributed calibration tests: one per behavior
shengliangxu Oct 16, 2026
90543d1
Refuse a calibration stream shorter than the world size
shengliangxu Oct 16, 2026
66a65b0
Keep the calibration loader's drop_last in its per-rank shard
shengliangxu Oct 16, 2026
a34bfde
Refuse rank-local calibration on a model distributed over several ranks
shengliangxu Oct 23, 2026
83c6937
Refuse rank-local calibration that another algorithm runs for its scales
shengliangxu Oct 23, 2026
f77b7e8
Refuse max and MSE told not to sync, on a model distributed over seve…
shengliangxu Oct 23, 2026
58308c8
Import the quantization package at the top of the distributed calibra…
shengliangxu Oct 23, 2026
fb6471e
Record the plan hf_ptq's FSDP2 load distributes the model by
shengliangxu Oct 23, 2026
beb2933
Destroy the group the batch-size test's pooled job creates
shengliangxu Oct 24, 2026
7a63bf4
Refuse a loader that picks its own samples, and calibrate without gra…
shengliangxu Oct 24, 2026
bdbc3fe
Carry a plain BatchSampler's batching into each rank's calibration shard
shengliangxu Oct 24, 2026
a1a54d9
Shard a loader with auto-batching off instead of refusing it
shengliangxu Oct 24, 2026
ae57933
Refuse expert-parallel calibration that would merge experts' scales
shengliangxu Oct 25, 2026
e2137fc
Check each routed expert's groups and re-plant a2a forwards when rebound
shengliangxu Oct 25, 2026
aa060df
Keep a lone rank's calibration loader as is; document FSDP2 refusals
shengliangxu Oct 25, 2026
ce49b0e
Let modules without routed experts calibrate under an EP plan
shengliangxu Oct 25, 2026
49d238d
Re-plant only the a2a forward wrappers quantizer insertion dropped
shengliangxu Oct 27, 2026
b71bc66
Measure a distributed batch size on the GPU the rank computes on
shengliangxu Oct 27, 2026
36f54d7
Refuse a streaming calibration loader that has workers
shengliangxu Oct 27, 2026
2174d52
Drop the worker clauses a refused streaming loader makes moot
shengliangxu Oct 27, 2026
9992833
Refuse a random sampler that draws its own number of samples
shengliangxu Oct 27, 2026
f9aae8c
Name the calibration defaults the rank-local refusal relies on
shengliangxu Oct 27, 2026
8d7daa5
Size the two-rank batch test's group with bounded_collectives
shengliangxu Oct 29, 2026
74ddb3d
Import the batch sizing's distributed helpers at module top
shengliangxu Oct 29, 2026
d4bc40b
Read the calibration options through each mode's schema before refusing
shengliangxu Oct 29, 2026
01731d7
Check the experts' groups by the ranks they span, not only the data-p…
shengliangxu Oct 29, 2026
e1304e0
Average awq_clip's per-tensor clip losses over the ranks, and allow i…
shengliangxu Oct 29, 2026
1f6b1e2
Refuse a calibration whose distributed_sync is None, as when it is False
shengliangxu Oct 29, 2026
cea62b0
Check the experts' groups against the expert-parallel mesh's rows and…
shengliangxu Oct 29, 2026
b1766f1
Give AWQ-Clip's data-parallel clip its own changelog entry, under Bug…
shengliangxu Oct 29, 2026
4861acb
Average awq_clip's clip search over the ranks once per module, after …
shengliangxu Oct 29, 2026
d87f5af
Accept the experts' groups of an (edp, ep) mesh with either axis inne…
shengliangxu Oct 29, 2026
7a6a2fa
Skip awq_clip's clip search for an input with no tokens
shengliangxu Oct 29, 2026
cdc0dab
Leave hf_ptq's FSDP2 load recording its plan to the PR whose export r…
shengliangxu Oct 29, 2026
6eeb2fb
Size an FSDP2 model's calibration batch by its plan, then calibrate i…
shengliangxu Oct 29, 2026
2c6f0af
Calibrate a real expert-parallel model through the forward loop, on GPUs
shengliangxu Oct 29, 2026
3b940cb
Check the experts' groups against the ep-innermost mesh only
shengliangxu Oct 29, 2026
367609d
Reduce awq_clip's clip search in one all-reduce per module, before an…
shengliangxu Oct 29, 2026
6245fa7
Reduce awq_clip's clip-search losses in place, with no copy of them
shengliangxu Oct 29, 2026
afefa2a
Name distributed_sync only for the calibrations that have it
shengliangxu Oct 29, 2026
359a064
Say that an expert-parallel caller seeds the experts' groups before q…
shengliangxu Oct 29, 2026
f5d5468
Say that every rank still decodes every sample of a sharded stream
shengliangxu Oct 29, 2026
8e811ae
Say that nvfp4_awq_clip still works where multi-rank FSDP2 refuses AWQ
shengliangxu Oct 30, 2026
1df1095
Refuse auto_quantize on an expert-parallel model before converting it
shengliangxu Oct 30, 2026
14bb439
Refuse a sampler over another source than the loader's dataset
shengliangxu Oct 30, 2026
ad6e140
Give mypy a sized default for the sampler's source
shengliangxu Oct 30, 2026
dea9b79
Re-plant only a forward that quantizer insertion rebound
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
123 changes: 122 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,116 @@
"temporarily_fold_weights",
]

# Calibration algorithms whose statistics stay on the rank that gathered them. 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 +75 to +83

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 Compatibility] The refusal itself is well-founded — awq_lite averages only act_scale across the DP group (model_calib.py:1855-1859); its alpha search loss is accumulated rank-locally (model_calib.py:1762) and never reduced, so replicas really can pick different best_alpha / pre_quant_scale. But turning that into a hard NotImplementedError is a backward-breaking change for a documented path, and the PR does not say so anywhere user-facing:

  • These five methods back INT4_AWQ_CFG, W4A8_AWQ_BETA_CFG, the nvfp4_awq-family recipes and SVDQUANT*_CFG. Any of them on a model carrying a multi-rank DistributionPlan now raises instead of running.
  • CHANGELOG.rst gains only the awq_clip entry. Per CLAUDE.md, backward-breaking changes get an entry; "int4_awq / w4a8_awq / nvfp4_awq / svdquant are now refused on a model distributed over several ranks — calibrate in a single process" is exactly that.
  • examples/hf_ptq/README.md gains a note that nvfp4_awq_clip works with --use_fsdp2, which reads as if the neighbouring AWQ recipes do too. The note should say which ones are refused.

This does not fire for users today — set_distribution_plan has no caller outside tests/ on this base, so recorded_distribution_plan is None on the --use_fsdp2 path and the function returns early. It becomes user-visible the moment a later PR in the series records the plan, which is the point of the series, so the docs are better written now than discovered then.

Two smaller things in the same block:

  1. The error text (line 152) advises "Calibrate with max, MSE, GPTQ, SmoothQuant or awq_clip, with distributed_sync on". That qualifier only applies to max/MSE — AWQClipCalibConfig, SmoothQuantCalibConfig and the GPTQ config have no distributed_sync field at all, so the advice sends users looking for a knob that does not exist on four of the five suggestions.
  2. local_hessian is listed here and has distributed_sync: bool | None = True (config.py:1077), so it is refused even when a user sets that flag explicitly. That is correct (the field only forwards to max_calibrate for the input amax at model_calib.py:1307; the Hessian-derived weight scales stay rank-local), but the combination of a distributed_sync=True default and an unconditional refusal is confusing enough to deserve a half-line comment here saying why its flag does not help.

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.

Both smaller points are taken in b517c75:

  • The advice now reads "Calibrate with max or MSE with distributed_sync on, GPTQ, SmoothQuant or awq_clip, or in a single process".
  • local_hessian's entry now says its distributed_sync syncs only the max amax it starts from.

The changelog and README entries go with the PR that makes the refusal reachable. As you note, nothing outside tests/ records a plan on this base, so no user meets the refusal through this PR. #2752 records a plan for hf_ptq.py --use_fsdp2, and it documents the refusal:

  • Its Backward Breaking Changes entry lists AWQ-Lite, AWQ-Full, SVDQuant, local Hessian and imatrix, also as an AutoQuantize candidate, and says to run them without --use_fsdp2.
  • Its README note, just above the nvfp4_awq_clip line, names the refused --qformats: int4_awq, w4a8_awq_beta, nvfp4_awq_lite, nvfp4_awq_full, nvfp4_svdquant, nvfp4_w4a4_weight_local_hessian and the iq* formats.

An entry here would announce a refusal that no user can reach until #2752.



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 of such a model 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. Under expert
parallelism each rank's per-expert quantizers hold its own experts, and calibration syncs them
over the groups their module's ``ParallelState`` names, every rank for a module converted
without one: a module holding routed experts must name the experts' expert-parallel group and,
for data parallelism, only the ranks holding the same experts, or any calibration would merge
different experts' scales. Called before anything changes, so the caller seeds the routed
experts' ``ParallelState`` before quantizing, as the expert-parallel load does; their quantized
modules, whose state calibration reads, are checked again once converted, 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 +154 to +157

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 +159 to +168

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 +161 to +169

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 +168 to +169

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 +159 to +169

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 +161 to +170

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 +157 to +178

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 +238,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 +613,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 +1052,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 +1181,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