diff --git a/examples/configs/grpo_math_1B_megatron.yaml b/examples/configs/grpo_math_1B_megatron.yaml index 15700751845..1a45b4c265b 100644 --- a/examples/configs/grpo_math_1B_megatron.yaml +++ b/examples/configs/grpo_math_1B_megatron.yaml @@ -116,7 +116,14 @@ policy: # Use ["moe"] for MoE models to recompute expert activations only. recompute_modules: null tensor_model_parallel_size: 1 + # Generalized Tensor Parallelism (GTP): total shards per weight across the TP + # and GTP axes. Must be a multiple of tensor_model_parallel_size; the quotient + # is the GTP degree, an extra dim-0 weight sharding taken out of the DP axis + # and all-gathered on demand. null (or equal to TP) leaves GTP off. + tensor_parallel_num_weight_shards: null expert_tensor_parallel_size: 1 + # Expert-side counterpart, relative to expert_tensor_parallel_size. + expert_tensor_parallel_num_weight_shards: null expert_model_parallel_size: 1 pipeline_model_parallel_size: 1 num_layers_in_first_pipeline_stage: null diff --git a/nemo_rl/models/generation/megatron/config.py b/nemo_rl/models/generation/megatron/config.py index 179fc3367df..fd177275aa0 100644 --- a/nemo_rl/models/generation/megatron/config.py +++ b/nemo_rl/models/generation/megatron/config.py @@ -117,6 +117,17 @@ def dedicated_inference_megatron_cfg( """ inference_mcfg = merged_inference_megatron_cfg(policy_config) inference_mcfg["context_parallel_size"] = 1 + # Inference never uses GTP either: refit reassembles the training model's + # extra dim-0 weight shards into whole inference weights, and rematerializing + # them on every forward would be pure overhead. Pin the inference weight-shard + # counts to its own TP degrees so a GTP-sharded training model always takes + # the dedicated-inference-model reshard path. + inference_mcfg["tensor_parallel_num_weight_shards"] = inference_mcfg[ + "tensor_model_parallel_size" + ] + inference_mcfg["expert_tensor_parallel_num_weight_shards"] = inference_mcfg[ + "expert_tensor_parallel_size" + ] train_mcfg = cast(dict[str, Any], policy_config["megatron_cfg"]) layout_keys = ( @@ -125,8 +136,24 @@ def dedicated_inference_megatron_cfg( "expert_model_parallel_size", "expert_tensor_parallel_size", "context_parallel_size", + "tensor_parallel_num_weight_shards", + "expert_tensor_parallel_num_weight_shards", ) - layout_differs = any(inference_mcfg[k] != train_mcfg[k] for k in layout_keys) + # A null/absent weight-shard count means "no GTP", i.e. exactly the TP + # degree. Normalize before comparing so `null` and an explicit TP-equal + # value are not mistaken for a layout change. + normalized_train = { + **train_mcfg, + "tensor_parallel_num_weight_shards": ( + train_mcfg.get("tensor_parallel_num_weight_shards") + or train_mcfg["tensor_model_parallel_size"] + ), + "expert_tensor_parallel_num_weight_shards": ( + train_mcfg.get("expert_tensor_parallel_num_weight_shards") + or train_mcfg["expert_tensor_parallel_size"] + ), + } + layout_differs = any(inference_mcfg[k] != normalized_train[k] for k in layout_keys) impl_differs = inference_mcfg.get("transformer_impl") != train_mcfg.get( "transformer_impl" ) diff --git a/nemo_rl/models/megatron/setup.py b/nemo_rl/models/megatron/setup.py index 399633cea52..455fe583fdd 100644 --- a/nemo_rl/models/megatron/setup.py +++ b/nemo_rl/models/megatron/setup.py @@ -13,13 +13,16 @@ # limitations under the License. import copy +import functools import hashlib +import inspect import json import os import threading import time import warnings from collections.abc import Mapping +from contextlib import contextmanager from dataclasses import fields, is_dataclass, replace from typing import Any, Callable, Optional, TypeVar @@ -62,6 +65,9 @@ from megatron.bridge.utils.vocab_utils import calculate_padded_vocab_size from megatron.core import parallel_state from megatron.core.inference.shards import build_inference_pg_collection +from megatron.core.model_parallel_config import ( + resolve_tensor_parallel_weight_shards, +) from megatron.core.process_groups_config import ProcessGroupCollection from megatron.core.transformer import MegatronModule from megatron.core.transformer.enums import AttnBackend, InferenceCudaGraphScope @@ -684,6 +690,9 @@ def setup_model_config( # Apply MoE settings _apply_moe_config(model_cfg, config) + # Apply GTP settings (after MoE, which finalizes expert_tensor_parallel_size) + _apply_gtp_config(model_cfg, config) + # Apply MTP settings _apply_mtp_config(model_cfg, config) @@ -894,6 +903,125 @@ def _apply_parallelism_config(model_cfg: Any, config: PolicyConfig) -> None: ) +def _apply_gtp_config(model_cfg: Any, config: PolicyConfig) -> None: + """Apply Generalized Tensor Parallelism (GTP) weight-shard configuration. + + ``tensor_parallel_num_weight_shards`` is MCore's user-facing knob: the total + number of shards a weight is split into across the TP and GTP axes. GTP is + the quotient ``tensor_parallel_num_weight_shards // tensor_model_parallel_size`` + and shards each weight further along dim 0, rematerializing it on demand. + + The provider dataclass was already constructed by the time NeMo-RL applies + its settings, so ``ModelParallelConfig.__post_init__`` has already run and + will not re-derive the internal ``gtp_weight_remat_size``. Derive it here + with MCore's own reconciliation helper so the two stay consistent. + """ + num_weight_shards = config["megatron_cfg"].get( + "tensor_parallel_num_weight_shards", None + ) + expert_num_weight_shards = config["megatron_cfg"].get( + "expert_tensor_parallel_num_weight_shards", None + ) + if num_weight_shards is None and expert_num_weight_shards is None: + return + + ( + model_cfg.tensor_parallel_num_weight_shards, + model_cfg.gtp_weight_remat_size, + ) = resolve_tensor_parallel_weight_shards( + model_cfg.tensor_model_parallel_size, + num_weight_shards, + model_cfg.gtp_weight_remat_size, + ) + ( + model_cfg.expert_tensor_parallel_num_weight_shards, + model_cfg.expert_gtp_weight_remat_size, + ) = resolve_tensor_parallel_weight_shards( + model_cfg.expert_tensor_parallel_size, + expert_num_weight_shards, + model_cfg.expert_gtp_weight_remat_size, + shards_field="expert_tensor_parallel_num_weight_shards", + tp_field="expert_tensor_parallel_size", + ) + + +@contextmanager +def _gtp_process_groups(model_cfg: Any): + """Make Megatron-Bridge create the GTP rematerialization process groups. + + Bridge's ``initialize_megatron`` calls + ``parallel_state.initialize_model_parallel`` without ``gtp_remat_size`` / + ``expert_gtp_remat_size``, so those groups are never created and + ``gtp_weight_remat_size`` on the model config silently has no effect (MCore + layers only wrap their weights for GTP when the group exists and has size + > 1). Bridge resolves the function off the ``parallel_state`` module at call + time, so temporarily wrapping the module attribute is enough to forward the + sizes. No-op unless GTP is actually requested. + + TODO: drop this once Megatron-Bridge forwards the GTP sizes itself. + """ + gtp_remat_size = getattr(model_cfg, "gtp_weight_remat_size", 1) + expert_gtp_remat_size = getattr(model_cfg, "expert_gtp_weight_remat_size", 1) + if gtp_remat_size <= 1 and expert_gtp_remat_size <= 1: + yield + return + + original = parallel_state.initialize_model_parallel + accepted = inspect.signature(original).parameters + missing = [ + name + for name in ("gtp_remat_size", "expert_gtp_remat_size") + if name not in accepted + ] + if missing: + raise RuntimeError( + "GTP was requested via megatron_cfg.tensor_parallel_num_weight_shards " + f"(gtp={gtp_remat_size}, expert_gtp={expert_gtp_remat_size}), but the " + f"pinned Megatron-LM's initialize_model_parallel does not accept {missing}. " + "Bump Megatron-LM to a revision with GTP support." + ) + + # Accepting the kwargs is not the same as being able to use GTP: MCore's GTP + # core requires a newer TransformerEngine than it requires overall, and when + # that check fails it degrades to import stubs rather than raising. Nothing + # downstream re-checks -- MCore's own layer constructors gate GTP on the + # process-group size alone -- so without this the run dies ~6 frames deep in + # generalized_tensor_parallelism.py on `isinstance() arg 2 must be a type`, + # which says nothing about TE. Fail here with the actual requirement instead. + # Local import: gtp_api does not exist on Megatron-LM revisions predating GTP, + # and setup.py must keep importing against those. + from megatron.core.tensor_parallel import gtp_api + + if not gtp_api.HAVE_GTP: + raise RuntimeError( + "GTP was requested via megatron_cfg.tensor_parallel_num_weight_shards " + f"(gtp={gtp_remat_size}, expert_gtp={expert_gtp_remat_size}), but the " + "installed Megatron-LM reports GTP unavailable (HAVE_GTP=False). This is " + "almost always TransformerEngine being too old -- MCore's GTP core " + "requires a newer TE than MCore itself does. Check " + "megatron.core.tensor_parallel.generalized_tensor_parallelism for the " + "minimum version, or leave tensor_parallel_num_weight_shards unset/null " + "to run without GTP." + ) + + @functools.wraps(original) + def initialize_model_parallel_with_gtp(*args: Any, **kwargs: Any) -> Any: + kwargs.setdefault("gtp_remat_size", gtp_remat_size) + kwargs.setdefault("expert_gtp_remat_size", expert_gtp_remat_size) + return original(*args, **kwargs) + + parallel_state.initialize_model_parallel = initialize_model_parallel_with_gtp + print( + f"[gtp] enabling GTP weight rematerialization (gtp_remat_size={gtp_remat_size} " + f"expert_gtp_remat_size={expert_gtp_remat_size})", + flush=True, + ) + try: + yield + finally: + parallel_state.initialize_model_parallel = original + + def _apply_moe_config(model_cfg: Any, config: PolicyConfig) -> None: """Apply Mixture of Experts configuration.""" model_cfg.expert_tensor_parallel_size = config["megatron_cfg"][ @@ -1602,6 +1730,25 @@ def build_inference_model( inference_provider.sequence_parallel and inference_provider.tensor_model_parallel_size > 1 ) + # GTP shards weights to save training memory and rematerializes them on every + # forward/backward — pure overhead for inference. Refit is what maps the + # training model's GTP shards onto these whole weights, so force the layout + # off here rather than inheriting it from the training provider snapshot. + # + # Forcing it off on the provider is necessary but not sufficient: megatron's + # layers resolve their GTP axis from the process-group collection, not from + # the provider, and `build_inference_pg_collection` must declare the axis + # explicitly off for that resolution to not fall back to the *training* MPU + # globals. That is NVIDIA/Megatron-LM#6940; until it merges, running with + # `tensor_parallel_num_weight_shards` < TP needs a megatron-core carrying it. + inference_provider.tensor_parallel_num_weight_shards = ( + inference_provider.tensor_model_parallel_size + ) + inference_provider.gtp_weight_remat_size = 1 + inference_provider.expert_tensor_parallel_num_weight_shards = ( + inference_provider.expert_tensor_parallel_size + ) + inference_provider.expert_gtp_weight_remat_size = 1 # Inference never trains: disable recompute. inference_provider.recompute_granularity = None inference_provider.recompute_method = None @@ -1670,11 +1817,12 @@ def setup_model_and_optimizer( state.initialize_async_checkpoint_worker() megatron_cfg.dist.external_gpu_device_mapping = True - initialize_megatron( - cfg=megatron_cfg, - get_embedding_ranks=get_embedding_ranks, - get_position_embedding_ranks=get_position_embedding_ranks, - ) + with _gtp_process_groups(megatron_cfg.model): + initialize_megatron( + cfg=megatron_cfg, + get_embedding_ranks=get_embedding_ranks, + get_position_embedding_ranks=get_position_embedding_ranks, + ) if megatron_cfg.ft and megatron_cfg.ft.enable_ft_package: fault_tolerance.setup(megatron_cfg, state) diff --git a/nemo_rl/models/policy/__init__.py b/nemo_rl/models/policy/__init__.py index 7d14674a392..d0d65d5de8c 100644 --- a/nemo_rl/models/policy/__init__.py +++ b/nemo_rl/models/policy/__init__.py @@ -348,6 +348,13 @@ class MegatronConfig(TypedDict): # when None. Use ["moe"] to recompute only expert activations (production-proven config). recompute_modules: NotRequired[list[str] | None] tensor_model_parallel_size: int + # Generalized Tensor Parallelism (GTP). Total number of shards each weight is + # split into across the tensor-parallel and GTP axes; must be a multiple of + # tensor_model_parallel_size. The quotient is MCore's gtp_weight_remat_size: + # an extra dim-0 sharding of every weight, carved out of the data-parallel + # axis and all-gathered on demand in the forward and backward passes. Absent + # or null leaves GTP off (equivalent to tensor_model_parallel_size). + tensor_parallel_num_weight_shards: NotRequired[int | None] pipeline_model_parallel_size: int num_layers_in_first_pipeline_stage: int | None num_layers_in_last_pipeline_stage: int | None @@ -365,6 +372,9 @@ class MegatronConfig(TypedDict): sequence_parallel: bool freeze_moe_router: bool expert_tensor_parallel_size: int + # Expert-side counterpart of tensor_parallel_num_weight_shards; must be a + # multiple of expert_tensor_parallel_size. Independent of the dense knob. + expert_tensor_parallel_num_weight_shards: NotRequired[int | None] expert_model_parallel_size: int # If True, defer the casting of logits to float32 until the backward pass. # If you are using logprob_chunk_size, you must set this to True. diff --git a/tests/functional/L1_Functional_Tests_Megatron_4.sh b/tests/functional/L1_Functional_Tests_Megatron_4.sh index 484300a0ccf..7450bcdbc1a 100644 --- a/tests/functional/L1_Functional_Tests_Megatron_4.sh +++ b/tests/functional/L1_Functional_Tests_Megatron_4.sh @@ -48,11 +48,31 @@ megatron_generation_supported() { return 0 } +# GTP needs a newer TransformerEngine than MCore itself does (MCore's GTP core +# declares its own TE floor and degrades to import stubs below it). NeMo-RL +# currently pins TE release_v2.15, which is under that floor, so the GTP test +# cannot pass yet -- registering it unguarded would just make L1 permanently red. +# Ask MCore directly rather than hardcoding a version here, so this re-enables +# itself the moment the TE pin is bumped. +# TODO: remove this guard once NeMo-RL's TE pin satisfies MCore's GTP floor. +megatron_gtp_supported() { + if ! uv run --no-sync python -c \ + 'import sys; from megatron.core.tensor_parallel.gtp_api import HAVE_GTP; sys.exit(0 if HAVE_GTP else 1)' \ + &> /dev/null; then + echo "WARNING: Skipping GTP test; Megatron-LM reports HAVE_GTP=False (TransformerEngine too old)" + return 1 + fi + return 0 +} + if megatron_generation_supported; then run_test fast uv run --no-sync bash ./tests/functional/grpo_megatron_generation.sh run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_topology.sh run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_non_colocated.sh run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_colocated_reshard.sh + if megatron_gtp_supported; then + run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_gtp.sh + fi run_test fast uv run --no-sync bash ./tests/functional/grpo_megatron_generation_colocated_async_grpo.sh run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_colocated_reshard_async_grpo.sh run_test uv run --no-sync bash ./tests/functional/grpo_megatron_generation_async_gym.sh diff --git a/tests/functional/grpo_megatron_generation_gtp.sh b/tests/functional/grpo_megatron_generation_gtp.sh new file mode 100755 index 00000000000..c608003711c --- /dev/null +++ b/tests/functional/grpo_megatron_generation_gtp.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) +PROJECT_ROOT=$(realpath $SCRIPT_DIR/../..) +# Mark the current repo as safe, since wandb fetches metadata about the repo +git config --global --add safe.directory $PROJECT_ROOT + +set -eou pipefail + +EXP_NAME=$(basename $0 .sh) +EXP_DIR=$SCRIPT_DIR/$EXP_NAME +LOG_DIR=$EXP_DIR/logs +JSON_METRICS=$EXP_DIR/metrics.json +RUN_LOG=$EXP_DIR/run.log +export PYTHONPATH=${PROJECT_ROOT}:${PYTHONPATH:-} + +rm -rf $EXP_DIR $LOG_DIR +mkdir -p $EXP_DIR $LOG_DIR + +# GTP refit: training runs TP1 x GTP2 (tensor_parallel_num_weight_shards=2 over +# TP=1), so every training weight is split along dim 0 across the 2 ranks and +# rematerialized on demand. Inference runs TP1 with whole weights, so refit must +# reassemble each weight from its GTP shards -- including the alignment padding +# that carries no logical data. Getting that wrong yields subtly wrong inference +# weights, which shows up as generation/training logprob disagreement, hence the +# token_mult_prob_error gate. +# +# Requires a Megatron-LM with GTP support in megatron/core/resharding +# (NVIDIA/Megatron-LM#6133). Older revisions reject the plan outright. +cd $PROJECT_ROOT +uv run coverage run -a --data-file=$PROJECT_ROOT/tests/.coverage --source=$PROJECT_ROOT/nemo_rl \ + $PROJECT_ROOT/examples/run_grpo.py \ + --config $PROJECT_ROOT/examples/configs/grpo_math_1B_megatron.yaml \ + policy.model_name=Qwen/Qwen2.5-0.5B \ + grpo.num_prompts_per_step=2 \ + grpo.num_generations_per_prompt=4 \ + policy.train_global_batch_size=4 \ + policy.logprob_batch_size=4 \ + policy.train_micro_batch_size=1 \ + policy.megatron_cfg.tensor_model_parallel_size=1 \ + policy.megatron_cfg.tensor_parallel_num_weight_shards=2 \ + policy.generation.backend=megatron \ + policy.generation.mcore_generation_config.refit_backend=nccl \ + cluster.gpus_per_node=2 \ + grpo.max_num_steps=2 \ + logger.tensorboard_enabled=true \ + logger.log_dir=$LOG_DIR \ + logger.wandb_enabled=false \ + logger.monitor_gpus=true \ + checkpointing.enabled=false \ + $@ \ + 2>&1 | tee $RUN_LOG + +uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS + +uv run tests/check_metrics.py $JSON_METRICS \ + 'max(data["train/token_mult_prob_error"]) < 1.05' + +# Guard against a vacuous pass. GTP is only active when MCore actually creates +# the gtp_remat process group; if that silently degrades to size 1 the run is an +# ordinary TP1 job that would pass every metric above without testing anything. +if ! grep -q "\[gtp\] enabling GTP weight rematerialization (gtp_remat_size=2" $RUN_LOG; then + echo "FAIL: GTP marker not found; the training model was not GTP-sharded" + exit 1 +fi + +# GTP-sharded training weights must be reassembled into a dedicated inference +# model. Without this, generation would run on the training model directly and +# the refit path under test would never execute. +if ! grep -q "\[colocated-reshard\] building dedicated inference model" $RUN_LOG; then + echo "FAIL: colocated-reshard marker not found; dedicated inference model was never built" + exit 1 +fi diff --git a/tests/unit/models/generation/test_megatron_generation_layout.py b/tests/unit/models/generation/test_megatron_generation_layout.py new file mode 100644 index 00000000000..b8e884e8f0e --- /dev/null +++ b/tests/unit/models/generation/test_megatron_generation_layout.py @@ -0,0 +1,161 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# 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. + +"""CPU tests for colocated megatron generation layout selection. + +`dedicated_inference_megatron_cfg` decides whether colocated generation runs +directly on the shared training model or whether the worker builds a second, +resharded inference model. Getting a "no" wrong is silent: generation still +produces tokens, so the GPU functional tests keep passing while the refit path +they were written to exercise never executes. That failure mode is exactly what +happened for GTP -- the layout comparison did not look at the weight-shard +counts, so a TP1 x GTP2 training model looked identical to a TP1 inference +model. These tests pin the decision without a GPU. +""" + +from copy import deepcopy +from typing import Any, cast + +import pytest + +from nemo_rl.models.generation.megatron.config import ( + dedicated_inference_megatron_cfg, + merged_inference_megatron_cfg, +) +from nemo_rl.models.policy import PolicyConfig + + +def _policy_config( + *, + megatron_overrides: dict[str, Any] | None = None, + generation_overrides: dict[str, Any] | None = None, +) -> PolicyConfig: + """A minimal policy config carrying only what the layout selector reads.""" + megatron_cfg: dict[str, Any] = { + "tensor_model_parallel_size": 1, + "pipeline_model_parallel_size": 1, + "expert_model_parallel_size": 1, + "expert_tensor_parallel_size": 1, + "context_parallel_size": 1, + "tensor_parallel_num_weight_shards": None, + "expert_tensor_parallel_num_weight_shards": None, + "sequence_parallel": False, + "transformer_impl": "transformer_engine", + "activation_checkpointing": True, + } + megatron_cfg.update(megatron_overrides or {}) + return cast( + PolicyConfig, + { + "megatron_cfg": megatron_cfg, + "generation": { + "backend": "megatron", + "mcore_generation_config": deepcopy(generation_overrides or {}), + }, + }, + ) + + +def test_matched_layout_is_reshardless(): + """Identical train/inference layout and impl => generate on the shared model.""" + assert dedicated_inference_megatron_cfg(_policy_config()) is None + + +@pytest.mark.parametrize( + "shards_key, tp_key", + [ + ("tensor_parallel_num_weight_shards", "tensor_model_parallel_size"), + ( + "expert_tensor_parallel_num_weight_shards", + "expert_tensor_parallel_size", + ), + ], +) +def test_weight_shard_count_equal_to_tp_is_not_a_layout_change(shards_key, tp_key): + """An explicit TP-equal shard count means "GTP off", same as null. + + `null` and an explicit value equal to the TP degree describe the same + unsharded layout. If only one of them compared equal, users would get a + pointless dedicated inference model (and a full refit every wake) purely + from writing the default out longhand. + """ + config = _policy_config(megatron_overrides={tp_key: 2, shards_key: 2}) + assert dedicated_inference_megatron_cfg(config) is None + + +@pytest.mark.parametrize( + "shards_key, tp_key, remat_key", + [ + ( + "tensor_parallel_num_weight_shards", + "tensor_model_parallel_size", + "tensor_parallel_num_weight_shards", + ), + ( + "expert_tensor_parallel_num_weight_shards", + "expert_tensor_parallel_size", + "expert_tensor_parallel_num_weight_shards", + ), + ], +) +def test_gtp_training_model_forces_a_dedicated_inference_model( + shards_key, tp_key, remat_key +): + """GTP on the training side must take the reshard path. + + Inference never uses GTP: refit reassembles the training model's dim-0 + weight shards into whole inference weights. So a GTP-sharded training model + is by construction a different layout, and the resolved inference config + must pin the shard count back down to its own TP degree. + """ + config = _policy_config(megatron_overrides={shards_key: 2}) + inference_mcfg = dedicated_inference_megatron_cfg(config) + assert inference_mcfg is not None + assert inference_mcfg[remat_key] == inference_mcfg[tp_key] + + +def test_context_parallel_training_forces_a_dedicated_inference_model(): + """Inference pins CP=1, so any CP>1 training layout differs.""" + config = _policy_config(megatron_overrides={"context_parallel_size": 2}) + inference_mcfg = dedicated_inference_megatron_cfg(config) + assert inference_mcfg is not None + assert inference_mcfg["context_parallel_size"] == 1 + + +def test_differing_transformer_impl_forces_a_dedicated_inference_model(): + """Same layout but a different impl still needs a second model.""" + config = _policy_config( + generation_overrides={"transformer_impl": "inference_optimized"} + ) + assert dedicated_inference_megatron_cfg(config) is not None + + +def test_merged_cfg_rejects_inference_optimized_without_sequence_parallel(): + """inference_optimized layers hard-require SP with TP>1. + + The colocated build bypasses validate_and_set_config, so this merge is the + only place the user gets a named config key instead of a raw MCore assert. + """ + config = _policy_config( + megatron_overrides={"tensor_model_parallel_size": 2}, + generation_overrides={"transformer_impl": "inference_optimized"}, + ) + with pytest.raises(ValueError, match="sequence_parallel"): + merged_inference_megatron_cfg(config) + + +def test_merged_cfg_disables_activation_checkpointing(): + """Inference never trains, so the training recompute setting must not leak.""" + merged = merged_inference_megatron_cfg(_policy_config()) + assert merged["activation_checkpointing"] is False