Repository navigation
[OMNIML-6060] Load an MoE expert-parallel: shard the experts at load time - #2741
Open
shengliangxu wants to merge 47 commits into
Open
shengliangxu wants to merge 47 commits into
shengliangxu wants to merge 47 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Contributor
shengliangxu
force-pushed
the
shengliangx/hfep-5-distributed-calibration
branch
from
October 12, 2026 02:24
7f91f94 to
963e78a
Compare
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 12, 2026 02:24
758dca6 to
5c154cc
Compare
Contributor
|
Collaborator
Author
|
/ok to test 5c154cc |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## shengliangx/hfep-5-distributed-calibration #2741 +/- ##
==============================================================================
+ Coverage 61.69% 61.75% +0.05%
==============================================================================
Files 649 649
Lines 72627 72795 +168
==============================================================================
+ Hits 44808 44953 +145
- Misses 27819 27842 +23
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 12, 2026 08:39
5c154cc to
e1ef1d1
Compare
Collaborator
Author
|
/ok to test e1ef1d1 |
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 13, 2026 00:53
e1ef1d1 to
fbdcd0c
Compare
shengliangxu
force-pushed
the
shengliangx/hfep-5-distributed-calibration
branch
2 times, most recently
from
October 13, 2026 01:32
c3ccb86 to
96159d7
Compare
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 13, 2026 01:32
fbdcd0c to
288db20
Compare
Collaborator
Author
|
/ok to test 288db20 |
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 13, 2026 19:38
288db20 to
ac84840
Compare
shengliangxu
force-pushed
the
shengliangx/hfep-5-distributed-calibration
branch
from
October 13, 2026 19:38
96159d7 to
540716e
Compare
Collaborator
Author
|
/ok to test ac84840 |
shengliangxu
force-pushed
the
shengliangx/hfep-6a-ep-loader
branch
from
October 14, 2026 00:14
ac84840 to
95781ac
Compare
shengliangxu
force-pushed
the
shengliangx/hfep-5-distributed-calibration
branch
from
October 14, 2026 01:47
177c692 to
6e21d0a
Compare
model_load_utils.py had no __all__, though CONTRIBUTING.md asks every module to declare its public surface. Declare it now that the module carries the distribution-aware loaders: the four public functions it has on main, plus load_pretrained, resolve_distribution_plan, fsdp2_wrap and load_and_prepare_ep. The package's __init__ keeps not re-exporting it, as on main, so importing modelopt.torch.utils.plugins does not import transformers. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep loaded a Hub ID through Transformers, but recorded the ID itself as the checkpoint for the export to read the unplaced tensors back from. Read as a local directory it held nothing, so an export dropped tensors such as an MTP head without a word. The load now resolves the checkpoint to its local snapshot first, as the FSDP2 load does, and loads and records that. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep made a Hub ID local right after its process-group checks, before its config and the refusals that read it. A refused load could download a whole checkpoint first, and a test driving a refusal without a process group failed in torch.distributed.get_rank(). The config read takes a Hub ID as it is and fetches only the config. The checkpoint is now resolved after the config and its refusals, before the weights load. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The EP load unregisters the experts class's own quant wrapper so the grouped experts stay a2a-managed. For GPT-OSS that wrapper is what quantizes its experts: the grouped quantization leaves a transposed (num_experts, in_dim, out_dim) layout unquantized, with only a warning, so the model came out quantized everywhere but its experts. The load now refuses such experts before it unregisters anything. From the model specs on, GPT-OSS's spec refuses expert parallelism when the plan is decided; this check holds for a plan or config handed in too. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep dequantizes MXFP4 checkpoints as it loads. A checkpoint in any other quantized format (GPTQ, ModelOpt NVFP4, compressed-tensors without a spec that decompresses it) fell through to transformers' own quantizer, which loads the tensors as stored, for PTQ to treat as plain weights. Such a checkpoint is now refused once the config is read, before anything else is loaded. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ring it The expert-parallel load removed the routed experts' class from QuantModuleRegistry, which every model in the process shares, so a later model of that class quantized without expert parallelism lost its wrapper. Refuse those experts instead, as the transposed ones are: of the HF experts ModelOpt wraps itself, GPT-OSS's are transposed and Llama4's are refused here. The defensive except, which turned a failed unregister into a warning and a crash in the first forward, goes with it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…parallel load The refusal of experts with a quant wrapper of their own also caught the generic fused-experts wrapper, which is what expert-parallel PTQ quantizes through. mtq.quantize registers it, for the whole process, for every fused-experts class it meets, so a second expert-parallel load of the same class in one process was refused: the next parametrization of a multi-GPU test, or a script that quantizes two models. Only wrappers that are not _QuantFusedExperts refuse now, such as Llama4's. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ig's dtype load_and_prepare_ep now reads the plan before it resolves the checkpoint or builds a mesh, and refuses one that styles no routed-experts container: the load would leave the experts whole on every rank and seed every module with the dense state. It loads in the config's dtype, the field the rest of the module reads, not the deprecated torch_dtype alias, and chooses the class that builds the model with the caller's trust_remote_code. _ckpt_quant_method also reads a VLM's llm_config, so the format refusal sees a quantized language model nested there. The mxfp4 branch reuses the method it already read, and the experts-implementation loop visits the root once. The GPU load test checks the mesh calibration reads back from the model. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep checked that base_model_ep_plan styles some routed experts, then found the experts by matching the containers those keys name against the loaded model's module names, a different rule. A plan that styles its grouped-GEMM parameters a level below the container (layers.*.mlp.experts.*.gate_up_proj) passes the first and matches nothing: every module, the sharded experts included, was seeded with the dense state, so the experts' amax would reduce over ranks holding different experts. The load now refuses a plan that matches no module. _refuse_unquantizable_experts also refuses a fused-experts wrapper that quantizes on the eager forward only (_eager_only, FP8's): the all-to-all transport runs the grouped GEMM, where such a wrapper applies no block scales. expert_parallel_mesh names the cause when an expert-parallel model carries no device mesh. The mesh builder load_and_prepare_ep swaps in takes any arguments, so a release that passes more does not break the load. _auto_class's docstring says require_lm_head applies to a VLM only and keeps the headless AutoModel fallback; the mxfp4 format comment says it is for experts in the standard layout; the loader's docstring and local name call the expert-data-parallel axis edp. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep picked DistributedConfig's arguments with a ladder that passed tp_plan whenever transformers builds its own mesh, as if that builder implied a tp_plan field. Each argument is now decided by its own field: tp_plan goes on DistributedConfig where it has the field and to from_pretrained otherwise, never both, and tp_size is the EP degree where transformers builds its own mesh. Every release from 5.6 to 5.19 gets the same arguments as before (the builder only exists from 5.18, where both fields do); a test runs the load against each field/builder combination. The comment's version claims are corrected against the 5.6-5.19 wheels: from_pretrained refuses tp_plan beside distributed_config from 5.17, and DistributedConfig has no tp_plan field before 5.10, nor in 5.11 and 5.12. The eager-only-experts rule, which the loader's refusal and quantization's own torch_a2a check both apply, now lives in one helper in the quantization HF plugin, so the two cannot drift. The loader's comment and docstring say where its class choice leaves the decision's: with require_lm_head=False a VLM builds without its head. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep checked its plan against the run (the world size) but not against the model. The torch_a2a transport it installs finds an expert's owner rank as global_id // num_local_experts, which is a valid mapping only when every rank holds the same number of experts. A plan handed in directly, which skips the decision's own divisibility check, could ask for an uneven split. The loader now reads the expert count as the decision does (_num_experts_from_config) and refuses a plan whose ep_size does not divide it, before the checkpoint is resolved or any mesh is built. A unit test hands in 3 experts over 2 ranks and expects the refusal before anything loads. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The expert-parallel load refused experts stored transposed only when they set is_transposed, while register_fused_experts_on_the_fly also leaves a gated fused-experts container unquantized when its shapes show that layout, flag or not. Both now apply one rule, _transposed_experts, beside _eager_only_experts in the quantization HF plugin, so the load refuses every expert container quantization would leave unquantized, and the two checks cannot drift apart. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
load_and_prepare_ep refused an ep_size that does not divide the routed experts only when the config names their count one of the four ways _num_experts_from_config reads it. Otherwise the count read as 0, which every ep_size divides, and the load went ahead: transformers shards the experts by their weights' dim 0, whatever the config calls the count, and 5.16.0 splits a count ep_size does not divide without an error, so the all-to-all transport would send tokens to the wrong rank. When the config names no count, the load now reads it off the loaded experts' weights, which transformers keeps whole on the DTensors it shards them into from 5.16, and refuses one ep_size does not divide, or none shown (before 5.16 the shards are plain tensors), as the distribution plan's decision refuses a count it cannot read. A count the config names is still checked before anything loads. Calibration tells routed experts by an "experts" in their names (model_calib._is_routed_expert) and reduces any other module's weight amax over the expert-parallel group, which for a container named otherwise would merge the scales of different experts. The load now refuses such a container before it seeds any parallel state; the routed-expert containers of every transformers 5.18 and 5.19 EP plan are named so. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
transformers 5.6 checks the experts implementation a model is built with against the four it ships (eager, grouped_mm, batched_mm, deepgemm); from 5.7 it checks the registry register_torch_a2a fills. So on 5.6.x every expert-parallel load got through the floor and then failed in from_pretrained with 'experts_implementation="torch_a2a" is not supported'. The load now refuses 5.6 before it sets anything up. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…l's config The load swaps the config's base_model_ep_plan to the torch_a2a styles to shard by them, and the model kept that config, so the swapped plan was the one an export would write into config.json, naming styles nothing without ModelOpt can resolve. The model keeps the plan it was sharded by on itself (model.tp_plan, copied when it was built), so the config it keeps now goes back to the checkpoint's own plan once the load is done. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A plan the config class carries as a default (transformers' own, e.g. Qwen3-MoE's) takes the restore's pop branch, which the test did not reach. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
transformers 5.18 builds a mesh of its own for the load, which _experts_sharded_over swaps for the expert-parallel one. Were that to stop working, the experts would be sharded over another mesh without an error: the all-to-all would route tokens to ranks without their experts, and calibration would merge the scales of different experts. The DTensors the experts load into show both the whole count and this rank's share, so the load checks the share, and every rank refuses alike. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The loader puts the checkpoint's plan back on the config and leaves the torch_a2a experts implementation set. Calibration re-installs the all-to-all forwards by model.tp_plan, which must keep the styles the model was built with, and an export writes the config, which must not carry torch_a2a. A tiny Qwen3-MoE, built through the loader, holds transformers to both. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The share check read only DTensors, so it never ran on transformers below 5.16, where the experts load as plain shards, nor for a plan whose expert weights no style shards, which leaves them whole on every rank. Both would compute tokens against other experts' weights without an error. A plain weight's share is now checked against the count the config names. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
expert_parallel_mesh reads the model's distribution plan, which this loader leaves to its caller to record. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The share check passed when it found nothing to check: a matched container that owns no weights itself (each expert's in a child module) shows no share, and an unchecked share passed as a correct one. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Calibration re-installs the all-to-all forwards over the mesh the model carries as _device_mesh. transformers sets it from the mesh the load hands it, through device_mesh on older releases and its mesh builder on newer ones; the loader now sets it too, so the model carries it on every release. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The loader sets model._device_mesh itself, because which mesh transformers keeps differs by release; the test's comment still credited transformers. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Its architecture reads as a VLM's, so the headless return comes before the seq2seq lookup; the docstring now says so, and a table row pins it. No caller reaches it: no transformers 5.18 encoder-decoder config carries an expert-parallel plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Its unit tests stand in a two-rank process group and a transformers release by patching modelopt.torch.utils.distributed and modelopt.torch.utils.moe, which reach the loader only because it looks the names up when it runs. CONTRIBUTING asks for the reason on every in-function import. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
print_rank_0 is the rank-0 print with flush this module already imports; the four notes printed it by hand, behind is_master(). Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…urned set_distribution_plan records the plan on every submodule, while load_and_prepare_ep keeps the mesh on the model it returns, so a submodule of an expert-parallel model, such as a VLM's language model, is refused for want of a mesh. The refusal told its caller to load with load_and_prepare_ep, which it already had; it now says to pass the model the loader returned. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The comment said a container showing no weights is refused; the check refuses a model none of whose containers shows its share, which relies on a model's routed experts sharing one layout, as every transformers MoE's do. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Collaborator
Author
|
/ok to test 0c5af29 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Re-cut again on 2026-10-29, so more of the series merges in parallel: #2780 and #2747 now build on this PR directly instead of on #2743, and #2743 stays on #2742. This PR's base branch was rebuilt from the current #2779 (now on main), #3067 and #2740. The series first became a tree on 2026-10-27, from main 1521053 (this PR was [9/21]); it was rebased onto main 7c3c868 later that day, after #2739 merged, and onto main f03651e that night.
Base:
shengliangx/hfep-base-6a-ep-loader, a branch (not a PR) that merges #2779, #3067 and #2740, the PRs this one builds on; #3067 and #2740 bring #3074 and #3122 below them. The diff below is this PR's alone, and it merges after all of them.Re-cut: this PR's diff is unchanged, except that
_eager_only_expertstakes #3067's rule (see the last Testing line), its_auto_classtable rows moved totest_model_load_utils.py, where #2737 moved that test, and two docstrings keep main's newer wording. Review fixes since the first re-cut are listed at the end of Testing.Series layout
main([OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745, [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736, [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100, [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #3101 and [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099 have merged)What does this PR do?
Type of change: New feature
load_and_prepare_ep(ckpt_path, plan)loads a model by an expert-parallelDistributionPlan(#2736, #2737), with every rank both data-parallel and expert-parallel. It checks the plan against the run before anything else. It records no plan on the model:load_pretrainedrecords the plan once for every load (#2779; the expert-parallel route is #2780), and a caller loading through this loader directly records it withset_distribution_plan, as the GPU tests here do. It lives inmodelopt.torch.utils.plugins.model_load_utilswith the other loaders.modelopt.torch.utils.moekeeps what expert parallelism is built from: finding the routed experts (#2737), the all-to-all transport (#2738), and the EP mesh calibration reads back.moe.require_transformers): below 5.6, transformers' expert-parallel load shards nothing, without an error, and 5.6 builds a model only with the experts implementations it ships, so it refusestorch_a2a.DistributedConfig(enable_expert_parallel=True)) builds only each rank's experts, so no rank ever holds the whole expert set. The model'sbase_model_ep_planis swapped to thetorch_a2astyles from [OMNIML-6059] [1/21] Add the DP=EP all-to-all expert transport #2738, so tokens travel to the rank that owns their expert. The swap lasts only for the load: the model keeps the plan it was sharded by asmodel.tp_plan, which calibration re-installs the forwards from, and the config it keeps goes back to the checkpoint's own plan, so an exportedconfig.jsonnames no ModelOpt-only style. The experts implementation the forwards dispatch on (torch_a2a) stays set; transformers leaves it out of a serialized config._parallel_state, which quantization keeps when it converts the module (Megatron's expert MLPs rely on the same rule):base_model_ep_planthat sharded them, not from class names, by the rule the decision ([OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737) counts them with. The loader builds its model through the class the decision chose, except thatrequire_lm_head=Falsebuilds a VLM without its head, throughAutoModel(the EP degree and the expert containers do not depend on the head). The experts are quantized as they are, through the grouped GEMM of theirtorch_a2aforward. Experts it cannot quantize that way are refused: those stored transposed (GPT-OSS's), told by theiris_transposedflag or, for gated ones that do not say so, by their shapes, which it would leave unquantized; those ModelOpt quantizes through a wrapper of their own (Llama4's), whose forward drops the transport's process group; and those whose fused-experts wrapper quantizes on the eager forward only (FP8's), which the transport does not run. A plan whose expert containers match no module of the loaded model is refused after the load, before any parallel state is seeded. So is a routed-expert container calibration would not take for one by its name (noexpertsin it,model_calib._is_routed_expert), whose weight amax it would reduce over ranks holding different experts. No quant-module registration is added or removed. The transposed and eager-only rules are the ones quantization itself applies, from one helper each in the quantization HF plugin (_transposed_experts,_eager_only_experts).Mxfp4Config(dequantize=True)), since quantizers attach to bf16 weights.float-quantized(GLM-4.5/4.6-FP8).record_unplaced_source_keys, from [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733), so the export carries them from that record.expert_parallel_mesh(model)returns the mesh the experts were loaded over (transformers keeps it on the model), so calibration can re-install the all-to-all forwards ([OMNIML-6071] Calibrate a distributed model on per-rank data #2740). A model planned expert-parallel that carries no mesh is refused by name (load it withload_and_prepare_ep).from_pretraineddrops itsdevice_meshargument oncedistributed_configis given, and builds a world-sizedtpmesh of its own.tpto the EP degree and handsfrom_pretrainedthe EP mesh for the load. Earlier releases honordevice_meshand are untouched.DistributedConfigargument is decided by its own field:tp_planonDistributedConfigwhere it has the field (5.10, and 5.13 on), else onfrom_pretrained, never both;tp_sizeis the EP degree where transformers builds its own mesh.ep_sizegives it is refused, on every rank alike: whole on every rank (a plan whose expert weights no style shards), or sharded over another mesh (a mesh-builder patch that stopped applying). A DTensor (5.16 on) shows its whole count and this rank's share; a plain tensor's share is checked against the count the config names. Weights that show no share at all, as a container owning none itself would, are refused too, rather than passed unchecked.The loader reads the plan before it resolves the checkpoint or builds a mesh, and refuses one that styles no routed-experts container, or one whose
ep_sizedoes not divide the routed experts its config counts, as the decision counts them: the all-to-all transport finds an expert's rank by dividing its id by each rank's share, so every rank must hold as many. A count the config names none of those ways is read after the load off the experts' weights, which transformers keeps whole on the DTensors it shards them into from 5.16 (5.16.0 splits an uneven count without an error); a load whose weights show no countep_sizedivides is refused, as the decision refuses a count it cannot read. It loads in the config'sdtype, builds the VLM class with the caller'strust_remote_code, and reads a quantized language model's format fromllm_configtoo.Usage
Testing
New
tests/unit/torch/utils/test_ep_loader.py, on CPU:ep_sizethat does not divide the experts (3 over 2 ranks); and, after the load, a plan whose expert containers match no module of the model, containers whose names calibration does not take for routed experts (routed), and a count the config does not name, read off the experts' weights: a DTensor countep_sizedivides loads on, one it does not, or plain weights that show none, are refused;test_model_load_utils.py);from_pretrainedchecks a model's experts implementation against a fixed list withouttorch_a2a; checked against the 5.6.0, 5.6.1 and 5.6.2 releases, while 5.7 and later check the registrytorch_a2ais registered in;base_model_ep_planafter the load, not the swappedtorch_a2astyles, for a plan on the config and for a class default, each failing without its half of the restore;ep_sizegives it refused after the load: sharded over another mesh (sharded-over-another-mesh), or plain weights left whole while the config names the count (never-sharded); and experts whose container owns no weights itself, which show no share at all (owning-no-weights);model.tp_plankeeps thetorch_a2astyles once the config's plan is put back, and the config serializes no experts implementation (checked on transformers 5.7, 5.13, 5.16 and 5.18);from_pretrained's own mesh builder handing back the EP mesh during the load, and restored afterwards even when the load raises;DistributedConfigandfrom_pretrainedarguments for each field/builder combination of the releases from 5.6 to 5.19, and for the builder without atp_planfield, which no release has and which raisedTypeErrorbefore the fix;expert_parallel_mesh: the model's mesh for an expert-parallel plan, none for a model with no plan or a replicated one, and an expert-parallel model without a mesh refused.Mutation-checked: each test fails on at least one deliberate break of the code, the plan checks and the plan-read mesh included; the count read off the weights (dropping it fails the not-dividing and not-shown rows, dropping its none-shown refusal the latter) and the experts-name refusal each fail without their fix; the no-match, eager-only and missing-mesh refusals, and the refusal of an
ep_sizethat does not divide the experts, each fail without their fix, and so does the transposed rule's shape test (reading the flag alone fails the shapes row, an inverted shape test the standard row).The refusal table's "with the fix" column was measured on an earlier revision of this branch, with [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 on top and at the stack tip, which agree on every case; the runs below say which revision they are from. The rest of the GPU tests run in CI only.
New
tests/gpu/torch/utils/test_expert_parallel_load.py: loads a tiny Qwen3-MoE expert-parallel, with EP over every GPU and over half of them, and checks each rank's share of the experts and the logits against the whole model, and that the load recorded its unplaced keys (none, for this checkpoint, whichsave_pretrainedwrites per expert) and its plan, on every module. On a 4-GPU node, 2 passed on this branch: EP 4, and EP 2 with each expert on two ranks. On 1 GPU, both skip.On this revision, with [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 on top and at the stack tip: the expert-parallel GPU tests pass on 2 GPUs (8 passed, 4 skipped) and 4 GPUs (12 passed), EP over half the ranks included. Those runs did not record their transformers version: the GPU harness installs it per run, resolved under
transformers>=5.5,<5.19, which gave 5.18.0 wherever a run printed it. (This note said 5.9 before, which was wrong.) At the previous revision, on transformers 5.18 at the stack tip, a tiny randomly initialized Kimi-K3 quantized expert-parallel on 2 GPUs loaded its experts as DTensors, passed the share check, calibrated and exported.On transformers 5.18, 4 GPUs, before and after the fix:
tpmesh before;The refusal on GPUs: [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742's quantized expert-parallel training test (
test_quantized_expert_parallel_model_trains_like_the_whole_model), before this PR accepted the generic wrapper (an earlier revision of the stack) and on this revision. Before, the NVFP4 cases were refused (Expert-parallel PTQ cannot quantize Qwen3MoeExperts); the FP8 cases already passed:Tests that differ only in their inputs are merged into parametrized tables. Each merge is checked by mutation.
The config refusals (unsupported formats, no plan, a TP-only plan) in one table; the config's dtype and
trust_remote_codereaching the load; anllm_configformat row; and the GPU load test reading the expert-parallel mesh back from the model.Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full
tests/uniton transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
Review fixes, 2026-10-27: the loader keeps the expert-parallel mesh on the model itself. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.
Rebased on 2026-10-27 onto main 7c3c868, after [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8).
Rebased over [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745's last review fix (2026-10-27):
_eager_only_experts, which this PR factors out of the backend normalization, carries [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745's lookup for a class the on-the-fly registration has not registered yet.Review suggestion, 2026-10-27, last round:
_auto_class's docstring saysrequire_lm_head=Falsebuilds an encoder-decoder model headless too, pinned by a new table row; rebased over [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737'sAutoModelForCausalLMfallback (Phi-4-multimodal).Review suggestions, 2026-10-27, last round: a model planned expert-parallel without its device mesh is refused with advice to pass the model
load_and_prepare_epreturned, not a submodule of it; the loader's notes print throughprint_rank_0; a comment says why the loader imports inside the function.Review suggestion, 2026-10-28: the share check's comment says it reads the containers' weights together and relies on one expert layout per model, as every transformers MoE has.
Declined review finding, 2026-10-27:
require_transformerskeeps refusing a pre-release below its floor (5.7.0.dev0<5.7). Every floor the series checks (5.7, 5.8, 5.13) is released, transformers main (5.21.0.dev0) passes them, and a pre-release of a floor can predate the feature it gates.Declined review suggestion, 2026-10-28, fifth round: raising when no config takes the
torch_a2atag; everyPretrainedConfigdefines_experts_implementation(transformers 5.5.4 and 5.18), so the model's own config always takes it.Re-cut on 2026-10-29 (see the note at the top), rebased commit by commit onto the rebuilt base:
_eager_only_expertstakes [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067's rule, the registry lookup alone. [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 dropped the non-gated wrapper, so the only wrapper the on-the-fly registration still adds is the generic one, which is not eager-only; the fallback lookup for a class not registered yet, carried over from [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745 (the 2026-10-27 line above), is gone. The eager-only wrappers (FP8's, GPT-OSS's, Nemotron-H's) register when their modules are imported. The loader's refusal and the backend normalization keep sharing the helper._auto_classtable rows moved totest_model_load_utils.py. The docstring keeps main's rule that anis_encoder_decoderconfig counts whether or not it names its architecture, andregister_torch_a2a's keeps main's wording beside the 5.7 floor.test_model_load_utils.py,test_moe_a2a.py,test_meta_dcp_load.py,test_fused_experts.py, the Nemotron-H tests) pass on transformers 5.18 and 5.5.4, and so does the wholetests/unit/torch/utilsdirectory on 5.5.4.Before your PR is "Ready for review"
CONTRIBUTING.md: N/A2604148c5f1, the same diff this PR kept until the 2026-10-29 re-cut. The re-cut changed only_eager_only_experts's rule (now [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067's), two docstrings, and where its test-table edits live (Testing).Additional Information
Part 9 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2779, #3067 and #2740.
Summary by CodeRabbit
New Features
Bug Fixes