Skip to content

Commit f0bbeae

Browse files
committed
feat: always-on UUID log directories with symlink
Logs are now always written to output_dir/runs/<uuid>/ with a output_dir/logs symlink pointing to the current run's directory. This isolates logs per run while preserving backward compatibility through the symlink — all existing code that reads get_log_dir(output_dir) / trainer.log continues to work unchanged. When resuming (ckpt.resume_step is set), the existing log directory is reused so logs append to the same run. On clean_output_dir, the entire output_dir is wiped (existing behavior), so a fresh UUID is generated. No config changes needed — the behavior is transparent. SLURM templates also work as-is since /logs resolves through the symlink created by the launcher before sbatch.
1 parent 3d2dbae commit f0bbeae

5 files changed

Lines changed: 162 additions & 5 deletions

File tree

src/prime_rl/entrypoints/inference.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from prime_rl.configs.inference import InferenceConfig
1111
from prime_rl.utils.config import cli, to_toml_dict
1212
from prime_rl.utils.logger import setup_logger
13-
from prime_rl.utils.pathing import format_log_message, get_config_dir, get_log_dir
13+
from prime_rl.utils.pathing import format_log_message, get_config_dir, get_log_dir, setup_log_dir
1414
from prime_rl.utils.process import DEFAULT_COMMON_ENV_VARS, DEFAULT_INFERENCE_ENV_VARS, set_proc_title
1515

1616
INFERENCE_TOML = "inference.toml"
@@ -168,6 +168,8 @@ def inference_local(config: InferenceConfig):
168168

169169

170170
def inference(config: InferenceConfig):
171+
config.output_dir.mkdir(parents=True, exist_ok=True)
172+
setup_log_dir(config.output_dir)
171173
if config.slurm is not None:
172174
inference_slurm(config)
173175
else:

src/prime_rl/entrypoints/rl.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
get_ckpt_dir,
2525
get_log_dir,
2626
resolve_latest_ckpt_step,
27+
setup_log_dir,
2728
validate_output_dir,
2829
)
2930
from prime_rl.utils.process import (
@@ -535,6 +536,10 @@ def rl(config: RLConfig):
535536
if ckpt_output_dir is not None:
536537
ckpt_output_dir.mkdir(parents=True, exist_ok=True)
537538

539+
# Create a UUID-isolated log directory and symlink output_dir/logs to it.
540+
# When resuming, reuse the existing run's log directory.
541+
setup_log_dir(config.output_dir, resuming=resuming)
542+
538543
# Clean stale rollouts and broadcasts. When resuming, anything past the resume
539544
# step is stale. When training from scratch, every existing step directory is
540545
# stale — without this, a fresh run in a dirty output_dir would pick up rollouts

src/prime_rl/entrypoints/sft.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
from prime_rl.configs.sft import SFTConfig
1212
from prime_rl.utils.config import cli, to_toml_dict
1313
from prime_rl.utils.logger import setup_logger
14-
from prime_rl.utils.pathing import format_log_message, get_config_dir, get_log_dir, validate_output_dir
14+
from prime_rl.utils.pathing import (
15+
format_log_message,
16+
get_config_dir,
17+
get_log_dir,
18+
setup_log_dir,
19+
validate_output_dir,
20+
)
1521
from prime_rl.utils.process import (
1622
DEFAULT_COMMON_ENV_VARS,
1723
DEFAULT_TRAINER_ENV_VARS,
@@ -122,7 +128,7 @@ def sft_local(config: SFTConfig):
122128
logger.success("Dry run complete. To start an SFT run locally, remove --dry-run from your command.")
123129
return
124130

125-
log_dir = config.output_dir / "logs"
131+
log_dir = get_log_dir(config.output_dir)
126132
log_dir.mkdir(parents=True, exist_ok=True)
127133

128134
from prime_rl.utils.utils import get_free_port
@@ -132,7 +138,7 @@ def sft_local(config: SFTConfig):
132138
"--role=trainer",
133139
f"--rdzv-endpoint=localhost:{get_free_port()}",
134140
f"--rdzv-id={uuid.uuid4().hex}",
135-
f"--log-dir={config.output_dir / 'logs' / 'trainer' / 'torchrun'}",
141+
f"--log-dir={log_dir / 'trainer' / 'torchrun'}",
136142
f"--local-ranks-filter={','.join(map(str, config.log.ranks_filter))}",
137143
"--redirect=3",
138144
"--tee=3",
@@ -211,6 +217,10 @@ def sft(config: SFTConfig):
211217
validate_output_dir(config.output_dir, resuming=resuming, clean=clean)
212218
config.output_dir.mkdir(parents=True, exist_ok=True)
213219

220+
# Create a UUID-isolated log directory and symlink output_dir/logs to it.
221+
# When resuming, reuse the existing run's log directory.
222+
setup_log_dir(config.output_dir, resuming=resuming)
223+
214224
if not config.dry_run:
215225
from prime_rl.trainer.model import pre_download_model
216226

src/prime_rl/utils/pathing.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import os
23
import shutil
34
import time
45
from pathlib import Path
@@ -7,9 +8,57 @@
78

89

910
def get_log_dir(output_dir: Path) -> Path:
11+
"""Return the log directory for ``output_dir``.
12+
13+
``logs`` is a symlink to ``runs/<uuid>/`` (see :func:`setup_log_dir`).
14+
Falls back to the plain ``output_dir / "logs"`` directory when no
15+
symlink exists yet (e.g. in unit tests that don't call ``setup_log_dir``).
16+
"""
1017
return output_dir / "logs"
1118

1219

20+
def setup_log_dir(output_dir: Path, *, resuming: bool = False) -> Path:
21+
"""Create a per-run UUID log directory and symlink ``logs`` to it.
22+
23+
``output_dir/runs/<uuid>/`` holds the actual log files for this run.
24+
``output_dir/logs`` is a symlink pointing there, so existing code that
25+
reads ``get_log_dir(output_dir) / "trainer.log"`` continues to work.
26+
27+
When *resuming* and the symlink already exists, the existing run
28+
directory is reused so logs from the resumed run are appended to the
29+
same directory.
30+
31+
Returns the resolved log directory (the symlink path, i.e. what
32+
``get_log_dir`` would return).
33+
"""
34+
import uuid
35+
36+
logger = get_logger()
37+
logs_link = output_dir / "logs"
38+
runs_dir = output_dir / "runs"
39+
40+
# Reuse existing run directory when resuming
41+
if resuming and logs_link.is_symlink():
42+
target = logs_link.resolve()
43+
logger.debug(f"Reusing existing log directory: {target}")
44+
return logs_link
45+
46+
runs_dir.mkdir(parents=True, exist_ok=True)
47+
run_uuid = uuid.uuid4().hex
48+
run_dir = runs_dir / run_uuid
49+
run_dir.mkdir(parents=True, exist_ok=True)
50+
51+
# Atomically update the symlink: create a temp link then rename
52+
tmp_link = output_dir / f".logs.tmp.{run_uuid}"
53+
if tmp_link.exists() or tmp_link.is_symlink():
54+
tmp_link.unlink()
55+
os.symlink(run_dir, tmp_link)
56+
os.replace(tmp_link, logs_link)
57+
58+
logger.debug(f"Created log directory: {run_dir} (symlinked at {logs_link})")
59+
return logs_link
60+
61+
1362
def format_log_message(
1463
log_dir: Path,
1564
trainer: bool = False,

tests/unit/utils/test_pathing.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from prime_rl.utils.pathing import validate_output_dir
3+
from prime_rl.utils.pathing import get_log_dir, setup_log_dir, validate_output_dir
44

55

66
def test_nonexistent_dir_passes(tmp_path):
@@ -55,3 +55,94 @@ def test_clean_on_nonexistent_dir_is_noop(tmp_path):
5555
output_dir = tmp_path / "does_not_exist"
5656
validate_output_dir(output_dir, resuming=False, clean=True)
5757
assert not output_dir.exists()
58+
59+
60+
def test_setup_log_dir_creates_symlink(tmp_path):
61+
"""setup_log_dir creates runs/<uuid>/ and symlinks logs to it."""
62+
output_dir = tmp_path / "outputs"
63+
output_dir.mkdir()
64+
65+
log_dir = setup_log_dir(output_dir)
66+
67+
assert log_dir == output_dir / "logs"
68+
assert log_dir.is_symlink()
69+
target = log_dir.resolve()
70+
assert target.parent == (output_dir / "runs").resolve()
71+
assert target.is_dir()
72+
# The symlink target should be a UUID directory under runs/
73+
assert target.parent.name == "runs"
74+
assert len(target.name) == 32 # uuid4().hex length
75+
76+
77+
def test_setup_log_dir_writable_through_symlink(tmp_path):
78+
"""Files written through the symlink land in the UUID directory."""
79+
output_dir = tmp_path / "outputs"
80+
output_dir.mkdir()
81+
82+
log_dir = setup_log_dir(output_dir)
83+
(log_dir / "trainer.log").write_text("test")
84+
85+
target = log_dir.resolve()
86+
assert (target / "trainer.log").read_text() == "test"
87+
assert (log_dir / "trainer.log").read_text() == "test"
88+
89+
90+
def test_setup_log_dir_creates_fresh_uuid_each_call(tmp_path):
91+
"""Each call to setup_log_dir creates a new UUID run directory."""
92+
output_dir = tmp_path / "outputs"
93+
output_dir.mkdir()
94+
95+
log_dir_1 = setup_log_dir(output_dir)
96+
target_1 = log_dir_1.resolve()
97+
98+
# Write a file to the first run
99+
(log_dir_1 / "trainer.log").write_text("run1")
100+
101+
log_dir_2 = setup_log_dir(output_dir)
102+
target_2 = log_dir_2.resolve()
103+
104+
# New UUID directory
105+
assert target_1 != target_2
106+
# Both directories exist
107+
assert target_1.is_dir()
108+
assert target_2.is_dir()
109+
# Symlink now points to the second run
110+
assert log_dir_2.resolve() == target_2
111+
# First run's files are preserved
112+
assert (target_1 / "trainer.log").read_text() == "run1"
113+
114+
115+
def test_setup_log_dir_resuming_reuses_existing(tmp_path):
116+
"""When resuming and symlink exists, reuse the existing run directory."""
117+
output_dir = tmp_path / "outputs"
118+
output_dir.mkdir()
119+
120+
log_dir_1 = setup_log_dir(output_dir)
121+
target_1 = log_dir_1.resolve()
122+
(log_dir_1 / "trainer.log").write_text("run1")
123+
124+
# Resume should reuse the same directory
125+
log_dir_2 = setup_log_dir(output_dir, resuming=True)
126+
assert log_dir_2.resolve() == target_1
127+
assert (log_dir_2 / "trainer.log").read_text() == "run1"
128+
129+
130+
def test_setup_log_dir_resuming_without_symlink_creates_new(tmp_path):
131+
"""When resuming but no symlink exists, create a new run directory."""
132+
output_dir = tmp_path / "outputs"
133+
output_dir.mkdir()
134+
135+
log_dir = setup_log_dir(output_dir, resuming=True)
136+
assert log_dir.is_symlink()
137+
assert log_dir.resolve().is_dir()
138+
139+
140+
def test_get_log_dir_returns_symlink_path(tmp_path):
141+
"""get_log_dir returns output_dir/logs (the symlink path)."""
142+
output_dir = tmp_path / "outputs"
143+
output_dir.mkdir()
144+
145+
setup_log_dir(output_dir)
146+
log_dir = get_log_dir(output_dir)
147+
assert log_dir == output_dir / "logs"
148+
assert log_dir.is_symlink()

0 commit comments

Comments
 (0)