Skip to content

fix(recover): warm up NCCL communicators before the first post-recover step - #1548

Open
Le8r0nJames wants to merge 3 commits into
mainfrom
zjw/recover-comm-warmup
Open

fix(recover): warm up NCCL communicators before the first post-recover step#1548
Le8r0nJames wants to merge 3 commits into
mainfrom
zjw/recover-comm-warmup

Conversation

@Le8r0nJames

Copy link
Copy Markdown
Collaborator

Description

PyTorch builds 2-rank communicators for unbatched pipeline send/recv lazily, and NCCL connects transport buffers per protocol class on first use. After a recover restart those connects happen inside the first ppo_update, when the device is already at peak occupancy, and the ~10MB transport calloc can fail on the PP-last-stage ranks (NCCL 'Failed to CUDA calloc' at operation=Connect) — observed on memory-tight multi-node MoE restarts.

This PR exercises every communicator the train step needs right after the recover checkpoint load, while memory is still light:

  • sweeps all registered NCCL process groups (a hand-written enumeration misses groups created by other components, e.g. MoE expert grad-bucket reductions, which then still connect lazily at peak memory) with all_reduce and all_to_all_single in both message-size protocol classes;
  • exercises the distributed-optimizer collective shapes and both pipeline directions in batched and unbatched form;
  • transport buffers persist for the process lifetime, so a single warmup covers all later steps.

The recover handler invokes the hook via duck-typing; engines without warmup_communicators are unaffected. Warmup failures degrade to lazy connection with a warning instead of failing the restart. AREAL_SKIP_COMM_WARMUP=1 disables the warmup.

Related Issue

N/A

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📝 Documentation update
  • ♻️ Refactoring
  • ⚡ Performance improvement
  • ✅ Test coverage improvement

Checklist

  • I have read the
    Contributing Guide
  • Pre-commit hooks pass (pre-commit run --all-files)
  • Relevant tests pass
  • Documentation updated (not applicable)
  • Branch is up to date with main
  • Self-reviewed
  • This PR was created by a coding agent via /create-pr
  • This PR is a breaking change

Additional Context

Surfaced on restarts where the first post-recover step runs near peak memory (colocated/offload-heavy setups); the lazy-connect mechanism itself is generic to any PP + recover configuration. Multi-GPU behavior was validated on a 32-GPU MoE recover restart: previously failing first-step connects now complete during the post-load window.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a communicator warmup mechanism to eagerly initialize NCCL communicators and allocate transport buffers during recovery restarts, preventing out-of-memory errors at peak occupancy. This is implemented across MegatronEngine, TrainController, and the recovery utility. Feedback highlights a potential crash during DP group warmup if the data-parallel size does not evenly divide the tensor size, and suggests wrapping the DP and PP warmup blocks in individual try-except blocks to improve robustness.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread areal/engine/megatron_engine.py Outdated
Comment on lines +1375 to +1416
dp_group = mpu.get_data_parallel_group(with_context_parallel=True)
dp_size = dist.get_world_size(group=dp_group)
if dp_size > 1:
shard = torch.empty(
large.numel() // dp_size, dtype=large.dtype, device=device
)
dist.reduce_scatter_tensor(shard, large, group=dp_group)
dist.all_gather_into_tensor(large, shard, group=dp_group)
pp_size = mpu.get_pipeline_model_parallel_world_size()
if pp_size > 1:
pp_rank = mpu.get_pipeline_model_parallel_rank()
nxt = mpu.get_pipeline_model_parallel_next_rank()
prv = mpu.get_pipeline_model_parallel_prev_rank()
is_first = mpu.is_pipeline_first_stage(ignore_virtual=True)
is_last = mpu.is_pipeline_last_stage(ignore_virtual=True)
for buf in (small, large):
send_buf = buf.clone()
recv_buf = torch.empty_like(buf)
# Unbatched pair comms, both directions; even ranks send
# first so the chain cannot deadlock.
for send_peer, recv_peer, do_send, do_recv in (
(nxt, prv, not is_last, not is_first),
(prv, nxt, not is_first, not is_last),
):
if pp_rank % 2 == 0:
if do_send:
dist.send(send_buf, send_peer)
if do_recv:
dist.recv(recv_buf, recv_peer)
else:
if do_recv:
dist.recv(recv_buf, recv_peer)
if do_send:
dist.send(send_buf, send_peer)
ops = []
if not is_last:
ops.append(dist.P2POp(dist.isend, send_buf, nxt))
if not is_first:
ops.append(dist.P2POp(dist.irecv, recv_buf, prv))
if ops:
for work in dist.batch_isend_irecv(ops):
work.wait()

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.

