Source code for nvalchemi.distributed.distributed_model

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

"""Adapter that turns a single-process model wrapper into a distributed callable.

Pattern::

    wrapper    = MACEWrapper.from_checkpoint("small")
    sharded    = ShardedBatch.from_batch(full_batch, mesh=m, config=cfg)
    dist_model = DistributedModel(wrapper, cfg)
    out        = dist_model(sharded)        # dict[str, Tensor]
    e          = out["energy"]              # globally reduced
    f          = out["forces"]              # per-rank owned rows

The adapter owns framework concerns (halo padding, output consolidation) so
inner wrappers stay single-process-focused. Per-model distributed knowledge
lives in the wrapper's ``distribution_spec``.

Composite wrappers (``PipelineModelWrapper``) are rejected here — use
``DistributedPipelineModel`` for distributed composition.
"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any

import torch

from nvalchemi.data import Batch
from nvalchemi.distributed._core.context import (
    DistributedContext,
    activate_dd_context,
)
from nvalchemi.distributed._core.particle_halo import ParticleHaloConfig
from nvalchemi.distributed.config import DomainConfig
from nvalchemi.distributed.partitioner import SpatialPartitioner
from nvalchemi.neighbors import compute_neighbors

if TYPE_CHECKING:
    from nvalchemi.distributed.sharded_batch import ShardedBatch
from nvalchemi.models._utils import prepare_strain
from nvalchemi.models.base import BaseModelMixin

__all__ = ["DistributedModel", "DistributionError"]


def isolate_compile_cache_per_rank() -> None:
    """Give each rank its own ``torch.compile`` on-disk cache dir (multi-rank DD).

    The inductor FxGraphCache + the AOTAutograd cache default to one shared dir;
    under multi-rank DD a rank can deserialize another rank's guarded entry and
    raise a ``KeyError`` mid-forward → a skipped collective → NCCL deadlock.
    Pointing each rank at its own dir removes the collision while keeping the
    caches ON (disabling them instead re-lowers the AOT graph every step).

    Idempotent and launcher-friendly: only sets a var that is currently unset (so a
    launcher/user setting wins), keys off ``LOCAL_RANK`` (torchrun) / ``RANK``, and
    is a no-op single-process. These vars are read when inductor/triton actually
    lower a graph — i.e. at the *first forward*, not when ``torch.compile`` merely
    wraps the model — so calling this at ``DistributedModel`` construction (via
    :func:`_configure_dd_dynamo`) reliably lands before the first DD forward. That
    covers the common ``from_checkpoint(compile_model=True)`` -> ``DistributedModel``
    order: the loader only wraps the model (lazily) and runs no forward, so nothing
    is lowered until the DD forward, by which point the dirs are set. The one gap is
    a forward triggered *before* construction (e.g. a manual sanity-check call); for
    that, a launcher exporting these from ``LOCAL_RANK`` at process start is the
    bulletproof path.
    """
    import os  # noqa: PLC0415

    rank = os.environ.get("LOCAL_RANK") or os.environ.get("RANK")
    if rank is None:
        return
    import tempfile  # noqa: PLC0415

    root = os.path.join(tempfile.gettempdir(), "nvalchemi_dd_compile_cache")
    for _var, _sub in (
        ("TORCHINDUCTOR_CACHE_DIR", "inductor"),
        ("TRITON_CACHE_DIR", "triton"),
    ):
        os.environ.setdefault(_var, os.path.join(root, f"{_sub}_rank{rank}"))


def _configure_dd_dynamo() -> None:
    """Tune Dynamo/inductor for a distributed forward.

    Safe (and correct) to call whenever a model runs under a multi-rank DD scope —
    compiled by the framework OR pre-compiled by its own loader (e.g. MACE
    ``loader.compile``), since the hazards below are triggered by the *wrapped*
    model recompiling under DD, independent of who invoked ``torch.compile``.

    1. **Recompile ceiling.** The fixed-shape caps grow during warmup
       (``max_send`` / ``n_cap`` / ``e_cap`` each bump shapes a few times before
       settling), so more than the default 8 recompiles are expected. If a rank
       hits the limit and stops recompiling mid-warmup it diverges from its peer
       and the halo all-to-all deadlocks (NCCL watchdog timeout). torch>=2.6
       renamed ``cache_size_limit`` -> ``recompile_limit`` (plus the accumulated
       twin); set whichever names exist so the ceiling actually takes effect.
    2. **Per-rank on-disk compile caches.** The inductor FxGraphCache + the
       (separate) AOTAutograd cache key on the *local* rank's graph but default to
       ONE shared dir, so under multi-rank DD a rank can deserialize another rank's
       guarded entry and hit a ``KeyError`` (e.g. a cueq segment ``lengths`` dim) →
       skip a collective → NCCL deadlock. The fix is to point each rank at its OWN
       cache dir (keeping the caches ON — disabling them re-lowers the AOT graph
       every step). This calls :func:`isolate_compile_cache_per_rank`, a no-op if the
       dirs are already set. Since those dirs are read at first-forward lowering (not
       when ``torch.compile`` wraps the model) and this runs at ``DistributedModel``
       construction, it lands before the first DD forward — including the common
       ``from_checkpoint(compile_model=True)`` -> ``DistributedModel`` order, where
       the loader only wraps (lazily) and runs no forward. A launcher exporting the
       dirs from ``LOCAL_RANK`` at process start still covers the edge case of a
       forward triggered before construction.
    """
    import torch._dynamo as _td  # noqa: PLC0415

    for _attr, _val in (
        ("recompile_limit", 64),
        ("cache_size_limit", 64),
        ("accumulated_recompile_limit", 512),
        ("accumulated_cache_size_limit", 512),
    ):
        if hasattr(_td.config, _attr):
            setattr(_td.config, _attr, max(getattr(_td.config, _attr), _val))
    _td.config.force_parameter_static_shapes = False

    isolate_compile_cache_per_rank()


def _prepare_dd_compile(spec: "Any", compile_kwargs: "dict | None") -> dict:
    """Validate the spec supports a compiled distributed forward and return the
    resolved ``torch.compile`` kwargs.

    Distributed compile is fixed-shape (graphs are padded to per-rank caps), so
    ``dynamic`` defaults to ``False``. Dynamo tuning is applied separately in
    :func:`_configure_dd_dynamo` (unconditionally at scope setup). Raises if the
    spec declares no ``CompilePolicy``.
    """
    cp = getattr(spec, "compile", None)
    if cp is None:
        raise DistributionError(
            "compile=True requires the model's distribution_spec to declare a "
            "CompilePolicy (force_strategy). This model does not support a "
            "compiled distributed forward."
        )
    import os  # noqa: PLC0415

    # Optional activation-memory budget (env-gated): backward can't recompute
    # across opaque custom ops, so it saves their outputs, which dominates peak
    # memory. <1.0 recomputes the rest instead. Unset -> default 1.0.
    _actb = os.environ.get("NVALCHEMI_ACT_BUDGET")
    if _actb:
        import torch._functorch.config as _fcfg  # noqa: PLC0415

        _fcfg.activation_memory_budget = float(_actb)
    # A wired consumer backwards through this graph twice; AOT's donated-buffer
    # optimisation frees buffers the second pass needs.
    import torch._functorch.config as _fcfg2  # noqa: PLC0415

    _fcfg2.donated_buffer = False

    ck = dict(compile_kwargs or {})
    ck.setdefault("dynamic", False)
    return ck


def _wrapper_is_precompiled(wrapper: "Any") -> bool:
    """True if the wrapper already holds a ``torch.compile``-d module.

    ``torch.compile`` returns an ``OptimizedModule`` (carrying ``_orig_mod``); a
    wrapper that compiled itself in its loader (e.g. MACE
    ``from_checkpoint(compile_model=True)``) holds one in its module tree.
    Walking the tree keeps the check model-agnostic. This matters because running
    the *eager* distributed path against such a model is silently wrong: the halo
    correction (eager per-op handlers, or the compile-refresh adapters) never sees
    the message-passing ops sealed inside the loader's compiled graph, so every
    rank returns an uncorrected owned-only forward.
    """
    try:
        from torch._dynamo.eval_frame import OptimizedModule  # noqa: PLC0415
    except Exception:  # pragma: no cover - torch internal layout changed
        OptimizedModule = None

    def _is_optimized(obj: "Any") -> bool:
        if OptimizedModule is not None and isinstance(obj, OptimizedModule):
            return True
        return type(obj).__name__ == "OptimizedModule"

    modules = getattr(wrapper, "modules", None)
    if callable(modules):
        for m in modules():
            if _is_optimized(m):
                return True
    # The compiled module need not sit in the ``nn.Module`` tree — a wrapper may
    # hold it on a plain helper object — so also scan non-Module attributes.
    seen: set[int] = set()

    def _scan(obj: "Any", depth: int) -> bool:
        if depth < 0 or id(obj) in seen:
            return False
        seen.add(id(obj))
        state = getattr(obj, "__dict__", None)
        if not isinstance(state, dict):
            return False
        for value in state.values():
            if _is_optimized(value):
                return True
            if isinstance(value, torch.nn.Module):
                continue  # already covered by the tree walk
            if hasattr(value, "__dict__") and _scan(value, depth - 1):
                return True
        return False

    return _scan(wrapper, 2)


def _partition_health_verdict(
    n_owned: int, n_padded: int, group: Any, device: Any
) -> tuple[bool, bool, int]:
    """Collective verdict on halo-partition health, identical on every rank.

    Returns ``(any_empty, any_trivial, n_global)``:

    * ``any_empty`` — some rank has 0 owned atoms (more ranks than the geometry
      can fill); the caller raises. Genuinely broken.
    * ``any_trivial`` — some rank's halo already covers every atom
      (0 remote atoms); the caller warns. Correct but no parallelism is gained.

    A SUM gives the global atom count; a MAX over the two flags shares the
    verdict so no rank raises while others proceed (avoids collective desync).
    """
    import torch.distributed as dist  # noqa: PLC0415

    gsum = torch.tensor([float(n_owned)], device=device)
    dist.all_reduce(gsum, op=dist.ReduceOp.SUM, group=group)
    n_global = int(round(gsum.item()))
    flags = torch.tensor(
        [1 if n_owned == 0 else 0, 1 if n_padded >= n_global else 0],
        device=device,
        dtype=torch.int32,
    )
    dist.all_reduce(flags, op=dist.ReduceOp.MAX, group=group)
    return bool(flags[0].item()), bool(flags[1].item()), n_global


def _resolve_partition_health(
    any_empty: bool,
    any_trivial: bool,
    n_global: int,
    *,
    world_size: int,
    require_nondegenerate: bool,
    rank: int,
) -> None:
    """Act on a collective partition-health verdict, identically on every rank.

    * ``any_empty`` (a rank owns 0 atoms) is always fatal — the geometry can't
      fill this many ranks.
    * ``any_trivial`` (some rank's halo covers all atoms, 0 remote) means DD
      isn't exercised: fatal when ``require_nondegenerate`` (a force-equivalence
      check there proves nothing), otherwise a rank-0 warning.

    Pure (no collectives) so the empty/trivial branches are unit-testable on
    CPU; the verdict flags are already reduced across the mesh by the caller, so
    every rank passes the same values and raises identically (no desync)."""
    if any_empty:
        raise RuntimeError(
            "Degenerate domain decomposition: a rank was assigned 0 owned "
            f"atoms (world_size={world_size}, total atoms={n_global}). There "
            "are more ranks than this geometry can partition — use fewer ranks "
            "or a larger system."
        )
    if not any_trivial:
        return
    msg = (
        "Degenerate (trivial) domain decomposition: every rank's halo already "
        f"covers all {n_global} atoms (0 remote atoms), so domain parallelism "
        "gains nothing here — each rank does the full system's work. This "
        "happens when box/ranks <= ~2*ghost_width (ghost_width = cutoff + "
        "skin); use fewer ranks or a larger system to actually decompose."
    )
    # Opt-in strict mode (tests, guaranteed-decomposed runs): a trivial
    # partition can't validate the halo path, so fail loud.
    if require_nondegenerate:
        raise RuntimeError(
            msg + " (require_nondegenerate=True — refusing to run a partition "
            "that doesn't exercise the halo boundary.)"
        )
    if rank == 0:
        from loguru import logger  # noqa: PLC0415

        logger.warning(msg + " (results are still correct.)")


def _mark_halo_receiver_edges_as_padding(padded_batch: "Batch", n_owned: int) -> None:
    """Rewrite ``neighbor_list`` so halo-receiver edges look like the
    padding-sentinel rows ``compute_neighbors`` already emits.

    Each global edge is replicated on every rank holding both endpoints, so a
    per-receiver scatter must count each edge on exactly one rank — the one
    owning its receiver — else halo-receiver edges double-count. Wrappers
    already drop genuine padding rows (indices == ``num_nodes``) via a
    ``(edge_index < n_atoms)`` filter; marking halo-receiver rows with the same
    sentinel routes them through that drop with no per-rank logic in the wrapper.

    Sync-free and idempotent: one compare plus one in-place ``masked_fill_``.
    No-ops when the ``edges`` group is missing, the NL is empty, or
    ``n_owned == n_padded`` (single-process).
    """
    edges = padded_batch._edges_group
    if edges is None:
        return
    nl = edges._data.get("neighbor_list")
    if nl is None or nl.shape[0] == 0:
        return
    sentinel = padded_batch.num_nodes  # matches compute_neighbors padding
    halo_recv = nl[:, 1] >= n_owned
    nl[:, 1].masked_fill_(halo_recv, sentinel)


def _build_halo_meta_packed(
    meta: "Any",
    config: "Any",
    device: "Any",
    n_pad: int,
    max_send_cap: "int | None" = None,
) -> "Any":
    """Build the fixed-shape halo routing tensor from the per-step ``meta``, or
    ``None`` when there is no cross-rank halo.

    Carried as a graph input under compile, this lets the compile-path halo
    handlers route through the static halo ops with the routing as a runtime
    tensor rather than baked-in constants.

    ``max_send`` is the max over ``meta.send_sizes`` — the all-gathered
    send-count matrix, identical on every rank — so the cap is consistent
    across ranks.
    """
    if not meta.send_sizes or meta.n_padded <= meta.n_owned:
        return None
    max_send = max((max(row) for row in meta.send_sizes), default=0)
    if max_send <= 0:
        return None
    # Use the fixed per-rank cap when compiling so the routing tensor keeps a
    # constant shape across steps -> no recompile as send counts drift.
    eff_max_send = int(max_send_cap) if max_send_cap is not None else int(max_send)
    from nvalchemi.distributed._core.particle_halo import (  # noqa: PLC0415
        build_halo_meta_tensors,
        pack_halo_meta,
    )

    si, rd, rr, no = build_halo_meta_tensors(
        meta, config.rank, eff_max_send, n_pad, device
    )
    return pack_halo_meta(si, rd, rr, no)


def _promote_positions_to_shardtensor(
    padded_batch: "Batch",
    spec: "Any",
    meta: "Any",
    config: "ParticleHaloConfig",
    n_systems: int,
    max_send_cap: "int | None" = None,
) -> None:
    """Wrap the padded batch's per-atom fields in-place as ShardTensors.

    Mutates the ``_atoms_group`` slots named by ``spec.distribution.shard_fields``
    so each primary op input (e.g. ``positions``, ``charges``,
    ``atomic_numbers``) is a ShardTensor. Custom ops consuming them fire
    ShardTensor dispatch, which routes their outputs through the registered
    per-system / halo-correction handlers.

    A field is promoted whenever an op needs a ShardTensor arg for its handler
    to fire (e.g. ``charges`` for the PME total-charge op, ``atomic_numbers`` so
    one-hot encoding carries ShardTensor-ness into ``node_attrs``). The set is
    spec-driven, so each model promotes exactly the fields it needs.
    """
    from nvalchemi.distributed._core.shard_tensor import ShardTensor

    atoms = padded_batch._atoms_group
    if atoms is None:
        return
    # Build the fixed-shape halo routing once (same for every per-atom field).
    # Under compile the handlers route through the static halo ops with this as
    # a runtime graph input; eager ignores it.
    _pos = atoms.get("positions")
    _device = _pos.device if _pos is not None else None
    halo_meta_packed = (
        _build_halo_meta_packed(meta, config, _device, int(_pos.shape[0]), max_send_cap)
        if _device is not None
        else None
    )
    # Spec-driven, always a concrete tuple, so ``()`` (promote nothing) is valid.
    for key in spec.distribution.shard_fields:
        if key not in atoms:
            continue
        t = atoms[key]
        if isinstance(t, ShardTensor):
            continue
        atoms[key] = ShardTensor.wrap(
            t,
            spec=spec,
            meta=meta,
            config=config,
            n_systems=n_systems,
            halo_meta_packed=halo_meta_packed,
        )


def _reduce_scatter_owned(
    full: torch.Tensor,
    counts: "list[int]",
    rank: int,
    nlo: int,
    nhi: int,
    grp: "Any",
) -> torch.Tensor:
    """Sum a full ``[N, *]`` tensor across ranks and return this rank's owned,
    rank-contiguous block ``[counts[rank], *]``.

    Node-partition GP replicates the full node set, so each rank produces a full
    ``[N, *]`` partial that must be summed then sliced to owned. An even
    reduce-scatter — each rank-block padded to ``max(counts)`` so the chunks are
    uniform — lands only this rank's owned slice and moves ~half the cross-rank
    volume of ``all_reduce([N, *])`` + slice. Falls back to a local slice with no
    process group (single rank).
    """
    if grp is None:
        return full[nlo:nhi].contiguous()

    import torch.distributed as dist  # noqa: PLC0415

    world = len(counts)
    mc = max(counts)
    tail = tuple(full.shape[1:])
    buf = full.new_zeros((world, mc, *tail))
    off = 0
    for r in range(world):
        c = counts[r]
        if c:
            buf[r, :c] = full[off : off + c]
        off += c
    buf = buf.reshape(world * mc, *tail)
    owned = full.new_empty((mc, *tail))
    dist.reduce_scatter_tensor(owned, buf, op=dist.ReduceOp.SUM, group=grp)
    return owned[: counts[rank]].contiguous()


class DistributionError(ValueError):
    """Raised when a wrapper cannot be adapted by :class:`DistributedModel`.

    Typical causes: the wrapper is composite (``PipelineModelWrapper`` — use
    ``DistributedPipelineModel``); or its ``distribution_spec`` is ``None``.
    """


[docs] class DistributedModel: """Wrap an atomic single-process model wrapper for domain-decomposed inference. Parameters ---------- wrapper Atomic :class:`~nvalchemi.models.base.BaseModelMixin`. Its ``distribution_spec`` must be non-None. Composite wrappers (``PipelineModelWrapper``) are rejected — use :class:`DistributedPipelineModel` for composition. domain_config Shared simulation config carrying the cutoff, skin, mesh, and optional grid_dims. The partitioner and halo config are built lazily from the first :class:`ShardedBatch`'s geometry. spec : MLIPSpec, optional, keyword-only Explicit distribution spec (the joint model x strategy product). When ``None`` (default), it is obtained from ``wrapper.distribution_spec(domain_config.strategy)``; a wrapper with ``distribution_spec=None`` requires this argument. compile : bool, optional, keyword-only When ``True``, compile the energy-autograd forward path (fixed-shape padded, per-rank). The spec carries only the compile contract; this switch enables it. Default ``False``. compile_kwargs : dict, optional, keyword-only Extra keyword arguments forwarded to the compile of the distributed forward, merged with the spec's compile contract. Only consulted when ``compile=True``. Default ``None``. Notes ----- Construction is side-effect-free. The first call to ``__call__`` initializes the partitioner / halo config / world size from the supplied ``ShardedBatch`` and invokes ``wrapper.distributed_setup``. ``close()`` — or ``__exit__`` / ``__del__`` — calls ``wrapper.distributed_teardown`` to restore any module-level state. Use as a context manager for scoped lifecycle:: with DistributedModel(wrapper, config) as dist_model: out = dist_model(sharded) """ def __init__( self, wrapper: "BaseModelMixin", domain_config: DomainConfig, *, spec: "MLIPSpec | None" = None, compile: bool = False, compile_kwargs: dict | None = None, ) -> None: # Reject composite wrappers. Delayed import avoids a circular import. from nvalchemi.models.pipeline import PipelineModelWrapper if isinstance(wrapper, PipelineModelWrapper): raise DistributionError( "DistributedModel wraps atomic BaseModelMixin instances only. " "For composite wrappers, compose their adapters via " "DistributedPipelineModel([...])." ) # Explicit ``spec=`` wins, else ask the wrapper for the spec matching the # config-selected strategy (the spec is a joint model x strategy product). if spec is None: _ds = getattr(wrapper, "distribution_spec", None) spec = ( _ds(getattr(domain_config, "strategy", None)) if callable(_ds) else _ds ) if spec is None: raise DistributionError( f"{type(wrapper).__name__} has distribution_spec=None and no " "explicit spec= was passed. Atomic wrappers must either " "declare a MLIPSpec property or be constructed via " "`DistributedModel(wrapper, cfg, spec=...)`." ) from nvalchemi.distributed._core.adapter import ( # noqa: PLC0415 AdapterRegistry, ) self._wrapper = wrapper # Fixed-shape padding caps (compile-only, per-rank), keyed # "atoms"/"edges"/"max_send". Grown on overflow; empty until first # compiled forward. self._cap_state: dict[str, int] = {} self._config = domain_config self._spec = spec # ``compile=True`` makes the forward compile the energy-autograd path. # The spec carries only the compile contract; the switch lives here. self._dd_compile_requested: bool = bool(compile) self._dd_compile_kwargs: dict | None = ( _prepare_dd_compile(self._spec, compile_kwargs) if compile else None ) # A model may arrive already torch.compiled by its own loader (e.g. # ``MACEWrapper.from_checkpoint(compile_model=True)``) in front of a # *plain* DistributedModel. The eager DD path is silently WRONG for such # a model — the halo correction never sees the message-passing ops sealed # inside the compiled graph, so each rank returns an uncorrected owned-only # forward. When the model uses a framework-owned energy-autograd force # strategy, engage the compiled DD path (it consumes a pre-compiled inner # model correctly); if the spec declares no compiled forward at all, raise # rather than return garbage. Models that keep force autograd inside the # model (``MODEL_INTERNAL``, e.g. UMA's fairchem-owned internal compile) # run the eager path correctly and are left untouched. if not self._dd_compile_requested and _wrapper_is_precompiled(wrapper): _cp_pre = getattr(self._spec, "compile", None) if _cp_pre is not None and _cp_pre.forces_via_autograd: self._dd_compile_requested = True self._dd_compile_kwargs = _prepare_dd_compile( self._spec, compile_kwargs ) elif _cp_pre is None: raise DistributionError( f"{type(wrapper).__name__}'s model is already torch.compiled " "(e.g. from_checkpoint(compile_model=True)), but its " "distribution_spec declares no CompilePolicy, so a correct " "compiled distributed forward cannot be built. Either build " "the wrapper WITHOUT compiling it and pass " "DistributedModel(..., compile=True), or add a CompilePolicy " "to its distribution_spec." ) # Tune Dynamo/inductor for DD unconditionally: the wrapped model may be # compiled by its own loader (e.g. MACE ``loader.compile``) rather than the # framework, in which case ``compile`` above is False yet the model still # recompiles under DD and needs the raised ceiling + cross-rank-safe caches # (see :func:`_configure_dd_dynamo`). A no-op when nothing compiles. _configure_dd_dynamo() # Reduced-precision fp32 breaks DD/single-process agreement; say so once. from nvalchemi.distributed._runtime import ( warn_if_reduced_precision, # noqa: PLC0415 ) warn_if_reduced_precision() # Fixed-shape graph padder for the compiled halo path. A model may # declare a custom padder via its CompilePolicy; the default is the # generic COO ``edge_index`` padder, so a standard MPNN declares nothing. from nvalchemi.distributed.graph_padder import COOPadder # noqa: PLC0415 _compile_policy = getattr(self._spec, "compile", None) self._graph_padder = ( getattr(_compile_policy, "graph_padder", None) or COOPadder() ) self._setup_called = False # DistributedModel is single-lifecycle: once ``close()`` has torn down the # process-wide adapter state, re-entering ``with model:`` won't re-install # it, so a second use is rejected (construct a fresh instance instead). self._closed = False # The parallelization strategy owning this model's distributed forward; # built lazily from the resolved storage policy (see ``_strategy``). self._strategy_obj: Any = None # Installs/restores the spec's custom_ops + third_party_helpers. # Populated on first forward; restored in ``close()``. self.adapter_registry: AdapterRegistry = AdapterRegistry() # Lazy-built from the first batch's geometry (cell / pbc). self._partitioner: SpatialPartitioner | None = None self._halo_config: ParticleHaloConfig | None = None # Per-scope runtime context, built in ``_ensure_initialized`` and shared # by reference with the wrapper so per-step mutations are visible. self._dist_ctx: DistributedContext | None = None # World size, read from the mesh on first call (or 1). self._world_size: int | None = None # Partition-health check runs once (first halo forward). self._partition_health_checked: bool = False # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @property def wrapper(self) -> "BaseModelMixin": """The underlying single-process model wrapper.""" return self._wrapper @property def config(self) -> DomainConfig: """The :class:`DomainConfig` held by this adapter.""" return self._config def __call__( self, sharded: "ShardedBatch", *, wired_fields: "dict[str, Any] | None" = None, ) -> dict[str, Any]: """Run a distributed forward on a :class:`ShardedBatch`. Parameters ---------- sharded : ShardedBatch The sharded system to run the forward on. wired_fields : dict[str, Any] | None Optional ``{field_name: owned_value}`` overrides for per-atom inputs produced by an upstream model (cross-model composition). Each owned tensor is gathered into *this* model's ghost layout via the autograd-aware :func:`halo_forward_exchange` and written onto the padded batch before the forward, so the consumer sees the producer's value on its ghosts and the pathway stays differentiable (backward scatter-adds ghost grads to owners). Eager-only; raises under compiled distribution. Returns ------- dict[str, Any] Output dict with owned-shape (per-atom) and replicated (per-system) tensors. Notes ----- Halo exchange and neighbor-list management are the caller's responsibility (typically via :func:`halo_exchange` + ``NeighborListHook`` inside ``DomainParallel``). The adapter handles spec-driven input adaptation, the wrapper forward, and output consolidation. """ from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 _clear_exchange_counts_cache, ) # The exchange-counts cache key is per-rank, so ranks with different # send histories could diverge and deadlock. Resetting symmetrically # each forward avoids this at the cost of one redundant collective # (per-layer reuse within a forward is preserved). _clear_exchange_counts_cache() self._ensure_initialized(sharded) # The parallelization strategy owns its distributed forward; this model # is the shared forward toolkit it drives. A new strategy plugs in as a # new class, without a framework type-switch here. return self._strategy().run_forward(self, sharded, wired_fields) def _strategy(self) -> Any: """The :class:`ParallelizationStrategy` for this model's storage policy (built once, cached).""" if self._strategy_obj is None: from nvalchemi.distributed.strategy import ( # noqa: PLC0415 strategy_for_policy, ) mesh = self._config.mesh rank = mesh.get_local_rank() if mesh is not None else 0 self._strategy_obj = strategy_for_policy( self._spec.distribution.policy, self._config, rank ) return self._strategy_obj
[docs] def from_batch(self, batch: "Batch | None", *, src: int = 0) -> dict[str, Any]: """One-call distributed inference from a full ``Batch``. The convenience entry for one-off inference: shards ``batch`` across the scope's mesh (via :meth:`ShardedBatch.from_batch`, using the held :class:`DomainConfig`) and runs the distributed forward — so a caller never constructs a :class:`ShardedBatch` by hand. Collective: every rank calls it, with the full system on rank ``src`` and ``None`` elsewhere; every rank gets the consolidated output dict back. Parameters ---------- batch The full-system :class:`~nvalchemi.data.Batch` on rank ``src``; ``None`` on the other ranks. src The rank holding the full batch (default 0). Returns ------- dict[str, Any] The consolidated outputs (owned-shape per-atom + replicated per-system), identical to calling :meth:`__call__` on a hand-built :class:`ShardedBatch`. """ from nvalchemi.distributed.sharded_batch import ShardedBatch # noqa: PLC0415 # The policy chooses how atoms map to ranks: spatial (halo) or balanced # index ranges (graph parallel). partition_mode = getattr( self._spec.distribution.policy, "partition_mode", "spatial" ) sharded = ShardedBatch.from_batch( batch, mesh=self._config.mesh, config=self._config, src=src, partition_mode=partition_mode, ) return self(sharded)
[docs] def close(self) -> None: """Release resources and restore any state setup mutated. Safe to call multiple times. Restores all adapters installed by :attr:`adapter_registry` (custom_ops + third_party_helpers), then defers to the wrapper's optional ``distributed_teardown`` hook for any wrapper-side runtime state. """ if self._setup_called: self.adapter_registry.restore() from nvalchemi.distributed._core.adapter import ( # noqa: PLC0415 restore_auto_marshalled, ) restore_auto_marshalled(getattr(self, "_auto_marshal_mementos", [])) if hasattr(self._wrapper, "distributed_teardown"): self._wrapper.distributed_teardown() self._setup_called = False self._closed = True
def __enter__(self) -> "DistributedModel": # Single-lifecycle: setup installs process-wide adapter state that # ``close()`` restores, and re-entry would not re-install it — fail loudly # rather than run half-set-up. if self._closed: raise RuntimeError( "DistributedModel is single-use; construct a new one after close()" ) # Drop the process-global exchange-counts cache so the first forward in # this context starts cold; a stale entry (recv_counts depend on all # ranks' send_counts) could deadlock if some ranks hit it and others # recompute the all_gather. from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 _clear_exchange_counts_cache, ) _clear_exchange_counts_cache() return self def __exit__(self, *_exc: Any) -> None: from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 _clear_exchange_counts_cache, ) _clear_exchange_counts_cache() self.close() def __del__(self) -> None: try: self.close() except Exception: # noqa: S110 pass # ------------------------------------------------------------------ # Initialization: build partitioner + halo config + world size # ------------------------------------------------------------------ def _ensure_initialized(self, sharded: "ShardedBatch") -> None: """Build the halo config from the sharded batch's geometry the first time we see one. When available, reuse the partitioner cached on :attr:`ShardedBatch.partitioner` (built there from the same config + broadcast cell/pbc) to avoid duplicate work and potential drift. Fall back to constructing one from the sharded batch's geometry when not available (e.g. gloo-harness batches built outside :meth:`ShardedBatch.from_batch`). """ if self._partitioner is not None: return # The policy owns its topology — spatial partitioner + halo config, or a # balanced index partition with no ghost shell — so this stays generic. self._partitioner, self._halo_config = ( self._spec.distribution.policy.build_topology(self._config, sharded) ) # World size from the configured mesh; default to 1. if self._config.mesh is not None: try: self._world_size = self._config.mesh.size() except Exception: self._world_size = 1 else: self._world_size = 1 # Per-scope runtime context; per-step fields are mutated by the call # paths below. self._dist_ctx = DistributedContext( mesh=self._config.mesh, halo_config=self._halo_config, n_systems_global=sharded.num_graphs, n_atoms_total=sharded.n_global, ) # Spec-driven adapter installation: install every custom_op and # third_party_helper in declaration order; restored in close(). from nvalchemi.distributed._core.adapter import ( # noqa: PLC0415 JitAdapter, auto_marshal_scripted_submodules, ) # Scripted-op marshalling mode: env override > config > "auto". marshal_mode = os.environ.get("NVALCHEMI_SCRIPTED_MARSHAL") or getattr( self._config, "scripted_marshal", "auto" ) if marshal_mode not in ("auto", "declared", "off"): marshal_mode = "auto" adapters = list(self._spec.distribution.custom_ops) + list( self._spec.distribution.third_party_helpers ) if marshal_mode == "off": # Drop marshal-mode JitAdapters; leave eager JitAdapters / # PythonAdapters / OpAdapters in place. adapters = [ a for a in adapters if not ( isinstance(a, JitAdapter) and getattr(a, "mode", "eager") == "marshal" ) ] self.adapter_registry.install(adapters) # Auto-discovery ("auto" mode only): wrap scripted submodules' forward # with the marshaller, deduped against declared adapters and the config # exclude-list. Restored in close(). self._auto_marshal_mementos: list[Any] = [] if marshal_mode == "auto": declared_targets = tuple( a.attr_name for a in self._spec.distribution.third_party_helpers if isinstance(a, JitAdapter) ) self._auto_marshal_mementos = auto_marshal_scripted_submodules( self._wrapper, exclude=tuple(getattr(self._config, "scripted_marshal_exclude", ())), declared_targets=declared_targets, ) # Always invoke the wrapper's setup hook last, so wrappers that # build closures over ``ctx.gather_meta`` see the spec handlers # already in place. if hasattr(self._wrapper, "distributed_setup"): self._wrapper.distributed_setup(self._dist_ctx) self._setup_called = True def _needs_forces(self) -> bool: return bool( self._wrapper.model_config.autograd_outputs & self._wrapper.model_config.active_outputs ) # ------------------------------------------------------------------ # Halo-storage path # ------------------------------------------------------------------ def _check_partition_health(self, meta: Any, device: Any) -> None: """Flag a degenerate halo partition once (first halo forward). An empty shard (a rank with 0 owned atoms — more ranks than the geometry can fill) is broken: raise on every rank. A trivial partition (every rank's halo covers the whole system, 0 remote atoms) is correct but gains no parallelism — warn once. The verdict is taken collectively so every rank acts identically (avoids desync from one rank raising).""" if self._partition_health_checked: return self._partition_health_checked = True if not self._world_size or self._world_size <= 1: return # single process — not domain-decomposed from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 mesh_group, ) group = mesh_group(self._halo_config.mesh) any_empty, any_trivial, n_global = _partition_health_verdict( int(meta.n_owned), int(meta.n_padded), group, device ) rank = ( self._config.mesh.get_local_rank() if self._config.mesh is not None else 0 ) _resolve_partition_health( any_empty, any_trivial, n_global, world_size=self._world_size, require_nondegenerate=getattr(self._config, "require_nondegenerate", False), rank=rank, ) def _graph_parallel_owned_edges( self, sharded: "ShardedBatch", meta: Any, rank: int ) -> torch.Tensor: """This rank's ``(E, 2)`` owned-target neighbor list for the GP path. Materializes the full graph once from the replicated geometry, keeps the edges whose receiver this rank owns, and remaps that receiver to its owned-local row; senders stay global ids into the per-layer replicated node tensor. The edge index is non-differentiable routing — the differentiable geometry flows through ``refresh_neighbors`` in the wrapper — so the gather + neighbor build run under ``no_grad``. """ with torch.no_grad(): global_batch = sharded.to_global_batch() compute_neighbors( global_batch, config=self._wrapper.model_config.neighbor_config ) nl = global_batch.neighbor_list.to(torch.long) src_g, dst_g = nl[:, 0], nl[:, 1] owner = meta.owner_rank.to(dst_g.device) local = meta.local_index.to(dst_g.device) keep = owner[dst_g] == rank return torch.stack([src_g[keep], local[dst_g[keep]]], dim=1) def _graph_parallel_owned_nbmat( self, sharded: "ShardedBatch", meta: Any, rank: int ) -> "dict[str, torch.Tensor]": """This rank's owned-receiver dense neighbour matrix for the GP path. The dense analogue of :meth:`_graph_parallel_owned_edges`. Materializes the full dense ``neighbor_matrix`` once from the replicated geometry, then keeps only the rows whose receiver atom this rank owns. Sender columns stay global ids into the all-gathered node set (``refresh_neighbors(positions)`` in the wrapper); receiver rows are this rank's owned atoms in owned-local order. Non-differentiable routing built under ``no_grad`` — geometry differentiates through ``refresh_neighbors``. Returns a ``node_properties`` dict (``neighbor_matrix`` / ``num_neighbors`` / optionally ``neighbor_matrix_shifts``) to hand to :meth:`ShardedBatch.local_batch_with_edges`. """ with torch.no_grad(): global_batch = sharded.to_global_batch() compute_neighbors( global_batch, config=self._wrapper.model_config.neighbor_config ) # Owned receiver rows, in owned-local order. Post-scatter the sharded # atoms are in rank-contiguous order, so the boolean mask selects this # rank's block in local_index order (row i = owned-local atom i). owned = meta.owner_rank.to(global_batch.neighbor_matrix.device) == rank props: dict[str, torch.Tensor] = { "neighbor_matrix": global_batch.neighbor_matrix[owned].to(torch.long), "num_neighbors": global_batch.num_neighbors[owned].to(torch.long), } shifts = getattr(global_batch, "neighbor_matrix_shifts", None) if shifts is not None: props["neighbor_matrix_shifts"] = shifts[owned] return props def _graph_parallel_dense_full_autograd( self, sharded: "ShardedBatch" ) -> dict[str, Any]: """Node-partition GP for dense-``neighbor_matrix`` models whose kernel indexes the position array (``gp_replicate_geometry``; e.g. PME's fused real-space+reciprocal kernel). The full geometry is replicated on every rank so the kernel can index global senders and spread the full charge set (correct reciprocal). The dense ``neighbor_matrix`` is masked to this rank's owned receivers (``num_neighbors[non-owned] = 0``), so the **real-space** work partitions while the **reciprocal** reads all charges (replicated — correct, not yet compute-partitioned). Energy is the framework's owned-aware sum of the per-node ``node_energy_key`` output; forces come from autograd of that owned energy over the full-position leaf, cross-rank ``SUM``, sliced to owned — the same adjoint as :meth:`_graph_parallel_internal`, but the framework (not the model) owns the force autograd. """ from types import SimpleNamespace # noqa: PLC0415 import torch.distributed as dist # noqa: PLC0415 from nvalchemi.distributed._core.context import ( activate_dd_context, # noqa: PLC0415 ) from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 mesh_group, ) from nvalchemi.distributed._core.placement import ShardRouting # noqa: PLC0415 from nvalchemi.distributed.output_consolidation import ( # noqa: PLC0415 consolidate_sharded_outputs, ) mesh = self._config.mesh rank = mesh.get_local_rank() if mesh is not None else 0 world = self._world_size or 1 # Full node set on every rank; positions are a fresh autograd leaf. full = sharded.to_global_batch() atoms = full._atoms_group pos = atoms["positions"].detach().requires_grad_(True) atoms["positions"] = pos # Strained before the neighbour build so the whole forward sees it. want_stress = "stress" in self._wrapper.model_config.active_outputs strain = cell_local = None if want_stress: cell_local = full.cell cell_local = ( cell_local.to_local() if hasattr(cell_local, "to_local") else cell_local ) atoms["positions"], strained_cell, strain = prepare_strain( pos, cell_local, full.batch_idx.long() ) object.__setattr__(full, "cell", strained_cell) assignment = sharded.rank_assignment.to(pos.device) counts_t = torch.bincount(assignment, minlength=world) counts = [int(c) for c in counts_t.tolist()] nlo = int(counts_t[:rank].sum().item()) nhi = nlo + counts[rank] owned_mask = assignment == rank meta = ShardRouting.from_assignment(assignment, rank, world) meta.n_systems_global = sharded.num_graphs # Dense neighbours over the full geometry, masked to owned receivers so the # kernel's real-space loop does no work for non-owned rows (their energy is # dropped by the owned-aware sum anyway). The reciprocal reads full charges. from nvalchemi.neighbors import compute_neighbors # noqa: PLC0415 compute_neighbors(full, config=self._wrapper.model_config.neighbor_config) num = full._atoms_group.get("num_neighbors") if num is not None: num = num.clone() num[~owned_mask] = 0 full._atoms_group["num_neighbors"] = num self._dist_ctx.policy = self._spec.distribution.policy self._dist_ctx.gather_meta = meta self._dist_ctx.halo_meta = None # Run energy-only: the framework owns the force autograd, so the wrapper # must not consume/free the energy graph with its own force head. Widen # active outputs to include the per-node energy key. nek = self._spec.node_energy_key _mc = self._wrapper.model_config _saved_active = _mc.active_outputs _mc.active_outputs = {"energy"} | ({nek} if nek else set()) try: with activate_dd_context(self._dist_ctx): output = self._wrapper(full) finally: _mc.active_outputs = _saved_active # Owned-aware per-system energy from the per-node key (each atom counted # once by its owner), then a plain cross-rank SUM for the global energy. node_e = output[nek] batch_idx = full.batch_idx.long() e_partial = torch.zeros( sharded.num_graphs, dtype=node_e.dtype, device=node_e.device ).index_add(0, batch_idx[owned_mask], node_e[owned_mask]) grp = ( mesh_group(mesh) if (dist.is_initialized() and world > 1 and mesh is not None) else None ) out: dict[str, Any] = {} # Energy is a tiny ``[n_systems]`` reduction (latency-bound); launch it # async so it overlaps the force autograd + reduce-scatter below. e_global = e_partial.detach().clone() e_handle = ( dist.all_reduce(e_global, op=dist.ReduceOp.SUM, group=grp, async_op=True) if grp is not None else None ) want_forces = self._needs_forces() if want_forces or want_stress: grad_inputs = ([pos] if want_forces else []) + ( [strain] if want_stress else [] ) grads = torch.autograd.grad( [e_partial.sum()], grad_inputs, create_graph=False, allow_unused=True, ) if want_forces: grad = grads[0] f = torch.zeros_like(pos) if grad is None else -grad out["forces"] = _reduce_scatter_owned(f, counts, rank, nlo, nhi, grp) if want_stress: # Each rank's owned energy is a distinct partial, so the virials # sum (no replication to divide out). virial = grads[-1] if virial is None: virial = torch.zeros( sharded.num_graphs, 3, 3, dtype=pos.dtype, device=pos.device ) virial = virial.detach() if grp is not None: dist.all_reduce(virial, op=dist.ReduceOp.SUM, group=grp) out["stress"] = virial / torch.det(cell_local).abs().reshape(-1, 1, 1) if e_handle is not None: e_handle.wait() out["energy"] = e_global self._dist_ctx.gather_meta = None # Every value here is already global, so consolidation must not touch them. return consolidate_sharded_outputs( output=out, model_config=self._wrapper.model_config, world_size=self._world_size, owned_only_outputs=frozenset({"energy", "forces", "stress"}), all_reduce_outputs=frozenset(), halo_config=SimpleNamespace(mesh=mesh), ) def _graph_parallel_internal(self, sharded: "ShardedBatch") -> dict[str, Any]: """Node-partition graph-parallel for models that compute forces internally. Each rank owns a balanced index slice of the atoms. The full geometry is replicated so the model's internal (otf) graph build can index global senders, but a declared adapter (the wrapper's ``_generate_graph``) restricts the node-wise work to this rank's owned slice and the per-layer node-feature all-gather (``refresh_neighbors`` → the policy's replicate; reduce-scatter on the backward) feeds the convolution its global sources. The model computes its own per-system energy (an owned partial, via its declared ``LOCAL``-scope reduction) and its own forces (``-dE_owned/d pos`` over the *full* positions). Because the feature all-gather's reduce-scatter backward routes each node's feature gradient to its owner exactly once, a plain cross-rank ``SUM`` of the per-rank force — with **no** ``/world_size`` — recovers the global force; it is then sliced to this rank's owned atoms. The energy partials likewise sum to the global energy. The complement of :meth:`_call_graph_parallel`'s framework-autograd path, for opaque force heads (e.g. UMA). """ from types import SimpleNamespace # noqa: PLC0415 import torch.distributed as dist # noqa: PLC0415 from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 mesh_group, ) from nvalchemi.distributed._core.placement import ( # noqa: PLC0415 ShardRouting, ) from nvalchemi.distributed.output_consolidation import ( # noqa: PLC0415 consolidate_sharded_outputs, ) mesh = self._config.mesh rank = mesh.get_local_rank() if mesh is not None else 0 world = self._world_size or 1 import os as _os # noqa: PLC0415 import time as _time # noqa: PLC0415 _prof = _os.environ.get("NVALCHEMI_DD_PROFILE") and rank == 0 _marks: list = [] def _mark(label: str) -> None: if _prof: torch.cuda.synchronize() _marks.append((label, _time.perf_counter())) _mark("start") # Full node set on every rank; positions become a fresh autograd leaf for # the model's internal force autograd. Mutate the gathered batch in place # rather than reconstructing AtomicData/Batch — the rebuild (pydantic # validation + collation) was the dominant per-forward DD overhead and is # redundant: ``to_global_batch`` already returns a complete batch. full = sharded.to_global_batch() _mark("to_global_batch") atoms = full._atoms_group pos = atoms["positions"].detach().requires_grad_(True) atoms["positions"] = pos batch_r = full _mark("rebuild_batch") # Owned partition = the ShardState's own split (the strategy is the single # owner of the layout). ``to_global_batch`` ordered the full node set by # rank, so this rank's owned atoms are exactly the contiguous block where # ``rank_assignment == rank``. Deriving the split here from a freshly # recomputed *balanced* formula instead would disagree with the scattered # owned batch whenever the atom count doesn't divide evenly across ranks, # mis-slicing the per-atom outputs (owned rows) relative to the integrator # batch. Reading it from the ShardState keeps forward-output, local_view, # and dynamics batch on one split by construction. n_atoms = pos.shape[0] assignment = sharded.rank_assignment.to(pos.device) counts_t = torch.bincount(assignment, minlength=world) counts = [int(c) for c in counts_t.tolist()] nlo = int(counts_t[:rank].sum().item()) nhi = nlo + counts[rank] meta = ShardRouting.from_assignment(assignment, rank, world) meta.n_systems_global = sharded.num_graphs self._dist_ctx.policy = self._spec.distribution.policy self._dist_ctx.gather_meta = meta self._dist_ctx.halo_meta = None self._dist_ctx.owned_offset = 0 # Publish the fixed-shape graph padder (declared on ``CompilePolicy``, the # same one halo uses) so the wrapper's ``maybe_pad_graph`` precomputes an # edge-capped, ``otf_graph=False`` graph — the strategy's ``cap_atoms=False`` # (set in ``run_forward``) makes it edge-only, restricting the edges to this # rank's owned receivers. Static per-rank edge shapes ⇒ no recompile churn. self._dist_ctx.cap_state = self._cap_state _cp = self._spec.compile _padder = ( _cp.graph_padder if (_cp is not None and _cp.graph_padder is not None) else None ) self._dist_ctx.graph_padder = _padder _mark("meta_setup") # Publish the static node-partition all-gather routing so the per-layer # ``refresh_neighbors`` inside the model's compiled forward uses the # fullgraph-traceable fixed gather (fetch every node from its owner). The # routing is index-based and constant across MD steps, so it is read as # trace-time constants without recompiling. Eager forwards ignore it # (``refresh_neighbors`` gates the fixed gather on ``is_compiling``). from nvalchemi.distributed._core.compile_routing import ( # noqa: PLC0415 clear_gp_compile_routing, set_gp_compile_routing, ) gi = torch.arange(n_atoms, device=pos.device) set_gp_compile_routing( gi, meta.owner_rank, meta.local_index, max(counts), world, mesh ) try: with activate_dd_context(self._dist_ctx): output = self._wrapper(batch_r) # No dead-atom rows under node partition (edge-only caps), so unpad is # a no-op on the per-atom outputs; kept for symmetry with the halo path. if _padder is not None: output = _padder.unpad(output) finally: clear_gp_compile_routing() # Restore the backbone's ``otf_graph`` flag the padder flipped, even on # a forward error, so the next step isn't stuck on the fixed-shape path. if _padder is not None: _padder.restore() _mark("wrapper_forward") grp = ( mesh_group(mesh) if (dist.is_initialized() and world > 1 and mesh is not None) else None ) # Energy: each rank holds its owned per-system partial → global SUM. It is # a tiny ``[n_systems]`` reduction (latency-bound); launch it async so it # overlaps the (larger) force reduce-scatter below. e_handle = None if "energy" in output and isinstance(output["energy"], torch.Tensor): e = output["energy"] if grp is not None: e = e.clone() e_handle = dist.all_reduce( e, op=dist.ReduceOp.SUM, group=grp, async_op=True ) output["energy"] = e # Forces: the model returns ``-dE_owned/d pos`` over the full positions. # The feature all-gather's reduce-scatter backward already routed each # node's gradient to its owner once, so a plain SUM (no ``/world``) is # the global force. Reduce-scatter over the rank-contiguous owned blocks # lands only this rank's owned slice — half the cross-rank volume of # all-reduce + slice (consolidation gathers it back to global order). if "forces" in output and isinstance(output["forces"], torch.Tensor): output["forces"] = _reduce_scatter_owned( output["forces"], counts, rank, nlo, nhi, grp ) if e_handle is not None: e_handle.wait() # Other spec-declared rank partials (currently UMA stress) follow the # same owned-energy algebra as energy: a raw SUM, with no /world. Mark # them already global so sharded consolidation does not apply its normal # replicated-energy autograd /world correction. already_global_outputs = {"energy", "forces"} for key in sorted(self._spec.all_reduce_outputs - already_global_outputs): value = output.get(key) if not isinstance(value, torch.Tensor): continue value = value.clone() if grp is not None: dist.all_reduce(value, op=dist.ReduceOp.SUM, group=grp) output[key] = value already_global_outputs.add(key) self._dist_ctx.gather_meta = None self._dist_ctx.owned_offset = 0 _mark("reduce_outputs") out = consolidate_sharded_outputs( output, model_config=self._wrapper.model_config, world_size=self._world_size, owned_only_outputs=frozenset(already_global_outputs), all_reduce_outputs=frozenset(), halo_config=SimpleNamespace(mesh=mesh), ) _mark("consolidate") if _prof: segs = ", ".join( f"{_marks[i][0]}={1000 * (_marks[i][1] - _marks[i - 1][1]):.1f}" for i in range(1, len(_marks)) ) total = 1000 * (_marks[-1][1] - _marks[0][1]) print(f"[dd-prof] total={total:.1f}ms | {segs}", flush=True) return out def _reduce_node_energy( self, output: dict[str, Any], node_energy_key: str, padded_batch: "Batch", num_graphs: int, ) -> dict[str, Any]: """Reduce a wrapper's per-node energy into the per-system ``"energy"``. Owned-slice + per-graph scatter + cross-rank all-reduce (autograd-aware, fp64-accumulated) via :func:`~nvalchemi.distributed.helpers.system_sum`. Pops ``node_energy_key`` and overrides ``"energy"`` so downstream consolidation sees the owned-aware total rather than the wrapper's plain sum (which double-counts ghosts). Must run inside an active DD context. """ from nvalchemi.distributed._core.enums import Scope # noqa: PLC0415 from nvalchemi.distributed.helpers import system_sum, to_local # noqa: PLC0415 node_e = to_local(output.pop(node_energy_key)) # A wired consumer differentiates its own owned share, not the # all-reduced total, whose backward is amplified across ranks. reduced = system_sum( node_e, to_local(padded_batch.batch_idx).to(torch.long), int(num_graphs), scope=Scope.OWNED, ) ref = output.get("energy") if ref is not None: reduced = reduced.to(ref.dtype).reshape(ref.shape) elif reduced.dim() == 1: reduced = reduced.unsqueeze(-1) output["energy"] = reduced return output def _reduce_node_virial( self, output: dict[str, Any], node_virial_key: str, padded_batch: "Batch", num_graphs: int, ) -> dict[str, Any]: """Reduce a wrapper's per-node virial into the per-system ``"stress"``. Analytic-kernel-virial wrappers (LJ, DFTD3) return a per-system virial summed over each rank's all-local (owned + ghost) atoms, which is wrong under decomposition and can't be owned-masked once collapsed. They instead emit the per-atom virial ``(n_nodes, 3, 3)`` (energy units) under ``node_virial_key``; this owned-slices + all-reduces it (each pair counted once by its owning atom, mirroring ``atomic_energies``), converts to the tensile-positive Cauchy stress ``-W/V`` using the cell volume, and overrides the wrapper's all-local ``"stress"``. Must run inside an active DD context. Parameters ---------- output : dict[str, Any] The wrapper's raw output dict; the virial key is consumed. node_virial_key : str Key of the per-node virial ``(n_nodes, 3, 3)``. padded_batch : Batch This rank's padded view, supplying ``batch_idx`` and ``cell``. num_graphs : int Number of systems in the batch. Returns ------- dict[str, Any] *output* with ``"stress"`` replaced by the decomposition-correct value. """ from nvalchemi.distributed._core.enums import Scope # noqa: PLC0415 from nvalchemi.distributed.helpers import system_sum, to_local # noqa: PLC0415 node_v = to_local(output.pop(node_virial_key)) virial = system_sum( node_v, to_local(padded_batch.batch_idx).to(torch.long), int(num_graphs), scope=Scope.OWNED, ) # (n_systems, 3, 3), replicated cell = to_local(padded_batch.cell) volume = torch.det(cell).abs().view(-1, 1, 1) stress = -virial / volume ref = output.get("stress") if ref is not None: stress = stress.to(ref.dtype).reshape(ref.shape) output["stress"] = stress return output # ------------------------------------------------------------------ # Compiled energy-autograd path (framework-owned) # ------------------------------------------------------------------ def _dd_compiled_region(self, eager: bool = False) -> Any: """Build (once, cached) the energy-only region. The region publishes the halo routing — carried as tensor attributes on the batch — so the wrapper's per-layer halo-refresh adapters fire inside the traced graph, then runs the energy-only wrapper forward. The routing is read from the batch so Dynamo lifts it to graph inputs (it drifts per step and can't be baked); ``world_size`` is static and bakes in. """ attr = "_dd_region_eager" if eager else "_dd_region" region = getattr(self, attr, None) if region is not None: return region from nvalchemi.distributed._core.compile_routing import ( # noqa: PLC0415 clear_compile_routing, set_compile_routing, ) wrapper = self._wrapper ck = dict(self._dd_compile_kwargs or {}) backend = ck.pop("backend", "inductor") def _region(batch: Any) -> Any: si = getattr(batch, "_halo_si", None) if si is not None: set_compile_routing( si, batch._halo_rd, batch._halo_rr, batch._halo_no, int(getattr(batch, "_halo_ws", 1)), ) return wrapper.forward(batch) if eager: # Same region uncompiled, so eager and compiled cannot drift. def eager_runner(batch: Any) -> Any: try: return _region(batch) finally: clear_compile_routing() self._dd_region_eager = eager_runner return eager_runner compiled = torch.compile(_region, backend=backend, **ck) def runner(batch: Any) -> Any: # Clear the holder after each call so a later eager refresh never # reads trace-time (fake / stale) routing. try: return compiled(batch) finally: clear_compile_routing() self._dd_region = runner return runner def _compiled_energy_autograd_forward( self, padded_batch: "Batch", meta: Any, n_graphs: int, eager: bool = False, extra_grad_inputs: "list[Any] | None" = None, retain_graph: bool = False, ) -> dict[str, Any]: """Compiled energy + autograd-force forward. For a model using ``forces_via_autograd``, the framework owns the whole compile path so the wrapper carries none of it: make ``positions`` a fresh leaf (autograd boundary is outside compile); thread the halo routing as graph-input batch attributes; run the wrapper energy-only through the cached compiled region; consolidate per-node energy (owned per-graph sum + cross-rank all-reduce); take ``forces = -d(energy)/d(positions)``. The returned ``{energy, forces}`` feeds the shared ``consolidate_padded_outputs`` like the eager output. """ from nvalchemi.distributed._core.particle_halo import ( # noqa: PLC0415 build_halo_meta_tensors, ) from nvalchemi.distributed.compile_bridge import ( # noqa: PLC0415 _consolidate_node_energy, ) atoms = padded_batch._atoms_group pos = atoms["positions"] pos_plain = pos.to_local() if hasattr(pos, "to_local") else pos # Fresh leaf so autograd.grad (outside compile) differentiates the # compiled output w.r.t. it, unless the caller pinned its own leaf that # this padded view descends from — detaching would orphan it. if not (retain_graph and pos_plain.requires_grad): pos_plain = pos_plain.detach().requires_grad_(True) # Stress via the strain trick: perturb positions AND cell by a symmetric # per-system strain leaf, then virial = d(energy)/d(strain). Because we # differentiate the framework's already-consolidated GLOBAL energy, this is # correct for every force strategy (real + reciprocal spaces alike), filling # the compiled-DD stress the energy-autograd path otherwise omits. The # per-rank virial is summed across ranks by consolidation (stress declared # ALL_REDUCE), exactly like the autograd forces. Gated on stress being # requested. Strain application is OUTSIDE the compiled region (like the # positions leaf), so it adds no graph ops. want_stress = "stress" in self._wrapper.model_config.active_outputs and bool( getattr(self._spec.compile, "stress_via_strain", False) ) strain = None cell_orig = cell_local = None if want_stress: _bidx = padded_batch.batch_idx _bidx = (_bidx.to_local() if hasattr(_bidx, "to_local") else _bidx).long() cell_orig = getattr(padded_batch, "cell", None) cell_local = ( None if cell_orig is None else ( cell_orig.to_local() if hasattr(cell_orig, "to_local") else cell_orig ) ) # No cell (open boundary): strain the positions against a dummy so the # leaf still exists and the virial is position-only. _cell_in = ( cell_local if cell_local is not None else torch.zeros( int(n_graphs), 3, 3, dtype=pos_plain.dtype, device=pos_plain.device ) ) pos_use, cell_use, strain = prepare_strain(pos_plain, _cell_in, _bidx) if cell_local is not None: object.__setattr__(padded_batch, "cell", cell_use) atoms["positions"] = pos_use else: atoms["positions"] = pos_plain # Fixed-shape halo routing as graph inputs, attached to the batch so # Dynamo lifts them (they drift per step). ``max_send`` is the persistent # per-rank cap (grown in lockstep across ranks above). n_padded = int(pos_plain.shape[0]) max_send = self._cap_state.get("max_send") or max( (max(r) for r in meta.send_sizes), default=0 ) ws = len(meta.send_sizes) si, rd, rr, no = build_halo_meta_tensors( meta, self._halo_config.rank, max_send, n_padded, pos_plain.device ) for key, val in ( ("_halo_si", si), ("_halo_rd", rd), ("_halo_rr", rr), ("_halo_no", no), ("_halo_ws", ws), ): object.__setattr__(padded_batch, key, val) # The model declares how its energy-only forward yields a global energy: # per-node ``atomic_energies`` (framework consolidates) or an already # self-consolidated global ``energy``. _cp = self._spec.compile energy_key = _cp.energy_output consolidate = _cp.consolidate_node_energy # The framework owns energy / forces / stress; any other requested output # is the model's own and still has to come out of the compiled region. mc = self._wrapper.model_config saved_active = mc.active_outputs aux_keys = {k for k in saved_active if k not in ("energy", "forces", "stress")} mc.active_outputs = {energy_key} | aux_keys try: out = self._dd_compiled_region(eager=eager)(padded_batch) finally: mc.active_outputs = saved_active e = out[energy_key] if consolidate: # Per-node energy: owned-only per-graph sum + cross-rank all-reduce. energy = _consolidate_node_energy( e, padded_batch.batch_idx.long(), int(n_graphs) ) # Wrappers accumulate in a wider dtype but return the total in the # positions dtype; match that so DD and single-GPU agree. if energy.dtype != pos_plain.dtype: energy = energy.to(pos_plain.dtype) else: # Model self-consolidated the global per-system energy already. energy = e grad_inputs = [pos_plain] if not want_stress else [pos_plain, strain] # The graph is freed here, so a wired consumer's dE/dfield must come # out of this same backward. n_core = len(grad_inputs) if extra_grad_inputs: grad_inputs = grad_inputs + list(extra_grad_inputs) grads = torch.autograd.grad( [energy], grad_inputs, grad_outputs=[torch.ones_like(energy)], create_graph=False, # A wired consumer's graph descends from the producer's, and a # pinned leaf means the caller backwards through this again. retain_graph=bool(extra_grad_inputs) or retain_graph, allow_unused=True, ) grad = grads[0] forces = torch.zeros_like(pos_plain) if grad is None else -grad result: dict[str, Any] = {"energy": energy, "forces": forces} for _k in aux_keys: _v = out.get(_k) if _v is None: continue # Strip the cap padding: consolidation expects the real padded view. if isinstance(_v, torch.Tensor) and _v.shape[:1] == pos_plain.shape[:1]: _v = _v[: int(meta.n_padded)] result[_k] = _v if extra_grad_inputs: result["_extra_grads"] = list(grads[n_core:]) if want_stress: virial = grads[1] # d(energy)/d(strain): this rank's partial virial if virial is None or cell_local is None: result["stress"] = torch.zeros( int(n_graphs), 3, 3, dtype=pos_plain.dtype, device=pos_plain.device ) else: # sigma = (1/V) dE/d(strain) with the strain applied as # r->r(I+eps), cell->cell(I+eps) (matches the analytic wrappers' # tensile-positive Cauchy stress). Consolidation sums the per-rank # virial across ranks (stress declared ALL_REDUCE). vol = torch.det(cell_local).abs().reshape(-1, 1, 1) result["stress"] = virial / vol if cell_orig is not None: object.__setattr__(padded_batch, "cell", cell_orig) adapted = self._wrapper.adapt_output(result, padded_batch) if "_extra_grads" in result: # ``adapt_output`` would drop this, but a wired caller needs the # dE/dfield from the same backward that produced the forces. adapted["_extra_grads"] = result["_extra_grads"] return adapted