Skip to content

NMFW-464: Distributed checkpoint save/load for the hetero MIMO training loop - #26

Merged
yashaswikarnati merged 5 commits into
ykarnati/nmfw-464-nemotron-vlm-with-hetero-parallelfrom
ykarnati/nmfw-464-checkpoint-support
May 15, 2026
Merged

yashaswikarnati merged 5 commits into
ykarnati/nmfw-464-nemotron-vlm-with-hetero-parallelfrom
ykarnati/nmfw-464-checkpoint-support

Conversation

@yashaswikarnati

Copy link
Copy Markdown
Owner

Summary

Adds distributed-checkpoint save/load to the standalone hetero MIMO training loop (examples/mimo/train_hetero.py) so MimoModel + MimoOptimizer (Chained DistributedOptimizer in the MoE recipe) + LR/WD scheduler + Python/Torch/CUDA RNG state round-trip through megatron.core.dist_checkpointing without depending on parallel_state. Layout is intentionally compatible with megatron/training/checkpointing.py output.

Validated end-to-end on cw-dfw 8-GPU 20L mock (stage2): save at iter 3 → reload → resume at iter 4 with cosine LR continuation (1.59e-4 → 1.32e-4 → 1.01e-4) and matching loss trajectory.

What's in this PR

New files

  • examples/mimo/training/hetero/checkpointing.pysave_checkpoint / load_checkpoint wrapping dist_checkpointing.save/load with a tracker file, per-branch RNG ShardedObjects, content_metadata persistence, and atomic tracker writes.

CLI surface (in examples/mimo/training/hetero/args.py)

  • --save, --load, --save-interval, --no-save-optim, --no-load-optim, --no-load-scheduler, --no-save-rng, --no-load-rng, --finetune, --dist-ckpt-optim-fully-reshardable (off by default; help text warns about save-time gather OOM on <80GB GPUs).

Loop wiring (examples/mimo/training/hetero/loop.py)

  • Load before train iters, periodic save on --save-interval, always save final iter when --save is set.

Three pre-existing bug fixes uncovered by hetero usage

  • megatron/core/ssm/mamba_mixer.py: MambaMixer.sharded_state_dict now passes tp_group=self.tp_group and dp_cp_group=metadata['dp_cp_group'] to make_sharded_tensors_for_checkpoint (was falling back to parallel_state; gated_delta_net already does this correctly).
  • megatron/core/models/mimo/optimizer.py:MimoOptimizer.sharded_state_dict: now applies add_prefix_for_sharding(module_sd, f'mimo.{name}.') so the two branches' optimizers don't collide on identical internal ShardedObject keys like chained_0.optimizer.distributed.dp_group_idx_0.optimizer/shard_0_1.
  • megatron/core/models/mimo/optimizer.py:_get_replica_id: now returns (pp_rank, tp_rank, dp_rank) instead of (0, pp_rank, dp_rank) — without tp_rank, two TP ranks within the same DP rank both claimed primary writer and dist_checkpointing.save errored with 'Duplicate ShardedObject keys'.

New _extract_param_state_sharding_type helpers in MimoOptimizer route DistributedOptimizer's top-level param_state_sharding_type config string through a per-module ShardedObject (with a unique key per branch), so the non-rank-0 module owner doesn't lose it when only rank 0's common.pt is authoritative. Without this, DistributedOptimizer.load_state_dict raised AssertionError: 'param_state_sharding_type' in state_dict.

TP group propagation (examples/mimo/training/hetero/runtime.py): a new _propagate_tp_groups_for_checkpoint walker stamps self.tp_group on every descendant that doesn't already have one (e.g. ExtendedRMSNorm in mamba_mixer, RADIO encoder submodules) so the default MegatronModule.sharded_state_dict path doesn't fall through to parallel_state.get_tensor_model_parallel_group. The walker uses hasattr so a module that intentionally sets tp_group = None is left alone.

Megatron-parity additions in common state

  • args (dict(vars(args))) so the saved layout is inspectable / future-checkable.
  • checkpoint_version = 3.0.
  • content_metadata = _clean_metadata_for_serialization(_build_optim_metadata(args)) passed to dist_checkpointing.save, persisting distrib_optim_sharding_type in metadata.json.
  • Cross-rank max-reduce in _read_tracker (mirrors megatron.training.checkpointing.read_metadata).
  • Atomic tracker write (.tmp + os.replace).

Test coverage

  • tests/unit_tests/models/test_mimo_checkpoint.py parameterized on use_distributed_optimizer. New test_encoder_tp2_llm_tp2_pp3_distributed_optimizer exercises the MimoOptimizer prefix walk + param_state_sharding_type extract that the existing Float16Optimizer variant doesn't reach.

Test plan

  • cw-dfw 8-GPU 20L mock smoke: save iter 3 → reload → train iter 4-5 (DistributedOptimizer + EP=4 + TP=2 + 2-branch Chained), exit 0 on both runs.
  • Verified resume reads optimizer=yes, scheduler=yes, rng=yes, finetune=False and LR continues cosine schedule across the reload boundary.
  • tools/autoformat.sh (black, isort) clean.
  • tests/unit_tests/models/test_mimo_checkpoint.py::TestMimoCheckpoint (existing Float16Optimizer + new DistributedOptimizer variant) — run on the next CI sweep.
  • Cross-TP/EP reload with --dist-ckpt-optim-fully-reshardable — opt-in, deferred to a follow-up due to save-time memory cost.

Notes

  • This is a fork-side PR against ykarnati/nmfw-464-nemotron-vlm-with-hetero-parallel (the NMFW-464 integration branch), not against NVIDIA/Megatron-LM:main. Cluster session nmfw-464-ckpt-iter2 retains the latest validation artifacts if you want to inspect the saved common.pt.
  • Independent reviewer findings (B1 docstring on --save-interval, B4 mkdir-on-all-ranks, R1 tp_group propagation comment, R2 OOM warning in --dist-ckpt-optim-fully-reshardable help) are all addressed in this commit.

🤖 Generated with Claude Code

Adds the standalone `examples/mimo/training/hetero/checkpointing.py` module
plus the CLI surface and loop wiring needed to round-trip MimoModel,
MimoOptimizer (ChainedOptimizer-of-DistributedOptimizers in the MoE recipe)
and the LR/WD scheduler through `megatron.core.dist_checkpointing` without
depending on the `parallel_state` singleton.

Layout stays compatible with `megatron/training/checkpointing.py` output:
`<save>/latest_checkpointed_iteration.txt` plus per-iteration directories
containing `common.pt`, `metadata.json`, `.metadata`, and torch_dist shards.
Common state now carries `args`, `checkpoint_version=3.0`, the LR scheduler
state, and a per-branch `mimo.{branch}.rng_state` ShardedObject; the tracker
read uses a cross-rank MAX reduce to mirror megatron's `read_metadata`.

Fixes three pre-existing dist-ckpt bugs that hetero usage uncovered:
- `megatron/core/ssm/mamba_mixer.py` was calling
  `make_sharded_tensors_for_checkpoint` without passing `tp_group` and
  `dp_cp_group`, which fell back to the parallel_state singleton and
  asserted in hetero mode (gated_delta_net was already correct).
- `MimoOptimizer.sharded_state_dict` now applies
  `add_prefix_for_sharding(module_sd, f'mimo.{name}.')` to each per-branch
  optimizer sub-dict so two modules' identical internal ShardedObject keys
  (e.g. `chained_0.optimizer.distributed.dp_group_idx_0.*`) don't collide.
- `_get_replica_id` now folds in `tp_rank` so two TP ranks within DP=0
  don't both claim primary writer for the same shard.

Also routes DistributedOptimizer's per-module `param_state_sharding_type`
config string through a new ShardedObject (`_extract_*` helpers) so the
non-rank-0 module owner doesn't lose it when only rank 0's common.pt is
authoritative.

A `_propagate_tp_groups_for_checkpoint` walker stamps `self.tp_group` on
descendants that omit it (e.g. `ExtendedRMSNorm`, RADIO submodules) so the
default `MegatronModule.sharded_state_dict` path doesn't fall through to
`parallel_state.get_tensor_model_parallel_group`.

Validated end-to-end on cw-dfw 8-GPU 20L mock (stage2):
- Save iter 3 (DistributedOptimizer + EP=4 + TP=2 + 2-module Chained)
- Reload iter 3 → resume at iter 4 with cosine LR continuation
  (1.59e-4 → 1.32e-4 → 1.01e-4), losses match prior trajectory.

New flags: `--save`, `--load`, `--save-interval`, `--no-save-optim`,
`--no-load-optim`, `--no-load-scheduler`, `--no-save-rng`, `--no-load-rng`,
`--finetune`, `--dist-ckpt-optim-fully-reshardable`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread megatron/core/models/mimo/optimizer.py Outdated
return (0, pg_collection.pp.rank(), pg_collection.dp.rank())
tp = getattr(pg_collection, 'tp', None)
tp_rank = tp.rank() if tp is not None else 0
return (pg_collection.pp.rank(), tp_rank, pg_collection.dp.rank())

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should tp rank be first or pp rank? how do we know ? can we check existing megatron code for optimizer or other classes? whats the expectation here ?

suffix = f'.{idx}' if idx > 0 else ''
_extract_param_groups(sub_sd, name, suffix, replica_id)
_extract_grad_scaler(sub_sd, name, suffix, replica_id)
_extract_param_state_sharding_type(sub_sd, name, suffix, replica_id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my own understanding why do we need this ?

_stamp_tp_group(submodule, topology.vision_pg.tp)


def _stamp_tp_group(module: torch.nn.Module, tp_group) -> None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for my own understanding, why do we need this ? this walks over all modules and sets tp group for every module in self.tp_group? which modules are missing ? trying to think if this indirect way is better or we directly fix the module that does not have tp group set, depends on what modules are missing. now

persist MimoModel + MimoOptimizer + LR scheduler state without depending on
`megatron.training.checkpointing` (which assumes the parallel_state singleton).

Stays intentionally close to the layout that `megatron/training/checkpointing.py`

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reuse functions and functionality from megatron/training where ever possible ?

yashaswikarnati and others added 4 commits May 15, 2026 20:23
Three convergent simplifications to MimoOptimizer's distributed-checkpoint
path, matching Kamran's MimoOptimizer fixes PR:

1. Replace the ShardedObject-based round-trip for
   `param_state_sharding_type` with a metadata stash. The sharding type is
   not per-rank state — it's a load-time interpretation hint that the caller
   supplies via the `metadata` kwarg on `sharded_state_dict()`. We stash that
   metadata in `self._last_sharded_metadata` at save and re-inject the
   sharding type into each per-module sub state-dict during
   `load_state_dict()` for ranks that lost it via dist_checkpointing's
   common-state path (i.e. non-rank-0 module owners in non-colocated
   layouts). Drops `_extract_param_state_sharding_type` /
   `_restore_param_state_sharding_type` along with their ShardedObject keys.

2. `_restore_param_groups` now uses `setdefault('optimizer', {})` before
   writing back `param_groups`. After `_extract_param_groups` deletes
   `param_groups` at save time, the leftover empty `'optimizer'` dict can be
   dropped by the common-state round-trip on ranks whose active module
   wasn't on rank 0 at save. The setdefault makes the restore path tolerant
   of that drop.

3. `_get_replica_id` reorders to `(tp_rank, pp_rank, dp_rank)` to match the
   convention used by `make_sharded_object_for_checkpoint` in
   `megatron/core/transformer/utils.py:168-172`. Dedup math is unchanged —
   `(0, 0, 0)` is still the primary replica — but the order is now
   consistent with the rest of the codebase.

Validated on cw-dfw 1-node 8-GPU 20L mock (stage2, DistributedOptimizer +
ChainedOptimizer + EP=4 + TP=2): save iter 3, reload, resume iter 4 with
cosine LR continuation (1.59e-4 → 1.32e-4 → 1.01e-4) and matching loss
trajectory. Save exit 0, load exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Disabled `_propagate_tp_groups_for_checkpoint` and re-ran the 20L mock
to enumerate exactly which modules fall through to
`parallel_state.get_tensor_model_parallel_group()` and assert. Confirmed
both branches escape:

- RADIO encoder internals (first failure, reached via
  `nemotron_moe_vlm.RadioEncoder.sharded_state_dict` → HF radio_model
  leaves with no tp_group + no own sharded_state_dict).
- `MambaLayer.__init__` in `megatron/core/ssm/mamba_layer.py` plumbs
  pg_collection to the mixer but never sets `self.tp_group`.
- `ExtendedRMSNorm` at `megatron/core/ssm/mamba_mixer.py:93` never sees
  pg_collection at all.

Fixing each at the source would mean patches across core (Mamba) plus a
partial walk of RADIO's HF wrapper, validated against all existing
non-hetero users of those modules. The walker is the smaller intervention:
one place, hasattr-guarded, applied per branch with the correct pg.

Re-enables the walker (it was already in PR1; this commit only updates
the docstring to record the experiment's findings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kamran reverted the metadata-stash approach in NVIDIA#4801 (discussion
r3250847203) and adopted Li Ding's PR NVIDIA#4791 pattern, which is the same
ShardedObject round-trip we had originally. Align our MimoOptimizer with
that final shape:

- Restore `_extract_param_state_sharding_type` / `_restore_param_state_sharding_type`
  helpers. Hooks back into the existing `_iter_optimizer_sub_dicts` loop.
- Add `if not opt_sub: del sub_sd['optimizer']` to `_extract_param_groups`
  (from NVIDIA#4791) so the now-empty `'optimizer'` wrapper doesn't round-trip
  through common-state with undefined behavior on the load side.
- Drop `self._last_sharded_metadata` and the metadata-stash recover path
  from `load_state_dict` / `sharded_state_dict`. The ShardedObject route
  is self-contained and doesn't need caller-state coupling.

Kept (not in NVIDIA#4791, specific to our non-colocated hetero layout):
- `add_prefix_for_sharding(module_sd, f'mimo.{name}.')` so the two
  branches' identical inner ShardedObject keys (e.g.
  `chained_0.optimizer.distributed.dp_group_idx_0.*`) don't collide.
- `_get_replica_id` returning `(tp_rank, pp_rank, dp_rank)` (from NVIDIA#4801).

Validated on cw-dfw 1-node 8-GPU 20L mock (stage2, DistributedOptimizer
+ ChainedOptimizer + EP=4 + TP=2): save iter 3 exit 0, reload + resume
iter 4 with cosine LR continuation (1.59e-4 → 1.32e-4 → 1.01e-4),
matching loss trajectory across the boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three modules under our hetero save path don't store `self.tp_group` in
their constructors and therefore trip `MegatronModule.sharded_state_dict`'s
parallel_state fallback (`megatron/core/transformer/module.py:85`) in
heterogeneous-parallelism layouts where parallel_state is intentionally
not initialized. Fix them at the source instead of papering over with the
hasattr-guarded walker:

- `megatron/core/models/vision/radio.py:RADIOViTModel.__init__` — already
  extracts `tp_group` at line 129 for the embedder; now also stamps
  `self.tp_group = tp_group`.
- `megatron/core/ssm/mamba_layer.py:MambaLayer.__init__` — takes
  pg_collection and plumbs it into the mixer; now also stores
  `self.tp_group = pg_collection.tp` on the layer itself.
- `megatron/core/ssm/mamba_mixer.py:ExtendedRMSNorm` — adds an
  `__init__(*args, tp_group=None, **kwargs)` override that stores
  `self.tp_group` eagerly, and updates the single call site at line ~369
  to pass `tp_group=self.pg_collection.tp`. The lazy `hasattr` fallback
  inside `sharded_state_dict` is preserved for callers that don't pass
  tp_group.

With these three constructor fixes in place, the
`_propagate_tp_groups_for_checkpoint` walker (and `_stamp_tp_group`
helper) in `examples/mimo/training/hetero/runtime.py` is no longer needed.
Removed entirely.

Validated on cw-dfw 1-node 8-GPU 20L mock with the walker disabled:
- save iter 3 exit 0 (DistributedOptimizer + ChainedOptimizer + EP=4 + TP=2)
- reload iter 3 → resume iter 4-5 with cosine LR continuation
  (1.59e-4 → 1.32e-4 → 1.01e-4), exit 0
- losses match prior runs (iter 1: 12.187, iter 2: 12.190, iter 3: 12.177,
  resume iter 4: 11.817, iter 5: 11.264)

The downstream check `if not hasattr(self, 'tp_group')` in subsequent
descendants (TransformerBlock, TransformerLayer, Attention, MLP,
ColumnParallelLinear) was already satisfied by their own constructors;
verified by reading those files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yashaswikarnati
yashaswikarnati marked this pull request as ready for review May 15, 2026 22:10
@yashaswikarnati
yashaswikarnati merged commit 23bf04a into ykarnati/nmfw-464-nemotron-vlm-with-hetero-parallel May 15, 2026
1 check failed
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