Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c22907b
Decide the expert-parallel plan from the model and the knobs
shengliangxu Oct 10, 2026
e2037f8
Decide the plan through the session
shengliangxu Oct 13, 2026
4cde41f
Decide the plan through the session's plan handle
shengliangxu Oct 14, 2026
896ac7c
Decide the plan through the renamed broadcast_from
shengliangxu Oct 14, 2026
7212b50
Decide the plan on rank 0 and broadcast it, without a session to keep…
shengliangxu Oct 14, 2026
7102844
Decide a model's distribution in resolve_distribution_plan, from its …
shengliangxu Oct 14, 2026
5f3a86c
Keep each env knob on one docstring line, as Sphinx parses it
shengliangxu Oct 14, 2026
db4cfbb
Name the world size as a value MODELOPT_FSDP_SIZE accepts
shengliangxu Oct 14, 2026
5c34be9
Say which GPU the automatic dense placement is sized against
shengliangxu Oct 15, 2026
9b157ac
Fail every rank when one cannot read the checkpoint, before the plan'…
shengliangxu Oct 16, 2026
17adc73
Read no distribution knob in a single process
shengliangxu Oct 16, 2026
0673d4d
Test on two real ranks that every rank takes the plan rank 0 decided
shengliangxu Oct 16, 2026
ec3b0b4
Leave require_transformers and the headless VLM build to their first …
shengliangxu Oct 16, 2026
c1f2b1c
Trim the plan decision's tests
shengliangxu Oct 16, 2026
eafb18f
Date env and test_resolve_distribution_plan 2026, as LICENSE_HEADER does
shengliangxu Oct 22, 2026
75a6a6f
Trust a checkpoint's remote class only under trust_remote_code, and n…
shengliangxu Oct 24, 2026
2b8563c
Count a rank's expert share in the dense fit, and take the Hub options
shengliangxu Oct 24, 2026
ec865ee
Build a trusted remote VLM through the image-text class it registers
shengliangxu Oct 24, 2026
a889e44
Leave the auto class as it was after building a model on the meta device
shengliangxu Oct 25, 2026
432b131
Read the auto class's registrations without a fallback
shengliangxu Oct 25, 2026
bf7557b
Name a plan's routed experts by both styles, not the first one found
shengliangxu Oct 27, 2026
4a9f7df
Build an encoder-decoder model through its seq2seq class, head included
shengliangxu Oct 27, 2026
ab81f87
Read the EP degree's auto in any case, as the FSDP knob reads its values
shengliangxu Oct 27, 2026
5d21a79
Count the routed experts in the config that holds their plan
shengliangxu Oct 27, 2026
b16d580
Say that the automatic dense layout replicates on a rank 0 without a GPU
shengliangxu Oct 27, 2026
2092b0c
Name the knob an EP degree came from when it cannot shard the experts
shengliangxu Oct 27, 2026
9af2525
Print the decided distribution with print_rank_0
shengliangxu Oct 27, 2026
a048b74
Leave the auto classes a remote model's parts register in as found
shengliangxu Oct 27, 2026
a334adc
Refuse an EP plan whose expert containers match no module
shengliangxu Oct 27, 2026
6f85cf7
Name the knob a degree came from when it cannot divide the world
shengliangxu Oct 27, 2026
2b1792d
Wildcard each module name once when finding the routed experts
shengliangxu Oct 27, 2026
c1e5ed4
Say which auto-class registrations the meta build's restore assumes
shengliangxu Oct 27, 2026
c16c587
Say a caller resolves the checkpoint before deciding its plan
shengliangxu Oct 27, 2026
76216df
Build a multimodal causal LM through AutoModelForCausalLM for its plan
shengliangxu Oct 27, 2026
d61e947
Say each rank returns its own copy of the config, rank 0's being what…
shengliangxu Oct 27, 2026
ffb5707
Say why the automatic EP degree is the largest one
shengliangxu Oct 28, 2026
5c03073
Import the plan decision's MoE helpers and auto classes at module top
shengliangxu Oct 28, 2026
539b57d
Import the distributed helpers' module at the top too
shengliangxu Oct 28, 2026
71d090d
Count a tied embedding and LM head once when sizing the model
shengliangxu Oct 29, 2026
d42fc09
Build an encoder-decoder config that names no architecture through it…
shengliangxu Oct 29, 2026
0ac9e27
Say why the routed-expert match imports transformers locally
shengliangxu Oct 29, 2026
cff385b
Move the plan decision's tests into the files of the modules they cover
shengliangxu Oct 29, 2026
09329ac
Drop build_meta_causal_lm's local AutoModelForImageTextToText import
shengliangxu Oct 29, 2026
2a6290b
Refuse routed experts whose config gives no expert count, unless EP is 1
shengliangxu Oct 29, 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
44 changes: 44 additions & 0 deletions modelopt/torch/utils/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The ``MODELOPT_*`` environment-variable knobs of distributed HuggingFace model loading.

