Repository navigation
Fix order local refit copies after source updates - #6823
Conversation
Signed-off-by: Ting Sun <suntcrick@gmail.com>
|
This PR has been automatically converted to draft because all PRs must start as drafts. When you are ready for review, click Ready for Review to begin the review process. This will:
See the contribution guide for more details. |
|
/claude fix |
|
/claude assign |
Signed-off-by: svcnvidia-nemo-ci <svcnvidia-nemo-ci@nvidia.com>
|
🛠️ Claude fix commit
What changed Files changed by Claude
Why DCO Sanitized and posted by |
|
/ok to test a27ddc8 |
|
❌ Claude fix stopped because exact-SHA CI did not complete in time. View exact-SHA CI. |
|
/claude strict-review |
| pairs = match_local_ops_by_task_id( | ||
| local_sends, local_recvs, "NCCLCopyService", self.rank | ||
| ) | ||
| self._copy_stream.wait_stream(torch.cuda.current_stream()) |
There was a problem hiding this comment.
[CRITICAL Correctness] The fix is right, but it is applied to only 2 of the 4 backends that have the identical defect.
NixlCopyService._do_local_copies() (nixl_copy_service.py:148) and NVSHMEMCopyService.run() (nvshmem_copy_service.py:138) use exactly the same side-stream pattern as the two files fixed here — a _copy_stream / _local_copy_stream created in __init__, local copies issued inside with torch.cuda.stream(...), and a current_stream().wait_stream(...) join at the end of run() — with no wait_stream(current_stream()) before the copy:
# nixl_copy_service.py:147-150
pairs = match_local_ops_by_task_id(local_sends, local_recvs, "NixlCopyService", self.rank)
with torch.no_grad(), torch.cuda.stream(self._copy_stream): # <-- reads src with no producer ordering
for send_op, recv_op in pairs:
recv_op.tensor.copy_(send_op.tensor)# nvshmem_copy_service.py:137-148
with torch.no_grad():
with torch.cuda.stream(self._local_copy_stream): # <-- same hazard
for task_id, dst in self._local_recv_ops.items():
...
dst.copy_(src, non_blocking=True)Why it matters: this is the same silent stale-weight read the PR describes, on the same collocated refit path, reachable through the same public swap_model_weights() entry point — just with refit_method="nixl" or "nvshmem". NIXL's later torch.cuda.current_stream().synchronize() at line 231 is guarded by if remote_sends: and happens after the local copies are already enqueued, so it does not order them. NVSHMEM's torch_copy_stream_wrapper.synchronize() inside pipeline_executor.py:351 is a different stream on the RemoteCopyService self-move path and does not cover _local_copy_stream either. Leaving these two unfixed means a user who switches backends silently reacquires the bug the PR just closed, and the new unit test won't catch it because it only parametrizes over NCCLCopyService and GlooCopyService.
Suggested fix: add the same one-liner to both backends and extend the new test's parametrize list to cover them (guarded by the existing import/availability skips those backends already need). Given that four independent call sites now need the identical two-line dance, consider instead putting it in a small shared helper in base.py — e.g. local_copy_stream_scope(stream) — so a fifth backend can't reintroduce the hazard.
| producer_gate = torch.cuda.Event() | ||
| release_stream = torch.cuda.Stream() | ||
| with torch.cuda.stream(release_stream): | ||
| torch.cuda._sleep(100_000_000) |
There was a problem hiding this comment.
[IMPORTANT Correctness] The hardcoded cycle count makes this regression test hardware-dependent, and it fails in the silent direction.
torch.cuda._sleep takes device cycles, not time. 100_000_000 cycles is ~55 ms on an H100 at ~1.8 GHz, but the wall-clock delay scales inversely with the SM clock — it shrinks under boost and on future/faster parts. If the gate ever elapses before the unordered copy would have run, the test passes with or without the wait_stream calls, and the guard against this regression quietly stops working with no failure to alert anyone.
This repo already has the conversion helper for exactly this reason — tests/unit_tests/determinism/utils.py:16-27 imports get_cycles_per_ms from torch.testing._internal.common_utils with a conservative fallback, with a comment noting PyTorch's own test_cuda.py uses it to express _sleep durations in milliseconds.
Suggested fix: express the gate in milliseconds so the delay is stable across clocks:
from torch.testing._internal.common_utils import get_cycles_per_ms
GATE_MS = 50
...
with torch.cuda.stream(release_stream):
torch.cuda._sleep(int(GATE_MS * get_cycles_per_ms()))
producer_gate.record()| def test_local_copy_waits_for_current_stream(service_cls): | ||
| """A local copy must observe writes already queued on the caller's stream.""" | ||
| Utils.initialize_distributed() | ||
| service = service_cls() | ||
| source = torch.zeros(1, device="cuda") | ||
| destination = torch.zeros_like(source) | ||
| torch.cuda.synchronize() | ||
|
|
||
| producer_gate = torch.cuda.Event() | ||
| release_stream = torch.cuda.Stream() | ||
| with torch.cuda.stream(release_stream): | ||
| torch.cuda._sleep(100_000_000) | ||
| producer_gate.record() | ||
|
|
||
| torch.cuda.current_stream().wait_event(producer_gate) | ||
| source.fill_(1) | ||
|
|
||
| service.submit_send(source, dist.get_rank(), task_id=0) | ||
| service.submit_recv(destination, dist.get_rank(), task_id=0) | ||
| service.run() | ||
|
|
||
| torch.cuda.synchronize() | ||
|
|
||
| torch.testing.assert_close(destination, source) |
There was a problem hiding this comment.
[IMPORTANT Correctness] Two test-hygiene issues that will surface as confusing CI failures rather than as this test failing on its own merits.
1. GlooCopyService() leaks a process group per invocation. With group=None, GlooCopyService.__init__ calls dist.new_group(backend="gloo") (gloo_copy_service.py:28). dist.new_group is collective — every rank must call it in the same order — and nothing here calls service.close() or destroys the group. This test file is otherwise entirely rank-agnostic (pure-Python assertions and monkeypatched stubs), so it is the first test here to require lockstep participation from all 8 ranks in the CI unit-test bucket. Since pytest collection order is stable this will likely work today, but it is fragile: any -k filter, -x early exit, or rank-divergent skip leaves ranks disagreeing on how many groups exist, which manifests as an NCCL/Gloo hang rather than a clear failure. Wrap the service in a try/finally that calls service.close(), and consider constructing the Gloo PG once at module scope in a fixture.
2. service_cls() reads the default PG, so this only exercises rank-0-vs-itself semantics correctly by accident. CopyService.__init__ takes dist.get_rank() when group is None, and the test then submits with dist.get_rank() — consistent, so the local-pair matching works on every rank. That part is fine. But every rank independently runs the full body including the _sleep gate, so the bucket pays the gate latency 8× in parallel with no added coverage; the hazard is purely rank-local. A @pytest.mark.skipif(dist.get_rank() != 0)-style guard is not safe here because of the collective new_group above — which is another reason to hoist the PG creation out of the test body first.
3. torch.testing.assert_close(destination, source) cannot fail as intended. Both tensors are read after torch.cuda.synchronize(), so if the copy read a stale source the destination would hold 0 while source holds 1 — that works. But if the fill itself were dropped, both would be 0 and the assertion would still pass. Comparing against a literal expected value makes the oracle explicit:
| def test_local_copy_waits_for_current_stream(service_cls): | |
| """A local copy must observe writes already queued on the caller's stream.""" | |
| Utils.initialize_distributed() | |
| service = service_cls() | |
| source = torch.zeros(1, device="cuda") | |
| destination = torch.zeros_like(source) | |
| torch.cuda.synchronize() | |
| producer_gate = torch.cuda.Event() | |
| release_stream = torch.cuda.Stream() | |
| with torch.cuda.stream(release_stream): | |
| torch.cuda._sleep(100_000_000) | |
| producer_gate.record() | |
| torch.cuda.current_stream().wait_event(producer_gate) | |
| source.fill_(1) | |
| service.submit_send(source, dist.get_rank(), task_id=0) | |
| service.submit_recv(destination, dist.get_rank(), task_id=0) | |
| service.run() | |
| torch.cuda.synchronize() | |
| torch.testing.assert_close(destination, source) | |
| @pytest.mark.parametrize("service_cls", [NCCLCopyService, GlooCopyService]) | |
| def test_local_copy_waits_for_current_stream(service_cls): | |
| """A local copy must observe writes already queued on the caller's stream.""" | |
| Utils.initialize_distributed() | |
| service = service_cls() | |
| try: | |
| source = torch.zeros(1, device="cuda") | |
| destination = torch.zeros_like(source) | |
| torch.cuda.synchronize() | |
| producer_gate = torch.cuda.Event() | |
| release_stream = torch.cuda.Stream() | |
| with torch.cuda.stream(release_stream): | |
| torch.cuda._sleep(int(50 * get_cycles_per_ms())) | |
| producer_gate.record() | |
| torch.cuda.current_stream().wait_event(producer_gate) | |
| source.fill_(1) | |
| service.submit_send(source, dist.get_rank(), task_id=0) | |
| service.submit_recv(destination, dist.get_rank(), task_id=0) | |
| service.run() | |
| torch.cuda.synchronize() | |
| torch.testing.assert_close(destination, torch.ones_like(destination)) | |
| finally: | |
| service.close() |
Strict Review SummaryCRITICAL: 1 · IMPORTANT: 2 · SUGGESTION: 1 AssessmentThe core diagnosis is correct and the fix is the right one. Both Most impactful findingThe same defect exists in two backends this PR does not touch. With four call sites now needing the identical two-line dance, a shared helper in Other findings
RiskLow for what it changes; the residual risk is what it leaves unchanged. No public API, checkpoint-format, argument, or default changes; no backward-compatibility surface touched; the added dependency is device-side only. The two unfixed backends are the reason this shouldn't merge as-is — the fix is correct but incomplete, and partial coverage of a silent-corruption bug is the worst state to leave it in, since the presence of a targeted regression test implies coverage that isn't there. Recommendation: extend the fix and the test parametrization to NIXL and NVSHMEM (ideally via a shared helper), then this is good to go. |
|
Hi @Sunt-ing, could you check the comments from agent? |
Signed-off-by: Ting Sun <suntcrick@gmail.com>
|
@YangFei1990 Sure. First, thanks for flagging NIXL and NVSHMEM. I reproduced the same bug in both backends through the public I extended the patch and CUDA ordering test to both optional backends. The test uses real CUDA streams and tensors and mocks only the remote transport, so it does not require the optional NIXL or NVSHMEM runtime. Under the repository's 8-GPU Each wait remains at its backend's local-copy site, one line next to the stream it orders. A shared helper would add indirection while all four backends would still need separate calls. I did not add a Gloo cleanup fixture because |
|
@wdykas can you please take a look at this MR? Also, let's create a review group for refit related code. |
|
/ok to test aee431b |
|
@wdykas Thanks for the approval! I'm evaluating it for collocated RL training, where the rollout model needs the latest trainer weights after each optimizer step. |
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA/Megatron-LM/actions/runs/34521483023 |
Signed-off-by: Ting Sun <suntcrick@gmail.com> Signed-off-by: svcnvidia-nemo-ci <svcnvidia-nemo-ci@nvidia.com> Co-authored-by: svcnvidia-nemo-ci <svcnvidia-nemo-ci@nvidia.com> (cherry picked from commit 79be654)
What does this PR do?
Local same-rank refit copies can silently read stale model weights when the source update is still queued on the caller's CUDA stream. NCCL, Gloo, NIXL, and NVSHMEM all run the local copy on a dedicated stream, but only order the caller stream after that copy; they do not order the copy stream after the producer before it reads the source tensor.
This change makes each local copy stream wait for the caller's current stream before copying. It preserves the existing copy-to-caller join and does not change the public API or remote-rank transport.
Tests
With the fix, every rank passes all four backend cases (
4 passedper rank). Removing only the correspondingwait_streamcalls makes every affected case fail with the destination retaining the stale value (maximum absolute difference1.0). The NIXL/NVSHMEM test uses real CUDA streams and tensors while mocking only the optional remote transport, so it does not require those optional runtime dependencies in the unit-test environment.As supplemental validation, I also exercised the public
swap_model_weights()path after fused AdamW updates on a 20-layer, hidden-size 4096 GPT model. The source/destination logits were the oracle:3.001003.32862.838802.83730.958307.26280.984900.9629NCCL/Gloo refit latency
I measured the cost of the added dependency with the repository's
examples/rl/benchmark_refit.pyon one H200. Both variants used collocated TP1/PP1/EP1, BF16, a 20-layer/4096-hidden/32-head GPT model (4.23B parameters per model), sequence length 8, batch size 1, five warmup iterations, and 30 timed iterations. Each timed iteration synchronizes CUDA before refit, so this comparison measures the dependency overhead when source work is already complete.The fixed tree differed from the current tree only by this PR. The same temporary current-main API compatibility adjustment and statistics output were applied to both trees and are not part of the PR. All values below are milliseconds;
mean / median / stddev / min-maxsummarizes all 30 samples.7.39 / 7.38 / 0.05 / 7.33-7.527.51 / 7.45 / 0.24 / 7.40-8.67+0.07(+0.95%)+0.12(+1.62%)16.34 / 8.28 / 44.07 / 8.24-249.6716.21 / 8.27 / 43.47 / 8.24-246.39-0.01(-0.12%)-0.13(-0.80%)Each Gloo arm contained one approximately 250 ms outlier, which inflates its mean and standard deviation; the median shows no measurable regression at the reported precision. If source work is still pending, the new dependency intentionally includes the time required before those weights are valid rather than allowing a stale copy to return early.
Contribution process
Pre-checks