Source code for nvalchemi.training._checkpoint

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Multi-component, manifest-based checkpoint layer.

This module saves and loads checkpoints for multiple named models,
optimizers, and schedulers without relying on :mod:`pickle`. A top-level
``manifest.json`` coordinates all components and their associations.

Layout
------
A single call to :func:`save_checkpoint` writes::

    {root_folder}/
      manifest.json
      models/{name}/
        spec.json
        checkpoints/{N}.pt
      optimizers/{name}/          # optional
        spec.json
        checkpoints/{N}.pt
      schedulers/{name}/          # optional
        spec.json
        checkpoints/{N}.pt

The ``manifest.json`` records which components are present, the latest
checkpoint index, and optional associations that wire optimizers to models
and schedulers to optimizers::

    {
      "checkpoint_index": 0,
      "models": ["student", "teacher"],
      "optimizers": ["student_opt"],
      "schedulers": ["student_sched"],
      "associations": {
        "student": {
          "optimizers": ["student_opt"],
          "schedulers": ["student_sched"]
        }
      }
    }

The ``associations`` key specifies connectivity between models and
their respective optimizer(s) and LR scheduler(s). This can be explicitly
provided by the user, or automatically inferred by matching parameters
with optimizers/LR schedulers.

Examples
--------
Single model::

    save_checkpoint("runs/exp1", models={"main": (model, spec)})
    result = load_checkpoint("runs/exp1")
    model, spec = result.models["main"]

Knowledge distillation (two models + optimizer + scheduler)::

    save_checkpoint(
        "runs/kd",
        models={"student": (student, s_spec), "teacher": (teacher, t_spec)},
        optimizers={"s_opt": (optimizer, opt_spec)},
        schedulers={"s_sched": (scheduler, sched_spec)},
        # associations can be inferred automatically from param_groups
    )
    result = load_checkpoint("runs/kd")
    student, _ = result.models["student"]
"""

from __future__ import annotations

import itertools
import json
import warnings
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from pathlib import Path
from typing import Annotated, Any

import torch
import torch.nn as nn
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer

from nvalchemi.hooks._protocol import CheckpointableHook
from nvalchemi.training._spec import (
    BaseSpec,
    create_model_spec,
    create_model_spec_from_json,
)
from nvalchemi.training.distributed import get_world_size

CheckpointValidator = Callable[[str, Mapping[str, Any], Mapping[str, Any]], None]
"""Callable used to validate a loaded model entry.

Validators receive ``(model_name, model_entry, loaded_checkpoint)`` and should
raise an exception with an actionable message when compatibility checks fail.
"""

# ---------------------------------------------------------------------------
# Dual-mode field helpers
# ---------------------------------------------------------------------------


def _component_before(v: Any) -> dict[str, Any]:
    """Accept ``list[str]`` (from JSON) or ``dict`` (from code) for component fields."""
    if isinstance(v, list):
        # From disk: list of names → placeholder dict (values populated later)
        return {name: None for name in v}
    return v


def _component_serialize(d: dict[str, Any]) -> list[str]:
    """Serialize a component dict to a sorted list of its keys."""
    return sorted(d.keys())


def _is_fsdp_wrapped(module: nn.Module) -> bool:
    """Return whether ``module`` is wrapped by FSDP or FSDP2."""
    fsdp_types: list[type[nn.Module]] = []
    try:
        from torch.distributed.fsdp import FullyShardedDataParallel

        fsdp_types.append(FullyShardedDataParallel)
    except (ImportError, AttributeError):
        pass
    try:
        from torch.distributed._composable.fsdp import FSDPModule

        fsdp_types.append(FSDPModule)
    except (ImportError, AttributeError):
        pass
    return bool(fsdp_types) and isinstance(module, tuple(fsdp_types))


def _checkpoint_model(module: nn.Module) -> nn.Module:
    """Return a model suitable for native checkpoint state and spec extraction."""
    if isinstance(module, torch.nn.parallel.DistributedDataParallel):
        return module.module
    if _is_fsdp_wrapped(module):
        recipe_url = (
            "https://docs.pytorch.org/tutorials/recipes/"
            "distributed_checkpoint_recipe.html"
        )
        raise NotImplementedError(
            "Native nvalchemi checkpoints do not yet support FSDP/FSDP2-wrapped "
            "models. Use torch.distributed.checkpoint with PyTorch's distributed "
            f"checkpoint recipe instead: {recipe_url}"
        )
    return module


def _checkpoint_model_components(
    models: Mapping[str, tuple[nn.Module, BaseSpec]],
) -> dict[str, tuple[nn.Module, BaseSpec]]:
    """Unwrap supported distributed model wrappers before checkpointing."""
    return {
        name: (_checkpoint_model(module), spec)
        for name, (module, spec) in models.items()
    }


# ---------------------------------------------------------------------------
# Manifest schema + runtime container (unified)
# ---------------------------------------------------------------------------

_SCHEMA_VERSION = 1
"""Current manifest schema version.  Bump when manifest structure changes."""

_STRATEGY_FILENAME = "strategy.json"
"""File containing strategy recipe and runtime counters for native checkpoints."""

_STRATEGY_CHECKPOINT_DIR = Path("strategy") / "checkpoints"
"""Directory containing per-index strategy checkpoint metadata."""

_HOOK_CHECKPOINT_DIR = Path("hooks") / "checkpoints"
"""Directory containing per-index runtime hook state."""

_SCHEDULER_OPTIMIZERS_KEY = "scheduler_optimizers"
"""Association key mapping scheduler component names to optimizer names."""

_OPTIMIZER_PARAMETER_NAMES_KEY = "optimizer_parameter_names"
"""Association key mapping optimizer component names to parameter names."""

# Type aliases for the runtime dict shapes
_ModelDict = dict[str, tuple[nn.Module, BaseSpec] | None]
_OptimizerDict = dict[str, tuple[torch.optim.Optimizer, BaseSpec] | None]
_SchedulerDict = dict[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec] | None]
_Associations = dict[str, dict[str, Any]]


[docs] class CheckpointManifest(BaseModel): """Unified checkpoint manifest and runtime container. This Pydantic model serves a dual role: 1. **On-disk schema** — ``manifest.json`` stores component names as sorted string lists together with metadata and associations. 2. **Runtime container** — after :func:`load_checkpoint` hydrates the components, the same instance carries live ``(object, spec)`` tuples. The ``models``, ``optimizers``, and ``schedulers`` fields accept either a ``list[str]`` (from JSON) or a ``dict[str, tuple]`` (from code). Serialization always produces sorted name lists via :class:`~pydantic.PlainSerializer`. Examples -------- >>> manifest = CheckpointManifest( ... checkpoint_index=0, models={"main": None}, ... ) >>> manifest.model_dump()["models"] ['main'] """ model_config = ConfigDict(arbitrary_types_allowed=True) schema_version: Annotated[ int, Field(default=_SCHEMA_VERSION, description="Manifest schema version.") ] checkpoint_index: Annotated[ int, Field(description="Latest checkpoint index written.") ] models: Annotated[ _ModelDict, BeforeValidator(_component_before), PlainSerializer(_component_serialize, return_type=list[str]), Field(description="Model components keyed by name."), ] optimizers: Annotated[ _OptimizerDict, BeforeValidator(_component_before), PlainSerializer(_component_serialize, return_type=list[str]), Field(default_factory=dict, description="Optimizer components keyed by name."), ] schedulers: Annotated[ _SchedulerDict, BeforeValidator(_component_before), PlainSerializer(_component_serialize, return_type=list[str]), Field(default_factory=dict, description="Scheduler components keyed by name."), ] associations: Annotated[ _Associations, Field( default_factory=dict, description="Model-centric linkage to optimizers/schedulers.", ), ] @staticmethod def _migrate(raw: dict[str, Any]) -> dict[str, Any]: """Migrate an older manifest dict to the current schema version. Parameters ---------- raw Parsed ``manifest.json`` content. Returns ------- dict[str, Any] Dict conforming to the current ``_SCHEMA_VERSION``, ready for :meth:`pydantic.BaseModel.model_validate`. Raises ------ ValueError If the manifest's schema version is newer than supported. """ version = raw.get("schema_version", 0) if version > _SCHEMA_VERSION: raise ValueError( f"Checkpoint schema version {version} is newer than supported " f"({_SCHEMA_VERSION}). Upgrade nvalchemi to load this checkpoint." ) # Future migrations chain here: # if version < 1: # raw = _migrate_v0_to_v1(raw) raw["schema_version"] = _SCHEMA_VERSION return raw @classmethod def read(cls, root: Path) -> CheckpointManifest: """Read, migrate, and validate ``manifest.json`` from *root*. Parameters ---------- root Checkpoint root directory containing ``manifest.json``. Returns ------- CheckpointManifest Validated manifest instance. Component dicts contain placeholder ``None`` values until hydrated by :func:`load_checkpoint`. Raises ------ FileNotFoundError If ``manifest.json`` does not exist. ValueError If the manifest's schema version is newer than supported. pydantic.ValidationError If the manifest JSON does not conform to the schema. """ manifest_path = root / "manifest.json" if not manifest_path.exists(): raise FileNotFoundError( f"No manifest.json found in {root}. Use save_checkpoint to " f"create a checkpoint first." ) raw = json.loads(manifest_path.read_text()) migrated = cls._migrate(raw) return cls.model_validate(migrated) def write(self, root: Path) -> None: """Write this manifest to ``{root}/manifest.json``. Parameters ---------- root Checkpoint root directory. """ (root / "manifest.json").write_text(self.model_dump_json(indent=2))
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _ckpt_indices(ckpt_dir: Path) -> list[int]: """Return sorted integer stems from ``*.pt`` files in *ckpt_dir*.""" return sorted(int(p.stem) for p in ckpt_dir.glob("*.pt") if p.stem.isdigit()) def _without_spec_timestamps(value: Any) -> Any: """Return JSON-like *value* with BaseSpec timestamps removed recursively.""" if isinstance(value, dict): return { key: _without_spec_timestamps(item) for key, item in value.items() if not (key == "timestamp" and "cls_path" in value) } if isinstance(value, list): return [_without_spec_timestamps(item) for item in value] return value def _check_spec_consistency(spec_path: Path, spec: BaseSpec) -> None: """Write *spec* to *spec_path* on first call; raise on mismatch thereafter. Parameters ---------- spec_path Path to the ``spec.json`` file. spec The spec to write or compare against the existing file. Raises ------ ValueError If the existing ``spec.json`` disagrees with *spec* on any field other than ``timestamp``. """ spec_json = spec.model_dump_json(indent=2) if spec_path.exists(): existing = _without_spec_timestamps(json.loads(spec_path.read_text())) new_spec = _without_spec_timestamps(json.loads(spec_json)) if existing != new_spec: diffs = sorted( k for k in set(existing) | set(new_spec) if existing.get(k) != new_spec.get(k) ) preview = ", ".join( f"{k}: {existing.get(k)!r} -> {new_spec.get(k)!r}" for k in diffs[:3] ) suffix = f" (+{len(diffs) - 3} more)" if len(diffs) > 3 else "" raise ValueError( f"spec.json at {spec_path} disagrees with the spec being " f"saved. Differing fields: {preview}{suffix}." ) else: spec_path.write_text(spec_json) def _save_component( root: Path, category: str, name: str, state_dict: dict[str, Any], spec: BaseSpec, checkpoint_index: int, ) -> None: """Write *spec* and *state_dict* under ``root/category/name/``.""" comp_dir = root / category / name ckpt_dir = comp_dir / "checkpoints" ckpt_dir.mkdir(parents=True, exist_ok=True) _check_spec_consistency(comp_dir / "spec.json", spec) torch.save(state_dict, ckpt_dir / f"{checkpoint_index}.pt") def _snapshot_state_value(value: Any) -> Any: """Return a CPU copy of tensors nested inside a state-dict value.""" if isinstance(value, torch.Tensor): return value.detach().to(device="cpu", copy=True) if isinstance(value, Mapping): return {key: _snapshot_state_value(item) for key, item in value.items()} if isinstance(value, list): return [_snapshot_state_value(item) for item in value] if isinstance(value, tuple): return tuple(_snapshot_state_value(item) for item in value) return value def _snapshot_state_dict(state_dict: Mapping[str, Any]) -> dict[str, Any]: """Return a CPU-only state dict detached from live training objects.""" return {key: _snapshot_state_value(value) for key, value in state_dict.items()} def _snapshot_components( components: Mapping[str, tuple[Any, BaseSpec]], ) -> dict[str, tuple[dict[str, Any], BaseSpec]]: """Capture component state dicts and specs for asynchronous writing.""" return { name: (_snapshot_state_dict(component.state_dict()), spec) for name, (component, spec) in components.items() } def _hook_state_key(hook: object, occurrence: int) -> str: """Return the stable class-occurrence key used for hook state matching.""" return f"{type(hook).__module__}.{type(hook).__qualname__}:{occurrence}" def _iter_checkpointable_hooks(hooks: Iterable[object]) -> Iterator[CheckpointableHook]: """Yield hooks that explicitly opt into checkpointed runtime state.""" for hook in hooks: children = getattr(hook, "_hooks", None) if isinstance(children, Sequence) and not isinstance(children, (str, bytes)): yield from _iter_checkpointable_hooks(children) if isinstance(hook, CheckpointableHook): yield hook def _snapshot_hook_states(strategy: Any) -> dict[str, dict[str, Any]]: """Capture checkpointable runtime hook state detached from live tensors.""" states: dict[str, dict[str, Any]] = {} occurrences: dict[str, int] = {} for hook in _iter_checkpointable_hooks(strategy.hooks): class_name = f"{type(hook).__module__}.{type(hook).__qualname__}" occurrence = occurrences.get(class_name, 0) occurrences[class_name] = occurrence + 1 states[_hook_state_key(hook, occurrence)] = _snapshot_state_dict( hook.state_dict() ) return states def _hook_state_path(root: Path, checkpoint_index: int) -> Path: """Return the hook-state checkpoint path for ``checkpoint_index``.""" return root / _HOOK_CHECKPOINT_DIR / f"{checkpoint_index}.pt" def _save_hook_states( root: Path, hook_states: Mapping[str, Mapping[str, Any]], checkpoint_index: int, ) -> None: """Write hook state for a checkpoint when checkpointable hooks are present.""" if not hook_states: return path = _hook_state_path(root, checkpoint_index) path.parent.mkdir(parents=True, exist_ok=True) state_dict = dict(hook_states) torch.save(state_dict, path) def _load_hook_states( root: Path, strategy: Any, checkpoint_index: int, *, map_location: str | torch.device | None, ) -> None: """Restore matching checkpointable hook state into a loaded strategy.""" path = _hook_state_path(root, checkpoint_index) if not path.exists(): return saved_states = torch.load( path, weights_only=True, map_location=map_location, ) occurrences: dict[str, int] = {} for hook in _iter_checkpointable_hooks(strategy.hooks): class_name = f"{type(hook).__module__}.{type(hook).__qualname__}" occurrence = occurrences.get(class_name, 0) occurrences[class_name] = occurrence + 1 state = saved_states.get(_hook_state_key(hook, occurrence)) if state is not None: hook.load_state_dict(state) def _resolve_checkpoint_index(root: Path, checkpoint_index: int) -> int: """Return an explicit checkpoint index, resolving ``-1`` by auto-increment.""" if checkpoint_index != -1: return checkpoint_index manifest_path = root / "manifest.json" if manifest_path.exists(): prev = CheckpointManifest.read(root) return prev.checkpoint_index + 1 return 0 def _create_checkpoint_snapshot( root_folder: Path | str, *, checkpoint_index: int = -1, strategy: Any, ) -> dict[str, Any]: """Capture a strategy checkpoint payload detached from live tensors. The snapshot is intended for background filesystem writes. It still runs on the caller thread and copies tensors to CPU so later training updates cannot mutate data while :func:`torch.save` serializes it. """ from nvalchemi.training.strategy import TrainingStrategy if not isinstance(strategy, TrainingStrategy): raise TypeError( "strategy must be a TrainingStrategy instance; got " f"{type(strategy).__name__}." ) root = Path(root_folder) models, optimizers, schedulers, associations, strategy_metadata = ( _strategy_components(strategy) ) return { "checkpoint_index": _resolve_checkpoint_index(root, checkpoint_index), "models": _snapshot_components(models), "optimizers": _snapshot_components(optimizers), "schedulers": _snapshot_components(schedulers), "associations": _copy_associations(associations), "strategy_metadata": dict(strategy_metadata), "hook_states": _snapshot_hook_states(strategy), } def _write_checkpoint_snapshot( root_folder: Path | str, snapshot: Mapping[str, Any] ) -> int: """Write a detached checkpoint snapshot to disk.""" root = Path(root_folder) checkpoint_index = int(snapshot["checkpoint_index"]) models = snapshot["models"] optimizers = snapshot["optimizers"] schedulers = snapshot["schedulers"] associations = snapshot["associations"] strategy_metadata = snapshot.get("strategy_metadata") hook_states = snapshot.get("hook_states", {}) for name, (state_dict, spec) in models.items(): _save_component( root, "models", name, state_dict, spec, checkpoint_index, ) for name, (state_dict, spec) in optimizers.items(): _save_component( root, "optimizers", name, state_dict, spec, checkpoint_index, ) for name, (state_dict, spec) in schedulers.items(): _save_component( root, "schedulers", name, state_dict, spec, checkpoint_index, ) manifest = CheckpointManifest( checkpoint_index=checkpoint_index, models={name: None for name in models}, optimizers={name: None for name in optimizers}, schedulers={name: None for name in schedulers}, associations=associations, ) manifest.write(root) _save_hook_states(root, hook_states, checkpoint_index) if strategy_metadata is not None: _write_strategy_metadata( root, strategy_metadata, checkpoint_index=checkpoint_index ) return checkpoint_index def _assoc_names(assoc: Mapping[str, Any], key: str) -> list[str]: """Return an association list field, tolerating older or malformed entries.""" raw = assoc.get(key, []) return list(raw) if isinstance(raw, list) else [] def _assoc_scheduler_optimizers(assoc: Mapping[str, Any]) -> dict[str, str]: """Return scheduler-to-optimizer association edges from *assoc*.""" raw = assoc.get(_SCHEDULER_OPTIMIZERS_KEY, {}) if not isinstance(raw, Mapping): return {} return {str(scheduler): str(optimizer) for scheduler, optimizer in raw.items()} def _assoc_optimizer_parameter_names( assoc: Mapping[str, Any], ) -> dict[str, tuple[str, ...]]: """Return optimizer-to-parameter-name association edges from *assoc*.""" raw = assoc.get(_OPTIMIZER_PARAMETER_NAMES_KEY, {}) if not isinstance(raw, Mapping): return {} result: dict[str, tuple[str, ...]] = {} for optimizer, names in raw.items(): if isinstance(names, str) or not isinstance(names, Sequence): continue result[str(optimizer)] = tuple(str(name) for name in names) return result def _copy_associations(associations: Mapping[str, Mapping[str, Any]]) -> _Associations: """Return a shallow JSON-like copy of association entries.""" copied: _Associations = {} for model_name, assoc in associations.items(): entry: dict[str, Any] = { "optimizers": _assoc_names(assoc, "optimizers"), "schedulers": _assoc_names(assoc, "schedulers"), } scheduler_optimizers = _assoc_scheduler_optimizers(assoc) if scheduler_optimizers: entry[_SCHEDULER_OPTIMIZERS_KEY] = scheduler_optimizers optimizer_parameter_names = _assoc_optimizer_parameter_names(assoc) if optimizer_parameter_names: entry[_OPTIMIZER_PARAMETER_NAMES_KEY] = { optimizer: list(names) for optimizer, names in optimizer_parameter_names.items() } copied[model_name] = entry return copied def _scheduler_optimizer_edges( optimizers: Mapping[str, tuple[torch.optim.Optimizer, BaseSpec]], schedulers: Mapping[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]], ) -> dict[str, str]: """Return scheduler component names keyed to their optimizer component names.""" edges: dict[str, str] = {} for scheduler_name, (scheduler, _) in schedulers.items(): for optimizer_name, (optimizer, _) in optimizers.items(): if scheduler.optimizer is optimizer: # type: ignore[attr-defined] edges[scheduler_name] = optimizer_name break return edges def _with_scheduler_optimizer_edges( associations: Mapping[str, Mapping[str, Any]], optimizers: Mapping[str, tuple[torch.optim.Optimizer, BaseSpec]], schedulers: Mapping[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]], ) -> _Associations: """Attach explicit scheduler-to-optimizer edges to model associations.""" enriched = _copy_associations(associations) edges = _scheduler_optimizer_edges(optimizers, schedulers) if not edges: return enriched for assoc in enriched.values(): optimizer_names = set(_assoc_names(assoc, "optimizers")) scheduler_names = set(_assoc_names(assoc, "schedulers")) model_edges = { scheduler_name: optimizer_name for scheduler_name, optimizer_name in edges.items() if scheduler_name in scheduler_names and optimizer_name in optimizer_names } if model_edges: assoc[_SCHEDULER_OPTIMIZERS_KEY] = { **_assoc_scheduler_optimizers(assoc), **model_edges, } return enriched def _infer_associations( models: dict[str, tuple[nn.Module, BaseSpec]], optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec]], schedulers: dict[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]], ) -> _Associations: """Infer model-centric associations from optimizer ``param_groups``. For each optimizer, collect the ``data_ptr()`` values of every parameter in its ``param_groups`` and match against each model's ``parameters()``. The optimizer is associated with every model that owns at least one of those parameters. Schedulers are linked to their optimizer via ``scheduler.optimizer is optimizer`` identity checks. Parameters ---------- models ``{name: (module, spec)}`` mapping. optimizers ``{name: (optimizer, spec)}`` mapping. schedulers ``{name: (scheduler, spec)}`` mapping. Returns ------- dict[str, dict[str, list[str]]] Model-centric associations, e.g. ``{"student": {"optimizers": ["s_opt"], "schedulers": ["s_sched"]}}``. """ # Build data_ptr → model_name index ptr_to_model: dict[int, str] = {} for model_name, (module, _) in models.items(): for p in module.parameters(): ptr_to_model[p.data_ptr()] = model_name # Map each optimizer to every model that owns at least one parameter opt_to_models: dict[str, list[str]] = {} for opt_name, (optimizer, _) in optimizers.items(): matched: dict[str, bool] = {} for group in optimizer.param_groups: for p in group["params"]: owner = ptr_to_model.get(p.data_ptr()) if owner is not None: matched[owner] = True if matched: opt_to_models[opt_name] = list(matched) # Map each scheduler to its optimizer (identity check) sched_to_opt = _scheduler_optimizer_edges(optimizers, schedulers) # Build model-centric structure assoc: _Associations = {} for opt_name, model_names in opt_to_models.items(): for model_name in model_names: assoc.setdefault(model_name, {"optimizers": [], "schedulers": []}) assoc[model_name]["optimizers"].append(opt_name) for sched_name, opt_name in sched_to_opt.items(): model_names = opt_to_models.get(opt_name, []) for model_name in model_names: assoc.setdefault(model_name, {"optimizers": [], "schedulers": []}) assoc[model_name]["schedulers"].append(sched_name) scheduler_optimizers = assoc[model_name].setdefault( _SCHEDULER_OPTIMIZERS_KEY, {} ) scheduler_optimizers[sched_name] = opt_name return assoc def _find_associated_model_params( optimizer_name: str, associations: _Associations, models: dict[str, tuple[nn.Module, BaseSpec]], ) -> Iterator[torch.nn.Parameter]: """Return chained parameters from all models associated with *optimizer_name*.""" matched: list[str] = [] for model_name, assoc in associations.items(): if optimizer_name in _assoc_names(assoc, "optimizers"): matched.append(model_name) if matched: named_params_by_model = { model_name: dict(models[model_name][0].named_parameters()) for model_name in matched } parameter_names = [] for model_name in matched: parameter_names.extend( _assoc_optimizer_parameter_names(associations[model_name]).get( optimizer_name, () ) ) if parameter_names: params: list[torch.nn.Parameter] = [] missing: list[str] = [] for qualified_name in parameter_names: model_name, _, parameter_name = qualified_name.partition(".") named_params = named_params_by_model.get(model_name) if named_params is None or parameter_name not in named_params: missing.append(qualified_name) continue params.append(named_params[parameter_name]) if missing: raise ValueError( f"Checkpoint optimizer {optimizer_name!r} references missing " f"parameter(s): {missing!r}." ) return iter(params) return itertools.chain.from_iterable( models[name][0].parameters() for name in matched ) # Fallback: if exactly one model exists, use it if len(models) == 1: return next(iter(models.values()))[0].parameters() raise ValueError( f"Cannot determine which model's parameters to use for optimizer " f"{optimizer_name!r}. Provide associations or use a single model." ) def _find_associated_optimizer( scheduler_name: str, associations: _Associations, optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec]], ) -> torch.optim.Optimizer: """Return the optimizer whose associations include *scheduler_name*.""" for assoc in associations.values(): edge = _assoc_scheduler_optimizers(assoc).get(scheduler_name) if edge is not None: if edge in optimizers: return optimizers[edge][0] raise ValueError( f"Scheduler {scheduler_name!r} is associated with optimizer " f"{edge!r}, but that optimizer was not loaded." ) scheduler_names = _assoc_names(assoc, "schedulers") optimizer_names = _assoc_names(assoc, "optimizers") if scheduler_name in scheduler_names: scheduler_index = scheduler_names.index(scheduler_name) if scheduler_index < len(optimizer_names): optimizer_name = optimizer_names[scheduler_index] if optimizer_name in optimizers: return optimizers[optimizer_name][0] # Fallback: if exactly one optimizer exists, use it if len(optimizers) == 1: return next(iter(optimizers.values()))[0] raise ValueError( f"Cannot determine which optimizer to use for scheduler " f"{scheduler_name!r}. Provide associations or use a single optimizer." ) def _strategy_metadata_path(root: Path) -> Path: """Return the checkpoint strategy metadata path under ``root``.""" return root / _STRATEGY_FILENAME def _indexed_strategy_metadata_path(root: Path, checkpoint_index: int) -> Path: """Return the per-index strategy metadata path under ``root``.""" return root / _STRATEGY_CHECKPOINT_DIR / f"{checkpoint_index}.json" def _read_strategy_metadata( root: Path, *, checkpoint_index: int, latest_checkpoint_index: int, ) -> dict[str, Any] | None: """Read strategy checkpoint metadata if the checkpoint contains it.""" indexed_path = _indexed_strategy_metadata_path(root, checkpoint_index) if indexed_path.exists(): return json.loads(indexed_path.read_text()) path = _strategy_metadata_path(root) if not path.exists(): return None if checkpoint_index != latest_checkpoint_index: raise FileNotFoundError( "This checkpoint has root-level strategy metadata only, so " f"checkpoint_index={checkpoint_index} cannot be loaded coherently. " f"Load the latest index ({latest_checkpoint_index}) or recreate the " "checkpoint with per-index strategy metadata." ) return json.loads(path.read_text()) def _write_strategy_metadata( root: Path, metadata: Mapping[str, Any], *, checkpoint_index: int, ) -> None: """Write latest and per-index JSON strategy metadata.""" root.mkdir(parents=True, exist_ok=True) payload = json.dumps(metadata, indent=2) _strategy_metadata_path(root).write_text(payload) indexed_path = _indexed_strategy_metadata_path(root, checkpoint_index) indexed_path.parent.mkdir(parents=True, exist_ok=True) indexed_path.write_text(payload) def _component_name(model_name: str, kind: str, index: int, count: int) -> str: """Return a stable optimizer/scheduler component name for a model config.""" suffix = kind if count == 1 else f"{kind}_{index}" return f"{model_name}_{suffix}" def _models_from_strategy_metadata( strategy: Any, metadata: Mapping[str, Any], ) -> dict[str, tuple[nn.Module, BaseSpec]]: """Collect model components and specs from a strategy checkpoint payload.""" raw_specs = metadata.get("model_specs", {}) if not isinstance(raw_specs, Mapping): raise ValueError("strategy checkpoint metadata has invalid 'model_specs'.") models: dict[str, tuple[nn.Module, BaseSpec]] = {} missing: list[str] = [] for name, module in strategy.models.items(): checkpoint_module = _checkpoint_model(module) raw = raw_specs.get(name) if raw is None: missing.append(name) continue models[name] = (checkpoint_module, create_model_spec_from_json(dict(raw))) if missing: raise ValueError( "Cannot save strategy checkpoint because model spec generation " f"failed for model(s) {missing!r}. Ensure these models can be " "reconstructed from BaseSpec before checkpointing." ) return models def _strategy_components( strategy: Any, ) -> tuple[ dict[str, tuple[nn.Module, BaseSpec]], dict[str, tuple[torch.optim.Optimizer, BaseSpec]], dict[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]], _Associations, dict[str, Any], ]: """Extract manifest components from a :class:`TrainingStrategy` instance.""" metadata = strategy.to_checkpoint_dict() models = _models_from_strategy_metadata(strategy, metadata) flat_opts, flat_scheds = strategy._setup_runtime_optimizers(rebuild=False) optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec]] = {} schedulers: dict[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]] = {} associations: _Associations = {} cursor = 0 for model_name, configs in strategy.optimizer_configs.items(): assoc = associations.setdefault( model_name, {"optimizers": [], "schedulers": []} ) for index, config in enumerate(configs): try: optimizer = flat_opts[cursor] scheduler = flat_scheds[cursor] except IndexError as exc: raise RuntimeError( "Strategy optimizer state is inconsistent with optimizer_configs." ) from exc optimizer_name = _component_name( model_name, "optimizer", index, len(configs) ) optimizers[optimizer_name] = ( optimizer, create_model_spec(config.optimizer_cls, **config.optimizer_kwargs), ) assoc["optimizers"].append(optimizer_name) parameter_names = assoc.setdefault(_OPTIMIZER_PARAMETER_NAMES_KEY, {}) optimizer_param_ids = { id(parameter) for group in optimizer.param_groups for parameter in group["params"] } parameter_names[optimizer_name] = [ f"{model_name}.{name}" for name, parameter in models[model_name][0].named_parameters() if id(parameter) in optimizer_param_ids ] if scheduler is not None: if config.scheduler_cls is None: raise RuntimeError( f"Strategy has scheduler state for {optimizer_name!r}, " "but its OptimizerConfig has scheduler_cls=None." ) scheduler_name = _component_name( model_name, "scheduler", index, len(configs) ) schedulers[scheduler_name] = ( scheduler, create_model_spec(config.scheduler_cls, **config.scheduler_kwargs), ) assoc["schedulers"].append(scheduler_name) scheduler_optimizers = assoc.setdefault(_SCHEDULER_OPTIMIZERS_KEY, {}) scheduler_optimizers[scheduler_name] = optimizer_name cursor += 1 return models, optimizers, schedulers, associations, metadata def _loaded_model_objects( manifest: CheckpointManifest, ) -> dict[str, nn.Module]: """Return loaded models from a hydrated manifest.""" return {name: pair[0] for name, pair in manifest.models.items() if pair is not None} def _install_strategy_optimizer_state( strategy: Any, manifest: CheckpointManifest ) -> None: """Attach loaded optimizer/scheduler objects to a strategy for restart.""" flat_opts: list[torch.optim.Optimizer] = [] flat_scheds: list[torch.optim.lr_scheduler.LRScheduler | None] = [] for model_name, configs in strategy.optimizer_configs.items(): for index, config in enumerate(configs): optimizer_name = _component_name( model_name, "optimizer", index, len(configs) ) optimizer_pair = manifest.optimizers.get(optimizer_name) if optimizer_pair is None: raise ValueError( f"Checkpoint strategy expects optimizer {optimizer_name!r}, " "but it was not loaded from the manifest." ) flat_opts.append(optimizer_pair[0]) scheduler_name = _component_name( model_name, "scheduler", index, len(configs) ) scheduler_pair = manifest.schedulers.get(scheduler_name) if config.scheduler_cls is not None and scheduler_pair is None: raise ValueError( f"Checkpoint strategy expects scheduler {scheduler_name!r}, " "but it was not loaded from the manifest." ) flat_scheds.append( scheduler_pair[0] if scheduler_pair is not None else None ) strategy._optimizers = flat_opts strategy._lr_schedulers = flat_scheds strategy._resume_optimizer_state = bool(flat_opts) if flat_opts: strategy._restore_runtime_optimizers_from_loaded_state() def _restore_strategy_runtime_state( strategy: Any, metadata: Mapping[str, Any] | None, ) -> None: """Restore saved runtime counters into a live strategy.""" if metadata is None: return runtime_state = metadata.get("runtime_state", {}) if runtime_state is None: return if not isinstance(runtime_state, Mapping): raise ValueError( "strategy checkpoint metadata has invalid 'runtime_state'; " f"got {type(runtime_state).__name__}." ) for key in ( "step_count", "global_step_count", "batch_count", "epoch_count", "epoch_step_count", ): if key in runtime_state: value = int(runtime_state[key]) if value < 0: raise ValueError( f"strategy checkpoint runtime counter {key!r} must be " f"non-negative; got {value}." ) setattr(strategy, key, value) if "global_step_count" not in runtime_state and strategy.step_count > 0: strategy.global_step_count = strategy.step_count * get_world_size( getattr(strategy, "distributed_manager", None) ) def _optimizer_scheduler_maps_from_strategy( strategy: Any, ) -> tuple[ dict[str, torch.optim.Optimizer], dict[str, torch.optim.lr_scheduler.LRScheduler], ]: """Return checkpoint component-name maps for a live strategy runtime.""" flat_opts, flat_scheds = strategy._setup_runtime_optimizers(rebuild=False) optimizers: dict[str, torch.optim.Optimizer] = {} schedulers: dict[str, torch.optim.lr_scheduler.LRScheduler] = {} cursor = 0 for model_name, configs in strategy.optimizer_configs.items(): for index, config in enumerate(configs): try: optimizer = flat_opts[cursor] scheduler = flat_scheds[cursor] except IndexError as exc: raise RuntimeError( "Strategy optimizer state is inconsistent with optimizer_configs." ) from exc optimizers[ _component_name(model_name, "optimizer", index, len(configs)) ] = optimizer if scheduler is not None: schedulers[ _component_name(model_name, "scheduler", index, len(configs)) ] = scheduler cursor += 1 return optimizers, schedulers def _restore_checkpoint_into_strategy( root: Path, manifest: CheckpointManifest, *, checkpoint_index: int, strategy: Any, strategy_metadata: Mapping[str, Any] | None, map_location: str | torch.device | None, ) -> dict[str, Any]: """Load checkpoint state into an already-constructed strategy.""" from nvalchemi.training.runtime import move_to_devices from nvalchemi.training.strategy import TrainingStrategy if not isinstance(strategy, TrainingStrategy): raise TypeError( "strategy must be a TrainingStrategy instance; got " f"{type(strategy).__name__}." ) missing_models = sorted(set(manifest.models) - set(strategy.models)) if missing_models: raise KeyError( "Checkpoint contains model(s) not present in the live strategy: " f"{missing_models!r}." ) loaded_models: dict[str, tuple[nn.Module, BaseSpec | None]] = {} for name in manifest.models: model = _checkpoint_model(strategy.models[name]) weights = torch.load( root / "models" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=map_location, ) model.load_state_dict(weights) spec_path = root / "models" / name / "spec.json" spec = _load_spec(spec_path) if spec_path.exists() else None loaded_models[name] = (model, spec) strategy.models = move_to_devices(strategy.models, strategy.devices) live_optimizers, live_schedulers = _optimizer_scheduler_maps_from_strategy(strategy) loaded_optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec | None]] = {} missing_optimizers = sorted(set(manifest.optimizers) - set(live_optimizers)) if missing_optimizers: raise KeyError( "Checkpoint contains optimizer(s) not present in the live strategy: " f"{missing_optimizers!r}." ) for name in manifest.optimizers: optimizer = live_optimizers[name] state = torch.load( root / "optimizers" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=map_location, ) optimizer.load_state_dict(state) spec_path = root / "optimizers" / name / "spec.json" spec = _load_spec(spec_path) if spec_path.exists() else None loaded_optimizers[name] = (optimizer, spec) loaded_schedulers: dict[ str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec | None] ] = {} missing_schedulers = sorted(set(manifest.schedulers) - set(live_schedulers)) if missing_schedulers: raise KeyError( "Checkpoint contains scheduler(s) not present in the live strategy: " f"{missing_schedulers!r}." ) for name in manifest.schedulers: scheduler = live_schedulers[name] state = torch.load( root / "schedulers" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=map_location, ) scheduler.load_state_dict(state) spec_path = root / "schedulers" / name / "spec.json" spec = _load_spec(spec_path) if spec_path.exists() else None loaded_schedulers[name] = (scheduler, spec) strategy._resume_optimizer_state = bool(loaded_optimizers) _restore_strategy_runtime_state(strategy, strategy_metadata) _load_hook_states( root, strategy, checkpoint_index, map_location=map_location, ) manifest.models = loaded_models manifest.optimizers = loaded_optimizers manifest.schedulers = loaded_schedulers manifest.checkpoint_index = checkpoint_index return _manifest_to_loaded_checkpoint(manifest, root=root, strategy=strategy) def _manifest_to_loaded_checkpoint( manifest: CheckpointManifest, *, root: Path, strategy: Any = None, source_format: str = "native", ) -> dict[str, Any]: """Convert a hydrated manifest into the high-level builtin dict shape.""" models: dict[str, dict[str, Any]] = {} for model_name, pair in manifest.models.items(): if pair is None: continue model, spec = pair assoc = manifest.associations.get( model_name, {"optimizers": [], "schedulers": []} ) model_optimizers = { name: {"optimizer": opt_pair[0], "spec": opt_pair[1]} for name in _assoc_names(assoc, "optimizers") if (opt_pair := manifest.optimizers.get(name)) is not None } model_schedulers = { name: {"scheduler": sched_pair[0], "spec": sched_pair[1]} for name in _assoc_names(assoc, "schedulers") if (sched_pair := manifest.schedulers.get(name)) is not None } models[model_name] = { "model": model, "spec": spec, "optimizers": model_optimizers, "schedulers": model_schedulers, "metadata": {"associations": assoc}, } return { "strategy": strategy, "models": models, "manifest": manifest, "checkpoint_index": manifest.checkpoint_index, "source": {"format": source_format, "path": str(root)}, } def _run_validators( loaded: Mapping[str, Any], validators: Sequence[CheckpointValidator] | None, ) -> None: """Run caller-supplied validators against each loaded model entry.""" if not validators: return source = loaded.get("source", {}) source_path = ( source.get("path", "<unknown>") if isinstance(source, Mapping) else source ) for model_name, entry in loaded.get("models", {}).items(): for validator in validators: validator_name = getattr(validator, "__name__", type(validator).__name__) try: validator(model_name, entry, loaded) except Exception as exc: raise ValueError( f"Checkpoint validator {validator_name!r} failed for model " f"{model_name!r} loaded from {source_path}: {exc}" ) from exc def _load_mace_checkpoint( checkpoint_path: Path, *, map_location: str | torch.device | None, adapter_kwargs: Mapping[str, Any] | None, ) -> dict[str, Any]: """Load a local MACE checkpoint through :class:`MACEWrapper`.""" kwargs = dict(adapter_kwargs or {}) allowed = {"model_name", "dtype", "enable_cueq", "compile_model", "compile_kwargs"} unknown = sorted(set(kwargs) - allowed) if unknown: raise ValueError(f"Unknown MACE adapter option(s): {unknown}.") if not checkpoint_path.is_file(): raise FileNotFoundError( "The MACE checkpoint adapter only accepts local checkpoint files; " f"{checkpoint_path} does not exist." ) model_name = kwargs.pop("model_name", "main") dtype = kwargs.pop("dtype", None) enable_cueq = kwargs.pop("enable_cueq", False) compile_model = kwargs.pop("compile_model", False) compile_kwargs = kwargs.pop("compile_kwargs", {}) if not isinstance(compile_kwargs, Mapping): raise TypeError("MACE adapter option 'compile_kwargs' must be a mapping.") device = torch.device("cpu") if map_location is None else torch.device(map_location) warnings.warn( "Loading MACE .pt checkpoints requires the MACE full-model pickle " "loader under the hood. Only load local MACE checkpoints from trusted " "sources.", UserWarning, stacklevel=2, ) from nvalchemi.models.mace import MACEWrapper model = MACEWrapper.from_checkpoint( checkpoint_path, device=device, dtype=dtype, enable_cueq=enable_cueq, compile_model=compile_model, **dict(compile_kwargs), ) return { "strategy": None, "models": { model_name: { "model": model, "spec": None, "optimizers": {}, "schedulers": {}, "metadata": {"adapter": "mace"}, } }, "manifest": None, "checkpoint_index": None, "source": {"format": "mace", "path": str(checkpoint_path)}, } def _strategy_target_device( strategy_metadata: Mapping[str, Any] | None, map_location: str | torch.device | None, ) -> torch.device | None: """Return the model/optimizer load device for a strategy checkpoint.""" if map_location is not None: return torch.device(map_location) if strategy_metadata is None: return None raw_devices = strategy_metadata.get("devices") if not isinstance(raw_devices, Sequence) or isinstance(raw_devices, str): return None if not raw_devices: return None return torch.device(raw_devices[0]) def _with_strategy_device_override( strategy_metadata: Mapping[str, Any], map_location: str | torch.device | None, ) -> dict[str, Any]: """Return strategy metadata with runtime devices overridden when requested.""" metadata = dict(strategy_metadata) if map_location is not None: metadata["devices"] = [str(torch.device(map_location))] return metadata def _build_model_from_checkpoint_spec( root: Path, name: str, *, load_location: torch.device | None, **kwargs: Any, ) -> tuple[nn.Module, BaseSpec]: """Build a model without its saved weights from its checkpoint spec. Extra ``**kwargs`` are forwarded to :meth:`BaseSpec.build` as runtime construction overrides for the model factory. """ spec = _load_spec(root / "models" / name / "spec.json") build_on_device = load_location is not None and spec.accepts_kwarg("device") build_kwargs = { **kwargs, **({"device": load_location} if build_on_device else {}), } model = spec.build(**build_kwargs) # Native checkpoints load weights and optimizer through :class:`nn.Module` # state_dict APIs; non-modules are not supported here. if not isinstance(model, nn.Module): raise RuntimeError( f"Model spec for {name!r} built {type(model)!r}, expected nn.Module." ) # Move models whose factories do not accept device after construction. # Factory-loaded models such as MACE + cuEq need the device during # construction so conversion happens on the intended accelerator. if load_location is not None and not build_on_device: model.to(load_location) return model, spec # --------------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------------
[docs] def save_checkpoint( root_folder: Path | str, models: dict[str, tuple[nn.Module, BaseSpec]] | Any | None = None, optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec]] | None = None, schedulers: ( dict[str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec]] | None ) = None, associations: _Associations | None = None, checkpoint_index: int = -1, strategy: Any | None = None, ) -> int: """Save a checkpoint with a manifest. The low-level component form accepts explicit ``models``, ``optimizers``, and ``schedulers`` mappings. The strategy-aware form accepts ``strategy=TrainingStrategy(...)`` (or the strategy as the second positional argument) and writes additional ``strategy.json`` metadata with the serializable recipe and restart counters. Parameters ---------- root_folder Root directory for the checkpoint tree. models Mapping of model name to ``(module, spec)`` pairs, or a :class:`~nvalchemi.training.strategy.TrainingStrategy` instance. optimizers Optional mapping of optimizer name to ``(optimizer, spec)`` pairs. schedulers Optional mapping of scheduler name to ``(scheduler, spec)`` pairs. associations Optional model-centric linkage mapping a model name to ``{"optimizers": [...], "schedulers": [...]}``. When ``None`` (default), associations are inferred automatically by matching optimizer ``param_groups`` to model parameters via ``data_ptr()`` identity, and schedulers to optimizers via object identity. checkpoint_index Index for the checkpoint files. ``-1`` (default) auto-increments from the manifest's last index, or starts at ``0``. strategy Optional training strategy to save as a restartable checkpoint. Returns ------- int The checkpoint index that was written. Raises ------ ValueError If an existing ``spec.json`` disagrees with the spec being saved (ignoring ``timestamp``). Examples -------- >>> import tempfile, torch.nn as nn >>> from nvalchemi.training._spec import create_model_spec >>> with tempfile.TemporaryDirectory() as tmp: ... spec = create_model_spec(nn.Linear, in_features=4, out_features=2) ... save_checkpoint(tmp, models={"main": (nn.Linear(4, 2), spec)}) 0 """ from nvalchemi.training.strategy import TrainingStrategy root = Path(root_folder) strategy_metadata: dict[str, Any] | None = None if strategy is None and isinstance(models, TrainingStrategy): strategy = models models = None if strategy is not None: if not isinstance(strategy, TrainingStrategy): raise TypeError( "strategy must be a TrainingStrategy instance; got " f"{type(strategy).__name__}." ) ( models, optimizers, schedulers, associations, strategy_metadata, ) = _strategy_components(strategy) if models is None: raise ValueError("save_checkpoint requires models=... or strategy=....") models = _checkpoint_model_components(models) optimizers = optimizers or {} schedulers = schedulers or {} if associations is None: associations = _infer_associations(models, optimizers, schedulers) else: associations = _with_scheduler_optimizer_edges( associations, optimizers, schedulers ) checkpoint_index = _resolve_checkpoint_index(root, checkpoint_index) # Save each component category for name, (module, spec) in models.items(): _save_component( root, "models", name, module.state_dict(), spec, checkpoint_index ) for name, (opt, spec) in optimizers.items(): _save_component( root, "optimizers", name, opt.state_dict(), spec, checkpoint_index ) for name, (sched, spec) in schedulers.items(): _save_component( root, "schedulers", name, sched.state_dict(), spec, checkpoint_index ) # Write manifest — pass live dicts directly; PlainSerializer extracts keys manifest = CheckpointManifest( checkpoint_index=checkpoint_index, models=models, optimizers=optimizers, schedulers=schedulers, associations=associations, ) manifest.write(root) if strategy_metadata is not None: _write_strategy_metadata( root, strategy_metadata, checkpoint_index=checkpoint_index ) if strategy is not None: _save_hook_states(root, _snapshot_hook_states(strategy), checkpoint_index) return checkpoint_index
[docs] def load_checkpoint( root_folder: Path | str, checkpoint_index: int = -1, map_location: str | torch.device | None = None, model_names: Iterable[str] | None = None, *, adapter: str | None = None, adapter_kwargs: Mapping[str, Any] | None = None, validators: Sequence[CheckpointValidator] | None = None, hooks: Sequence[Any] | None = None, training_fn: Any = None, strategy: Any | None = None, ) -> CheckpointManifest | dict[str, Any]: """Load a multi-component checkpoint written by :func:`save_checkpoint`. Components are rebuilt in dependency order: models first, then optimizers (which need model parameters), then schedulers (which need an optimizer instance). Associations from the manifest wire each optimizer to the correct model and each scheduler to the correct optimizer. Parameters ---------- root_folder Root directory containing ``manifest.json``. checkpoint_index Index of the checkpoint to load. ``-1`` (default) loads the latest index recorded in the manifest. map_location Forwarded to every :func:`torch.load` call. When not ``None``, each loaded model is additionally moved via ``model.to(map_location)``. Optimizers and schedulers have their state placed by ``torch.load`` alone (they lack a standard ``.to()`` API). model_names If given, load only the models with these names together with the optimizers and schedulers wired to them through ``manifest.associations``. Accepts any iterable of strings (typically a set). ``None`` (default) loads every component on disk. The returned manifest's ``associations`` still reflects the full on-disk mapping, so callers can inspect what was not loaded. adapter Optional foreign-checkpoint adapter name. V1 supports ``"mace"`` for trusted local MACE ``.pt`` files. adapter_kwargs Adapter-specific options. For ``adapter="mace"``, accepted keys are ``model_name``, ``dtype``, ``enable_cueq``, ``compile_model``, and ``compile_kwargs``. validators Optional callbacks invoked as ``validator(model_name, entry, loaded)`` for each high-level loaded model entry. Use these for model-specific chemistry or topology compatibility checks. hooks Runtime hooks supplied when reconstructing a saved strategy. training_fn Runtime training function override supplied when reconstructing a saved strategy. strategy Optional already-constructed strategy to hydrate from the checkpoint. This mode restores model, optimizer, scheduler, runtime-counter, and checkpointable hook state into the live objects instead of rebuilding models from saved specs. Returns ------- CheckpointManifest For legacy component-only checkpoints, a hydrated manifest is returned. dict[str, Any] For strategy checkpoints or adapter loads, a builtin dict containing ``strategy``, ``models``, ``manifest``, ``checkpoint_index``, and ``source`` is returned. Raises ------ FileNotFoundError If ``manifest.json`` is missing or a checkpoint ``.pt`` file does not exist. KeyError If any name in ``model_names`` does not appear in ``manifest.models``. RuntimeError If a model spec does not build an :class:`~torch.nn.Module`. Examples -------- >>> import tempfile, torch.nn as nn >>> from nvalchemi.training._spec import create_model_spec >>> with tempfile.TemporaryDirectory() as tmp: ... spec = create_model_spec(nn.Linear, in_features=4, out_features=2) ... _ = save_checkpoint(tmp, models={"main": (nn.Linear(4, 2), spec)}) ... result = load_checkpoint(tmp) ... isinstance(result.models["main"][0], nn.Linear) True Loading onto CPU regardless of the original device:: result = load_checkpoint("runs/exp1", map_location="cpu") Selecting a subset of models (e.g., teacher and student but not the third auxiliary model):: result = load_checkpoint("runs/kd", model_names={"teacher", "student"}) """ root = Path(root_folder) if adapter is not None: if strategy is not None: raise ValueError("load_checkpoint does not support strategy with adapter.") if adapter != "mace": raise ValueError( f"Unsupported checkpoint adapter {adapter!r}; supported: ['mace']." ) loaded = _load_mace_checkpoint( root, map_location=map_location, adapter_kwargs=adapter_kwargs, ) _run_validators(loaded, validators) return loaded manifest = CheckpointManifest.read(root) if checkpoint_index == -1: checkpoint_index = manifest.checkpoint_index associations = manifest.associations strategy_metadata = _read_strategy_metadata( root, checkpoint_index=checkpoint_index, latest_checkpoint_index=manifest.checkpoint_index, ) load_location = _strategy_target_device(strategy_metadata, map_location) # Path 1: restore a user-supplied live strategy. # The model structure must already match the checkpoint. # This includes registration-time module patches or adapters. if strategy is not None: if model_names is not None: raise ValueError( "load_checkpoint(strategy=...) restores the complete live strategy; " "model_names is not supported in this mode." ) loaded = _restore_checkpoint_into_strategy( root, manifest, checkpoint_index=checkpoint_index, strategy=strategy, strategy_metadata=strategy_metadata, map_location=load_location, ) if strategy_metadata is not None: loaded["strategy_metadata"] = _with_strategy_device_override( strategy_metadata, map_location ) _run_validators(loaded, validators) return loaded # Path 2: rebuild the strategy from strategy metadata. # Registration-time hooks run before weights are loaded. if strategy_metadata is not None and model_names is None: from nvalchemi.training.strategy import TrainingStrategy # Build models from specs without loading weights. unweighted_models = { name: _build_model_from_checkpoint_spec( root, name, load_location=load_location, )[0] for name in manifest.models } loaded_strategy_models: Any = unweighted_models if strategy_metadata.get("single_model_input") is True and set( unweighted_models ) == {"main"}: loaded_strategy_models = unweighted_models["main"] # Reconstruct the strategy and run registration-time hooks. runtime_strategy_metadata = _with_strategy_device_override( strategy_metadata, map_location ) restored_strategy = TrainingStrategy.from_checkpoint_dict( runtime_strategy_metadata, models=loaded_strategy_models, hooks=hooks, # extra user-supplied runtime hooks training_fn=training_fn, ) # Load model weights and optimizer/scheduler/runtime state. loaded = _restore_checkpoint_into_strategy( root, manifest, checkpoint_index=checkpoint_index, strategy=restored_strategy, strategy_metadata=runtime_strategy_metadata, map_location=load_location, ) loaded["strategy_metadata"] = runtime_strategy_metadata _run_validators(loaded, validators) return loaded # Path 3: component-level loads: either the checkpoint has no strategy # metadata, or ``model_names`` requested a partial load from a strategy # checkpoint. Partial loads do not reconstruct strategy hooks. # Determine what models to load. selected_models = set(manifest.models) if model_names is None else set(model_names) unknown = selected_models - set(manifest.models) if unknown: raise KeyError( f"Unknown model(s) {sorted(unknown)!r}. " f"Available: {sorted(manifest.models)!r}" ) # Build the load set as the union of each selected model's associations. # When ``model_names is None`` this is equivalent to loading every # component listed in the manifest. models_to_load = [n for n in manifest.models if n in selected_models] if model_names is None: optimizers_to_load = list(manifest.optimizers) schedulers_to_load = list(manifest.schedulers) else: wanted_optimizers: set[str] = set() wanted_schedulers: set[str] = set() for n in selected_models: assoc = associations.get(n, {}) wanted_optimizers.update(_assoc_names(assoc, "optimizers")) wanted_schedulers.update(_assoc_names(assoc, "schedulers")) optimizers_to_load = [n for n in manifest.optimizers if n in wanted_optimizers] schedulers_to_load = [n for n in manifest.schedulers if n in wanted_schedulers] # --- Models --- loaded_models: dict[str, tuple[nn.Module, BaseSpec]] = {} for name in models_to_load: model, spec = _build_model_from_checkpoint_spec( root, name, load_location=load_location, ) weights = torch.load( root / "models" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=load_location, ) model.load_state_dict(weights) loaded_models[name] = (model, spec) # --- Optimizers --- loaded_optimizers: dict[str, tuple[torch.optim.Optimizer, BaseSpec]] = {} for name in optimizers_to_load: spec = _load_spec(root / "optimizers" / name / "spec.json") params = _find_associated_model_params(name, associations, loaded_models) optimizer = spec.build(params) state = torch.load( root / "optimizers" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=load_location, ) optimizer.load_state_dict(state) loaded_optimizers[name] = (optimizer, spec) # --- Schedulers --- loaded_schedulers: dict[ str, tuple[torch.optim.lr_scheduler.LRScheduler, BaseSpec] ] = {} for name in schedulers_to_load: spec = _load_spec(root / "schedulers" / name / "spec.json") assoc_optimizer = _find_associated_optimizer( name, associations, loaded_optimizers ) scheduler = spec.build(assoc_optimizer) state = torch.load( root / "schedulers" / name / "checkpoints" / f"{checkpoint_index}.pt", weights_only=True, map_location=load_location, ) scheduler.load_state_dict(state) loaded_schedulers[name] = (scheduler, spec) # Hydrate manifest with live objects manifest.models = loaded_models manifest.optimizers = loaded_optimizers manifest.schedulers = loaded_schedulers manifest.checkpoint_index = checkpoint_index if strategy_metadata is None: if validators is not None: loaded = _manifest_to_loaded_checkpoint(manifest, root=root) _run_validators(loaded, validators) return manifest loaded = _manifest_to_loaded_checkpoint( manifest, root=root, strategy=None, ) if strategy_metadata is not None: loaded["strategy_metadata"] = _with_strategy_device_override( strategy_metadata, map_location ) _run_validators(loaded, validators) return loaded
def _load_spec(spec_path: Path) -> BaseSpec: """Read and rehydrate a :class:`BaseSpec` from *spec_path*.""" if not spec_path.exists(): raise FileNotFoundError(f"Expected spec at {spec_path} but file not found.") return create_model_spec_from_json(json.loads(spec_path.read_text()))