Skip to content

[ckpt] feat: add nccl_parallel checkpoint engine (all actor ranks send) - #7263

Closed
savaresejeremy wants to merge 1 commit into
verl-project:mainfrom
savaresejeremy:feat/nccl-parallel-checkpoint-engine
Closed

savaresejeremy wants to merge 1 commit into
verl-project:mainfrom
savaresejeremy:feat/nccl-parallel-checkpoint-engine

Conversation

@savaresejeremy

@savaresejeremy savaresejeremy commented Aug 4, 2026 •

Copy link
Copy Markdown

What does this PR do?

Adds an additive checkpoint-engine backend, nccl_parallel, that removes the single-sender bottleneck in cross-node weight sync. All S actor ranks send concurrently, each owning a stripe of the global bucket sequence over its own NCCL group; receivers join all S groups and reassemble in ascending global order. With S=1 it degenerates to a single-sender engine. RFC: #7262.

This is the capability issue #7167 asks for, built with a different mechanism. The passive-participant topology measured there on 2026-08-03 came out ~19x slower end-to-end because the cross-group ordering it needed removed the export/broadcast overlap. This engine pins collective enqueue order with bulk-synchronous phase barriers and keeps execution overlapped through non-blocking comm streams and per-transfer CUDA events, so the export keeps running while broadcasts are in flight. Mechanism details are in the module docstring.

Measurements

2-node, 8-GPU FSDP2 trainer to 8-GPU vLLM rollout, 27B bf16 model, GEN_TP=4, bucket 2048MB, AWS EFA, n=20 refits: 2.51s ±0.27 per refit vs 3.01s ±0.25 for the stock nccl engine. Plateau accuracy (steps 61-100 of 100-step GSM8K runs) matched stock within 0.0005. A same-window paired re-run (n=20 each, mean/median/p95) gave stock 3.005/2.911/3.533 s vs parallel 2.457/2.466/2.818 s. Both arms ran code behaviorally identical to this PR at S=8 (the 16-byte bucket rounding below is a no-op at 2048MB/8).

4-node validation (same recipe, 10 refits per arm, one run per topology, phase=8): 1T+3R (S=8) 2.480 s, 2T+2R (S=16) 5.548 s, 3T+1R (S=24) 2.931 s. All completed with normal learning curves, and sync time did not move when the receiver fleet tripled. The 2T+2R split is slower at fixed phase and we don't have an explanation for that yet. A second pass of the matrix on the exact submitted tree reproduced the ordering (2.84/5.01/3.88 s, with 2T+2R again slowest); these runs are correctness evidence, not tuned benchmarks.

A note on bucket sizing: the per-group bucket is rounded down to a 16-byte boundary, with a CPU regression test. Without the rounding, 2048MB/24 senders is an odd number of bytes, and a bucket-sized cut through a large tensor leaves the tensors behind it at a dtype-misaligned offset (S=8 and S=16 divide 2048MB evenly and are unaffected). The rounding removes the bucket cut as a source of misalignment but is not a general alignment guarantee: back-to-back packing of a mixed-dtype stream can still misalign a later tensor at any bucket size. That behavior predates this engine, is shared with the stock nccl engine, and does not arise on the homogeneous bf16 export path, so we think it belongs in its own fix rather than this PR.

Duplicate-work check

AI assistance disclosure

This contribution was developed with AI assistance (Claude, via internal agent tooling) covering code, tests, and documentation drafting. All changes were human-reviewed line by line and the branch was reviewed internally before submission; commits carry Co-authored-by: Claude trailers per AGENTS.md.

Test

CPU (no GPU required; phase validation and bucket-size alignment):

pytest tests/checkpoint_engine/test_parallel_correctness_on_gpu.py \
  -k "invalid_phase or bucket_size_alignment" -q

GPU (single 8-GPU node; model defaults to ~/models/Qwen/Qwen3-8B-Base):

pytest tests/checkpoint_engine/test_parallel_stream_mechanisms_on_gpu.py -x -q
pytest tests/checkpoint_engine/test_parallel_correctness_on_gpu.py -x -q

Results on our cluster (8x B200, 2026-08-04, the exact submitted branch content rebased on 1ae9455):

  • mechanisms: 1 passed in 34.83s
  • correctness: 8 passed in 434.69s (0:07:14): 6 GPU cases (S=1 degenerate; S=2 default-phase; S=2 rebuild_group=True; S=4 default; S=4 phase=3; S=4 phase=2) x 3 update rounds each, plus 2 CPU cases (invalid-phase, bucket-size alignment)

CI-cost note for maintainers: the correctness suite measured about 7 minutes wall clock on an 8x B200 node (six cases, each an 8B-model FSDP init plus 3 sync rounds; the (1, N-1) case stages the model in 7 rollout workers' host RAM), so budget more than that on L20x8. We can trim cases (for example, drop one phase<S variant) or move it to a test_special_* name, though nothing runs those without dedicated workflow wiring. Whatever the maintainers prefer.

Add a parallel-sender NCCL checkpoint engine: instead of actor rank 0
broadcasting every bucket, all S actor ranks send concurrently, each
owning a stripe of the global bucket sequence over its own NCCL group.
Receivers join all S groups and consume buckets in ascending global
order, so the reassembled stream is identical to the stock engine's.

Concurrent trainer-side broadcasts next to live FSDP collectives are
made safe by three mechanisms, documented in the module docstring:
bulk-synchronous phases that pin cross-communicator enqueue order,
non-blocking comm streams (patched ray stream factory, fail-closed)
with per-transfer CUDA events, and a zmq PUB/SUB subscription handshake
against the slow-joiner drop. The phase kwarg
(engine_kwargs: {nccl_parallel: {phase: N}}) caps concurrent in-phase
transfers for receivers that cannot co-schedule the full fan-out. The
per-group bucket is the configured budget divided by the sender count,
floored at 32MB and rounded down to a 16-byte boundary so a bucket-sized
cut through a large tensor cannot leave the tensors packed behind it at
a dtype-misaligned offset.

Measured (2-node, 8xB200 FSDP2 trainer to 8xB200 vLLM rollout,
Qwen3.5-27B 55.6GB bf16, GEN_TP=4, bucket 2048MB, EFA, n=20 refits):
2.51s +/-0.27 per refit vs 3.01s +/-0.25 for the stock nccl engine;
plateau accuracy (steps 61-100 of 100-step GSM8K runs) matched stock
within 0.0005. Also validated on 4-node topologies (1/2/3 trainer
nodes, S=8/16/24, 10 refits each, phase=8): all completed with normal
learning curves, one run per topology.

Tests: a correctness test mirroring test_correctness_on_gpu.py (6 GPU
cases: S=1 degenerate, phase defaulting, non-divisor and small phase<S
marker-drain paths, rebuild_group=True; 3 update rounds each) plus two
CPU checks (out-of-range phase rejected, bucket-size alignment across
S in {1,3,7,8,16,24,192}), and a 1-GPU mechanism test for the
stream-factory patch, comm-stream lookup, and cupy event completion.

No new dependencies: cupy is imported with the same guarded idiom as
the existing nccl engine.

Co-authored-by: Claude
@CLAassistant

CLAassistant commented Aug 4, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@wuxibin89

Copy link
Copy Markdown
Collaborator

@savaresejeremy Thanks for you contribution, busy on v0.9.0 release for now, will review it ASAP.

@savaresejeremy

Copy link
Copy Markdown
Author

@wuxibin89 I also raised #7318, an independent fix for the mixed-dtype packing alignment issue mentioned in this PR's text. It applies to the existing engines and does not depend on this PR.

wuxibin89 pushed a commit that referenced this pull request Aug 14, 2026
…ine (#7291)

### What does this PR do?

Adds an opt-in `multi_sender` mode to the NCCL checkpoint engine that
admits actor rank 0's node-local peers (and hence probably connected via
high-speed interconnects) into the broadcast group as relays, so NCCL
can drive more than one NIC per node during weight sync.

Addresses #7167. With a single sender, broadcast bandwidth is capped by
the one NIC reachable from the source GPU (~25 GB/s on a GH200 node with
4 NICs and ~100 GB/s bisection). Admitting the source's NVLink peers
gives NCCL's broadcast tree fan-out points it can reach over NVLink and
push on from over their own NICs.

The relays carry no data the rollout needs — rank 0 remains the sole
source, and there is still exactly one group and one broadcast per
bucket. Actor workers on any other node are excluded (rank `-1`): they
would have to pull a full copy over the fabric to contribute nothing.

Relationship to open PRs:
- **#7263** (`nccl_parallel`) targets the same issue by a different
mechanism: a new backend where S actor ranks each send a stripe of the
bucket sequence over S separate NCCL groups. This PR instead keeps the
existing single-group broadcast and changes only group *membership*, as
a flag on the existing `nccl` engine. The two are alternatives; see the
discussion on #7167.
- **#7107** (bucket-size fix) is a prerequisite. Relays size each
broadcast to match the root, so they depend on the root sending
`send_buf[:offset]`. This PR is rebased on / assumes #7107; it does not
restate that change.

### Checklist Before Starting

- [x] Search for similar PRs. Paste at least one query link here:
https://github.com/verl-project/verl/pulls?q=is%3Apr+is%3Aopen+nccl+checkpoint+engine+broadcast
and https://github.com/verl-project/verl/pulls?q=is%3Apr+is%3Aopen+7167

### Test

**GPU correctness and latency:** 

The following measurements include the mean over 10 iterations run on
the same hardware as #7167. The measurements include the time taken for
all-reduce + broadcast + copy the tensors on a non-blocking stream on
the receiver side. All my measurements are based on a 4-trainer (1
node)+ 4-rollout (1 node) configuration. 1 warm-up iteration was run
before starting with the measurements, and the iterations were run with
`rebuild_group: False`.

| Model | Mode | Mean (s) | Min–Max (s) |
|---|---|---:|---:|
| Qwen3.5-27B | single-sender | 5.026 | 5.02–5.04 |
| Qwen3.5-27B | **multi-sender** | **1.791** | 1.78–1.86 |
| Qwen3-30B-A3B | single-sender | 5.570 | 5.56–5.58 |
| Qwen3-30B-A3B | **multi-sender** | **1.892** | 1.86–1.92 |

Speedup: 2.81× on Qwen3.5-27B, 2.94× on Qwen3-30B-A3B.

**Effects of scaling out**

Scaling the rollout side out costs nothing measurable. A 4-trainer (1
node) + 12-rollout (3 nodes) configuration syncs Qwen3.5-27B in
1.82–1.88 s under multi-sender, within noise of the 1.78–1.86 s measured
against 4 rollout workers on a single node.

Scaling the *trainer* side across nodes does cost, but the cost is not
in the broadcast. An 8-trainer (2 nodes) + 8-rollout (2 nodes)
configuration syncs the same model in 2.60–2.62 s under multi-sender
versus 5.85–5.90 s single-sender — still a 2.25× speedup. Both modes
slow by nearly the same absolute amount relative to their
single-trainer-node numbers (+0.82 s and +0.85 s), which points at a
fixed cost incurred ahead of the broadcast rather than anything in the
transfer itself: gathering full parameters now crosses the 25 GB/s CXI
fabric instead of staying on NVLink. Only rank-0 nodes contribute relays
by design, so the second trainer node adds gather cost without adding
senders.


### API and Usage Example

Off by default; existing behavior is unchanged.

```python
CheckpointEngineConfig(
    backend="nccl",
    update_weights_bucket_megabytes=2048,
    engine_kwargs={"nccl": {"multi_sender": True}},
)
```

The master's setting governs the whole group: it travels in
`MasterMetadata`, so `build_topology` (a classmethod on the driver)
resolves one mode for every worker.

### Design & Code Changes

Rank 0 stays the only source of weights. `multi_sender` changes only who
else is in the group.

- **`WorkerMetadata`** — `prepare()` now reports each worker's Ray node
id, since `build_topology` runs on the driver and cannot query worker
placement itself. Only rank 0 fills the nested `master` field. Node id
is the locality key: a node never spans more than one NVLink domain, so
matching on it can under-select peers but never over-select them.
- **`build_topology`** dispatches to `_single_sender_ranks` (upstream
behaviour, `[0, -1, …]`) or `_multi_sender_ranks` (rank 0 plus every
actor worker on its node, others `-1`), then assembles the kwargs once.
Senders take the low ranks.
- **`num_senders`** is passed to `init_process_group` and splits the
group: ranks below it send or relay, ranks at or above it consume. It
also fixes an incidental leak — only true consumers now subscribe to the
zmq bucket-metadata topic, where previously every `rank > 0` did.
- **`_relay_weights`** walks the same weight stream as the root via the
new `get_weight_chunks_size` helper, which splits on identical
boundaries to `split_weight_chunks` without materializing buffers, and
issues a matching broadcast per bucket while dropping the payload.
Boundaries must be derived locally rather than read off the wire:
producing a weight is itself collective over the actor group, so
blocking on the wire would deadlock against rank 0's gathers.
- **Root wait placement** is now mode-dependent. Single sender waits
just before enqueueing the next broadcast, preserving the fill/send
overlap the `send_buf`/`recv_buf` swap exists for. With relays present
the root waits as soon as the bucket is filled, so the root and the
relays cannot issue the broadcast group's and the actor group's
collectives in different orders, hence preventing a deadlock.

AI assistance (claude code) was used to refactor this code and build the
PR.

**Why this makes sense after NCCL M2N**

This PR proposes a small change to the existing legacy NCCL backend
without introducing new collectives or a completely new backend.
Moreover, M2N currently relies on GIN for inter-NVL domain
communication, which is not supported on certain backends such as CXI.
@savaresejeremy

Copy link
Copy Markdown
Author

Closing this. #7291 landed the multi-sender capability #7167 asked for with a much smaller change to the existing nccl engine, and carrying a second backend for the same goal is not worth the review cost. Thanks @wuxibin89. #7318 is independent of this PR and still stands on its own. If a striped-sender topology is ever wanted for a shape node-local relays cannot cover, the branch and the numbers in #7262 stay available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants