Source code for nvalchemi.hooks.reporting._tensorboard

# 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.
"""TensorBoard reporting sink."""

from __future__ import annotations

from collections.abc import Mapping
from enum import Enum
from pathlib import Path
from types import TracebackType
from typing import Protocol

from torch import distributed as dist

from nvalchemi._optional import OptionalDependency
from nvalchemi.hooks._context import HookContext
from nvalchemi.hooks.reporting._distributed import (
    normalize_rank_reduction,
    reduce_scalar_snapshot,
)
from nvalchemi.hooks.reporting._scalars import ScalarCallback, collect_scalars
from nvalchemi.hooks.reporting._state import ReportingState


class TensorBoardWriter(Protocol):
    """Subset of ``SummaryWriter`` used by :class:`TensorBoardReporter`."""

    def add_scalar(
        self,
        tag: str,
        scalar_value: float,
        global_step: int | None = None,
    ) -> None:
        """Write one scalar event.

        Parameters
        ----------
        tag : str
            TensorBoard scalar tag.
        scalar_value : float
            Scalar value to write.
        global_step : int | None, optional
            Step associated with the scalar.
        """
        ...

    def flush(self) -> None:
        """Flush pending TensorBoard events."""
        ...

    def close(self) -> None:
        """Close the writer."""
        ...


[docs] @OptionalDependency.TENSORBOARD.require class TensorBoardReporter: """Write scalar reporting snapshots to TensorBoard. Parameters ---------- log_dir : str | Path TensorBoard log directory. custom_scalars : Mapping[str, ScalarCallback] | None, optional Additional scalar callbacks passed to :func:`collect_scalars`. include_losses : bool, default True When ``True``, include loss scalars from the hook context. include_optimizer_lrs : bool, default True When ``True``, include optimizer learning rates from the hook context. rank_reduction : torch.distributed.ReduceOp | {"none", "mean", "sum", "min", "max"} | None, default None Optional distributed reduction applied to scalars before writing. String values are normalized to :class:`torch.distributed.ReduceOp`. Reduction requires every rank to call this reporter; only rank zero writes the reduced snapshot. tag_prefix : str | None, optional Optional prefix prepended to every TensorBoard tag. flush : bool, default True Flush the writer after every report event. rank_zero_only : bool, default True Request rank-zero-only dispatch from :class:`ReportingOrchestrator`. When ``False`` and ``rank_reduction="none"``, ``log_dir`` must contain ``"{rank}"`` or ``"{global_rank}"`` so every rank writes its own event directory. writer : TensorBoardWriter | None, optional Preconstructed writer. This is mainly useful for tests or integrations that own writer construction. """ def __init__( self, log_dir: str | Path, *, custom_scalars: Mapping[str, ScalarCallback] | None = None, include_losses: bool = True, include_optimizer_lrs: bool = True, rank_reduction: dist.ReduceOp | str | None = None, tag_prefix: str | None = None, flush: bool = True, rank_zero_only: bool = True, writer: TensorBoardWriter | None = None, ) -> None: self.rank_reduction = rank_reduction self._rank_reduction_op, _ = normalize_rank_reduction(rank_reduction) self.log_dir = Path(log_dir) self.custom_scalars = custom_scalars self.include_losses = include_losses self.include_optimizer_lrs = include_optimizer_lrs self.tag_prefix = tag_prefix.strip("/") if tag_prefix is not None else None self.flush = flush self._write_rank_zero_only = ( rank_zero_only or self._rank_reduction_op is not None ) self.rank_zero_only = rank_zero_only and self._rank_reduction_op is None self.requires_all_ranks = self._rank_reduction_op is not None self._writer = writer self._external_writer = writer is not None self._open_log_dir: Path | None = None if not self._write_rank_zero_only and not self._has_rank_token: raise ValueError( "TensorBoardReporter log_dir must contain '{rank}' or " "'{global_rank}' when rank_zero_only=False and " "rank_reduction='none'." ) def __enter__(self) -> TensorBoardReporter: """Return this reporter; writers are opened lazily on first write.""" return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Close the TensorBoard writer.""" self.close()
[docs] def close(self) -> None: """Close the writer if it is open.""" if self._writer is None: return self._writer.close() self._writer = None self._open_log_dir = None
[docs] def report(self, ctx: HookContext, stage: Enum, state: ReportingState) -> None: """Write one scalar snapshot to TensorBoard. Parameters ---------- ctx : HookContext Workflow hook context. stage : Enum Hook stage being reported. state : ReportingState Shared reporting state from the orchestrator. """ snapshot = collect_scalars( ctx, stage, state, custom_scalars=self.custom_scalars, include_losses=self.include_losses, include_optimizer_lrs=self.include_optimizer_lrs, ) if self._rank_reduction_op is not None: snapshot = reduce_scalar_snapshot( snapshot, self.rank_reduction, reporter_name=type(self).__name__, ) if not self._is_rank_zero(ctx): return elif self._write_rank_zero_only and not self._is_rank_zero(ctx): return writer = self._open(self._resolve_log_dir(ctx.global_rank)) step = snapshot.step_count if snapshot.step_count is not None else None if step is None: step = snapshot.event_count for key, value in sorted(snapshot.scalars.items()): writer.add_scalar(self._tag(key), value, global_step=step) if self.flush: writer.flush()
@property def _has_rank_token(self) -> bool: path = str(self.log_dir) return "{rank}" in path or "{global_rank}" in path def _open(self, log_dir: Path) -> TensorBoardWriter: if self._writer is not None and self._external_writer: return self._writer if self._writer is not None and self._open_log_dir == log_dir: return self._writer if self._writer is not None: self.close() from torch.utils.tensorboard import SummaryWriter self._writer = SummaryWriter(log_dir=str(log_dir)) self._open_log_dir = log_dir return self._writer def _resolve_log_dir(self, global_rank: int) -> Path: path = str(self.log_dir) path = path.replace("{global_rank}", str(global_rank)) path = path.replace("{rank}", str(global_rank)) return Path(path) def _tag(self, key: str) -> str: return key if self.tag_prefix is None else f"{self.tag_prefix}/{key}" def _is_rank_zero(self, ctx: HookContext) -> bool: return ctx.global_rank == 0