Skip to content

Commit 0273efb

Browse files
[feat] checkpoint_util: add CheckpointManager with async prune
CheckpointManager saves model.ckpt-<step> dirs and prunes old ones to keep the most recent keep_checkpoint_max. Pruning never blocks the caller: prune() only enqueues a coalesced request and a single daemon prune worker performs all filesystem work (glob listing, best-checkpoint lookup, recursive delete via shutil.rmtree), which matters for slow OSS-backed model_dirs. Only rank 0 deletes. When exporter_type == "best", the current best-by-metric checkpoint is retained even if outside the recent window. close() drains the queue at the end of training for a deterministic flush; a weakref.finalize safety net (registered when the worker starts) runs the same drain at interpreter exit if close() is skipped (e.g. training raised), so the worker is never leaked and pending deletions still complete. The class also exposes load/discovery methods that delegate to the existing free functions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2bfa13 commit 0273efb

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

tzrec/utils/checkpoint_util.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import glob
1313
import json
1414
import os
15+
import queue
1516
import re
17+
import shutil
18+
import threading
19+
import weakref
1620
from dataclasses import replace
1721
from typing import Dict, List, Optional, Tuple
1822

@@ -38,6 +42,9 @@
3842
from tzrec.utils.dynamicemb_util import has_dynamicemb
3943
from tzrec.utils.logging_util import logger
4044

45+
# queue token meaning "run a prune pass"; ``None`` means "stop the worker".
46+
_PRUNE_REQUEST = object()
47+
4148

4249
class PartialLoadPlanner(DefaultLoadPlanner):
4350
"""Support restore partial states.
@@ -256,6 +263,183 @@ def best_checkpoint(
256263
return latest_checkpoint(model_dir)
257264

258265

266+
class CheckpointManager:
267+
"""Saves training checkpoints and prunes old ones asynchronously.
268+
269+
``prune`` never deletes on the calling thread: it enqueues a coalesced
270+
request to a single daemon worker that does all filesystem work (listing,
271+
best-checkpoint lookup, deletion), so the training loop never blocks on
272+
``model_dir`` I/O. ``glob`` of ``model_dir`` is the source of truth on every
273+
pass, so the manager holds no checkpoint registry. Load/discovery methods
274+
delegate to the module-level free functions.
275+
276+
Args:
277+
model_dir: directory holding ``model.ckpt-<step>`` checkpoints.
278+
keep_checkpoint_max: max number of recent checkpoints to keep; 0 keeps all.
279+
export_config: when ``exporter_type == "best"``, the current best checkpoint
280+
(by eval metric) is always retained even if older than the kept window.
281+
eval_result_filename: eval result file (relative to ``model_dir``) used to
282+
locate the best checkpoint.
283+
"""
284+
285+
def __init__(
286+
self,
287+
model_dir: str,
288+
keep_checkpoint_max: int = 0,
289+
export_config: Optional[export_pb2.ExportConfig] = None,
290+
eval_result_filename: str = TRAIN_EVAL_RESULT_FILENAME,
291+
) -> None:
292+
self._model_dir = model_dir
293+
self._keep_checkpoint_max = keep_checkpoint_max
294+
self._export_config = export_config
295+
self._eval_result_filename = eval_result_filename
296+
self._prune_queue: "queue.Queue[object]" = queue.Queue()
297+
self._prune_pending = False
298+
self._lock = threading.Lock()
299+
self._prune_worker: Optional[threading.Thread] = None
300+
self._finalizer: Optional[weakref.finalize] = None
301+
302+
def save(
303+
self,
304+
step: int,
305+
model: nn.Module,
306+
optimizer: Optional[optim.Optimizer] = None,
307+
dataloader_state: Optional[Dict[str, int]] = None,
308+
) -> str:
309+
"""Save a checkpoint at the given step, then request an async prune."""
310+
ckpt_dir = os.path.join(self._model_dir, f"model.ckpt-{step}")
311+
save_model(ckpt_dir, model, optimizer)
312+
if dataloader_state is not None:
313+
save_dataloader_state(ckpt_dir, dataloader_state)
314+
self.prune()
315+
return ckpt_dir
316+
317+
def prune(self) -> None:
318+
"""Request an async prune pass (keep recent N + best). Rank 0 only.
319+
320+
Cheap and non-blocking: only flips a flag and enqueues one coalesced
321+
request. The flag drops the request when a pass is already queued or
322+
in-flight, so the queue stays bounded. All filesystem work happens on
323+
the worker thread.
324+
"""
325+
if self._keep_checkpoint_max <= 0:
326+
return
327+
if int(os.environ.get("RANK", 0)) != 0:
328+
return
329+
with self._lock:
330+
if self._prune_pending:
331+
return
332+
self._prune_pending = True
333+
self._ensure_prune_worker()
334+
self._prune_queue.put(_PRUNE_REQUEST)
335+
336+
def close(self) -> None:
337+
"""Drain queued prune passes and stop the worker (idempotent).
338+
339+
Called at the end of training for a deterministic flush. A weakref.finalize
340+
safety net (registered when the worker starts) runs the same drain at
341+
interpreter exit if close() is skipped (e.g. training raised), so the worker
342+
is never leaked and pending deletions still complete.
343+
"""
344+
if self._prune_worker is None:
345+
return
346+
self._finalizer.detach()
347+
self._drain(self._prune_queue, self._prune_worker)
348+
self._prune_worker = None
349+
350+
# --- load / discovery (delegate to the module-level free functions) ---
351+
352+
def latest_checkpoint(self) -> Tuple[Optional[str], int]:
353+
"""Latest checkpoint under this manager's model_dir."""
354+
return latest_checkpoint(self._model_dir)
355+
356+
def best_checkpoint(self) -> Tuple[Optional[str], int]:
357+
"""Best checkpoint under model_dir per the configured export metric."""
358+
return best_checkpoint(
359+
self._model_dir, self._export_config, self._eval_result_filename
360+
)
361+
362+
def restore(
363+
self,
364+
ckpt_path: str,
365+
model: nn.Module,
366+
optimizer: Optional[optim.Optimizer] = None,
367+
ckpt_param_map_path: Optional[str] = None,
368+
) -> None:
369+
"""Restore model/optimizer state from a checkpoint dir."""
370+
restore_model(ckpt_path, model, optimizer, ckpt_param_map_path)
371+
372+
def restore_dataloader_state(self, ckpt_path: str) -> Optional[Dict[str, int]]:
373+
"""Restore dataloader state saved alongside a checkpoint."""
374+
return restore_dataloader_state(ckpt_path)
375+
376+
@staticmethod
377+
def _drain(prune_queue: "queue.Queue[object]", worker: threading.Thread) -> None:
378+
"""Stop the prune worker and wait for it to finish (no ``self`` ref).
379+
380+
Used by both ``close()`` and the weakref.finalize safety net. FIFO ensures
381+
any pending prune request runs before the stop sentinel.
382+
"""
383+
prune_queue.put(None) # stop sentinel
384+
worker.join()
385+
386+
def _ensure_prune_worker(self) -> None:
387+
if self._prune_worker is None:
388+
self._prune_worker = threading.Thread(
389+
target=self._prune_worker_loop, name="ckpt-prune", daemon=True
390+
)
391+
self._prune_worker.start()
392+
# Safety net: if close() is never reached (e.g. training raised), drain
393+
# the worker at interpreter exit so it is not leaked. The callback must
394+
# not reference self, or it would pin the manager.
395+
self._finalizer = weakref.finalize(
396+
self, self._drain, self._prune_queue, self._prune_worker
397+
)
398+
399+
def _prune_worker_loop(self) -> None:
400+
while True:
401+
item = self._prune_queue.get()
402+
try:
403+
if item is None: # stop sentinel
404+
return
405+
# allow a fresh request to be enqueued while this pass runs.
406+
with self._lock:
407+
self._prune_pending = False
408+
try:
409+
self._run_prune()
410+
except Exception as e: # noqa: BLE001
411+
logger.warning(f"Checkpoint prune pass failed: {e}")
412+
finally:
413+
self._prune_queue.task_done()
414+
415+
def _run_prune(self) -> None:
416+
"""All filesystem work for one prune pass -- runs only on the worker."""
417+
ckpt_metas = glob.glob(
418+
os.path.join(self._model_dir, "model.ckpt-*" + os.path.sep)
419+
)
420+
ckpt_metas = [x.rstrip(os.path.sep) for x in ckpt_metas]
421+
if len(ckpt_metas) <= self._keep_checkpoint_max:
422+
return
423+
ckpt_metas.sort(key=_get_checkpoint_step)
424+
protected = set(ckpt_metas[-self._keep_checkpoint_max :])
425+
if (
426+
self._export_config is not None
427+
and self._export_config.exporter_type == "best"
428+
):
429+
best_ckpt_path, _ = best_checkpoint(
430+
self._model_dir, self._export_config, self._eval_result_filename
431+
)
432+
if best_ckpt_path is not None:
433+
protected.add(best_ckpt_path.rstrip(os.path.sep))
434+
for ckpt_path in ckpt_metas:
435+
if ckpt_path not in protected:
436+
logger.info(f"Removing old checkpoint {ckpt_path}...")
437+
try:
438+
shutil.rmtree(ckpt_path)
439+
except Exception as e: # noqa: BLE001
440+
logger.warning(f"Failed to remove checkpoint {ckpt_path}: {e}")
441+
442+
259443
_DISTCP_RANK_RE = re.compile(r"__(\d+)_\d+\.distcp$")
260444

261445

tzrec/utils/checkpoint_util_test.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
import shutil
1616
import tempfile
1717
import unittest
18+
from unittest import mock
1819

1920
import torch
2021
import torch.distributed as dist
2122
import torchrec
23+
from parameterized import parameterized
2224
from torch import nn
2325
from torchrec import EmbeddingBagCollection
2426
from torchrec.distributed.model_parallel import (
@@ -216,6 +218,115 @@ def test_latest_checkpoint_with_custom_dir(self):
216218
self.assertEqual(ckpt_path, os.path.join(self.test_dir, "custom"))
217219
self.assertEqual(step, 0)
218220

221+
def _remaining_ckpt_steps(self):
222+
return sorted(
223+
int(p.rsplit("-", 1)[1])
224+
for p in os.listdir(self.test_dir)
225+
if p.startswith("model.ckpt-")
226+
)
227+
228+
@parameterized.expand(
229+
[
230+
# name, keep_checkpoint_max, protect_best, expected remaining steps
231+
("keep_all", 0, False, [0, 10, 20, 30]),
232+
("recent_2", 2, False, [20, 30]),
233+
("protect_best", 2, True, [10, 20, 30]),
234+
("keep_ge_count", 10, False, [0, 10, 20, 30]),
235+
]
236+
)
237+
def test_checkpoint_manager_prune(
238+
self, _name, keep_checkpoint_max, protect_best, expected_steps
239+
):
240+
for step in [0, 10, 20, 30]:
241+
os.makedirs(os.path.join(self.test_dir, f"model.ckpt-{step}"))
242+
export_config = None
243+
if protect_best:
244+
with open(
245+
os.path.join(self.test_dir, TRAIN_EVAL_RESULT_FILENAME), "w"
246+
) as f:
247+
f.write('{"global_step":0, "auc":0.50}\n')
248+
f.write('{"global_step":10, "auc":0.99}\n') # best, outside recent-2
249+
f.write('{"global_step":20, "auc":0.60}\n')
250+
f.write('{"global_step":30, "auc":0.70}\n')
251+
export_config = ExportConfig(
252+
exporter_type="best",
253+
best_exporter_metric="auc",
254+
metric_larger_is_better=True,
255+
)
256+
manager = checkpoint_util.CheckpointManager(
257+
self.test_dir,
258+
keep_checkpoint_max=keep_checkpoint_max,
259+
export_config=export_config,
260+
)
261+
with mock.patch.dict(os.environ, {"RANK": "0"}):
262+
manager.prune()
263+
manager.close() # drains the async worker -> deterministic
264+
self.assertEqual(self._remaining_ckpt_steps(), expected_steps)
265+
266+
def test_checkpoint_manager_prune_non_rank_zero(self):
267+
for step in [0, 10, 20, 30]:
268+
os.makedirs(os.path.join(self.test_dir, f"model.ckpt-{step}"))
269+
manager = checkpoint_util.CheckpointManager(
270+
self.test_dir, keep_checkpoint_max=2
271+
)
272+
with mock.patch.dict(os.environ, {"RANK": "1"}):
273+
manager.prune()
274+
manager.close()
275+
self.assertEqual(self._remaining_ckpt_steps(), [0, 10, 20, 30])
276+
277+
def test_checkpoint_manager_prune_idempotent(self):
278+
for step in [0, 10, 20, 30]:
279+
os.makedirs(os.path.join(self.test_dir, f"model.ckpt-{step}"))
280+
manager = checkpoint_util.CheckpointManager(
281+
self.test_dir, keep_checkpoint_max=2
282+
)
283+
with mock.patch.dict(os.environ, {"RANK": "0"}):
284+
manager.prune()
285+
manager.prune() # coalesced; must not double-delete or error
286+
manager.close()
287+
self.assertEqual(self._remaining_ckpt_steps(), [20, 30])
288+
289+
def test_checkpoint_manager_finalizer_drains(self):
290+
# Simulate the exception path: prune() runs but close() is never reached.
291+
# The weakref.finalize safety net must drain the worker at interpreter exit.
292+
for step in [0, 10, 20, 30]:
293+
os.makedirs(os.path.join(self.test_dir, f"model.ckpt-{step}"))
294+
manager = checkpoint_util.CheckpointManager(
295+
self.test_dir, keep_checkpoint_max=2
296+
)
297+
with mock.patch.dict(os.environ, {"RANK": "0"}):
298+
manager.prune()
299+
self.assertTrue(manager._finalizer.alive)
300+
manager._finalizer() # invoke directly to simulate at-exit
301+
self.assertEqual(self._remaining_ckpt_steps(), [20, 30])
302+
self.assertFalse(manager._prune_worker.is_alive())
303+
self.assertFalse(manager._finalizer.alive)
304+
305+
def test_checkpoint_manager_discovery(self):
306+
for step in [0, 10, 20]:
307+
os.makedirs(os.path.join(self.test_dir, f"model.ckpt-{step}"))
308+
with open(os.path.join(self.test_dir, TRAIN_EVAL_RESULT_FILENAME), "w") as f:
309+
f.write('{"global_step":0, "auc":0.6}\n')
310+
f.write('{"global_step":10, "auc":0.7}\n') # best
311+
f.write('{"global_step":20, "auc":0.5}\n')
312+
export_config = ExportConfig(
313+
exporter_type="best",
314+
best_exporter_metric="auc",
315+
metric_larger_is_better=True,
316+
)
317+
manager = checkpoint_util.CheckpointManager(
318+
self.test_dir, export_config=export_config
319+
)
320+
self.assertEqual(
321+
manager.latest_checkpoint(),
322+
checkpoint_util.latest_checkpoint(self.test_dir),
323+
)
324+
self.assertEqual(
325+
manager.best_checkpoint(),
326+
checkpoint_util.best_checkpoint(self.test_dir, export_config),
327+
)
328+
self.assertEqual(manager.best_checkpoint()[1], 10)
329+
219330
def test_dist_save_restore_model(self):
220331
port = misc_util.get_free_port()
221332
procs = []

0 commit comments

Comments
 (0)