Source code for pimm.engines.hooks.export

"""Optional hook: push model checkpoints to the Hugging Face Hub during/after training.

This hook is entirely opt-in: it only runs when explicitly added to a config's
``hooks`` list, e.g.::

    hooks = [
        ...,
        dict(type="CheckpointSaver"),
        dict(type="PushToHub", repo_id="youngsm/sonata-pilarnet-L", private=True),
    ]

By default it uploads the raw ``model_best`` checkpoint at the end of training
(``weights_only=True``), so the result loads back byte-identically via
``weight=hf://<repo>/model_best.pth``. Set ``weights_only=False`` to push a
consolidated ``pimm export`` artifact instead.

Cross-cluster sync (pretrain on one site, monitor/fine-tune on another) is
supported via periodic pushes:

- ``on_best=True``   -> push ``model_best`` whenever the best metric improves
- ``every_n_epochs`` -> push ``model_last`` at the end of every N epochs

Periodic uploads run in a background thread (``background=True``) so they never
block the training step. Place ``PushToHub`` *after* the checkpoint saver in the
``hooks`` list so the files it reads are already written for the current step.

Uploading large checkpoints repeatedly to the same path accumulates orphaned blobs
in the repo's LFS history (each push is a new commit; the old blob stays
referenced by the old commit and counts against storage). To reclaim that space we
``super_squash_history``: a cheap server-side metadata op (no re-upload) that
collapses history to a single commit. By default it runs once at the end of
training (``squash_history=True``); for long runs with many periodic pushes set
``squash_every_n_pushes=N`` to also squash mid-run and keep storage bounded to
~N checkpoints.

Failures are logged but never crash the run.
"""

from __future__ import annotations

import os

from pimm.utils.comm import is_main_process
from pimm.utils.path import (
    latest_complete_checkpoint,
    resolve_model_weight_file,
    split_checkpoint_weight_file,
)
from .builder import HOOKS
from .default import HookBase


[docs] @HOOKS.register_module() class PushToHub(HookBase): """Push model checkpoints to the Hugging Face Hub during and/or after training. Entirely opt-in. By default it uploads the raw ``model_best`` checkpoint once at the end of training (``weights_only=True``), so it loads back byte-identically via ``weight=hf://<repo>/model_best.pth``; set ``weights_only=False`` to push a consolidated ``pimm export`` artifact instead. For cross-cluster sync it can also push periodically: with ``on_best=True`` it pushes ``model_best`` in ``after_step`` whenever the best metric improves, and with ``every_n_epochs`` set it pushes the rolling checkpoint in ``after_epoch`` every N epochs. The final end-of-training push happens in ``after_train``, which also drains any in-flight background uploads. Registered as ``PushToHub``. Args: repo_id (str): Target Hub repo, e.g. ``"youngsm/sonata-pilarnet-L"``. checkpoint (str): Which checkpoint to push at end of training, e.g. ``"model_best"`` or ``"last"``/``"model_last"`` (the latter resolve to the newest rolling checkpoint). Defaults to ``"model_best"``. weights_only (bool): If ``True``, upload the raw checkpoint file; if ``False``, build and push a consolidated ``pimm export`` artifact. Defaults to ``True``. private (bool): Create the repo as private if it does not exist. Defaults to ``True``. token (str, optional): Hugging Face token; falls back to the ambient credential when ``None``. Defaults to ``None``. revision (str, optional): Target branch/revision for uploads. Defaults to ``None``. name (str, optional): Destination filename in the repo for raw uploads; defaults to the source file's basename when ``None``. Defaults to ``None``. on_train_end (bool): Push ``checkpoint`` in ``after_train``. Defaults to ``True``. on_best (bool): Push ``model_best`` in ``after_step`` whenever ``best_metric_value`` improves. Defaults to ``False``. every_n_epochs (int, optional): Push the rolling checkpoint in ``after_epoch`` every N epochs. ``None`` disables. Defaults to ``None``. every_n_steps (int, optional): Push the rolling checkpoint in ``after_step`` every N global steps -- use this to match an iteration-oriented saver like ``CheckpointSaverIteration`` (set it to the saver's ``save_freq``). The step counter is seeded from resumed progress in ``before_train``. ``None`` disables. Defaults to ``None``. background (bool): Run periodic raw uploads in a background thread so they never block the training step (uploads to the same path are serialized). The final ``after_train`` push is always blocking. Defaults to ``True``. squash_history (bool): Squash the repo's git/LFS history once in ``after_train`` (after the final push) to reclaim orphaned LFS blobs. Cheap server-side metadata op; irreversible (drops history). Defaults to ``True``. squash_every_n_pushes (int, optional): Also squash after every N successful periodic pushes (``on_best``/``every_n_epochs``) so a long run does not accumulate unbounded LFS storage before it ends. ``None`` disables mid-run squashing. Defaults to ``None``. Note: Uploads run on rank 0 only, and failures are logged but never crash the run. Place ``PushToHub`` **after** the checkpoint saver in the ``hooks`` list so the files it reads are already written. Repeatedly uploading large checkpoints to the same path accumulates orphaned LFS blobs in the repo's history; ``squash_history`` (end of run) and ``squash_every_n_pushes`` (mid run) reclaim that storage via ``super_squash_history``. Example: Add to ``cfg.hooks`` after the checkpoint saver; by default it uploads ``model_best`` to the Hub once at the end of training: .. code-block:: python hooks = [ dict(type="CheckpointSaver"), dict(type="PushToHub", repo_id="youngsm/sonata-pilarnet-L", private=True), ] # → in after_train (rank 0, blocking) uploads # <save_path>/model/model_best.pth to # hf://youngsm/sonata-pilarnet-L/model_best.pth; with on_best=True it # also pushes model_best in after_step whenever best_metric_value # improves, and every_n_epochs=N pushes the rolling ckpt in after_epoch """ def __init__( self, repo_id, checkpoint="model_best", weights_only=True, private=True, token=None, revision=None, name=None, on_train_end=True, on_best=False, every_n_epochs=None, every_n_steps=None, background=True, squash_history=True, squash_every_n_pushes=None, ): """Configure the target repo, what to push, and when.""" self.repo_id = repo_id self.checkpoint = checkpoint self.weights_only = weights_only self.private = private self.token = token self.revision = revision self.name = name self.on_train_end = on_train_end self.on_best = on_best self.every_n_epochs = every_n_epochs self.every_n_steps = every_n_steps self.background = background self.squash_history = squash_history self.squash_every_n_pushes = squash_every_n_pushes self._api = None self._repo_ready = False self._seen_best = -float("inf") self._futures = {} # path_in_repo -> in-flight future (one per path) self._push_count = 0 self._step_count = 0 def _get_api(self): if self._api is None: from huggingface_hub import HfApi self._api = HfApi(token=self.token) return self._api def _ensure_repo(self): if not self._repo_ready: self._get_api().create_repo( repo_id=self.repo_id, repo_type="model", private=self.private, exist_ok=True, ) self._repo_ready = True def _resolve_checkpoint_file(self, name): """Resolve a checkpoint name to a single local weight file. ``"last"``/``"model_last"`` resolve to the newest rolling checkpoint via ``latest_complete_checkpoint`` -- in the default ``standard``/DCP format that is the ``model/last/`` directory (no ``model_last.pth`` exists), so a plain name probe would never find it. """ model_dir = os.path.join(self.trainer.cfg.save_path, "model") if name in ("last", "model_last"): ckpt = latest_complete_checkpoint(model_dir) if ckpt is None: raise FileNotFoundError(f"No rolling checkpoint under {model_dir}") return resolve_model_weight_file(str(ckpt)) candidates = [name] if not os.path.splitext(name)[1]: candidates += [f"{name}.pth"] for cand in candidates: path = cand if os.path.isabs(cand) else os.path.join(model_dir, cand) if os.path.isfile(path): return path split = split_checkpoint_weight_file(path) if os.path.isfile(split): return split if os.path.isdir(path): return resolve_model_weight_file(path) raise FileNotFoundError( f"PushToHub could not resolve checkpoint {name!r} under {model_dir}" ) def _push_raw(self, weight_file, *, background): """Upload a single raw checkpoint file; returns the hf:// URI.""" self._ensure_repo() path_in_repo = self.name or os.path.basename(weight_file) if background: # Serialize uploads to the SAME path so two in-flight commits cannot # race/clobber each other; keeps one future per path (bounded). prev = self._futures.get(path_in_repo) if prev is not None and not prev.done(): prev.result() future = self._get_api().upload_file( path_or_fileobj=weight_file, path_in_repo=path_in_repo, repo_id=self.repo_id, repo_type="model", revision=self.revision, run_as_future=background, ) if background: self._futures[path_in_repo] = future return f"hf://{self.repo_id}/{path_in_repo}" def _push_export(self, weight_file): """Upload a consolidated pimm-export artifact; returns the hf:// URI.""" import tempfile from pimm.export import push_to_hub, save_pretrained config_path = os.path.join(self.trainer.cfg.save_path, "config.py") with tempfile.TemporaryDirectory() as tmp: save_pretrained(weight_file, tmp, config_path=config_path) push_to_hub( tmp, self.repo_id, private=self.private, token=self.token, revision=self.revision, ) return f"hf://{self.repo_id}" def _push(self, name, *, background, weights_only=None): """Resolve ``name`` and push it; best-effort, never fatal. Returns success.""" if not is_main_process(): return False weights_only = self.weights_only if weights_only is None else weights_only try: weight_file = self._resolve_checkpoint_file(name) if weights_only: uri = self._push_raw(weight_file, background=background) else: uri = self._push_export(weight_file) # always synchronous verb = "queued" if (background and weights_only) else "pushed" self.trainer.logger.info(f"PushToHub: {verb} {weight_file} -> {self.repo_id}") self.trainer.logger.info(f"PushToHub: load with weight={uri}") return True except Exception as exc: # pragma: no cover - best-effort upload self.trainer.logger.warning(f"PushToHub: failed to push {name}: {exc}") return False def _drain_futures(self): """Block until all in-flight background uploads finish; best-effort.""" for future in self._futures.values(): try: future.result() except Exception as exc: # pragma: no cover - best-effort upload if is_main_process(): self.trainer.logger.warning(f"PushToHub: background upload failed: {exc}") self._futures.clear() def _squash(self): """Squash repo history to reclaim orphaned LFS blobs; best-effort, never fatal. Server-side metadata op (no re-upload), so it is cheap. In-flight background uploads are drained first so a concurrent commit cannot race the squash. """ if not is_main_process(): return self._drain_futures() try: self._get_api().super_squash_history( repo_id=self.repo_id, repo_type="model", branch=self.revision, ) self.trainer.logger.info(f"PushToHub: squashed history of {self.repo_id}") except Exception as exc: # pragma: no cover - best-effort self.trainer.logger.warning(f"PushToHub: failed to squash history: {exc}") def _record_push(self): """Count a successful periodic push and squash on the configured cadence.""" self._push_count += 1 if self.squash_every_n_pushes and self._push_count % self.squash_every_n_pushes == 0: self._squash()
[docs] def before_train(self): """Seed the step counter from resumed progress (mirrors the iteration saver).""" self._step_count = int(getattr(self.trainer, "global_step", 0) or ( self.trainer.start_epoch * len(self.trainer.train_loader) + self.trainer.start_iter ))
[docs] def after_step(self): """Push model_best on improvement and/or the rolling ckpt on a step cadence.""" self._step_count += 1 if self.on_best: best = float(self.trainer.best_metric_value) if best > self._seen_best: # Only advance _seen_best on a successful push, so a transient failure # (e.g. model_best.pth not written yet) is retried on a later step. if self._push("model_best", background=self.background, weights_only=True): self._seen_best = best self._record_push() if self.every_n_steps and self._step_count % self.every_n_steps == 0: if self._push("last", background=self.background, weights_only=True): self._record_push()
[docs] def after_epoch(self): """Push the rolling checkpoint every N epochs when configured.""" if not self.every_n_epochs: return if (self.trainer.epoch + 1) % self.every_n_epochs == 0: if self._push("last", background=self.background, weights_only=True): self._record_push()
[docs] def after_train(self): """Push the configured checkpoint when training finishes (blocking).""" if self.on_train_end: self._push(self.checkpoint, background=False) # Squash drains in-flight uploads first; otherwise just drain so the job # does not exit before background uploads finish. if self.squash_history or self.squash_every_n_pushes: self._squash() else: self._drain_futures()