Source code for pimm.engines._train_utils

"""Checkpointable training-state helpers for exact resume."""

from __future__ import annotations

import json
import os
import random
import socket
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any

import numpy as np
import torch
import torch.distributed as dist

from pimm.utils.config import Config, _is_hook_type_override, _split_hook_type_options
from pimm.utils.env import set_seed


def worker_init_fn(worker_id, num_workers, rank, seed):
    """Worker init func for dataloader.

    The seed of each worker equals to num_worker * rank + worker_id + user_seed

    Args:
        worker_id (int): Worker id.
        num_workers (int): Number of workers.
        rank (int): The rank of current process.
        seed (int): The random seed to use.
    """

    worker_seed = None if seed is None else num_workers * rank + worker_id + seed
    set_seed(worker_seed)


def _set_nested(d, key_path, value):
    """Set nested dict value: 'a.b.c' -> d['a']['b']['c'] = value."""
    keys = key_path.split(".")
    for key in keys[:-1]:
        d = d.setdefault(key, {})
    d[keys[-1]] = value


def _apply_hook_overrides_from_dict(cfg, override_dict):
    """Apply hook parameter overrides from a dict.

    Args:
        cfg: Config object
        override_dict: Dict mapping hook type to parameter dict.
                      Example: {"WandbNamer": {"extra": "fft"}, "InstanceSegmentationEvaluator": {"every_n_steps": 500}}
    """
    if not hasattr(cfg, "hooks") or not override_dict:
        return

    for hook_type, params in override_dict.items():
        if not isinstance(params, dict):
            continue

        # find and update matching hook(s)
        for hook_cfg in cfg.hooks:
            if hook_cfg.get("type") == hook_type:
                for param_path, val in params.items():
                    _set_nested(hook_cfg, param_path, val)


def _apply_hook_overrides(cfg, options):
    """Process --options hooks.HookType.param=value entries.

    Example: --options hooks.PretrainEvaluator.every_n_steps=500
    """
    if not hasattr(cfg, "hooks") or not options:
        return

    for key, val in options.items():
        if not key.startswith("hooks."):
            continue

        # parse: hooks.HookType.param.subparam -> (HookType, param.subparam)
        parts = key.split(".", 2)
        if len(parts) < 3:
            continue

        hook_type = parts[1]
        param_path = parts[2]

        # find and update matching hook(s)
        for hook_cfg in cfg.hooks:
            if hook_cfg.get("type") == hook_type:
                _set_nested(hook_cfg, param_path, val)


def _to_plain_data(value):
    """Convert config objects into JSON-serializable Python containers."""
    if isinstance(value, Config):
        return _to_plain_data(value._cfg_dict)
    if hasattr(value, "to_dict"):
        try:
            return _to_plain_data(value.to_dict())
        except TypeError:
            pass
    if isinstance(value, dict):
        return {str(k): _to_plain_data(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_to_plain_data(v) for v in value]
    return value


def _write_json(path, payload):
    """Write an indented JSON file with a trailing newline."""
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, default=str)
        f.write("\n")


def _git_metadata():
    """Return best-effort Git metadata for run provenance."""

    def run_git(args):
        """Run a git command in the current repo and return stdout."""
        try:
            return subprocess.check_output(
                ["git", *args],
                cwd=os.getcwd(),
                stderr=subprocess.DEVNULL,
                text=True,
            ).strip()
        except Exception:
            return None

    commit = run_git(["rev-parse", "HEAD"])
    if commit is None:
        return {}
    status = run_git(["status", "--short", "--untracked-files=no"])
    return {
        "commit": commit,
        "branch": run_git(["rev-parse", "--abbrev-ref", "HEAD"]),
        "is_dirty": bool(status),
        "dirty_check": "tracked_files_only",
    }


