Repository navigation
feat(weights): sparse XOR deltas for the filesystem weight broadcast - #4085
faresobeid wants to merge 95 commits into
Conversation
e7c9109 to
df525f5
Compare
9a5ace2 to
01b0801
Compare
…and index ranges they hold
… fp32 routers) as long as they never change
…ine to fp8_per_block
…r's critical path
…se pinned buffers for anchors and prefetch
…arger zstd frames read in parallel
…page cache after reading
…nchor and release it after the write
… prefetch by tensor only
…a writes, uint8 gap frames
…e files from the page cache; map fp8 tensors from their bf16 checkpoint
…rs to bound GPU memory
…ock the snapshot in the background
…lta.interval` names the anchor interval - Tied weights (e.g. a tied lm_head) appear under several names; only the first carries deltas, checksums and anchor slices, so their changes are not sent twice. Engines hold a tied weight once. - `DeltaWeightBroadcastConfig.anchor_interval` is now `interval`: a full checkpoint (anchor) every this many steps, deltas against the previous step in between.
…the weight update `WeightReceiver.prepare` does the work of an update that needs no engine pause; the watcher awaits it before `on_version_pending`. The filesystem receiver acknowledges the version and has the engines read and decode a sparse delta there, so rollouts keep being dispatched meanwhile. Before, train dispatch stopped for the whole receive (0.37-0.75 s on Qwen3-0.6B/8B vs 0.33-0.40 s with NCCL), which left fewer rollouts in flight.
…m-weight-broadcast # Conflicts: # src/prime_rl/trainer/optim/__init__.py
…weight Only the deltas go out once under a tied weight's first name. Its other names are written to full checkpoints and to anchors (and their checksums), from the same values.
…sts on load A tensor vLLM casts on load (e.g. GLM-5's fp32 `indexer.k_norm` from a bf16 checkpoint) was left out of `row_ranges`, so an anchor or replay load kept the engine's stale values without a word. Its writes are now recorded like the others and loaded by value with the same cast; a delta that changes it still raises.
…m-weight-broadcast
vLLM 0.33.0 is compiled against and pins torch 2.14.0, so this also moves the torch stack: torch 2.14.0, torchvision 0.29.0, triton 3.8.0, NCCL 2.30.7, flashinfer 0.7.2.post1, transformers 5.20.2 (what vLLM 0.33 CI tests), nvidia-cudnn-frontend 1.31.0, and NIXL 1.5.0 (required by vLLM's NIXL connector from 0.33). Breakages fixed: - serving_tokens: upstream reads sampling_params.logprobs before consuming outputs and passes derender_request; decide logprob packing up front. - server: cli_args moved to vllm.entrypoints.common. - qwen38 PLE overlap is now a method; GPT-OSS _load_weights_other lost heads_per_rank/head_start and sinks now load via their own loader. - routed experts: _get_local_prefix_cache_hit takes max_cache_hit_length; P/D decode requests skip the new retained-row caps. - sampling_order: accept has_topp_only_rows. Removed (fixed upstream): DeepSeek V4 C128 boundary (#59709), DeepSeek V4 attn_sink loading (#57797), aux-output store eviction rescan (#62392). flash-mla has no torch 2.14 build; the trainer now uses the FlashMLA vLLM bundles (also on aarch64). flash-attn moves to its torch 2.14 wheels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itself vllm-project/vllm#41482 resets load_inplace before caching the adapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orch 2.14 From PrimeIntellect-ai/prime-kernels release v0.1.0-b050074 (PrimeIntellect-ai/prime-kernels#24). torchao stays on v0.1.1: it only uses the stable libtorch ABI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vllm-project/vllm#62591 admits an external hit only when every block's routing rows are still retained, which it does for at most 2x the GPU KV cache. With a CPU offload tier every multi-turn reload was refused: 0 external hit tokens per turn on a 48-conversation x 4-turn OLMoE run (RTX PRO 6000), vs 13-25k with routed experts off. A load within the routed-experts clamp needs none of its rows, so admit it whole; with the fix the external hits match the routed-experts-off run exactly, every row count is right and generated tokens are identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l checksums - TP rank 0 plans each update (checkpoint to load, deltas to apply, full or incremental verify) and the others follow it, so workers can no longer split between apply, replay and anchor load (an anchor completing mid-update, or a failure on one worker) and issue different collectives. - A failure on any worker fails the update on all of them (gloo flag), so they all reload and replay the same way next time. - One fixed-size check over every checkpoint tensor of the run: every worker runs the same reductions whatever it holds. A tensor is checked against multiplicity x expected when its holders cover it evenly, so KV heads replicated over TP workers (TP > num KV heads) no longer fail every load. - Checksums move with the changes in apply_delta (O(changes) instead of a pass over every param); steps that carry an anchor recompute them in full. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the per-chunk tables, searchsorted, argsort and index_put of the torch path on GPU: each change finds its tensor by binary search over the delta's entries, maps through device tables built once per index map, and adds its bits in place; it also returns its running-checksum contribution. The kernel is compiled when the index map is built, outside any pause. The torch path stays for CPU and as the reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write can carry row and/or column offset tables instead of strides (element (r, c) at offset + rows[r] + cols[c]). Loads, deltas (torch path and fused kernel) and checksums all go through them. This is what kernel layouts that shuffle rows or swizzle block scales need (Blackwell MXFP8/NVFP4 MoE and linear backends); nothing creates such maps yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pdates Positions, counts and how evenly the TP group covers each tensor depend only on the index map's checksum plan, so they (and three of the four reductions) are computed once; an update reduces only the sums and the mismatch flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Triton specialized the kernel on its sizes and pointer alignments, so a delta with new ones compiled inside the weight-update pause (0.7-2 s apply spikes on an MXFP8 Qwen3-0.6B engine, against 0.02 s otherwise). do_not_specialize keeps the variants the index map compiles ahead of time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CUDA events split each chunk into copies, index decode and patch, next to the host time of the loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…LM 0.33 vllm-project/vllm#61343 quantizes per-token groups as x * (1/s) instead of x / s, so the trainer's Triton casts (meant to be bit-identical to vLLM's) differed in 0.03-0.08% of fp8 values. Multiply by the exact reciprocal (div_rn(1, s)) on the non-ue8m0 path; test_fp8_utils passes again (25/25 on an RTX PRO 6000). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # uv.lock
… from the params The update plan probed the delta manifest without revalidating the step dir, so a stale NFS lookup cache could turn a sparse step into a full load. Anchor and full-checkpoint loads checked the rows read from disk, not the params after load_rows; they now check the live params like the delta path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vLLM can move blocks within a param after loading: FlashInfer CUTLASS (unquantized MoE, e.g. on SM120) swaps each expert's fused w13 halves to [up; gate]. The index map, recorded from the loader's writes, kept them at [gate; up], so anchor loads and replays served swapped experts while the checks (which read through the index map) passed. At the index map's build, while the engine holds its boot weights, compare the bytes at every write against the boot checkpoint's rows (fp8: either scale format; casts like the loader). A write whose block sits at another write's place in the same param takes that place; a block found nowhere, or a layout that is not a permutation of the writes, raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1de91f1. Configure here.
| model = self._model() | ||
| index_map = CheckpointIndexMap(model, tensors) | ||
| boot_dir = self._boot_dir() | ||
| moved = index_map.match_boot(_read_rows(index_map, boot_dir, next(model.parameters()).device)) |
There was a problem hiding this comment.
Boot read requires every recorded name
Medium Severity
The first sparse update now always reads every recorded checkpoint name from the engine boot dir via _read_rows. A name the loader recorded from the trainer (for example a tied lm_head.weight omitted from the Hugging Face safetensors) is missing from checkpoint_index and raises, so the update never starts. Before this, the boot index was only used for fp8 layout checks.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1de91f1. Configure here.


Adds sparse deltas to the filesystem weight broadcast. Between anchors, the trainer publishes only the weight elements that changed in the optimizer step, in the engine's weight format (bf16, or fp8 for engines that quantize online to
fp8_per_block). Engines patch their weights in place and verify every load against trainer checksums.How it works
Trainer: deltas from the optimizer step. Just before the optimizer writes a parameter chunk, the local shard still holds the previous broadcast's weights, so the delta is taken from the step itself and no copy of the weights is kept.
offload = "none"/"optimizer"): AdamW / SignSGD step up to 64M local elements at a time and callupdate_hookaround each chunk (ChunkedUpdates). Muon steps its own batches of matrices as chunks (the same on every rank, since they run collectives); per-group step counts still advance once per step.offload = "full":FullCPUOffloadOptimizer.weight_hookreplaces the H2D copy of the new values.Format. The prime->HF conversion is replayed once on meta tensors, so each changed element maps to an HF tensor and index. Each rank writes zstd part files (index gaps + zigzag value differences, ~1.5 B per change) with a position-weighted checksum for every HF tensor. Anchors are per-rank slices of a full checkpoint, written without a gather; full checkpoints carry per-tensor checksums too.
FP8 engines (online
fp8_per_block, incl. DeepGEMM ue8m0). Every engine worker (one per DP, PP and TP rank) reports which tensors it holds in fp8 and their scale format at every full broadcast; the trainer clears the last run's reports and takes them at its first broadcast. The trainer quantizes old and new weights with vLLM's quantizer (compiled once with AOTInductor at the startup broadcast, bit-identical) and diffs the fp8 bytes; changed block scales travel in the delta as{name}_scale_inv. MLA absorbed weights are recomputed after each delta.Engine (
FileSystemWeightUpdateWorker)./prefetch_weightsreads and decompresses this worker's parts while the engine keeps serving, into a reused pinned buffer. The orchestrator does this inWeightReceiver.prepare, before it stops train dispatch for the update, so rollouts keep being dispatched meanwhile. In the pause the worker applies the delta in place on the GPU and verifies every tensor it holds.load_weightson placeholders (name mapping, qkv/gate_up/expert packing, TP/EP slicing; no per-model tables).multiplicity x trainer checksumwhen its holders cover it evenly: TP slices once, KV heads replicated over TP workers (TP > num KV heads) once per replica. Before, replicated KV heads were summed once per replica and never matched.apply_deltaupdates each tensor's checksum byposition x (new - old)of its changed elements (O(changes) instead of a pass over every param); steps that carry an anchor recompute them in full. The static half of the TP check (positions, counts, how evenly the TP group covers each tensor, and three of its four reductions) is computed once per index map.inference/vllm/worker/sparse_apply.py): each change finds its tensor by binary search over the delta's entries, maps through device tables built once per index map, adds its bits in place (indices are unique, so no sort or atomics) and returns its running-checksum contribution. It replaces the per-chunk tables,searchsorted,argsortandindex_putof the torch path, which stays for CPU and as the reference. It is compiled when the index map is built, withdo_not_specializeon its sizes and pointers so no delta recompiles it inside the pause (we saw 0.7-2 s apply spikes from that).(r, c)atoffset + rows[r] + cols[c]), through loads, deltas and checksums: what kernel layouts that shuffle rows or swizzle block scales need (Blackwell MXFP8/NVFP4 backends; used by the MXFP8 follow-up PR).Earlier results (before this round)
All runs on H200; every update verified against trainer checksums.
offload = "optimizer"and with full offload; +0.34 s for AdamW"none".offload = "optimizer": 82.4 GiB vs 89.5 GiB with full-copy diffs.anchor_interval = 50that averages to ~0.2-0.5 s per step.Exactness. Every delta, anchor and full-checkpoint load passed the checksum check. A fresh engine joining at a non-anchor step verifies; fail-then-retry heals; a double-applied or off-by-one delta, and a corrupted anchor, fail the check. fp8 in-place patches are byte-identical to a full fp8 reload (Qwen3-30B, DeepSeek-V3 with MLA), including scale changes.
Per-op timings on the current head
H200, reverse-text, batch 128 × 16, max 512 completion tokens, 40 steps (GLM: 30), anchor every 20 steps (GLM: 15). Medians over steps > 3. Single node: 4 trainer + 4 inference GPUs (Qwen3-30B-A3B: 6 + 2, TP2). GLM-4.5-Air: 1 trainer node (EP8, full offload) + 1 inference node (DP8+EP). "Dispatch-blocking update" is the time train dispatch is stopped per step (pause + apply/verify + resume for sparse, pause + transfer + resume for NCCL). Every sparse delta was applied and checksum-verified on every engine worker (0 mismatches); anchors were written and verified at write time.
interval = 50that is 0.2-0.35 s per step.Validation
On vLLM 0.33 (
chore/vllm-0.33merged in, so until #4365 lands the diff againstmainalso shows the vLLM bump):CPU:
pytest tests/unit/orchestrator tests/unit/test_configs.py tests/unit/utils tests/unit/transports tests/unit/inference tests/unit/train/test_chunked_updates.py -m "not gpu": 552 passed (551 before the plan/checksum change, plustest_running_checksums_follow_deltas); ruff clean.GPU (RTX PRO 6000, vLLM 0.33): a TP=4 engine of a Qwen3-0.6B variant with 2 KV heads (each replicated on 2 TP workers) loading a trainer-format anchor through
update_weights_from_path: before this change every layer'sk_proj/v_projfailed verification (diverged from the trainer); now the anchor loads and verifies, and generation is unchanged.Qwen3-4B (TP=2 engine, 2 trainer GPUs, AdamW, 12 steps, anchor every 5; 130-190M changes per update, 3-5% of elements), seconds per update on one engine worker, RTX PRO 6000:
7eb55c659)0ea767d4f)Every update on the fused head was applied and verified (24/24, 0 mismatches) and the run trained normally (reward 0.25 -> 0.40).
0ea767d4falone is slower on GPU (the torch path's running-checksum gathers); it ships with the fused kernel. The remaining apply spread (0.1-1.4 s for the same work) is being traced with the new per-phase log.Kernel microbenchmark (1 GiB of bf16 params, RTX PRO 6000): apply torch 10.1 ms / fused 2.3 ms at 5M changes, 25.1 / 2.7 ms at 21M; full checksum 25 ms vs running 0.1 ms.
GPU unit tests:
tests/unit/inference/test_checkpoint_index_map.py(fused apply bit-identical to the torch path, mapped writes included) pass.Earlier (vLLM 0.32):
main(plain merge). CPU on the head:pytest tests/unit/orchestrator tests/unit/test_configs.py tests/unit/utils tests/unit/transports tests/unit/inference tests/unit/train/test_chunked_updates.py -m "not gpu"531 passed;ruff check,ruff format --check,uv lock --checkclean.lm_head.weightbyte-identical tomodel.embed_tokens.weight.Notes
fp8_per_block; conversions that copy or transform values.Note
High Risk
Changes the RL weight-sync path (sparse deltas, in-place patches, cross-rank verification) alongside a major vLLM/torch upgrade; mis-sync or checksum bugs would silently or loudly break inference/trainer alignment.
Overview
Adds sparse filesystem weight broadcast: with
[weight_broadcast.delta], the trainer publishes only changed weight elements between periodic full anchors, and inference workers prefetch, patch in place, and checksum-verify updates instead of reloading full checkpoints.The trainer diffs each optimizer chunk via a new
ChunkedUpdatesmixin and Tritonsparse_kernels, overlapping encode with the step; the orchestrator callsprepare//prefetch_weightswhile engines still serve, then applies deltas through an expandedFileSystemWeightUpdateWorker(CheckpointIndexMap, fusedsparse_applykernel, anchor replay, TP-group planning).Also bumps the GPU stack to vLLM 0.33 / torch 2.14 / NIXL 1.5, drops the standalone
flash-mlawheel (FlashMLA from vLLM), removes obsolete DeepSeek V4 vLLM monkey patches, and adjusts routed-experts / LoRA / token serving patches for vLLM 0.33 behavior.Reviewed by Cursor Bugbot for commit 1de91f1. Bugbot is set up for automated code reviews on this repo. Configure here.