Skip to content

Balance LayerWise optimizer shards by Newton-Schulz cost, not parameter size - #6379

Merged
deepakn94 merged 6 commits into
NVIDIA:mainfrom
deepakn94:dnarayanan/balance_layerwise_lpt
Aug 12, 2026
Merged

Balance LayerWise optimizer shards by Newton-Schulz cost, not parameter size#6379
deepakn94 merged 6 commits into
NVIDIA:mainfrom
deepakn94:dnarayanan/balance_layerwise_lpt

Conversation

@deepakn94

@deepakn94 deepakn94 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

LayerWiseDistributedOptimizer assigns whole Muon matrices to dp_size shards, packing them largest-first onto the least-loaded shard. It measures "loaded" by numel, which breaks under GTP: a GTP-sharded weight stores only its local shard, but Newton-Schulz runs on the full all-gathered matrix, so its real cost is gtp_remat_size times what its numel suggests.

Ordering by numel therefore starts with the cheapest work and leaves the most expensive matrices to fill in wherever there is room, which piles them onto shards that are already busy. Every rank waits for the slowest, so the Muon step is gated on whichever shard collected them.

Changes

  1. _ns_compute_cost() estimates Newton-Schulz cost from the full post-all-gather shape (GTP shards along dim 0): max(M, N) * min(M, N)^2, the dominant term in the orthogonalization.
  2. Order and assign by that cost instead of numel.
  3. Track shard_compute_loads persistently across buckets, so expensive matrices spread over the whole buffer rather than clustering inside each bucket. shard_cursors still resets per bucket, which preserves the memory layout. A per-bucket numel cap (_NUMEL_EPSILON = 0.3) bounds the extra padding this can introduce, and assignment falls back to the previous least-numel rule when no shard satisfies the cap.
  4. Correct the bucket-cutter comment from Treat LayerWise bucket_size as a soft minimum and fill would-be padding with real parameters #5415. See Notes.

Results

Measured end-to-end on Ultra-half (54-layer hybrid Mamba-MoE, 1024 GB200 GPUs, Muon + GTP=64): 2080 -> 1707 ms/iter, a 19% throughput improvement.

Tests

tests/unit_tests/distributed/test_layer_wise_param_layout.py gains TestComputeBalancedLayout, built so numel and cost disagree: three GTP-sharded matrices whose Newton-Schulz cost is 16x the largest dense matrix but whose numel is the smallest in the set. Numel-ordered placement puts all three on one shard for a 3.77x compute imbalance; cost-ordered placement spreads them across three shards for 1.33x. The test asserts both the spread and the imbalance bound, so it fails against the previous heuristic.

The pre-existing cases in TestSizeMatchingLayout all use 1-D params, where _ns_compute_cost falls back to nelement() and cost equals numel. They therefore pass identically under both heuristics and cannot detect this change; a class docstring now says so.

Notes

_emit_bucket sorts a chunk before packing it, while the incremental estimate in the bucket cutter walks params in backprop order. #5415's comment claimed the two mirror each other. They do not, and after this PR they also differ in what they measure. Equal-sized params make both differences vanish; mixed sizes can send params to different shards, so absorbing occasionally enlarges a bucket instead of filling it.

@kunlunl raised this in review of #5415 with a counterexample, now pinned as test_mixed_sizes_can_absorb_into_larger_bucket: dp_size=2, backprop-order numels [192, 192, 256, 128, 128, 192], bucket_size=448 emits 1408 elements where closing at the threshold emits 1280. Documented rather than fixed, since an exact estimate would mean re-sorting the chunk on every param, and the target case is equal-sized expert matrices.

This helps without GTP too, though less dramatically. Newton-Schulz cost is numel * min(M, N), so numel misvalues a matrix by its aspect ratio: across this model's shapes that factor ranges from 512 to 10240, meaning a tall-thin matrix and a square one of the same byte count do very different amounts of work. Balancing numel equalises bytes per shard; balancing cost equalises the Newton-Schulz FLOPs that actually gate the step. Without GTP the two orderings largely agree and the gain is small, but it grows with dp_size, where there is less slack to absorb a misranked matrix, and it is never worse.

