Source code for nvalchemi.distributed._core.context

# 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.

"""Per-scope distributed runtime metadata.

:class:`DistributedContext` is the single object carrying the
runtime-only metadata that a model wrapper needs to read on every
forward pass under a :class:`~nvalchemi.distributed.DistributedModel`
scope.

Lifecycle::

    DistributedModel.__enter__
        ctx = DistributedContext(...)               # built once
        wrapper.distributed_setup(ctx)              # wrapper stashes ref
    DistributedModel.__call__(sharded_batch)
        ctx.halo_meta   = sharded.halo_meta         # per-step write
        ctx.gather_meta = ...                       # per-step write
        wrapper(padded)                              # wrapper reads ctx
    DistributedModel.__exit__
        wrapper.distributed_teardown()              # wrapper drops ref

The ctx is *mutable* by design: per-step values like
:attr:`halo_meta` / :attr:`gather_meta` are updated by the framework
on every forward pass, with the wrapper holding a single reference
that always observes the current state.

Part of the upstream-candidate ``_core/`` surface; must not import
from ``nvalchemi.models`` / ``nvalchemi.data`` / ``nvalchemi.dynamics`` /
``nvalchemi.distributed._chemistry``.
"""

from __future__ import annotations

import contextvars
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

import torch

if TYPE_CHECKING:
    from collections.abc import Iterator

__all__ = [
    "NOT_DISTRIBUTED",
    "DistributedContext",
    "activate_dd_context",
    "current_dd_context",
]


@dataclass
class DistributedContext:
    """Runtime metadata for one ``DistributedModel`` scope.

    Mutable by design — see module docstring for the lifecycle.

    Attributes
    ----------
    mesh
        The :class:`torch.distributed.device_mesh.DeviceMesh` that the
        scope's collective ops dispatch over. ``None`` for single-rank
        runs.
    halo_config
        :class:`~nvalchemi.distributed._core.particle_halo.ParticleHaloConfig`
        carrying the partitioner + ghost-width + process group needed
        for halo exchanges. Set once in
        :meth:`DistributedModel._ensure_initialized`; stays constant
        across calls.
    n_systems_global
        Number of graphs in the global batch (i.e. before sharding).
        Used by wrappers that need to size per-system tensors against
        the un-partitioned count rather than the per-rank slice.
    n_atoms_total
        Number of atoms in the global batch. Used by Ewald / PME for
        cache sizing of reciprocal-space resources keyed on the global
        atom count.
    halo_meta
        Per-step :class:`~nvalchemi.distributed._core.particle_halo.ParticleHaloMetadata`
        produced by the latest halo exchange. ``None`` outside of the
        halo-storage call path or before the first forward pass.
    gather_meta
        Per-step :class:`~nvalchemi.distributed._core.gather_primitives.ShardRouting`
        produced by the latest sharded-storage call. ``None`` outside of
        the sharded-storage path or before the first forward pass.
    """

    mesh: Any = None
    # The active field StoragePolicy; the per-layer intent verbs
    # (refresh_neighbors / scatter_to_owners) delegate their cross-rank behavior
    # to it so a new strategy plugs in without framework branches.
    policy: Any = None
    halo_config: Any = None
    n_systems_global: int | None = None
    n_atoms_total: int | None = None
    halo_meta: Any = None
    gather_meta: Any = None
    # The active ParallelizationStrategy for this scope, published so spec-declared
    # adapters can reach the strategy's layout verbs without the framework branching
    # on strategy type. Set by each strategy's run_forward.
    strategy: Any = None
    # First row of this rank's owned slice within the per-rank node tensor. 0
    # when owned rows come first (halo padded view; node-partition shard), so
    # owned-only reductions slice ``[:n_owned]``. Under the node-replicate
    # strategy every rank holds the full node set, so its owned rows are an
    # interior slice ``[owned_offset : owned_offset + n_owned]`` instead.
    owned_offset: int = 0
    # Fixed-shape-padding cap state (grow-on-overflow / stride buckets), owned by
    # the framework. ``DistributedModel`` points this at its persistent per-model
    # cap dict each forward, so a wrapper that pads inside its own forward
    # (AIMNet2's dense nbmat, UMA's fairchem graph) grows the SAME caps the
    # framework persists across MD steps — via ``current_dd_context().cap_state``
    # + the shared ``resolve_cap`` — instead of a private holder.
    cap_state: dict[str, int] = field(default_factory=dict)
    # The fixed-shape-caps GraphPadder for a model that builds + internally
    # compiles its own graph (UMA): set by ``DistributedModel`` for this forward
    # so the wrapper can pad its adapted graph in one call via
    # :meth:`maybe_pad_graph`. ``None`` when no caps apply (single-process, or a
    # model the framework pads at the Batch level instead).
    graph_padder: Any = None
    # Whether the active strategy caps the atom dim (halo: owned+ghost fluctuate
    # → True; graph-parallel node partition: fixed atom set → edge-only, False).
    # The strategy publishes it in ``run_forward``; :meth:`maybe_pad_graph` hands
    # it to the padder so *what* to cap is strategy-driven, not model-hardcoded.
    cap_atoms: bool = True
    # Free-form scratch space for wrapper-private state that should
    # share the ctx's lifetime. Kept untyped on purpose — the spec
    # layer is generic and shouldn't know about per-wrapper conventions.
    extras: dict[str, Any] = field(default_factory=dict)

    # ------------------------------------------------------------------
    # Derived read-only view (the ``current_dd_context()`` vocabulary).
    #
    # These properties expose the runtime facts an adapter body / refresh
    # hook needs, derived from the per-step metadata the framework writes
    # above. Compile-safety: ``policy`` / ``world_size`` / ``rank`` /
    # ``compiling`` are constant for a forward and safe anywhere;
    # ``n_owned`` / ``n_padded`` *vary* per step and must only be read in
    # eager or ``@torch._dynamo.disable``d code (the value would otherwise
    # bake into a compiled graph as a stale constant).
    # ------------------------------------------------------------------

    @property
    def is_halo(self) -> bool:
        """True when this forward runs on the halo-storage path."""
        return self.halo_meta is not None

    @property
    def is_sharded(self) -> bool:
        """True when this forward runs on the sharded-storage path."""
        return self.gather_meta is not None

    @property
    def is_distributed(self) -> bool:
        """True inside a real domain-decomposed forward.

        False for the :data:`NOT_DISTRIBUTED` sentinel and for any
        single-process call, so distribution-agnostic helper bodies can
        early-return to plain local behavior.
        """
        if self.is_halo or self.is_sharded:
            return True
        return self.mesh is not None and self.world_size > 1

    @property
    def rank(self) -> int:
        """This process's rank within the scope's mesh (0 if unknown)."""
        cfg = self.halo_config
        if cfg is not None and getattr(cfg, "rank", None) is not None:
            return int(cfg.rank)
        if self.mesh is not None:
            try:
                return int(self.mesh.get_local_rank())
            except Exception:  # pragma: no cover — defensive
                return 0
        return 0

    @property
    def world_size(self) -> int:
        """Number of ranks in the scope (1 if single-process)."""
        if self.halo_meta is not None:
            return len(self.halo_meta.send_sizes)
        if self.mesh is not None:
            try:
                return int(self.mesh.size())
            except Exception:  # pragma: no cover — defensive
                return 1
        return 1

    @property
    def n_owned(self) -> int | None:
        """Rows this rank owns this step (``None`` if not yet known).

        Varies per step — read only in eager / dynamo-disabled code.
        """
        if self.halo_meta is not None:
            return int(self.halo_meta.n_owned)
        if self.gather_meta is not None:
            return int(self.gather_meta.n_owned)
        return None

    @property
    def n_padded(self) -> int | None:
        """Count of real rows in this rank's node tensor (``None`` if unknown).

        On the halo path this is owned + halo (``n_padded``). Under the
        node-replicate strategy every rank holds the full node set, so it is the
        global node count (``gather_meta.n_global``) — every real row is present.
        Varies per step — read only in eager / dynamo-disabled code.
        """
        if self.halo_meta is not None:
            return int(self.halo_meta.n_padded)
        if self.gather_meta is not None:
            return int(self.gather_meta.n_global)
        return None

    def maybe_pad_graph(self, data: Any) -> Any:
        """Pad a model's adapted graph to fixed per-rank shapes, if caps apply.

        The one-call seam a model that builds + internally compiles its own graph
        (e.g. UMA's fairchem graph) uses inside its forward: when the framework has
        set :attr:`graph_padder` for this forward it pads ``data`` to the
        persistent :attr:`cap_state` capacities; otherwise (single-process, or a
        model padded at the Batch level) it returns ``data`` unchanged. The
        framework owns the matching ``unpad`` / ``restore`` after the forward, so
        the wrapper carries no other caps logic.

        Parameters
        ----------
        data
            The model's adapted graph/input to pad in place.

        Returns
        -------
        Any
            ``data`` padded to the fixed caps, or unchanged when no padder is set.
        """
        if self.graph_padder is None:
            return data
        return self.graph_padder.pad(data, self.cap_state, cap_atoms=self.cap_atoms)

    @property
    def compiling(self) -> bool:
        """True while tracing under ``torch.compile``.

        A helper that reads varying state (``n_owned`` …) must consult
        this and route varying values through threaded graph inputs
        rather than baking the Python value.
        """
        return bool(torch.compiler.is_compiling())


# ----------------------------------------------------------------------
# Ambient accessor — the public ``current_dd_context()`` surface.
#
# The framework activates the live :class:`DistributedContext` for the
# duration of the wrapper's forward (see ``DistributedModel`` /
# ``DomainParallel``); adapter bodies and refresh hooks read it through
# :func:`current_dd_context`, the way ``torch.no_grad()`` is read.
# ----------------------------------------------------------------------

#: Returned by :func:`current_dd_context` outside any DD forward. Inert:
#: ``is_distributed`` is False, so single-process code that happens to
#: call a context-aware helper falls through to plain local behavior.
NOT_DISTRIBUTED = DistributedContext()

_ACTIVE_DD_CONTEXT: contextvars.ContextVar[DistributedContext | None] = (
    contextvars.ContextVar("nvalchemi_active_dd_context", default=None)
)


[docs] def current_dd_context() -> DistributedContext: """Return the live DD context for the current forward. Inside a :class:`~nvalchemi.distributed.DomainParallel` / ``DistributedModel`` forward this is the framework's per-step context (policy, halo metadata, counts). Outside one — single-process code, or before the first forward — it is the inert :data:`NOT_DISTRIBUTED` sentinel. Read it in eager code (or under ``@torch._dynamo.disable``) only. Inside a compiled region the varying fields would bake as stale constants; code there receives what it needs as threaded graph inputs instead. """ return _ACTIVE_DD_CONTEXT.get() or NOT_DISTRIBUTED
@contextmanager def activate_dd_context(ctx: DistributedContext) -> Iterator[DistributedContext]: """Make ``ctx`` the active context for the duration of the block. The framework wraps each wrapper forward in this scope so :func:`current_dd_context` resolves to the live, per-step context. Restores the previous context on exit (re-entrant via :class:`contextvars.ContextVar`). """ token = _ACTIVE_DD_CONTEXT.set(ctx) try: yield ctx finally: _ACTIVE_DD_CONTEXT.reset(token)