Each knob is a small accessor that reads ``os.environ`` *at call time*, not at import: a launcher sets
them before it forks the distributed workers, and tests may override them, so the live value is what
matters. Callers do ``from modelopt.torch.utils import env`` then ``env.ep_degree()``.

Distribution plan (see ``resolve_distribution_plan``):

- ``MODELOPT_EP_DEGREE``: expert-parallel degree; ``""``/unset -> ``"auto"``.
- ``MODELOPT_FSDP_SIZE``: the dense layout. ``"1"`` replicates it; ``"world"``, or the world size as a
number, FSDP2-shards it over every rank; ``""``/unset -> ``"auto"``.
"""

import os

__all__ = [
"ep_degree",
"fsdp_size",
]


def ep_degree() -> str:
"""Expert-parallel degree knob for ``resolve_distribution_plan``; ``""``/unset -> ``"auto"``."""
return os.environ.get("MODELOPT_EP_DEGREE") or "auto"


def fsdp_size() -> str:
"""Dense-sharding knob for ``resolve_distribution_plan``; ``""``/unset -> ``"auto"``."""
return os.environ.get("MODELOPT_FSDP_SIZE") or "auto"
95 changes: 94 additions & 1 deletion modelopt/torch/utils/moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

"""HuggingFace-coupled MoE expert-parallel (EP) helpers.

The DP=EP all-to-all expert transport (``torch_a2a``).
Finding a model's routed experts, choosing the EP degree they are sharded over, and the DP=EP
all-to-all expert transport (``torch_a2a``).

The all-to-all transport is plain ``torch.distributed.all_to_all_single`` (NCCL) wrapped around
transformers' ``_grouped_linear``. ``register_torch_a2a()`` registers it into
Expand All @@ -24,11 +25,103 @@
"""

import warnings
from typing import TYPE_CHECKING

import torch
import torch.distributed as dist
from torch.distributed.nn.functional import all_to_all_single as autograd_all_to_all_single

if TYPE_CHECKING:
from torch import nn


def _ep_config(hf_config):
"""Return the config that carries the EP plan / expert count.

For a plain MoE causal LM this is the root config. For a VLM whose nested text submodel
is the MoE (e.g. ``KimiK25.text_config`` -> deepseek_v3, or ``NemotronH_Omni.llm_config``
-> nemotron_h), this is that nested config.
"""
if getattr(hf_config, "base_model_ep_plan", None):
return hf_config
for _attr in ("text_config", "llm_config"):
sub = getattr(hf_config, _attr, None)
if sub is not None and getattr(sub, "base_model_ep_plan", None):
return sub
# Fall through: no plan anywhere; let callers decide.
return hf_config


def _num_experts_from_config(hf_config) -> int:
"""Return the MoE expert count from an HF config (0 if the model is not MoE).

Recurses into ``.text_config`` / ``.llm_config`` so VLMs whose nested text submodel is the MoE
(Kimi-K2.7, Llama-4 multimodal, NemotronH-Omni, ...) report the LM's expert count.
"""
for cfg in (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
hf_config,
getattr(hf_config, "text_config", None),
getattr(hf_config, "llm_config", None),
):
if cfg is None:
continue
for attr in ("num_experts", "num_local_experts", "n_routed_experts", "num_routed_experts"):
value = getattr(cfg, attr, None)
if value:
return int(value)
return 0


def _choose_ep_degree(world_size: int, num_experts: int) -> int:
"""The largest EP degree dividing both ``world_size`` and ``num_experts``; ``1`` if none does.

