[bugfix] Fix distributed update weights for pipeline parallel#329
[bugfix] Fix distributed update weights for pipeline parallel#329knlnguyen1802 wants to merge 2 commits into
Conversation
Signed-off-by: knlnguyen1802 <knlnguyen1802@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request updates the weight synchronization mechanism in UpdateWeightFromDistributed to support pipeline parallel (PP) configurations greater than one by sending weights one pipeline stage at a time. This is achieved by dynamically connecting and disconnecting rollout engines for the active stage and synchronizing across ranks using barriers. The review feedback suggests simplifying the active PP stage loop by only updating self._group_name on the active source rank, which avoids unnecessary state mutation on inactive ranks and simplifies the cleanup logic, along with a corresponding update to the unit tests.
| pp_rank = mpu.get_pipeline_model_parallel_rank() | ||
| base_is_pp_src_rank = self._is_pp_src_rank | ||
| original_group_name = getattr(self, "_group_name", None) | ||
| try: | ||
| for active_pp_rank in range(pp_world_size): | ||
| self._active_weight_sync_pp_rank = active_pp_rank | ||
| self._is_pp_src_rank = base_is_pp_src_rank and pp_rank == active_pp_rank | ||
| self._group_name = f"vime-pp_{active_pp_rank}" | ||
| if self._is_pp_src_rank: | ||
| if self._model_update_groups is not None: | ||
| disconnect_rollout_engines_from_distributed( | ||
| self.args, self._group_name, self._model_update_groups, self.rollout_engines | ||
| ) | ||
| self._model_update_groups = connect_rollout_engines_from_distributed( | ||
| self.args, | ||
| self._group_name, | ||
| self.rollout_engines, | ||
| engine_gpu_counts=self._engine_gpu_counts, | ||
| ) | ||
| pbar = tqdm(desc=f"[{self._group_name}] Update weights", total=0) | ||
| else: | ||
| pbar = None | ||
|
|
||
| dist.barrier(group=get_gloo_group()) | ||
| self._send_weights(pbar) | ||
| if self._is_pp_src_rank: | ||
| torch.cuda.synchronize() | ||
| dist.barrier(group=get_gloo_group()) | ||
| finally: | ||
| self._active_weight_sync_pp_rank = None | ||
| self._is_pp_src_rank = base_is_pp_src_rank | ||
| if original_group_name is not None: | ||
| self._group_name = original_group_name |
There was a problem hiding this comment.
We can simplify the loop by only updating self._group_name when self._is_pp_src_rank is True. Since self._group_name is only used on the active PP stage's source rank, there is no need to temporarily set it on inactive or non-source ranks. This also completely eliminates the need to save and restore original_group_name in the finally block, avoiding unnecessary state mutation on non-source ranks.
pp_rank = mpu.get_pipeline_model_parallel_rank()
base_is_pp_src_rank = self._is_pp_src_rank
try:
for active_pp_rank in range(pp_world_size):
self._active_weight_sync_pp_rank = active_pp_rank
self._is_pp_src_rank = base_is_pp_src_rank and pp_rank == active_pp_rank
if self._is_pp_src_rank:
self._group_name = f"vime-pp_{active_pp_rank}"
if self._model_update_groups is not None:
disconnect_rollout_engines_from_distributed(
self.args, self._group_name, self._model_update_groups, self.rollout_engines
)
self._model_update_groups = connect_rollout_engines_from_distributed(
self.args,
self._group_name,
self.rollout_engines,
engine_gpu_counts=self._engine_gpu_counts,
)
pbar = tqdm(desc=f"[{self._group_name}] Update weights", total=0)
else:
pbar = None
dist.barrier(group=get_gloo_group())
self._send_weights(pbar)
if self._is_pp_src_rank:
torch.cuda.synchronize()
dist.barrier(group=get_gloo_group())
finally:
self._active_weight_sync_pp_rank = None
self._is_pp_src_rank = base_is_pp_src_rank| assert send_calls == [ | ||
| (0, True, "vime-pp_0", True, DummyGroup("vime-pp_0")), | ||
| (1, False, "vime-pp_1", False, DummyGroup("vime-pp_0")), | ||
| ] |
There was a problem hiding this comment.
Since self._group_name is no longer modified on inactive ranks, we should update the test assertion to expect the original group name ("g") for the inactive PP stage.
| assert send_calls == [ | |
| (0, True, "vime-pp_0", True, DummyGroup("vime-pp_0")), | |
| (1, False, "vime-pp_1", False, DummyGroup("vime-pp_0")), | |
| ] | |
| assert send_calls == [ | |
| (0, True, "vime-pp_0", True, DummyGroup("vime-pp_0")), | |
| (1, False, "g", False, DummyGroup("vime-pp_0")), | |
| ] |
Summary
This PR fixes a hang in non-colocated full NCCL weight sync when the Megatron actor uses pipeline parallelism, for example
--tensor-model-parallel-size 1 --pipeline-model-parallel-size 2.The root cause is that Vime treated every
DP=0, TP=0pipeline stage as a vLLM weight-sync source rank. WithPP>1, multiple trainer ranks concurrently initialized separate trainer-to-vLLM NCCL transfer groups against the same vLLM rollout engines. Current vLLM weight transfer keeps one active receiver communicator, so later initialization could overwrite earlier initialization and leave trainer and receiver waiting on different NCCL groups.The fix keeps the existing fast path for
PP=1. ForPP>1, Vime now sends one pipeline stage at a time inside a single vLLM weight-update session. Only the active pipeline stage initializes the vLLM receiver communicator and broadcasts its local weights. Inactive pipeline stages only join the required synchronization barriers.Problem
The default non-colocated full weight sync path is:
MegatronTrainRayActorselectsUpdateWeightFromDistributedfor--update-weight-mode full --update-weight-transport nccl.UpdateWeightFromDistributed.connect_rollout_enginesmarks a trainer rank as a source whendata_parallel_rank == 0andtensor_model_parallel_rank == 0.connect_rollout_engines_from_distributed.NCCLWeightTransferEngine.trainer_send_weights, while vLLM receives through its weight-transfer engine.This works for
TP=2, PP=1because there is only oneDP=0, TP=0source rank.It fails for
TP=1, PP=2because both pipeline stages haveTP=0:The old code named those groups
vime-pp_0andvime-pp_1, but the current vLLM-side wrapper does not actually usegroup_nameto keep multiple receiver communicators.VLLMEngine.init_weights_update_groupandVLLMEngine.update_weights_from_distributedpass the request to vLLM's weight-transfer endpoints, whose dense NCCL backend stores a single activemodel_update_groupand receives from source rank0.That makes the old multi-PP behavior unsafe:
The rollout engine lock did not prevent this because it only serialized bucket broadcasts. It did not serialize or isolate communicator initialization.
Goals
PP>1.PP=1.Design
1. Keep the PP=1 fast path unchanged
When
mpu.get_pipeline_model_parallel_world_size() == 1, the implementation keeps the old persistent transfer group behavior:DP=0, TP=0source rank connects rollout engines duringconnect_rollout_engines.update_weightsstarts one vLLM weight-update session._send_weightspath.This means the common non-PP case has the same process-group lifecycle and the same number of NCCL transfer groups as before.
2. Defer vLLM NCCL group creation for PP>1
When
PP>1,connect_rollout_enginesrecords the rollout engines, lock, engine GPU counts, PP world size, and local source eligibility, but it does not immediately create a vLLM NCCL transfer group.This prevents multiple PP source ranks from racing to initialize receiver communicators before any bucket transfer starts.
3. Send one pipeline stage at a time
update_weightsstill owns the outer vLLM lifecycle:For
PP>1, sending weights is now staged:Each active PP stage reuses the existing
connect_rollout_engines_from_distributedandupdate_weights_from_distributedhelpers. The difference is the timing: only one PP stage owns the vLLM receiver communicator at a time.4. Preserve barrier symmetry
The raw and bridge weight-export paths have different barrier shapes:
Inactive PP stages must not iterate local weights for the wrong pipeline stage, but they still need to join the same number of barriers as the active stage. The fix adds an active-stage predicate and mirrors the correct barrier behavior for both paths.
Test Plan
Focused unit coverage should include:
PP>1connect defers vLLM NCCL group initialization instead of creating a group immediately.NCCLWeightTransferEngine.trainer_send_weights.Suggested commands:
Runtime validation should include:
to confirm the existing non-PP path is unchanged, and:
to confirm staged
vime-pp_0, thenvime-pp_1, sync completes without the NCCL hang.When changing TP/PP topology between runs, use a topology-compatible checkpoint or start from model weights. Resuming a distributed optimizer checkpoint saved with a different
(TP, PP)layout can fail before weight sync starts.