Source code for pimm.utils.checkpoints

"""Checkpoint format, IO, and resume management for pimm training runtimes."""

from __future__ import annotations

import os
import shutil
from collections import OrderedDict

import torch
from torch.distributed.checkpoint.state_dict import (
    StateDictOptions,
    get_optimizer_state_dict,
    set_optimizer_state_dict,
)

import pimm.utils.comm as comm
from pimm.datasets.stateful import (
    assert_exact_dataloader_state_available,
    dataloader_state_dict,
    load_dataloader_state_dict,
)
from pimm.engines._train_utils import (
    TrainState,
    apply_train_state_to_trainer,
    capture_distributed_rng_state,
    capture_rng_state,
    restore_distributed_rng_state,
)
from pimm.observability import structured_logger as sl
from pimm.utils.comm import is_main_process, synchronize
from pimm.utils.path import (
    checkpoint_success_file as _dcp_success_file,
    is_complete_dcp_checkpoint,
    is_complete_split_checkpoint,
    latest_complete_checkpoint,
    resolve_model_weight_file,
    split_checkpoint_trainer_dir,
    split_checkpoint_weight_file,
)


HF_URI_PREFIX = "hf://"

# Serialized-weights filenames a pimm export may contain (one of them), in
# preference order. Centralized so download/upload/probe sites stay in sync.
EXPORT_WEIGHT_NAMES = ("model.safetensors", "model.bin")

# Config filename a pimm export writes (HF-idiomatic `config.json`) and the
# names it will read, in preference order. `training_config.json` is the legacy
# name kept for backward compatibility with already-published exports; the run
# dir may also carry resolved_config.json / model_config.json.
EXPORT_CONFIG_NAME = "config.json"
EXPORT_CONFIG_READ_NAMES = (
    "config.json",
    "training_config.json",
    "resolved_config.json",
    "model_config.json",
)


def configure_hf_cache():
    """Point Hugging Face's own cache env at pimm's cache, once, so EVERY hub
    download in this process shares one location -- pimm's `hf://` warm-start and
    `from_pretrained`, the `PushToHub` `HfApi`, and any direct `huggingface_hub`
    use. Sets ``HF_HUB_CACHE`` rather than threading ``cache_dir=`` per call.

    Precedence: an existing ``HF_HUB_CACHE``/``HF_HOME`` (respected, already
    shared) > ``$MODEL_DIR/hub`` > HF's default. Returns the active cache dir
    (or ``None``).
    """
    if os.environ.get("HF_HUB_CACHE") or os.environ.get("HF_HOME"):
        return os.environ.get("HF_HUB_CACHE")
    model_dir = os.environ.get("MODEL_DIR")
    target = os.path.join(model_dir, "hub") if model_dir else None
    if target:
        os.environ["HF_HUB_CACHE"] = target
    return target


def is_remote_weight(uri):
    """Return True if ``uri`` is a remote weight reference (currently ``hf://``)."""
    return isinstance(uri, str) and uri.startswith(HF_URI_PREFIX)


def parse_hf_uri(uri):
    """Parse an ``hf://`` URI into ``(repo_id, revision, filename)``.

    A Hub repo id is ``namespace/name`` (exactly one slash); an optional
    ``@revision`` attaches to it, and anything after is the in-repo file path.
    ``revision``/``filename`` are ``None``/``""`` when absent.
    """
    if not (isinstance(uri, str) and uri.startswith(HF_URI_PREFIX)):
        raise ValueError(f"Not an hf:// reference: {uri}")
    spec = uri[len(HF_URI_PREFIX):]
    revision = None
    if "@" in spec:
        repo_id, _, rest = spec.partition("@")
        revision, _, filename = rest.partition("/")
        revision = revision or None
    else:
        parts = spec.split("/", 2)
        if len(parts) >= 2 and parts[1]:
            repo_id = f"{parts[0]}/{parts[1]}"
            filename = parts[2] if len(parts) == 3 else ""
        else:
            repo_id = parts[0]
            filename = ""
    if not repo_id:
        raise ValueError(f"Malformed hf:// reference (missing repo id): {uri}")
    return repo_id, revision, filename


