Repository navigation
Conversation
Add `--train-backend fsdp2`: HF transformers causal LMs sharded with `torch.distributed.fsdp.fully_shard` over every training rank, as a second backend next to Megatron. Backend selection goes through a new `vime/backends/registry.py` (backend name -> actor class and backend-only flags, by import path). Shared training files change in two places only: - `_pre_parse_mode()` takes the `--train-backend` choices from the registry and merges the selected backend's own flags (`--fsdp-*`), which the Megatron parser would otherwise drop as unknown; - `RayTrainGroup` resolves the actor class through the registry. The backend lives in `vime/backends/fsdp_utils/` and reuses vime's shared pieces instead of copying them: - the rollout side's DP/micro-batch schedule (micro-batches are packed into one padding-free row with restarting position ids); - `megatron_utils/loss.py` (log-probs, advantages, policy/KL/entropy losses) and `train_metric_utils`, with Megatron's gradient scaling; these read their process groups from `megatron.core.mpu`, so the actor registers the pure data-parallel layout (TP = PP = CP = 1); - the vLLM weight-transfer trainers (CUDA IPC for colocated engines, NCCL otherwise) fed by a source that materializes parameters per bucket with `full_tensor()` under their HF names; - vime's checkpoint layout (`iter_<rollout_id>` + tracker), saved with torch.distributed.checkpoint, plus optional HF export. The LR/weight-decay schedule counts samples like Megatron's, AdamW uses Megatron's no-decay grouping, and the reference model shares the sharded module through host copies of each rank's shards. Scope: dense causal LMs (e.g. Qwen3), colocate and non-colocate, GRPO-style actor with optional reference model, sleep/wake (shards and optimizer state moved to the host), checkpoint save/resume. Critic, Megatron OPD teacher, draft/MTP training, routing replay, multimodal, TP/PP/CP and disk/delta weight sync are rejected with an explicit error. Megatron's argument parser and model-shape validation still run for fsdp2, so launches keep passing the model-arch flags (`scripts/models/*.sh`). Model loading, the rank-0 broadcast flow, checkpointing, packing and the LR schedule are adapted from slime's FSDP backend before its removal (THUDM/slime 0104a9e, Apache-2.0); provenance is noted in each file. Tests: - CPU (registered in the plugin-contracts step): backend registry and flag parsing, packing/schedule/LR, and a gloo `fully_shard` smoke test on a tiny random Qwen3 that checks packed log-probs against an unsharded model, identical updates for dp=1 and dp=2 (GRPO, per-token loss, KL with a reference model), weight export, model switching, checkpoint round trip and HF export. - GPU (registered in the vime-customized suite): Megatron vs fsdp2 on the same weights and batch (grad norms and old-policy log-probs). It has not been run yet; its tolerances are first estimates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Follow vime's review preferences on the new backend: - inline single-use wrappers (pure data-parallel mpu registration, HF state dict loading, meta-device construction, rollout dtype) at their only call sites and remove `fsdp_utils/parallel_state.py`; - remove code only tests used (`register_train_backend`, `unpack_sequences`, `is_fsdp_checkpoint`) and state nothing read (`FSDPTrainer.active_tag`, the always-empty weight-update metrics); - reuse `ROLLOUT_TOP_P_TOKEN_KEYS` from the shared loss module; - read configuration attributes directly instead of `getattr` defaults, and reject disk weight sync with one predicate (delta sync already requires the disk transport). Behavior is unchanged. The weight source's contract was checked against the vLLM weight-transfer trainers at the pinned commit 43b4aaea: the IPC and NCCL trainers only call `metadata()` and iterate the source, and NCCL verifies each yielded name, dtype and shape against the metadata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
The fsdp2 backend keeps Megatron as a dependency: launches go through Megatron's argument parser and validation, and the shared loss/metric helpers read their process groups from `megatron.core.mpu`. - `test_fsdp2_cpu_smoke.py` registers the actor's pure data-parallel layout with Megatron's `initialize_model_parallel` when Megatron is installed, and falls back to the stub only without it. - `test_fsdp2_backend_registry.py` adds a full `parse_args()` for an fsdp2 launch (skipped without Megatron): the `--fsdp-*` flags survive Megatron's parser, and the shared fields the trainer reads (load path, LR, clip, weight-decay schedule) are filled in by Megatron's validation. - Both files also run in the `upstream-sync-cpu` step, whose image ships the pinned Megatron, so CI covers the real path as well as the stubbed one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Match how the Megatron updaters on this branch drive the vLLM weight transfer: create the NCCL trainer with `create_nccl_trainer`, shut every trainer down the same way on disconnect, and pause generation and flush the cache on every sync (there is no `--flush-cache-interval` here). Also keep the CPU test count in `.buildkite/README.md` in step with this branch's pipeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Documentation build overview
68 files changed ·
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new FSDP2 training backend (--train-backend fsdp2) to shard Hugging Face transformers models using PyTorch's fully_shard. It adds the necessary Ray training actor, trainer, distributed checkpointing, data packing, learning rate scheduler, and weight updater to push weights to vLLM rollout engines, along with comprehensive CPU unit and smoke tests. Feedback on the changes highlights two key issues: a critical race condition in FSDPWeightSource._materialize where calling to_local() on a DTensor before wait() prevents waiting on the asynchronous redistribute collective, and a potential ZeroDivisionError in FSDPParamScheduler.get_wd if self.wd_incr_steps is zero.
| if isinstance(tensor, DTensor): | ||
| tensor = tensor.redistribute(placements=[Replicate()] * tensor.device_mesh.ndim, async_op=True) | ||
| tensor = tensor.to_local() | ||
| pending.append((name, tensor)) | ||
| for name, tensor in pending: | ||
| if hasattr(tensor, "wait"): | ||
| tensor = tensor.wait() | ||
| yield name, tensor |
There was a problem hiding this comment.
In _materialize, calling tensor.to_local() immediately after redistribute(..., async_op=True) on a DTensor returns a standard torch.Tensor. Since standard PyTorch tensors do not have a wait() method, the subsequent hasattr(tensor, \"wait\") check evaluates to False, and the code never waits for the asynchronous redistribute collective to complete. This creates a critical race condition where incomplete or uninitialized weights are yielded and sent to the rollout engines.\n\nTo fix this, keep the DTensor in the pending list, call wait() on it first, and only then call to_local() to retrieve the physical local tensor.
if isinstance(tensor, DTensor):
tensor = tensor.redistribute(placements=[Replicate()] * tensor.device_mesh.ndim, async_op=True)
pending.append((name, tensor))
for name, tensor in pending:
if hasattr(tensor, \"wait\"):
tensor = tensor.wait()
if isinstance(tensor, DTensor):
tensor = tensor.to_local()
yield name, tensor| if self.num_steps > self.wd_incr_steps: | ||
| return self.end_wd |
There was a problem hiding this comment.
If self.wd_incr_steps is 0 (which can happen if args.train_iters is 0 due to a small dataset or large global batch size), the division float(self.num_steps) / float(self.wd_incr_steps) at line 86 will raise a ZeroDivisionError for non-constant weight decay styles.\n\nAdding a guard to check if self.wd_incr_steps == 0 and returning self.end_wd immediately prevents this crash.
| if self.num_steps > self.wd_incr_steps: | |
| return self.end_wd | |
| if self.wd_incr_steps == 0 or self.num_steps > self.wd_incr_steps: | |
| return self.end_wd |
…cking With --offload-train the train group preloads torch_memory_saver with tracking enabled from process start, so every CUDA allocation of the actor is a VMM mapping. CUDA IPC cannot export those: the first colocated weight push failed in vLLM's packed IPC producer with `storage._share_cuda_() -> CUDA error: invalid argument` (Qwen3-0.6B, 4x H100, colocate). The Megatron actor already runs its push under `torch_memory_saver.disable()`; do the same in the fsdp2 actor. The actor sleeps (and empties the cache) before every push, so the transfer buffers are fresh, untracked allocations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
… gradient non-vacuous - Add a CPU test that the actor pushes weights with torch_memory_saver tracking disabled under --offload-train, and never touches it otherwise. It fails without the previous fix. - In the first H100 run of the Megatron/fsdp2 alignment test every response was truncated, so all rewards and GRPO advantages were zero and both backends reported a zero gradient: the 1% grad-norm comparison passed without comparing anything. Give the loss a small entropy term so the gradient does not depend on rewards alone, and stop the test when Megatron's gradient is zero. The CPU check of the script covers both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
…nce loss Add a CPU test that compares the trainer's step-0 gradient on two gloo ranks with a plain, unsharded fp32 computation of the same loss (entropy term, optionally k1 KL against a reference model). It covers packing, loss scaling, the reduce-scatter and the switch between the actor and reference weights in one place. With all rewards zero the GRPO advantages vanish, so the expected gradient comes from the entropy and KL terms alone. With k1 the gradient does not depend on the reference log-probs, so a reference model that differs from the actor leaves the expected gradient unchanged. That case fails if the trainer keeps computing on the reference weights after the reference log-probs (checked by removing the switch back to the actor). All three cases (entropy only, KL with reference equal to the actor, KL with a different reference) match within rtol 1e-4. This rules out a logic error in the KL / reference-model path; it does not exercise bf16 numerics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
Implements the design proposed in #478:
--train-backend fsdp2, a second training backend next to Megatron that shards HF transformers causal LMs with PyTorch FSDP2 (fully_shard) over all training ranks.The new code is in
vime/backends/registry.pyandvime/backends/fsdp_utils/. Shared training files get two small hooks:_pre_parse_mode()takes the--train-backendchoices from the registry and merges the selected backend's own flags (--fsdp-*). The Megatron parser runs withignore_unknown_args=Trueand would otherwise drop them silently.RayTrainGroupresolves the actor class through the registry.The backend reuses vime's existing training pieces rather than copying them:
position_ids);megatron_utils/loss.pyandtrain_metric_utils, with Megatron's gradient scaling (the actor registers a pure data-parallel layout inmegatron.core.mpu);full_tensor();torch.distributed.checkpoint.The LR / weight-decay schedule counts samples exactly like Megatron's
OptimizerParamScheduler, and AdamW uses Megatron's no-decay grouping. The reference model shares the sharded module through host copies of each rank's shards. Megatron stays a dependency: fsdp2 launches go through Megatron's argument parser and validation and pass the usualMODEL_ARGS.Scope:
Model loading, checkpointing, packing and the LR schedule are adapted from slime's former FSDP backend (Apache-2.0); provenance is noted in each file header.
Commits:
create_nccl_trainer, and pause + flush on every sync because this branch has no--flush-cache-interval;The diff contains only these changes (19 files, +2903/−5) on top of
0z5a-branchat8c8b8e8e.Validation
H100 runs (4× H100, Qwen3-0.6B)
All three runs used the equivalent code on top of
main; this branch differs from it only in commit 4's weight-transfer helper calls. The second and third runs include commits 5 and 6; commit 7 only adds a CPU test. The third run only replays the KL variant's step-0 batch (see below). In the end-to-end cases vLLM starts with dummy weights, so rollouts can only see weights the trainer pushed.train.py: 3 rollouts × 2 steps, KL loss with a reference model, a DCP checkpoint per rolloutsdpafp32 4.7e-6How the alignment is judged. The criterion is relative to bf16 noise and was fixed before the second run:
The test's built-in limits are stricter (log-prob ≤ 2e-2; grad norm within 1%). By those, the plain alignment also passes, and the KL variant fails at step 0. The test itself has no KL term, so its limits are unchanged.
KL variant on CPU. The plain alignment, without the KL term, agrees within 0.06%, and the KL term shares its loss scaling with the rest of the loss, so the gap comes with the k1 term. At coefficient 1.0 that term dominates the gradient (grad norm about 2.5 with it, about 0.08 in the plain alignment run). fsdp2's KL loss and reference-model switch were therefore checked on CPU: real
fully_shardover gloo, 2 ranks, fp32, a tiny Qwen3 with tied embeddings. The fsdp2 trainer's step-0 gradient was compared with an independent plain-PyTorch computation of the same loss (zero advantages, entropy coefficient 0.01, k1 KL coefficient 1.0):The k1 gradient does not depend on the reference values, and the last two cases agree. So fsdp2's KL loss and its switch between actor and reference weights compute the intended gradient; commit 7 keeps this as a test. This rules out a logic error in fsdp2's KL / reference-model path.
KL variant: fp32 replay on H100. The second run's step-0 batch was replayed without bf16:
--fsdp-param-dtype fp32, sdpa, TF32 off, 4 ranks)--fsdp-gradient-checkpointingWithout bf16, the fsdp2 trainer reproduces the exact gradient. The 1.07% gap is the bf16 numerics of the two implementations, with opposite signs, and needs no code change. At step 1, which already carries step 0's update, both bf16 values sit above the fp32 replay (2.5077): Megatron by 0.59%, fsdp2 by 0.75%. Each bf16 configuration was run once; the fsdp2 rerun matches the second run within 1e-5 relative.
The first run found two problems, both fixed here:
storage._share_cuda_()raisedCUDA error: invalid argument. With--offload-train, the train group preloads torch_memory_saver with tracking enabled from process start, so every CUDA allocation of the actor is a VMM mapping that CUDA IPC cannot export. The Megatron actor already runs its push undertorch_memory_saver.disable(). In the second run a standalone probe reproduced the failure under tracking and showed the export succeeding underdisable().CPU, run on this branch
tests/test_fsdp2_backend_registry.py: 20 passed, 1 skipped without Megatron; 21 passed with Megatron-LM importable.parse_args()for an fsdp2 launch.torch_memory_saver.disable()is entered only under--offload-train. Removing commit 5 makes it fail.tests/test_fsdp2_data_packing.py: 9 passed.tests/test_fsdp2_cpu_smoke.py: 9 passed, with both thempustub and realmegatron.core.parallel_state. It runs realfully_shardover gloo on a tiny random Qwen3 and checks:Removing the micro-batch or world-size loss scaling makes the dp comparison fail; removing the switch back to the actor weights after the reference log-probs makes the differing-reference gradient case fail.
Existing tests touched by the shared hooks: 236 passed, 2 skipped.
pre-commit hooks passed on the changed files.
The real-Megatron runs on CPU used a local Megatron-LM v0.19.2 checkout (
4b4acac9), not the commit pinned in this branch's Dockerfile. Buildkite's CPU steps passed on the current head.Not covered yet:
--fsdp-cpu-offload;AI assistance: implementation and tests were prepared with Claude Code at the submitter's request, with attribution and DCO sign-off in each commit. Kept as a draft pending the submitter's line-by-line review; no end-to-end training-quality or performance result is claimed.
🤖 Generated with Claude Code