Source code for nvalchemi.training.hooks.update

# 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.
"""Training-update hook base class and orchestrator."""

from __future__ import annotations

from collections.abc import Iterator, Sequence
from types import TracebackType
from typing import TYPE_CHECKING, Any, ClassVar

from nvalchemi.hooks._context import TrainContext
from nvalchemi.hooks._protocol import Hook
from nvalchemi.training._stages import TrainingStage
from nvalchemi.training.optimizers import (
    _is_metric_driven,
    step_lr_schedulers,
    step_optimizers,
    zero_gradients,
)

if TYPE_CHECKING:
    import torch


_TRAINING_UPDATE_STAGES: tuple[TrainingStage, ...] = (
    TrainingStage.BEFORE_BATCH,
    TrainingStage.DO_BACKWARD,
    TrainingStage.DO_OPTIMIZER_STEP,
    TrainingStage.AFTER_OPTIMIZER_STEP,
)


_MULTIPLE_ORCHESTRATOR_MSG = (
    "Only one TrainingUpdateOrchestrator is allowed; compose update hooks "
    "with `+` before registration."
)


def _hook_claims_stage(hook: Any, stage: TrainingStage) -> bool:
    """Return True if hook fires on stage (mirrors _registry._call_hooks dispatch)."""
    runs_on_stage = getattr(hook, "_runs_on_stage", None)
    if runs_on_stage is not None:
        return runs_on_stage(stage)
    return getattr(hook, "stage", None) == stage


def _fold_training_update_hooks(
    hooks: Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator],
) -> list[Hook | TrainingUpdateOrchestrator]:
    """Fold TrainingUpdateHook/Orchestrator instances into a single orchestrator."""
    others: list[Hook] = []
    update_hooks: list[TrainingUpdateHook | TrainingUpdateOrchestrator] = []
    update_insertion_index: int | None = None
    n_orch = 0
    for h in hooks:
        if isinstance(h, TrainingUpdateOrchestrator):
            if update_insertion_index is None:
                update_insertion_index = len(others)
            update_hooks.append(h)
            n_orch += 1
        elif isinstance(h, TrainingUpdateHook):
            if update_insertion_index is None:
                update_insertion_index = len(others)
            update_hooks.append(h)
        else:
            others.append(h)
    if not update_hooks:
        return list(hooks)
    if n_orch > 1:
        raise ValueError(_MULTIPLE_ORCHESTRATOR_MSG)
    if len(update_hooks) == 1 and isinstance(
        update_hooks[0], TrainingUpdateOrchestrator
    ):
        folded = update_hooks[0]
    else:
        folded = TrainingUpdateOrchestrator(*update_hooks)
    insert_at = (
        update_insertion_index if update_insertion_index is not None else len(others)
    )
    result: list[Hook | TrainingUpdateOrchestrator] = list(others)
    result.insert(insert_at, folded)
    return result


def _check_veto(decision: object, hook: object, stage: TrainingStage) -> None:
    """Validate that ``__call__`` returned a strict ``bool`` for ``proceed``."""
    if not isinstance(decision, bool):
        raise TypeError(
            f"{type(hook).__name__}.__call__(stage={stage.name}) must return "
            f"(bool, Tensor | None); proceed got {type(decision).__name__}. "
            "Return True to proceed or False to skip."
        )


def _require_loss(
    loss: torch.Tensor | None, hook: object, stage: TrainingStage
) -> torch.Tensor:
    """Return ``loss`` or raise a stage-specific error for missing losses."""
    if loss is None:
        raise TypeError(
            f"{type(hook).__name__} did not provide a Tensor loss for "
            f"{stage.name}; got None."
        )
    return loss


def _get_scaler_scale(scaler: object) -> float | None:
    """Return the scaler scale as ``float`` when the scaler exposes one."""
    get_scale = getattr(scaler, "get_scale", None)
    if get_scale is None:
        return None
    try:
        return float(get_scale())
    except (TypeError, ValueError):
        return None


def _grad_scaler_step_skipped(
    grad_scaler: Any, opt: torch.optim.Optimizer
) -> bool | None:
    """Return whether ``grad_scaler.step(opt)`` skipped the optimizer step."""
    try:
        found_inf = grad_scaler._found_inf_per_device(opt)
    except Exception:
        return None
    try:
        return any(bool(v.item()) for v in found_inf.values())
    except Exception:
        return None


