Delta Weight Sync

Last updated: 07/10/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 with a trainer↔rollout link. Two effects stack here, and they pay off differently:

  • Sparse wire (the “delta” part): only ~1–3% of parameter bytes change per step, so the broadcast payload shrinks accordingly. This effect grows with model size and network distance — on a fast intra-node link with a small model, a full broadcast is already cheap.

  • Shard-local diff + sparse gather (the “sharded” part): no rank ever materializes full tensors or a full-model snapshot, and the gather moves only changed elements. This removes the full-tensor all-gather and rank-0 staging costs that the plain nccl engine pays regardless of network speed — which is why delta_sharded beat the full broadcast at every size we measured (0.5B through 72B, 1.3–3.1×), not just at the large end.

This is why delta_sharded is the only delta backend we ship: an earlier full-gather variant (diff on a rank-0 full-model snapshot) was consistently slower than delta_sharded at every size we measured, so it was dropped in favor of the sharded design.

Design

The delta_sharded backend plugs into the standard checkpoint-engine flow (CheckpointEngineManagerCheckpointEngineWorker), so they work with any trainer that drives weight sync through the checkpoint engine (including the V1 separate_async trainer).

  • Export contract: the trainer’s get_per_tensor_param_shard() yields (name, local_shard, ShardSpec) per local parameter — the spec (see :mod:verl.workers.engine.spec) describes the shard’s placement declaratively (DeviceMesh + Placements), and the engine derives the flat offset, gather group, and contributing rank itself. All layout knowledge stays on the trainer side; the engine is trainer-agnostic.

  • Diff: each rank byte-diffs its own shard against a pinned-CPU snapshot of that shard from the previous sync (no rank holds a full-model snapshot). The comparison is bit-exact (integer view inequality), so the reconstruction is lossless by construction — no thresholds, no drift.

  • Sparse gather + encoding: only the changed (position, value) pairs are gathered to rank 0 (batched, variable-length), translated to full-tensor coordinates, and packed as a shared (positions, values) payload plus a per-parameter manifest (indices encoding: int32 absolute positions).

  • 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 is an explicit dense pass — the raw weights stream through the same bucketed wire with no positions attached (values only), populating the trainer-side snapshot as they go — so a dummy-initialized rollout gets a correct base without any sparse-encoding overhead. Subsequent syncs are sparse.

Backend

delta_sharded (sharded snapshot)

delta_sharded 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.

    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 full-gather-then-diff, 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).

Supported training engines: the shard export requires Shard(0) DTensor parameters, which both FSDP versions provide:

  • FSDP2 (fully_shard, actor.strategy=fsdp2): native DTensor params; the export never stages the whole shard on the GPU (state_dict() is reference-only, shards move lazily per parameter).

  • FSDP1 (actor.strategy=fsdp, the default): verl configures SHARDED_STATE_DICT, whose export also emits per-rank Shard(0) DTensors. FSDP1’s state-dict export runs through the unshard machinery, so the whole-shard GPU staging round trip is kept for it (it is skipped for FSDP2). Single-GPU FSDP1 uses FULL_STATE_DICT (plain tensors) and degrades to the replicated/rank-0 path — still correct, just not shard-parallel.

Other shard dimensions than Shard(0) are not supported and raise.

Config note: the training engine reads the top-level actor_rollout_ref.actor.strategy; setting only actor.fsdp_config.strategy does not select FSDP2.

Measured results

All numbers: H100 nodes, GSM8K GRPO, verl V1 separate_async (disaggregated trainer/rollout), FSDP2 + param/optimizer offload, SGLang rollout, per-step steady-state weight sync.

model (placement)

delta_sharded

nccl (full broadcast)

speedup

saved / step

Qwen2.5-7B (1+1 nodes, sustained over 200 steps)

3.8 s

9.1 s

2.4x

5.2 s

Qwen2.5-32B (2+2 nodes)

12.5 s

23.2 s

1.9x

10.7 s

Qwen2.5-72B (4+4 nodes, TP8)

12.0 s

36.9 s

3.1x

24.9 s

The delta sync time stays essentially flat from 32B to 72B – the sharded sparse gather amortizes over the larger trainer world – while the full broadcast grows linearly with parameter bytes, so the advantage widens with scale. The per-step changed ratio is stable at ~1-3% of parameter bytes across sizes and stays there over long runs.

Correctness evidence (details in the PR):

  • 200-step GRPO equivalence at 7B (delta vs nccl, 400 syncs): reward trajectories track phase-for-phase, final rewards within sampling noise, zero receiver checksum failures.

  • Bit-exact round-trip: perturb -> apply as delta -> revert -> apply as delta reproduces greedy generations byte-identically on every prompt.

Usage

A runnable example is verl/experimental/one_step_off_policy/shell/grpo_0.6b_gsm8k_fsdp2_sglang_delta_sharded_2_6.sh — the SGLang 2+6 disaggregated GRPO recipe with backend=delta_sharded.

Current scope: disaggregated (hybrid_engine=False) + SGLang rollout in BF16, FSDP1/FSDP2 training engines. Selecting a delta backend with any other rollout engine raises NotImplementedError at worker startup; a per-backend apply interface (vllm/trt-llm plugins) is planned.

Roadmap

Planned extensions, in design order:

  • Megatron-core trainers: the same delta_sharded backend via a Megatron get_per_tensor_param_shard export whose spec carries the native mcore→HF conversion as a pure-permutation to_hf closure (implemented and validated in a stacked follow-up PR).

  • Quantized rollout (fp8 etc.): diff the quantized bytes (quantize-then-diff) so a low-precision rollout engine can consume deltas without a bf16 intermediate.