Skip to content

Route non-Muon params through DistributedOptimizer - #4771

Merged
deepakn94 merged 10 commits into
NVIDIA:mainfrom
deepakn94:dnarayanan/distopt_for_non_muon_params
May 21, 2026
Merged

Route non-Muon params through DistributedOptimizer#4771
deepakn94 merged 10 commits into
NVIDIA:mainfrom
deepakn94:dnarayanan/distopt_for_non_muon_params

Conversation

@deepakn94

@deepakn94 deepakn94 commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Splits the DDP buffer for layer-wise optimizers so Muon-managed matrices stay in a shard-aligned LayerWiseDistributedOptimizer buffer, while everything else (embeddings, biases, LayerNorm weights) lands in a separate byte-level DistributedOptimizer buffer. The top-level optimizer becomes ChainedOptimizer([LayerWise(Muon), DistOpt(Adam)]). This drops the massive (dp_size - 1) × embedding_numel padding overhead that the all-LayerWise layout pays when there are unique-large params (typical for embeddings).

What changed

  • BufferKey gains an is_layer_wise_distributed_optimizer field; group_params_for_buffers reads it from a param attribute set by tag_params_for_buffer_routing before DDP wrap.
  • LayerWiseDistributedOptimizer.compute_full_param_layout dispatches per-buffer: LayerWise's shard-aligned layout for its buffers, DistributedOptimizer's byte-level layout for the rest.
  • LayerWiseDistributedOptimizer._shard_params_from_layout and set_bucket_layerwise_params_list filter to LayerWise-managed buckets only (_bucket_is_layer_wise_managed helper).
  • _get_megatron_emerging_optimizer constructs a real DistributedOptimizer for non-emerging-optimizer (Adam) groups when the layer-wise + layout path is active, chained at the top level alongside LayerWise.
  • partition_buckets uniqueness check relaxed to only enforce on uint8 buffers (so two bf16 buffers can coexist in one DDP wrapper).
  • New start_param_sync_for_bucket_group_subset helper: when LayerWise and DistOpt are chained, each calls start_param_sync on only its own bucket groups (filtered via a predicate) so the same buckets aren't all-gathered twice per step.
  • LayerWise param layout: replaced cross-layer size-matching with greedy-LPT bin-packing in contiguous backprop chunks, restoring the bucket_id == cur + 1 invariant in _ParamAndGradBuffer.__init__ and preserving overlap_grad_reduce dispatch timing.
  • Bucket finalisation now uses threshold = max(bucket_size, dp_size * chunk_max_param * 0.9) so high-dp configs don't emit narrow buckets where the chunk's largest param dominates shard size — keeps per-bucket padding overhead under ~11%.
  • _build_sharded_state_dict_metadata treats use_layer_wise_distributed_optimizer as equivalent to use_distributed_optimizer for sharding-type purposes, fixing the deprecated fully_sharded_model_space fallback that crashed checkpoint save post-Integrate LayerWiseDistributedOptimizer with DDP buffer infrastructure #4509.

Convergence

image

Performance

8B Llama-style (TP=4 PP=1, mb=1, seq=8192), --ddp-num-buckets 6, sweep over DP ∈ {8, 16, 32} × GA ∈ {1, 2, 4, 8}. Per-cell numbers are medians across the 4 GA points at that DP, hybrid minus legacy:

8b_throughput_vs_ga

Take-away: hybrid is throughput-neutral to slightly-better than legacy at every DP.

Guard assertions for not-yet-supported configurations

The split path fails fast (clear error message) on:

  • num_distributed_optimizer_instances > 1distributed_optimizer_instance_id is hardcoded to 0 in this path; needs proper computation to lift.
  • Expert-parallel non-Muon param groups — would need a second DistOpt instance with the expert-DP process group + expert buffers.

Both fall back cleanly when use_layer_wise_param_layout=False (the legacy LayerWise ping-pong path).

Test plan

  • Unit tests: 62 LayerWise + 6 metadata-builder tests pass on cw-dfw.
  • End-to-end save_checkpoint/load_checkpoint roundtrip on the LayerWise + DistOpt chain (test_optimizer_common_state_dict_hybrid in tests/unit_tests/dist_checkpointing/test_layer_wise_optimizer.py).
  • 8B Transformer sweep across {Adam, Muon legacy, Muon hybrid} × {4, 8, 16} nodes × GA ∈ {1, 2, 4, 8}.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented May 13, 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.

@deepakn94

Copy link
Copy Markdown
Contributor Author

/claude review

@claude claude Bot left a comment

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.

LGTM

@deepakn94
deepakn94 force-pushed the dnarayanan/distopt_for_non_muon_params branch from 694aa11 to 34cd925 Compare May 13, 2026 18:00
@deepakn94

Copy link
Copy Markdown
Contributor Author

/claude review

@claude claude Bot left a comment

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.

LGTM

@deepakn94

Copy link
Copy Markdown
Contributor Author

/ok to test 34cd925

@deepakn94

Copy link
Copy Markdown
Contributor Author

/ok to test d52da35

Comment thread megatron/core/distributed/param_and_grad_buffer.py Outdated
@deepakn94

Copy link
Copy Markdown
Contributor Author

/claude review

@claude claude Bot left a comment

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.

LGTM

@FDecaYed FDecaYed left a comment

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.

Other part looks ok but I have some concerns on the tagging layerwise managed param and DDP wrapping part. I think we'll need another PR to refactor it. current approach of hard coding muon split seems not correct in longer term

My understanding is optimizer setup needs to happen after DDP wrapping due to it changing param/grad buffer. But on the other hand, we want a "optimizer managed layout" that can make comm optimal. If done naturally, this means creating optimizer first.
So overall I think we should a single source of code dealing with adam/muon/soap/etc split, as well as producing layout. all run in the beginning and same information is feed into both DDP and optimizers init.

Comment thread megatron/core/optimizer/distrib_optimizer.py
deepakn94 and others added 8 commits May 19, 2026 13:25
Splits the DDP buffer for layer-wise optimizers so Muon-managed matrices stay
in a shard-aligned LayerWiseDistributedOptimizer buffer, while everything else
(embeddings, biases, LayerNorm weights) lands in a separate byte-level
DistributedOptimizer buffer. The top-level optimizer becomes
ChainedOptimizer([LayerWise(Muon), DistOpt(Adam)]). This drops the massive
(dp_size - 1) * embedding_numel padding overhead that the all-LayerWise layout
pays when there are unique-large params (typical for embeddings).

What changed:
- BufferKey gains an is_layer_wise_distributed_optimizer field;
  group_params_for_buffers reads it from a param attribute set by
  tag_params_for_buffer_routing before DDP wrap.
- LayerWiseDistributedOptimizer.compute_full_param_layout dispatches
  per-buffer: LayerWise's shard-aligned layout for its buffers,
  DistributedOptimizer's byte-level layout for the rest.
- LayerWiseDistributedOptimizer._shard_params_from_layout and
  set_bucket_layerwise_params_list filter to LayerWise-managed buckets only
  via the _bucket_is_layer_wise_managed helper.
- _get_megatron_emerging_optimizer constructs a real DistributedOptimizer for
  non-emerging-optimizer (Adam) groups when the layer-wise + layout path is
  active, chained at the top level alongside LayerWise.
- partition_buckets uniqueness check relaxed to only enforce on uint8 buffers
  so two bf16 buffers can coexist in one DDP wrapper.
- setup_process_groups_for_optimizer takes use_gloo_process_groups threaded
  through.

Guard assertions for not-yet-supported configurations: the split path fails
fast on num_distributed_optimizer_instances > 1 (instance_id is hardcoded to
0) and on expert-parallel non-Muon param groups (would need a second DistOpt
with the expert-DP group). Both fall back cleanly when
use_layer_wise_param_layout=False (the default in production).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When LayerWiseDistributedOptimizer and DistributedOptimizer are chained
(layer-wise + DistOpt split path), each was calling
``model_chunk.start_param_sync()`` which iterates through *all* DDP bucket
groups, so the same buckets ended up being all-gathered twice per step.

Adds ``start_param_sync_for_bucket_group_subset`` as a method on both
LayerWise and DistOpt — each implementation iterates only its own bucket
groups:

- LayerWise walks the DDP bucket groups and filters by
  ``_bucket_is_layer_wise_managed`` (default_for_untagged=True, since legacy
  LayerWise owns every bucket).