high

Issue: Potential crash on non-power-of-two DP sizes and lack of exception handling

  1. Size Mismatch in reduce_scatter_tensor / all_gather_into_tensor:
    large is initialized with 16 * 1024 * 1024 (16,777,216) elements. If the data-parallel size (dp_size) is not a divisor of this number (e.g., dp_size = 3, 5, 6, etc.), large.numel() // dp_size will result in a size mismatch during reduce_scatter_tensor and all_gather_into_tensor, raising a RuntimeError and crashing the warmup. Slicing large to a multiple of dp_size (large[:shard_size * dp_size]) resolves this.

  2. Robustness:
    The DP and PP warmup sections are currently outside any try...except block. If any collective or parallel state query fails, it will crash the entire warmup process and fail the recovery load. Wrapping both sections in individual try...except blocks ensures that a failure in one section does not prevent the other sections from running, and gracefully falls back to lazy connection with a warning.

        try:
            dp_group = mpu.get_data_parallel_group(with_context_parallel=True)
            dp_size = dist.get_world_size(group=dp_group)
            if dp_size > 1:
                shard_size = large.numel() // dp_size
                shard = torch.empty(
                    shard_size, dtype=large.dtype, device=device
                )
                dist.reduce_scatter_tensor(shard, large[:shard_size * dp_size], group=dp_group)
                dist.all_gather_into_tensor(large[:shard_size * dp_size], shard, group=dp_group)
        except Exception:
            self.logger.warning(
                "communicator warmup skipped DP group", exc_info=True
            )
        try:
            pp_size = mpu.get_pipeline_model_parallel_world_size()
            if pp_size > 1:
                pp_rank = mpu.get_pipeline_model_parallel_rank()
                nxt = mpu.get_pipeline_model_parallel_next_rank()
                prv = mpu.get_pipeline_model_parallel_prev_rank()
                is_first = mpu.is_pipeline_first_stage(ignore_virtual=True)
                is_last = mpu.is_pipeline_last_stage(ignore_virtual=True)
                for buf in (small, large):
                    send_buf = buf.clone()
                    recv_buf = torch.empty_like(buf)
                    # Unbatched pair comms, both directions; even ranks send
                    # first so the chain cannot deadlock.
                    for send_peer, recv_peer, do_send, do_recv in (
                        (nxt, prv, not is_last, not is_first),
                        (prv, nxt, not is_first, not is_last),
                    ):
                        if pp_rank % 2 == 0:
                            if do_send:
                                dist.send(send_buf, send_peer)
                            if do_recv:
                                dist.recv(recv_buf, recv_peer)
                        else:
                            if do_recv:
                                dist.recv(recv_buf, recv_peer)
                            if do_send:
                                dist.send(send_buf, send_peer)
                    ops = []
                    if not is_last:
                        ops.append(dist.P2POp(dist.isend, send_buf, nxt))
                    if not is_first:
                        ops.append(dist.P2POp(dist.irecv, recv_buf, prv))
                    if ops:
                        for work in dist.batch_isend_irecv(ops):
                            work.wait()
        except Exception:
            self.logger.warning(
                "communicator warmup skipped PP group", exc_info=True
            )

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6b9030e: the DP warmup now truncates the buffer to shard_numel * dp_size so reduce_scatter/all_gather shapes stay consistent for any DP size, and the DP block degrades to lazy connection on failure like the per-group sweep. The pipeline P2P block intentionally stays outside a try/except: a one-sided exception there would leave the peer blocked, which is worse than surfacing the error - and the warmup as a whole is now opt-in.

