Skip to content

feat: disk-delta weight sync for non-colocated rollout engines - #1235

Merged
yueming-yuan merged 5 commits into
radixark:mainfrom
nanjiangwill:feat/delta-weight-sync
Jul 9, 2026
Merged

feat: disk-delta weight sync for non-colocated rollout engines#1235
yueming-yuan merged 5 commits into
radixark:mainfrom
nanjiangwill:feat/delta-weight-sync

Conversation

@nanjiangwill

@nanjiangwill nanjiangwill commented May 28, 2026

Copy link
Copy Markdown
Contributor

ci-sglang-pr: #30366

Adds --update-weight-transfer-mode disk-delta: weight sync for non-colocated rollout engines that ships only the bytes changed between syncs — for cross-cluster/datacenter disaggregation where moving the full model every sync dominates.

Engine-side /pull_weights: sgl-project/sglang#30366 (sglang-miles), sgl-project/sglang#30367 (main). Refs: THUDM/slime#1806, THUDM/slime#2089, THUDM/slime#2181.

Design

  • First update_weights() publishes nothing: it seeds a CPU snapshot from --hf-checkpoint's safetensors bytes (= the engines' actual base, robust to non-byte-exact megatron→HF round-trips) and issues pull_weights(0) so each host warms its local checkpoint.
  • Each sync: source ranks diff gathered HF tensors against the snapshot (pinned-buffer GPU→CPU copies pipelined into a diff+zstd thread pool) and publish weight_v{N:06d}/ under --update-weight-disk-dir as a canonical HF checkpoint dir — compressed-diff shards + an index carrying version/encoding/checksum metadata, coordinated over gloo rather than the filesystem.
  • Engines pull_weights(N) before pausing (disk-only, overlaps rollout): per-host flock, in-place apply, per-tensor checksum verify, fail-loud. Then pause → flush → vanilla update_weights_from_disk(local_dir, weight_version=N) → continue.
  • PP-replicated params (word embedding on an MTP-hosting or tied-embedding last stage) are deduped at publish time — name→checksum maps gathered, divergent duplicates raise, lowest rank keeps — since the XOR apply is an involution (applied twice = reverted).
  • Built on DistBucketedWeightUpdateMixin's callback gather; no NCCL groups or engine lock; density/wire metrics drain via pop_metrics() into the step log.

Args

--update-weight-transfer-mode disk-delta
--update-weight-disk-dir ...              # shared FS: trainer publishes, hosts read
--update-weight-local-checkpoint-dir ...  # host-local full checkpoint, patched in place
--update-weight-delta-encoding xor        # or: overwrite (idempotent, larger)
--update-weight-delta-checksum xxh3-128   # or: blake3, adler32
--custom-update-weight-post-write-path    # non-POSIX FS publish hook

Read-side counterpart: --sglang-custom-pull-weights-pre-read-hook. New deps: xxhash, zstandard, blake3.

Validation

GLM-4.7-Flash GRPO on 4× H200: 2-node actor (TP2/PP2/CP2/EP8, MTP on → exercises the dedup) + one tp16/dp16 engine spanning 2 rollout nodes (exercises the multi-host pull fan-out). Disk-delta publishes to an object-store-backed shared volume via the post-write/pre-read hooks (xor + xxh3-128); the baseline is the same config with the default --update-weight-transfer-mode broadcast (full-checkpoint NCCL each sync).

