Skip to content

fix: prevent PP hang in MoE aux-loss metric reduction - #3419

Merged
yfw merged 3 commits into
NVIDIA-NeMo:mainfrom
dafu-wu:fix/moe-auxloss-pp-hang
Aug 22, 2026
Merged

fix: prevent PP hang in MoE aux-loss metric reduction#3419
yfw merged 3 commits into
NVIDIA-NeMo:mainfrom
dafu-wu:fix/moe-auxloss-pp-hang

Conversation

@dafu-wu

@dafu-wu dafu-wu commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What does this PR do ?

Fixes a silent, no-traceback hang in get_moe_metrics() when training a
MoE model with moe_router_load_balancing_type=aux_loss and
pipeline_model_parallel_size > 1.

Symptom

With aux_loss load balancing enabled and PP > 1, training progresses for a
number of steps and then hangs: no exception, no OOM, no exit — GPUs go idle
and the process never returns. The same recipe with
moe_router_load_balancing_type=none runs indefinitely without issue.

Root cause

get_moe_metrics() calls reduce_aux_losses_tracker_across_ranks(), which runs
torch.distributed.all_reduce(values, group=pp_group) for each name present
in the local MoE logging tracker
:

# megatron/core/transformer/moe/moe_utils.py
def reduce_aux_losses_tracker_across_ranks(...):
    tracker = get_moe_layer_wise_logging_tracker()
    if track_names is None:
        track_names = tracker.keys()          # <-- only the LOCAL rank's keys
    ...
    for name in track_names:
        values = tracker[name]["values"]
        torch.distributed.all_reduce(values, group=pp_group)   # <-- collective

The tracker entry is created lazilysave_to_aux_losses_tracker only
allocates torch.zeros(num_layers) the first time a rank actually saves a loss.
So if some PP rank did not save an aux loss this step — e.g. a pipeline
stage that holds no MoE layer, or an MTP MoE layer that lives only on the last
stage — that rank has no key for load_balancing_loss, skips the all_reduce
for that name, while other PP ranks perform it. The collective then mismatches
its participants across the PP group and hangs.

This is exactly the situation Megatron's own track_moe_metrics guards against
via force_initialize=True; NeMo-RL calls reduce_aux_losses_tracker_across_ranks()
directly and bypassed that guard.

Fix

Mirror the force_initialize=True behaviour inside get_moe_metrics():
before the reduction, pre-initialize the tracker on every rank so each
tracked name exists with an equally-sized zero tensor of length
num_layers + mtp_num_layers — the same size the router uses in
save_to_aux_losses_tracker. num_layers / mtp_num_layers are threaded from
model_config in both the policy and value Megatron workers.

When num_layers is None (all existing/other call sites), the behaviour is
completely unchanged — this is a no-op guarded by an opt-in argument.

Usage

No config change required. The guard activates automatically for MoE models
(num_moe_experts > 1) in the Megatron policy/value workers, which now pass
num_layers / mtp_num_layers from model_config. After this change,
moe_router_load_balancing_type=aux_loss with PP > 1 no longer hangs.

Files

  • nemo_rl/models/megatron/common.py — pre-initialize tracker in get_moe_metrics()
  • nemo_rl/models/policy/workers/megatron_policy_worker.py — pass num_layers/mtp_num_layers
  • nemo_rl/models/value/workers/megatron_value_worker.py — pass num_layers/mtp_num_layers

Before your PR is "Ready for review"

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you add or update any necessary documentation?
  • Does the PR affect components that other people are working on?

Additional Information

Repro parallelism where the hang was observed: PP=4, TP=8, EP=8 on a
Nemotron-3-style 120B MoE SFT run; also reproducible with MTP enabled (the MTP
MoE layer lives only on the last PP stage, guaranteeing the tracker-key
asymmetry across the PP group).

@dafu-wu
dafu-wu requested review from a team as code owners July 29, 2026 22:13
@copy-pr-bot

copy-pr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@terrykong terrykong left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@yfw could you review?

@terrykong
terrykong requested a review from yfw July 30, 2026 22:27
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Aug 2, 2026
@dafu-wu

dafu-wu commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test ddda2fd

@dafu-wu

dafu-wu commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@yfw can you help review it?

@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-maintainers Waiting on maintainers to respond label Aug 6, 2026

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

Thanks for digging into this — the failure mode you describe is real. force_initialize=True is exactly the right precedent to reach for.

The main thing I'd like to work through with you: against the Megatron-Core this repo currently pins (0.19.0, via 3rdparty/Megatron-Bridge-workspace/Megatron-Bridge/3rdparty/Megatron-LM @ cf2f07d, per uv.lock), the pre-initialization doesn't reach Megatron's tracker — so the hang would still occur. Details in the first inline comment, including a short CPU-only script you can run to check it on your side.

This is a version boundary, not a misreading on your part — I checked the upstream history rather than assuming. Your patch is correct against megatron-core ≤0.17.x, where that accessor returned the live global dict and the entry shape (including reduce_group_has_dp) matched exactly what you wrote. The MoE-logging refactor landed in core_v0.18.0 and turned that accessor into a copy. So this needs a port to the newer API, not a rethink — the diagnosis itself holds up.

