Repository navigation
[ckpt] fix: load FSDP checkpoint shards onto CPU instead of the saved device - #7286
Open
jackychris wants to merge 1 commit into
Open
jackychris wants to merge 1 commit into
jackychris wants to merge 1 commit into
Conversation
… device
The three `torch.load` calls in `FSDPCheckpointManager.load_checkpoint` pass
no `map_location`, so each tensor is revived on the device it was saved from.
On a colocated single-GPU run the rollout engine already holds most of the
card, and resuming dies before the first step:
torch.OutOfMemoryError: Tried to allocate 4.64 GiB.
GPU 0 has a total capacity of 79.33 GiB of which 1.11 GiB is free.
Naming CPU is consistent with what the surrounding code already declares: the
enclosing `get_fsdp_state_ctx` is entered with
`ShardedStateDictConfig(offload_to_cpu=True)` whenever CUDA is available, so
this manager treats its sharded state dicts as CPU-resident on the save side.
The load side is the only place that does not say so. Staging through host
memory bounds the peak by the shard size rather than by whatever the card
happens to have free.
Co-authored-by: Claude
Signed-off-by: fuhao <fuhao_work@163.com>
5 of 8 tasks
TiagoMLucio
added a commit
to TiagoMLucio/verl
that referenced
this pull request
Oct 1, 2026
… device Read the model, optimizer and extra-state files in FSDPCheckpointManager.load_checkpoint with map_location="cpu". A resume then reads every per-rank file into host memory, so the model shard no longer sits on the GPU next to the live parameters while they load; load_state_dict still moves the optimizer state onto each parameter's device. Same change as upstream verl-project#7286.
This was referenced Oct 14, 2026
wuxibin89
pushed a commit
that referenced
this pull request
Oct 15, 2026
) ### What does this PR do? `FSDPCheckpointManager` gates FSDP's sharded-state-dict CPU offload on `is_cuda_available`: ```python state_dict_cfg = ShardedStateDictConfig(offload_to_cpu=True if is_cuda_available else False) optim_cfg = ShardedOptimStateDictConfig(offload_to_cpu=True if is_cuda_available else False) ``` `is_cuda_available` is `torch.cuda.is_available()` evaluated once at import (`verl/utils/device.py`). On any non-CUDA accelerator it is permanently `False`, so the gathered model and optimizer shards stay resident in device memory for the whole `torch.save` window instead of being staged through host memory. CUDA gets the memory headroom; every other backend pays for it, with no way to opt in. This switches the four sites to `is_device_available()` — the platform-abstraction check (`get_platform().is_available()`) that this same class already uses to decide whether accelerator RNG state is reachable in `BaseCheckpointManager.get_rng_state`. Why this is a per-device-class oversight rather than a deliberate CUDA-only choice: the `offload_to_cpu` lines predate the platform abstraction layer (present at least as of 2026-04-20; `is_device_available` arrived 2026-06-04 in #6086), and the migration to it has been incremental — #7871 applied the same `is_device_available()` treatment to the Megatron engine's seed path in September. `offload_to_cpu` is a plain PyTorch FSDP feature with no CUDA-specific code behind it, so there was never a technical reason to exclude other backends. The measurement below confirms it works on a non-CUDA backend today. ### Checklist Before Starting - [x] Search for similar PRs: - https://github.com/verl-project/verl/pulls?q=offload_to_cpu - https://github.com/verl-project/verl/pulls?q=is_device_available - https://github.com/verl-project/verl/issues?q=offload_to_cpu I enumerated every open PR in the repo and checked its changed-file list: **#7286 is the only open PR that touches `verl/utils/checkpoint/fsdp_checkpoint_manager.py`.** **Not a duplicate of #7286** (`[ckpt] fix: load FSDP checkpoint shards onto CPU instead of the saved device`, open). That PR is the closest neighbour — same file, same "treat sharded state dicts as CPU-resident" theme — but a disjoint change: it adds `map_location="cpu"` to the three `torch.load` calls in `load_checkpoint`, which is wrong on CUDA too and fixes where tensors are *revived from disk*. This PR changes the `ShardedStateDictConfig` / `ShardedOptimStateDictConfig` gather configs, which is CUDA-privileged by construction and governs where tensors land *during the FSDP gather*, on both the save and load paths. The two touch different lines and neither subsumes the other; they merge independently in either order. Worth noting for whoever lands the second one: together they make the manager's CPU-residency story consistent end to end, which is the stated motivation of #7286. **Related precedent, not overlapping**: #7871 (merged) added an `is_device_available()` check for non-GPU/NPU devices in `verl/workers/engine/megatron/utils.py`. Same class of fix, different file; it did not touch the checkpoint managers. - [x] PR title formatted as `[{modules}] {type}: {description}` ### Test **Measured on real hardware: 2× Intel Arc Pro B60 (XPU), `torch 2.11.0`, `xccl` backend.** The standalone repro below wraps an 8-layer 4096-wide model in FSDP across 2 ranks, runs one `optimizer.step()` so optimizer state exists, then performs the same sharded gather twice — once with `offload_to_cpu=False` (today's behaviour on XPU) and once with `True` (this patch) — reporting the resulting tensor device and the peak device memory during each gather: ``` device=xpu world_size=2 free/total=22057000960/25669140480 [offload_to_cpu=False] sample tensor 'net.0.weight' device=xpu:0 peak_device_mem_during_gather=2816.8 MiB optim_sd_keys=16 [offload_to_cpu=True] sample tensor 'net.0.weight' device=cpu peak_device_mem_during_gather=2560.8 MiB optim_sd_keys=16 RESULT offload_False_device=xpu:0 offload_True_device=cpu RESULT peak_delta_MiB=256.1 PASS: offload_to_cpu mechanism confirmed functional on this device ``` Two things this establishes: 1. **`offload_to_cpu=True` works correctly on a non-CUDA backend.** The gathered shard lands on `cpu` and the optimizer state dict is intact (16 entries, matching the 16 parameters). Nothing about this FSDP feature is CUDA-specific, so the current gate is excluding a working code path. 2. **The cost of the current `False` default is real and scales with model size.** 256 MiB of extra peak device memory held through the gather, on the order of the per-rank sharded state-dict size for this model — i.e. proportional to model/optimizer size, not a fixed overhead. On a card already near capacity this is the difference between a checkpoint save that completes and one that OOMs, which is the failure mode #7286 describes from the load side. **On CUDA this patch is a no-op by construction**, which is why no CUDA regression run is included: `PlatformCUDA.is_available()` is `return torch.cuda.is_available()` — the identical expression the old module-level `is_cuda_available` captured. The only semantic difference is import-time vs. call-time evaluation, which cannot differ in practice since CUDA availability is fixed for a process. On a CPU-only host both spellings yield `False`, so the `*_on_cpu` checkpoint tests are unaffected. Lint: `ruff==0.12.2 check` and `ruff format --check` clean (pinned to the repo's `.pre-commit-config.yaml` rev; note `ruff format` reflows two now-shorter ternaries, included in the diff). **Not covered:** `tests/special_distributed/test_fsdp_ckpt.py` — the repo's own end-to-end `FSDPCheckpointManager` save/load round trip — has not been run with this patch. It needs 2 accelerators and a Qwen2.5 tokenizer, and on XPU additionally a verl image with `flash_attention_2` available. What is established here is the mechanism and its memory cost on real XPU silicon, not a full verl checkpoint round trip through the patched manager. That test is the natural place to settle it if you would like it settled before merging, and I am happy to run it on the B60s on request. **Known trade-off, stated explicitly:** this makes non-CUDA backends stage shards through host memory, so it trades device-memory pressure for host-memory pressure — exactly the trade CUDA has always made here. On nodes where host RAM is the scarcer resource this is not free. I considered exposing it as a `checkpoint.offload_to_cpu` config override instead, and deliberately did not: that adds a user-facing knob to work around what is currently a one-platform exclusion from an existing default, and the right first step is to make the default consistent across backends. If maintainers would prefer the knob (either instead of or on top of this), say so and I will follow up separately. <details> <summary>Repro script (2 accelerators, torchrun, no verl import needed)</summary> ```python #!/usr/bin/env python3 """Repro: FSDP sharded state_dict() offload_to_cpu behavior on XPU vs CUDA. Run with: torchrun --standalone --nproc_per_node=2 repro_offload_to_cpu.py """ import argparse import os import torch import torch.distributed as dist import torch.nn as nn from torch.distributed.fsdp import FullyShardedDataParallel as FSDP from torch.distributed.fsdp import ShardedOptimStateDictConfig, ShardedStateDictConfig, StateDictType def get_device_module(): if hasattr(torch, "xpu") and torch.xpu.is_available(): return "xpu", torch.xpu if torch.cuda.is_available(): return "cuda", torch.cuda raise RuntimeError("no accelerator available") class Toy(nn.Module): def __init__(self, hidden=4096, layers=8): super().__init__() self.net = nn.Sequential(*[nn.Linear(hidden, hidden) for _ in range(layers)]) def forward(self, x): return self.net(x) def gather_once(model, optimizer, offload_to_cpu, device_mod, rank): device_mod.reset_peak_memory_stats() cfg = ShardedStateDictConfig(offload_to_cpu=offload_to_cpu) optim_cfg = ShardedOptimStateDictConfig(offload_to_cpu=offload_to_cpu) with FSDP.state_dict_type(model, StateDictType.SHARDED_STATE_DICT, cfg, optim_cfg): model_sd = model.state_dict() optim_sd = FSDP.optim_state_dict(model, optimizer) dist.barrier() peak = device_mod.max_memory_allocated() sample_key = next(iter(model_sd)) sample_tensor = model_sd[sample_key] sample_device = getattr(sample_tensor, "device", None) if sample_device is None and hasattr(sample_tensor, "local_tensor"): sample_device = sample_tensor.local_tensor().device if rank == 0: print( f"[offload_to_cpu={offload_to_cpu}] sample tensor '{sample_key}' device={sample_device} " f"peak_device_mem_during_gather={peak / 1024**2:.1f} MiB " f"optim_sd_keys={len(optim_sd.get('state', {}))}" ) return sample_device, peak def main(): parser = argparse.ArgumentParser() parser.add_argument("--hidden", type=int, default=4096) parser.add_argument("--layers", type=int, default=8) args = parser.parse_args() device_name, device_mod = get_device_module() local_rank = int(os.environ["LOCAL_RANK"]) device_mod.set_device(local_rank) backend = "xccl" if device_name == "xpu" else "nccl" dist.init_process_group(backend=backend) rank = dist.get_rank() device = torch.device(f"{device_name}:{local_rank}") model = Toy(args.hidden, args.layers).to(device) model = FSDP(model, device_id=device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) x = torch.randn(8, args.hidden, device=device) loss = model(x).sum() loss.backward() optimizer.step() dist.barrier() if rank == 0: free, total = None, None try: free, total = device_mod.mem_get_info() except Exception: pass print(f"device={device_name} world_size={dist.get_world_size()} free/total={free}/{total}") dev_false, peak_false = gather_once(model, optimizer, False, device_mod, rank) dev_true, peak_true = gather_once(model, optimizer, True, device_mod, rank) if rank == 0: print(f"RESULT offload_False_device={dev_false} offload_True_device={dev_true}") print(f"RESULT peak_delta_MiB={(peak_false - peak_true) / 1024**2:.1f}") assert str(dev_true).startswith("cpu"), "offload_to_cpu=True did not land the shard on cpu" assert not str(dev_false).startswith("cpu"), "offload_to_cpu=False unexpectedly landed on cpu" print("PASS: offload_to_cpu mechanism confirmed functional on this device") dist.barrier() dist.destroy_process_group() if __name__ == "__main__": main() ``` </details> ### API and Usage Example No API change. No config change. Behaviour change is limited to non-CUDA accelerators, which now get the same CPU-offload default CUDA already had. ### Design & Code Changes `verl/utils/checkpoint/fsdp_checkpoint_manager.py`: - Import `is_device_available` instead of `is_cuda_available`. - `load_checkpoint`: `ShardedStateDictConfig` / `ShardedOptimStateDictConfig` now take `offload_to_cpu=is_device_available()`. - `save_checkpoint`: same for both configs. - Drops the redundant `True if … else False` wrapper; `ruff format` then reflows the two surrounding ternaries onto single lines. ### Checklist Before Submitting - [x] Read the Contribute Guide - [x] `ruff` / `ruff-format` clean at the pinned `v0.12.2` - [x] Docs — no user-facing surface changes, nothing to update - [ ] Tests — see the Test section for what was measured on 2× B60 and what was not run - [ ] `ci-request` Slack message - [x] Not related to the `recipe` submodule
|
fuhao seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
What does this PR do?
The three
torch.loadcalls inFSDPCheckpointManager.load_checkpointpass nomap_location, so every tensor is revived on the device it was saved from. On acolocated run where the rollout engine already holds most of the card, resuming dies
before the first step:
Loading to CPU is consistent with what the surrounding code already declares: the
enclosing
get_fsdp_state_ctxis entered withShardedStateDictConfig(offload_to_cpu=True)whenever CUDA is available, so thismanager treats its sharded state dicts as CPU-resident on the save side. The load side
is the only place that does not say so.
What this changes is where the tensors land on their way in: staging them through host
memory bounds the peak by the shard size rather than by whatever the card happens to
have free. See the note at the end of the Test section for the part of that I have
observed and the part I have only reasoned about.
Checklist Before Starting
Search for similar PRs:
https://github.com/verl-project/verl/issues?q=repo%3Averl-project%2Fverl+is%3Aopen+map_location+checkpoint
One hit, Critical Security Vulnerabilities in Mathematical Evaluation and Serialization Code #5331, which mentions
weights_onlyandmap_locationin general terms butdoes not concern
fsdp_checkpoint_manager.pyor the resume path, so it is not aduplicate of this. Worth noting for a different reason: a future
weights_only=Truehardening pass would edit these same three calls, and whoever does that should not be
surprised to find them already carrying a
map_location.Also open on this file: [ckpt] fix: normalize PEFT keys in HF save and dump LoRA adapter with live config #4770 (
[ckpt] fix: normalize PEFT keys in HF save and dump LoRA adapter with live config, last touched 2026-07-13) editsfsdp_checkpoint_manager.pybut not these threetorch.loadcalls, so the two do notoverlap in content.
PR title formatted as
[{modules}] {type}: {description}Test
Found in the field, on a single A800-80G with a colocated SGLang engine, resuming a run
whose
model_world_size_1_rank_0.ptwas 34 GiB. The traceback above is from that run.CI cannot reach it: reproducing the OOM needs a resume from a sharded checkpoint onto a
device that is already near-full, and no current workflow sets that up. The underlying
behaviour is not in question, though —
torch.loadrestores tensors to their saveddevice unless told otherwise, which is documented and is exactly why
map_locationexists.
A standalone script (below) isolates it on a single card: save a shard from CUDA,
allocate ballast until the card is as full as it is mid-run, then load it both ways. On
the A800 that hit the original failure:
Same shape as the traceback above, at a size chosen to be quick rather than to match the
34 GiB shard. The placement is the whole mechanism: with no
map_locationa checkpointsaved from CUDA is revived in device memory, and once an engine is resident there is
nowhere to put it.
Also run on a consumer RTX 5070 under WSL, where step 3 does not raise — WDDM pages
device allocations out to host RAM instead of refusing them, and a single 20 GiB
allocation succeeds on an 11.9 GiB card. The placement difference is identical there;
only the OOM needs a card that hard-limits. The script probes for this and reports which
case it is in, so it does not read as a pass when the platform simply papered over it.
Lint:
ruff==0.12.2 checkandruff format --checkclean.Not covered by the above: a resume that completes with the patch applied. The run
that hit this died at the load and was not restarted, so what is established is the
failure and the mechanism — that
torch.loadwithoutmap_locationclaims devicememory, and that naming CPU does not — but not that
load_state_dictthen proceedsnormally on CPU-resident tensors. The surrounding code expects them to be CPU-resident
(
ShardedStateDictConfig(offload_to_cpu=True)on the save side), which is why I thinkthis is the right change rather than a workaround, but expecting is not the same as
observing and I would rather say so than imply otherwise.
tests/special_distributed/test_fsdp_ckpt.pycovers save/load round trips and needsmultiple GPUs; it has not been run here and is the natural place to settle this if you
would like it settled before merging.
Repro script (single CUDA device, no verl import needed)
API and Usage Example
No API change.
Design & Code Changes
verl/utils/checkpoint/fsdp_checkpoint_manager.py— addmap_location="cpu"to themodel, optimizer and extra-state
torch.loadcalls inload_checkpoint.Checklist Before Submitting
ruffandruff-formatclean (pinned to the repo'sv0.12.2)ci-requestSlack messagerecipesubmodule, so no submodule reference to updateAI assistance disclosure
AI assistance (Claude) was used to write the patch and the repro and to draft this
description.
Note on scope
AGENTS.md warns against one-off PRs for tiny edits. This is three words in three
places, so it is worth saying why it is not busywork: it is a hard crash on the
resume path, not a style preference, and the fix has exactly one reasonable spelling.
If you would rather see it folded into a broader checkpoint-hygiene change (e.g.
alongside
weights_only), say so and I will resubmit it that way.