Skip to content

Add direct hybrid architecture descriptors - #6295

Draft
Phlip79 wants to merge 1 commit into
NVIDIA:mainfrom
Phlip79:philip/direct-hybrid-architecture
Draft

Add direct hybrid architecture descriptors#6295
Phlip79 wants to merge 1 commit into
NVIDIA:mainfrom
Phlip79:philip/direct-hybrid-architecture

Conversation

@Phlip79

@Phlip79 Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

  • Add HybridLayerSpec, PipelineSplit, semantic layer tags, and the global resolver.
  • Validate and materialize direct definitions while keeping legacy lowering read-only.
  • Keep legacy HybridModel execution on its original parser and builder path.

PR 1 of the draft stack extracted from #6235.

@copy-pr-bot

copy-pr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

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.

Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/ok to test a38a1b6

@Phlip79

Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/claude strict-review

Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py Outdated
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py Outdated
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Strict Review Summary

Findings: CRITICAL: 2 · IMPORTANT: 7 · SUGGESTION: 2

hybrid_architecture.py is a well-structured extraction, and the direction (explicit descriptors instead of a symbol string, per-occurrence TransformerConfig, metainfo-carried semantic layer type) is a clear improvement over hybrid_layer_allocation.py. The new unit test file is thorough for the pattern/flatten/validation surface. Two defects, though, make the new path unusable in configurations the code claims to support, and both are invisible to the current tests.

Most impactful

1. quant_recipe uniformity check is an identity comparison (hybrid_architecture.py:551, CRITICAL Implementation)

_materialize_layer validates _UNIFORM_CONFIG_FIELDS with !=, but RecipeConfig (megatron/core/quantization/quant_config.py:131) is a plain class — not a dataclass — with no __eq__. The comparison degrades to identity, so the documented copy.deepcopy(base_config) per-layer workflow raises ValueError: ... changes model-wide field 'quant_recipe' for a byte-identical recipe. Every direct hybrid architecture becomes unconstructible whenever --quant-recipe is set. _config() in the new test leaves quant_recipe=None, so all 18 tests pass. Fix: move quant_recipe out of _UNIFORM_CONFIG_FIELDS and into the _materialize_config overwrite set (identity-propagated from base), or give RecipeConfig a real __eq__.

2. Per-layer configs carry a stale mtp_num_layers on the legacy path (hybrid_architecture.py:376, CRITICAL Correctness)

_resolve_legacy_architecture materializes segment configs (343-352) and MTP layer configs (363-369) before assigning config.mtp_num_layers = parsed.mtp_num_depths (376). Every resolved layer config therefore holds the pre-parse value (None in the common case). Three concrete consumers read it off the layer config: router.py:591-592 (wrong aux-loss denominator), router.py:582-584 (skipping the division silently double-counts load-balancing loss across MTP depths), and multi_token_prediction.py:875 (TypeError on float / None). Fix: hoist the parse/assign above line 343 and assert per-layer/architecture agreement in tests.

Also worth addressing before the stack lands

  • Resolver mutates the caller's config (:317) — config.virtual_pipeline_model_parallel_size is assigned as a side effect of validation, and it happens after arguments.py:755-835 has already derived VPP for rank assignment ("Must be set here in order to assign virtual parallel ranks"). Prefer returning the inferred value on ResolvedHybridArchitecture and letting the caller decide.
  • metric_num_layers double-counts (:88) — len(main_layers) + mtp_num_layers * len(mtp_layers) disagrees with the tracker's num_layers + mtp_num_layers indexing, and ignores mtp_use_repeated_layer (one shared layer, multi_token_prediction.py:1733-1740).
  • _validate_mtp_placement is narrower than its docstring (:570) — it only inspects segments[-1], so MTP layers attached to a non-final segment pass. The empty-final-PP-chunk allowance is also undocumented.
  • Inference-shape signature omits d_conv (:139-170) — d_conv is a ModuleSpec param (default 4, mamba_mixer.py:180), and conv_states_shape depends on it (mamba_mixer.py:1370). The mamba_num_heads is None -> expand * d_model path is also unmodeled.
  • _UNIFORM_CONFIG_FIELDS is a denylist with gaps (:476-530) — recompute_*, cuda_graph_*, calculate_per_token_loss, mtp_loss_scaling_factor, and variable_seq_lengths all silently permit per-layer divergence that downstream code reads stack-wide (e.g. hybrid_block.py:348). pipeline_model_parallel_size is listed twice (519, 534). An allowlist of genuinely per-layer fields would fail closed.
  • Unconditional per-occurrence deepcopy (:561-567) — N detached config snapshots that no longer track post-construction training-loop mutation (training.py:3566-3588, 3741, 4284), plus needless memory and startup cost when a layer uses the base config unchanged.
  • _split_pipe_free_legacy_layers forks uneven-PP logic (:429-473) — it reimplements select_pipeline_segment (hybrid_layer_allocation.py:405-444), disagrees on the middle_ranks == 0 leftover case, and drops the DEPRECATION warning at hybrid_layer_allocation.py:393-400. Two implementations of one layout rule will drift.

Risk assessment

Moderate for this PR in isolation; high for the direct-descriptor path once wired up. Nothing in megatron/core or megatron/training calls resolve_hybrid_architecture yet — grep finds only the module itself, the __init__.py re-exports, and the new test — so neither critical defect is reachable on main today. But finding 2 sits on the legacy code path that the follow-up PRs will route existing --hybrid-layer-pattern users through, and it degrades MoE aux-loss silently rather than failing loudly. Both criticals are small, local fixes; they plus regression tests belong in this PR rather than deferred, since the follow-up stack will build on these semantics.

No dtype, loss-scaling, or attention-math concerns — this PR contains no numerical kernels. No process-group concerns either: the module takes pp_rank/pp_size/vp_stage as parameters and reads no parallel_state globals, which is exactly the pattern CLAUDE.md asks for.

Comment thread megatron/core/models/hybrid/hybrid_architecture.py
Comment thread megatron/core/models/hybrid/hybrid_architecture.py Outdated
@Phlip79
Phlip79 force-pushed the philip/direct-hybrid-architecture branch from a38a1b6 to ab40c9f Compare August 5, 2026 22:44
@Phlip79

Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/ok to test ab40c9f

Signed-off-by: Philip Petrakian <ppetrakian@nvidia.com>
@Phlip79
Phlip79 force-pushed the philip/direct-hybrid-architecture branch from ab40c9f to f673468 Compare August 5, 2026 22:59
@Phlip79

Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/ok to test f673468

@Phlip79

Phlip79 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

/claude strict-review

+ ", ".join(active_conflicts)
)

config.virtual_pipeline_model_parallel_size = inferred_vp_size if inferred_vp_size > 1 else None

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.

[CRITICAL Correctness] A function named _validate_direct_pipeline mutates the caller's TransformerConfig to infer VPP degree, and it does so at a point in the lifecycle where that value can no longer take effect.

Ordering in pretrain(): initialize_megatroninitialize_model_parallel(..., args.virtual_pipeline_model_parallel_size) (creates the PP/VPP process groups, parallel_state.py:827) → setup_model_and_optimizerget_modelmodel_providerHybridModel.__init__ → (in the follow-up PR of this stack) resolve_hybrid_architecture. By the time this line runs:

  • get_model's chunk loop (megatron/training/training.py:1757-1780) has already branched on args.virtual_pipeline_model_parallel_size is not None. If the user only expressed VPP via PipelineSplit() nodes and left VPP unset, exactly one chunk gets built, while select_segment now believes there are pp_size * 2 segments — so half the model's layers are never instantiated and config.virtual_pipeline_model_parallel_size = 2 is then read by schedules.py:1517 / p2p_communication.py:161, producing a scheduler that disagrees with the built model.
  • The VPP process groups were sized from the pre-mutation value, so they can't be corrected here either.

Contrast the legacy path, which derives VPP at argument-parse time (megatron/training/arguments.py:948, "Only set VPP to None if it wasn't already derived from --hybrid-layer-pattern"). The direct path has no equivalent arg-time derivation.

Also note the mutation runs once per VPP chunk (the same config object is shared by every model_provider_func call), which makes the write order-dependent for no benefit.

Suggested fix: make this a pure validator that requires the config to already agree, and move derivation to argument/config-construction time where the legacy path does it:

    inferred_vp_size = num_segments // pp_size
    if pp_size == 1 and inferred_vp_size > 1:
        raise ValueError("Virtual pipeline parallelism requires pipeline_model_parallel_size > 1.")
    expected_vp_size = inferred_vp_size if inferred_vp_size > 1 else None
    if config.virtual_pipeline_model_parallel_size != expected_vp_size:
        raise ValueError(
            f"PipelineSplit() nodes imply virtual_pipeline_model_parallel_size="
            f"{expected_vp_size}, but the config specifies "
            f"{config.virtual_pipeline_model_parallel_size}. Set it before the process "
            f"groups and model chunks are created."
        )
    # no mutation here

If deriving-from-the-pattern is the intended UX, it needs a hook that runs before initialize_model_parallel, not inside the layer resolver.

Comment on lines +451 to +457
def _materialize_config(
layer_config: TransformerConfig, base_config: TransformerConfig
) -> TransformerConfig:
materialized = copy.deepcopy(layer_config)
for field_name in _TOPOLOGY_CONFIG_FIELDS:
setattr(materialized, field_name, getattr(base_config, field_name))
return materialized

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.

[CRITICAL Correctness] _materialize_config produces one deepcopy of TransformerConfig per layer occurrence, which permanently detaches every layer from the runtime mutations the training loop performs on the single shared config object. Several of those mutations are load-bearing:

Written at Field Read from self.config inside layers
training.py:3566-3588, 2152, 4284, 4300 timers, grad_scale_func, no_sync_func, grad_sync_func, param_sync_func, finalize_model_grads_func via get_model_config(model) — unwrapped model config, so still OK
transformer_block.py:310,319 _cpu_offloading_context tensor_parallel/layers.py:1157,1447 reads self.config._cpu_offloading_context on the layer's config
transformer/utils.py:367 (set_model_to_sequence_parallel, called from text_generation_controller.py:3480) sequence_parallel ~20 sites read self.config.sequence_parallel at forward time (multi_latent_attention.py:654,745,753, shared_experts.py:246,341,550, moe_layer.py:629, dot_product_attention.py:216, …)
transformer/utils.py:451 (toggle_cuda_graphs, called from rl_utils.py:1605,2243,2256) cuda_graph_impl attention.py:1449, transformer_layer.py:460, hybrid_block.py:207,308

Both toggle helpers write model.config.<field> — the model-level config — and then walk modules setting the attribute of the same name. Per-layer configs are neither the object written nor an attribute they set, so a layer that reads self.config.sequence_parallel / self.config.cuda_graph_impl sees the stale pre-toggle value. Concretely: MoE+TP inference prefill (text_generation_controller.py:3480) intends to disable SP everywhere except BaseMoELayer; with per-layer configs the non-MoE hybrid layers keep sequence_parallel=True and will scatter/gather on an unsharded sequence — silently wrong activations, not a crash. Similarly _cpu_offloading_context is written onto self.config by the block but read by ColumnParallelLinear off its config, so CPU offloading silently no-ops for offload-enabled hybrid runs.

There's also a deepcopy hazard: TransformerConfig holds non-value fields (quant_recipe: RecipeConfig — a plain class with no __eq__, init_method/activation_func callables, _cpu_offloading_context: ContextManager). Deep-copying these per layer both duplicates state that was meant to be shared and makes the != comparison in _materialize_layer (line 441) identity-based for quant_recipe, so two structurally identical recipes will spuriously raise.

Suggestion: don't deepcopy. Materialize by dataclasses.replace-style sharing of the mutable/runtime fields, or — cleaner — keep the base config as the single object and carry only the diff per occurrence (e.g. HybridLayerSpec(module_spec, overrides: dict)) that layers apply at build time. If deepcopy must stay, add an explicit allowlist of runtime-mutated fields that are re-pointed at the base config after copying (_cpu_offloading_context, sequence_parallel, cuda_graph_impl, timers, *_sync_func, grad_scale_func, quant_recipe) and document why.

Comment on lines +366 to +420
_UNIFORM_CONFIG_FIELDS = (
"hidden_size",
"params_dtype",
"pipeline_dtype",
"fp16",
"bf16",
"fp32_residual_connection",
"enable_autocast",
"autocast_dtype",
"apply_query_key_layer_scaling",
"attention_softmax_in_fp32",
"disable_bf16_reduced_precision_matmul",
"dsa_indexer_k_norm_fp32",
"fp8",
"fp8_recipe",
"fp8_param",
"fp8_quantizer_factory",
"fp8_margin",
"fp8_interval",
"fp8_amax_history_len",
"fp8_amax_compute_algo",
"fp8_wgrad",
"fp8_output_proj",
"fp8_dot_product_attention",
"fp8_multi_head_attention",
"tp_only_amax_red",
"activation_func_fp8_input_store",
"first_last_layers_bf16",
"num_layers_at_start_in_bf16",
"num_layers_at_end_in_bf16",
"moe_router_dtype",
"mamba_training_ssm_states_dtype",
"fp4",
"fp4_recipe",
"fp4_param",
"fp4_quantizer_factory",
"quant_recipe",
"normalization",
"layernorm_epsilon",
"layernorm_zero_centered_gamma",
"tensor_model_parallel_size",
"tensor_parallel_num_weight_shards",
"gtp_weight_remat_size",
"pipeline_model_parallel_size",
"pipeline_model_parallel_comm_backend",
"context_parallel_size",
"hierarchical_context_parallel_sizes",
"max_seqlen_per_dp_cp_rank",
"hybrid_context_parallel",
"expert_model_parallel_size",
"expert_tensor_parallel_size",
"expert_tensor_parallel_num_weight_shards",
"expert_gtp_weight_remat_size",
"sequence_parallel",
)

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.

[CRITICAL Correctness] _UNIFORM_CONFIG_FIELDS is a hand-maintained denylist over a config surface of 338 fields (259 in TransformerConfig + 79 in ModelParallelConfig). Anything not listed is silently accepted as a legal per-occurrence override, and several omissions are not benign:

  • multi_latent_attention — read at hybrid_model.py:247,482 off the model config to decide whether to build the standard RotaryEmbedding at all. An mla occurrence that flips it per-layer gets no decoupled RoPE while the model builds standard RoPE (or vice versa). This is exactly the mix that hybrid_layer_allocation.py:297 and :325 explicitly reject for the legacy path ("Not supported to have both Attention and MLA/DSA in one model") — the direct path has no equivalent check for attention + mla/dsa coexistence at all.
  • recompute_granularity / recompute_method / recompute_num_layers / recompute_modulesrecompute_num_layers is a whole-block count consumed by the block, not the layer; per-layer values are meaningless and will mis-slice recompute.
  • cpu_offloading / cpu_offloading_num_layers — same whole-block semantics; transformer_block.py:303 passes self.config.num_layers into get_cpu_offload_context.
  • cuda_graph_impl / cuda_graph_modules, deterministic_mode, gradient_accumulation_fusion, tp_comm_overlap, perform_initialization / use_cpu_initialization, moe_token_dispatcher_type, moe_grouped_gemm, batch_invariant_mode — all model-global in practice.
  • MLA/GDN shape fields are neither in the denylist nor in the inference-shape signatures: kv_lora_rank, qk_head_dim, v_head_dim, qk_pos_emb_head_dim (the last two feed dynamic_context.py:491's kv_reduced_dim), and linear_key_head_dim / linear_num_key_heads / linear_value_head_dim / linear_num_value_heads / linear_conv_kernel_dim for GDN state shapes.

Denylists over a 338-field surface will keep drifting as fields are added — nothing in CI notices. Please invert to an allowlist of the fields a per-occurrence override may legally change (mamba shape group, attention head/KV group, ffn_hidden_size, moe_ffn_hidden_size, moe_router_topk, MLA rank/head-dim group, GDN linear-attention group, window_size, softmax_scale, …) and reject everything else with the same error message. That makes a newly added field fail closed instead of open, and it removes the need to keep 54 unrelated names in sync. If an allowlist is too restrictive for the follow-up PRs, at minimum add a unit test that asserts set(dataclasses.fields(TransformerConfig)) - allowed - denied == set() so a new field forces a triage decision.

Comment on lines +84 to +88
@property
def metric_num_layers(self) -> int:
"""Return the number of explicit layer slots used by MoE metrics."""

return len(self.main_layers) + self.mtp_num_layers * len(self.mtp_layers)

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.

[IMPORTANT Correctness] metric_num_layers doesn't match what the MoE metrics tracker actually expects, so a direct-architecture run would allocate a mis-sized metric tensor.

Router._record_aux_loss (megatron/core/transformer/moe/router.py:590-593) computes the tensor size as:

num_layers = self.config.num_layers
if self.config.mtp_num_layers is not None:
    num_layers += self.config.mtp_num_layers

i.e. num_layers + mtp_num_layersnot num_layers + mtp_num_layers * len(mtp_layers). The same formula appears in MoEMetricsTracker.report (moe_logging.py:184: init_size = num_layers + (mtp_num_layers or 0)), and layer_number for MTP layers is self.layer_number + self.config.num_layers (router.py:596).

With mtp_num_layers=2 and a 2-layer MTP block, this property returns num_layers + 4 while the router allocates num_layers + 2 and indexes into it. Since record initializes on first use (moe_logging.py:122) and later .report() compares/reduces across ranks, a size disagreement between what this property reports and what the router allocates produces either an allreduce shape mismatch across PP ranks or a silently wrong per-layer aux-loss attribution.

Either match the router's formula, or — if the intent is genuinely "one slot per materialized MTP layer" — that's a change to the tracker's indexing convention and needs to land together with the router/tracker change, not ahead of it.

Also: this property has no test, and neither do has_heterogeneous_inference_shapes and has_incompatible_dynamic_inference_shapes. Given the 633-line test file covers the rest of the module thoroughly, these three look like an oversight rather than a deliberate gap.

Comment on lines +110 to +170
@property
def has_heterogeneous_inference_shapes(self) -> bool:
"""Whether dynamic inference would need non-uniform cache/buffer shapes."""

layers = self.main_layers + self.mtp_layers
signatures: dict[str, set[tuple[Any, ...]]] = {
"mamba": set(),
"attention": set(),
"moe": set(),
}
for layer in layers:
config = layer.config
if layer.layer_type == "mamba":
signatures["mamba"].add(
(
config.mamba_state_dim,
config.mamba_head_dim,
config.mamba_num_heads,
config.mamba_num_groups,
)
)
elif layer.layer_type == "attention":
signatures["attention"].add(
(config.num_query_groups or config.num_attention_heads, config.kv_channels)
)
elif layer.layer_type == "moe":
signatures["moe"].add((config.moe_router_topk,))
return any(len(values) > 1 for values in signatures.values())

def has_incompatible_dynamic_inference_shapes(self, base_config: TransformerConfig) -> bool:
"""Whether dynamic inference's model-global buffers fit every occurrence.

Dynamic inference currently allocates attention KV and MoE routing buffers
from the model-wide config. Even a family-uniform direct override is
incompatible when it differs from that allocation source. Mamba state
shapes are discovered from the built layer, so only differing Mamba
occurrences are rejected.
"""

if self.has_heterogeneous_inference_shapes:
return True

base_attention_signature = (
base_config.num_query_groups or base_config.num_attention_heads,
base_config.kv_channels,
)
layers = self.main_layers + self.mtp_layers
for layer in layers:
if layer.layer_type == "attention":
occurrence_signature = (
layer.config.num_query_groups or layer.config.num_attention_heads,
layer.config.kv_channels,
)
if occurrence_signature != base_attention_signature:
return True
elif (
layer.layer_type == "moe"
and layer.config.moe_router_topk != base_config.moe_router_topk
):
return True
return False

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.

[IMPORTANT Correctness] These two inference-shape predicates are declared but never called anywhere in the tree (verified by grep across megatron/ and tests/ — the only references are the export lists in __init__.py). Per the PR-stack framing that's presumably intentional for a later PR, but they encode assumptions that will be hard to correct once callers exist, and three of them look wrong today:

  1. gdn, mla, and dsa occurrences are invisible to both predicates. Only mamba, attention, and moe are inspected. GDN carries its own recurrent-state shape (linear_key_head_dim, linear_num_key_heads, linear_value_head_dim, linear_num_value_heads, linear_conv_kernel_dim), and MLA/DSA KV-cache sizing comes from kv_lora_rank + qk_pos_emb_head_dim (dynamic_context.py:491). A model that varies any of those across occurrences is reported as homogeneous and will get an undersized cache.

  2. The attention signature omits num_attention_heads. (num_query_groups or num_attention_heads, kv_channels) collapses two genuinely different layers: num_attention_heads=16, num_query_groups=4 and num_attention_heads=8, num_query_groups=4 produce the identical signature (4, kv_channels). KV cache sizing does only depend on num_query_groups, so this may be deliberate — if so, say so in the docstring, because the name "inference shapes" reads as covering the whole attention shape. Note also that num_query_groups is resolved to num_attention_heads in TransformerConfig.__post_init__ (transformer_config.py:1335), making the or fallback dead for any real config.

  3. has_incompatible_dynamic_inference_shapes short-circuits on has_heterogeneous_inference_shapes, which folds Mamba back in — contradicting its own docstring. The docstring says "Mamba state shapes are discovered from the built layer, so only differing Mamba occurrences are rejected," but line 149 returns True for any heterogeneity including Mamba, before the per-family base-config comparison runs. If Mamba heterogeneity is meant to be tolerated here, the check needs to be per-family rather than a blanket short-circuit.

Suggest either extending these to cover all six layer families and fixing the Mamba short-circuit, or deferring both properties to the PR that introduces the caller so the semantics can be reviewed against real use.

Comment on lines +300 to +315
if num_segments > 1:
conflicts = {
"pipeline_model_parallel_layout": config.pipeline_model_parallel_layout,
"num_layers_in_first_pipeline_stage": config.num_layers_in_first_pipeline_stage,
"num_layers_in_last_pipeline_stage": config.num_layers_in_last_pipeline_stage,
"account_for_embedding_in_pipeline_split": (
config.account_for_embedding_in_pipeline_split
),
"account_for_loss_in_pipeline_split": config.account_for_loss_in_pipeline_split,
}
active_conflicts = [name for name, value in conflicts.items() if value not in (None, False)]
if active_conflicts:
raise ValueError(
"PipelineSplit() already defines pipeline ownership and cannot be combined with: "
+ ", ".join(active_conflicts)
)

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.

[IMPORTANT Correctness] Two issues in the conflict detection:

  1. num_segments > 1 is the wrong guard. With pp_size=1 and a single segment, num_layers_in_first_pipeline_stage etc. are skipped entirely — but a user who sets them along with direct layer_specs has expressed a contradiction that still deserves an error. The natural guard is "direct layer_specs was supplied at all", not "there are splits". As written, pp_size=1 + num_layers_in_first_pipeline_stage=4 passes silently.

  2. value not in (None, False) misclassifies 0. 0 == False in Python, so 0 in (None, False) is True. If num_layers_in_first_pipeline_stage=0 is ever reachable it is silently treated as unset. transformer_config.py:2023-2024 rejects <= 0 for that field, so this is latent rather than live today — but the idiom is fragile and will bite the next boolean-vs-int field added to the dict. Prefer explicit per-field predicates:

        active_conflicts = [
            name
            for name, value in conflicts.items()
            if value is not None and value is not False
        ]

That distinguishes 0 from False and reads as the intended "explicitly set" test.

Comment on lines +460 to +471
def _validate_mtp_placement(architecture: ResolvedHybridArchitecture) -> None:
"""Reject a prediction block on a logical chunk with no decoder layers."""

if (
architecture.mtp_layers
and architecture.mtp_num_layers > 0
and not architecture.segments[-1]
):
raise ValueError(
"MTP must share the final logical PP/VPP chunk with at least one decoder layer; "
"standalone MTP placement is not supported."
)

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.

[IMPORTANT Correctness] _validate_mtp_placement only inspects segments[-1], but with VPP the last physical pipeline stage is not the last segment — and MTP is placed on the last physical stage.

Segment order is VPP-major (select_segment: vp_stage * pp_size + pp_rank), so for pp_size=2, vp_size=2 the segments are laid out [pp0/vp0, pp1/vp0, pp0/vp1, pp1/vp1]. segments[-1] is pp_rank=1, vp_stage=1, which happens to be the final chunk here — but the emptiness check that matters is on whichever chunk mtp_on_this_rank selects (hybrid_model.py:222-227, which uses is_pp_last_stageis_vp_last_stage). Those coincide only because the last segment is pp-last ∧ vp-last. The bug is the reverse case: an empty segments[-1] combined with a non-empty earlier chunk on the same physical rank is rejected, and a non-empty segments[-1] with MTP landing elsewhere is accepted. The check would be clearer and future-proof written in terms of the selection it's guarding:

def _validate_mtp_placement(architecture, pp_size, vp_size) -> None:
    last_chunk, _ = architecture.select_segment(
        pp_rank=pp_size - 1, pp_size=pp_size, vp_stage=None if vp_size == 1 else vp_size - 1
    )
    if architecture.mtp_layers and architecture.mtp_num_layers > 0 and not last_chunk:
        raise ValueError(...)

Separately: the architecture.mtp_num_layers > 0 term is unreachable-as-a-guard here — resolve_hybrid_architecture already raised at line 245 if raw_mtp_layers is non-empty with mtp_num_layers <= 0, so mtp_layers non-empty implies mtp_num_layers > 0. Harmless, but it suggests the invariant isn't as clear as it could be.

Comment on lines +238 to +243
raw_mtp_segments = (
flatten_hybrid_layer_pattern(mtp_layer_specs, allow_splits=False)
if mtp_layer_specs is not None
else ((),)
)
raw_mtp_layers = raw_mtp_segments[0]

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 Simplification] flatten_hybrid_layer_pattern with allow_splits=False can only ever return a 1-tuple, so the ((),) sentinel + [0] indexing is a two-step construction where one suffices. It also makes the invariant implicit — a reader has to reason about why raw_mtp_segments[0] is safe.

        raw_mtp_layers: tuple[HybridLayerSpec, ...] = ()
        if mtp_layer_specs is not None:
            (raw_mtp_layers,) = flatten_hybrid_layer_pattern(mtp_layer_specs, allow_splits=False)

