Skip to content

Commit 421c669

Browse files
dyurk-lilaclaude
andcommitted
feat(profiler): reject FSDP profiler configs that crash on swap-based CPU offload
The FSDP2 manual CPU-offload path moves models with model.to("cpu") -> nn.Module._apply -> torch.utils.swap_tensors, which raises "RuntimeError: _apply(): Couldn't swap <param>" while torch.profiler holds weakrefs to those params during an active window (reproduced on CPU with torch 2.12). That offload only fires mid-loop under colocation. Map of the crash precondition (all required): - strategy == "fsdp" (Megatron offloads via flat-buffer/.data reassignment, never swap_tensors -> immune) - fsdp_config.cpu_offload == False (the default "manual" path; cpu_offload=True uses FSDP2-native offload, no manual swap) - colocate_all OR colocate_policy_ref (otherwise no in-loop offload happens) SFT is single-model and hardcodes colocate_all=False -> never at risk. Enforce via TorchProfilerConfig.validate(): the RL validator (validate_cfg) now passes strategy/colocation/cpu_offload context so an incompatible config fails fast at startup with an actionable message (set cpu_offload=true, disable colocation, or use Megatron). SFT calls validate() with no context so the cross-field check is skipped. Added unit + end-to-end tests and documented the restriction in config.mdx. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1bd9c9 commit 421c669

4 files changed

Lines changed: 92 additions & 2 deletions

File tree

docs/content/docs/configuration/config.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ policy:
306306

307307
**Scope:** the profiler captures **only the policy model's training step** (forward/backward + optimizer). In an RL run it does **not** profile the critic or reference models, and it does **not** profile generation/inference — only policy training compute on the configured `ranks`.
308308

309+
**FSDP + colocation restriction:** on the FSDP backend, the default CPU-offload path moves models to CPU with `torch.utils.swap_tensors`, which crashes mid-run (`RuntimeError: _apply(): Couldn't swap <param>`) when the profiler holds references to those parameters. That offload only happens under colocation, so `validate_cfg` **rejects at startup** the combination of `enable: true` + `trainer.strategy=fsdp` + `policy.fsdp_config.cpu_offload: false` (default) + colocation (`placement.colocate_all: true` *or* `placement.colocate_policy_ref: true`). To profile FSDP, either set `policy.fsdp_config.cpu_offload: true` (FSDP2-native offload, no swap) or disable colocation. The **Megatron** backend offloads via a different mechanism and is unaffected.
310+
309311
Which steps are recorded is controlled entirely by [`torch.profiler.schedule`](https://docs.pytorch.org/docs/stable/profiler.html#torch.profiler.schedule): the profiler skips the first `skip_first` steps, then repeats a cycle of `wait` (idle) + `warmup` (tracing discarded) + `active` (tracing recorded) steps `repeat` times (`repeat: 0` profiles every cycle for the whole run). This is how you profile multiple steps, at an interval, repeating.
310312

311313
- `policy.torch_profiler_config.enable`: Master switch. When `false` (default), no profiler RPCs are dispatched and there is zero overhead.

skyrl/train/config/config.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,27 @@ class TorchProfilerConfig(BaseConfig):
206206
"""``chrome_trace`` -> ``*.pt.trace.json`` (HTA-friendly) or ``stacks`` ->
207207
flamegraph-style self-CUDA-time stacks (requires ``with_stack=True``)."""
208208

209-
def validate(self) -> None:
209+
def validate(
210+
self,
211+
strategy: Optional[str] = None,
212+
colocate_all: Optional[bool] = None,
213+
colocate_policy_ref: Optional[bool] = None,
214+
fsdp_cpu_offload: Optional[bool] = None,
215+
) -> None:
210216
"""Validate the profiler config. No-op when disabled.
211217
212218
Called from both ``validate_cfg`` (RL) and ``validate_sft_cfg`` (SFT) so
213219
an invalid config fails fast at startup rather than silently degrading
214220
(e.g. an unknown ``export_type`` would otherwise fall through to the
215221
chrome-trace branch, and an unknown ``activities`` entry would disable
216222
profiling mid-run via the wrapper's exception isolation).
223+
224+
The ``strategy``/``colocate_*``/``fsdp_cpu_offload`` args (passed by the RL
225+
validator, which has the full root config) gate an incompatibility check:
226+
the FSDP2 manual CPU-offload path moves params with ``torch.utils.swap_tensors``,
227+
which fails (``RuntimeError: Couldn't swap <param>``) while ``torch.profiler``
228+
holds weakrefs to those params during an active window. SFT calls this with no
229+
context (single-model, never colocated) so the check is skipped there.
217230
"""
218231
if not self.enable:
219232
return
@@ -262,6 +275,29 @@ def validate(self) -> None:
262275
if self.active < 1:
263276
raise ValueError(f"`torch_profiler_config.active` must be >= 1, got {self.active}.")
264277

278+
# Cross-field: reject configs that would crash mid-run on the FSDP2 swap-based
279+
# CPU offload. The manual offload path (fsdp_strategy: `manual_offload`, used
280+
# when `fsdp_config.cpu_offload=False`) calls `model.to("cpu")` -> nn.Module._apply
281+
# -> torch.utils.swap_tensors, which raises "Couldn't swap <param>" if any weakref
282+
# to a param is alive. torch.profiler holds such weakrefs to every param it observes
283+
# during an active window. That offload only fires mid-loop under colocation
284+
# (colocate_all, or colocate_policy_ref for the policy/ref pair). Megatron offloads
285+
# via its own flat-buffer/`.data` reassignment path (no swap_tensors) and is immune;
286+
# `fsdp_config.cpu_offload=True` uses FSDP2-native offload (also no manual swap).
287+
if strategy == "fsdp" and fsdp_cpu_offload is False and (colocate_all or colocate_policy_ref):
288+
raise ValueError(
289+
"`torch_profiler_config.enable=true` is incompatible with this FSDP configuration: "
290+
"with the manual CPU-offload path (`policy.fsdp_config.cpu_offload=false`, the default) "
291+
"under colocation "
292+
f"(`placement.colocate_all={colocate_all}`, `placement.colocate_policy_ref={colocate_policy_ref}`), "
293+
"the trainer offloads models to CPU via `torch.utils.swap_tensors` while the profiler holds "
294+
"references to their parameters, which crashes mid-run with "
295+
"`RuntimeError: _apply(): Couldn't swap <param>`. "
296+
"To profile: set `policy.fsdp_config.cpu_offload=true` (FSDP2-native offload, no swap), or "
297+
"disable colocation (`placement.colocate_all=false` and `placement.colocate_policy_ref=false`), "
298+
"or use the Megatron backend (`trainer.strategy=megatron`)."
299+
)
300+
265301

266302
@dataclass
267303
class MegatronLoraConfig(BaseConfig):

skyrl/train/utils/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,12 @@ def validate_cfg(cfg: SkyRLTrainConfig):
271271
"or negative to keep all checkpoints"
272272
)
273273

274-
cfg.trainer.policy.torch_profiler_config.validate()
274+
cfg.trainer.policy.torch_profiler_config.validate(
275+
strategy=cfg.trainer.strategy,
276+
colocate_all=cfg.trainer.placement.colocate_all,
277+
colocate_policy_ref=cfg.trainer.placement.colocate_policy_ref,
278+
fsdp_cpu_offload=cfg.trainer.policy.fsdp_config.cpu_offload,
279+
)
275280

276281
# TODO (devpatel): move to initializing ray and syncing registries codepath at startup
277282
repopulate_all_registries()

tests/train/test_config.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,50 @@ def test_validate_cfg_invokes_profiler_validation(self):
339339
cfg.trainer.policy.torch_profiler_config.export_type = "bogus"
340340
with pytest.raises(ValueError, match=r"export_type"):
341341
validate_cfg(cfg)
342+
343+
# -- FSDP swap-offload incompatibility (cross-field) --------------------------
344+
# The FSDP2 manual CPU-offload path moves params via torch.utils.swap_tensors,
345+
# which crashes mid-run ("Couldn't swap <param>") while the profiler holds
346+
# weakrefs to those params. That offload only fires under colocation; Megatron
347+
# and fsdp cpu_offload=true use non-swap paths and are safe.
348+
349+
def test_fsdp_colocate_all_manual_offload_rejected(self):
350+
with pytest.raises(ValueError, match=r"Couldn't swap"):
351+
self._cfg().validate(strategy="fsdp", colocate_all=True, colocate_policy_ref=True, fsdp_cpu_offload=False)
352+
353+
def test_fsdp_colocate_policy_ref_only_rejected(self):
354+
# colocate_policy_ref alone still offloads the policy/ref pair mid-loop.
355+
with pytest.raises(ValueError, match=r"Couldn't swap"):
356+
self._cfg().validate(strategy="fsdp", colocate_all=False, colocate_policy_ref=True, fsdp_cpu_offload=False)
357+
358+
def test_fsdp_no_colocation_allowed(self):
359+
# No colocation -> no in-loop offload -> safe.
360+
self._cfg().validate(strategy="fsdp", colocate_all=False, colocate_policy_ref=False, fsdp_cpu_offload=False)
361+
362+
def test_fsdp_native_cpu_offload_allowed(self):
363+
# cpu_offload=true uses FSDP2-native offload (no manual swap_tensors) -> safe.
364+
self._cfg().validate(strategy="fsdp", colocate_all=True, colocate_policy_ref=True, fsdp_cpu_offload=True)
365+
366+
def test_megatron_colocation_allowed(self):
367+
# Megatron offloads via flat-buffer/.data reassignment (no swap_tensors) -> safe.
368+
self._cfg().validate(strategy="megatron", colocate_all=True, colocate_policy_ref=True, fsdp_cpu_offload=False)
369+
370+
def test_offload_check_skipped_without_context(self):
371+
# Called with no context (e.g. the SFT path), the cross-field check is skipped.
372+
self._cfg().validate()
373+
374+
def test_validate_cfg_rejects_profiler_under_default_colocation(self):
375+
# End-to-end: the default config is fsdp + colocate_all + cpu_offload=false,
376+
# so simply enabling the profiler must fail fast through validate_cfg.
377+
cfg = _make_validated_test_config()
378+
cfg.trainer.policy.torch_profiler_config.enable = True
379+
cfg.trainer.policy.torch_profiler_config.save_path = "/tmp/skyrl_prof_test"
380+
with pytest.raises(ValueError, match=r"Couldn't swap"):
381+
validate_cfg(cfg)
382+
383+
def test_validate_cfg_allows_profiler_with_native_offload(self):
384+
cfg = _make_validated_test_config()
385+
cfg.trainer.policy.torch_profiler_config.enable = True
386+
cfg.trainer.policy.torch_profiler_config.save_path = "/tmp/skyrl_prof_test"
387+
cfg.trainer.policy.fsdp_config.cpu_offload = True
388+
validate_cfg(cfg) # must not raise on the profiler/offload check

0 commit comments

Comments
 (0)