|
12 | 12 | import glob |
13 | 13 | import json |
14 | 14 | import os |
| 15 | +import queue |
15 | 16 | import re |
| 17 | +import shutil |
| 18 | +import threading |
| 19 | +import weakref |
16 | 20 | from dataclasses import replace |
17 | 21 | from typing import Dict, List, Optional, Tuple |
18 | 22 |
|
|
38 | 42 | from tzrec.utils.dynamicemb_util import has_dynamicemb |
39 | 43 | from tzrec.utils.logging_util import logger |
40 | 44 |
|
| 45 | +# queue token meaning "run a prune pass"; ``None`` means "stop the worker". |
| 46 | +_PRUNE_REQUEST = object() |
| 47 | + |
41 | 48 |
|
42 | 49 | class PartialLoadPlanner(DefaultLoadPlanner): |
43 | 50 | """Support restore partial states. |
@@ -256,6 +263,183 @@ def best_checkpoint( |
256 | 263 | return latest_checkpoint(model_dir) |
257 | 264 |
|
258 | 265 |
|
| 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 | + |
259 | 443 | _DISTCP_RANK_RE = re.compile(r"__(\d+)_\d+\.distcp$") |
260 | 444 |
|
261 | 445 |
|
|
0 commit comments