Source code for nvalchemi.training.hooks.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.
"""Periodic checkpoint-saving training hook."""

from __future__ import annotations

from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
from types import TracebackType
from typing import Annotated, ClassVar

from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator

from nvalchemi.hooks._context import TrainContext
from nvalchemi.training._checkpoint import (
    _create_checkpoint_snapshot,
    _write_checkpoint_snapshot,
)
from nvalchemi.training._stages import TrainingStage

__all__ = ["CheckpointHook"]


[docs] class CheckpointHook(BaseModel): """Periodically save restartable training strategy checkpoints. The hook observes completed training counters and saves :class:`~nvalchemi.training.strategy.TrainingStrategy` checkpoints through the same manifest layout as :func:`nvalchemi.training.save_checkpoint`. It fires either every ``step_interval`` completed optimizer steps or every ``epoch_interval`` completed epochs. The two cadences are mutually exclusive so each hook owns one clear checkpoint policy. With ``async_save=True`` (default), the hook first captures an immutable CPU snapshot of model, optimizer, scheduler, and strategy metadata on the training thread, then writes that snapshot on a single background thread. This avoids racing against live training tensors while still moving the filesystem work off the critical path. If a later checkpoint is due while the previous background write is still running, the hook waits for the previous write before capturing the next snapshot so manifest indices stay ordered. Raises ------ ValueError If neither interval is provided, or an interval is not positive. RuntimeError If the hook is called without a strategy workflow in ``TrainContext``. Examples -------- >>> from nvalchemi.training import CheckpointHook, TrainingStrategy >>> hook = CheckpointHook("runs/example/checkpoints", step_interval=1000) >>> strategy = TrainingStrategy(..., hooks=[hook]) # doctest: +SKIP >>> strategy.run(train_loader) # doctest: +SKIP """ checkpoint_dir: Annotated[ Path, Field(description="Root directory for restartable training checkpoints."), ] step_interval: Annotated[ int | None, Field(default=None, gt=0, description="Completed-step save interval."), ] = None epoch_interval: Annotated[ int | None, Field(default=None, gt=0, description="Completed-epoch save interval."), ] = None async_save: Annotated[ bool, Field(description="Write checkpoint snapshots on a background thread."), ] = True rank_zero_only: Annotated[ bool, Field(description="Restrict checkpoint writes to distributed rank 0."), ] = True last_checkpoint_index: Annotated[ int | None, Field( default=None, ge=0, exclude=True, description=( "Most recent checkpoint index known to have been written. In " "async mode, this updates when the background future completes." ), ), ] = None frequency: ClassVar[int] = 1 stage: ClassVar[TrainingStage | None] = None model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") _executor: ThreadPoolExecutor | None = PrivateAttr(default=None) _future: Future[int] | None = PrivateAttr(default=None) def __init__( self, checkpoint_dir: Path | str | None = None, **data: object ) -> None: """Initialize the hook, accepting ``checkpoint_dir`` positionally.""" if checkpoint_dir is not None: if "checkpoint_dir" in data: raise TypeError( "CheckpointHook got checkpoint_dir both positionally and " "as a keyword argument." ) data["checkpoint_dir"] = checkpoint_dir super().__init__(**data) @model_validator(mode="after") def _validate_cadence(self) -> CheckpointHook: """Require exactly one save cadence.""" if (self.epoch_interval is None) == (self.step_interval is None): raise ValueError( "CheckpointHook requires exactly one of step_interval or " "epoch_interval." ) return self def _runs_on_stage(self, stage: TrainingStage) -> bool: """Return whether this hook observes a training stage.""" return ( self.step_interval is not None and stage is TrainingStage.AFTER_BATCH ) or (self.epoch_interval is not None and stage is TrainingStage.AFTER_EPOCH) def __enter__(self) -> CheckpointHook: """Create the background writer when async checkpointing is enabled.""" if self.async_save and self._executor is None: self._executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix="nvalchemi-checkpoint", ) return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Flush any pending checkpoint write before leaving training.""" del exc, tb try: self.close() except Exception: if exc_type is None: raise def close(self) -> None: """Wait for pending async writes and close the background writer.""" try: self._finish_pending(block=True) finally: if self._executor is not None: self._executor.shutdown(wait=True) self._executor = None def _finish_pending(self, *, block: bool) -> None: """Collect a pending async result, optionally waiting for it.""" if self._future is None: return if not block and not self._future.done(): return self.last_checkpoint_index = self._future.result() self._future = None def _should_save(self, ctx: TrainContext, stage: TrainingStage) -> bool: """Return whether ``ctx`` reaches the configured save cadence.""" if self.rank_zero_only and ctx.global_rank != 0: return False if ( stage is TrainingStage.AFTER_BATCH and self.step_interval is not None and ctx.step_count > 0 ): return ctx.step_count % self.step_interval == 0 if ( stage is TrainingStage.AFTER_EPOCH and self.epoch_interval is not None and ctx.epoch > 0 ): return ctx.epoch % self.epoch_interval == 0 return False def _save_checkpoint(self, ctx: TrainContext) -> None: """Capture and write one strategy checkpoint.""" if ctx.workflow is None: raise RuntimeError( "CheckpointHook requires TrainContext.workflow to reference " "the active TrainingStrategy." ) self._finish_pending(block=False) if self._future is not None: self._finish_pending(block=True) snapshot = _create_checkpoint_snapshot( self.checkpoint_dir, strategy=ctx.workflow, ) if not self.async_save: self.last_checkpoint_index = _write_checkpoint_snapshot( self.checkpoint_dir, snapshot, ) return if self._executor is None: raise RuntimeError( "CheckpointHook async writer is not initialized. Run it through " "TrainingStrategy so hook contexts are entered, or call " "__enter__() before invoking the hook directly." ) self._future = self._executor.submit( _write_checkpoint_snapshot, self.checkpoint_dir, snapshot, ) def __call__(self, ctx: TrainContext, stage: TrainingStage) -> None: """Save a checkpoint when the configured cadence is reached.""" if self._should_save(ctx, stage): self._save_checkpoint(ctx)