def resolve_remote_weight(uri):
    """Resolve an ``hf://`` weight reference to a local path, downloading on first use.

    Accepted forms (non-``hf://`` strings are returned unchanged)::

        hf://<repo_id>                     -> the repo's single weights file
        hf://<repo_id>/<path/to/file>      -> a single file (e.g. model_best.pth)
        hf://<repo_id>@<revision>/<file>   -> a file at a branch/tag/commit

    Downloads land in the Hugging Face cache (``HF_HUB_CACHE``/``HF_HOME``, or
    ``$MODEL_DIR/hub`` when neither is set); subsequent runs hit the cache. In a
    multi-rank job only each node's local rank 0 fetches; the others read the
    warm cache after a barrier, so the Hub is hit once per node (correct for
    node-local caches).
    """
    if not (isinstance(uri, str) and uri.startswith(HF_URI_PREFIX)):
        return uri
    repo_id, revision, filename = parse_hf_uri(uri)

    def _download():
        try:
            from huggingface_hub import HfApi, hf_hub_download
        except ImportError as exc:  # pragma: no cover - optional dependency
            raise ImportError(
                "huggingface_hub is required to load hf:// weights"
            ) from exc
        # Export pimm's cache to HF's env (process-wide sharing) AND pass it
        # explicitly -- HF reads HF_HUB_CACHE into a constant at import time, so
        # the explicit cache_dir keeps our own call correct regardless of timing.
        cache = configure_hf_cache()
        target = filename
        if not target:
            # Repo form (no file): pick the single weights file from the repo
            # listing -- prefer a consolidated export, else a raw checkpoint --
            # and fetch only that, so a repo that also holds a large raw .pth is
            # not pulled in full and a raw-only repo still resolves.
            target = _pick_repo_weight_file(
                HfApi().list_repo_files(repo_id=repo_id, revision=revision), repo_id
            )

        # Report progress via the global logger: a start line (ref + cache dir), a
        # heartbeat every 15s with bytes-so-far (so a multi-minute fetch never
        # looks hung), and a final line with the saved path, size, and elapsed.
        import glob
        import threading
        import time

        from huggingface_hub.constants import HF_HUB_CACHE
        from pimm.utils.logger import get_root_logger

        log = get_root_logger()
        ref = f"hf://{repo_id}/{target}" + (f"@{revision}" if revision else "")
        cache_dir = cache or HF_HUB_CACHE
        blob_dir = os.path.join(cache_dir, f"models--{repo_id.replace('/', '--')}", "blobs")

        # Total size up front (one cheap HEAD) so the heartbeat can show %/ETA.
        total_mb = None
        try:
            from huggingface_hub import get_hf_file_metadata, hf_hub_url

            meta = get_hf_file_metadata(hf_hub_url(repo_id, target, revision=revision))
            if meta.size:
                total_mb = meta.size / 1e6
        except Exception:  # pragma: no cover - best-effort metadata
            pass

        total_str = f" ({total_mb:.0f} MB)" if total_mb else ""
        log.info(f"Fetching weight {ref}{total_str}  (cache dir: {cache_dir}) ...")

        def _partial_mb():
            """Best-effort size of the in-flight `.incomplete` blob, in MB."""
            try:
                parts = glob.glob(os.path.join(blob_dir, "*.incomplete"))
                if parts:
                    return max(os.path.getsize(p) for p in parts) / 1e6
            except OSError:
                pass
            return 0.0

        start = time.time()
        done = threading.Event()
        last = {"t": start, "mb": 0.0}

        def _heartbeat():
            # hf's own tqdm goes to stderr and is invisible in captured/non-TTY
            # job logs, so surface real progress (MB / % / MB-s / ETA) here.
            while not done.wait(10):
                now = time.time()
                mb = _partial_mb()
                speed = (mb - last["mb"]) / max(now - last["t"], 1e-9)
                last["t"], last["mb"] = now, mb
                if total_mb and total_mb > 0:
                    pct = 100.0 * mb / total_mb
                    eta = (total_mb - mb) / speed if speed > 1e-6 else None
                    eta_str = f", ETA {eta:.0f}s" if eta is not None else ""
                    log.info(
                        f"  ... {target}: {mb:.0f}/{total_mb:.0f} MB "
                        f"({pct:.0f}%), {speed:.0f} MB/s{eta_str}"
                    )
                else:
                    log.info(
                        f"  ... downloading {target}: {mb:.0f} MB, "
                        f"{speed:.0f} MB/s ({int(now - start)}s)"
                    )

        hb = threading.Thread(target=_heartbeat, daemon=True)
        hb.start()
        try:
            path = hf_hub_download(
                repo_id=repo_id, filename=target, revision=revision, cache_dir=cache
            )
        finally:
            done.set()
        elapsed = time.time() - start
        try:
            size_mb = os.path.getsize(path) / 1e6
        except OSError:
            size_mb = float("nan")
        cached = " (from cache)" if elapsed < 1.0 else ""
        log.info(
            f"Resolved weight {ref}{cached}\n"
            f"  saved to: {path}\n"
            f"  size:     {size_mb:.1f} MB    elapsed: {elapsed:.0f}s"
        )
        return path

    if comm.get_world_size() > 1:
        # Only each node's local lead hits the Hub (one download per node-local
        # cache). Every other rank then receives the resolved local path via
        # all_gather and reads the warm cache directly -- it never calls the Hub
        # (no list_repo_files, no HEAD), so there is exactly one set of network
        # requests per node instead of one per rank. all_gather also acts as the
        # barrier: it returns only once the lead has finished downloading.
        local_path = _download() if comm.get_local_rank() == 0 else None
        gathered: list = [None] * comm.get_world_size()
        # Gather over GLOO (CPU), not NCCL: the non-lead ranks block here for the
        # whole download, and an NCCL collective would trip its watchdog timeout
        # on a multi-minute fetch (the classic "hang"). GLOO has no such watchdog.
        torch.distributed.all_gather_object(
            gathered, local_path, group=comm._get_global_gloo_group()
        )
        # The cache path is identical across nodes (same HF_HUB_CACHE + repo +
        # commit), and each node's lead populated it locally, so any lead's path
        # resolves on every rank.
        resolved = next((p for p in gathered if p), None)
        if resolved is None:
            raise RuntimeError(f"hf:// download produced no path on any rank: {uri}")
        return resolved
    return _download()


def _pick_repo_weight_file(files, repo_id):
    """Choose the one weights file to load from a Hub repo's file listing."""
    for name in EXPORT_WEIGHT_NAMES:
        if name in files:
            return name
    pths = [f for f in files if f.endswith(".pth") and "/" not in f]
    for name in ("model_best.pth", "model_last.pth"):
        if name in pths:
            return name
    if len(pths) == 1:
        return pths[0]
    raise FileNotFoundError(
        f"No loadable weights file in hf://{repo_id} (saw: {sorted(files)}). "
        "Use the explicit file form hf://<repo>/<file> to disambiguate."
    )


def exported_weights_file(path):
    """Return the weights file inside a pimm-export directory, or None.

    A pimm export (``save_pretrained``) writes the serialized tensors as
    ``model.safetensors`` (default) or ``model.bin``; this probes for them.
    """
    for name in EXPORT_WEIGHT_NAMES:
        candidate = os.path.join(str(path), name)
        if os.path.isfile(candidate):
            return candidate
    return None