Comment thread areal/engine/megatron_engine.py Outdated
those allocations happen up front; the buffers persist across
offload/onload for the lifetime of the process.
"""
if os.environ.get("AREAL_SKIP_COMM_WARMUP", "").strip() == "1":

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.

It's better to change the default value to 1 here, do not silently change the behavior, as there is still a risk of hanging in the warm up section.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 6b9030e - the warmup is now opt-in: AREAL_COMM_WARMUP=1 enables it and the default keeps the previous lazy-connection behavior. The docstring and commit message now state the hang rationale explicitly.

Comment thread areal/engine/megatron_engine.py Outdated
continue
dist.all_reduce(small.clone(), group=group)
dist.all_reduce(large.clone(), group=group)
chunk = 4096

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.

What does this hardcoding represent here? Can it be applicable to tensors of different sizes? Please provide detailed explanation in the docstring or comments.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added a comment block in 6b9030e: the two probes do not model training tensor shapes - they sit on each side of NCCL's message-size protocol thresholds (LL/LL128 vs Simple; 2KB vs 32MB bf16 payloads), and NCCL allocates transport buffers per communicator and protocol class on first use. It targets the message classes observed in training rather than guaranteeing coverage of every tensor size, since protocol selection also depends on collective type, topology, and NCCL settings.

sitabulaixizawaluduo added a commit that referenced this pull request Jul 22, 2026
@Le8r0nJames
Le8r0nJames force-pushed the zjw/recover-comm-warmup branch 2 times, most recently from 6b9030e to f1a48af Compare July 24, 2026 07:05
def get_device_stats(self) -> DeviceRuntimeInfo:
return DeviceRuntimeInfo.get_current()

def warmup_communicators(self) -> None:

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.

If the issue only occurs under the conditions of mcore version 0.11 and ppsize=2, add a judgment in the function content to avoid undefined behavior as much as possible.

…r step

PyTorch builds 2-rank communicators for unbatched pipeline send/recv
lazily and NCCL connects transport buffers per protocol on first use.
On a recover restart the backward-direction pipeline pairs therefore
connect inside the first ppo_update, when the PP-last-stage ranks are
already at peak occupancy, and the ~10MB transport calloc fails.

Add an opt-in communicator warmup (AREAL_COMM_WARMUP=1), run right
after the recover checkpoint load while memory is still light: sweep
every registered NCCL process group with both message-size protocol
classes, plus the distributed-optimizer collective shapes and both
unbatched and batched pipeline P2P directions. The transport buffers
persist across offload/onload for the process lifetime.

The warmup stays disabled by default because it issues collectives on
every group and would hang on an already-unhealthy rank; individual
warmup failures degrade to the previous lazy-connection behavior.
The warmup reached for torch.cuda directly while the rest of the engine
goes through current_platform, which pins it to CUDA. That is the wrong
way round here: eager communicator setup exists in this tree because
HCCL is the backend most prone to failing on a lazy first collective.

The engine already resolves its own device during setup, so take it from
there instead of asking the runtime again, and synchronise through the
platform like the other seven call sites in this file. Skip the warmup
altogether on CPU-only platforms, matching warmup_process_groups.
The pipeline probes were issued without a group, so they landed on the
default group. Megatron routes unbatched pipeline p2p through the
pipeline group, and only at pp_size == 2 does one direction move to WORLD
so the two transfers can overlap. Past two stages the warmup therefore
touched a communicator the train step never uses, leaving the one that
allocates at peak memory cold - the case it was written for.

The sweep is now split into transport helpers next to
warmup_process_groups, which they complement: that one forces the
communicator to exist with a tiny all-reduce, these size the buffers each
collective shape needs. Knowledge of mpu stays in the engine, so the
helpers are reusable and testable without one.

all-to-all gets its own helper rather than running on every group. Its
buffers live as long as the process, so warming a group that never
dispatches tokens would hold that memory for nothing.

Collective failures are no longer swallowed. A rank that logged a warning
and moved on would leave its peers blocked on an operation that never
arrives, which is worse than surfacing the error.

The knob becomes a config field, since an env-only switch cannot be
expressed in an experiment config.
@Le8r0nJames
Le8r0nJames force-pushed the zjw/recover-comm-warmup branch from f1a48af to c291222 Compare August 7, 2026 06:51
dist.all_reduce(tensor, group=group)


def nccl_process_groups() -> list[dist.ProcessGroup]:

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.

The XCCL recovery triggers a single-sided entry into cross-engine all_reduce. Before recovery, the RLTrainer calls actor.connect_engine.XCCL to create a custom NCCL group containing the training PP head and inference workers. This group is registered into the process-level _world.pg_map via _new_process_group_helper. Subsequent recovery occurs in rl_trainer.py, where nccl_process_groups() enumerates the entire _world.pg_map, and warmup_collective_transports() performs all_reduce on each group. Warmup RPC is sent only to training workers, excluding inference workers within the same XCCL group. This results in the training PP head with the weight-update group indefinitely waiting for inference members until a process-group timeout; the exception handling in RecoverHandler cannot take effect before the RPC returns.

This is why the current tests haven't uncovered issues: tests/test_megatron_comm_warmup.py:80-83 always provides an empty pg_map.

Suggested fix: Avoid traversing the process-level NCCL registry. Explicitly enumerate train-step-owned groups, or record reliable owner/purpose for groups and exclude cross-engine weight-update groups. Add real multi-process recovery tests for XCCL connected rollouts.

Comment thread areal/utils/recover.py
logger.info(f"Saved recover checkpoint to {path} (with_optim={with_optim})")

@staticmethod
def _warmup_communicators(

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.

Ray/Slurm's generic RPC will retry exceptions, including engine HTTP 500. RecoverHandler._warmup_communicators() ultimately catches all exceptions, logs a warning, and then continues to recover.

If a rank fails to complete a collective due to allocation or synchronize failure after some work is done, other ranks may have already successfully returned. After the failed rank is retried individually, it will re-enter the first collective, while the other ranks no longer participate, causing a new deadlock. Even if NCCL errors are returned on all ranks, the communicator may already be damaged and cannot safely "degrade" to lazy connect as in the first training.

Suggested fixes:

  • The warmup RPC must be non-retriable.
  • Stopping recovery and propagate the error if any rank fails.
  • Do not claim to be able to fallback to lazy connection unless you can coordinate the destruction and reconstruction of the group on all ranks.

Comment thread areal/utils/recover.py
for name, engine_ in normalized_engine.items():
self._load_checkpoint(engine_, name=name)

self._warmup_communicators(normalized_engine)

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.

Warmup occurs before SGLang's pause, KV-cache offload, and weight offload. Therefore, the allocation of 32 MiB probe tensors for warmup and the persistence of transport buffers will happen while SGLang is still resident. This is contrary to the goal of "while memory is light" and may retrigger the CUDA allocation failure that this PR attempts to address.

Suggested fix: The AWEX colocated path should pause/offload rollout first, then perform communicator warmup, and then load the actor checkpoint. The non-colocated path can maintain an independent timeline.

@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had recent activity within the last 14 days.

Please add a comment or push new commits to keep it active.

Thank you for your contribution!

@github-actions github-actions Bot added the stale label Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants