Source code for nvalchemi.hooks.physicsnemo_profiling

# 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.
"""PhysicsNeMo-backed PyTorch profiler hook."""

from __future__ import annotations

from collections.abc import Callable
from enum import Enum
from pathlib import Path
from typing import Annotated, Any, ClassVar

from physicsnemo.utils.profiling import (
    Profiler,
    TorchProfilerConfig,
    TorchProfileWrapper,
)
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from torch.profiler import ProfilerActivity

from nvalchemi.distributed import (
    DistributedManager,
    resolve_global_rank,
    resolve_world_size,
)
from nvalchemi.hooks._context import HookContext

__all__ = ["TorchProfilerHook"]


def _parse_activity(activity: ProfilerActivity | str) -> ProfilerActivity:
    """Normalize a profiler activity enum or string alias."""
    if isinstance(activity, ProfilerActivity):
        return activity
    normalized = activity.lower()
    match normalized:
        case "cpu":
            return ProfilerActivity.CPU
        case "cuda":
            return ProfilerActivity.CUDA
        case _:
            raise ValueError(
                f"Unknown profiler activity {activity!r}; expected 'cpu' or 'cuda'."
            )


[docs] class TorchProfilerHook(BaseModel): """Capture PyTorch profiler traces through PhysicsNeMo's profiler wrapper. ``TorchProfilerHook`` drives PhysicsNeMo's :class:`~physicsnemo.utils.profiling.Profiler` (backed by :class:`~physicsnemo.utils.profiling.TorchProfileWrapper`) so that ``torch.profiler`` traces are collected for an nvalchemi workflow without hand-rolling profiler setup, stepping, and finalization. The same hook attaches to both training and dynamics workflows: it recognizes :attr:`TrainingStage.BEFORE_TRAINING`, :attr:`~TrainingStage.BEFORE_BATCH`, :attr:`~TrainingStage.AFTER_BATCH`, and :attr:`~TrainingStage.AFTER_TRAINING`, plus :attr:`DynamicsStage.BEFORE_STEP` and :attr:`~DynamicsStage.AFTER_STEP`. The profiler starts when the hook enters its context (``__enter__``) or, if it is dispatched by a workflow without being used as a context manager, lazily on the first supported start stage. It advances the ``torch.profiler`` schedule once per batch or dynamics step (at ``AFTER_BATCH`` / ``AFTER_STEP``) and finalizes traces at ``AFTER_TRAINING`` or when the hook context closes. Register it like any other hook by adding it to a strategy's or dynamics object's ``hooks=[...]`` list; for dynamics runs it is also valid to wrap the run in a ``with`` block so start/finalize bracket exactly the profiled region. Outputs are written under ``output_dir`` (named by ``name``). In distributed runs, or whenever ``rank_subdirs`` is set, per-process outputs land in ``output_dir / rank_<global_rank>``, and the optional ``on_trace_ready_path`` TensorBoard handler directory is rank-suffixed the same way. Activity selection accepts either :class:`~torch.profiler.ProfilerActivity` values or the string aliases ``"cpu"`` / ``"cuda"``; ``None`` lets PhysicsNeMo pick CPU and CUDA when available. Examples -------- Profile a training run by registering the hook alongside the strategy's other hooks: >>> import torch # doctest: +SKIP >>> from nvalchemi.hooks.physicsnemo_profiling import TorchProfilerHook # doctest: +SKIP >>> from nvalchemi.training import ( # doctest: +SKIP ... EnergyMSELoss, OptimizerConfig, TrainingStrategy, default_training_fn, ... ) >>> profiler = TorchProfilerHook( # doctest: +SKIP ... output_dir="prof/train", ... activities=("cpu", "cuda"), ... record_shapes=True, ... profile_memory=True, ... with_flops=True, ... ) >>> strategy = TrainingStrategy( # doctest: +SKIP ... models=model, ... optimizer_configs=OptimizerConfig( ... optimizer_cls=torch.optim.Adam, optimizer_kwargs={"lr": 1e-3}, ... ), ... training_fn=default_training_fn, ... loss_fn=EnergyMSELoss(), ... num_epochs=1, ... devices=[torch.device("cuda")], ... hooks=[profiler], ... ) >>> strategy.run(train_loader) # doctest: +SKIP For dynamics, use the hook as a context manager so the profiler brackets the exact steps you care about: >>> hook = TorchProfilerHook(output_dir="prof/md", activities=("cuda",)) # doctest: +SKIP >>> with hook: # doctest: +SKIP ... dynamics.run(batch, num_steps=100) Notes ----- Only one PhysicsNeMo profiler may be active at a time: ``_start`` raises a :class:`RuntimeError` if the global :class:`~physicsnemo.utils.profiling.Profiler` is already initialized or enabled, so construct and register this hook before any other PhysicsNeMo profiler configuration. The hook is single-use — once finalized it cannot be restarted, and calling it (or re-entering it) after ``close`` raises. Finalization happens at ``AFTER_TRAINING`` or on context exit; dynamics workflows that never emit an ``AFTER_TRAINING`` stage should be run under the ``with`` block (or have ``close`` called) to flush traces. ``frequency`` is a :class:`ClassVar`-style workflow field, and ``stage`` is ``None`` because the hook handles multiple stages itself rather than binding to a single one. """ output_dir: Annotated[ Path, Field(description="Root directory for PhysicsNeMo profiler outputs."), ] activities: Annotated[ tuple[ProfilerActivity, ...] | None, Field( default=None, description=( "PyTorch profiler activities, or None to let PhysicsNeMo " "choose CPU and CUDA when available." ), ), ] = None schedule: Annotated[ Callable[..., Any] | None, Field(default=None, description="Optional torch.profiler schedule."), ] = None record_shapes: Annotated[ bool, Field(description="Record input tensor shapes in the trace.") ] = True profile_memory: Annotated[ bool, Field(description="Profile memory allocations.") ] = True with_flops: Annotated[ bool, Field(description="Estimate FLOPs for supported operations.") ] = True with_stack: Annotated[bool, Field(description="Record Python stack traces.")] = ( False ) on_trace_ready_path: Annotated[ Path | None, Field( default=None, description="Optional path for PyTorch tensorboard trace handler output.", ), ] = None frequency: Annotated[ int, Field( default=1, ge=1, description="Run every N workflow steps.", ), ] = 1 name: Annotated[ str, Field(default="torch", description="PhysicsNeMo profiler output name."), ] = "torch" rank_subdirs: Annotated[ bool, Field( default=True, description="Write nvalchemi-managed outputs under rank_<global_rank>.", ), ] = True stage: ClassVar[Enum | None] = None model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=False, extra="forbid", ) _profiler: Any | None = PrivateAttr(default=None) _torch_profiler: Any | None = PrivateAttr(default=None) _started: bool = PrivateAttr(default=False) _closed: bool = PrivateAttr(default=False) _entered_context: bool = PrivateAttr(default=False) @field_validator("activities", mode="before") @classmethod def _normalize_activities(cls, value: Any) -> tuple[ProfilerActivity, ...] | None: """Normalize activity aliases before pydantic validation.""" if value is None: return None if isinstance(value, (str, ProfilerActivity)): raw_values = (value,) else: raw_values = tuple(value) return tuple(_parse_activity(activity) for activity in raw_values) def __enter__(self) -> TorchProfilerHook: """Enter the hook context and start profiling.""" self._start() return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any, ) -> None: """Finalize profiler output when a workflow context exits.""" self.close() def _runs_on_stage(self, stage: Enum) -> bool: """Return whether this hook handles ``stage``. Parameters ---------- stage : Enum Workflow stage enum value. Returns ------- bool ``True`` for supported training and dynamics stages. """ from nvalchemi.dynamics.base import DynamicsStage from nvalchemi.training._stages import TrainingStage match stage: case ( TrainingStage.BEFORE_TRAINING | TrainingStage.BEFORE_BATCH | TrainingStage.AFTER_BATCH | TrainingStage.AFTER_TRAINING | DynamicsStage.BEFORE_STEP | DynamicsStage.AFTER_STEP ): return True case _: return False def __call__(self, ctx: HookContext, stage: Enum) -> None: """Handle a supported training or dynamics stage. Parameters ---------- ctx : HookContext Workflow context containing rank and workflow metadata. stage : Enum Current workflow stage. """ from nvalchemi.dynamics.base import DynamicsStage from nvalchemi.training._stages import TrainingStage match stage: case TrainingStage.BEFORE_TRAINING | DynamicsStage.BEFORE_STEP: self._start(ctx) case TrainingStage.BEFORE_BATCH if not self._started: self._start(ctx) case TrainingStage.AFTER_BATCH | DynamicsStage.AFTER_STEP: if not self._started: self._start(ctx) if self._profiler is not None: self._profiler.step() case TrainingStage.AFTER_TRAINING: self.close() case _: return def _start(self, ctx: HookContext | None = None) -> None: """Start the PhysicsNeMo profiler.""" if self._started: return if self._closed: raise RuntimeError( "TorchProfilerHook cannot be restarted after it has finalized." ) profiler = Profiler() if getattr(profiler, "initialized", False) or getattr( profiler, "enabled", False ): raise RuntimeError( "PhysicsNeMo Profiler is already initialized or enabled. " "Create and register TorchProfilerHook before other " "PhysicsNeMo profiler configuration, or finalize the existing " "profiler before starting this hook." ) rank = resolve_global_rank(None if ctx is None else ctx.global_rank) output_path = self._resolve_output_path(rank) trace_path = self._resolve_trace_path(rank) output_path.mkdir(parents=True, exist_ok=True) if trace_path is not None: trace_path.mkdir(parents=True, exist_ok=True) config = TorchProfilerConfig( name=self.name, torch_prof_activities=self.activities, record_shapes=self.record_shapes, with_stack=self.with_stack, profile_memory=self.profile_memory, with_flops=self.with_flops, schedule=self.schedule, on_trace_ready_path=trace_path, ) torch_profiler = TorchProfileWrapper(config) enabled_torch_profiler = profiler.enable("torch") profiler.output_path = output_path profiler.__enter__() self._profiler = profiler self._torch_profiler = enabled_torch_profiler or torch_profiler self._started = True self._entered_context = True def _resolve_output_path(self, rank: int) -> Path: """Return the PhysicsNeMo output path for this process.""" output_dir = self.output_dir if DistributedManager.is_initialized() and not DistributedManager().distributed: return output_dir if self.rank_subdirs or resolve_world_size() > 1: return output_dir / f"rank_{rank}" return output_dir def _resolve_trace_path(self, rank: int) -> Path | None: """Return the rank-specific tensorboard trace path, if configured.""" if self.on_trace_ready_path is None: return None return self.on_trace_ready_path / f"rank_{rank}" def close(self) -> None: """Finalize profiler outputs once.""" if not self._started: return if self._profiler is None: return if self._entered_context: self._profiler.__exit__(None, None, None) self._entered_context = False self._profiler.finalize() self._started = False self._closed = True