The unpacking asserts the 1-segment invariant instead of assuming it.

Comment on lines +474 to +492
def _validate_uniform_expert_count(
architecture: ResolvedHybridArchitecture, base_config: TransformerConfig
) -> None:
expert_counts = {
layer.config.num_moe_experts
for layer in architecture.main_layers + architecture.mtp_layers
if layer.layer_type == "moe"
}
if len(expert_counts) > 1:
raise ValueError(
"All MoE occurrences must use one uniform num_moe_experts value; got "
f"{sorted(expert_counts)}."
)
if expert_counts and next(iter(expert_counts)) != base_config.num_moe_experts:
occurrence_count = next(iter(expert_counts))
raise ValueError(
"MoE occurrence num_moe_experts must match the model-wide config; "
f"got {occurrence_count!r} != {base_config.num_moe_experts!r}."
)

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 Simplification] The first branch is subsumed by the second: if every MoE occurrence must equal base_config.num_moe_experts, then a set with more than one element necessarily contains a non-matching value. The two-error structure gives a nicer message for the "layers disagree with each other" case, which is worth keeping — but it can be expressed without the double next(iter(...)):

def _validate_uniform_expert_count(architecture, base_config) -> None:
    expert_counts = {
        layer.config.num_moe_experts
        for layer in architecture.main_layers + architecture.mtp_layers
        if layer.layer_type == "moe"
    }
    if not expert_counts:
        return
    if expert_counts != {base_config.num_moe_experts}:
        raise ValueError(
            "Every MoE occurrence must use the model-wide num_moe_experts "
            f"({base_config.num_moe_experts!r}); got {sorted(expert_counts, key=repr)}."
        )

Note sorted(expert_counts) on line 485 will raise TypeError if any occurrence leaves num_moe_experts at its None default (transformer_config.py:232) alongside an int — reachable if someone tags a layer moe without setting experts. key=repr or a plain list avoids that.

Comment on lines +69 to +76
@dataclass(frozen=True, slots=True)
class ResolvedHybridArchitecture:
"""A validated global hybrid architecture shared by every PP/VPP chunk."""

segments: tuple[tuple[HybridLayerSpec, ...], ...]
mtp_layers: tuple[HybridLayerSpec, ...] = ()
mtp_num_layers: int = 0
source: str = "direct"

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 Naming] Two naming points on this dataclass:

  • mtp_num_layers is not a layer count — it's the number of MTP prediction depths. len(mtp_layers) is the layer count. The name collides with TransformerConfig.mtp_num_layers (which is also depths, so the confusion is pre-existing) and directly causes the ambiguity in metric_num_layers. mtp_num_depths — the name ParsedHybridPattern already uses for exactly this quantity (hybrid_layer_allocation.py:71) — would be consistent with the module it's sourced from and describe what the thing is.

  • source: str = "direct" is a two-valued discriminator typed as free-form str. Literal["direct", "legacy"] documents the domain and lets mypy catch a typo'd comparison; it's also the repo idiom for small closed sets (recompute_granularity, moe_token_dispatcher_type, cuda_graph_impl in transformer_config.py are all Literal).

Comment on lines +320 to +340
def _resolve_legacy_architecture(
config: TransformerConfig, hybrid_stack_spec: ModuleSpec, hybrid_layer_pattern: str | None
) -> ResolvedHybridArchitecture:
parsed = parse_hybrid_pattern(hybrid_layer_pattern)
submodules = hybrid_stack_spec.submodules
symbol_fields = {
Symbols.MAMBA: ("mamba", "mamba_layer"),
Symbols.GDN: ("gdn", "gdn_layer"),
Symbols.ATTENTION: ("attention", "attention_layer"),
Symbols.DS_ATTENTION: ("dsa", "dsa_layer"),
Symbols.MLA: ("mla", "mla_layer"),
Symbols.MLP: ("mlp", "mlp_layer"),
Symbols.MOE: ("moe", "moe_layer"),
}

def descriptor(symbol: str) -> HybridLayerSpec:
layer_type, field_name = symbol_fields[symbol]
return HybridLayerSpec(
module_spec=_legacy_module_spec(getattr(submodules, field_name), layer_type),
config=config,
)

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 Simplification] _resolve_legacy_architecture takes hybrid_stack_spec purely to read .submodules, and the symbol_fields map duplicates the mapping already implied by HybridStackSubmodules' field names (hybrid_block.py:45-51). Two small cleanups:

  • The tuple values ("mamba", "mamba_layer") are (layer_type, field_name) where field_name is always f"{layer_type}_layer". Either derive it (f"{layer_type}_layer") or keep only the layer type and let _legacy_module_spec do the getattr — the duplicated string pair is the kind of thing that drifts if a field is ever renamed.

  • descriptor() re-tags on every call, so a pattern like "MMMM" walks _with_layer_type four times for the same ModuleSpec. Since the stock specs now carry the tag already (hybrid_layer_specs.py), _with_layer_type hits the fast-path return at line 511 — but for an untagged custom stack it allocates a fresh copy.copy + dict per occurrence. Hoisting the tagging out of the per-symbol loop (tag each of the 7 fields once, then look up) is both cheaper and clearer.

Neither is behavior-affecting; flagging for the follow-up that makes this path load-bearing.

get_inference_optimized_moe_spec,
get_moe_module_spec,
)
from megatron.core.models.hybrid.hybrid_architecture import HYBRID_LAYER_TYPE

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 Simplification] This import creates a cycle in intent even though Python tolerates it today: hybrid_architecture imports from hybrid_layer_allocation, and hybrid_layer_specs now imports from hybrid_architecture just to get a string constant. The stock specs don't otherwise depend on the resolver.

HYBRID_LAYER_TYPE is a pure metadata key with no behavior attached. Putting it next to Symbols in hybrid_layer_allocation.py (which both modules already import) — or in a tiny leaf module — keeps the dependency edges one-directional and makes it obvious the constant is shared vocabulary rather than resolver-owned.

Also worth noting: the tag values ("mamba", "gdn", …) are duplicated as bare string literals across two stack specs plus VALID_HYBRID_LAYER_TYPES — 15 sites. A LayerType enum or module-level constants would turn a typo into a NameError instead of a validation failure at recipe-construction time.

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Strict Review — PR #6295 "Add direct hybrid architecture descriptors"

Reviewed the full diff (4 files, +1200/-0) plus surrounding context in hybrid_model.py, hybrid_block.py, hybrid_layer_allocation.py, transformer_config.py/model_parallel_config.py, training.py, arguments.py, moe/router.py, moe/moe_logging.py, and inference/contexts/dynamic_context.py. Skills loaded: mcore-migrate-gpt-to-hybrid, mcore-testing, mcore-linting-and-formatting.

Findings: CRITICAL 3 · IMPORTANT 4 · SUGGESTION 5

Most impactful

1. _validate_direct_pipeline mutates config.virtual_pipeline_model_parallel_size too late to take effect (hybrid_architecture.py:317)

This is the one I would block on. The write lands inside HybridModel.__init__, which runs after initialize_model_parallel sized the VPP process groups and after the get_model chunk loop (training.py:1757) already branched on args.virtual_pipeline_model_parallel_size is not None. A user who expresses VPP only through PipelineSplit() nodes gets one model chunk built while select_segment and the 1F1B scheduler both believe there are pp_size × vp_size segments — half the layers never instantiated, scheduler disagreeing with the model. The legacy path avoids this by deriving VPP at argument-parse time (arguments.py:948). Recommend making this a pure validator and moving derivation upstream.

2. Per-layer deepcopy of TransformerConfig detaches layers from runtime config mutation (hybrid_architecture.py:451-457)

_materialize_config gives every occurrence its own config object. Four runtime writers target the shared object and are silently lost: _cpu_offloading_context (written transformer_block.py:310, read tensor_parallel/layers.py:1157,1447 off the layer's config → CPU offloading no-ops), sequence_parallel via set_model_to_sequence_parallel (utils.py:367, called from text_generation_controller.py:3480 for MoE+TP prefill → non-MoE hybrid layers keep SP on and scatter an unsharded sequence: wrong activations, no crash), cuda_graph_impl via toggle_cuda_graphs (rl_utils.py:1605,2243), and the optimizer/timer callables. Deepcopy also duplicates quant_recipe (a RecipeConfig with no __eq__, so the != check at line 441 is identity-based and will spuriously reject structurally-identical recipes).

3. _UNIFORM_CONFIG_FIELDS is a 54-entry denylist over a 338-field config surface (hybrid_architecture.py:366-420)

Fails open. Notable omissions: multi_latent_attention (read at hybrid_model.py:247,482 to decide whether standard RoPE is built at all), the four recompute_* fields, cpu_offloading{,_num_layers}, cuda_graph_impl, deterministic_mode, tp_comm_overlap, moe_token_dispatcher_type. Also, the direct path has no equivalent of the legacy attention + mla/dsa rejection (hybrid_layer_allocation.py:297,325). Inverting to an allowlist makes newly added fields fail closed.

Also flagged

  • metric_num_layers uses mtp_num_layers * len(mtp_layers); the MoE metrics tracker and Router._record_aux_loss both use num_layers + mtp_num_layers (router.py:590-593, moe_logging.py:184) → mis-sized metric tensor / cross-rank shape mismatch.
  • has_heterogeneous_inference_shapes / has_incompatible_dynamic_inference_shapes ignore gdn, mla, and dsa shape fields entirely; the second contradicts its own docstring about tolerating Mamba heterogeneity (line 149 short-circuits on it).
  • _validate_mtp_placement checks segments[-1] rather than the chunk mtp_on_this_rank actually selects (hybrid_model.py:222); correct only incidentally under VPP-major ordering.
  • Conflict detection guarded on num_segments > 1 skips pp_size=1, and value not in (None, False) treats 0 as unset.
  • Suggestions: mtp_num_layersmtp_num_depths (it is depths, not layers — ParsedHybridPattern already uses that name); source: Literal["direct","legacy"]; simplify the ((),) MTP sentinel and _validate_uniform_expert_count; move HYBRID_LAYER_TYPE to a leaf module to keep hybrid_layer_specshybrid_architecture from being a dependency inversion; consider a LayerType enum for the 15 duplicated tag literals.

Verification notes

Confirmed by grep that all 54 _UNIFORM_CONFIG_FIELDS and all 11 _TOPOLOGY_CONFIG_FIELDS names resolve to real fields on TransformerConfig/ModelParallelConfig — no typos. Confirmed the new public API has no in-tree consumers yet beyond __init__.py re-exports, consistent with the PR-1-of-a-stack framing. No lines exceed the 100-char limit. I did not execute the unit tests (torch.distributed.run GPU harness unavailable here), so the 633-line test file is reviewed by reading only. metric_num_layers, has_heterogeneous_inference_shapes, and has_incompatible_dynamic_inference_shapes have no test coverage.

Risk assessment: Medium-High

Nothing here is wired into a live training path yet, which caps the blast radius of merging as-is — and the legacy path is genuinely left read-only, which the tests verify well (test_legacy_summary_does_not_infer_or_mutate_vpp, ..._does_not_add_a_layer_count_validation, ..._preserves_shared_config_identity are the right guards). The concern is that findings 1-3 are interface decisions: the VPP mutation point, the per-layer config ownership model, and denylist-vs-allowlist all get much more expensive to change once the follow-up PRs build on them. Worth resolving in this PR rather than later in the stack.

Findings 1, 2, and 3 should be addressed before this becomes load-bearing. The remaining items are ordinary follow-ups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant