Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions modelopt/torch/utils/moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,32 @@

import torch
import torch.distributed as dist
from packaging.version import parse
from torch.distributed.nn.functional import all_to_all_single as autograd_all_to_all_single

if TYPE_CHECKING:
from torch import nn


def require_transformers(min_version: str, feature: str) -> None:
"""Fail loudly if the installed transformers is older than ``min_version`` (the floor for ``feature``).

Callers enforce the version where the needed APIs first exist, so a too-old transformers errors
clearly instead of silently mis-behaving (e.g. transformers 5.5 silently shards nothing for EP).
A pre-release of the floor release (``5.7.0.dev0`` for ``5.7``) is refused too: it can predate
the feature the floor stands for.
"""
# Imported here: this module imports without transformers.
import transformers

have = transformers.__version__
if parse(have) < parse(min_version):

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] A pre-release of the floor release is hard-refused. Under PEP 440, 5.7 normalizes to 5.7.0 and parse("5.7.0.dev0") < parse("5.7.0"), so a transformers install from main reporting 5.7.0.dev0 (or an rc) is refused against a "5.7" floor even though it carries the API the floor exists for. Since new transformers MoE/EP APIs land on main first, that is a likely configuration for exactly the users this gate targets — and unlike the existing gate in modelopt/torch/__init__.py:51, which only warns, this one raises and blocks the load outright.

Comparing base versions accepts a dev build of the floor release while still refusing 5.6.2:

from packaging.version import Version

have = transformers.__version__
if Version(Version(have).base_version) < Version(min_version):

If refusing pre-releases is deliberate (a 5.7.0.dev0 predating the feature is possible), say so in the docstring so the next reader doesn't "fix" it.

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 the refusal, and the docstring now says it is deliberate (2a046d6).

A pre-release of the floor release can predate the feature the floor stands for. Comparing base versions would let such a build through and fail later, inside the load. Every floor the stack checks (5.7, 5.8, 5.13) is a published release, and transformers main (5.21.0.dev0) is above all of them, so current main builds pass.

raise RuntimeError(
f"{feature} requires transformers >= {min_version}, but {have} is installed. "
f"Upgrade with: pip install -U 'transformers>={min_version}'."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)


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

Expand Down
31 changes: 20 additions & 11 deletions modelopt/torch/utils/plugins/model_load_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,18 +924,22 @@ def parallel_load_and_prepare_fsdp2(
return model


def _auto_class(hf_config, trust_remote_code: bool = False) -> Any:
"""The auto class that builds ``hf_config``'s whole model, LM head included.

A causal LM builds through ``AutoModelForCausalLM``. A VLM -- a nested text config, a vision
config, or a ``*ForConditionalGeneration`` architecture -- builds through the remote
``AutoModelForCausalLM`` when the checkpoint registers its ``*ForConditionalGeneration`` there,
its code is trusted and it registers no image-text class, else through
def _auto_class(hf_config, require_lm_head: bool = True, trust_remote_code: bool = False) -> Any:
"""The auto class that builds ``hf_config``'s whole model.

A causal LM builds through ``AutoModelForCausalLM``, head included: ``require_lm_head`` only
applies to a model whose architecture reads as a VLM's -- a nested text config, a vision
config, or a ``*ForConditionalGeneration`` architecture -- or that is an encoder-decoder model
(``is_encoder_decoder``), whether or not it names its architecture. Without ``require_lm_head``
such a model builds through ``AutoModel``, its base model, when that can build it (the config
is a native one, or a trusted remote one registering ``AutoModel``): most base models leave the
head out, since it is not required, though some keep it (Llama4's). Otherwise it builds through
the remote ``AutoModelForCausalLM`` when the checkpoint registers its
``*ForConditionalGeneration`` there, its code is trusted and it registers no image-text class,
else through its seq2seq class if it is an encoder-decoder model, else through
``AutoModelForImageTextToText`` if that recognizes the config or, its code trusted, the
checkpoint registers one, else through ``AutoModelForCausalLM`` if that recognizes it (as it
does Phi-4-multimodal), else through ``AutoModel``, which leaves the head out. An
encoder-decoder model (``is_encoder_decoder``, whether or not it names its architecture, which
reads like a VLM's) builds through its seq2seq class.
does Phi-4-multimodal), else still through ``AutoModel``.
"""
architectures = getattr(hf_config, "architectures", None) or []
is_vlm = (
Expand All @@ -946,6 +950,11 @@ def _auto_class(hf_config, trust_remote_code: bool = False) -> Any:
if not is_vlm and not getattr(hf_config, "is_encoder_decoder", False):
return AutoModelForCausalLM

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] require_lm_head=False is silently ignored for the most common config. A causal LM returns AutoModelForCausalLM — head included — whatever the caller asked for. The docstring is explicit about it, so this isn't a bug, but the parameter name promises a headless model and delivers one only for VLM / encoder-decoder configs; CLAUDE.md's pre-push guidance ("fail loudly rather than silently ignore an option or route") points the other way.

Two ways to close it, either is fine:

  • Honor it uniformly — return AutoModel if not require_lm_head else AutoModelForCausalLM here. The EP loader's meta build only counts experts, so dropping a causal LM's head costs it nothing.
  • Keep the behavior and rename the parameter to say what it actually selects (e.g. allow_headless_backbone), so a future caller doesn't read require_lm_head=False as a guarantee.

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 behaviour and the name. The docstring now states the contract (8e32f5e).

require_lm_head says whether the head is required, not that it must be absent:

  • With it False, a VLM may build through its base model, which usually leaves the head out. Some base models keep it (Llama4's, as CodeRabbit notes).
  • A causal LM's only full-model class carries its head, and building it through AutoModel instead would drop a head the caller may still calibrate and export.

So False relaxes a requirement and is not ignored: no caller is told it gets a model without a head. The docstring now says "most base models leave the head out, since it is not required, though some keep it".

auto_map = getattr(hf_config, "auto_map", None) or {}
if not require_lm_head and (
type(hf_config) in AutoModel._model_mapping
or (trust_remote_code and "AutoModel" in auto_map)
):
return AutoModel
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment on lines +953 to +957

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 AutoModel gate added here closes the main gap from the last round (thanks — remote-vlm-without-a-base-class / remote-vlm-backbone pin both sides well). One residual corner, worth a thought while [2/3] is still being written rather than a change here:

type(hf_config) in AutoModel._model_mapping resolves through _LazyAutoMapping.__contains__, which matches on item.__name__ against _reverse_config_mapping — i.e. by class name, not class identity. AutoModel.from_config then decides with type(config) in cls._model_mapping.keys(), and keys() materializes the actual config classes, so that check is identity-based.

The two disagree for a remote-code checkpoint whose config class name collides with a native one — common for a model shipped with remote code before (or alongside) its upstreaming, e.g. a vendored configuration_*.py defining Qwen2VLConfig. With trust_remote_code=True and an auto_map that registers only AutoModelForCausalLM, the first disjunct is True on the name match, so this returns AutoModel; from_config then finds no "AutoModel" in auto_map and no identity match in keys(), and raises ValueError: Unrecognized configuration class, which _decide_distribution re-wraps as the generic "needs its model built on the meta device, which failed". That points at the model rather than at the headless request — the same symptom the second disjunct was added to avoid, reached through the first one instead.

Two reasons this is a SUGGESTION and not a blocker: the same name-based idiom already backs the pre-existing branches below (lines 967, 971), so it is the file's established convention rather than something this PR introduces; and switching to .keys() to make it identity-based is not free — it imports every registered config module, which is exactly the cost _LazyAutoMapping exists to avoid. If you keep the name-based check (reasonable), the docstring's "the config is a native one" is the line to tighten: the condition is really "a config AutoModel maps by name", which is not quite the same claim.

(Noting for completeness, not as a separate finding: CodeRabbit's open point on moe.py:53 — pip install -U 'transformers>={min_version}' can land a user above the transformers<5.19 ceiling in pyproject.toml:90 and trip the warning at modelopt/torch/__init__.py:51. Your call; it is message wording, not behavior.)

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 check, for the reasons you give: the branches below use the same idiom, and matching by identity would import every registered config module. The corner you describe ends in a refusal with a vaguer message, not a wrong load, since from_config raises before anything loads. You're right that the docstring should say "a config AutoModel maps by name" rather than "a native one". On its own that edit isn't worth a push, because a push here restacks every PR above this one, so I'll make it in the next change to this PR.

remote_with_head = (
trust_remote_code
and "conditionalgeneration" in str(auto_map.get("AutoModelForCausalLM", "")).lower()
Expand Down Expand Up @@ -1027,7 +1036,7 @@ def _decide_distribution(
trust_remote_code,
None,
hf_config,
auto_class=_auto_class(hf_config, trust_remote_code),
auto_class=_auto_class(hf_config, trust_remote_code=trust_remote_code),
)
# init_empty_weights re-wraps every parameter it registers, which unties what from_config
# tied: re-tie, so a tied embedding and LM head count once, as the loaded model holds them.
Expand Down
53 changes: 37 additions & 16 deletions tests/unit/torch/utils/test_model_load_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,20 +305,35 @@ def _tiny_qwen2_5_vl():


@pytest.mark.parametrize(
("config", "trust_remote_code", "expected"),
("config", "require_lm_head", "trust_remote_code", "expected"),
[
(SimpleNamespace(architectures=["LlamaForCausalLM"]), False, AutoModelForCausalLM),
(_vlm_config(auto_map=_REMOTE_VLM), True, AutoModelForCausalLM),
(_vlm_config(auto_map=_REMOTE_VLM), False, AutoModel),
(_vlm_config(auto_map=_REMOTE_IMAGE_TEXT_VLM), True, AutoModelForImageTextToText),
(_tiny_qwen2_5_vl(), False, AutoModelForImageTextToText),
(Phi4MultimodalConfig(), False, AutoModelForCausalLM),
(_vlm_config(), False, AutoModel),
(T5Config(architectures=["T5ForConditionalGeneration"]), False, AutoModelForSeq2SeqLM),
(WhisperConfig(), False, AutoModelForSpeechSeq2Seq),
(SimpleNamespace(architectures=["LlamaForCausalLM"]), True, False, AutoModelForCausalLM),
(_tiny_qwen2_5_vl(), False, False, AutoModel),
(_vlm_config(auto_map=_REMOTE_VLM), True, True, AutoModelForCausalLM),
(_vlm_config(auto_map=_REMOTE_VLM), True, False, AutoModel),
(_vlm_config(auto_map=_REMOTE_IMAGE_TEXT_VLM), True, True, AutoModelForImageTextToText),
(_tiny_qwen2_5_vl(), True, False, AutoModelForImageTextToText),
(Phi4MultimodalConfig(), True, False, AutoModelForCausalLM),
(_vlm_config(), True, False, AutoModel),
(
T5Config(architectures=["T5ForConditionalGeneration"]),
True,
False,
AutoModelForSeq2SeqLM,
),
(WhisperConfig(), True, False, AutoModelForSpeechSeq2Seq),
(T5Config(architectures=["T5ForConditionalGeneration"]), False, False, AutoModel),
(_vlm_config(auto_map=_REMOTE_VLM), False, True, AutoModelForCausalLM),
(
_vlm_config(auto_map={**_REMOTE_VLM, "AutoModel": "m.KimiK25Model"}),
False,
True,
AutoModel,
),
],
ids=[
"causal-lm",
"vlm-backbone",
"remote-vlm-with-head",
"remote-vlm-untrusted",
"remote-image-text-vlm",
Expand All @@ -327,14 +342,20 @@ def _tiny_qwen2_5_vl():
"unknown-vlm",
"encoder-decoder",
"speech-encoder-decoder",
"encoder-decoder-backbone",
"remote-vlm-without-a-base-class",
"remote-vlm-backbone",
],
)
def test_the_class_that_builds_the_whole_model(config, trust_remote_code, expected):
"""A VLM's AutoModel leaves the LM head out: it builds through the class that carries the head,
keeping the headless backbone only when no class recognizes the config. A remote class counts
only when the checkpoint's code is trusted. An encoder-decoder model's architecture reads like a
VLM's, but its seq2seq class carries its head, also for a config that names no architecture."""
assert _auto_class(config, trust_remote_code) is expected
def test_the_class_that_builds_the_whole_model(
config, require_lm_head, trust_remote_code, expected
):
"""A VLM's AutoModel leaves the LM head out: with the head required it builds through the class
that carries it, keeping the headless backbone only when no class recognizes the config. A remote
class counts only when the checkpoint's code is trusted. An encoder-decoder model's architecture
reads like a VLM's, but its seq2seq class carries its head, also for a config that names no
architecture."""
assert _auto_class(config, require_lm_head, trust_remote_code=trust_remote_code) is expected


# -------------------------------------------------------------------------------- the decision
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/torch/utils/test_moe_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import copy
import warnings
from contextlib import nullcontext
from datetime import timedelta
from types import SimpleNamespace

Expand Down Expand Up @@ -503,3 +504,16 @@ def test_routed_experts_are_the_plans_containers_under_any_prefix(plan, prefix):
assert found == {
name: modules[name] for name in [f"{prefix}layers.{i}.mlp.experts" for i in (0, 1)]
}


@pytest.mark.parametrize(("installed", "refused"), [("5.6.2", True), ("5.7.0", False)])
def test_a_transformers_below_a_features_floor_is_refused(monkeypatch, installed, refused):
transformers = pytest.importorskip("transformers")
monkeypatch.setattr(transformers, "__version__", installed)

with (
pytest.raises(RuntimeError, match=r"EP load requires transformers >= 5\.7, but 5\.6\.2 is")
if refused
else nullcontext()
):
moe.require_transformers("5.7", "EP load")
Loading