For calibration, the preconditions for the hang look like: an MoE model, and a non-zero aux-loss balancing type, and PP>1, and a layout where some PP stage owns no aux-loss-recording MoE layer. No config shipped in this repo combines those (the aux_loss exemplars are PP=1; the MoE+PP ones use "none"), so this is a user-config-reachable bug — consistent with how you found it.

Reviewed with a team of specialized agents; the mechanism finding was independently reproduced by execution three times.

Generated by Claude Code

Comment thread nemo_rl/models/megatron/common.py Outdated
Comment thread nemo_rl/models/policy/workers/megatron_policy_worker.py
Comment thread nemo_rl/models/megatron/common.py Outdated
Comment thread nemo_rl/models/megatron/common.py Outdated
Comment thread nemo_rl/models/megatron/common.py
Comment thread nemo_rl/models/megatron/common.py
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-customer Waiting on the original author to respond label Aug 8, 2026
@dafu-wu
dafu-wu requested a review from a team as a code owner August 11, 2026 01:25
@dafu-wu

dafu-wu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for digging into this — the failure mode you describe is real. force_initialize=True is exactly the right precedent to reach for.

The main thing I'd like to work through with you: against the Megatron-Core this repo currently pins (0.19.0, via 3rdparty/Megatron-Bridge-workspace/Megatron-Bridge/3rdparty/Megatron-LM @ cf2f07d, per uv.lock), the pre-initialization doesn't reach Megatron's tracker — so the hang would still occur. Details in the first inline comment, including a short CPU-only script you can run to check it on your side.

This is a version boundary, not a misreading on your part — I checked the upstream history rather than assuming. Your patch is correct against megatron-core ≤0.17.x, where that accessor returned the live global dict and the entry shape (including reduce_group_has_dp) matched exactly what you wrote. The MoE-logging refactor landed in core_v0.18.0 and turned that accessor into a copy. So this needs a port to the newer API, not a rethink — the diagnosis itself holds up.

For calibration, the preconditions for the hang look like: an MoE model, and a non-zero aux-loss balancing type, and PP>1, and a layout where some PP stage owns no aux-loss-recording MoE layer. No config shipped in this repo combines those (the aux_loss exemplars are PP=1; the MoE+PP ones use "none"), so this is a user-config-reachable bug — consistent with how you found it.

Reviewed with a team of specialized agents; the mechanism finding was independently reproduced by execution three times.

Generated by Claude Code

@yfw Thanks for the thorough review — the mechanism finding is correct, and I've pushed 2df90ac9 addressing all six comments.

On the megatron-core version question

The repro ran on NeMo-RL 37cb7de1 ("perf: perf recipe changes to enable force on policy ratio", #3135), which pins:

NeMo-RL 37cb7de1
  └─ Megatron-Bridge 554c7b93
       └─ Megatron-LM 00225507  =  megatron-core 0.19.0

Same minor as this PR's base (cf2f07d). So this wasn't a ≤0.17.x environment — at 00225507:

Which means the original patch was inert on the very environment where I reproduced the hang. I had confirmed the root cause (py-spy on the stuck ranks showed the PP all_reduce participant mismatch) but never re-ran the 4-node repro after writing the patch, so I never validated the fix itself. Your finding is exactly right — and thanks for checking the upstream history rather than assuming.

What changed in 2df90ac9

# Comment Fix
1 tracker shim MoEMetricsTracker.ensure_initialized() — what report(force_initialize=True) calls. Also drops the hardcoded device="cuda" and the stale reduce_group_has_dp field.
2 third call site _finish_train_step_body now passes the same kwargs; all three sites symmetric.
3 track_names New get_aux_loss_track_names(model_config) covering load_balancing_loss / seq_load_balancing_loss / global_load_balancing_loss / z_loss, incl. the list form of moe_router_load_balancing_type.
4 zero-metric regression Gate is now num_layers is not None and track_names — empty under "none", so nothing is pre-initialized.
5 tests Assert against the real get_moe_metrics_tracker().metrics, no stubbing of the deprecated accessor. Verified they fail on ddda2fde and pass on 2df90ac9.
6 docstring / shadowing Args: completed per the get_mtp_metrics() convention; loop-local renamed num_tracked_layers.

One deliberate deviation from the suggested snippet

I used exact matching plus a non-zero coefficient check rather than the substring test in training.py:2689:

  • "aux_loss" in "seq_aux_loss" is True, so a substring test would pre-initialize load_balancing_loss for seq_aux_loss users — re-creating the zero-metric problem from comment 4.
  • sft.yaml:142 and dpo.yaml:157 set moe_router_load_balancing_type: "aux_loss" but never set moe_aux_loss_coeff (mcore default 0.0), so the router returns early and records nothing.

This mirrors MoETopKRouter.get_aux_loss_coeff / is_aux_loss_enabled. Happy to switch to the upstream form if you'd rather stay byte-identical with training.py.

@dafu-wu
dafu-wu force-pushed the fix/moe-auxloss-pp-hang branch 2 times, most recently from 22e8c0a to 52538e2 Compare August 11, 2026 01:55
@dafu-wu

dafu-wu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 52538e2

@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-customer Waiting on the original author to respond label Aug 11, 2026
@dafu-wu

dafu-wu commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@yfw @terrykong can you help review it again?

@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Aug 14, 2026
@yfw

yfw commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

/ok to test 52538e2

@yfw yfw added the CI:Lfast Runs a fast test suite and re-use nightly `main` container (but sync dependencies to PRs version) label Aug 17, 2026
@yfw

yfw commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

/ok to test e492ecc

@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-maintainers Waiting on maintainers to respond label Aug 18, 2026
When moe_router_load_balancing_type=aux_loss is enabled with
pipeline_model_parallel_size > 1, training could silently hang inside
get_moe_metrics().

Root cause: reduce_aux_losses_tracker_across_ranks() runs
torch.distributed.all_reduce over the pipeline-parallel group for each
name present in the *local* MoE logging tracker. Megatron creates the
tracker entry lazily (save_to_aux_losses_tracker only allocates
torch.zeros(num_layers) the first time a rank saves a loss). If some PP
rank did not save an aux loss this step (e.g. a stage with no MoE layer,
or an MTP MoE layer that lives only on the last stage), that rank skips
the all_reduce for the name while other PP ranks perform it, so the
collective mismatches participants and hangs with no traceback.

Fix: mirror Megatron's own track_moe_metrics(force_initialize=True)
guard. Before the reduction, pre-initialize the tracker on every rank so
each tracked name exists with an equally-sized zero tensor of length
(num_layers + mtp_num_layers), matching the size the router uses in
save_to_aux_losses_tracker. num_layers/mtp_num_layers are threaded from
model_config in both the policy and value Megatron workers. When
num_layers is None the behaviour is unchanged, so this is a no-op for all
existing call sites.

Signed-off-by: dafu-wu <wuchengyi2006@163.com>
Addresses review feedback on the aux-loss pre-initialization added to
get_moe_metrics(). The previous approach did not take effect on the pinned
megatron-core, and would have introduced a zero-valued metric once it did.

Reach the live tracker. get_moe_layer_wise_logging_tracker() is a deprecated
shim that rebuilds a dict copy on every call, so writing the pre-initialized
entry through it was discarded and reduce_aux_losses_tracker_across_ranks()
still skipped the all_reduce -- the hang was not actually prevented. Use
MoEMetricsTracker.ensure_initialized() instead, which is what Megatron's own
report(force_initialize=True) calls. It also picks the device itself, removing
the hardcoded device="cuda", and drops the stale reduce_group_has_dp field
(renamed to needs_dp_avg in the MoE logging refactor).

Derive track_names from the model config. The router records a distinct name
per balancing type (load_balancing_loss, seq_load_balancing_loss,
global_load_balancing_loss, z_loss), each driving its own all_reduce, and
moe_router_load_balancing_type may be a list. Hardcoding "load_balancing_loss"
left seq_aux_loss and global_aux_loss users hanging. get_aux_loss_track_names()
matches the balancing type exactly and requires a non-zero coefficient, which
mirrors MoETopKRouter.get_aux_loss_coeff/is_aux_loss_enabled: a substring test
would treat seq_aux_loss as aux_loss, and configs that name a balancing type
without setting moe_aux_loss_coeff leave the router recording nothing.

Gate pre-initialization on track_names being non-empty. Workers only check
num_moe_experts > 1, not whether load balancing is on. With the default
moe_router_load_balancing_type: "none" the tracker stays empty today and the
metric is dropped by the falsy guard; an effective pre-init would have started
reporting a permanently-zero load_balancing_loss for every MoE config in the
repo. Deriving track_names yields an empty list under "none", so nothing is
pre-initialized.

Also pass the pre-init arguments at the third call site,
_finish_train_step_body (single-controller / split-API path), which was missed
and has no MoE coverage in the nightly suite; document the new parameters in
the Args block; and rename the loop-local num_layers to num_tracked_layers so
it no longer shadows the parameter.

Tests assert against the real get_moe_metrics_tracker().metrics rather than
stubbing the deprecated accessor -- a stubbed plain dict is live, so such a
test would pass while production stayed a no-op.

Signed-off-by: dafu-wu <wuchengyi2006@163.com>
@dafu-wu
dafu-wu force-pushed the fix/moe-auxloss-pp-hang branch from e492ecc to 1857253 Compare August 18, 2026 17:24
@dafu-wu

dafu-wu commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@yfw Thanks for approving the PR! Could you also approve and run the four pending workflows? I don’t have permission to approve them.

@vikalluru

Copy link
Copy Markdown

@dafu-wu @yfw - Can we merge the PR now?

@yfw

yfw commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

/ok to test b43bbf5

@yfw
yfw enabled auto-merge (squash) August 21, 2026 16:53
@yfw
yfw merged commit 2ebd36f into NVIDIA-NeMo:main Aug 22, 2026
83 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI:Lfast Runs a fast test suite and re-use nightly `main` container (but sync dependencies to PRs version) community-request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants