Skip to content

Commit e1bd9c9

Browse files
dyurk-lilaclaude
andcommitted
feat(profiler): require explicit local save_path; drop implicit ckpt-derived default
Per review: remove the non-obvious {ckpt_path}/profiler_traces default and the silent cloud-URI -> ./profiler_traces fallback. The relative fallback would land traces in the Ray runtime working dir under /tmp/ray, and the ckpt-derived default was surprising. save_path is now a required, explicit local path, validated fail-fast at startup (non-empty + not a cloud URI). Updated docs, config defaults comment, and tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f3d466 commit e1bd9c9

5 files changed

Lines changed: 73 additions & 75 deletions

File tree

docs/content/docs/configuration/config.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ policy:
276276
torch_profiler_config: # torch.profiler-based training-loop profiler (see below)
277277
enable: false
278278
ranks: [0]
279-
save_path: null # defaults to {ckpt_path}/profiler_traces
279+
save_path: null # REQUIRED when enable=true; absolute local path
280280
skip_first: 10 # torch.profiler.schedule
281281
wait: 0
282282
warmup: 1
@@ -310,7 +310,7 @@ Which steps are recorded is controlled entirely by [`torch.profiler.schedule`](h
310310

311311
- `policy.torch_profiler_config.enable`: Master switch. When `false` (default), no profiler RPCs are dispatched and there is zero overhead.
312312
- `policy.torch_profiler_config.ranks`: List of global ranks to profile (e.g. `[0]`). Add a mid-pipeline-stage rank to diagnose pipeline bubbles.
313-
- `policy.torch_profiler_config.save_path`: Output directory for traces. Defaults to `{ckpt_path}/profiler_traces`.
313+
- `policy.torch_profiler_config.save_path`: Output directory for traces. **Required** when `enable: true` (there is no implicit default). Use an absolute local path: Ray workers run from a `/tmp/ray/.../working_dir_files` runtime working dir, so a relative path would scatter traces there, and `torch.profiler` cannot write to cloud URIs (`s3://`/`gs://`) — both are rejected at startup.
314314
- `policy.torch_profiler_config.{skip_first,wait,warmup,active,repeat}`: Passed directly to `torch.profiler.schedule`.
315315
- `policy.torch_profiler_config.activities`: Subset of `["cpu", "cuda"]` to record.
316316
- `policy.torch_profiler_config.{record_shapes,profile_memory,with_stack,with_flops,with_modules}`: Passed directly to `torch.profiler.profile`. `with_stack` and `record_shapes` add overhead; `profile_memory` is heavier still and off by default.

skyrl/backends/skyrl_train/utils/profiler.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import torch.distributed
55
from loguru import logger
66

7-
from skyrl.backends.skyrl_train.utils.io.io import is_cloud_path
8-
97
# Map config activity strings to torch ProfilerActivity members.
108
_ACTIVITY_MAP = {
119
"cpu": torch.profiler.ProfilerActivity.CPU,
@@ -17,15 +15,13 @@ def build_profiler_from_policy_cfg(trainer_cfg):
1715
"""Construct a :class:`Profiler` from ``policy.torch_profiler_config``, or None.
1816
1917
Returns ``None`` when profiling is disabled, so callers can simply assign the
20-
result to ``self.profiler``. The trace ``save_path`` defaults to
21-
``{ckpt_path}/profiler_traces`` (mirrors how memory snapshots default under
22-
``ckpt_path``).
18+
result to ``self.profiler``. ``save_path`` is an explicit, required local path
19+
(validated at startup) -- there is no implicit ``ckpt_path``-derived default.
2320
"""
2421
cfg = trainer_cfg.policy.torch_profiler_config
2522
if not cfg.enable:
2623
return None
27-
default_save_path = os.path.join(trainer_cfg.ckpt_path, "profiler_traces")
28-
return Profiler(cfg, default_save_path=default_save_path)
24+
return Profiler(cfg)
2925

3026

3127
class Profiler:
@@ -51,7 +47,7 @@ class Profiler:
5147
with_modules, export_type.
5248
"""
5349

54-
def __init__(self, config, default_save_path: str = None):
50+
def __init__(self, config):
5551
self.enable = config.enable
5652
self.prof = None
5753
# Per-window self-device-time kernel summary, refreshed by on_trace_ready
@@ -62,17 +58,9 @@ def __init__(self, config, default_save_path: str = None):
6258
if not config.enable:
6359
return
6460
self.config = config
65-
self.save_path = config.save_path or default_save_path or "./profiler_traces"
66-
# torch.profiler writes traces with the local filesystem only. ``save_path``
67-
# commonly defaults to ``{ckpt_path}/profiler_traces``, and ckpt_path can be a
68-
# cloud URI (s3://, gs://) -- which torch can't write to, so the trace would be
69-
# silently lost. Fall back to a local dir so profiling still produces output.
70-
if is_cloud_path(self.save_path):
71-
logger.warning(
72-
f"[Profiler] cloud save_path {self.save_path!r} is not writable by torch.profiler; "
73-
f"falling back to local './profiler_traces'."
74-
)
75-
self.save_path = "./profiler_traces"
61+
# `save_path` is a required, explicit local path -- validated by
62+
# TorchProfilerConfig.validate() at startup (non-empty + not a cloud URI).
63+
self.save_path = config.save_path
7664
self.ranks = list(config.ranks)
7765
self.export_type = getattr(config, "export_type", "chrome_trace")
7866
self.rank = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0

skyrl/train/config/config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ class TorchProfilerConfig(BaseConfig):
180180
enable: bool = False
181181
ranks: List[int] = field(default_factory=lambda: [0])
182182
save_path: Optional[str] = None
183-
"""Trace output dir. Defaults to ``{ckpt_path}/profiler_traces`` when None."""
183+
"""Trace output dir. Required when ``enable=True``; use an absolute local path.
184+
Ray workers run from a ``/tmp/ray/.../working_dir_files`` runtime dir, so a
185+
relative path would scatter traces there -- and ``torch.profiler`` cannot write
186+
to cloud URIs (``s3://``/``gs://``)."""
184187

185188
# torch.profiler.schedule -- one cycle = wait + warmup + active steps.
186189
skip_first: int = 10
@@ -216,6 +219,22 @@ def validate(self) -> None:
216219
return
217220
if not self.ranks:
218221
raise ValueError("`torch_profiler_config.ranks` must be non-empty when profiling is enabled.")
222+
# `save_path` is a required, explicit, local path -- no implicit default. Ray
223+
# workers run from a /tmp/ray runtime working dir, so a relative path would
224+
# scatter traces there, and torch.profiler can't write cloud URIs.
225+
if not self.save_path:
226+
raise ValueError(
227+
"`torch_profiler_config.save_path` must be set when profiling is enabled. "
228+
"Use an absolute local path -- Ray workers run from a /tmp/ray runtime "
229+
"working dir, so a relative path would write traces there."
230+
)
231+
from skyrl.backends.skyrl_train.utils.io.io import is_cloud_path
232+
233+
if is_cloud_path(self.save_path):
234+
raise ValueError(
235+
f"`torch_profiler_config.save_path` must be a local path; got cloud URI "
236+
f"{self.save_path!r}. torch.profiler cannot write to cloud storage."
237+
)
219238
# An empty `activities` passes the membership check below vacuously, but
220239
# `torch.profiler.profile(activities=[])` records nothing -- fail fast instead.
221240
if not self.activities:

tests/backends/skyrl_train/utils/test_profiler.py

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _run_loop(prof: Profiler, n_steps: int) -> None:
4444

4545

4646
def test_disabled_is_noop(tmp_path):
47-
prof = Profiler(_ProfCfg(enable=False), default_save_path=str(tmp_path))
47+
prof = Profiler(_ProfCfg(enable=False, save_path=str(tmp_path)))
4848
assert prof.prof is None
4949
assert prof.check() is False
5050
_run_loop(prof, 5) # must not raise
@@ -54,16 +54,15 @@ def test_disabled_is_noop(tmp_path):
5454
def test_rank_not_selected_is_noop(tmp_path):
5555
# is_initialized() is False in this process -> rank resolves to 0, which is
5656
# not in ranks=[1], so the profiler must not arm.
57-
prof = Profiler(_ProfCfg(ranks=[1]), default_save_path=str(tmp_path))
57+
prof = Profiler(_ProfCfg(ranks=[1], save_path=str(tmp_path)))
5858
assert prof.prof is None
5959
_run_loop(prof, 3)
6060
assert glob.glob(os.path.join(str(tmp_path), "*")) == []
6161

6262

6363
def test_single_window_writes_one_trace(tmp_path):
6464
prof = Profiler(
65-
_ProfCfg(skip_first=0, wait=0, warmup=0, active=1, repeat=1),
66-
default_save_path=str(tmp_path),
65+
_ProfCfg(skip_first=0, wait=0, warmup=0, active=1, repeat=1, save_path=str(tmp_path)),
6766
)
6867
assert prof.check() is True
6968
_run_loop(prof, 3)
@@ -75,8 +74,7 @@ def test_single_window_writes_one_trace(tmp_path):
7574
def test_repeat_writes_multiple_windows(tmp_path):
7675
# Two cycles of (warmup=1 + active=1); needs >= 2*(1+1) steps to fire twice.
7776
prof = Profiler(
78-
_ProfCfg(skip_first=0, wait=0, warmup=1, active=1, repeat=2),
79-
default_save_path=str(tmp_path),
77+
_ProfCfg(skip_first=0, wait=0, warmup=1, active=1, repeat=2, save_path=str(tmp_path)),
8078
)
8179
_run_loop(prof, 8)
8280
traces = glob.glob(os.path.join(str(tmp_path), "*.pt.trace.json*"))
@@ -86,51 +84,35 @@ def test_repeat_writes_multiple_windows(tmp_path):
8684
def test_skip_first_defers_recording(tmp_path):
8785
# With skip_first larger than the loop, no window should ever open.
8886
prof = Profiler(
89-
_ProfCfg(skip_first=100, wait=0, warmup=0, active=1, repeat=1),
90-
default_save_path=str(tmp_path),
87+
_ProfCfg(skip_first=100, wait=0, warmup=0, active=1, repeat=1, save_path=str(tmp_path)),
9188
)
9289
_run_loop(prof, 5)
9390
assert glob.glob(os.path.join(str(tmp_path), "*.pt.trace.json*")) == []
9491

9592

96-
def test_save_path_overrides_default(tmp_path):
97-
explicit = tmp_path / "explicit"
98-
prof = Profiler(
99-
_ProfCfg(save_path=str(explicit)),
100-
default_save_path=str(tmp_path / "default"),
101-
)
102-
assert prof.save_path == str(explicit)
103-
104-
105-
def test_default_save_path_used_when_none(tmp_path):
106-
default = str(tmp_path / "default")
107-
prof = Profiler(_ProfCfg(save_path=None), default_save_path=default)
108-
assert prof.save_path == default
109-
110-
111-
def test_cloud_save_path_falls_back_to_local(tmp_path):
112-
# ckpt_path (and thus the derived default save_path) can be a cloud URI, which
113-
# torch.profiler can't write to. The wrapper must fall back to a local dir so
114-
# the trace isn't silently lost.
115-
prof = Profiler(_ProfCfg(save_path="s3://my-bucket/run/profiler_traces"), default_save_path=str(tmp_path))
116-
assert prof.save_path == "./profiler_traces"
93+
def test_save_path_is_taken_verbatim(tmp_path):
94+
# `save_path` is an explicit, required local path -- the wrapper uses it as-is
95+
# (no implicit default, no rewriting). Cloud-URI/empty rejection lives in
96+
# TorchProfilerConfig.validate(), exercised in tests/train/test_config.py.
97+
explicit = str(tmp_path / "explicit")
98+
prof = Profiler(_ProfCfg(save_path=explicit))
99+
assert prof.save_path == explicit
117100

118101

119102
def test_kernel_summary_none_when_disabled(tmp_path):
120-
prof = Profiler(_ProfCfg(enable=False), default_save_path=str(tmp_path))
103+
prof = Profiler(_ProfCfg(enable=False, save_path=str(tmp_path)))
121104
assert prof.get_kernel_summary() is None
122105

123106

124107
def test_kernel_summary_empty_before_first_window(tmp_path):
125-
prof = Profiler(_ProfCfg(skip_first=0, wait=0, warmup=0, active=1), default_save_path=str(tmp_path))
108+
prof = Profiler(_ProfCfg(skip_first=0, wait=0, warmup=0, active=1, save_path=str(tmp_path)))
126109
summary = prof.get_kernel_summary()
127110
assert summary == {"window_count": 0, "pairs": []}
128111

129112

130113
def test_kernel_summary_populated_after_window(tmp_path):
131114
prof = Profiler(
132-
_ProfCfg(skip_first=0, wait=0, warmup=0, active=1, repeat=1, activities=["cpu"]),
133-
default_save_path=str(tmp_path),
115+
_ProfCfg(skip_first=0, wait=0, warmup=0, active=1, repeat=1, activities=["cpu"], save_path=str(tmp_path)),
134116
)
135117
prof.start()
136118
for _ in range(3):
@@ -157,14 +139,14 @@ def test_kernel_summary_populated_after_window(tmp_path):
157139
def test_activities_threaded_to_torch(tmp_path):
158140
import torch
159141

160-
prof = Profiler(_ProfCfg(activities=["cpu"]), default_save_path=str(tmp_path))
142+
prof = Profiler(_ProfCfg(activities=["cpu"], save_path=str(tmp_path)))
161143
# The underlying torch profiler was constructed with the CPU activity only.
162144
assert torch.profiler.ProfilerActivity.CPU in prof.prof.activities
163145
assert torch.profiler.ProfilerActivity.CUDA not in prof.prof.activities
164146

165147

166148
def test_step_failure_disables_without_raising(tmp_path):
167-
prof = Profiler(_ProfCfg(), default_save_path=str(tmp_path))
149+
prof = Profiler(_ProfCfg(save_path=str(tmp_path)))
168150
prof.start()
169151

170152
class _Boom:
@@ -241,42 +223,33 @@ def test_rpcs_drive_profiler_when_present(self):
241223

242224
class TestBuildProfilerFromPolicyCfg:
243225
"""``build_profiler_from_policy_cfg`` is the production entry both worker
244-
backends call in ``init_model``. It gates on ``enable`` and composes the
245-
default ``{ckpt_path}/profiler_traces`` save path when none is set."""
226+
backends call in ``init_model``. It gates on ``enable`` and passes the
227+
explicit, validated ``save_path`` straight through (no implicit default)."""
246228

247229
@staticmethod
248-
def _trainer_cfg(prof_cfg, ckpt_path):
230+
def _trainer_cfg(prof_cfg):
249231
from types import SimpleNamespace
250232

251-
return SimpleNamespace(ckpt_path=ckpt_path, policy=SimpleNamespace(torch_profiler_config=prof_cfg))
233+
return SimpleNamespace(policy=SimpleNamespace(torch_profiler_config=prof_cfg))
252234

253235
def test_returns_none_when_disabled(self, tmp_path):
254236
from skyrl.backends.skyrl_train.utils.profiler import (
255237
build_profiler_from_policy_cfg,
256238
)
257239

258-
cfg = self._trainer_cfg(_ProfCfg(enable=False), str(tmp_path))
240+
cfg = self._trainer_cfg(_ProfCfg(enable=False, save_path=str(tmp_path)))
259241
assert build_profiler_from_policy_cfg(cfg) is None
260242

261-
def test_builds_profiler_with_ckpt_default_save_path(self, tmp_path):
243+
def test_builds_profiler_with_explicit_save_path(self, tmp_path):
262244
from skyrl.backends.skyrl_train.utils.profiler import (
263245
Profiler,
264246
build_profiler_from_policy_cfg,
265247
)
266248

267-
cfg = self._trainer_cfg(_ProfCfg(enable=True, save_path=None), str(tmp_path))
268-
prof = build_profiler_from_policy_cfg(cfg)
269-
assert isinstance(prof, Profiler)
270-
assert prof.save_path == os.path.join(str(tmp_path), "profiler_traces")
271-
272-
def test_explicit_save_path_wins_over_ckpt_default(self, tmp_path):
273-
from skyrl.backends.skyrl_train.utils.profiler import (
274-
build_profiler_from_policy_cfg,
275-
)
276-
277249
explicit = str(tmp_path / "explicit")
278-
cfg = self._trainer_cfg(_ProfCfg(enable=True, save_path=explicit), str(tmp_path))
250+
cfg = self._trainer_cfg(_ProfCfg(enable=True, save_path=explicit))
279251
prof = build_profiler_from_policy_cfg(cfg)
252+
assert isinstance(prof, Profiler)
280253
assert prof.save_path == explicit
281254

282255

tests/train/test_config.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,18 @@ class TestTorchProfilerConfigValidation:
268268
def _cfg(**overrides):
269269
from skyrl.train.config.config import TorchProfilerConfig
270270

271+
# `save_path` is required when enabled; supply a valid local default so
272+
# tests targeting *other* fields don't trip the save_path guard.
273+
overrides.setdefault("save_path", "/tmp/skyrl_prof_test")
271274
return TorchProfilerConfig(enable=True, **overrides)
272275

273276
def test_disabled_skips_all_checks(self):
274277
from skyrl.train.config.config import TorchProfilerConfig
275278

276279
# Garbage values must be tolerated while disabled (the default state).
277-
TorchProfilerConfig(enable=False, export_type="bogus", activities=["gpu"], ranks=[], active=0).validate()
280+
TorchProfilerConfig(
281+
enable=False, export_type="bogus", activities=["gpu"], ranks=[], active=0, save_path=None
282+
).validate()
278283

279284
def test_defaults_are_valid_when_enabled(self):
280285
self._cfg().validate() # must not raise
@@ -283,6 +288,19 @@ def test_empty_ranks_rejected(self):
283288
with pytest.raises(ValueError, match=r"ranks.*non-empty"):
284289
self._cfg(ranks=[]).validate()
285290

291+
def test_missing_save_path_rejected(self):
292+
# save_path has no implicit default; an enabled run must set it explicitly.
293+
with pytest.raises(ValueError, match=r"save_path.*must be set"):
294+
self._cfg(save_path=None).validate()
295+
with pytest.raises(ValueError, match=r"save_path.*must be set"):
296+
self._cfg(save_path="").validate()
297+
298+
def test_cloud_save_path_rejected(self):
299+
# torch.profiler can only write the local filesystem; cloud URIs fail fast.
300+
for uri in ("s3://bucket/run/traces", "gs://bucket/run/traces", "gcs://bucket/run/traces"):
301+
with pytest.raises(ValueError, match=r"save_path.*local path"):
302+
self._cfg(save_path=uri).validate()
303+
286304
def test_unknown_activity_rejected(self):
287305
with pytest.raises(ValueError, match=r"activities"):
288306
self._cfg(activities=["cpu", "gpu"]).validate()

0 commit comments

Comments
 (0)