Source code for nvalchemi.distributed.helpers

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

"""Context-aware helpers for domain-decomposed model wrappers.

Functions a model author calls inside a wrapper to express a distributed
operation by intent — "refresh my neighbor rows", "sum per system" — without
naming the mechanism. Each reads the live distributed context the framework
sets up for the forward and does the right thing under the halo policy, in
single-process, and under ``torch.compile``.

This is the shared home of logic that would otherwise be copy-pasted across
model wrappers.
"""

from __future__ import annotations

import functools
from typing import Any

import torch

from nvalchemi.distributed._core.compile_routing import (
    compile_routing_active,
    get_compile_routing,
    get_gp_compile_routing,
)
from nvalchemi.distributed._core.context import current_dd_context
from nvalchemi.distributed._core.enums import Scope
from nvalchemi.distributed._core.gather_primitives import fixed_gather_to_replicate
from nvalchemi.distributed._core.particle_halo import (
    halo_forward_static_op,
    halo_scatter_correct_static_op,
)
from nvalchemi.distributed._core.per_system import per_system_reduce

__all__ = [
    "Scope",
    "distributed_method",
    "neighbor_refresh_adapters",
    "localize",
    "refresh_neighbors",
    "scatter_to_owners",
    "system_sum",
    "to_local",
]