EP must divide ``num_experts`` (whole experts per rank) and ``world_size`` (a valid mesh
factorization). Each expert shard is then replicated over the remaining ranks
(``DistributionPlan.edp``). The largest degree holds the fewest experts per rank, which leaves
the most memory for activations, i.e. for calibration batches; any degree above 1 pays the same
two all-to-alls per MoE layer. Heuristic -- callers may override.
"""
if num_experts <= 0:
return 1
return next(
(ep for ep in range(world_size, 0, -1) if world_size % ep == 0 and num_experts % ep == 0),
1,
)
Comment on lines +75 to +89

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] "auto" maximizes the EP degree with no reference to memory, while dense="auto" right next to it is decided by a memory budget — the two halves of the same auto plan use different criteria.

_choose_ep_degree returns the largest valid degree, so an 8-rank run on a model whose experts comfortably fit one GPU still gets ep_size=8. Because this run is DP=EP, that buys nothing in memory it needed and costs two all_to_all_single round trips per MoE layer per calibration step (torch_a2a, below in this file) that ep_size=1 would not pay at all. The asymmetry also shows in the order inside _decide_distribution: ep_size is fixed first, then held is computed from it — so the budget can never feed back into the degree, only into dense.

Not a correctness bug, and the docstring already says "Heuristic -- callers may override". But the smallest degree that fits the budget is the choice that matches what dense="auto" is already doing, and it is the same measurement you have in hand:

# in _decide_distribution, once `experts` is known and dense/ep are both "auto":
#   expert_bytes = sum(p.numel() * p.element_size() for m in experts.values() for p in m.parameters())
#   rest = total - expert_bytes
#   smallest ep in ascending order of the valid divisors such that
#       rest + expert_bytes // ep <= _REPLICATED_SHARE * memory
#   falling back to the largest valid degree when none fits.

If maximizing EP is the deliberate choice (e.g. you want the largest degree so the later [7/21]+ loader always exercises the sharded path), a sentence in _choose_ep_degree's docstring saying why largest rather than smallest-that-fits would stop the next reader from reading it as an oversight — the dense neighbour sets the expectation that auto means "as replicated as memory allows".

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 1a4e341 as a docstring: the largest degree is deliberate. It holds the fewest experts per rank, which leaves the most memory for activations, and hf_ptq sizes its calibration batch to that memory; every degree above 1 runs the same two all-to-alls per MoE layer, and only degree 1 avoids them, by holding every expert on every rank. The trade-off you describe is real across nodes: past one node's GPUs the all-to-all leaves NVLink, so a model whose experts fit at a node-sized degree could calibrate faster there. The models this series targets need their experts spread over every GPU to fit at all, so the default stays the largest, and MODELOPT_EP_DEGREE or a handed plan picks another.



# The styles a base_model_ep_plan gives a layer's routed-experts container. A plan that styles the
# experts' grouped-GEMM parameters names the containers through their parents too, so a plan that
# styles some containers one way and others the other names them all.
_EXPERTS_STYLES = ("moe_tp_experts", "nemotron_experts_ep")


def _routed_expert_keys(ep_plan: dict[str, str]) -> set[str]:
"""The keys of ``ep_plan``, a ``base_model_ep_plan``, that name a routed-experts container."""
return {key for key, style in ep_plan.items() if style in _EXPERTS_STYLES} | {
key.rsplit(".", 1)[0] for key, style in ep_plan.items() if style == "grouped_gemm"
}


def _routed_expert_modules(model: "nn.Module", ep_plan: dict[str, str]) -> "dict[str, nn.Module]":
"""``model``'s routed-expert containers, ``{name: module}``, as its ``base_model_ep_plan`` names them.

A module is one when its name, with layer indices wildcarded as transformers does, is a container
key or ends with one: the keys are relative to the base model, so they omit the ``model.`` or
``language_model.model.`` a causal LM or a VLM puts in front. Deciding a model's distribution and
loading it expert-parallel both find the experts this way, so the two cannot disagree.
"""
# Local: transformers is the optional [hf] extra, and this module must import without it.
from transformers.integrations.tensor_parallel import replace_layer_number_by_wildcard

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.

This new function-local import has no concrete justification in the code or PR body. CONTRIBUTING.md requires imports at module scope unless a documented circular/optional/heavy-dependency reason requires deferral. Please move it to the top, or add a brief comment explaining the optional-Transformers boundary if this module must remain usable without Transformers. This is a convention issue, not an independent correctness blocker.

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.

Kept local, with the reason now in a comment (4465f96): transformers is the optional [hf] extra, and modelopt.torch.utils.moe must import without it. tests/unit/torch/utils/test_moe_a2a.py imports the module at its top in the torch-only partial install and skips its transformers tests one by one; a module-level transformers import would fail that collection. The module's other transformers imports are local too.

keys = _routed_expert_keys(ep_plan)
found = {}
for name, module in model.named_modules():
generic = replace_layer_number_by_wildcard(name)
if any(generic == key or generic.endswith("." + key) for key in keys):
found[name] = module
return found
Comment on lines +116 to +122

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] Skip the walk entirely when the plan names no expert container.

_decide_distribution calls this for every model, and ep_plan is {} for every dense model and every MoE without a base_model_ep_plan. With keys empty, any(...) is False for every module, but replace_layer_number_by_wildcard(name) still runs a regex substitution per module — tens of thousands of them on a large model, all to return {}.

Suggested change
keys = _routed_expert_keys(ep_plan)
found = {}
for name, module in model.named_modules():
generic = replace_layer_number_by_wildcard(name)
if any(generic == key or generic.endswith("." + key) for key in keys):
found[name] = module
return found
keys = _routed_expert_keys(ep_plan)
if not keys:
return {}
found = {}
for name, module in model.named_modules():
generic = replace_layer_number_by_wildcard(name)
if any(generic == key or generic.endswith("." + key) for key in keys):
found[name] = module
return found

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 taken: the walk is not where the time goes. On CPU, _routed_expert_modules with an empty plan takes 3.0-4.0 ms over the 1,215-1,645 modules of DeepSeek-V3 (671B), a 1.03T DeepSeek-V3, Llama-3.1-405B and Qwen3-235B-A22B. Fused experts keep the module count that low in transformers 5.18 and 5.5.4. The meta build the walk follows takes 0.54-0.65 s.



# ---------------------------------------------------------------------------------------------------
# DP=EP all-to-all expert transport (`torch_a2a`)
#
Expand Down
Loading
Loading