def _save_config_artifacts(cfg, file_path, options):
    """Persist resolved config, model config, and run metadata artifacts."""
    cfg.dump(os.path.join(cfg.save_path, "config.py"))

    cfg_dict = _to_plain_data(cfg)
    _write_json(os.path.join(cfg.save_path, "resolved_config.json"), cfg_dict)

    if isinstance(cfg_dict, dict) and "model" in cfg_dict:
        _write_json(os.path.join(cfg.save_path, "model_config.json"), cfg_dict["model"])

    metadata = {
        "created_at": datetime.now(timezone.utc).isoformat(),
        # The original user-facing `pimm submit/launch ...` command (set by the
        # launcher via PIMM_LAUNCH_COMMAND) -- the command to re-run this job.
        "launch_command": cfg.get("launch_command") or os.environ.get("PIMM_LAUNCH_COMMAND"),
        # The resolved train.py argv this process was actually invoked with.
        "command": sys.argv,
        "cwd": os.getcwd(),
        "host": socket.gethostname(),
        "config_file": file_path,
        "config_file_abs": os.path.abspath(file_path) if file_path else None,
        "cli_options": _to_plain_data(options or {}),
        "cli_option_keys": sorted((options or {}).keys()),
        "save_path": cfg.save_path,
        "resume": bool(cfg.resume),
        "git": _git_metadata(),
    }
    _write_json(os.path.join(cfg.save_path, "run_metadata.json"), metadata)


def capture_rng_state() -> dict[str, Any]:
    """Capture Python, NumPy, and Torch RNG state for exact resume."""
    state: dict[str, Any] = {
        "python": random.getstate(),
        "numpy": np.random.get_state(),
        "torch": torch.get_rng_state(),
    }
    if torch.cuda.is_available():
        state["torch_cuda"] = torch.cuda.get_rng_state_all()
    return state


def restore_rng_state(state: dict[str, Any]) -> None:
    """Restore Python, NumPy, and Torch RNG state captured earlier."""
    if not state:
        return
    random.setstate(state["python"])
    np.random.set_state(state["numpy"])
    torch.set_rng_state(state["torch"])
    if "torch_cuda" in state and torch.cuda.is_available():
        torch.cuda.set_rng_state_all(state["torch_cuda"])


def capture_distributed_rng_state() -> dict[str, Any]:
    """Capture per-rank RNG state in a rank-indexed container."""
    local_state = capture_rng_state()
    if dist.is_available() and dist.is_initialized():
        world_size = dist.get_world_size()
        states = [None for _ in range(world_size)]
        dist.all_gather_object(states, local_state)
        return {
            "world_size": world_size,
            "states": states,
        }
    return {
        "world_size": 1,
        "states": [local_state],
    }


def restore_distributed_rng_state(state: dict[str, Any], *, strict: bool = True) -> None:
    """Restore the RNG state for the current rank from a gathered state."""
    if not state:
        return
    if "states" not in state:
        restore_rng_state(state)
        return
    world_size = dist.get_world_size() if dist.is_available() and dist.is_initialized() else 1
    rank = dist.get_rank() if dist.is_available() and dist.is_initialized() else 0
    saved_world_size = int(state.get("world_size", len(state["states"])))
    if strict and saved_world_size != world_size:
        raise ValueError(
            f"RNG state was saved with world_size={saved_world_size}, "
            f"but current world_size={world_size}."
        )
    states = state["states"]
    if rank >= len(states):
        if strict:
            raise ValueError(f"No RNG state available for rank {rank}.")
        rank = 0
    restore_rng_state(states[rank])


[docs] @dataclass class TrainState: """Serializable resume point for trainer, sampler, dataloader, and RNG state.""" schema_version: int = 1 epoch: int = 0 iter_in_epoch: int = 0 global_step: int = 0 samples_seen: int = 0 world_size: int = 1 batch_size_per_rank: int | None = None grad_accum_step: int = 0 best_metric_value: float | None = None rng_state: dict[str, Any] | None = None sampler_state: dict[str, Any] | None = None dataloader_state: dict[str, Any] | None = None extra_state: dict[str, Any] = field(default_factory=dict) @property def iteration(self) -> int: """Backward-compatible alias for iter_in_epoch.""" return self.iter_in_epoch @iteration.setter def iteration(self, value: int) -> None: """Set iter_in_epoch through the legacy iteration name.""" self.iter_in_epoch = int(value)
[docs] def state_dict(self) -> dict[str, Any]: """Return a checkpoint-friendly dictionary representation.""" return { "schema_version": int(self.schema_version), "epoch": int(self.epoch), "iter_in_epoch": int(self.iter_in_epoch), "iteration": int(self.iter_in_epoch), "global_step": int(self.global_step), "samples_seen": int(self.samples_seen), "world_size": int(self.world_size), "batch_size_per_rank": self.batch_size_per_rank, "grad_accum_step": int(self.grad_accum_step), "best_metric_value": self.best_metric_value, "rng_state": self.rng_state, "sampler_state": self.sampler_state, "dataloader_state": self.dataloader_state, "extra_state": dict(self.extra_state), }
[docs] @classmethod def from_state_dict(cls, state_dict: dict[str, Any]) -> "TrainState": """Create TrainState from checkpoint data.""" state = cls() state.load_state_dict(state_dict) return state
[docs] def load_state_dict(self, state_dict: dict[str, Any]) -> None: """Load state with compatibility for older iteration key names.""" self.schema_version = int(state_dict.get("schema_version", 0) or 0) self.epoch = int(state_dict.get("epoch", 0)) self.iter_in_epoch = int( state_dict.get("iter_in_epoch", state_dict.get("iteration", state_dict.get("iter", 0))) ) self.global_step = int(state_dict.get("global_step", 0)) self.samples_seen = int(state_dict.get("samples_seen", 0)) self.world_size = int(state_dict.get("world_size", 1)) self.batch_size_per_rank = state_dict.get("batch_size_per_rank") self.grad_accum_step = int(state_dict.get("grad_accum_step", 0)) self.best_metric_value = state_dict.get("best_metric_value") self.rng_state = state_dict.get("rng_state") self.sampler_state = state_dict.get("sampler_state") self.dataloader_state = state_dict.get("dataloader_state") self.extra_state = dict(state_dict.get("extra_state", {}))
[docs] @classmethod def from_trainer(cls, trainer: Any) -> "TrainState": """Capture the next resume position from a live trainer.""" comm_info = getattr(trainer, "comm_info", {}) iter_per_epoch = int(comm_info.get("iter_per_epoch", 0) or 0) raw_iter = comm_info.get("iter", -1) current_iter = -1 if raw_iter is None else int(raw_iter) next_iter = current_iter + 1 epoch = int(getattr(trainer, "epoch", 0)) # Store the next batch to consume, rolling to the next epoch at the end. if iter_per_epoch > 0 and next_iter >= iter_per_epoch: next_epoch = epoch + 1 iter_in_epoch = 0 else: next_epoch = epoch iter_in_epoch = max(0, next_iter) global_step = int( getattr(trainer, "global_step", 0) or (epoch * iter_per_epoch + max(0, next_iter)) ) batch_size_per_rank = getattr(trainer.cfg, "batch_size_per_gpu", None) world_size = 1 try: import pimm.utils.comm as comm world_size = comm.get_world_size() except Exception: pass samples_seen = int(getattr(trainer, "samples_seen", 0) or 0) if not samples_seen and batch_size_per_rank is not None: samples_seen = global_step * int(batch_size_per_rank) * world_size best_metric_value = getattr(trainer, "best_metric_value", None) if isinstance(best_metric_value, torch.Tensor): best_metric_value = best_metric_value.item() return cls( epoch=next_epoch, iter_in_epoch=iter_in_epoch, global_step=global_step, samples_seen=samples_seen, world_size=world_size, batch_size_per_rank=batch_size_per_rank, best_metric_value=best_metric_value, )
def apply_train_state_to_trainer(trainer: Any, train_state: TrainState) -> None: """Apply a restored TrainState to the mutable trainer counters.""" trainer.train_state = train_state trainer.start_epoch = int(train_state.epoch) trainer.start_iter = int(train_state.iter_in_epoch) trainer.global_step = int(train_state.global_step) trainer.samples_seen = int(train_state.samples_seen) if train_state.best_metric_value is not None: trainer.best_metric_value = train_state.best_metric_value