[docs] def to_local(x: Any) -> Any: """Return the plain local tensor backing a ShardTensor, else ``x`` unchanged. Call this before handing a tensor to a kernel that must not see a ShardTensor. On the halo policy the result is the rank's owned+ghost block. Parameters ---------- x : Any A tensor, ShardTensor, or any non-tensor value. Returns ------- Any ``x.to_local()`` for a ShardTensor; ``x`` unchanged otherwise (a plain tensor or non-tensor passes straight through, so the call is always safe). """ if x is None or not hasattr(x, "to_local"): return x return x.to_local()
[docs] def localize(data: dict[str, Any]) -> dict[str, Any]: """Run :func:`to_local` over every value in a model-input dict. Localize a whole input dict in one call so a kernel that consumes it never sees a ShardTensor. Parameters ---------- data : dict[str, Any] A model-input dict whose values may include ShardTensors. Returns ------- dict[str, Any] A shallow copy with each value run through :func:`to_local`; non-tensor entries (configs, ints) pass through unchanged. """ return {k: to_local(v) for k, v in data.items()}
[docs] def distributed_method(body: Any) -> Any: """Decorate a ``MethodAdapter`` body that only diverges under domain decomposition. Removes the boilerplate guard repeated across method adapters so the body holds only the distributed behavior. The wrapped replacement runs the original method verbatim whenever the live context is not distributed (single-process, or any call outside a distributed forward), and otherwise invokes ``body`` with the live context already in hand. Gating on ``is_distributed`` (not ``is_halo``) keeps the body policy-agnostic: it fires under any strategy (halo, graph-parallel, graph-replicate), and its cross-rank steps are expressed through the policy-dispatched intent verbs. Parameters ---------- body : callable The halo behavior, called as ``body(ctx, original, instance, *args, **kwargs)`` where ``ctx`` is the live :class:`~nvalchemi.distributed._core.context.DistributedContext` and ``original`` is the unpatched method. Returns ------- callable An ``(original, instance, *args, **kwargs)`` replacement suitable for :class:`~nvalchemi.distributed._core.adapter.MethodAdapter`. Examples -------- >>> @distributed_method ... def _refresh_block(ctx, original, block, x, *args, **kwargs): ... return original(block, refresh_neighbors(x), *args, **kwargs) """ @functools.wraps(body) def wrapped(original: Any, instance: Any, *args: Any, **kwargs: Any) -> Any: ctx = current_dd_context() if not ctx.is_distributed: return original(instance, *args, **kwargs) return body(ctx, original, instance, *args, **kwargs) return wrapped
[docs] def neighbor_refresh_adapters( modules: Any, *, output: int = 0, always: bool = False ) -> tuple: """Build adapters that recombine each module's per-node ``forward`` output across ranks. For message-passing blocks whose internal scatter the framework cannot reach: pass the live sub-modules (``model.interactions``); this finds the concrete classes that define ``forward`` and returns one :class:`MethodAdapter` per class. Each adapter runs the block, then applies :func:`scatter_to_owners` to forward output ``output`` (an ``int`` index when the block returns a tuple, else the whole output). By default this fires only inside a compiled DD region (the halo path needs it only under compile; eager halo corrects via dispatch). ``always=True`` fires in eager too — the node-replicate strategy, where the block's per-node output is each rank's partial message sum and the recombine is the all-reduce that must run every forward. In single-process ``scatter_to_owners`` is the identity, so the adapter is a no-op there regardless. Correcting the block's per-node output equals correcting its internal scatter when the downstream ops are linear in the message (true at MACE's interaction boundary: the nonlinear product basis is a separate downstream block, so it sees the recombined message). """ from nvalchemi.distributed._core.adapter import MethodAdapter # noqa: PLC0415 def _refresh(original: Any, *args: Any, **kwargs: Any) -> Any: out = original(*args, **kwargs) if not always and not compile_routing_active(): return out if isinstance(out, tuple): fixed = list(out) fixed[output] = scatter_to_owners(fixed[output]) return type(out)(fixed) return scatter_to_owners(out) seen: dict[tuple, Any] = {} for m in modules: cls = type(m) seen.setdefault((cls.__module__, cls.__qualname__), cls) return tuple(MethodAdapter(cls, "forward", _refresh) for cls in seen.values())
[docs] def refresh_neighbors(x: torch.Tensor) -> torch.Tensor: """Populate this rank's neighbor (ghost) rows of a per-node tensor. Call this at the start of a message-passing block that reads a node's neighbors: it refreshes the ghost rows of ``x`` so each rank sees current neighbor features. Autograd-aware — gradients on the refreshed rows accumulate back to the owning ranks. On the halo policy ``x`` is ``[owned | ghost (| dead padding)]``; owned rows are exchanged into the ghost region and any trailing padding rows are preserved. In single-process this is the identity. Parameters ---------- x : torch.Tensor ``(n_rows, *F)`` per-node features with this rank's owned rows first. Returns ------- torch.Tensor Same shape as ``x`` with the neighbor rows populated. """ # Under compile: use the fixed-shape static op wired to the step's routing # tensors (not current_dd_context, which would bake stale values). ``x`` is # already capped, so the op runs over the whole padded tensor. routing = get_compile_routing() if routing is not None: si, rd, rr, no, ws = routing return halo_forward_static_op(x, si, rd, rr, no, ws) # Graph-parallel (node-partition) under a model-internal compiled forward: # the fullgraph-traceable fixed all-gather, so the per-layer node replicate # fuses into the compiled region. Gated on ``is_compiling`` so the eager path # keeps the (faster, exact-size) ``policy.replicate`` all-gather; the routing # is static (index partition), so reading it as trace-time constants never # recompiles. gp = get_gp_compile_routing() if gp is not None and torch.compiler.is_compiling(): gi, owner, local, cap, ws, mesh = gp return fixed_gather_to_replicate(x, gi, owner, local, cap, ws, mesh) ctx = current_dd_context() if not ctx.is_distributed: return x return ctx.policy.replicate(x, ctx)
[docs] def scatter_to_owners(out: torch.Tensor) -> torch.Tensor: """Fold per-edge contributions written into ghost rows back to owners. After a message-passing block scatters per-edge messages into nodes — leaving each rank's partial sums in its ghost rows — this accumulates those partials into the owning ranks and re-broadcasts, so every rank's owned and ghost rows hold the correct totals for the next block. Autograd-aware. Identity in single-process. Parameters ---------- out : torch.Tensor ``(n_rows, *F)`` per-node tensor with this rank's partial sums in the ghost rows. Returns ------- torch.Tensor Same shape, with owners and ghosts carrying the cross-rank totals. """ # Under compile: the fixed-shape static op wired to the step's routing # tensors — the in-graph form of the eager reverse+forward below. routing = get_compile_routing() if routing is not None: si, rd, rr, no, ws = routing return halo_scatter_correct_static_op(out, si, rd, rr, no, ws) ctx = current_dd_context() if not ctx.is_distributed: return out return ctx.policy.fold(out, ctx)
[docs] def system_sum( vals: torch.Tensor, idx: torch.Tensor, n: int, scope: Scope = Scope.OWNED, ) -> torch.Tensor: """Sum per-node values into per-system totals, without double-counting. Each rank holds neighbor copies of atoms it does not own, so a plain ``scatter_add`` over all rows would over-count. This sums only this rank's owned rows and (for :attr:`Scope.OWNED`) all-reduces across the mesh to the true global per-system total. In single-process it is a plain ``scatter_add`` over all rows. Under compile it masks the ghost / dead rows by the routing's n_owned tensor (a tensor mask, not a dynamic slice, so the partition can drift without forcing a recompile) and reduces over all rows. A wrapper calls it the same way in both modes. Parameters ---------- vals : torch.Tensor ``(n_rows, *F)`` per-node values with owned rows first. idx : torch.Tensor ``(n_rows,)`` integer system index for each row, in ``[0, n)``. n : int Number of systems in the (global) batch. scope : Scope, default ``Scope.OWNED`` ``OWNED`` → owned-only sum + cross-rank all-reduce (global total on every rank). ``LOCAL`` → this rank's owned-only partial with no all-reduce (the framework's output consolidation finishes it). Returns ------- torch.Tensor ``(n, *F)`` per-system totals (replicated on every rank for ``OWNED``; a per-rank partial for ``LOCAL``). """ idx_long = idx.to(torch.long) # Under compile: mask the ghost / dead rows by the n_owned tensor (not a # dynamic ``[:n_owned]`` slice, which would recompile as the partition # drifts), then reduce over all rows (masked rows contribute 0). routing = get_compile_routing() if routing is not None: ctx = current_dd_context() _, _, _, n_owned_t, _ = routing rowidx = torch.arange(vals.shape[0], device=vals.device) owned = ( (rowidx < n_owned_t).reshape((-1,) + (1,) * (vals.ndim - 1)).to(vals.dtype) ) masked = vals * owned if scope is Scope.OWNED: return per_system_reduce(masked, idx_long, n, ctx.halo_config) out = vals.new_zeros((n, *vals.shape[1:])) return out.index_add_(0, idx_long, masked) ctx = current_dd_context() if not ctx.is_distributed: out = vals.new_zeros((n, *vals.shape[1:])) return out.index_add_(0, idx_long, vals) n_owned = ctx.n_owned # Owned rows are a contiguous slice; ``owned_offset`` is 0 when they come # first (halo padded view, node-partition shard) and the rank's interior # start under the node-replicate strategy (every rank holds the full set). off = ctx.owned_offset vals_owned = vals[off : off + n_owned].contiguous() idx_owned = idx_long[off : off + n_owned].contiguous() if scope is Scope.OWNED: # ``per_system_reduce`` needs only the mesh to all-reduce over. The halo # policy carries it on ``halo_config``; a halo-free policy (graph # parallel) supplies it straight off the context. cfg = ctx.halo_config if cfg is None: from types import SimpleNamespace # noqa: PLC0415 cfg = SimpleNamespace(mesh=ctx.mesh) return per_system_reduce(vals_owned, idx_owned, n, cfg) # LOCAL: per-rank partial, no all-reduce; consolidation finishes the sum. out = vals.new_zeros((n, *vals.shape[1:])) return out.index_add_(0, idx_owned, vals_owned)