Skip to content

Commit 9b2cd8c

Browse files
committed
chore: merge concurrent MIMO validation into Muon fixes
Signed-off-by: Chen Cui <chcui@nvidia.com>
2 parents c930fc8 + 4e0bed9 commit 9b2cd8c

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# CI_TIMEOUT=15
2+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and limitations under the License.
10+
11+
#!/bin/bash
12+
set -euo pipefail
13+
14+
export CUDA_VISIBLE_DEVICES=0,1
15+
export CUDA_DEVICE_MAX_CONNECTIONS=1
16+
MIMO_LAYOUT_OUTPUT=$(mktemp -d)
17+
uv run python -m torch.distributed.run --nnodes=1 --nproc_per_node=2 \
18+
--master-addr=127.0.0.1 --master-port=29517 \
19+
tests/functional_tests/training/mimo_optimizer_layout.py --output "$MIMO_LAYOUT_OUTPUT"
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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

Comments
 (0)