diff --git a/modelopt/torch/models/README.md b/modelopt/torch/models/README.md index 1d54ffa47dd..a659fcab163 100644 --- a/modelopt/torch/models/README.md +++ b/modelopt/torch/models/README.md @@ -75,6 +75,7 @@ A `ModelSpec` holds one attribute per section, each `None` unless the model fill |---|---|---| | `moe_spec` (`MoESpec`) | MoE architecture facts only — block classes, expert projection naming, which layout that naming describes | the model is dense | | `export_spec` (`ExportSpec`) | Per-model data *and policy* of the unified HF export path, e.g. whether grouped expert export is validated for this model | export needs nothing model-specific | +| `ep_spec` (`EPSpec`) | Per-model data of the expert-parallel PTQ path: the `base_model_ep_plan` to attach and the config it goes on, the transformers release it needs, or why the model cannot be expert-parallel yet | the model is dense, or stock transformers already ships all of it | `None` means "the model has nothing to say about this", which stays distinct from a filled-in-but-empty section. diff --git a/modelopt/torch/models/__init__.py b/modelopt/torch/models/__init__.py index a855418664e..3d107b7e12d 100644 --- a/modelopt/torch/models/__init__.py +++ b/modelopt/torch/models/__init__.py @@ -33,6 +33,7 @@ from .moe import is_moe from .specs import ( + EPSpec, ExportSpec, ModelSpec, MoESpec, @@ -52,6 +53,7 @@ # cannot drift silently: an entry that no longer exists fails at import, and one that is # no longer used fails lint. __all__ = [ + "EPSpec", "ExportSpec", "MoESpec", "ModelSpec", @@ -87,6 +89,9 @@ phimoe, nemotron, nemotron_h, + minimax_m3_vl, + llama4_text, + nemotron_h_omni, qwen2_moe, qwen3, qwen3_5_moe, diff --git a/modelopt/torch/models/deepseek_v3/specs.py b/modelopt/torch/models/deepseek_v3/specs.py index 3a774ecb931..d2f170cfc06 100644 --- a/modelopt/torch/models/deepseek_v3/specs.py +++ b/modelopt/torch/models/deepseek_v3/specs.py @@ -19,7 +19,7 @@ ``DeepseekMoE`` block of DeepSeek-MoE is a separate spec under ``deepseek``. """ -from ..specs import ExportSpec, ModelSpec, MoESpec, register +from ..specs import EPSpec, ExportSpec, ModelSpec, MoESpec, register __all__: list[str] = [] @@ -37,6 +37,17 @@ model_type="deepseek_v3", min_transformers_version="5.5", export_spec=ExportSpec(grouped_expert_export=True), + ep_spec=EPSpec( + # Stock transformers ships no plan for deepseek_v3 before 5.13; from 5.13 the config's + # own plan wins (see apply_distributed_support) and this one goes unused. No gate entry + # on purpose: the router is logits-only and the MoE block computes GLOBAL top-k ids, so + # the experts already receive unmasked global ids -- what the all-to-all dispatch wants. + base_model_ep_plan={ + "layers.*.mlp.experts.gate_up_proj": "grouped_gemm", + "layers.*.mlp.experts.down_proj": "grouped_gemm", + "layers.*.mlp.experts": "moe_tp_experts", + }, + ), moe_spec=MoESpec( block_names=("DeepseekV3MoE",), expert_linear_names=("gate_proj", "down_proj", "up_proj"), diff --git a/modelopt/torch/models/gpt_oss/specs.py b/modelopt/torch/models/gpt_oss/specs.py index be8419781c7..a4b95c790e9 100644 --- a/modelopt/torch/models/gpt_oss/specs.py +++ b/modelopt/torch/models/gpt_oss/specs.py @@ -15,7 +15,7 @@ """GPT-OSS specs (HF model type ``gpt_oss``).""" -from ..specs import ModelSpec, MoESpec, register +from ..specs import EPSpec, ModelSpec, MoESpec, register __all__: list[str] = [] @@ -23,6 +23,13 @@ ModelSpec( model_type="gpt_oss", min_transformers_version="5.5", + ep_spec=EPSpec( + unsupported=( + "GPT-OSS stores its experts transposed, (num_experts, in, out), with gate and up " + "interleaved in gate_up_proj, and the expert-parallel export cannot write that " + "layout." + ), + ), moe_spec=MoESpec( # GPT-OSS fuses gate and up into a single gate_up_proj. # transformers names the block GptOssMLP; GptOssMoE is kept for the diff --git a/modelopt/torch/models/llama4/specs.py b/modelopt/torch/models/llama4/specs.py index dc733398d87..ace9c0b8d69 100644 --- a/modelopt/torch/models/llama4/specs.py +++ b/modelopt/torch/models/llama4/specs.py @@ -15,10 +15,18 @@ """Llama4 specs (HF model type ``llama4``).""" -from ..specs import ModelSpec, MoESpec, register +from ..specs import EPSpec, ModelSpec, MoESpec, register __all__: list[str] = [] +# transformers ships a base_model_ep_plan for Llama-4, which alone would route it into the +# expert-parallel path. +EP_UNSUPPORTED = ( + "Llama-4's MoE block routes tokens itself and runs every expert densely, rather than " + "dispatching its top-k through the experts interface, and its experts are stored transposed, " + "(num_experts, in, out), which the expert-parallel export cannot write." +) + # Llama4TextExperts is fused: one module holding 3-D gate_up_proj and down_proj # parameters, run through torch.bmm. There is no (gate, up) pair left to fuse, and the # grouped-export path (get_experts_list) does not apply. The block also carries a @@ -27,6 +35,7 @@ ModelSpec( model_type="llama4", min_transformers_version="5.5", + ep_spec=EPSpec(unsupported=EP_UNSUPPORTED), moe_spec=MoESpec( block_names=("Llama4TextMoe",), expert_linear_names=("gate_up_proj", "down_proj"), diff --git a/modelopt/torch/models/llama4_text/__init__.py b/modelopt/torch/models/llama4_text/__init__.py new file mode 100644 index 00000000000..89c3f2a5103 --- /dev/null +++ b/modelopt/torch/models/llama4_text/__init__.py @@ -0,0 +1,18 @@ +# 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. + +"""llama4_text specs (HF model type ``llama4_text``).""" + +from . import specs diff --git a/modelopt/torch/models/llama4_text/specs.py b/modelopt/torch/models/llama4_text/specs.py new file mode 100644 index 00000000000..3b998b66031 --- /dev/null +++ b/modelopt/torch/models/llama4_text/specs.py @@ -0,0 +1,38 @@ +# 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. + +"""Llama-4 text-only specs (HF model type ``llama4_text``). + +The same architecture as ``llama4`` reached through ``Llama4ForCausalLM``, with its own +``base_model_ep_plan`` on the root config, so it is refused expert parallelism the same way. + +Only that refusal is registered under this type, deliberately: no MoE or export section, so its +MoE and export lookups find none, as they did before this type had a spec. Giving it ``llama4``'s +MoE section would change how text-only Llama-4 checkpoints match MoE blocks in quantization and +export, which belongs with tests of those paths, not with its expert-parallel refusal. +""" + +from ..llama4.specs import EP_UNSUPPORTED +from ..specs import EPSpec, ModelSpec, register + +__all__: list[str] = [] + +register( + ModelSpec( + model_type="llama4_text", + min_transformers_version="5.5", + ep_spec=EPSpec(unsupported=EP_UNSUPPORTED), + ) +) diff --git a/modelopt/torch/models/minimax_m3_vl/__init__.py b/modelopt/torch/models/minimax_m3_vl/__init__.py new file mode 100644 index 00000000000..d8916c327a4 --- /dev/null +++ b/modelopt/torch/models/minimax_m3_vl/__init__.py @@ -0,0 +1,18 @@ +# 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. + +"""minimax_m3_vl specs (HF model type ``minimax_m3_vl``).""" + +from . import specs diff --git a/modelopt/torch/models/minimax_m3_vl/specs.py b/modelopt/torch/models/minimax_m3_vl/specs.py new file mode 100644 index 00000000000..025d2f7b22a --- /dev/null +++ b/modelopt/torch/models/minimax_m3_vl/specs.py @@ -0,0 +1,33 @@ +# 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. + +"""MiniMax-M3 specs (HF model type ``minimax_m3_vl``). + +transformers ships everything M3 needs to run expert-parallel: its MoE block calls the experts with +the router's top-k, its experts dispatch through the experts interface, and its text config carries a +``base_model_ep_plan``. The generic all-to-all transport takes over unchanged. +""" + +from ..specs import EPSpec, ModelSpec, register + +__all__: list[str] = [] + +register( + ModelSpec( + model_type="minimax_m3_vl", + min_transformers_version="5.13", + ep_spec=EPSpec(requires_transformers="5.13"), + ) +) diff --git a/modelopt/torch/models/nemotron_h/specs.py b/modelopt/torch/models/nemotron_h/specs.py index 7198c2b31b3..8356310948c 100644 --- a/modelopt/torch/models/nemotron_h/specs.py +++ b/modelopt/torch/models/nemotron_h/specs.py @@ -15,15 +15,26 @@ """Nemotron-H specs (HF model type ``nemotron_h``).""" -from ..specs import ExportSpec, ModelSpec, MoESpec, register +from ..specs import EPSpec, ExportSpec, ModelSpec, MoESpec, register __all__: list[str] = [] +# Ungated experts, so ``up_proj``/``down_proj`` rather than a gate/up pair. The gate emits logits and +# the MoE block runs its own group-limited top-k, so the plan has no router entry and the experts +# receive global expert ids -- which the expert-parallel all-to-all experts forward routes on. The +# loader maps ``nemotron_experts_ep``, a name no transformers style carries, to that forward. +BASE_MODEL_EP_PLAN = { + "layers.*.mixer.experts.up_proj": "grouped_gemm", + "layers.*.mixer.experts.down_proj": "grouped_gemm", + "layers.*.mixer.experts": "nemotron_experts_ep", +} + register( ModelSpec( model_type="nemotron_h", min_transformers_version="5.5", export_spec=ExportSpec(grouped_expert_export=True), + ep_spec=EPSpec(base_model_ep_plan=BASE_MODEL_EP_PLAN), moe_spec=MoESpec( # NemotronHMOE experts (NemotronHMLP) use up_proj and down_proj only (no gate). block_names=("NemotronHMOE",), diff --git a/modelopt/torch/models/nemotron_h_omni/__init__.py b/modelopt/torch/models/nemotron_h_omni/__init__.py new file mode 100644 index 00000000000..8e852a66f9d --- /dev/null +++ b/modelopt/torch/models/nemotron_h_omni/__init__.py @@ -0,0 +1,19 @@ +# 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. + + +"""NemotronH-Omni specs (HF model type ``nemotron_h_omni``).""" + +from . import specs diff --git a/modelopt/torch/models/nemotron_h_omni/specs.py b/modelopt/torch/models/nemotron_h_omni/specs.py new file mode 100644 index 00000000000..1f13446f986 --- /dev/null +++ b/modelopt/torch/models/nemotron_h_omni/specs.py @@ -0,0 +1,51 @@ +# 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. + + +"""NemotronH-Omni specs (HF model type ``nemotron_h_omni``). + +The Omni VLM's language sub-model is a native NemotronH, so its MoE facts are NemotronH's. +What differs is where the expert-parallel plan has to be attached: on the nested language-model +config, not the VLM root. transformers aggregates each sub-model's plan up to the root WITH its +module prefix, so a plan there surfaces as ``language_model..layers.*`` and matches the real +parameter paths -- while a plan on the top config reaches no expert at all. + +Only that plan is registered under the root type, so lookups of the MoE and export sections by +``nemotron_h_omni`` find neither. No export depends on them: on transformers 5 the language +model's experts are one fused container, which the export writes through its own handler, and +every step those lookups gate leaves a fused container as it is. +""" + +from ..nemotron_h.specs import BASE_MODEL_EP_PLAN +from ..specs import EPSpec, ModelSpec, register + +__all__: list[str] = [] + +register( + ModelSpec( + model_type="nemotron_h_omni", + # transformers ships the Omni VLM itself from 5.18. The checkpoint's own code + # (``trust_remote_code=True``) names the language-model config ``llm_config``, and + # transformers' port ``text_config``: the plan goes on whichever the config has, tried in + # the order the load reads them. + min_transformers_version="5.18", + ep_spec=EPSpec( + # Its own copy: the registry is process-wide, and a spec holding NemotronH's dict would + # carry any change to one plan into the other. + base_model_ep_plan=dict(BASE_MODEL_EP_PLAN), + plan_config_attr=("text_config", "llm_config"), + ), + ) +) diff --git a/modelopt/torch/models/qwen3_moe/specs.py b/modelopt/torch/models/qwen3_moe/specs.py index c8b9bcd3e5b..809bab41798 100644 --- a/modelopt/torch/models/qwen3_moe/specs.py +++ b/modelopt/torch/models/qwen3_moe/specs.py @@ -15,7 +15,7 @@ """Qwen3-MoE specs (HF model type ``qwen3_moe``).""" -from ..specs import ExportSpec, ModelSpec, MoESpec, register +from ..specs import EPSpec, ExportSpec, ModelSpec, MoESpec, register __all__: list[str] = [] @@ -23,6 +23,11 @@ ModelSpec( model_type="qwen3_moe", min_transformers_version="5.5", + ep_spec=EPSpec( + # No plan here: qwen3_moe's own base_model_ep_plan ships in transformers, and it is + # the architecture the all-to-all path was built around. Its plan lands in 5.7. + requires_transformers="5.7", + ), moe_spec=MoESpec( block_names=("Qwen3MoeSparseMoeBlock",), expert_linear_names=("gate_proj", "down_proj", "up_proj"), diff --git a/modelopt/torch/models/specs.py b/modelopt/torch/models/specs.py index 0a203780b7f..4599813e12a 100644 --- a/modelopt/torch/models/specs.py +++ b/modelopt/torch/models/specs.py @@ -44,9 +44,13 @@ from typing import TYPE_CHECKING, Literal, get_args, get_type_hints if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + import torch.nn as nn __all__ = [ + "EPSpec", "ExportSpec", "MoESpec", "ModelSpec", @@ -155,6 +159,78 @@ def expert_linear_names_for(self, module, fused: bool | None = None) -> tuple[st return self.expert_linear_names +@dataclass(kw_only=True) +class EPSpec(SpecSection): + """Subsystem section: per-model data of the expert-parallel (DP=EP) PTQ path. + + Calibrating a large MoE data-parallel with its experts sharded needs three things from + the model, and all three are facts about the architecture rather than about any run: + which modules are the routed experts and how they shard, which transformers release + first ships that information, and -- for a VLM -- which nested config carries it. + + Most models need only the data below. A checkpoint whose modeling code it ships itself + may need actual code run against it before it can be loaded expert-parallel (patching a + remote-code class, decompressing packed experts); that is what ``prepare`` is for, and it + is deliberately the exception rather than the interface. + """ + + base_model_ep_plan: dict[str, str] | None = None + """The ``base_model_ep_plan`` transformers needs to shard this model's experts, applied + to the config when it does not already carry one. + + Stock transformers ships a plan for some MoE models and not others; where it does, leave + this ``None`` and let the model's own plan win. Entries map a parameter glob to a + parallel style, e.g. ``{"layers.*.mlp.experts.gate_up_proj": "grouped_gemm"}``. + + A router entry is not always wanted. DeepSeek-V3's router is logits-only and its MoE + block computes global top-k ids, so the experts already receive unmasked global ids -- + which is exactly what the all-to-all dispatch needs, and adding a gate entry would + mask them.""" + + requires_transformers: str | None = None + """Earliest transformers release that supports THIS model expert-parallel. The expert-parallel + load's own floor holds for every model with an EP section; the later of the two applies. + + Distinct from ``ModelSpec.min_transformers_version``, which is about the modeling code + matching the spec. A model can be fully modelled and still not be shardable: qwen3_moe is + modelled from 5.5, but its own ``base_model_ep_plan`` lands in 5.7.""" + + plan_config_attr: str | tuple[str, ...] | None = None + """The nested config attribute carrying the expert-parallel plan, for a VLM whose MoE is + its text sub-model: ``"text_config"`` or ``"llm_config"``, the two the expert-parallel load + reads a plan from, or both for a model whose configs spell it either way, the first one a + config has taking the plan. ``None`` means the root config. + + The plan has to sit on whichever config describes the module tree the globs are written + against, so a plan on the wrong config silently matches nothing.""" + + prepare: "Callable[..., Any] | None" = None + """Escape hatch for a model that cannot be made expert-parallel by data alone. + + Called as ``prepare(hf_config, ckpt_path=..., trust_remote_code=...)`` and returns the + config to load. Reserved for checkpoints carrying their own modeling code, where the work is + patching classes or installing on-the-fly weight decompression. Runs after the declarative + fields above have been applied, so a ``prepare`` that returns another config than the one it + was handed must carry over the ``base_model_ep_plan`` they attached.""" + + prepared_ep_plan: dict[str, str] | None = None + """The ``base_model_ep_plan`` ``prepare`` attaches, for a model whose plan only ``prepare`` + supplies. + + Stated, not applied: ``prepare`` still attaches it. A run that rules expert parallelism out + skips ``prepare``, which patches the model's code, yet must still tell an expert-parallel MoE + it would hold whole on every rank from a dense model. It finds the routed experts through this + plan, in the model as the checkpoint's own code builds it: ``prepare`` replaces the experts in + place, so the plan names their containers there too.""" + + unsupported: str | None = None + """Why this model cannot be quantized expert-parallel yet, when its config would route it there. + + Stock transformers ships a ``base_model_ep_plan`` for some MoEs the expert-parallel path + cannot handle, and the plan alone makes a model expert-parallel capable. Naming the reason + here refuses the run before any weight is loaded instead of failing deep in quantization.""" + + @dataclass(kw_only=True) class ExportSpec(SpecSection): """Subsystem section: per-model data of the unified HF export path. @@ -233,6 +309,10 @@ class ModelSpec: """Per-model data of the unified HF export path, or ``None`` when export needs nothing model-specific.""" + ep_spec: EPSpec | None = None + """Per-model data of the expert-parallel PTQ path, or ``None`` when the model is dense + or needs nothing beyond what stock transformers already ships.""" + _SPECS: dict[str, ModelSpec] = {} diff --git a/tests/unit/torch/models/test_ep_specs.py b/tests/unit/torch/models/test_ep_specs.py new file mode 100644 index 00000000000..d58f4145c42 --- /dev/null +++ b/tests/unit/torch/models/test_ep_specs.py @@ -0,0 +1,61 @@ +# 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 expert-parallel section of the model specs.""" + +import pytest + +from modelopt.torch.models import get_spec, get_specs + +EP_MODEL_TYPES = [s.model_type for s in get_specs() if s.ep_spec is not None] + + +# --------------------------------------------------------------------------- the registered specs + + +@pytest.mark.parametrize("model_type", EP_MODEL_TYPES) +def test_every_ep_plan_is_well_formed(model_type): + """Globs map to style names; a malformed entry matches nothing and fails silently at load.""" + plan = get_spec(model_type).ep_spec.base_model_ep_plan + if plan is None: + return + assert plan, f"{model_type} declares an empty plan, which is not the same as declaring none" + for glob, style in plan.items(): + assert isinstance(glob, str) and glob, f"{model_type}: bad glob {glob!r}" + assert isinstance(style, str) and style, f"{model_type}: bad style for {glob!r}" + + +@pytest.mark.parametrize("model_type", EP_MODEL_TYPES) +def test_a_spec_declares_a_plan_a_floor_a_prepare_or_a_refusal(model_type): + """An EP section that says nothing would claim support the model does not have.""" + ep = get_spec(model_type).ep_spec + assert ep.base_model_ep_plan or ep.requires_transformers or ep.prepare or ep.unsupported, ( + f"{model_type} has an EP section that declares nothing" + ) + + +def test_the_omni_vlm_reuses_the_text_models_plan(): + """Two spellings of one architecture must not drift into two different plans, nor share one + dict, which a change through either spec would carry into the other.""" + omni = get_spec("nemotron_h_omni").ep_spec.base_model_ep_plan + text = get_spec("nemotron_h").ep_spec.base_model_ep_plan + assert omni == text + assert omni is not text + + +def test_the_omni_vlm_puts_its_plan_on_the_nested_config(): + """Its experts live in the language sub-model, and the globs are written against that tree.""" + assert get_spec("nemotron_h_omni").ep_spec.plan_config_attr == ("text_config", "llm_config") + assert get_spec("nemotron_h").ep_spec.plan_config_attr is None