Skip to content

Commit 4bbddb1

Browse files
committed
create an explicit config-contract with optional list of moe experts per layer
1 parent cacd727 commit 4bbddb1

3 files changed

Lines changed: 182 additions & 26 deletions

File tree

src/megatron/bridge/models/conversion/model_bridge.py

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -259,18 +259,83 @@ def _install_pg_collection_on_mappings(
259259
mapping.set_process_groups_from_pg_collection(pg_collection)
260260

261261

262+
def _resolve_num_local_experts(config: TransformerConfig, param_name: str, ep_size: int) -> int:
263+
"""Resolve the per-EP-rank expert count for an expert parameter.
264+
265+
Expert numbering during conversion must match how the model was built, so it
266+
is driven entirely by the model config — never by traversing an instantiated
267+
module. This keeps name mapping independent of runtime layout (PP/VPP, MTP,
268+
custom MoE wrappers) and of whether a live model is available.
269+
270+
The expert layout is an explicit config contract:
271+
272+
* Homogeneous models leave ``num_moe_experts_per_layer`` unset and every MoE
273+
layer uses the scalar ``config.num_moe_experts``.
274+
* Heterogeneous models set ``config.num_moe_experts_per_layer`` to a list of
275+
global expert counts indexed by global decoder-layer number (``0`` for
276+
dense layers).
277+
278+
Args:
279+
config: The Megatron model config (authoritative expert layout).
280+
param_name: Expert parameter name, already in *global* layer numbering
281+
(the PP branch of :func:`_megatron_local_name_to_global` runs first).
282+
ep_size: Expert-parallel group size.
283+
284+
Returns:
285+
The number of experts owned by each expert-parallel rank for the layer
286+
that ``param_name`` belongs to.
287+
288+
Raises:
289+
ValueError: If the configured layout is missing or inconsistent with the
290+
parameter being renumbered (unset scalar, per-layer list too short,
291+
an expert parameter on a layer declared dense, or a global count not
292+
divisible by ``ep_size``).
293+
"""
294+
per_layer = getattr(config, "num_moe_experts_per_layer", None)
295+
296+
# Multi-token-prediction layers (``mtp.layers.N``) are not decoder layers and
297+
# are not covered by the per-decoder-layer contract; match only decoder
298+
# layers so MTP experts fall through to the scalar count.
299+
layer_match = re.search(r"(?:^|\.)decoder\.layers\.(\d+)(?=\.)", param_name)
300+
301+
if per_layer is not None and layer_match is not None:
302+
global_layer_idx = int(layer_match.group(1))
303+
if global_layer_idx >= len(per_layer):
304+
raise ValueError(
305+
f"num_moe_experts_per_layer has length {len(per_layer)} but expert parameter "
306+
f"{param_name!r} resolves to global layer {global_layer_idx}; the list must "
307+
"cover every decoder layer (length == num_layers)."
308+
)
309+
num_global_experts = per_layer[global_layer_idx]
310+
if not num_global_experts:
311+
raise ValueError(
312+
f"num_moe_experts_per_layer[{global_layer_idx}] == {num_global_experts}, but "
313+
f"expert parameter {param_name!r} exists on that layer; layers declared dense "
314+
"must not carry expert parameters."
315+
)
316+
else:
317+
num_global_experts = config.num_moe_experts
318+
319+
if num_global_experts is None:
320+
raise ValueError(
321+
f"Cannot renumber expert parameter {param_name!r}: num_moe_experts is None and no "
322+
"per-layer expert layout (num_moe_experts_per_layer) is configured."
323+
)
324+
if num_global_experts % ep_size != 0:
325+
raise ValueError(
326+
f"Global expert count {num_global_experts} for {param_name!r} is not divisible by "
327+
f"expert-parallel size {ep_size}."
328+
)
329+
return num_global_experts // ep_size
330+
331+
262332
def _megatron_local_name_to_global(
263333
models: MegatronModule | List[MegatronModule],
264334
config: TransformerConfig,
265335
param_name: str,
266336
vp_stage: Optional[int] = None,
267337
) -> str:
268338
"""Adjust layer number and expert number from local to global numbering."""
269-
# ``layer_module`` is resolved lazily and shared between the PP and EP
270-
# branches. It stays ``None`` on the EP-only path (PP=1, EP>1) until the EP
271-
# branch resolves it, so it must never be read before being assigned.
272-
layer_module = None
273-
274339
# PP
275340
pp_group = _get_pp_group(models)
276341
if "layers." in param_name and get_pg_size(pp_group) > 1:
@@ -295,25 +360,13 @@ def _megatron_local_name_to_global(
295360
is_expert_param = (is_grouped_expert_param or is_local_expert_param) and ".adapter." not in param_name
296361
ep_group = _get_ep_group(models) if is_expert_param else None
297362
if is_expert_param and ep_group is not None and get_pg_size(ep_group) > 1:
298-
# Resolve the layer module independently of the PP branch above: on the
299-
# EP-only path (PP=1, EP>1) that branch never runs, so ``layer_module``
300-
# is still ``None`` here. Prefer the per-layer module count
301-
# (heterogeneous-safe); fall back to the config-derived count when the
302-
# model isn't available (e.g. name-only conversions in tests).
303-
if layer_module is None:
304-
layer_match = re.match(r"^(.+?\.layers\.\d+)", param_name)
305-
if models is not None and layer_match is not None:
306-
try:
307-
_, layer_module = get_module_and_param_from_name(
308-
models=models, param_name=layer_match.group(1), vp_stage=vp_stage
309-
)
310-
except (ValueError, AttributeError):
311-
layer_module = None
312-
313-
if isinstance(layer_module, MegatronModule):
314-
num_experts_per_rank = layer_module.mlp.num_local_experts # per-layer, heterogeneous-safe
315-
else:
316-
num_experts_per_rank = config.num_moe_experts // get_pg_size(ep_group)
363+
# Expert numbering follows the explicit config contract (scalar
364+
# ``num_moe_experts`` for homogeneous models, ``num_moe_experts_per_layer``
365+
# for heterogeneous ones). ``param_name`` is already in global layer
366+
# numbering because the PP branch above runs first. This is intentionally
367+
# independent of the instantiated module so conversion does not depend on
368+
# runtime layout (PP/VPP, MTP, custom MoE wrappers) or on a live model.
369+
num_experts_per_rank = _resolve_num_local_experts(config, param_name, get_pg_size(ep_group))
317370

318371
def _update_grouped_expert_number(param_name: str, param_type: str) -> str:
319372
"""Update expert number from local to global for weight or bias parameters."""

src/megatron/bridge/models/gpt_provider.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ class GPTModelProvider(TransformerConfig, ModelProviderMixin[MCoreGPTModel]):
181181

182182
# MoE / FP8
183183
num_moe_experts: Optional[int] = None
184+
num_moe_experts_per_layer: Optional[list[int]] = None
185+
"""Explicit per-layer expert layout for heterogeneous MoE models.
186+
187+
``None`` (the default) means the model is homogeneous and every MoE layer
188+
uses the scalar :attr:`num_moe_experts`. When set, it must be a list of
189+
length ``num_layers`` giving the *global* number of routed experts for each
190+
decoder layer, indexed by global layer number; use ``0`` for dense (non-MoE)
191+
layers, and every non-zero entry must be divisible by the expert-parallel
192+
size.
193+
194+
This is the authoritative, config-level contract for expert layout: it is
195+
the layout the model is built from and the layout checkpoint conversion
196+
(HF <-> Megatron) uses to renumber experts across expert-parallel ranks.
197+
Conversion therefore never inspects an instantiated module to recover
198+
per-layer expert counts, keeping name mapping decoupled from runtime layout
199+
(PP/VPP, MTP, custom MoE wrappers) and from whether a live model exists.
200+
Multi-token-prediction layers (``mtp.layers.*``) are not decoder layers and
201+
are not covered by this list; they follow the scalar :attr:`num_moe_experts`.
202+
"""
184203
moe_grouped_gemm: bool = False
185204
qk_layernorm: bool = False
186205
fp8: Optional[str] = None

tests/unit_tests/models/test_quant_mapping.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,9 @@ def test_regex_matches_expert_bias_indices(self, name):
856856
assert re.search(r"\.bias\d+$", name) is not None
857857
assert re.search(r"\.weight\d+$", name) is None
858858

859-
def _call_local_to_global(self, param_name, num_moe_experts=4, ep_size=2, ep_rank=0):
859+
def _call_local_to_global(
860+
self, param_name, num_moe_experts=4, ep_size=2, ep_rank=0, num_moe_experts_per_layer=None
861+
):
860862
"""Invoke the real `_megatron_local_name_to_global` with parallel_state mocked.
861863
862864
Mocks ``parallel_state.get_expert_model_parallel_group`` and
@@ -870,7 +872,7 @@ def _call_local_to_global(self, param_name, num_moe_experts=4, ep_size=2, ep_ran
870872
ep_group.rank.return_value = ep_rank
871873
pp_group = MagicMock()
872874

873-
config = SimpleNamespace(num_moe_experts=num_moe_experts)
875+
config = SimpleNamespace(num_moe_experts=num_moe_experts, num_moe_experts_per_layer=num_moe_experts_per_layer)
874876

875877
def _pg_size(group):
876878
if group is ep_group:
@@ -911,3 +913,85 @@ def test_expert_bias_renumbered_under_ep(self):
911913
ep_rank=1,
912914
)
913915
assert out == "decoder.layers.0.mlp.experts.linear_fc1.bias3"
916+
917+
918+
class TestHeterogeneousExpertLayout(TestEPRenumberRegexSkipsAmax):
919+
"""The per-layer expert-count contract (`num_moe_experts_per_layer`).
920+
921+
Conversion must read the expert layout from the model config, not from an
922+
instantiated module. Homogeneous models keep using the scalar
923+
``num_moe_experts``; heterogeneous models declare a per-decoder-layer list of
924+
global expert counts and conversion renumbers each layer with its own count.
925+
"""
926+
927+
def test_homogeneous_falls_back_to_scalar(self):
928+
"""With the per-layer list unset, every layer uses the scalar count."""
929+
out = self._call_local_to_global(
930+
"decoder.layers.3.mlp.experts.linear_fc1.weight0",
931+
num_moe_experts=4,
932+
ep_size=2,
933+
ep_rank=1,
934+
num_moe_experts_per_layer=None,
935+
)
936+
assert out == "decoder.layers.3.mlp.experts.linear_fc1.weight2"
937+
938+
def test_heterogeneous_uses_per_layer_count(self):
939+
"""Different layers renumber with their own global expert count.
940+
941+
Layer 1 has 8 experts (4 per rank on EP=2); layer 2 has 4 experts (2 per
942+
rank). The same local ``weight0`` therefore maps to different globals.
943+
"""
944+
per_layer = [0, 8, 4, 8] # layer 0 dense, others MoE with differing counts
945+
out_layer1 = self._call_local_to_global(
946+
"decoder.layers.1.mlp.experts.linear_fc1.weight0",
947+
ep_size=2,
948+
ep_rank=1,
949+
num_moe_experts_per_layer=per_layer,
950+
)
951+
assert out_layer1 == "decoder.layers.1.mlp.experts.linear_fc1.weight4"
952+
953+
out_layer2 = self._call_local_to_global(
954+
"decoder.layers.2.mlp.experts.linear_fc1.weight0",
955+
ep_size=2,
956+
ep_rank=1,
957+
num_moe_experts_per_layer=per_layer,
958+
)
959+
assert out_layer2 == "decoder.layers.2.mlp.experts.linear_fc1.weight2"
960+
961+
def test_mtp_layer_uses_scalar_not_per_layer_list(self):
962+
"""MTP layers are outside the per-decoder-layer contract; they use the scalar."""
963+
out = self._call_local_to_global(
964+
"mtp.layers.0.transformer_layer.mlp.experts.linear_fc1.weight0",
965+
num_moe_experts=4,
966+
ep_size=2,
967+
ep_rank=1,
968+
num_moe_experts_per_layer=[8, 8],
969+
)
970+
assert out == "mtp.layers.0.transformer_layer.mlp.experts.linear_fc1.weight2"
971+
972+
def test_per_layer_list_too_short_raises(self):
973+
"""A list that does not cover the resolved layer is a hard error."""
974+
with pytest.raises(ValueError, match="must cover every decoder layer"):
975+
self._call_local_to_global(
976+
"decoder.layers.5.mlp.experts.linear_fc1.weight0",
977+
ep_size=2,
978+
num_moe_experts_per_layer=[8, 8],
979+
)
980+
981+
def test_expert_param_on_dense_layer_raises(self):
982+
"""An expert parameter on a layer declared dense (count 0) is a hard error."""
983+
with pytest.raises(ValueError, match="declared dense"):
984+
self._call_local_to_global(
985+
"decoder.layers.0.mlp.experts.linear_fc1.weight0",
986+
ep_size=2,
987+
num_moe_experts_per_layer=[0, 8],
988+
)
989+
990+
def test_count_not_divisible_by_ep_raises(self):
991+
"""A global expert count not divisible by EP size is a hard error."""
992+
with pytest.raises(ValueError, match="not divisible by expert-parallel size"):
993+
self._call_local_to_global(
994+
"decoder.layers.1.mlp.experts.linear_fc1.weight0",
995+
ep_size=4,
996+
num_moe_experts_per_layer=[0, 6],
997+
)

0 commit comments

Comments
 (0)