-
Notifications
You must be signed in to change notification settings - Fork 4.2k
[checkpoint_engine][rollout] Delta weight sync over NCCL for disaggregated rollout (+ sharded-snapshot variant) #6974
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ChangyiYang
wants to merge
11
commits into
verl-project:main
Choose a base branch
from
ChangyiYang:delta-sharded-snapshot
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,274
−2
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
5211fb0
[checkpoint_engine][rollout] feat: delta weight sync over NCCL for di…
gxlvera 2a4b1d2
[test] assert delta weight sync result is byte-identical to full sync
gxlvera 4ef3fc5
[checkpoint_engine] refactor: move delta_sync core under checkpoint_e…
gxlvera e280845
[checkpoint_engine] chore: drop disk-transport remnants from delta_sync
gxlvera 80736ca
[docs][example] document delta weight sync + add example script
gxlvera 8fb1e28
[checkpoint_engine][fsdp] feat: sharded-snapshot delta weight sync
gxlvera d43ca74
[test] sharded delta: bit-identity vs the full-gather diff
gxlvera 334cd4a
[docs] document the delta_sharded checkpoint-engine backend
gxlvera 99e23fe
[docs][engine] address review: split delta docs, declare get_per_tens…
gxlvera 91b6308
[checkpoint_engine] stream delta flushes and apply them in place via …
gxlvera 7230670
[checkpoint_engine] keep delta positions on the GPU through flush ass…
gxlvera File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # Delta Weight Sync | ||
|
|
||
| Last updated: 07/09/2026. | ||
|
|
||
| ## Motivation | ||
|
|
||
| In a disaggregated setup (``hybrid_engine=False``) the trainer must broadcast its updated weights to the | ||
| rollout engine after every step. By default this is a full-weight broadcast whose cost grows with model | ||
| size. Because RL updates are highly sparse — under typical learning rates over 99% of BF16 weight bytes | ||
| are unchanged step-over-step — you can instead broadcast only the parameters that changed (a *delta*), | ||
| cutting the weight-sync traffic to the sparsity ratio while staying lossless (bit-exact; a per-flush | ||
| checksum is verified on the receiver). | ||
|
|
||
| When to use: disaggregated training where the trainer↔rollout link is the bottleneck (commodity network / | ||
| cross-node / large models). On a fast intra-node NVLink link with a small model the full broadcast is | ||
| already cheap, so delta mainly pays off as the model size and the network distance grow. | ||
|
|
||
| ## Design | ||
|
|
||
| The delta backends plug into the standard checkpoint-engine flow (``CheckpointEngineManager`` → | ||
| ``CheckpointEngineWorker``), so they work with any trainer that drives weight sync through the | ||
| checkpoint engine (including the V1 ``separate_async`` trainer). | ||
|
|
||
| - **Diff**: the trainer byte-diffs each parameter against a pinned-CPU snapshot of the previous sync. | ||
| The comparison is bit-exact (integer view inequality), so the reconstruction is lossless by | ||
| construction — no thresholds, no drift. | ||
| - **Encoding**: changed elements are shipped as a shared ``(positions, values)`` payload plus a | ||
| per-parameter manifest. ``encoding`` selects the position encoding: ``indices`` (int32 absolute | ||
| positions, lowest compute) or ``deltas`` (uint16 gap deltas, smaller on the wire). | ||
| - **Transport**: the sparse payload is broadcast over the existing NCCL collective group in | ||
| bucket-sized flushes (streamed: each flush is sent and freed as it is produced, so sender peak | ||
| memory stays ~2 buckets regardless of model size). | ||
| - **Apply**: each rollout worker hands its local copy of the sparse payload to its colocated SGLang | ||
| TP worker via same-GPU ``update_weights_from_tensor`` IPC, where a verl-shipped loader — | ||
| registered automatically through SGLang's stock ``--custom-weight-loader`` hook, so **no SGLang | ||
| fork or patch is needed** — verifies the flush checksum (fail loud), densifies each parameter's | ||
| delta into a NaN-masked tensor, and overwrites only the changed positions *in place* on the live | ||
| weights. No full-model mirror is staged anywhere on the rollout side: receiver peak memory is one | ||
| bucket plus one decode chunk, independent of model size. | ||
| - **Seeding**: the first sync broadcasts a full delta (every position), so a dummy-initialized rollout | ||
| gets a correct base; subsequent syncs are sparse. | ||
|
|
||
| ## Backends | ||
|
|
||
| ### ``delta`` | ||
|
|
||
| Diffs on rank 0 after the ordinary full-tensor gather; rank 0 holds a full-model pinned-CPU snapshot. | ||
|
|
||
| ```shell | ||
| actor_rollout_ref.rollout.checkpoint_engine.backend=delta \ | ||
| +actor_rollout_ref.rollout.checkpoint_engine.engine_kwargs.delta.encoding=indices | ||
| ``` | ||
|
|
||
| ### ``delta_sharded`` (sharded snapshot) | ||
|
|
||
| The ``delta`` backend above still ``full_tensor()``-gathers every parameter to rank 0 before diffing. | ||
| The ``delta_sharded`` backend pushes the diff *below* the all-gather: each actor rank pins a snapshot of | ||
| only **its** FSDP shard, byte-diffs the shard locally, and gathers just the changed ``(position, value)`` | ||
| pairs to rank 0 (via the engine's ``get_per_tensor_param_shard()`` export). So the gather volume drops | ||
| from the full parameter to the sparsity ratio (~1–3%), and no rank needs a full-model snapshot — the | ||
| memory and the gather traffic both shard with the world size. | ||
|
|
||
| ```shell | ||
| actor_rollout_ref.rollout.checkpoint_engine.backend=delta_sharded \ | ||
| +actor_rollout_ref.rollout.checkpoint_engine.engine_kwargs.delta_sharded.encoding=indices | ||
| ``` | ||
|
|
||
| The assembled delta is **bit-identical** to what ``delta`` produces, so the wire format, the per-flush | ||
| checksum, and the rollout-side receiver are all unchanged. Each rank computes its shard's absolute | ||
| position in the full flattened parameter purely locally (from the DTensor spec, no extra collective). | ||
| Scope: FSDP2 ``Shard(0)`` parameters (the common case) plus replicated / non-DTensor params; other shard | ||
| dimensions are not supported and raise. | ||
|
|
||
| ## Usage | ||
|
|
||
| A runnable example is ``verl/experimental/one_step_off_policy/shell/grpo_0.6b_gsm8k_fsdp2_sglang_delta_2_6.sh`` — | ||
| the SGLang 2+6 disaggregated GRPO recipe with ``backend=delta``. | ||
|
|
||
| Current scope: disaggregated (``hybrid_engine=False``) + SGLang rollout in BF16, FSDP2 training engine. | ||
|
|
||
| ## Roadmap | ||
|
|
||
| Planned extensions, in design order: | ||
|
|
||
| - **Megatron sharded delta**: diff each rank's mcore shard locally and reuse the native | ||
| megatron→HF bridge on rank 0, extending ``delta_sharded`` beyond FSDP. | ||
| - **Quantized rollout (fp8 etc.)**: diff the quantized bytes (quantize-then-diff) so a low-precision | ||
| rollout engine can consume deltas without a bf16 intermediate. | ||
| - **Upstream SGLang delta receiver**: once SGLang ships a native distributed delta receiver, the | ||
| broadcast can land directly on the TP workers (dropping the same-GPU IPC forward); the current | ||
| custom-weight-loader apply is already in place and bit-compatible with that wire format. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| """Validate verl.checkpoint_engine.delta_sync.sharded against the full-gather diff. | ||
| torchrun --nproc_per_node=4 tests/checkpoint_engine/sharded_delta_multigpu_check.py | ||
| """ | ||
| import torch | ||
| import torch.distributed as dist | ||
| from torch.distributed.device_mesh import init_device_mesh | ||
| from torch.distributed.tensor import Replicate, Shard, distribute_tensor | ||
|
|
||
| from verl.checkpoint_engine.delta_sync.sharded import ( | ||
| gather_v_to_rank0, | ||
| local_shard_view, | ||
| shard_delta_indices, | ||
| ) | ||
|
|
||
|
|
||
| def _run_case(shape, placements, mesh, dev, rank, si): | ||
| """Diff a distributed param via the real sharded module vs a full-gather baseline.""" | ||
| torch.manual_seed(si) | ||
| full_old = torch.randn(*shape, dtype=torch.bfloat16, device=dev) | ||
| full_new = full_old.clone() | ||
| g = torch.Generator(device=dev).manual_seed(100 + si) | ||
| numel = full_old.numel() | ||
| k = max(1, numel // 100) | ||
| pert = torch.randint(0, numel, (k,), device=dev, generator=g) | ||
| full_new.view(-1)[pert] += torch.randn(k, dtype=torch.bfloat16, device=dev, generator=g) * 0.1 | ||
|
|
||
| dt_old = distribute_tensor(full_old, mesh, placements) | ||
| dt_new = distribute_tensor(full_new, mesh, placements) | ||
|
|
||
| # --- sharded path (real module) --- | ||
| loc_new, off, contributes = local_shard_view(dt_new) | ||
| loc_old, off2, _ = local_shard_view(dt_old) | ||
| assert off == off2 | ||
| if contributes: | ||
| gidx, gval = shard_delta_indices(loc_new, loc_old, off) | ||
| else: | ||
| gidx = torch.empty(0, dtype=torch.int64, device=dev) | ||
| gval = torch.empty(0, dtype=loc_new.dtype, device=dev) | ||
| sh_idx, sh_val = gather_v_to_rank0(gidx, gval) | ||
|
|
||
| # --- baseline: full gather + diff --- | ||
| fo = dt_old.full_tensor().reshape(-1) | ||
| fn = dt_new.full_tensor().reshape(-1) | ||
| bmask = fn.view(torch.int16) != fo.view(torch.int16) | ||
| b_idx = bmask.nonzero(as_tuple=False).view(-1).to(torch.int64) | ||
| b_val = fn[b_idx] | ||
|
|
||
| if rank != 0: | ||
| return True | ||
| so = torch.argsort(sh_idx) | ||
| bo = torch.argsort(b_idx) | ||
| idx_ok = torch.equal(sh_idx[so], b_idx[bo]) | ||
| val_ok = torch.equal(sh_val[so].view(torch.int16), b_val[bo].view(torch.int16)) | ||
| ok = idx_ok and val_ok and (sh_idx.numel() == b_idx.numel()) | ||
| tag = "x".join(p.__class__.__name__[0] for p in placements) | ||
| print(f"[shape={shape} mesh={tuple(mesh.shape)} {tag}] nnz sharded={sh_idx.numel()} " | ||
| f"full={b_idx.numel()} idx={idx_ok} val={val_ok} -> {'PASS' if ok else 'FAIL'}") | ||
| return ok | ||
|
|
||
|
|
||
| def main(): | ||
| dist.init_process_group("nccl") | ||
| rank, world = dist.get_rank(), dist.get_world_size() | ||
| torch.cuda.set_device(rank) | ||
| dev = torch.device("cuda", rank) | ||
| all_ok = True | ||
|
|
||
| # 1D FSDP mesh, Shard(0) -- uneven shapes stress the offset math. | ||
| mesh1d = init_device_mesh("cuda", (world,)) | ||
| for si, shape in enumerate([(4096, 1024), (7, 3), (30001, 8), (128,)]): | ||
| all_ok = _run_case(shape, [Shard(0)], mesh1d, dev, rank, si) and all_ok | ||
|
|
||
| # 2D FSDP x SP(replicate) mesh -- the ulysses case: weights are Shard(0) on the FSDP dim | ||
| # and Replicate on the SP dim, so only SP-coord-0 ranks may contribute (no double-count). | ||
| if world % 2 == 0: | ||
| mesh2d = init_device_mesh("cuda", (world // 2, 2), mesh_dim_names=("fsdp", "sp")) | ||
| for si, shape in enumerate([(4096, 1024), (7, 3), (128,)]): | ||
| all_ok = _run_case(shape, [Shard(0), Replicate()], mesh2d, dev, rank, 50 + si) and all_ok | ||
|
|
||
| if rank == 0: | ||
| print("=" * 50) | ||
| print(f"OVERALL: {'ALL PASS ✅' if all_ok else 'FAIL ❌'}") | ||
| print("=" * 50) | ||
| dist.barrier() | ||
| dist.destroy_process_group() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move to a separate delta weight sync design doc: megatron, fp8, etc should be include in future.