- DistOpt filters with ``default_for_untagged=False`` so untagged buckets
  (pure non-LayerWise paths) still count as DistOpt-managed.

To preserve DDP's FP8 / MXFP8 post-all-gather work, the per-bucket-group
sync goes through a new ``DistributedDataParallel._start_bucket_group_param_sync``
helper, which both optimizers call. ``DDP.start_param_sync`` is refactored
to delegate to the same helper so the FP8 logic lives in one place.

The LayerWise param sync now lives on ``step_with_ready_grads`` (not
``step``) so it also runs when LayerWise is a child of an outer
``ChainedOptimizer`` (which calls ``step_with_ready_grads`` directly and
bypasses ``step``).

Also default ``use_layer_wise_param_layout=True`` at the production
``get_model`` call site so live layer-wise training runs the
shard-aligned + DistOpt split path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous size-matching algorithm grouped equal-sized parameters from
arbitrary backprop positions into the same bucket, producing buckets
whose backprop range was non-contiguous. _ParamAndGradBuffer.__init__'s
backprop-order iteration then saw non-monotonic bucket ids and tripped
its bucket_id == cur + 1 invariant. Reusing the buffer's monotonic
emit order also stalled overlap_grad_reduce because a bucket only
dispatches reduce-scatter once all its params have grads, and a
non-contiguous bucket only completes well past the end of its backward
segment.