def load_weight_state(path, map_location):
    """Load a raw checkpoint or a ``.safetensors`` file into a state mapping.

    ``safetensors`` needs a device string; honor an explicit string
    ``map_location`` (e.g. ``"cpu"``) and otherwise fall back to GPU-if-available
    (mirroring the torch ``map_location`` lambda used for resume loads).
    """
    if str(path).endswith(".safetensors"):
        from safetensors.torch import load_file

        if isinstance(map_location, str):
            device = map_location
        else:
            device = "cuda" if torch.cuda.is_available() else "cpu"
        return load_file(str(path), device=device)
    return torch.load(path, map_location=map_location, weights_only=False)


def _distributed_object_state(local_state):
    """Gather one Python state object per rank into a checkpointable wrapper."""
    if torch.distributed.is_available() and torch.distributed.is_initialized():
        world_size = torch.distributed.get_world_size()
        states = [None for _ in range(world_size)]
        torch.distributed.all_gather_object(states, local_state)
        return {
            "_pimm_distributed_state": True,
            "world_size": world_size,
            "states": states,
        }
    return local_state


def local_object_state(state, *, strict=True):
    """Return the current rank's state from a distributed object wrapper."""
    if not isinstance(state, dict) or not state.get("_pimm_distributed_state"):
        return state
    if torch.distributed.is_available() and torch.distributed.is_initialized():
        world_size = torch.distributed.get_world_size()
        rank = torch.distributed.get_rank()
    else:
        world_size = 1
        rank = 0
    saved_world_size = int(state.get("world_size", len(state.get("states", []))))
    if strict and saved_world_size != world_size:
        raise ValueError(
            f"State was saved with world_size={saved_world_size}, "
            f"but current world_size={world_size}."
        )
    states = state.get("states", [])
    if rank >= len(states):
        if strict:
            raise ValueError(f"No distributed state available for rank {rank}.")
        rank = 0
    return states[rank]


def build_logger_state(
    trainer,
    *,
    checkpoint_global_step=None,
    initialize_wandb=False,
):
    """Build checkpointable logging state, using the active W&B run when present."""
    use_wandb = bool(_cfg_get(trainer.cfg, "use_wandb", False))
    wandb_state = {
        "group": _cfg_get(trainer.cfg, "wandb_group", None),
        "run_name": _cfg_get(trainer.cfg, "wandb_run_name", None),
        "run_id": _cfg_get(trainer.cfg, "wandb_run_id", None),
        "job_type": _cfg_get(trainer.cfg, "wandb_job_type", None),
        "resume": _cfg_get(trainer.cfg, "wandb_resume", None),
        "resume_step": None,
        "step_metric": "train/global_step",
        "step_offset": _cfg_get(trainer.cfg, "log_step_offset", 0),
        "checkpoint_global_step": checkpoint_global_step,
    }
    if use_wandb and initialize_wandb:
        writer = getattr(trainer, "writer", None)
        local_state = None
        if is_main_process() and writer is not None:
            checkpoint_state = getattr(writer, "checkpoint_state", None)
            if checkpoint_state is not None:
                local_state = checkpoint_state()
        gathered = comm.all_gather(local_state)
        active_state = next((state for state in gathered if state is not None), None)
        if active_state is None:
            raise RuntimeError(
                "W&B checkpointing requires the main process writer to expose "
                "checkpoint_state()."
            )
        wandb_state.update(active_state)
    return {
        "backend": "wandb" if use_wandb else "tensorboard",
        "wandb": wandb_state,
    }


def configure_logger_from_checkpoint(trainer, checkpoint):
    """Configure the lazy W&B writer to rewind to checkpoint history state."""
    if not _cfg_get(trainer.cfg, "use_wandb", False):
        return
    if _cfg_get(trainer.cfg, "wandb_fresh_run_on_resume", False):
        trainer.logger.info(
            "Starting a fresh W&B run for this checkpoint continuation; "
            "model and trainer state still resume exactly."
        )
        return
    writer = getattr(trainer, "writer", None)
    configure_resume = getattr(writer, "resume_from_checkpoint", None)
    if configure_resume is None:
        return

    logger_state = checkpoint.get("logger", {})
    wandb_state = logger_state.get("wandb", {}) if isinstance(logger_state, dict) else {}
    if not isinstance(wandb_state, dict):
        wandb_state = {}
    run_id = wandb_state.get("run_id", checkpoint.get("wandb_run_id"))
    resume_step = wandb_state.get(
        "resume_step",
        checkpoint.get("wandb_resume_step"),
    )
    if run_id and resume_step is not None:
        configure_resume({"run_id": run_id, "resume_step": resume_step})
        return
    if resume_step is not None:
        raise ValueError(
            "Checkpoint contains a W&B resume_step without a W&B run_id."
        )
    trainer.logger.warning(
        "Checkpoint has no W&B rewind state; falling back to configured W&B "
        "resume behavior. Optimizer steps replayed after rollback may appear "
        "more than once."
    )


def build_checkpoint_payload(
    trainer,
    *,
    distributed_rng=False,
    initialize_logger=False,
):
    """Build the structured checkpoint payload consumed by checkpoint loads."""
    train_state = TrainState.from_trainer(trainer)
    local_dataloader_state = dataloader_state_dict(trainer.train_loader)
    assert_exact_dataloader_state_available(
        local_dataloader_state,
        loader=trainer.train_loader,
        iter_in_epoch=train_state.iter_in_epoch,
    )
    train_state.dataloader_state = (
        _distributed_object_state(local_dataloader_state)
        if distributed_rng
        else local_dataloader_state
    )
    rng_state = (
        capture_distributed_rng_state()
        if distributed_rng
        else capture_rng_state()
    )
    train_state.rng_state = rng_state
    model_state = trainer.model.state_dict()
    optimizer_state = get_optimizer_state_dict(
        trainer.model,
        trainer.optimizer,
        options=StateDictOptions(),
    )
    scheduler_state = trainer.scheduler.state_dict()
    scaler_state = (
        trainer.scaler.state_dict()
        if getattr(trainer, "scaler", None) is not None
        else None
    )
    world_size = comm.get_world_size()
    distributed_backend = (
        torch.distributed.get_backend()
        if torch.distributed.is_available() and torch.distributed.is_initialized()
        else None
    )
    logger_state = build_logger_state(
        trainer,
        checkpoint_global_step=train_state.global_step,
        initialize_wandb=initialize_logger,
    )
    return {
        "schema": "pimm.trainer_checkpoint",
        "version": 3,
        "checkpoint_version": 3,
        "model": {"state_dict": model_state},
        "optimizer": {
            "state_dict": optimizer_state,
            "class": trainer.optimizer.__class__.__name__,
            "format": "torch.distributed.checkpoint.state_dict",
        },
        "scheduler": {
            "state_dict": scheduler_state,
            "class": trainer.scheduler.__class__.__name__,
            "total_steps": getattr(trainer.scheduler, "total_steps", None),
        },
        "scaler": {
            "enabled": bool(getattr(trainer.cfg, "enable_amp", False)),
            "state_dict": scaler_state,
        },
        "dataloader": {
            "backend": trainer.train_loader.__class__.__name__,
            "state": train_state.dataloader_state,
            "world_size": world_size,
            "batch_size_per_rank": getattr(trainer.cfg, "batch_size_per_gpu", None),
            "num_workers": getattr(trainer.cfg, "num_worker_per_gpu", None),
            "drop_last": getattr(trainer.train_loader, "drop_last", None),
        },
        "rng": {
            "world_size": world_size,
            "state": rng_state,
        },
        "trainer": {
            "epoch": train_state.epoch,
            "iter_in_epoch": train_state.iter_in_epoch,
            "global_step": train_state.global_step,
            "samples_seen": train_state.samples_seen,
            "best_metric_value": train_state.best_metric_value,
        },
        "logger": logger_state,
        "distributed": {
            "world_size": world_size,
            "backend": distributed_backend,
            "rank_order": list(range(world_size)),
        },
    }


def empty_checkpoint_payload(trainer):
    """Build an empty typed payload for DCP load to fill in place."""
    payload = build_checkpoint_payload(trainer, distributed_rng=True)
    payload["trainer"]["best_metric_value"] = -float("inf")
    payload["trainer"].update(
        {"epoch": 0, "iter_in_epoch": 0, "global_step": 0, "samples_seen": 0}
    )
    return payload


def checkpoint_model_state_dict(checkpoint):
    """Extract model weights from structured or legacy checkpoint formats."""
    if isinstance(checkpoint, dict):
        if isinstance(checkpoint.get("model"), dict) and "state_dict" in checkpoint["model"]:
            return checkpoint["model"]["state_dict"]
        if "state_dict" in checkpoint:
            return checkpoint["state_dict"]
    return checkpoint


def checkpoint_optimizer_state_dict(checkpoint):
    """Extract optimizer state from structured or legacy checkpoints."""
    optimizer = checkpoint.get("optimizer", None)
    if isinstance(optimizer, dict) and "state_dict" in optimizer:
        return optimizer["state_dict"]
    return optimizer


def checkpoint_scheduler_state_dict(checkpoint):
    """Extract scheduler state from structured or legacy checkpoints."""
    scheduler = checkpoint.get("scheduler", None)
    if isinstance(scheduler, dict) and "state_dict" in scheduler:
        return scheduler["state_dict"]
    return scheduler


def checkpoint_scaler_state_dict(checkpoint):
    """Extract AMP scaler state from structured or legacy checkpoints."""
    scaler = checkpoint.get("scaler", None)
    if isinstance(scaler, dict) and "state_dict" in scaler:
        return scaler["state_dict"]
    return scaler


def checkpoint_dataloader_state(checkpoint, train_state=None):
    """Extract dataloader resume state, preferring parsed TrainState."""
    if train_state is not None and train_state.dataloader_state is not None:
        return train_state.dataloader_state
    dataloader = checkpoint.get("dataloader", None)
    if isinstance(dataloader, dict) and "state" in dataloader:
        return dataloader["state"]
    return dataloader


def checkpoint_rng_state(checkpoint, train_state=None):
    """Extract RNG resume state, preferring parsed TrainState."""
    if train_state is not None and train_state.rng_state is not None:
        return train_state.rng_state
    rng = checkpoint.get("rng", None)
    if isinstance(rng, dict) and "state" in rng:
        return rng["state"]
    return checkpoint.get("rng_state", None)


def checkpoint_train_state(checkpoint):
    """Parse structured trainer state, returning None for legacy checkpoints."""
    if checkpoint.get("train_state", None) is not None:
        return TrainState.from_state_dict(checkpoint["train_state"])
    trainer_state = checkpoint.get("trainer", None)
    if not isinstance(trainer_state, dict):
        return None
    dataloader_state = checkpoint_dataloader_state(checkpoint)
    rng_state = checkpoint_rng_state(checkpoint)
    dataloader = checkpoint.get("dataloader", {})
    world_size = (
        int(dataloader.get("world_size", comm.get_world_size()))
        if isinstance(dataloader, dict)
        else comm.get_world_size()
    )
    return TrainState(
        schema_version=int(checkpoint.get("checkpoint_version", checkpoint.get("version", 0)) or 0),
        epoch=int(trainer_state.get("epoch", 0)),
        iter_in_epoch=int(trainer_state.get("iter_in_epoch", trainer_state.get("iteration", 0))),
        global_step=int(trainer_state.get("global_step", 0)),
        samples_seen=int(trainer_state.get("samples_seen", 0)),
        world_size=world_size,
        batch_size_per_rank=(
            dataloader.get("batch_size_per_rank") if isinstance(dataloader, dict) else None
        ),
        best_metric_value=trainer_state.get("best_metric_value"),
        rng_state=rng_state,
        dataloader_state=dataloader_state,
    )


def build_trainer_state_payload(checkpoint):
    """Return checkpoint state with model weights removed."""
    return {key: value for key, value in checkpoint.items() if key != "model"}


def empty_trainer_state_payload(trainer):
    """Build an empty typed payload for split-checkpoint trainer state loading."""
    return build_trainer_state_payload(empty_checkpoint_payload(trainer))


def atomic_torch_save(payload, filename):
    """Save a torch checkpoint via a temp file and one-level backup."""
    tmp = filename + ".tmp"
    prev = filename + ".prev"
    torch.save(payload, tmp)
    with open(tmp, "rb") as handle:
        os.fsync(handle.fileno())
    if os.path.exists(prev):
        os.remove(prev)
    if os.path.exists(filename):
        os.replace(filename, prev)
    os.replace(tmp, filename)


def save_model_weights_file(payload, filename):
    """Save just the model weights as a portable single-file checkpoint.

    Produces the same ``{"state_dict": ...}`` layout as the split-checkpoint
    weight file, so it loads identically via ``checkpoint_model_state_dict`` and
    a plain ``torch.load`` (used for ``model_best.pth`` and iter snapshots).
    """
    atomic_torch_save({"state_dict": checkpoint_model_state_dict(payload)}, filename)


def save_dcp_checkpoint(payload, checkpoint_dir):
    """Save a distributed checkpoint directory with atomic publish semantics."""
    import torch.distributed.checkpoint as dcp

    tmp_dir = checkpoint_dir + ".tmp"
    prev_dir = checkpoint_dir + ".prev"
    if is_main_process():
        if os.path.exists(tmp_dir):
            shutil.rmtree(tmp_dir)
    synchronize()

    dcp.save(payload, checkpoint_id=tmp_dir)
    if is_main_process():
        with open(_dcp_success_file(tmp_dir), "w", encoding="utf-8") as handle:
            handle.write("ok\n")
        if os.path.exists(prev_dir):
            shutil.rmtree(prev_dir)
        if os.path.exists(checkpoint_dir):
            os.replace(checkpoint_dir, prev_dir)
        os.replace(tmp_dir, checkpoint_dir)
        if os.path.exists(prev_dir):
            shutil.rmtree(prev_dir)
    synchronize()


def save_split_checkpoint(payload, checkpoint_dir):
    """Save model weights plus DCP trainer state without duplicating tensors."""
    tmp_dir = checkpoint_dir + ".tmp"
    prev_dir = checkpoint_dir + ".prev"
    if is_main_process():
        if os.path.exists(tmp_dir):
            shutil.rmtree(tmp_dir)
        os.makedirs(tmp_dir, exist_ok=True)
        atomic_torch_save(
            {"state_dict": checkpoint_model_state_dict(payload)},
            split_checkpoint_weight_file(tmp_dir),
        )
    synchronize()

    save_dcp_checkpoint(
        build_trainer_state_payload(payload),
        split_checkpoint_trainer_dir(tmp_dir),
    )
    if is_main_process():
        with open(_dcp_success_file(tmp_dir), "w", encoding="utf-8") as handle:
            handle.write("ok\n")
        if os.path.exists(prev_dir):
            shutil.rmtree(prev_dir)
        if os.path.exists(checkpoint_dir):
            os.replace(checkpoint_dir, prev_dir)
        os.replace(tmp_dir, checkpoint_dir)
        if os.path.exists(prev_dir):
            shutil.rmtree(prev_dir)
    synchronize()


def load_dcp_trainer_state(checkpoint_dir, trainer):
    """Load a complete trainer-state DCP into a typed placeholder payload."""
    import torch.distributed.checkpoint as dcp

    if not is_complete_dcp_checkpoint(checkpoint_dir):
        raise FileNotFoundError(f"Incomplete DCP checkpoint: {checkpoint_dir}")
    payload = empty_trainer_state_payload(trainer)
    from torch.distributed.checkpoint.default_planner import DefaultLoadPlanner
    dcp.load(payload, checkpoint_id=checkpoint_dir,
             planner=DefaultLoadPlanner(allow_partial_load=True))
    return payload


def load_dcp_checkpoint(checkpoint_dir, trainer):
    """Load a complete full DCP checkpoint into a typed placeholder payload."""
    import torch.distributed.checkpoint as dcp

    if not is_complete_dcp_checkpoint(checkpoint_dir):
        raise FileNotFoundError(f"Incomplete DCP checkpoint: {checkpoint_dir}")
    payload = empty_checkpoint_payload(trainer)
    from torch.distributed.checkpoint.default_planner import DefaultLoadPlanner
    dcp.load(payload, checkpoint_id=checkpoint_dir,
             planner=DefaultLoadPlanner(allow_partial_load=True))
    return payload


def load_split_checkpoint(checkpoint_dir, trainer, map_location):
    """Load a split checkpoint for exact resume."""
    if not is_complete_split_checkpoint(checkpoint_dir):
        raise FileNotFoundError(f"Incomplete split checkpoint: {checkpoint_dir}")
    checkpoint = load_dcp_trainer_state(split_checkpoint_trainer_dir(checkpoint_dir), trainer)
    weight_checkpoint = torch.load(
        split_checkpoint_weight_file(checkpoint_dir),
        map_location=map_location,
        weights_only=False,
    )
    checkpoint["model"] = {"state_dict": checkpoint_model_state_dict(weight_checkpoint)}
    return checkpoint


def _cfg_get(cfg, key, default=None):
    """Read config values from dict-like or attribute-style config objects."""
    if cfg is None:
        return default
    if isinstance(cfg, dict):
        return cfg.get(key, default)
    if hasattr(cfg, "get"):
        try:
            return cfg.get(key, default)
        except TypeError:
            pass
    return getattr(cfg, key, default)


def _summarize_keys(keys, *, depth=3, examples=2, max_groups=12):
    """Collapse a list of dotted state-dict keys into a few grouped, counted lines.

    A 500-key mismatch on a deep model is unreadable when dumped in full and
    near-identical across ranks. Grouping by a shallow prefix (e.g.
    ``model.backbone.enc``) turns it into a handful of "(N): example, ..." lines
    that still name the offending subtree.
    """
    strip_module = lambda s: s[7:] if s.startswith("module.") else s  # noqa: E731
    groups: "OrderedDict[str, list]" = OrderedDict()
    for key in keys:
        bare = strip_module(key)
        prefix = ".".join(bare.split(".")[:depth])
        groups.setdefault(prefix, []).append(bare)
    lines = []
    for prefix in sorted(groups)[:max_groups]:
        members = groups[prefix]
        ex = ", ".join(members[:examples])
        more = f", +{len(members) - examples} more" if len(members) > examples else ""
        lines.append(f"    {prefix}.* ({len(members)}): {ex}{more}")
    if len(groups) > max_groups:
        lines.append(f"    ... and {len(groups) - max_groups} more group(s)")
    return "\n".join(lines)


[docs] class CheckpointManager: """Own checkpoint format, save/load backends, and trainer resume semantics.""" def __init__(self, trainer): self.trainer = trainer def _checkpoint_format(self, hook_backend=None): """Resolve the on-disk checkpoint format.""" aliases = {"dcp": "standard", "torch": "legacy"} fmt = _cfg_get(self.trainer.cfg, "checkpoint_format", None) if fmt is None: fmt = hook_backend fmt = str(fmt or "standard").lower() fmt = aliases.get(fmt, fmt) if fmt not in ("standard", "legacy"): raise ValueError( "checkpoint_format must be 'standard' or 'legacy' " f"(or the deprecated 'dcp'/'torch'), got {fmt!r}" ) return fmt def _write_checkpoint( self, payload, *, fmt, is_best, step_count, save_freq, save_iter_checkpoints ): """Write a built payload in the resolved format. Must be called on ALL ranks: the ``standard`` format performs a collective DCP save. Rank-0-only side artifacts (the best/iter weight files and the legacy single file) are guarded internally. """ model_dir = os.path.join(self.trainer.cfg.save_path, "model") best_file = os.path.join(model_dir, "model_best.pth") do_iter_snapshot = bool( save_iter_checkpoints and save_freq and step_count and step_count % save_freq == 0 ) if fmt == "standard": last_dir = os.path.join(model_dir, "last") if is_main_process(): self.trainer.logger.info( f"Saving checkpoint to: {last_dir} (weights.pth + trainer/ DCP)" ) save_split_checkpoint(payload, last_dir) # collective: all ranks if is_main_process(): if is_best: save_model_weights_file(payload, best_file) if do_iter_snapshot: save_model_weights_file( payload, os.path.join(model_dir, f"iter_{step_count}.pth") ) return # legacy: single monolithic file, written by rank 0 only if is_main_process(): filename = os.path.join(model_dir, "model_last.pth") self.trainer.logger.info("Saving checkpoint to: " + filename) atomic_torch_save(payload, filename) if is_best: shutil.copyfile(filename, best_file) if do_iter_snapshot: shutil.copyfile( filename, os.path.join(model_dir, f"iter_{step_count}.pth") )
[docs] @sl.log_trace_span("checkpoint_save") def save_epoch_checkpoint(self, *, is_best=False, step_count=0, save_freq=None): """Save an epoch/metric-oriented checkpoint. Must run on all ranks.""" sl.add_step_tag("checkpoint") fmt = self._checkpoint_format() payload = build_checkpoint_payload( self.trainer, distributed_rng=True, initialize_logger=True, ) self._write_checkpoint( payload, fmt=fmt, is_best=is_best, step_count=step_count, save_freq=save_freq, save_iter_checkpoints=bool(save_freq), )
[docs] @sl.log_trace_span("checkpoint_save") def save_iteration_checkpoint( self, *, backend=None, is_best=False, step_count=0, save_freq=None, save_iter_checkpoints=False, ): """Save an iteration-oriented checkpoint. Must run on all ranks.""" sl.add_step_tag("checkpoint") fmt = self._checkpoint_format(backend) payload = build_checkpoint_payload( self.trainer, distributed_rng=True, initialize_logger=True, ) self._write_checkpoint( payload, fmt=fmt, is_best=is_best, step_count=step_count, save_freq=save_freq, save_iter_checkpoints=save_iter_checkpoints, )
[docs] @sl.log_trace_span("checkpoint_load") def load_weight_and_resume( self, *, keywords="", replacement=None, rules=None, strict=False ): """Load configured weights and restore training state when cfg.resume is true. Pass ``rules`` (a list of ``(keywords, replacement)`` pairs) to apply several key rewrites in a single load; the scalar ``keywords``/``replacement`` form is kept for back-compat and is treated as a one-rule list. """ if rules is None: rules = [(keywords, replacement if replacement is not None else keywords)] self.trainer.logger.info("=> Loading checkpoint & weight ...") weight_path = self.trainer.cfg.weight if isinstance(weight_path, str) and weight_path.startswith(HF_URI_PREFIX): if self.trainer.cfg.resume: raise ValueError( f"resume=True is not supported with an hf:// weight ({weight_path}). " "The Hub holds model weights only, not trainer state " "(optimizer/scheduler/step/dataloader is " "never uploaded). Set resume=False to warm-start a new run from these " "weights, or point `weight` at a local checkpoint dir (.../model/last) " "to resume the original run." ) self.trainer.logger.info(f"Resolving remote weight: {weight_path}") weight_path = resolve_remote_weight(weight_path) if weight_path and (os.path.isfile(weight_path) or os.path.isdir(weight_path)): self.trainer.logger.info(f"Loading weight at: {weight_path}") checkpoint = self._load_checkpoint(weight_path) self._load_model_weights(checkpoint, rules=rules, strict=strict) if self.trainer.cfg.resume: self.resume_training_state(checkpoint) return message = f"No weight found at: {weight_path}" # A non-empty weight path that does not resolve is always an error: the # user asked to load weights, so silently training from random init would # hide a typo'd/moved checkpoint. Only the genuinely-unset case is a no-op # (unless resuming, which requires a checkpoint). if weight_path or self.trainer.cfg.resume: raise FileNotFoundError(message) self.trainer.logger.info(message)
def _load_checkpoint(self, weight_path): """Load a direct, split, or directory checkpoint reference.""" map_location = (lambda storage, loc: storage.cuda()) if torch.cuda.is_available() else "cpu" if os.path.isdir(weight_path): exported = exported_weights_file(weight_path) if exported is not None and not self.trainer.cfg.resume: return load_weight_state(exported, map_location) if is_complete_split_checkpoint(weight_path): if self.trainer.cfg.resume: return load_split_checkpoint(weight_path, self.trainer, map_location) weight_file = resolve_model_weight_file(weight_path) return load_weight_state(weight_file, map_location) if is_complete_dcp_checkpoint(weight_path): return load_dcp_checkpoint(weight_path, self.trainer) if self.trainer.cfg.resume: raise FileNotFoundError(f"Incomplete checkpoint directory: {weight_path}") weight_file = resolve_model_weight_file(weight_path) return load_weight_state(weight_file, map_location) return load_weight_state(weight_path, map_location) def _load_model_weights(self, checkpoint, *, rules=None, strict=False): """Load checkpoint model weights, applying all keyword-rewrite rules in one pass. ``rules`` is a list of ``(keywords, replacement)`` pairs. Each source key is rewritten by the *most specific* (longest-keyword) rule whose (module-stripped) keyword it starts with, and every key lands in a single state dict. Because there is one ``load_state_dict``, the reported missing/unexpected keys are the truth about the final mapping -- unlike stacking one loader per rule, where each rule's ``load_state_dict`` flags the keys another rule owns as "missing". Matching by longest keyword (not input order) means rules can be passed as a plain ``{keyword: replacement}`` dict without order-dependent surprises when two keywords overlap (e.g. ``decoder`` vs ``decoder.cls_pred``). """ rules = rules or [("", "")] strip_module = lambda s: s[7:] if s.startswith("module.") else s # noqa: E731 norm_rules = sorted( ((strip_module(kw), strip_module(repl)) for kw, repl in rules), key=lambda r: len(r[0]), reverse=True, ) if is_main_process(): for kw, repl in rules: self.trainer.logger.info( f"Weight key rule: {kw or '<all>'!r} -> {repl or '<unchanged>'!r}" ) weight = OrderedDict() ddp = comm.get_world_size() > 1 for key, value in checkpoint_model_state_dict(checkpoint).items(): bare = strip_module(key) for kw, repl in norm_rules: if kw and bare.startswith(kw): bare = repl + bare[len(kw):] break weight["module." + bare if ddp else bare] = value # Skip shape-mismatched keys (load_state_dict(strict=False) still raises on # these). Lets a checkpoint warm-start a model whose architecture changed # shape -- e.g. different num_classes (class head) or num_queries (query # embeddings); the mismatched tensors stay at their init. mismatched = [] if not strict: model_sd = self.trainer.model.state_dict() mismatched = [ k for k, v in weight.items() if k in model_sd and tuple(model_sd[k].shape) != tuple(v.shape) ] for k in mismatched: del weight[k] if mismatched and is_main_process(): self.trainer.logger.info( f"Skipped {len(mismatched)} shape-mismatched key(s), kept at init:\n" f"{_summarize_keys(mismatched)}" ) missing, unexpected = self.trainer.model.load_state_dict(weight, strict=strict) n_model = len(self.trainer.model.state_dict()) n_loaded = n_model - len(missing) # `missing` from load_state_dict includes the shape-skipped keys above; those # are reported separately, so don't double-count them as a problem. real_missing = [k for k in missing if k not in set(mismatched)] if is_main_process(): self.trainer.logger.info( f"Loaded {n_loaded}/{n_model} model params " f"(missing: {len(missing)}, unexpected: {len(unexpected)})" ) # One combined pass -> these are genuinely absent from the checkpoint. # Surface at WARNING with a grouped summary instead of a per-key wall. if real_missing: self.trainer.logger.warning( f"{len(real_missing)} model param(s) not in checkpoint, left at " f"init:\n{_summarize_keys(real_missing)}" ) if unexpected: self.trainer.logger.info( f"{len(unexpected)} checkpoint key(s) unused by the model:\n" f"{_summarize_keys(unexpected)}" ) # Guard against rules that matched nothing: with strict=False this would # otherwise leave the whole model randomly initialized while training # proceeds, with only an INFO line to distinguish it from a real load. if n_model and n_loaded == 0: raise RuntimeError( f"Checkpoint load matched 0 of {n_model} model parameters " f"(rules={rules!r}). The model would train from random init. " f"Fix the keyword/replacement rules, or set strict=True to find the " f"mismatch." )
[docs] def resume_training_state(self, checkpoint): """Restore structured or legacy optimizer, scheduler, RNG, and cursor state.""" configure_logger_from_checkpoint(self.trainer, checkpoint) strict_state = self.trainer.cfg.get("resume_strict_state", True) iter_per_epoch = len(self.trainer.train_loader) train_state = checkpoint_train_state(checkpoint) if train_state is not None: dataloader_state = checkpoint_dataloader_state(checkpoint, train_state) train_state.dataloader_state = dataloader_state # Decide whether to drop the torchdata StatefulDataLoader cursor BEFORE # extracting it. That cursor asserts (lazily, on the first __iter__) on # ANY change to the world_size or num_workers it was saved with, and # local_object_state() below would itself raise on a world_size change # under strict resume. So when the world_size or num_workers changed # (or the resume is explicitly non-strict) we drop the cursor and # restart the resumed epoch from its first batch -- model / optimizer / # scheduler / global_step still restore below, so at most a sub-epoch of # data order is replayed. This makes resharding across GPU/worker counts # automatic without requiring resume_strict_state=False. skip_cursor, reason = False, "" if not strict_state: skip_cursor, reason = True, "resume_strict_state=False" elif isinstance(dataloader_state, dict) and dataloader_state.get( "_pimm_distributed_state" ): saved_ws = int( dataloader_state.get( "world_size", len(dataloader_state.get("states", [])) ) ) cur_ws = comm.get_world_size() if saved_ws != cur_ws: skip_cursor, reason = True, f"world_size {saved_ws}->{cur_ws}" if not skip_cursor: saved_dl = checkpoint.get("dataloader", {}) saved_workers = saved_dl.get("num_workers") if isinstance(saved_dl, dict) else None cur_workers = self.trainer.cfg.get("num_worker_per_gpu", None) if ( saved_workers is not None and cur_workers is not None and int(saved_workers) != int(cur_workers) ): skip_cursor, reason = True, f"num_workers {saved_workers}->{cur_workers}" # Extract this rank's cursor. When we are going to drop it anyway, load # non-strictly so a world_size change does not raise here. local_dataloader_state = local_object_state( dataloader_state, strict=strict_state and not skip_cursor, ) if skip_cursor and local_dataloader_state: self.trainer.logger.warning( f"Skipping dataloader-cursor restore ({reason}); restarting the " "resumed epoch from its first batch." ) local_dataloader_state = None apply_train_state_to_trainer(self.trainer, train_state) if train_state.iter_in_epoch > 0: if not local_dataloader_state: self.trainer.logger.warning( "Checkpoint is mid-epoch but has no dataloader state; " "resuming from the beginning of the saved epoch and " "replaying already-completed batches." ) self.trainer.start_iter = 0 self.trainer.global_step = self.trainer.start_epoch * iter_per_epoch else: load_dataloader_state_dict( self.trainer.train_loader, local_dataloader_state, strict=strict_state, ) rng_state = checkpoint_rng_state(checkpoint, train_state) restore_distributed_rng_state(rng_state, strict=strict_state) self.trainer.logger.info( "Resuming train from structured state: " f"epoch={self.trainer.start_epoch}, " f"iter={self.trainer.start_iter}, " f"global_step={self.trainer.global_step}" ) else: self._resume_legacy_training_state(checkpoint, iter_per_epoch) checkpoint_trainer_state = checkpoint.get("trainer", {}) if ( isinstance(checkpoint_trainer_state, dict) and "best_metric_value" in checkpoint_trainer_state ): self.trainer.best_metric_value = checkpoint_trainer_state["best_metric_value"] else: self.trainer.best_metric_value = checkpoint.get( "best_metric_value", self.trainer.best_metric_value ) optimizer_state = checkpoint_optimizer_state_dict(checkpoint) if optimizer_state is not None: self.load_optimizer_state(optimizer_state) else: self.trainer.logger.info("No optimizer state found in checkpoint.") scheduler_state = checkpoint_scheduler_state_dict(checkpoint) if scheduler_state is not None: self.trainer.scheduler.load_state_dict(scheduler_state) scaler_state = checkpoint_scaler_state_dict(checkpoint) if self.trainer.cfg.enable_amp and scaler_state is not None: self.trainer.scaler.load_state_dict(scaler_state)
def _resume_legacy_training_state(self, checkpoint, iter_per_epoch): """Translate legacy epoch/iter fields into current trainer cursors.""" checkpoint_epoch = int(checkpoint["epoch"]) checkpoint_iter = int(checkpoint.get("iter", 0) or 0) self.trainer.logger.info( f"Resuming train at saved epoch: {checkpoint_epoch}, saved iteration: {checkpoint_iter}" ) if 0 < checkpoint_iter < iter_per_epoch: self.trainer.logger.warning( "Legacy checkpoint is mid-epoch and has no dataloader state; " "resuming from the beginning of the saved epoch and replaying " "already-completed batches." ) self.trainer.start_epoch = max(0, checkpoint_epoch - 1) self.trainer.start_iter = 0 elif checkpoint_iter >= iter_per_epoch: self.trainer.start_epoch = checkpoint_epoch self.trainer.start_iter = 0 else: self.trainer.start_epoch = checkpoint_epoch self.trainer.start_iter = 0 self.trainer.global_step = ( self.trainer.start_epoch * iter_per_epoch + self.trainer.start_iter ) self.trainer.logger.info( "Resuming train at epoch index: " f"{self.trainer.start_epoch}, iteration: {self.trainer.start_iter}" )
[docs] def load_optimizer_state(self, optimizer_state): """Load canonical optimizer state and fail if moments are not restored.""" set_optimizer_state_dict( self.trainer.model, self.trainer.optimizer, optimizer_state, options=StateDictOptions(), ) if optimizer_state.get("state") and not self.trainer.optimizer.state_dict().get("state"): raise RuntimeError( "Optimizer checkpoint contained state tensors, but optimizer resume " "left no optimizer state. Exact resume would restart optimizer moments." )