def _step_optimizers_with_context(ctx: TrainContext) -> bool:
    """Step optimizers/schedulers and return whether optimizer stepping ran."""
    if ctx.grad_scaler is None:
        step_optimizers(ctx.optimizers)
        step_lr_schedulers(ctx.lr_schedulers)
        return True

    if not ctx.lr_schedulers or all(sched is None for sched in ctx.lr_schedulers):
        pre_scale = _get_scaler_scale(ctx.grad_scaler)
        for opt in ctx.optimizers:
            ctx.grad_scaler.step(opt)
        ctx.grad_scaler.update()
        post_scale = _get_scaler_scale(ctx.grad_scaler)
        return (
            True if pre_scale is None or post_scale is None else post_scale >= pre_scale
        )

    skipped_flags: list[bool | None] = []
    for opt in ctx.optimizers:
        ctx.grad_scaler.step(opt)
        skipped_flags.append(_grad_scaler_step_skipped(ctx.grad_scaler, opt))

    need_fallback = any(flag is None for flag in skipped_flags)
    pre_scale = _get_scaler_scale(ctx.grad_scaler) if need_fallback else None
    ctx.grad_scaler.update()
    post_scale = _get_scaler_scale(ctx.grad_scaler) if need_fallback else None
    fallback_skipped = (
        need_fallback
        and pre_scale is not None
        and post_scale is not None
        and post_scale < pre_scale
    )
    schedulers = list(ctx.lr_schedulers)
    if len(schedulers) < len(skipped_flags):
        schedulers.extend([None] * (len(skipped_flags) - len(schedulers)))
    step_skipped_flags = [
        skipped is True or (fallback_skipped and skipped is None)
        for skipped in skipped_flags
    ]
    if not any(step_skipped_flags):
        step_lr_schedulers(ctx.lr_schedulers)
        return True
    for sched, step_skipped in zip(schedulers, step_skipped_flags, strict=True):
        if sched is None:
            continue
        if _is_metric_driven(sched):
            continue
        if step_skipped:
            continue
        sched.step()
    return False


[docs] class TrainingUpdateHook: """Base class for hooks that customize training-update phases. Subclasses override :meth:`__call__` and dispatch on ``stage`` to handle one or more claimed stages: ``BEFORE_BATCH``, ``DO_BACKWARD``, ``DO_OPTIMIZER_STEP``, ``AFTER_OPTIMIZER_STEP``. Compose via ``+`` to build a :class:`TrainingUpdateOrchestrator`. See :ref:`training-update-hooks` for the stage contract and restrictions each update hook must follow. Attributes ---------- priority : int Dispatch order within an orchestrator; lower runs first. Canonical buckets: 10 = gradient accumulation, 20 = mixed precision, 30 = gradient clipping, 40 = spike skipping. Default 50. _exclusive_update_key : str | None Optional key for hook families that must appear at most once inside an orchestrator. Notes ----- ``TrainingUpdateHook`` is NOT directly compatible with the standard :class:`Hook` Protocol -- its ``__call__`` signature includes a ``will_skip`` argument and returns ``(bool, torch.Tensor | None)`` rather than the Protocol's ``__call__(ctx, stage) -> None``. This is intentional: ``Hook`` is a structural Protocol so domain-specific hook families can use signatures suited to their semantics. Bare instances must be composed via ``+`` or wrapped by a :class:`TrainingUpdateOrchestrator` (the strategy auto-wraps lone hooks); the orchestrator owns Protocol compliance. ``will_skip`` is a stage-local cumulative veto signal. It is ``True`` when an earlier, higher-priority hook has already requested that the current stage's gated operation be skipped. The orchestrator still calls later hooks after a veto so they can observe the decision, update bookkeeping, or emit diagnostics, but those hooks should avoid side effects that assume the gated operation will run. A hook may also return ``False`` to veto the operation for lower-priority hooks. This signal is intended for composable pipeline behavior. For example, a gradient-accumulation hook can veto ``DO_OPTIMIZER_STEP`` on non-step microbatches; later hooks then receive ``will_skip=True`` and can skip work such as gradient clipping, scaler updates, or expensive parameter scans. ``will_skip`` is reset for each stage dispatch and should not be interpreted as a global training-step status unless the orchestrator also records that state on ``ctx``. Each ``__call__`` returns ``(proceed, loss)``: - ``proceed`` is a strict ``bool`` (``int``/``None`` raise ``TypeError``). On ``BEFORE_BATCH`` and ``DO_OPTIMIZER_STEP`` the orchestrator applies any-veto-wins composition: if any hook returns ``False`` the gated operation (``zero_gradients`` or ``optimizer/scheduler.step``) is skipped. On ``DO_BACKWARD`` and ``AFTER_OPTIMIZER_STEP`` the value is unused; return ``True``. - ``loss`` is the loss tensor the hook would use, transformed or not. Default is ``ctx.loss`` unchanged. The orchestrator threads it through hooks in priority order during ``DO_BACKWARD`` so each hook sees its predecessor's transform; ``backward()`` runs once on the final loss. Hooks that run on stages other than ``DO_BACKWARD`` may return ``None`` for ``loss`` because the orchestrator ignores it there. Examples -------- >>> import torch >>> from nvalchemi.training._stages import TrainingStage >>> class ClipGrads(TrainingUpdateHook): ... priority = 30 ... def __init__(self, max_norm): ... self.max_norm = max_norm ... def __call__(self, ctx, stage, will_skip): ... match stage: ... case TrainingStage.DO_OPTIMIZER_STEP: ... if not will_skip: ... for opt in ctx.optimizers: ... params = (p for g in opt.param_groups for p in g["params"]) ... torch.nn.utils.clip_grad_norm_(params, self.max_norm) ... return True, ctx.loss ... case _: ... return True, ctx.loss """ priority: int = 50 _exclusive_update_key: ClassVar[str | None] = None def _runs_on_stage(self, stage: TrainingStage) -> bool: """Return ``True`` for stages a training-update hook claims.""" return stage in _TRAINING_UPDATE_STAGES def __call__( self, ctx: TrainContext, stage: TrainingStage, will_skip: bool, ) -> tuple[bool, torch.Tensor | None]: """Run the hook for an update stage. Parameters ---------- ctx : TrainContext Mutable training context shared by all hooks during the current stage dispatch. stage : TrainingStage Update stage currently being dispatched. will_skip : bool ``True`` when an earlier, higher-priority hook has already vetoed the gated operation for ``stage``. Hooks should use this to skip side effects that only make sense when the operation will run, while still performing any bookkeeping that must happen on every dispatch. Returns ------- tuple[bool, torch.Tensor | None] ``(proceed, loss)``. ``proceed`` controls the skip signal passed to subsequent hooks: ``True`` keeps the pipeline proceeding, while ``False`` causes later hooks to receive ``will_skip=True`` and skips the gated operation for ``stage``. ``loss`` is the loss tensor to pass to subsequent hooks; return ``ctx.loss`` unchanged when the hook does not transform the loss. """ return True, ctx.loss def __add__( self, other: TrainingUpdateHook | TrainingUpdateOrchestrator ) -> TrainingUpdateOrchestrator: """Compose this hook with another update hook or orchestrator. Parameters ---------- other : TrainingUpdateHook | TrainingUpdateOrchestrator Hook or orchestrator to compose with this hook. Returns ------- TrainingUpdateOrchestrator Orchestrator containing this hook and ``other``. Hook execution order is determined by ``priority`` after composition. """ if not isinstance(other, (TrainingUpdateHook, TrainingUpdateOrchestrator)): return NotImplemented return TrainingUpdateOrchestrator(self, other)
[docs] class TrainingUpdateOrchestrator: """Composes :class:`TrainingUpdateHook` instances and drives updates. Claims the training-update stages ``BEFORE_BATCH``, ``DO_BACKWARD``, ``DO_OPTIMIZER_STEP``, ``AFTER_OPTIMIZER_STEP``. The strategy also calls the orchestrator during ``SETUP`` so child hooks can initialize runtime state before the first batch. Per-stage behavior is selected by direct :class:`TrainingStage` comparisons to avoid per-batch multiple-dispatch overhead. See :ref:`training-update-hooks` for the stage contract enforced by the orchestrator. Parameters ---------- *hooks : TrainingUpdateHook or TrainingUpdateOrchestrator Hooks to compose. Any orchestrator argument is flattened into its children. Members are sorted by ``priority`` ascending; ties preserve insertion order (Python's stable sort). Attributes ---------- frequency : int Required by the :class:`Hook` Protocol; always ``1``. stage : None Set to ``None`` so the registry consults ``_runs_on_stage``. Raises ------ TypeError If any positional argument is not a ``TrainingUpdateHook`` or ``TrainingUpdateOrchestrator``. Notes ----- ``TrainingUpdateOrchestrator`` IS compatible with the standard :class:`Hook` Protocol -- it is the registry-facing wrapper around one or more :class:`TrainingUpdateHook` instances. Concrete training update hooks (``EMAHook``, ``GradientClipHook``, etc.) are NOT directly Protocol-compliant on their own; they must be composed into an orchestrator before registration. The training strategy auto-wraps a bare :class:`TrainingUpdateHook` for convenience. On ``DO_BACKWARD`` each hook returns ``(_, loss)``; the orchestrator assigns ``ctx.loss = loss`` between hooks so the next hook sees the transformed value. ``backward()`` is called once on the final ``ctx.loss``. Example: a ``*0.5`` hook followed by a ``*2.0`` hook leaves ``ctx.loss`` equal to the original loss before backward. """ frequency: int = 1 stage = None def __init__(self, *hooks: TrainingUpdateHook | TrainingUpdateOrchestrator) -> None: flattened: list[TrainingUpdateHook] = [] for i, h in enumerate(hooks): if isinstance(h, TrainingUpdateOrchestrator): flattened.extend(h._hooks) elif isinstance(h, TrainingUpdateHook): flattened.append(h) else: raise TypeError( f"argument {i} must be TrainingUpdateHook or " f"TrainingUpdateOrchestrator; got {type(h).__name__}. " "If you have an iterable, call " "TrainingUpdateOrchestrator(*hooks)." ) flattened.sort(key=lambda h: h.priority) exclusive_hooks: dict[str, TrainingUpdateHook] = {} for hook in flattened: key = hook._exclusive_update_key if key is None: continue if key in exclusive_hooks: first = type(exclusive_hooks[key]).__name__ second = type(hook).__name__ raise ValueError( f"Only one update hook with exclusive key {key!r} may be " f"registered; got {first} and {second}." ) exclusive_hooks[key] = hook self._hooks: list[TrainingUpdateHook] = flattened self._optimizer_step_skipped = False def _runs_on_stage(self, stage: TrainingStage) -> bool: """Return ``True`` for the stages this orchestrator claims.""" return stage in _TRAINING_UPDATE_STAGES def __enter__(self) -> TrainingUpdateOrchestrator: """Enter lifecycle contexts owned by child update hooks.""" for hook in self._hooks: enter = getattr(hook, "__enter__", None) if enter is not None: enter() return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Exit or close lifecycle contexts owned by child update hooks.""" for hook in reversed(self._hooks): exit_ = getattr(hook, "__exit__", None) if exit_ is not None: exit_(exc_type, exc, tb) else: close = getattr(hook, "close", None) if close is not None: close()
[docs] def close(self) -> None: """Close child update hooks that expose ``close``.""" for hook in reversed(self._hooks): close = getattr(hook, "close", None) if close is not None: close()
@property def optimizer_step_skipped(self) -> bool: """Whether the most recent optimizer-step stage was vetoed.""" return self._optimizer_step_skipped
[docs] def iter_hooks(self) -> Iterator[TrainingUpdateHook]: """Yield child update hooks in orchestrator dispatch order.""" return iter(self._hooks)
def _should_run_gated_stage(self, ctx: TrainContext, stage: TrainingStage) -> bool: """Run all hooks for a gated stage and return the any-veto-wins decision.""" should_run = True for hook in self._hooks: proceed, _ = hook(ctx, stage, not should_run) _check_veto(proceed, hook, stage) should_run = proceed and should_run return should_run def __call__(self, ctx: TrainContext, stage: TrainingStage) -> None: """Run orchestrator logic for ``stage`` when it is an update stage.""" match stage: case TrainingStage.SETUP: for hook in self._hooks: hook(ctx, stage, False) case TrainingStage.BEFORE_BATCH: # situation where this may skip is gradient accumulation; otherwise # the typical workflow would be to actually zero gradients if self._should_run_gated_stage(ctx, stage): zero_gradients(ctx.optimizers) clear_filtered = getattr( ctx.workflow, "_zero_optimizer_filtered_gradients", None ) if callable(clear_filtered): clear_filtered(ctx.optimizers) case TrainingStage.DO_BACKWARD: for hook in self._hooks: _, loss = hook(ctx, stage, False) ctx.loss = _require_loss(loss, hook, stage) _require_loss(ctx.loss, self, stage).backward() case TrainingStage.DO_OPTIMIZER_STEP: # situation where this might be skipped is during gradient # accumulation, or perhaps spike skipping should_run = self._should_run_gated_stage(ctx, stage) if should_run: should_run = _step_optimizers_with_context(ctx) self._optimizer_step_skipped = not should_run case TrainingStage.AFTER_OPTIMIZER_STEP: for hook in self._hooks: hook(ctx, stage, self._optimizer_step_skipped) def __add__( self, other: TrainingUpdateHook | TrainingUpdateOrchestrator ) -> TrainingUpdateOrchestrator: """Implements the syntactic sugar to compose multiple update hooks together""" if not isinstance(other, (TrainingUpdateHook, TrainingUpdateOrchestrator)): return NotImplemented return TrainingUpdateOrchestrator(self, other)