One sync per training step: vN is the model after optimizer step N, published and applied before rollout N+1. "baseline" is the startup update_weights() before the first rollout: disk-delta ships nothing (the trainer captures its diff-base snapshot while each host copies the engine's base checkpoint to local disk); broadcast pushes the full weights.

disk-delta vs full-checkpoint NCCL broadcast

broadcast (default) disk-delta
payload per sync 62.4 GB (full bf16) 0.69–0.83 GB (−98.8%)
update_weights time 5.9 s first, 3.5 s steady 54.8 s first, 23.2 s steady
generation-paused window the whole sync (3.5–5.9 s; engines pause before the broadcast) 3–5 s — just the update_weights_from_disk reload; every other phase runs while the engine serves
effective transfer rate 10.7–17.7 GB/s through gather→convert→NCCL (RDMA) ≥2.1 GB/s per host volume read (62.4 GB base seed in ≤30 s, hosts in parallel); per-sync wire is ~1% of full
connectivity required NCCL reachability trainer↔every engine GPU a shared filesystem / object store only

Broadcast wins on raw sync latency inside one datacenter; disk-delta moves ~80× fewer bytes with a comparable generation-paused window and runs where NCCL can't (cross-cluster/datacenter, object-store-only links).

disk-delta per-sync detail

Sync Density Wire update_weights time
baseline (pre-rollout 1) 48.7 s (snapshot; 62.4 GB host seed overlapped)
v1 (after step 1) 0.37% 0.69 GB 54.8 s
v2 (after step 2) 0.44% 0.79 GB 23.2 s
v3 (after step 3) 0.44% 0.78 GB — (run ends after final sync)

Steady-state breakdown of the 23.2 s (from engine request timestamps):

Phase Time Engine
trainer: diff 62.4 GB vs snapshot + zstd + publish shards to shared volume ~19 s (combined with next row) up, generating
engine hosts: pull, decompress, checksum-verify, apply into local checkpoint ″ (completes before pause) up, generating
pause + KV flush ~1 s paused
update_weights_from_disk: local disk → GPU 3–5 s paused
resume <1 s

The only phase that needs the engine stopped is the final update_weights_from_disk reload — in a fully async rollout loop the engine keeps serving through diff/publish/pull, so the effective generation pause per sync is just the 3–5 s reload. All pulls checksum-verified on both engine hosts; the index holds each tensor exactly once.

Caveats

  • The baseline call publishes nothing, so a run resumed mid-training serves base weights for its first rollout (engines assumed to start from the trainer's HF checkpoint).
  • CI: compatible with --check-weight-update-equal (when the checker is armed, the baseline also reloads the pulled v0 checkpoint — the checker resets engine tensors and expects the first sync to rewrite everything). tests/e2e/megatron/test_qwen3_4B_disk_delta.py is registered under run-ci-weight-update, disabled until the CI image ships sglang /pull_weights ([RL] Add /pull_weights: engine-side pull of published weights into a host-local checkpoint (sglang-miles) sgl-project/sglang#30366).
  • LoRA and PD-disaggregation unsupported (validated).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Delta Weight Sync, a sparse, non-colocated weight synchronization mode that sends only changed weight positions and values instead of broadcasting full parameters at each step. It supports both NCCL and disk-based transports, along with multiple encoding strategies (indices, deltas, and zstd compression). The code review identified three critical or high-severity issues in the delta sync implementation: a runtime AttributeError due to the use of a non-existent torch.hash_tensor API, a potential distributed deadlock in _finalize_sync caused by conditionally calling the collective _publish_batch operation, and a potential RuntimeError when calling .view() on non-contiguous tensors in _bytewise_diff_mask.

@yueming-yuan

Copy link
Copy Markdown
Collaborator

qq, is this compatible with --check-weight-update-equal?

Add --update-weight-transfer-mode disk-delta: source ranks diff each
gathered HF tensor against a CPU snapshot of the previous sync and
publish only the changed bytes (xor or overwrite encoding, zstd, per-
tensor checksums) as a canonical HF checkpoint dir under
--update-weight-disk-dir. Each engine's /pull_weights (companion sglang
PR) applies the deltas into a host-local checkpoint on every host the
engine spans, and the engine reloads it via the ordinary
update_weights_from_disk path.

Migrated from slime PRs THUDM/slime#1806, radixark#2089 and radixark#2181 (final
design: disk-level delta + engine-side pull), rebuilt on miles
primitives: the delta updater subclasses DistBucketedWeightUpdateMixin
and feeds its callback-based TP/EP gather into the pinned-buffer
diff/compress pipeline.
Megatron replicates the word embedding onto the last PP stage when it
hosts an MTP block (and for tied embeddings), so one source rank per
stage gathers and diffs the same HF tensor. The XOR apply is an
involution, so a tensor present in two shard files is applied twice and
reverts — caught by the per-tensor checksum on GLM-4.7-Flash (PP2 +
--mtp-num-layers 1). Dedup at publish time: gather the per-rank
name->checksum maps, raise on divergent duplicates (replicas are
gradient-synced; divergence means broken sync), lowest rank keeps the
tensor. Broadcast/P2P are unaffected (duplicate loads are idempotent).
@nanjiangwill
nanjiangwill force-pushed the feat/delta-weight-sync branch from 49cfa17 to 32581c9 Compare July 7, 2026 18:22
@nanjiangwill

nanjiangwill commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

qq, is this compatible with --check-weight-update-equal?

Not yet. --check-weight-update-equal (snapshot → reset_tensors → compare) assumes the first sync rewrites every engine tensor - but disk-delta's first update_weights() only seeds the trainer-side baseline (engines untouched), and later syncs write only the changed bytes, so the compare would fail.

Integrity is covered differently here: every sync, each host verifies a per-tensor xxh3-128 checksum of the applied bytes against the trainer's export and fails loud on mismatch.

If we want CI compatibility: when the checker is on, make the baseline also full-reload the pulled v0 checkpoint via update_weights_from_disk

@yueming-yuan

Copy link
Copy Markdown
Collaborator

Note for future maintenance: (1) unify the checksum mechanism in miles repo (2) when refactor the weight update logic, unify some logic here (e.g. lifecycle, pop metrics...)

@yueming-yuan yueming-yuan added the run-ci-weight-update Run weight update tests label Jul 7, 2026
…i-weight-update

The weights checker (--check-weight-update-equal, armed by --ci-test)
resets engine tensors at startup and compares after the first sync,
expecting it to rewrite every tensor — but the disk-delta baseline
publishes nothing. When the checker is armed, the baseline now also
reloads the just-pulled v0 checkpoint via update_weights_from_disk,
restoring engine state and setting the weight version the CI equality
check expects.

Adds tests/e2e/megatron/test_qwen3_4B_disk_delta.py (mirrors the p2p
weight-update test: 4 actor + 4 rollout GPUs, --ci-test), registered
under labels ["megatron", "weight-update"]; disabled until the CI image
ships sglang /pull_weights (sgl-project/sglang#30366).
@nanjiangwill

Copy link
Copy Markdown
Contributor Author

Update: --check-weight-update-equal is now supported — when the checker is armed, the baseline also reloads the pulled v0 checkpoint (the checker resets engine tensors and expects the first sync to rewrite everything). Added test_qwen3_4B_disk_delta.py under run-ci-weight-update, disabled until the CI image ships sglang /pull_weights (sgl-project/sglang#30366).

yueming-yuan and others added 2 commits July 8, 2026 15:25
…ge ships them

The GPU CI image predates the requirements.txt additions and everything
is installed --no-deps, so the disk-delta test failed with
ModuleNotFoundError: zstandard in the trainer actor. Same pattern as
polars: install alongside it until the next image rebuild.
@yueming-yuan
yueming-yuan merged commit 01a6d7b into radixark:main Jul 9, 2026
28 checks passed
@nanjiangwill
nanjiangwill deleted the feat/delta-weight-sync branch July 9, 2026 03:22
nanjiangwill added a commit to modal-projects/miles that referenced this pull request Jul 11, 2026
…int-url)

An elastic fleet behind one opaque HTTP endpoint exposes no per-engine
handles, so the disk-delta updater can't push pull_weights/update RPCs or
gather per-engine success. In publish-only mode each sync publishes the
version dir, then advances an atomic `latest` pointer (running the post-write
hook on the version dir first, and on the pointer after, so a non-POSIX
shared store surfaces the data before the pointer that names it). The fleet
pulls on its own schedule; per-request weight-version gating on the endpoint
is the consumer-side guarantee.

The trainer-side updater still connects every sync (actor.py guard: zero
engines means has_new_engines never fires and the disk-delta class defines no
is_rollout_engines_fresh), and validation forces rollout_num_gpus=0 so no
rollout GPUs are reserved in the placement group.

Ported from the nvfp4-disagg-v2 fork (1210351) onto the radixark#1235 delta
architecture, where it is simpler: pause/flush live inside _reload_engines,
so publish-only just takes the _announce_version branch, and
_capture_baseline needs no guard (empty engine lists are already no-ops).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ucT9vHENjhVatxSvsT1Mq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci-weight-update Run weight update tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants