Skip to content

Commit 65ae173

Browse files
feat(multipool): clean up orphaned .snapshot_scratch partials
Scratch partials under runs/<id>/.snapshot_scratch/step_<S>/ were only ever removed by consolidate_step (after it assembles a step's checkpoint). When the async consolidation job stalls, is preempted, or is never submitted, nothing removes them and they accumulate — in practice runs that never consolidated a single step each held multiple TB of dead partials (one sweep reclaimed ~6.3 TB). Two small, complementary cleanups, both safe because resume reads only the consolidated training_<S>.pth, never raw partials: * _prune_scratch_through(scratch_dir, S) in consolidate_step (off the loop): drops every step_<=S>, not just S. The final save is the highest step, so its consolidation sweeps the whole run — a completed run ends with zero scratch, even if some intermediate consolidations were skipped. * prune_old_scratch(scratch_dir, keep_last_n=2) in snapshot() (rank 0, off the collective): keeps only the newest KEEP_LAST_N_SCRATCH snapshots — the backstop for when consolidation never runs at all, so a stuck run can't grow without bound (residue at most keep_last_n step dirs). CPU-only unit tests for both helpers; three_pool/CLAUDE.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca693c1 commit 65ae173

5 files changed

Lines changed: 144 additions & 6 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Unit tests for scratch cleanup — the two complementary mechanisms.
2+
3+
CPU-only, no torch: pure filesystem logic.
4+
5+
* ``_prune_scratch_through``: consolidation drops everything at/below the step it
6+
just finished — so a completed run's scratch goes fully to zero.
7+
* ``prune_old_scratch``: the train-loop backstop keeps only the newest N snapshots,
8+
bounding scratch when consolidation never runs at all.
9+
"""
10+
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from param_decomp_lab.three_pool.consolidate import (
16+
SNAPSHOT_SCRATCH_DIRNAME,
17+
_prune_scratch_through,
18+
prune_old_scratch,
19+
)
20+
21+
22+
def _make_scratch(tmp_path: Path, steps: list[int]) -> Path:
23+
scratch = tmp_path / SNAPSHOT_SCRATCH_DIRNAME
24+
for s in steps:
25+
(scratch / f"step_{s}").mkdir(parents=True)
26+
return scratch
27+
28+
29+
def _steps(scratch: Path) -> set[int]:
30+
return {int(d.name.removeprefix("step_")) for d in scratch.glob("step_*")}
31+
32+
33+
def test_through_drops_at_or_below_step_keeps_newer(tmp_path: Path) -> None:
34+
scratch = _make_scratch(tmp_path, [20, 40, 60])
35+
_prune_scratch_through(scratch, step=40)
36+
assert _steps(scratch) == {60} # 20, 40 consolidated/superseded; 60 still pending
37+
38+
39+
def test_through_final_step_clears_a_finished_run(tmp_path: Path) -> None:
40+
# The highest step's consolidation sweeps everything — even a backlog of earlier,
41+
# never-consolidated steps — so a completed run leaves zero scratch (not N).
42+
scratch = _make_scratch(tmp_path, [20, 40, 60])
43+
_prune_scratch_through(scratch, step=60)
44+
assert _steps(scratch) == set()
45+
46+
47+
def test_old_keeps_newest_n_drops_the_rest(tmp_path: Path) -> None:
48+
scratch = _make_scratch(tmp_path, [20, 40, 60])
49+
prune_old_scratch(scratch, keep_last_n=2)
50+
assert _steps(scratch) == {40, 60} # ordered by step number, not name/mtime
51+
52+
53+
def test_old_noop_when_at_or_under_limit(tmp_path: Path) -> None:
54+
scratch = _make_scratch(tmp_path, [10])
55+
prune_old_scratch(scratch, keep_last_n=2)
56+
assert _steps(scratch) == {10}
57+
58+
59+
def test_noop_when_scratch_absent(tmp_path: Path) -> None:
60+
absent = tmp_path / SNAPSHOT_SCRATCH_DIRNAME
61+
prune_old_scratch(absent) # glob on a missing dir is empty — must not raise
62+
_prune_scratch_through(absent, step=10)
63+
64+
65+
def test_old_keep_last_n_must_be_at_least_one(tmp_path: Path) -> None:
66+
# keep_last_n=0 would delete the just-written step out from under its consolidation.
67+
with pytest.raises(AssertionError):
68+
prune_old_scratch(_make_scratch(tmp_path, [10]), keep_last_n=0)

param_decomp_lab/three_pool/CLAUDE.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ layer's output independently — is a separate, stable concept the chunkwise poo
1919
| `config.py` | `ThreePoolTopology` (`ci` / `ppgd` / `chunkwise` `PoolSpec`s of per-rank batch + `sites_per_chunk`) + `resolve(ordered_sites, batch_size) -> ResolvedLayout`. Authors per-rank batch, NOT rank ids; the resolver derives ranks/chunks/world_size in canonical order. Parse-time validation = cross-divisibility of the three per-rank batches |
2020
| `layout.py` | `World` topology + the runtime `Chunk` (`.ranks` / `.sites`); `build_world` constructs every process group (threading `pg_timeout` into each); `BatchEdge` — symmetric per-edge batch-slice geometry (CI↔chunk, CI↔PPGD) answering routing for both fan directions |
2121
| `checkpoint.py` | offline state_dict assembly from on-disk partials (`assemble_model_state_dict_from_partials`) + the leader key-partition helpers (`owned_model_state_keys` / `ci_fn_state_keys`) |
22-
| `consolidate.py` | `consolidate_step` — async, off-train-loop, **streaming** assembly of `model_<step>.pth` + `training_<step>.pth` from a step's small (parameter-shaped) scratch partials; prunes old `training_*.pth` + `ppgd_*/`; deletes the scratch dir. The data-shaped PPGD sources are NOT here — each adversary rank writes its own `ppgd_<step>/rank_<r>.pth` at snapshot time, in parallel (see "Checkpoint save" below). `load_ppgd_shard` reads a rank's shard on resume; `unconsolidated_steps` lists recoverable steps |
22+
| `consolidate.py` | `consolidate_step` — async, off-train-loop, **streaming** assembly of `model_<step>.pth` + `training_<step>.pth` from a step's small (parameter-shaped) scratch partials; prunes old `training_*.pth` + `ppgd_*/`; drops scratch at/below the step (`_prune_scratch_through`). The data-shaped PPGD sources are NOT here — each adversary rank writes its own `ppgd_<step>/rank_<r>.pth` at snapshot time, in parallel (see "Checkpoint save" below). `load_ppgd_shard` reads a rank's shard on resume; `unconsolidated_steps` lists recoverable steps. `prune_old_scratch` (called on-loop from `snapshot()`) bounds scratch when consolidation stalls/never runs |
2323
| `consolidate_cli.py` | `python -m …consolidate_cli <run> [--step N]` — manual CPU-only recovery for a failed/preempted async consolidation (separate module to avoid an import cycle with `experiments.lm.run`) |
2424
| `role.py` | `PoolRole = CIRole \| ChunkRole \| PPGDRole` — this rank's pool role; per-pool fields are union variants, not optional attrs |
2525
| `context.py` | `PoolContext = CIContext \| ChunkContext \| PPGDContext``world` + `role` + this pool's portals; the trainer matches on it to dispatch step fns |
@@ -186,7 +186,9 @@ the assembled CPU model, never the full partial set) → assembles the full
186186
`ComponentModel` state_dict + `ThreePoolTrainingState` → writes `model_<S>.pth` +
187187
`training_<S>.pth` → prunes old `training_*.pth` to the last
188188
`DEFAULT_KEEP_LAST_N_TRAINING` (=3; **all `model_*.pth` are kept**) → prunes old
189-
`ppgd_*/` → deletes `step_<S>/`. It runs inside the async slow-eval job
189+
`ppgd_*/` → drops scratch at/below `S` (`_prune_scratch_through`: `step_<S>/` plus any
190+
older step, consolidated or not — resume uses the newest checkpoint, so they're dead). It
191+
runs inside the async slow-eval job
190192
(`experiments/lm/async_eval.py`), as a CPU-only phase BEFORE the eval pass, so the
191193
assembled `model_<S>.pth` exists before the eval loads it. It reads **only** the
192194
small partials — the PPGD shards were already written at snapshot time, so
@@ -210,6 +212,23 @@ resume uses the newest checkpoint. Idempotent: a no-op if `training_<S>.pth`
210212
already exists (and it cleans any leftover scratch in that case); the scratch dir
211213
is deleted only on success.
212214

215+
**Scratch cleanup (two mechanisms).** `consolidate_step` is the only thing that
216+
removes scratch, so when its async job stalls, is preempted, or is never submitted,
217+
partials pile up — runs that never consolidated a single step were observed holding
218+
**multiple TB** of dead `step_<S>/` partials. Two complementary cleanups bound it,
219+
both safe because resume reads only the consolidated `training_<S>.pth`, never raw
220+
partials:
221+
222+
* **`_prune_scratch_through(scratch_dir, S)`** (in `consolidate_step`, off the loop):
223+
drops every `step_<=S>`, not just `S`. Because the final save is the highest step,
224+
its consolidation sweeps the whole run — a *completed* run ends with **zero**
225+
scratch, even if some intermediate consolidations were skipped.
226+
* **`prune_old_scratch(scratch_dir, keep_last_n=2)`** (in `snapshot()`, rank 0, off
227+
the collective): keeps only the newest `KEEP_LAST_N_SCRATCH` snapshots. The backstop
228+
for when consolidation *never* runs — without it a stuck run grows without bound;
229+
with it the residue is at most `keep_last_n` step dirs. (Two, not one, so the
230+
previous save's still-in-flight consolidation keeps its partials.)
231+
213232
This is the fix for how run 34446 (p-a5b667e9) died: the old synchronous rank-0
214233
read held the other ranks at a barrier past the NCCL watchdog. There is no
215234
on-loop read to outrun the watchdog now.
@@ -393,7 +412,8 @@ that whole packet stays bf16 rather than splitting the buffer by dtype (off the
393412
consolidation finishes, resume from the previous consolidated step — at most one
394413
save-interval of lost progress (the scratch partials for the unconsolidated step
395414
are left on disk; they can be consolidated manually via `consolidate_step` if
396-
that interval matters).
415+
that interval matters — but only the newest `KEEP_LAST_N_SCRATCH` snapshots survive
416+
the on-loop `prune_old_scratch`, so don't count on partials from many saves back).
397417

398418
`from_snapshot` validates the saved topology against the current one, but the
399419
comparison runs on EVERY rank, so it compares only the **rank-invariant** core

param_decomp_lab/three_pool/consolidate.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
resume uses the newest checkpoint, and an older checkpoint with its shards pruned
5656
still resumes (the adversary re-warms via ``n_warmup``)."""
5757

58+
KEEP_LAST_N_SCRATCH = 2
59+
"""How many snapshots' scratch to retain on the train loop. ``consolidate_step``
60+
clears each step's scratch as it lands, so this only bites when that async job stalls
61+
or never runs — the backstop that stops partials growing without bound. Two, not one,
62+
leaves the previous save's still-in-flight consolidation its partials."""
63+
5864

5965
def step_scratch_dir(scratch_dir: Path, step: int) -> Path:
6066
return scratch_dir / f"step_{step}"
@@ -109,7 +115,7 @@ def consolidate_step(
109115
# that wrote the checkpoint but died before removing it (e.g. crashed in
110116
# prune), so this step stops looking unconsolidated.
111117
logger.info(f"consolidate: {training_path.name} already exists; skipping")
112-
shutil.rmtree(step_dir, ignore_errors=True)
118+
_prune_scratch_through(scratch_dir, step)
113119
return
114120

115121
if not step_dir.is_dir():
@@ -188,8 +194,8 @@ def consolidate_step(
188194
_prune_old_training(out_dir, keep_last_n=keep_last_n_training)
189195
_prune_old_ppgd(out_dir, keep_last_n=DEFAULT_KEEP_LAST_N_PPGD)
190196

191-
shutil.rmtree(step_dir, ignore_errors=True)
192-
logger.info(f"consolidate: removed scratch {step_dir}")
197+
_prune_scratch_through(scratch_dir, step)
198+
logger.info(f"consolidate: removed scratch through step {step}")
193199

194200

195201
def _prune_old_training(out_dir: Path, *, keep_last_n: int) -> None:
@@ -245,6 +251,38 @@ def _prune_old_ppgd(out_dir: Path, *, keep_last_n: int) -> None:
245251
logger.info(f"consolidate: pruned old {ppgd_shard_dirname(step)}/")
246252

247253

254+
def _prune_scratch_through(scratch_dir: Path, step: int) -> None:
255+
"""Remove scratch for every snapshot at or before ``step`` (now consolidated).
256+
257+
Called by ``consolidate_step`` once ``training_<step>.pth`` exists. Resume loads
258+
the newest checkpoint, never raw partials, so a step ``<=`` a consolidated one is
259+
dead — and since the final save is the highest step, its consolidation sweeps a
260+
finished run's scratch to nothing (even if earlier consolidations were skipped).
261+
"""
262+
for d in scratch_dir.glob("step_*"):
263+
if d.is_dir() and int(d.name.removeprefix("step_")) <= step:
264+
shutil.rmtree(d, ignore_errors=True)
265+
266+
267+
def prune_old_scratch(scratch_dir: Path, *, keep_last_n: int = KEEP_LAST_N_SCRATCH) -> None:
268+
"""Delete all but the newest ``keep_last_n`` ``step_<S>/`` scratch dirs.
269+
270+
The train-loop backstop to ``_prune_scratch_through``: when the async
271+
consolidation that normally clears scratch never runs, this still bounds it. Safe
272+
on the loop for the same reason — resume loads only the consolidated
273+
``training_<S>.pth``, never raw partials, so dropping an older un-consolidated step
274+
costs at most a manual-recovery convenience, never a resume target.
275+
"""
276+
assert keep_last_n >= 1, "keep_last_n=0 would delete the just-written step"
277+
steps = sorted(
278+
(d for d in scratch_dir.glob("step_*") if d.is_dir()),
279+
key=lambda d: int(d.name.removeprefix("step_")),
280+
)
281+
for d in steps[:-keep_last_n]:
282+
shutil.rmtree(d, ignore_errors=True)
283+
logger.info(f"scratch GC: dropped stale {d.name}/")
284+
285+
248286
def unconsolidated_steps(out_dir: Path) -> list[int]:
249287
"""Steps that have scratch partials on disk but no `training_<step>.pth` yet.
250288

param_decomp_lab/three_pool/optimize.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class boundary.
8484
CONSOLIDATE_META_FILENAME,
8585
load_ppgd_shard,
8686
ppgd_shard_dirname,
87+
prune_old_scratch,
8788
step_scratch_dir,
8889
)
8990
from param_decomp_lab.three_pool.context import (
@@ -484,6 +485,11 @@ def snapshot(self, scratch_dir: Path) -> None:
484485
dist.barrier(group=p2p_group)
485486
trace("snapshot: barrier (post-write rejoin) done")
486487

488+
# Backstop the async consolidation that normally clears scratch: if its job
489+
# stalls or never runs, this keeps partials from growing without bound.
490+
if self.ctx.role.rank == 0:
491+
prune_old_scratch(scratch_dir)
492+
487493
def _build_my_partial(self) -> dict[str, Any]:
488494
my_named_params = self._named_params_for_my_optimizer()
489495
my_optimizer_by_name: dict[str, dict[str, Any]] = (

param_decomp_lab/three_pool/two_pool_optimize.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
CONSOLIDATE_META_FILENAME,
7474
load_ppgd_shard,
7575
ppgd_shard_dirname,
76+
prune_old_scratch,
7677
step_scratch_dir,
7778
)
7879
from param_decomp_lab.three_pool.context import ChunkContext
@@ -392,6 +393,11 @@ def snapshot(self, scratch_dir: Path) -> None:
392393
dist.barrier(group=p2p_group)
393394
trace("snapshot: barrier (post-write rejoin) done")
394395

396+
# Backstop the async consolidation that normally clears scratch: if its job
397+
# stalls or never runs, this keeps partials from growing without bound.
398+
if self.ctx.role.rank == 0:
399+
prune_old_scratch(scratch_dir)
400+
395401
@classmethod
396402
def from_snapshot(
397403
cls,

0 commit comments

Comments
 (0)