Skip to content

[hardware] feat: add platform abstraction layer and plugin-based engine override system - #6086

Merged
wuxibin89 merged 48 commits into
verl-project:mainfrom
flagos-ai:feat/multi_chip_support
Jun 4, 2026
Merged

wuxibin89 merged 48 commits into
verl-project:mainfrom
flagos-ai:feat/multi_chip_support

Conversation

@physics31415926

@physics31415926 physics31415926 commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Add a unified platform abstraction layer and plugin-based engine system to support multi-chip hardware (NVIDIA CUDA, Ascend NPU, Cambricon MLU, Moore Threads MUSA, etc.) in verl.

Key changes:

  1. Platform Abstraction Layer (verl.plugin.platform): Hardware-agnostic device API with auto-detection, backward-compatible with verl.utils.device.
  2. PlatformRegistry (@PlatformRegistry.register(platform="xxx")): Dynamic platform registration via decorator. Built-in platforms (CUDA, NPU) use the same mechanism. External plugins register new platforms without modifying the verl source tree.
  3. Engine Plugin System (EngineRegistry): Last-writer-wins registration — plugins override default engines by registering with the same (model_type, backend, device) key. Both registration and lookup use get_device_name() for automatic hardware matching.
  4. External Plugin: verl-plugin-fl (https://github.com/physics31415926/verl-plugin-fl) provides FL-optimized FSDP/Megatron engines with FlagGems, FlagCX, TE-FL support.

Plugin loading uses the existing VERL_USE_EXTERNAL_MODULES hook — no new config fields needed.

Checklist Before Starting

Test

  • tests/plugin/test_platform_abstraction.py: Platform detection, creation, singleton, and PlatformRegistry tests.
  • Validated on CUDA environment with verl-plugin-fl installed.

API and Usage Example

# Install the plugin
pip install git+https://github.com/flagos-ai/verl-plugin-fl.git

# Load the plugin via existing VERL_USE_EXTERNAL_MODULES hook
export VERL_USE_EXTERNAL_MODULES=verl_plugin_fl

For platform plugin developers (adding new hardware):

from verl.plugin.platform import PlatformBase, PlatformRegistry

@PlatformRegistry.register(platform="musa")
class PlatformMUSA(PlatformBase):
    @property
    def device_name(self) -> str:
        return "musa"
    # ... implement all abstract methods

For engine plugin developers:

from verl.utils.device import get_device_name
from verl.workers.engine.base import EngineRegistry

_device = get_device_name()

@EngineRegistry.register(model_type="language_model", backend="fsdp", device=_device)
class MyCustomEngine(FSDPEngineWithLMHead):
    pass

Design & Code Changes

How the platform + engine override works:

  1. User sets VERL_USE_EXTERNAL_MODULES=verl_plugin_fl
  2. verl/__init__.py calls import_external_libs() which imports the plugin module
  3. Plugin registers platform via @PlatformRegistry.register() (or uses built-in CUDA/NPU)
  4. Plugin registers engines via @EngineRegistry.register(device=get_device_name())
  5. EngineRegistry.get_engine_cls() uses get_device_name() -> returns plugin engine

Core changes:

  • verl/plugin/platform/platform_manager.py: PlatformRegistry with @register(platform="xxx") decorator, auto-detection iterates registered platforms
  • verl/plugin/platform/platform_cuda.py: @PlatformRegistry.register(platform="cuda")
  • verl/plugin/platform/platform_npu.py: @PlatformRegistry.register(platform="npu")
  • verl/workers/engine/base.py: EngineRegistry.register() allows overwriting (last writer wins); get_engine_cls() uses get_device_name()
  • docs/hardware/multi_chip_support.rst: Architecture documentation
  • examples/grpo_trainer/run_qwen3-0.6b_fl.sh: Example FL training script

Checklist Before Submitting

@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 FlagOS (FL) multi-chip support to the verl framework, enabling unified training and inference across diverse hardware platforms. Key additions include a hardware-agnostic platform abstraction layer, an environment manager for phase-specific configurations, and specialized FlagOS engines for Megatron and FSDP. Review feedback highlights a bug in the FlagCX enablement logic that could cause unnecessary crashes and notes that the FlagGems initialization function is currently a placeholder that needs implementation to fulfill the PR's objectives.

Comment thread verl/plugin/utils/config_manager.py Resolved
Comment thread verl/plugin/utils/config_manager.py Resolved
@CLAassistant

CLAassistant commented Apr 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@wuxibin89

wuxibin89 commented Apr 21, 2026 •

Copy link
Copy Markdown
Collaborator

Currently, verl has native support for NVIDIA/AMD/Ascend hardware, other training backend(e.g DeepSpeed, Bubblebee) should be dynamically loaded by hook to avoid maintain in source tree.

https://github.com/verl-project/verl/blob/main/verl/workers/engine/__init__.py
From our current engine registration, it's pretty easy to provide a register hook:

# path style
trainer.custom_engine_path=/path/to/fsdp_fl/transformer_impl.py
# module style
trainer.custom_engine_path=flag_os.verl_plugin.fsdp_fl.transformer_impl

Then we can load this file by importlib.util.spec_from_file_location in engine worker, and register backend dynamically.

Comment thread verl/workers/engine_workers.py Resolved
@physics31415926 physics31415926 changed the title [hardware] feat: Add platform abstraction layer and FlagOS multi-chip backend support [hardware] feat: add platform abstraction layer and plugin-based engine override system Apr 23, 2026
Comment thread verl/plugin/platform/platform_cpu.py Resolved
@wuxibin89

Copy link
Copy Markdown
Collaborator

verl relies on ray for device resource management: https://github.com/verl-project/verl/blob/main/verl/single_controller/ray/base.py#L137-L141

But Ray Accelerator has no support for other hardware platform, such as Cambricon MLU, Moore Threads MUSA.

Comment thread verl/workers/engine/base.py Resolved
@physics31415926
physics31415926 force-pushed the feat/multi_chip_support branch 2 times, most recently from 718ffd1 to a3b9120 Compare April 28, 2026 08:51
@physics31415926
physics31415926 marked this pull request as ready for review April 30, 2026 03:09
return "cuda"

@property
def device_module(self) -> ModuleType:

@ji-huazhong ji-huazhong Apr 30, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I’ve seen identical implementations across numerous repos, including DeepSpeed and vLLM 😂. It’s probably high time we adopted the modern torch.accelerator module 😄. https://docs.pytorch.org/docs/2.9/accelerator.html

This feature has covered most common torch.cuda interfaces since PyTorch 2.9, including device management and memory management. A few capabilities are yet to be integrated: device related ccl backend, unified configuration of *_visible_devices, and the profiler module. Our team is also working on abstracting away these backend discrepancies natively at the PyTorch layer.

cc @wuxibin89

@physics31415926
physics31415926 force-pushed the feat/multi_chip_support branch from a1397c3 to 81d0609 Compare May 12, 2026 07:54
@physics31415926
physics31415926 requested a review from ArronHZG as a code owner May 12, 2026 07:54
Comment thread verl/plugin/platform/platform_base.py Resolved
# ------------------------------------------------------------------

@abc.abstractmethod
@contextmanager

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The capability differences between different profiling backends have been reflected in DistProfiler. If we also need to implement this here, what should the cooperation relationship between it and DistProfiler be?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They operate at different layers and don't overlap:

PlatformBase.profiler_start/stop: lowest level, pure device abstraction — how to control the hardware profiler
DistProfiler: highest level — when and where to profile (rank/step selection, tool dispatch)
DistProfiler delegates to tool-specific impls (e.g. NsightSystemsProfiler), which in turn call get_platform().profiler_start() for the actual device operation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This logic assumes a one-to-one correspondence between the DistProfilerand the platform.

What if there are many different profilers on one platform/device? For example, mstx_profile\torch_profile\precision_debuger_profiler are all supported on npu. I don’t think it’s a good idea to couple DistProfiler with the platform.

DistProfilers NPU GPU
nvtx_profiler ❌ ✅
torch_profiler ✅ ✅
mstx_profiler ✅ ❌
precisidon_debuger ✅ ✅

@tardis-key

Copy link
Copy Markdown
Collaborator

Thanks for your contribution. A good platform abstraction can facilitate the development of various backends. Looking forward to merging this PR.
I notice that our current platform capabilities mainly cover device, communication, and profiling. Are there any other differentiated aspects? For example, the content in npu_patch (or do we consider it as differences between training backends rather than platform differences)? cc @wuxibin89

@uqyxx

uqyxx commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

verl relies on ray for device resource management: https://github.com/verl-project/verl/blob/main/verl/single_controller/ray/base.py#L137-L141

But Ray Accelerator has no support for other hardware platform, such as Cambricon MLU, Moore Threads MUSA.

Hi @physics31415926 @wuxibin89 , mlu already supports Ray, so the device_name here also need to be handled for mlu. Could you help to modify the code to get device_name from ray_resource_name() https://github.com/flagos-ai/verl-FL/blob/feat/multi_chip_support/verl/plugin/platform/platform_base.py#L189. Thank you!

@heavyrain-lzy

Copy link
Copy Markdown
Contributor

verl relies on ray for device resource management: https://github.com/verl-project/verl/blob/main/verl/single_controller/ray/base.py#L137-L141
But Ray Accelerator has no support for other hardware platform, such as Cambricon MLU, Moore Threads MUSA.

Hi @physics31415926 @wuxibin89 , mlu already supports Ray, so the device_name here also need to be handled for mlu. Could you help to modify the code to get device_name from ray_resource_name() https://github.com/flagos-ai/verl-FL/blob/feat/multi_chip_support/verl/plugin/platform/platform_base.py#L189. Thank you!

Hi @uqyxx The verl repository only retains the platforms for nvidia-GPU/huawei-NPU, and other hardwares can be support via https://github.com/verl-project/verl-hardware-plugin. You can decide how to implement the get_device_name

@uqyxx

uqyxx commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

verl relies on ray for device resource management: https://github.com/verl-project/verl/blob/main/verl/single_controller/ray/base.py#L137-L141
But Ray Accelerator has no support for other hardware platform, such as Cambricon MLU, Moore Threads MUSA.

Hi @physics31415926 @wuxibin89 , mlu already supports Ray, so the device_name here also need to be handled for mlu. Could you help to modify the code to get device_name from ray_resource_name() https://github.com/flagos-ai/verl-FL/blob/feat/multi_chip_support/verl/plugin/platform/platform_base.py#L189. Thank you!

Hi @uqyxx The verl repository only retains the platforms for nvidia-GPU/huawei-NPU, and other hardwares can be support via https://github.com/verl-project/verl-hardware-plugin. You can decide how to implement the get_device_name

Hi @heavyrain-lzy , yes we are developing in verl-project/verl-hardware-plugin#1, but code here https://github.com/verl-project/verl/blob/main/verl/single_controller/ray/base.py#L137-L141 only handles cuda and npu logic, other platforms are not covered. Even we implement 'get_device_name' in verl-hardware-plugin, the device name cannot be passed in here. So the code here should be like
device_name = get_platform().ray_resource_name()
not the current version
image

@heavyrain-lzy

Copy link
Copy Markdown
Contributor

@uqyxx This PR has unified the ray resource name when creating placement, you can see
image

@wuxibin89

Copy link
Copy Markdown
Collaborator

/gemini review

@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 a unified multi-chip platform abstraction layer to support RL training across multiple hardware platforms (such as NVIDIA CUDA and Huawei Ascend NPU) through a plugin system, refactoring existing device utilities to delegate to this new layer. Key feedback points out several critical issues: a memory leak in _state_dict_utils.py due to a strong reference cycle in weakref.finalize, an assertion in EngineRegistry.register that prevents engine overrides, a bug in CUDA platform detection when nvidia-smi is missing, a potential ModuleNotFoundError if cupy is not installed, and an AssertionError on non-CUDA platforms when calling get_placement_groups with its default "cuda" argument.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread verl/workers/engine/base.py Resolved
Comment thread verl/plugin/platform/platform_cuda.py Resolved
Comment thread verl/plugin/platform/platform_cuda.py Resolved
Comment thread verl/single_controller/ray/base.py Resolved
t.data_ptr(),
t.numel() * t.element_size(),
1, # lines up with 'cudaHostRegisterPortable'
from verl.plugin.platform import get_platform

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Don't change this file, it's only used torch<2.7

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.

Done. Thanks!

@wuxibin89
wuxibin89 merged commit 7a457c9 into verl-project:main Jun 4, 2026
83 of 110 checks passed
heavyrain-lzy added a commit to verl-project/verl-hardware-plugin that referenced this pull request Jun 4, 2026
wuxibin89 added a commit that referenced this pull request Oct 8, 2026
…orm hook and share the attention padding helpers (#7917)

### What does this PR do?

Two independent changes, both making verl's device handling less
CUDA-specific.

**1. A `profiler_markers()` hook on `PlatformBase`.** Lets a hardware
backend supply its own tracing markers -- `(mark_start_range,
mark_end_range, mark_annotate, marked_timer)` -- instead of nvtx or
mstx. Default is `None`, so existing platforms are unaffected.

This also fixes marker resolution. `verl/utils/profiler/__init__.py`
checked `is_nvtx_available()` before consulting the platform, and nvtx
ships in the `verl-core` extra, so on any install where nvtx imports the
platform was never asked. Order is now platform, nvtx, mstx, generic,
and stays lazy so the platform need not exist at import time.

Relatedly, `engine_workers.py` converts `tool_config` for any configured
`profiler.tool` rather than an allowlist of the five built-in names.
`omega_conf_to_dataclass` returns `None` for a missing or empty config,
so a tool with no `tool_config` entry still works, and an out-of-tree
tool can name its own dataclass through `_target_`.

**2. The attention padding helpers become device-neutral.**
`npu_flash_attn_utils.py` was already a pure torch/einops port of
flash-attn's `bert_padding`, with nothing NPU-specific in it, so it is
renamed `attention_padding_utils.py` and used as the fallback for *any*
install where `flash_attn` will not import -- not just NPU. That covers
non-CUDA accelerators and CPU-only installs such as unit tests and dev
boxes. The duplicate `_fallback_*` helpers in `attention_utils.py` are
deleted, and `torch_functional.py`'s two remaining hardcoded
`flash_attn` / `get_device_name() == "cuda"/"npu"` branches now go
through the same dispatcher.

No public API changes.

Consumed by `verl-hardware-plugin`'s `PlatformXPU`
([#22](verl-project/verl-hardware-plugin#22)).

### Why this is not duplicating an existing PR

Searched open and closed PRs for `profiler_markers`, `PlatformBase
hook`, `marked_timer`, `mark_start_range`, `attention_utils`,
`npu_flash_attn_utils`, `index_first_axis`.

For the profiler hook, nothing else lets a platform override the
markers. #7988 (also mine) adds a different `PlatformBase` hook -- an
optional `torch.profiler` activity list, i.e. what the built-in torch
profiler records, not which marker functions the trainer calls. It
overlaps only in touching `platform_base.py`; I will rebase whichever
lands second. #6086 (merged) created the platform abstraction both
extend.

For the attention change, **#7098 overlaps substantially and predates
this work.** It reaches the same conclusion from the same reasoning --
that the NPU copy only uses torch and einops, so dispatching on hardware
was unnecessary -- and both PRs delete the dispatch and touch
`tests/utils/test_attention_utils.py`, so they conflict. The differences
are small: #7098 inlines the helpers into `attention_utils.py` and
deletes the module, where this PR keeps it as
`attention_padding_utils.py`; #7098 also normalizes indices to `int64`,
which this PR does not. #7098 has the stronger test evidence of the two.
I did not see it before implementing, and I am happy to drop part 2 here
and review #7098 instead if you would rather land that -- part 1 is
independent and does not depend on it.

### Test

```bash
python -m pytest -q tests/utils/test_profiler_markers.py
python -m pytest -q tests/utils/test_attention_utils.py
python -m pytest -q tests/plugin/test_platform_abstraction.py
```

| Target | Result |
| --- | --- |
| `tests/utils/test_profiler_markers.py` | 2 passed, 2 skipped |
| `tests/utils/test_attention_utils.py` | 5 passed |
| `tests/plugin/test_platform_abstraction.py` | 14 passed |

The 2 skips are the nvtx and mstx paths; neither package is installed in
this image. Both new unit test files inject a fake platform, so no
accelerator is needed.

Against a real out-of-tree platform supplying Intel ITT markers,
`_resolve_markers()` selects them even with `is_nvtx_available()` forced
to `True` -- the regression the fix targets. `profiler.tool` resolution
was checked for three cases: an out-of-tree tool name resolves to its
implementation, a built-in name still reaches core's dispatch, and an
unknown name still falls through to `_NoOpProfiler`.

#### Existing tests touched

Four existing test files change only in routing their `unpad_input` /
`pad_input` imports through `verl.utils.attention_utils` -- no logic
change:

- `tests/models/test_transformer.py`
- `tests/models/test_transformers_ulysses.py`
- `tests/utils/test_activation_offload.py`
- `tests/special_distributed/test_fsdp_ckpt.py`

They are covered by the existing CUDA/NPU CI (`model.yml`,
`model_ascend.yml`). Before this change they failed at import on a
device without a flash-attn build (`unpad_input` unbound). Results on 4x
Intel Arc Pro B60 24GB:

- `test_transformer.py` -- 4/4 passed
- `test_transformers_ulysses.py` (`torchrun --nproc_per_node=4 -m
pytest`) -- all `sp_size<=4` configs pass; `sp_size=8` needs 8 GPUs
- `test_activation_offload.py` -- fsdp2 cases pass; FSDP1 cases hit a
Level Zero runtime error, an XPU-side issue unrelated to this change
- `test_fsdp_ckpt.py` -- the default config exceeds 24GB per device;
with a reduced batch, fsdp2 save/load round-trips bit-exactly

### Design & Code Changes

| File | Change |
| --- | --- |
| `verl/plugin/platform/platform_base.py` | `profiler_markers()`, `None`
by default |
| `verl/utils/profiler/__init__.py` | resolution order and the
nvtx-shadowing fix |
| `verl/workers/engine_workers.py` | allowlist replaced with an any-tool
check |
| `verl/utils/npu_flash_attn_utils.py` ->
`verl/utils/attention_padding_utils.py` | rename only |
| `verl/utils/attention_utils.py` | falls back to the padding utils
whenever `flash_attn` will not import; duplicate `_fallback_*` helpers
deleted |
| `verl/utils/torch_functional.py` | two hardcoded device branches
routed through the dispatcher |
| `tests/utils/test_profiler_markers.py`,
`tests/utils/test_attention_utils.py` | new |

### Checklist Before Submitting

- [x] Read the [Contribute
Guide](https://github.com/verl-project/verl/blob/main/CONTRIBUTING.md).
- [x] Apply [pre-commit
checks](https://github.com/verl-project/verl/blob/main/CONTRIBUTING.md#code-linting-and-formatting).
- [ ] Add / Update [the
documentation](https://github.com/verl-project/verl/tree/main/docs).
- [x] Add unit or end-to-end test(s) to [the CI
workflow](https://github.com/verl-project/verl/tree/main/.github/workflows).
The hardware that exposes the original collision is not in CI; the
in-tree platforms are covered by unit tests and the table above
documents that their behavior is unchanged.
- [x] Once your PR is ready for CI, send a message in the `ci-request`
channel.
- [x] If your PR is related to the `recipe` submodule — not applicable.

---------

Co-authored-by: wuxibin <wuxibin@bytedance.com>
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants