|
| 1 | +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. |
| 2 | +"""DP2 MIMO optimizer layout regression with real DDP and optimizer updates. |
| 3 | +
|
| 4 | +Run using ``uv run python -m torch.distributed.run --standalone |
| 5 | +--nproc_per_node=2 tests/functional_tests/training/mimo_optimizer_layout.py |
| 6 | +--output /tmp/mimo-layout``. Component forwards intentionally isolate the |
| 7 | +optimizer contract from the MIMO pipeline schedule and tokenizer/model assets. |
| 8 | +""" |
| 9 | + |
| 10 | +import argparse |
| 11 | +import copy |
| 12 | +import logging |
| 13 | +import os |
| 14 | +from pathlib import Path |
| 15 | +from types import SimpleNamespace |
| 16 | + |
| 17 | +import torch |
| 18 | +import torch.distributed as dist |
| 19 | +from megatron.core import dist_checkpointing, parallel_state |
| 20 | +from megatron.core.distributed import DistributedDataParallel, DistributedDataParallelConfig |
| 21 | +from megatron.core.hyper_comm_grid import HyperCommGrid |
| 22 | +from megatron.core.models.mimo import MimoModel |
| 23 | +from megatron.core.models.mimo.config import MimoModelConfig |
| 24 | +from megatron.core.models.mimo.optimizer import MimoOptimizer, get_mimo_optimizer |
| 25 | +from megatron.core.models.mimo.submodules.vision import VisionModalitySubmodules |
| 26 | +from megatron.core.optimizer.distrib_optimizer import DistributedOptimizer |
| 27 | +from megatron.core.optimizer.layer_wise_optimizer import LayerWiseDistributedOptimizer |
| 28 | +from megatron.core.optimizer.optimizer import MegatronOptimizer |
| 29 | +from megatron.core.process_groups_config import ProcessGroupCollection |
| 30 | +from megatron.core.transformer.module import MegatronModule |
| 31 | +from megatron.core.transformer.spec_utils import ModuleSpec |
| 32 | +from megatron.core.transformer.transformer_config import TransformerConfig |
| 33 | +from megatron.core.utils import unwrap_model |
| 34 | + |
| 35 | +from megatron.bridge.models.megatron_mimo.megatron_mimo_config import ( |
| 36 | + MegatronMIMOParallelismConfig, |
| 37 | + ModuleParallelismConfig, |
| 38 | +) |
| 39 | +from megatron.bridge.models.megatron_mimo.megatron_mimo_ddp import wrap_megatron_mimo_model_distributed |
| 40 | +from megatron.bridge.models.megatron_mimo.megatron_mimo_provider import _cast_model_dtype_except_fp32_marked |
| 41 | +from megatron.bridge.training.checkpointing import _build_sharded_state_dict_metadata, _uses_distributed_optimizer |
| 42 | +from megatron.bridge.training.config import ( |
| 43 | + CheckpointConfig, |
| 44 | + OptimizerConfig, |
| 45 | + _validate_and_sync_distributed_optimizer_settings, |
| 46 | +) |
| 47 | +from megatron.bridge.training.setup_megatron_mimo import _to_mcore_optimizer_config |
| 48 | +from megatron.bridge.training.train import force_param_sync |
| 49 | + |
| 50 | + |
| 51 | +logger = logging.getLogger(__name__) |
| 52 | + |
| 53 | + |
| 54 | +class TinyComponent(MegatronModule): |
| 55 | + """Matrices, an Adam-managed bias, a tied weight, and a frozen matrix.""" |
| 56 | + |
| 57 | + def __init__( |
| 58 | + self, config: TransformerConfig, pg_collection: ProcessGroupCollection, matrices_only: bool = False |
| 59 | + ) -> None: |
| 60 | + super().__init__(config) |
| 61 | + self.pg_collection = pg_collection |
| 62 | + self.linear = torch.nn.Linear(16, 16, bias=not matrices_only) |
| 63 | + self.second = torch.nn.Linear(16, 32, bias=False) |
| 64 | + self.tied = torch.nn.Linear(16, 16, bias=False) |
| 65 | + self.tied.weight = self.linear.weight |
| 66 | + self.excluded = torch.nn.Linear(16, 16, bias=False).requires_grad_(not matrices_only) |
| 67 | + self.excluded.weight.use_muon = False |
| 68 | + self.output = torch.nn.Linear(16, 16, bias=False).requires_grad_(not matrices_only) |
| 69 | + self.output.weight.is_embedding_or_output_parameter = True |
| 70 | + self.frozen = torch.nn.Linear(16, 16, bias=False).requires_grad_(False) |
| 71 | + |
| 72 | + def forward(self, x: torch.Tensor) -> torch.Tensor: |
| 73 | + return ( |
| 74 | + self.linear(x) |
| 75 | + + self.second(x).reshape(-1, 2, 16).sum(dim=1) |
| 76 | + + self.tied(x) |
| 77 | + + self.frozen(x) |
| 78 | + + self.excluded(x) |
| 79 | + + self.output(x) |
| 80 | + ) |
| 81 | + |
| 82 | + |
| 83 | +def components(model: MimoModel) -> list[DistributedDataParallel]: |
| 84 | + return [model.language_model, model.modality_submodules["vision"]] |
| 85 | + |
| 86 | + |
| 87 | +def build( |
| 88 | + optimizer_name: str, mode: str, *, matrices_only: bool = False, overlap: bool = False |
| 89 | +) -> tuple[MimoModel, MimoOptimizer]: |
| 90 | + torch.manual_seed(123) |
| 91 | + config = TransformerConfig(num_layers=1, hidden_size=16, num_attention_heads=2, bf16=True) |
| 92 | + pg = ProcessGroupCollection.use_mpu_process_groups() |
| 93 | + grid = HyperCommGrid([1, 1, 1, dist.get_world_size()], ["tp", "cp", "pp", "dp"]) |
| 94 | + grids = {"language": grid, "vision": grid} |
| 95 | + spec = ModuleSpec( |
| 96 | + module=TinyComponent, params={"config": config, "pg_collection": pg, "matrices_only": matrices_only} |
| 97 | + ) |
| 98 | + model = MimoModel( |
| 99 | + MimoModelConfig( |
| 100 | + language_model_spec=spec, |
| 101 | + modality_submodules_spec={ |
| 102 | + "vision": ModuleSpec( |
| 103 | + module=VisionModalitySubmodules, |
| 104 | + params={"pg_collection": pg}, |
| 105 | + submodules={"encoders": {"tiny": spec}}, |
| 106 | + ) |
| 107 | + }, |
| 108 | + module_to_grid_map=grids, |
| 109 | + ), |
| 110 | + cp_group=pg.cp, |
| 111 | + tp_group=pg.tp, |
| 112 | + ).cuda() |
| 113 | + original = {id(p): name for name, p in model.named_parameters()} |
| 114 | + model = _cast_model_dtype_except_fp32_marked([model], torch.bfloat16)[0] |
| 115 | + optimizer_config = OptimizerConfig( |
| 116 | + optimizer=optimizer_name, |
| 117 | + use_distributed_optimizer=mode != "replicated", |
| 118 | + bf16=True, |
| 119 | + lr=0.001, |
| 120 | + min_lr=0.001, |
| 121 | + weight_decay=0.0, |
| 122 | + clip_grad=0.0, |
| 123 | + muon_split_qkv=False, |
| 124 | + ) |
| 125 | + ddp = DistributedDataParallelConfig( |
| 126 | + grad_reduce_in_fp32=True, overlap_grad_reduce=overlap, overlap_param_gather=overlap |
| 127 | + ) |
| 128 | + _validate_and_sync_distributed_optimizer_settings(SimpleNamespace(optimizer=optimizer_config, ddp=ddp)) |
| 129 | + model = wrap_megatron_mimo_model_distributed( |
| 130 | + model, |
| 131 | + ddp, |
| 132 | + MegatronMIMOParallelismConfig( |
| 133 | + module_parallelisms={name: ModuleParallelismConfig(data_parallel_size=2) for name in grids} |
| 134 | + ), |
| 135 | + grids, |
| 136 | + {"language": pg, "vision": pg}, |
| 137 | + use_layer_wise_distributed_optimizer=optimizer_config.use_layer_wise_distributed_optimizer, |
| 138 | + use_layer_wise_param_layout=mode == "full", |
| 139 | + ) |
| 140 | + # Dtype casting and DDP must preserve parameter identity, including tied aliases. |
| 141 | + assert set(original) == {id(p) for p in model.parameters()} |
| 142 | + for component in components(model): |
| 143 | + raw = unwrap_model(component) |
| 144 | + tiny = raw if isinstance(raw, TinyComponent) else raw.encoders["tiny"] |
| 145 | + assert tiny.linear.weight is tiny.tied.weight |
| 146 | + assert raw.pg_collection.dp_cp.size() == dist.get_world_size() == 2 |
| 147 | + layout = component.full_param_layout |
| 148 | + if layout is not None: |
| 149 | + entries = { |
| 150 | + id(p): key.is_managed_by_layer_wise_optimizer |
| 151 | + for key, buffer in layout.layouts.items() |
| 152 | + for p in buffer.param_index_map |
| 153 | + } |
| 154 | + assert set(entries) == {id(p) for p in component.parameters() if p.requires_grad} |
| 155 | + if mode == "full": |
| 156 | + for identity, layer_wise in entries.items(): |
| 157 | + expected = optimizer_name == "muon" and original[identity].endswith( |
| 158 | + (".linear.weight", ".second.weight") |
| 159 | + ) |
| 160 | + assert layer_wise == expected, original[identity] |
| 161 | + logger.info("%s %s layout: %s", optimizer_name, mode, {original[k]: v for k, v in entries.items()}) |
| 162 | + else: |
| 163 | + logger.info("%s %s: no full layout", optimizer_name, mode) |
| 164 | + # Deliberately construct before asserting the expected layout mode: the old |
| 165 | + # normalization must fail in Core's real _shard_params_from_layout assertion. |
| 166 | + optimizer = get_mimo_optimizer(model, _to_mcore_optimizer_config(optimizer_config)) |
| 167 | + for component in components(model): |
| 168 | + if optimizer_name != "adam" and mode == "compact": |
| 169 | + assert component.full_param_layout is None |
| 170 | + assert not component.ddp_config.use_distributed_optimizer |
| 171 | + for module_name, info in optimizer.module_infos.items(): |
| 172 | + opt = info.optimizer |
| 173 | + if optimizer_name == "adam" and mode != "replicated": |
| 174 | + children = getattr(opt, "chained_optimizers", [opt]) |
| 175 | + assert children and all(isinstance(child, DistributedOptimizer) for child in children) |
| 176 | + elif optimizer_name == "muon" and mode == "compact": |
| 177 | + assert isinstance(opt, LayerWiseDistributedOptimizer) |
| 178 | + assert not opt.use_buffer_param_sync |
| 179 | + elif optimizer_name == "muon" and mode == "full": |
| 180 | + assert isinstance(opt.chained_optimizers[0], LayerWiseDistributedOptimizer) |
| 181 | + assert opt.chained_optimizers[0].use_buffer_param_sync |
| 182 | + assert any(isinstance(child, DistributedOptimizer) for child in opt.chained_optimizers) |
| 183 | + if optimizer_name == "muon" and mode != "replicated": |
| 184 | + layer_wise = opt if mode == "compact" else opt.chained_optimizers[0] |
| 185 | + owned = [{id(p) for p in shard} for shard in layer_wise.dp_cp_params_list] |
| 186 | + component = model.language_model if module_name == "language" else model.modality_submodules[module_name] |
| 187 | + trainable = {id(p) for p in component.parameters() if p.requires_grad} |
| 188 | + matrices = { |
| 189 | + identity for identity in trainable if original[identity].endswith((".linear.weight", ".second.weight")) |
| 190 | + } |
| 191 | + assert set.union(*owned) == (trainable if mode == "compact" else matrices) |
| 192 | + assert not owned[0].intersection(owned[1]) |
| 193 | + assert all(shard.intersection(matrices) for shard in owned) |
| 194 | + logger.info("%s %s optimizers: %s", optimizer_name, mode, [type(o).__name__ for o in optimizer.chained_optimizers]) |
| 195 | + return model, optimizer |
| 196 | + |
| 197 | + |
| 198 | +def step( |
| 199 | + model: MimoModel, optimizer: MimoOptimizer, index: int |
| 200 | +) -> tuple[float, dict[str, torch.Tensor], dict[str, torch.Tensor]]: |
| 201 | + optimizer.zero_grad() |
| 202 | + for component in components(model): |
| 203 | + component.zero_grad_buffer() |
| 204 | + x = torch.arange(64, device="cuda", dtype=torch.float32).reshape(4, 16) / 64 |
| 205 | + x = (x + 0.125 * (index + dist.get_rank())).bfloat16() |
| 206 | + # Compute the gradient oracle with plain modules, outside DDP's accumulation |
| 207 | + # and collective paths. Explicit averaging defines the global-batch gradient. |
| 208 | + names = {id(p): name for name, p in model.named_parameters()} |
| 209 | + expected_grads = {} |
| 210 | + for component in components(model): |
| 211 | + raw = unwrap_model(component) |
| 212 | + tiny = raw if isinstance(raw, TinyComponent) else raw.encoders["tiny"] |
| 213 | + plain = TinyComponent(tiny.config, tiny.pg_collection).cuda().bfloat16() |
| 214 | + plain.load_state_dict(tiny.state_dict()) |
| 215 | + plain(x).float().square().mean().backward() |
| 216 | + for name, param in tiny.named_parameters(): |
| 217 | + if param.requires_grad: |
| 218 | + gradient = dict(plain.named_parameters())[name].grad.float() |
| 219 | + dist.all_reduce(gradient) |
| 220 | + expected_grads[names[id(param)]] = gradient / dist.get_world_size() |
| 221 | + losses = [model.language_model(x).float().square().mean()] |
| 222 | + losses.append(model.modality_submodules["vision"](encoder_inputs={"tiny": {"x": x}}).float().square().mean()) |
| 223 | + sum(losses).backward() |
| 224 | + expected_norm = torch.stack([g.square().sum() for g in expected_grads.values()]).sum().sqrt() |
| 225 | + for component in components(model): |
| 226 | + component.finish_grad_sync() |
| 227 | + success, norm, _ = optimizer.step() |
| 228 | + assert success |
| 229 | + assert norm is not None |
| 230 | + torch.testing.assert_close(torch.tensor(norm, device="cuda"), expected_norm, rtol=1e-5, atol=1e-6) |
| 231 | + force_param_sync([model], optimizer) |
| 232 | + weights = {name: p.detach().clone() for name, p in model.named_parameters()} |
| 233 | + for weight in weights.values(): |
| 234 | + reference = weight.clone() |
| 235 | + dist.broadcast(reference, 0) |
| 236 | + torch.testing.assert_close(weight, reference, rtol=0, atol=0) |
| 237 | + assert torch.isfinite(weight).all() |
| 238 | + return norm, weights, expected_grads |
| 239 | + |
| 240 | + |
| 241 | +def checkpoint(model: MimoModel, optimizer: MimoOptimizer, path: Path, *, loading: bool) -> None: |
| 242 | + metadata = _build_sharded_state_dict_metadata( |
| 243 | + _uses_distributed_optimizer(optimizer.config, optimizer), CheckpointConfig(ckpt_format="torch_dist") |
| 244 | + ) |
| 245 | + model_sd = model.sharded_state_dict(metadata=metadata) |
| 246 | + state = { |
| 247 | + "model": model_sd, |
| 248 | + "optimizer": optimizer.sharded_state_dict(model_sd, is_loading=loading, metadata=metadata), |
| 249 | + } |
| 250 | + if loading: |
| 251 | + state = dist_checkpointing.load(state, str(path)) |
| 252 | + model.load_state_dict(state["model"]) |
| 253 | + optimizer.load_state_dict(state["optimizer"]) |
| 254 | + force_param_sync([model], optimizer) |
| 255 | + else: |
| 256 | + if dist.get_rank() == 0: |
| 257 | + path.mkdir(parents=True, exist_ok=True) |
| 258 | + dist.barrier() |
| 259 | + dist_checkpointing.save(state, str(path), content_metadata=metadata) |
| 260 | + |
| 261 | + |
| 262 | +def optimizer_snapshot(optimizer: MegatronOptimizer) -> object: |
| 263 | + """Capture local FP32 masters and moments, including nested optimizer leaves.""" |
| 264 | + children = getattr(optimizer, "chained_optimizers", None) |
| 265 | + if children is not None: |
| 266 | + return [optimizer_snapshot(child) for child in children] |
| 267 | + state = optimizer.optimizer.state_dict() |
| 268 | + return { |
| 269 | + "parameters": [p.detach().clone() for p in optimizer.get_parameters()], |
| 270 | + "state": copy.deepcopy(state["state"]), |
| 271 | + # Restore materializes step metadata on empty groups as well; only |
| 272 | + # nonempty groups have an update counter to compare. |
| 273 | + "steps": [copy.deepcopy(g.get("step", 0)) for g in state["param_groups"] if g["params"]], |
| 274 | + } |
| 275 | + |
| 276 | + |
| 277 | +def run(output: Path, optimizer_name: str, mode: str, *, overlap: bool) -> None: |
| 278 | + model, optimizer = build(optimizer_name, mode, overlap=overlap) |
| 279 | + initial = {name: p.detach().clone() for name, p in model.named_parameters()} |
| 280 | + first = step(model, optimizer, 0) |
| 281 | + for name, value in first[1].items(): |
| 282 | + if "frozen" in name: |
| 283 | + torch.testing.assert_close(value, initial[name], rtol=0, atol=0) |
| 284 | + else: |
| 285 | + assert not torch.equal(value, initial[name]), name |
| 286 | + checkpoint(model, optimizer, output / f"{optimizer_name}-{mode}-{overlap}", loading=False) |
| 287 | + continued = step(model, optimizer, 1) |
| 288 | + continued_optimizer = optimizer_snapshot(optimizer) |
| 289 | + resumed_model, resumed_optimizer = build(optimizer_name, mode, overlap=overlap) |
| 290 | + checkpoint(resumed_model, resumed_optimizer, output / f"{optimizer_name}-{mode}-{overlap}", loading=True) |
| 291 | + resumed = step(resumed_model, resumed_optimizer, 1) |
| 292 | + torch.testing.assert_close(resumed, continued, rtol=0, atol=0) |
| 293 | + torch.testing.assert_close(optimizer_snapshot(resumed_optimizer), continued_optimizer, rtol=0, atol=0) |
| 294 | + # Replicated optimizers provide a numerical oracle with no distributed |
| 295 | + # parameter ownership; compare every unique trainable/frozen parameter. |
| 296 | + reference_model, reference_optimizer = build(optimizer_name, "replicated") |
| 297 | + reference_first = step(reference_model, reference_optimizer, 0) |
| 298 | + reference_second = step(reference_model, reference_optimizer, 1) |
| 299 | + torch.testing.assert_close(first, reference_first, rtol=1e-5, atol=1e-6) |
| 300 | + torch.testing.assert_close(continued, reference_second, rtol=1e-5, atol=1e-6) |
| 301 | + logger.info( |
| 302 | + "PASS %s %s overlap=%s: DP2 updates, replicated oracle, frozen/tied identity, checkpoint continuation", |
| 303 | + optimizer_name, |
| 304 | + mode, |
| 305 | + overlap, |
| 306 | + ) |
| 307 | + |
| 308 | + |
| 309 | +def main() -> None: |
| 310 | + parser = argparse.ArgumentParser(description=__doc__) |
| 311 | + parser.add_argument("--output", type=Path, required=True) |
| 312 | + parser.add_argument("--case", choices=["construct", "compact", "full", "adam", "all"], default="all") |
| 313 | + args = parser.parse_args() |
| 314 | + logging.basicConfig(level=logging.INFO) |
| 315 | + logger.setLevel(logging.INFO) |
| 316 | + torch.cuda.set_device(int(os.environ["LOCAL_RANK"])) |
| 317 | + dist.init_process_group("nccl") |
| 318 | + parallel_state.initialize_model_parallel() |
| 319 | + try: |
| 320 | + if args.case in ("construct", "all"): |
| 321 | + build("muon", "compact", matrices_only=True) |
| 322 | + for name, mode in [("muon", "compact"), ("muon", "full"), ("adam", "full")]: |
| 323 | + if args.case in ("all", "adam" if name == "adam" else mode): |
| 324 | + for overlap in (False, True): |
| 325 | + run(args.output, name, mode, overlap=overlap) |
| 326 | + finally: |
| 327 | + parallel_state.destroy_model_parallel() |
| 328 | + dist.destroy_process_group() |
| 329 | + |
| 330 | + |
| 331 | +if __name__ == "__main__": |
| 332 | + main() |
0 commit comments