The shared-embedding branch is untouched.

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Aug 9, 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
deepakn94 marked this pull request as ready for review August 9, 2026 22:35
@deepakn94
deepakn94 requested review from a team as code owners August 9, 2026 22:35
@deepakn94
deepakn94 marked this pull request as draft August 9, 2026 22:41
@fanshiqing fanshiqing linked an issue Aug 10, 2026 that may be closed by this pull request
deepakn94 and others added 2 commits August 11, 2026 12:34
The existing LPT bin-packing sorts params by numel and assigns each to
the least-loaded shard by numel. With GTP, this creates severe compute
imbalance: a GTP-sharded param with local shape [548, 8192] has the
same numel as a non-GTP param of that size, but its Newton-Schulz cost
is 64x higher because NS operates on the full all-gathered [35072, 8192]
shape.

Three changes:
1. _ns_compute_cost() estimates NS cost using the full post-AllGather
   shape: max(M,N) * min(M,N)^2.
2. Sort and assign by compute cost instead of numel.
3. Persistent shard_compute_loads across buckets (shard_cursors still
   reset per bucket for correct memory layout) with a per-bucket numel
   cap (epsilon=0.3) to bound padding.

Reduces compute imbalance from 137x to 1.3x in simulation. Measured
19% throughput improvement on Ultra-half (54-layer hybrid Mamba-MoE,
1024 GB200 GPUs, Muon + GTP=64): 2080 -> 1707 ms/iter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Deepak Narayanan <dnarayanan@nvidia.com>
_emit_bucket now assigns by Newton-Schulz cost, but the incremental estimate
in the bucket cutter still tracks numel, so the comment claiming it mirrors
_emit_bucket is no longer true. Say what it actually does and note that it
errs optimistic, producing a slightly larger bucket rather than an invalid
layout.

Add a test covering the placement change: three GTP-sharded params whose
Newton-Schulz cost is 16x the largest dense param but whose numel is the
smallest. Numel-ordered placement piles all three onto one shard for a 3.77x
compute imbalance; cost-ordered placement spreads them, giving 1.33x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Deepak Narayanan <dnarayanan@nvidia.com>

@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.

Traced the new packing rule against every pre-existing TestSizeMatchingLayout case: the 30% numel cap does force the empty-candidates fallback in a few of them, but since the fallback reduces to the old least-numel rule the placements and asserted buffer sizes are unchanged. The candidate filter also mirrors the real placement arithmetic (pad_param_start(cursor) + numel), so an admitted shard genuinely stays under the cap.

The claim that equal-sized params make the _absorbs estimate exact holds: with equal costs, persistent shard_compute_loads only rotates which shard starts a bucket, so the multiset of shard_cursors — and hence max(shard_cursors) and the bucket size — is unchanged. The cost/imbalance numbers in the new tests and docstrings check out (268,435,456 vs 16,777,216; 1.33x; 1024 + 384 = 1408 vs 768 + 512 = 1280), and test_gtp_params_land_on_different_shards_in_each_bucket really does depend on the loads persisting, so it would fail if they were reset per bucket.

No new global process-group reads in megatron/core.

Nit: the _ns_compute_cost docstring says "for a 2D parameter", but the first branch is the non-2D nelement() fallback that the 1-D cases in TestSizeMatchingLayout rely on — "for a parameter" would match the body.

(Review by inspection; I did not execute the suite.)

The summary line said "for a 2D parameter", but the first branch returns
nelement() for anything that is not 2D, which is the path every 1-D case in
TestSizeMatchingLayout takes. Describe both branches in the order the body
handles them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Deepak Narayanan <dnarayanan@nvidia.com>
@deepakn94

Copy link
Copy Markdown
Contributor Author

/ok to test 4c62a5c

@svcnvidia-nemo-ci svcnvidia-nemo-ci added the Approved All necessary approvals have been made label Aug 12, 2026
@deepakn94
deepakn94 enabled auto-merge August 12, 2026 06:09
@deepakn94
deepakn94 added this pull request to the merge queue Aug 12, 2026
@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/31605644484

Merged via the queue into NVIDIA:main with commit 8534490 Aug 12, 2026
91 of 93 checks passed
@deepakn94
deepakn94 deleted the dnarayanan/balance_layerwise_lpt branch August 12, 2026 15:32
xuwchen pushed a commit to xuwchen/Megatron-LM that referenced this pull request Aug 13, 2026
…er size (NVIDIA#6379)

Signed-off-by: Deepak Narayanan <dnarayanan@nvidia.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 8534490)
xuwchen pushed a commit to xuwchen/Megatron-LM that referenced this pull request Aug 17, 2026
…er size (NVIDIA#6379)

Signed-off-by: Deepak Narayanan <dnarayanan@nvidia.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 8534490)
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: low nemotron

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] GTP+Muon

5 participants