The new layout walks params in backprop order, accumulates them into a
contiguous chunk until the chunk reaches bucket_size, then bin-packs
the chunk into dp_size shards via greedy LPT (largest first, assign to
least-loaded shard). Each bucket therefore spans a single contiguous
backprop range -- restoring the assertion and the overlap_grad_reduce
dispatch timing -- while LPT keeps shard sizes close to balanced and
zero-padded when params_per_layer * num_layers divides dp_size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In the LayerWise + DistributedOptimizer split path, non-Muon params are
managed by a real DistributedOptimizer instance even though the arg
parser flips ``args.use_distributed_optimizer`` off (line 1462). With
the previous condition, ``_build_sharded_state_dict_metadata`` skipped
populating ``distrib_optim_sharding_type``, so when the DistOpt
sub-optimizer's ``sharded_state_dict`` ran it fell through to the
deprecated ``fully_sharded_model_space`` default, which sets
``flattened_range`` on the produced ShardedTensors. After commit
5ab481c (Dec 2025) removed flattened_range support from
``ShardedTensor.validate_metadata_integrity``, that path raises
``CheckpointingException("ShardedTensor.flattened_range is not
supported.")``.

Treat ``use_layer_wise_distributed_optimizer`` as equivalent to
``use_distributed_optimizer`` for metadata purposes, so the DistOpt
sub-optimizer gets ``dp_reshardable`` (or ``fully_reshardable`` /
``fsdp_dtensor`` as configured) instead of falling through to the
broken default.

Tests:

- CPU-only ``TestBuildShardedStateDictMetadata`` pins the truth table
  of ``_build_sharded_state_dict_metadata``; the new
  ``use_layer_wise_distributed_optimizer``-only rows are the regression
  guard.

- End-to-end ``test_optimizer_common_state_dict_hybrid`` runs a full
  ``save_checkpoint``/``load_checkpoint`` roundtrip through the hybrid
  ChainedOptimizer (LayerWise wrapping Muon + a sibling DistOpt). Test
  util adjustments to support this: ``setup_model_and_optimizer`` now
  threads ``use_gloo_process_groups`` through to
  ``get_megatron_optimizer`` (the hybrid DistOpt setup uses an explicit
  ``pg_collection`` which is incompatible with the Gloo-on default),
  and the optimizer state-seeding loop recurses into nested
  ChainedOptimizers and skips optimizers without ``init_state_fn``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop ``use_gloo_process_groups`` from ``_get_megatron_emerging_optimizer``.
  The hybrid LayerWise + DistOpt path always supplies an explicit
  ``pg_collection`` to ``setup_process_groups_for_optimizer``, which
  unconditionally rejects Gloo groups in that mode, so the parameter
  only ever held one legal value. Hardcode ``use_gloo_process_groups=False``
  at the call site and drop the matching plumbing from the
  ``setup_model_and_optimizer`` test util.

- Collapse ``dtype_to_buffer_map`` in ``partition_buckets`` to a single
  ``fp8_buffer`` lookup. The map was only consulted for the unique fp8
  (uint8) buffer; storing every non-uint8 buffer under its dtype was
  unused and obscured the uniqueness invariant. Direct scan + assert
  keeps the invariant and drops the dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The LPT-in-contiguous-backprop layout finalised each bucket as soon as
the chunk's raw numel reached ``bucket_size``. At high dp this left
each bucket with too few params to fill all ``dp_size`` shards, so the
LPT pack ended with most shards empty and the bucket's largest param
forcing ``dp_size * max_param`` of padding. On the 8B Llama-style
config at dp=32 with ``--ddp-num-buckets 6``, every Muon-side bucket
held ~21 params for 32 shards and burned ~414M elements of padding
each — total 1.98B of padding for 1.74B real params (113% overhead),
which manifested as a ~12 GB jump in rank-0 ``allocated`` versus the
legacy LayerWise path.

Extend the finalisation threshold to ``max(bucket_size, dp_size *
chunk_max_param * PADDING_FLOOR)`` so a chunk grows until its raw
numel can absorb most of the on-buffer cost imposed by its largest
param. With ``PADDING_FLOOR = 0.9`` each bucket's overhead stays at
or below ~11%. On the same 8B dp=32 config this collapses 6 buckets
into 3 well-balanced ones: overhead drops 113% -> 21%, padding drops
1.98B -> 369M, and rank-0 ``allocated`` drops 24.4 GB -> 14.8 GB.
4n/8n behaviour is unchanged because the floor only fires when
``dp_size * max_param > bucket_size``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mode

The model-chunk split into ``[[chunks[0]], chunks[1:]]`` and the matching
per-chunk ``overlap_param_gather_with_optimizer_step`` attribute are only
applied by ``get_megatron_optimizer`` outside the emerging-optimizer
branch. ``_get_megatron_emerging_optimizer`` returns before that split,
so the first-chunk post-optimizer-step param-gather dispatch
(``ChainedOptimizer.step_with_ready_grads`` line 1289) never fires and
the flag is silently a no-op in Muon mode. Fail fast at optimizer
construction so the misconfiguration is obvious instead of an
unexplained throughput loss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old ``is_layer_wise_distributed_optimizer`` field/attribute read as if
the boolean answered ``is this a distributed optimizer``, when it
actually answers ``is this buffer managed by the LayerWise optimizer``.
Rename consistently across BufferKey, the ``torch.nn.Parameter``
attribute set by ``tag_params_for_buffer_routing``, and the
``is_layer_wise_managed_param`` / ``_bucket_is_layer_wise_managed``
helpers. Pure rename, no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svcnvidia-nemo-ci

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started!

You can track the progress here: https://github.com/NVIDIA/Megatron-LM/actions/runs/26130597558

@svcnvidia-nemo-ci

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started!

You can track the progress here: https://github.com/NVIDIA/Megatron-LM/actions/runs/26138083006

deepakn94 and others added 2 commits May 20, 2026 10:16
The precomputed LayerWise param layout (and the DistOpt-for-non-Muon
routing it enables) changes bf16 reduction ordering versus the legacy
LayerWise ping-pong path, so loss curves differ bit-for-bit between
the two. Add a ``--no-use-layer-wise-param-layout`` opt-out so existing
runs that pinned golden values against the legacy path can keep their
numerics; the precomputed layout stays the default for new runs.

The two failing functional tests
(``gpt3_moe_mcore_te_ep8_resume_torch_dist_dist_muon`` and its
``_1node`` sibling) gain the opt-out flag so their existing golden
values continue to compare bit-for-bit. New
``..._param_layout`` and ``..._param_layout_1node`` variants exercise
the new default with golden values captured from the run that
generated the divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Phlip79

Phlip79 commented May 20, 2026

Copy link
Copy Markdown
Member

/ok to test b6309df

@svcnvidia-nemo-ci

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started!

You can track the progress here: https://github.com/NVIDIA/Megatron-LM/actions/runs/26207656966

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

Labels

Approved All necessary approvals have been made complexity: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants