Skip to content

[ckpt] fix: load FSDP checkpoint shards onto CPU instead of the saved device - #7286

Open
jackychris wants to merge 1 commit into
verl-project:mainfrom
jackychris:fix/ckpt-load-map-location-cpu
Open

jackychris wants to merge 1 commit into
verl-project:mainfrom
jackychris:fix/ckpt-load-map-location-cpu

Conversation

@jackychris

@jackychris jackychris commented Aug 6, 2026 •

Copy link
Copy Markdown

What does this PR do?

The three torch.load calls in FSDPCheckpointManager.load_checkpoint pass no
map_location, so every tensor is revived on the device it was saved from. On a
colocated run where the rollout engine already holds most of the card, 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.

Loading to 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.

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

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.pt was 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.load restores tensors to their saved
device unless told otherwise, which is documented and is exactly why map_location
exists.

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:

device: NVIDIA A800 80GB PCIe
memory: 79.25 GiB total, 78.84 GiB free
memory is hard-limited; the OOM in step 3 is reachable.

1. saving a 2.00 GiB shard from CUDA ...
2. allocating 75.83 GiB of ballast (a resident rollout engine) ...
   1.00 GiB free, shard needs 2.00 GiB
3. torch.load(path, weights_only=False)          # what main does
   -> torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 2.00 GiB.
      GPU 0 has a total capacity of 79.25 GiB of which 1023.94 MiB is free.
4. torch.load(path, weights_only=False, map_location='cpu')   # the patch
   -> loaded onto cpu, GPU untouched

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_location a checkpoint
saved 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 check and ruff format --check clean.

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.load without map_location claims device
memory, and that naming CPU does not — but not that load_state_dict then proceeds
normally 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 think
this 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.py covers save/load round trips and needs
multiple 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)
#!/usr/bin/env python3
"""Minimal repro: `torch.load` without `map_location` revives checkpoint
tensors in device memory, so resuming OOMs when a rollout engine already holds the card.

Needs one CUDA device. Sizes itself to whatever card it finds, so it runs on a 12 GiB
consumer GPU as happily as on an 80 GiB A800:

    python pr02_map_location_gpu.py

It reproduces the failure rather than merely describing it:

1. save a checkpoint shard from CUDA, as `FSDPCheckpointManager.save_checkpoint` does;
2. free it, then allocate ballast so the card is as full as it is mid-run with a
   colocated inference engine resident;
3. `torch.load(path)` -- what `load_checkpoint` does today -- and watch it OOM;
4. `torch.load(path, map_location="cpu")` -- the patch -- and watch it succeed.
"""

import sys
import tempfile
from pathlib import Path

import torch

GIB = 1024**3


def _fmt(nbytes: float) -> str:
    return f"{nbytes / GIB:.2f} GiB"


def oversubscribes(total: int) -> bool:
    """Whether the platform lets a single allocation exceed physical VRAM.

    WSL2 drives the GPU through WDDM, which pages device memory out to host RAM, so
    `cudaMalloc` succeeds well past the card's capacity and no amount of ballast will
    force an OOM. Native Linux with a datacenter card does not do this.
    """
    try:
        t = torch.empty(int(total * 1.5) // 2, dtype=torch.float16, device="cuda")
    except torch.OutOfMemoryError:
        return False
    del t
    torch.cuda.empty_cache()
    return True


def main() -> int:
    if not torch.cuda.is_available():
        print("no CUDA device -- this repro is about device placement and needs one.")
        return 2

    torch.cuda.init()
    name = torch.cuda.get_device_name(0)
    free_at_start, total = torch.cuda.mem_get_info()
    print(f"device: {name}")
    print(f"memory: {_fmt(total)} total, {_fmt(free_at_start)} free")

    paged = oversubscribes(total)
    if paged:
        print("note:   this platform oversubscribes GPU memory (allocations exceed VRAM),")
        print("        so step 3 will not OOM here however full the card is. Placement is")
        print("        still measured; the OOM needs a card that refuses to page out.\n")
    else:
        print("memory is hard-limited; the OOM in step 3 is reachable.\n")

    # A shard big enough to matter but comfortably saveable on any card. The real one
    # that hit this was 34 GiB; the mechanism does not depend on the size.
    shard_bytes = min(2 * GIB, int(free_at_start * 0.25))
    n_elems = shard_bytes // 4  # fp32
    side = int(n_elems**0.5)
    shard_bytes = side * side * 4

    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "model_world_size_1_rank_0.pt"

        print(f"1. saving a {_fmt(shard_bytes)} shard from CUDA ...")
        state = {"weight": torch.randn(side, side, device="cuda")}
        torch.save(state, path)
        del state
        torch.cuda.empty_cache()
        print(f"   wrote {_fmt(path.stat().st_size)} to disk")

        # Occupy the card the way a resident SGLang engine does, leaving less headroom
        # than the shard needs.
        free_now, _ = torch.cuda.mem_get_info()
        headroom = int(shard_bytes * 0.5)
        ballast_bytes = max(0, free_now - headroom)
        print(f"\n2. allocating {_fmt(ballast_bytes)} of ballast (a resident rollout engine) ...")
        ballast = torch.empty(ballast_bytes // 2, dtype=torch.float16, device="cuda")
        free_after_ballast, _ = torch.cuda.mem_get_info()
        print(f"   {_fmt(free_after_ballast)} free, shard needs {_fmt(shard_bytes)}")

        print("\n3. torch.load(path, weights_only=False)          # what main does")
        try:
            loaded = torch.load(path, weights_only=False)
        except torch.OutOfMemoryError as e:
            first_line = str(e).strip().splitlines()[0]
            print(f"   -> torch.OutOfMemoryError: {first_line}")
            oomed = True
        else:
            why = "allocation paged out to host RAM" if paged else "card had room after all"
            print(f"   -> loaded onto {loaded['weight'].device}, no OOM ({why})")
            oomed = False
            del loaded
            torch.cuda.empty_cache()

        print("\n4. torch.load(path, weights_only=False, map_location='cpu')   # the patch")
        loaded = torch.load(path, weights_only=False, map_location="cpu")
        print(f"   -> loaded onto {loaded['weight'].device}, GPU untouched")
        assert loaded["weight"].device.type == "cpu"
        assert loaded["weight"].shape == (side, side)

        del ballast, loaded
        torch.cuda.empty_cache()

    print()
    print("Placement confirmed: the default reload claims device memory before FSDP has")
    print("a say; naming CPU keeps the card untouched.")
    if oomed:
        print("OOM reproduced: on this card that placement is fatal once the engine is resident.")
        return 0
    if paged:
        print("OOM not reachable here: the platform paged the allocation out to host RAM")
        print("rather than refusing it. Run on a card that hard-limits to see it fail.")
        return 0
    print("OOM not reproduced, and memory is hard-limited -- the card had room after all.")
    print("Re-run with a shard sized closer to the card.")
    return 1


if __name__ == "__main__":
    sys.exit(main())

API and Usage Example

No API change.

Design & Code Changes

verl/utils/checkpoint/fsdp_checkpoint_manager.py — add map_location="cpu" to the
model, optimizer and extra-state torch.load calls in load_checkpoint.

Checklist Before Submitting

  • Read the Contribute Guide
  • ruff and ruff-format clean (pinned to the repo's v0.12.2)
  • Docs — no user-facing change, so nothing to update
  • Tests — explained above why CI cannot cover this
  • ci-request Slack message
  • Not related to the recipe submodule, so no submodule reference to update

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

… 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>
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.
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
@cla-assistant

cla-assistant Bot commented Oct 24, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant