Source code for nvalchemi.hooks.reporting._orchestrator

# 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.
"""Hook-native reporting orchestrator."""

from __future__ import annotations

import warnings
from collections.abc import Sequence
from enum import Enum
from types import TracebackType

from torch import distributed as dist

from nvalchemi.hooks._context import HookContext
from nvalchemi.hooks.reporting._protocol import Reporter
from nvalchemi.hooks.reporting._state import ReportingState

ReportingStage = Enum | str

DEFAULT_REPORT_STAGES: frozenset[str] = frozenset(
    {"AFTER_OPTIMIZER_STEP", "AFTER_STEP"}
)


class ReportingErrorPolicy(str, Enum):
    """Policy used when an individual reporter raises.

    Attributes
    ----------
    RAISE : ReportingErrorPolicy
        Re-raise reporter exceptions.
    WARN : ReportingErrorPolicy
        Emit :class:`UserWarning` and continue to later reporters.
    IGNORE : ReportingErrorPolicy
        Record the error in :class:`ReportingState` and continue silently.
    """

    RAISE = "raise"
    WARN = "warn"
    IGNORE = "ignore"


[docs] class ReportingOrchestrator: """Fan out hook contexts to reporting sinks. ``ReportingOrchestrator`` is itself a normal hook. It uses ``_runs_on_stage`` so it can be registered with both training and dynamics hook registries while still choosing the workflow stages it observes. Parameters ---------- reporters : Sequence[Reporter] Reporters to call in order for each reporting event. frequency : int, optional Run every ``frequency`` workflow steps, using the existing hook registry gating. Default ``1``. stages : set[Enum | str] | None, optional Stages to report. Enum values are matched by identity; strings are matched against enum member names. Defaults to ``{"AFTER_OPTIMIZER_STEP", "AFTER_STEP"}``, which gives once-per-step training and dynamics reporting without importing either workflow. rank_zero_only : bool, optional If ``True``, suppress child reporters on nonzero ranks unless they expose ``requires_all_ranks=True`` for distributed collectives. Individual reporters may also expose ``rank_zero_only=True`` to request their own gating. Default ``False``. error_policy : ReportingErrorPolicy | str, optional Reporter failure handling policy. Default ``ReportingErrorPolicy.RAISE`` (the string ``"raise"`` is also accepted). state : ReportingState | None, optional Shared reporting state. If omitted, a new state object is created. """ def __init__( self, reporters: Sequence[Reporter], *, frequency: int = 1, stages: set[ReportingStage] | None = None, rank_zero_only: bool = False, error_policy: ReportingErrorPolicy | str = ReportingErrorPolicy.RAISE, state: ReportingState | None = None, ) -> None: self.reporters = list(reporters) self.frequency = frequency self.stage: Enum | None = None self.rank_zero_only = rank_zero_only self.error_policy = ReportingErrorPolicy(error_policy) self.state = state if state is not None else ReportingState() self._stages = frozenset( stages if stages is not None else DEFAULT_REPORT_STAGES ) self._context_depth = 0 self._entered_reporters: list[Reporter] = [] self._disabled_reporter_ids: set[int] = set() self._closed = False @property def global_rank(self) -> int: """Return the current distributed rank, or zero outside distributed runs.""" if dist.is_available() and dist.is_initialized(): return dist.get_rank() return 0 @property def is_rank_zero(self) -> bool: """Return whether this process is rank zero.""" return self.global_rank == 0 def _runs_on_stage(self, stage: Enum) -> bool: """Return whether reporting should run for ``stage``. Parameters ---------- stage : Enum Hook stage under consideration. Returns ------- bool ``True`` when the orchestrator should receive this stage. """ return stage in self._stages or stage.name in self._stages def __call__(self, ctx: HookContext, stage: Enum) -> None: """Dispatch one hook event to child reporters. Parameters ---------- ctx : HookContext Workflow hook context. stage : Enum Hook stage being dispatched. """ active_reporters = [ reporter for reporter in self.reporters if id(reporter) not in self._disabled_reporter_ids and not self._skip_reporter_for_rank(reporter) ] if not active_reporters: return self.state.mark_event(ctx, stage) for reporter in active_reporters: try: reporter.report(ctx, stage, self.state) except Exception as exc: self._handle_reporter_error( reporter, exc, ctx, stage, operation="report" ) def __enter__(self) -> ReportingOrchestrator: """Enter reporters that implement the context manager protocol.""" if self._context_depth > 0: self._context_depth += 1 return self self._closed = False self._entered_reporters = [] self._disabled_reporter_ids = set() for reporter in self.reporters: if self._skip_reporter_for_rank(reporter): self._disabled_reporter_ids.add(id(reporter)) continue enter = getattr(reporter, "__enter__", None) if enter is not None: try: enter() except Exception as exc: self._disabled_reporter_ids.add(id(reporter)) self._handle_enter_error(reporter, exc) else: self._entered_reporters.append(reporter) self._context_depth = 1 return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Exit reporters without replacing an active workflow exception.""" if self._context_depth == 0: return self._context_depth -= 1 if self._context_depth > 0: return self._finish_close(exc_type, exc, tb)
[docs] def close(self) -> None: """Close reporters in reverse order.""" self._finish_close(None, None, None)
def _reporter_rank_zero_only(self, reporter: Reporter) -> bool: """Return whether ``reporter`` requests rank-zero-only dispatch.""" return bool(getattr(reporter, "rank_zero_only", False)) def _reporter_requires_all_ranks(self, reporter: Reporter) -> bool: """Return whether ``reporter`` must be dispatched on every rank.""" return bool(getattr(reporter, "requires_all_ranks", False)) def _skip_reporter_for_rank(self, reporter: Reporter) -> bool: """Return whether ``reporter`` should be skipped on this rank.""" if self.is_rank_zero: return False if self._reporter_requires_all_ranks(reporter): return False return self.rank_zero_only or self._reporter_rank_zero_only(reporter) def _handle_enter_error(self, reporter: Reporter, exc: Exception) -> None: """Handle a reporter ``__enter__`` failure.""" if self.error_policy == ReportingErrorPolicy.RAISE: try: self._close_reporters( list(self._entered_reporters), type(exc), exc, exc.__traceback__, preserve_workflow_exception=True, ) finally: self._entered_reporters = [] self._closed = True self._handle_reporter_error(reporter, exc, operation="enter") def _finish_close( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Close reporters once and reset lifecycle state.""" if self._closed: self._context_depth = 0 return try: self._close_reporters( self.reporters, exc_type, exc, tb, preserve_workflow_exception=exc_type is not None, ) finally: self._entered_reporters = [] self._context_depth = 0 self._closed = True def _close_reporters( self, reporters: Sequence[Reporter], exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, *, preserve_workflow_exception: bool = False, ) -> None: """Close reporters, preferring ``__exit__`` for entered reporters.""" errors: list[tuple[str, Exception]] = [] entered_ids = {id(reporter) for reporter in self._entered_reporters} for reporter in reversed(reporters): reporter_id = id(reporter) if ( reporter_id in self._disabled_reporter_ids and reporter_id not in entered_ids ): continue exit_fn = getattr(reporter, "__exit__", None) close_fn = getattr(reporter, "close", None) was_entered = reporter_id in entered_ids if (not was_entered or exit_fn is None) and close_fn is None: continue try: if was_entered and exit_fn is not None: exit_fn(exc_type, exc, tb) else: close_fn() except Exception as close_exc: message = self._record_reporter_error( reporter, close_exc, operation="close", ) errors.append((message, close_exc)) self._apply_close_error_policy(errors, preserve_workflow_exception) def _apply_close_error_policy( self, errors: Sequence[tuple[str, Exception]], preserve_workflow_exception: bool, ) -> None: """Apply failure policy after all close attempts have completed.""" if not errors or self.error_policy == ReportingErrorPolicy.IGNORE: return if ( self.error_policy == ReportingErrorPolicy.WARN or preserve_workflow_exception ): for message, _ in errors: warnings.warn(message, UserWarning, stacklevel=2) return raise errors[0][1] def _handle_reporter_error( self, reporter: Reporter, exc: Exception, ctx: HookContext | None = None, stage: Enum | None = None, *, operation: str, preserve_workflow_exception: bool = False, ) -> None: """Apply the configured reporter failure policy.""" message = self._record_reporter_error( reporter, exc, ctx=ctx, stage=stage, operation=operation, ) if self.error_policy == ReportingErrorPolicy.IGNORE: return if ( self.error_policy == ReportingErrorPolicy.WARN or preserve_workflow_exception ): warnings.warn(message, UserWarning, stacklevel=2) return raise exc def _record_reporter_error( self, reporter: Reporter, exc: Exception, ctx: HookContext | None = None, stage: Enum | None = None, *, operation: str, ) -> str: """Record a reporter error message and return its text.""" message = ( f"{type(reporter).__name__} failed during {operation}: " f"{type(exc).__name__}: {exc}" ) self.state.add_message( "error", message, reporter=reporter, ctx=ctx, stage=stage, ) return message