Source code for nvalchemi.distributed._core.adapter

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

"""Unified third-party adapter API.

The adapter classes share one mental model: declare on a
:class:`DistributionSpec`, and the framework owns install/restore via
:class:`AdapterRegistry`. :class:`OpAdapter` delegates to
:func:`~nvalchemi.distributed._core.escape_hatches.wrap_custom_op`;
:class:`JitAdapter` / :class:`PythonAdapter` swap a module-level callable,
with install + restore owned by the registry rather than a hand-managed
handle.

**Which adapter? — decide by what you are adapting:**

==========================================  ==========================
What you are adapting                       Use
==========================================  ==========================
a custom / Triton / Warp kernel registered  :class:`OpAdapter` — declare
via ``@torch.library.custom_op`` /          its cross-rank I/O roles
``@torch.library.triton_op``                (``gather_inputs`` /
                                            ``scatter_outputs`` / …)
a module-level ``@torch.jit.script``        :class:`JitAdapter` — marshal
helper a ShardTensor must cross             the scripted op, or swap a
                                            plain-Python copy
a plain module-level Python function        :class:`PythonAdapter`, or
whose layout assumptions break under        :func:`FunctionAdapter` to name
partition (e.g. ``aimnet.nbops.mol_sum``)   it by the function object, not
                                            ``module``/``attr`` strings
a class *method* you must wrap              :class:`MethodAdapter` — wrap
(transform an arg, then call the            the call, transform, then
original) — e.g. ``ConvSV.forward``         invoke ``original``
==========================================  ==========================

The rule of thumb: ``OpAdapter`` / ``JitAdapter`` / ``PythonAdapter`` /
``FunctionAdapter`` *replace* a callable outright; :class:`MethodAdapter`
*wraps* one (it hands the replacement the original as its first argument).

* :class:`OpAdapter` — wrap a ``@torch.library.custom_op`` /
  ``@torch.library.triton_op`` kernel. Routes through ShardTensor
  dispatch when called with a ShardTensor argument.
* :class:`JitAdapter` — replace a ``@torch.jit.script`` helper with a
  plain-Python equivalent so ShardTensor's ``__torch_function__`` can
  fire inside.
* :class:`PythonAdapter` — replace a plain-Python module-level helper
  whose tensor-layout assumptions break under partition (e.g. AIMNet2's
  ``aimnet.nbops.mol_sum``). :func:`FunctionAdapter` is the same thing
  named by the function object instead of module/attr strings.
* :class:`MethodAdapter` — wrap a class method: intercept the call,
  transform an argument, then invoke the original.

All are picklable, frozen dataclasses. Lifecycle (install /
restore / introspection) is owned by :class:`AdapterRegistry`, which
:class:`DistributedModel` instantiates per scope.

Worked example::

    from nvalchemi.distributed._core.adapter import (
        OpAdapter, JitAdapter, PythonAdapter,
    )
    from nvalchemi.distributed._core.op_transforms import (
        GatherInputsFull, SliceOutputsOwned,
    )
    from nvalchemi.distributed._core.storage_policy import HaloStoragePolicy
    from nvalchemi.distributed._core.spec import DistributionSpec
    from nvalchemi.distributed.spec import MLIPSpec

    # Plain-Python equivalent of the model's @torch.jit.script helper.
    # Must be byte-for-byte identical except for the ``@torch.jit.script``
    # decorator, so ShardTensor's __torch_function__ can fire inside.
    def _envelopes_plain(r, cutoff):
        return ((r < cutoff).float() * (1 - r / cutoff) ** 2)

    def distribution_spec(self, strategy=None):
        return MLIPSpec(
            distribution=DistributionSpec(
                policy=HaloStoragePolicy(),
                custom_ops=(
                    OpAdapter(
                        mymace._kernel_radial_basis,
                        arg_transforms={0: GatherInputsFull()},
                        output_transforms={0: SliceOutputsOwned()},
                    ),
                ),
                third_party_helpers=(
                    JitAdapter(
                        "mymace.scripts", "envelopes",
                        replacement=_envelopes_plain,
                    ),
                    PythonAdapter(
                        "mymace.utils", "build_neighbor_mask",
                        replacement=self._distributed_neighbor_mask,
                    ),
                ),
            ),
        )

    # ``DistributedModel.__enter__`` builds an ``AdapterRegistry`` from
    # the spec's adapters and calls ``install()``; ``__exit__`` calls
    # ``restore()``. Adapters with ``replacement=None`` are pure
    # declarations — the wrapper installs the actual swap elsewhere
    # (e.g. ``distributed_setup`` when the replacement closes over
    # runtime metadata).
"""

from __future__ import annotations

import logging
import sys
import warnings
from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Sequence

from nvalchemi.distributed._core.op_transforms import (
    AllReduceSum,
    ArgTransform,
    GatherInputs,
    GatherInputsFull,
    OutputTransform,
    ScatterOutputs,
    SliceOutputsOwned,
    SliceOwned,
)

logger = logging.getLogger(__name__)


__all__ = [
    "AdapterStatus",
    "OpAdapter",
    "JitAdapter",
    "PythonAdapter",
    "FunctionAdapter",
    "MethodAdapter",
    "ModuleForwardAdapter",
    "AdapterRegistry",
    "ThirdPartyHelper",
    "register_adapter_kind",
    "_op_qualname",
    "_resolve_op",
]


# ----------------------------------------------------------------------
# Introspection.
# ----------------------------------------------------------------------


AdapterKind = Literal["op", "jit", "python", "method"]
AdapterState = Literal["pending", "installed", "restored", "failed"]


[docs] @dataclass(frozen=True) class AdapterStatus: """Introspectable record of one adapter's lifecycle state. Returned by :meth:`AdapterRegistry.list_active`. Surfaces what's been swapped in this process — useful when debugging "why is ``fairchem`` behaving weirdly outside the distributed scope" kinds of questions. Attributes ---------- kind ``"op"`` for :class:`OpAdapter`, ``"jit"`` / ``"python"``. target Human-readable identifier of the adapted callable (``"torch.ops.fairchem._kernel_xyz"`` / ``"aimnet.nbops.mol_sum"``). state ``"pending"`` (registered but not installed), ``"installed"``, ``"restored"`` (installed then cleaned up), ``"failed"`` (install raised — see ``error``). install_site ``"filename:lineno"`` capturing where the adapter was constructed. Empty when not auto-captured. error ``str(exception)`` when state == ``"failed"``; else ``None``. """ kind: AdapterKind target: str state: AdapterState install_site: str = "" error: str | None = None
def _capture_call_site() -> str: """Best-effort ``filename:lineno`` of the user code that constructed this adapter. Walks up the frame chain skipping any frame whose filename is ``<string>`` (the dataclass-synthesised init) or this module itself, so the result is always the caller in real source. """ try: # Start two up (skip ourselves + __post_init__). depth = 2 while True: frame = sys._getframe(depth) filename = frame.f_code.co_filename if filename == "<string>" or filename.endswith("/_core/adapter.py"): depth += 1 continue return f"{filename}:{frame.f_lineno}" except (ValueError, AttributeError): return "" # ---------------------------------------------------------------------- # OpAdapter — torch.library.custom_op wrapper. # ----------------------------------------------------------------------
[docs] @dataclass(frozen=True) class OpAdapter: """Adapt a ``@torch.library.custom_op`` / ``@torch.library.triton_op`` kernel to ShardTensor-aware dispatch. Two dicts declare per-position pre/post transformations: ``arg_transforms`` (input position → :data:`ArgTransform`) :class:`~nvalchemi.distributed._core.op_transforms.GatherInputs` — halo-pad owned input to ``(n_padded, *F)`` before kernel. :class:`~nvalchemi.distributed._core.op_transforms.GatherInputsFull` — sharded analogue: full-gather to ``(n_global + 1, *F)``. :class:`~nvalchemi.distributed._core.op_transforms.SliceOwned` — slice halo-padded input to ``(n_owned, *F)``. ``output_transforms`` (output position → :data:`OutputTransform`) :class:`~nvalchemi.distributed._core.op_transforms.ScatterOutputs` — ``halo_reverse_exchange + halo_forward_exchange`` after kernel. :class:`~nvalchemi.distributed._core.op_transforms.AllReduceSum` — cross-rank SUM (autograd-symmetric). :class:`~nvalchemi.distributed._core.op_transforms.SliceOutputsOwned` — slice ``(n_global + 1, *F)`` back to ``(n_owned + 1, *F)``. The **positional-role** form names which I/O plays which cross-rank role by position, with no transform objects to import:: OpAdapter(torch.ops.ns.fused_op, scatter_outputs=[0]) # node scatter OpAdapter(op, gather_inputs=[0], scatter_outputs=[0]) # neighbor read + scatter The role keywords lower onto the same two transform dicts, so the dict form above stays valid and serialization is unchanged. See the module docstring for the full worked example. """ op: Any arg_transforms: dict[int, ArgTransform] = field(default_factory=dict) output_transforms: dict[int, OutputTransform] = field(default_factory=dict) install_site: str = field(default="", compare=False, hash=False) def __init__( self, op: Any, arg_transforms: dict[int, ArgTransform] | None = None, output_transforms: dict[int, OutputTransform] | None = None, *, gather_inputs: tuple[int, ...] = (), neighbors_inputs: tuple[int, ...] = (), gather_inputs_full: tuple[int, ...] = (), owned_slice_inputs: tuple[int, ...] = (), scatter_outputs: tuple[int, ...] = (), all_reduce_outputs: tuple[int, ...] = (), slice_outputs_owned: tuple[int, ...] = (), install_site: str = "", ) -> None: # Positional-role keywords lower onto the transform dicts. An explicit # dict entry for a position wins over a role keyword for that position. # ``neighbors_inputs`` is an alias for ``gather_inputs``: an opaque kernel # that reads each atom's NEIGHBOR rows — the framework refreshes those # rows' ghosts before the kernel (halo forward-exchange in eager dispatch; # the static op under compile). at: dict[int, ArgTransform] = dict(arg_transforms or {}) ot: dict[int, OutputTransform] = dict(output_transforms or {}) for _p in (*gather_inputs, *neighbors_inputs): at.setdefault(_p, GatherInputs()) for _p in gather_inputs_full: at.setdefault(_p, GatherInputsFull()) for _p in owned_slice_inputs: at.setdefault(_p, SliceOwned()) for _p in scatter_outputs: ot.setdefault(_p, ScatterOutputs()) for _p in all_reduce_outputs: ot.setdefault(_p, AllReduceSum()) for _p in slice_outputs_owned: ot.setdefault(_p, SliceOutputsOwned()) # Accept the op PACKET (``torch.ops.ns.name``) and resolve ``.default`` # ourselves so callers never type ``.default``. An explicit overload # (``...name.default``) is used as-is. A ``"<ns>::<name>"`` string is a # lazily-resolved op reference — the live op is looked up at # :meth:`install` (runtime), so a spec that names a kernel from an # optional extension can be *declared* without that extension present. if not isinstance(op, str) and type(op).__name__ == "OpOverloadPacket": op = op.default object.__setattr__(self, "op", op) object.__setattr__(self, "arg_transforms", at) object.__setattr__(self, "output_transforms", ot) object.__setattr__(self, "install_site", install_site or _capture_call_site()) # -- Per-transform-kind position views (used by the dispatch path) -- @property def gather_inputs(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.arg_transforms.items() if isinstance(t, GatherInputs) ) ) @property def gather_inputs_full(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.arg_transforms.items() if isinstance(t, GatherInputsFull) ) ) @property def owned_slice_inputs(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.arg_transforms.items() if isinstance(t, SliceOwned) ) ) @property def scatter_outputs(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.output_transforms.items() if isinstance(t, ScatterOutputs) ) ) @property def all_reduce_outputs(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.output_transforms.items() if isinstance(t, AllReduceSum) ) ) @property def all_reduce_replicated_outputs(self) -> tuple[int, ...]: """Subset of :attr:`all_reduce_outputs` whose reduced value feeds a replicated whole-system computation, so the adjoint passes the incoming gradient through instead of reducing it again.""" return tuple( sorted( p for p, t in self.output_transforms.items() if isinstance(t, AllReduceSum) and t.replicated_consumer ) ) @property def slice_outputs_owned(self) -> tuple[int, ...]: return tuple( sorted( p for p, t in self.output_transforms.items() if isinstance(t, SliceOutputsOwned) ) ) # -- Lifecycle -- def _live_op(self) -> Any: """Resolve the op to a live handle. A ``"<ns>::<name>"`` string is looked up now (:func:`_resolve_op`); a live op is returned as-is.""" return _resolve_op(self.op) if isinstance(self.op, str) else self.op def _target_str(self) -> str: op = self.op if isinstance(op, str): return op schema = getattr(op, "_schema", None) if schema is not None and getattr(schema, "name", None): return schema.name return str(op)
[docs] def install(self) -> dict[str, Any]: """Register a ShardTensor-aware dispatch handler on the op (and its overload packet, if any). Returns a memento that :meth:`restore` consumes to clear the registration. A lazily-named op is resolved to its live handle here (raises if the declaring module isn't imported). """ # See ``escape_hatches.wrap_custom_op`` for full semantics. from nvalchemi.distributed._core.escape_hatches import ( wrap_custom_op, # noqa: PLC0415 ) op = self._live_op() wrap_custom_op( op, gather_inputs=self.gather_inputs, scatter_outputs=self.scatter_outputs, owned_slice_inputs=self.owned_slice_inputs, all_reduce_outputs=self.all_reduce_outputs, all_reduce_replicated_outputs=self.all_reduce_replicated_outputs, gather_inputs_full=self.gather_inputs_full, slice_outputs_owned=self.slice_outputs_owned, ) # Memento captures the op + packet for clear_handlers. packet = getattr(op, "_overloadpacket", None) return {"op": op, "packet": packet}
[docs] def restore(self, memento: dict[str, Any]) -> None: """Clear the handler registered by :meth:`install`.""" from nvalchemi.distributed._core.shard_tensor import ( clear_handlers, # noqa: PLC0415 ) clear_handlers(memento["op"]) if memento.get("packet") is not None and memento["packet"] is not memento["op"]: clear_handlers(memento["packet"])
[docs] def describe( self, state: AdapterState = "pending", error: str | None = None ) -> AdapterStatus: """Return an :class:`AdapterStatus` snapshot of this adapter.""" return AdapterStatus( kind="op", target=self._target_str(), state=state, install_site=self.install_site, error=error, )
# -- JSON serialization --
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-roundtrippable dict.""" return { "op": _op_qualname(self.op), "arg_transforms": { str(pos): _arg_transform_to_dict(t) for pos, t in sorted(self.arg_transforms.items()) }, "output_transforms": { str(pos): _output_transform_to_dict(t) for pos, t in sorted(self.output_transforms.items()) }, }
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "OpAdapter": """Reconstruct an :class:`OpAdapter` from :meth:`to_dict` output.""" return cls( op=_resolve_op(d["op"]), arg_transforms={ int(p): _arg_transform_from_dict(td) for p, td in d.get("arg_transforms", {}).items() }, output_transforms={ int(p): _output_transform_from_dict(td) for p, td in d.get("output_transforms", {}).items() }, )
# ---------------------------------------------------------------------- # Op-handle <-> qualname round-trip + transform JSON helpers. # Used by OpAdapter and re-exported from spec.py. # ---------------------------------------------------------------------- def _op_qualname(op: Any) -> str: """Return the schema-qualified ``"<namespace>::<name>"`` string for a torch op overload (or overload-packet). Falls back to ``str(op)`` when no schema is exposed. A qualified-name string passes through unchanged (an :class:`OpAdapter` may hold a lazily-resolved op name).""" if isinstance(op, str): return op schema = getattr(op, "_schema", None) if schema is not None and getattr(schema, "name", None): return schema.name overloads = getattr(op, "overloads", None) if callable(overloads): try: for overload_name in op.overloads(): child = getattr(op, overload_name) child_schema = getattr(child, "_schema", None) if child_schema is not None and getattr(child_schema, "name", None): return child_schema.name except Exception: # noqa: S110, BLE001 # Best-effort introspection — we fall through to ``str(op)`` # below for any op whose overload list raises. pass return str(op) def _resolve_op(qualname: str) -> Any: """Inverse of :func:`_op_qualname`. Resolves ``"<namespace>::<name>"`` to ``torch.ops.<namespace>.<name>.default``. The op-registering module must already be imported.""" import torch # noqa: PLC0415 if "::" not in qualname: raise ValueError( f"_resolve_op: expected '<namespace>::<name>' qualified form, " f"got {qualname!r}" ) ns, name = qualname.split("::", 1) namespace = getattr(torch.ops, ns, None) if namespace is None: raise RuntimeError( f"_resolve_op: torch.ops.{ns} not registered. The module that " f"declares {qualname!r} must be imported before loading the spec." ) overload_packet = getattr(namespace, name, None) if overload_packet is None: raise RuntimeError( f"_resolve_op: torch.ops.{ns}.{name} not registered in namespace {ns!r}." ) return overload_packet.default _ARG_TRANSFORM_REGISTRY: dict[str, type] = { "gather_inputs": GatherInputs, "gather_inputs_full": GatherInputsFull, "slice_owned": SliceOwned, } _OUTPUT_TRANSFORM_REGISTRY: dict[str, type] = { "scatter_outputs": ScatterOutputs, "all_reduce_sum": AllReduceSum, "slice_outputs_owned": SliceOutputsOwned, } def _transform_to_dict(t: Any, registry: dict[str, type], label: str) -> dict[str, Any]: """Encode a transform as its registry name plus its dataclass fields. The fields are carried generically rather than per-class: a transform field that changes behaviour — an adjoint rule, say — would otherwise revert to its default on the far side of a process boundary while the name still matched. """ import dataclasses # noqa: PLC0415 for kind, cls in registry.items(): if isinstance(t, cls): encoded = {"type": kind} encoded.update(dataclasses.asdict(t)) return encoded raise TypeError(f"unknown {label}: {type(t).__name__}") def _transform_from_dict( d: dict[str, Any], registry: dict[str, type], label: str ) -> Any: """Inverse of :func:`_transform_to_dict`.""" cls = registry.get(d.get("type")) if cls is None: raise ValueError( f"unknown {label} type {d.get('type')!r}; expected one of {list(registry)}." ) return cls(**{k: v for k, v in d.items() if k != "type"}) def _arg_transform_to_dict(t: ArgTransform) -> dict[str, Any]: return _transform_to_dict(t, _ARG_TRANSFORM_REGISTRY, "ArgTransform") def _arg_transform_from_dict(d: dict[str, Any]) -> ArgTransform: return _transform_from_dict(d, _ARG_TRANSFORM_REGISTRY, "ArgTransform") def _output_transform_to_dict(t: OutputTransform) -> dict[str, Any]: return _transform_to_dict(t, _OUTPUT_TRANSFORM_REGISTRY, "OutputTransform") def _output_transform_from_dict(d: dict[str, Any]) -> OutputTransform: return _transform_from_dict(d, _OUTPUT_TRANSFORM_REGISTRY, "OutputTransform") # ---------------------------------------------------------------------- # JitAdapter — replace @torch.jit.script helper with plain-Python. # ---------------------------------------------------------------------- def make_marshaller(original: Callable[..., Any]) -> Callable[..., Any]: """Build a marshaller around a scripted callable. A ``@torch.jit.script`` op called with a ShardTensor goes straight into the JIT executor — ``__torch_function__`` does NOT fire for scripted calls — so a TensorExpr-fused kernel reads the wrapper's raw ``data_ptr`` (``~=0`` for the storage-less ShardTensor) → CUDA illegal memory access. The marshaller unwraps ShardTensor args to their real-storage local tensors, runs the (still-scripted, still-fused) op, and re-wraps the output as a ShardTensor. The unwrap/rewrap go through ``_unwrap_grad_aware`` / ``_wrap_back_to_shardtensor`` so the autograd graph (positions → energy) stays intact for ``F = -dE/dx`` — the dominant MLIP force convention. Only correct for node/edge-LOCAL scripted ops (no cross-rank dependency inside the scripted region); the equivalence check is the correctness backstop, and ``DomainConfig.scripted_marshal`` / a denylist let a cross-rank op be excluded. """ def _marshalled(*args: Any, **kwargs: Any) -> Any: from nvalchemi.distributed._core.shard_tensor import ( # noqa: PLC0415 ShardTensor, _prefer_source, _unwrap_grad_aware, _wrap_back_to_shardtensor, ) source = _prefer_source(args, kwargs) if source is None: # No ShardTensor in the call — nothing to marshal; run as-is. return original(*args, **kwargs) def _unwrap(t: Any) -> Any: if isinstance(t, ShardTensor): return _unwrap_grad_aware(t) if isinstance(t, (list, tuple)): return type(t)(_unwrap(x) for x in t) return t local_args = tuple(_unwrap(a) for a in args) local_kwargs = {k: _unwrap(v) for k, v in kwargs.items()} out = original(*local_args, **local_kwargs) return _wrap_back_to_shardtensor(out, source) # Tag for diagnostics / dedup (auto-discovery skips already-marshalled attrs). _marshalled._nvalchemi_marshaller = True # type: ignore[attr-defined] # ``torch.compile`` cannot trace a scripted (``RecursiveScriptModule``) op — # dynamo raises ``UnspecializedNNModuleVariable ... ScriptModules # unsupported`` under ``fullgraph=True``. ``torch.compiler.disable`` makes # dynamo graph-break here and run the marshaller EAGERLY — unwrapping the # ShardTensor to its real-storage local (no TorchScript-fusion IMA), running # the still-scripted op, and re-wrapping via ``_wrap_back_to_shardtensor`` # (its ``_AutogradPreservingWrap`` keeps # ``wrapper.requires_grad == _local_tensor.requires_grad``, so the surrounding # compiled regions' fake-tensorization of the result does not trip the # inner/outer requires_grad assertion). A no-op in eager. import torch # noqa: PLC0415 disabled = torch.compiler.disable(_marshalled) disabled._nvalchemi_marshaller = True # type: ignore[attr-defined] return disabled _MARSHAL_WRAPPER_CLS: Any = None def _marshalling_module_cls() -> Any: """Lazily build the marshalling wrapper ``nn.Module`` (this module avoids a load-time torch import).""" global _MARSHAL_WRAPPER_CLS if _MARSHAL_WRAPPER_CLS is None: import torch # noqa: PLC0415 class _MarshallingModule(torch.nn.Module): _nvalchemi_marshal_wrap = True def __init__(self, inner: Any) -> None: super().__init__() self.inner = inner self._marshalled = make_marshaller(inner) def forward(self, *args: Any, **kwargs: Any) -> Any: return self._marshalled(*args, **kwargs) _MARSHAL_WRAPPER_CLS = _MarshallingModule return _MARSHAL_WRAPPER_CLS def auto_marshal_scripted_submodules( model: Any, *, exclude: Sequence[str] = (), declared_targets: Sequence[str] = () ) -> list[tuple[Any, str, Any]]: """Auto-discover and wrap each scripted submodule's ``forward`` with a marshaller so a ShardTensor can cross it (scripted/fused kernels can't read the storage-less wrapper). Returns ``[(parent, child_name, original_submodule), ...]`` for :func:`restore_auto_marshalled`. Skips submodules whose qualified name contains an ``exclude`` substring, or is already covered by a declared ``JitAdapter`` (``declared_targets``), or is already wrapped (idempotent). The wrapper intercepts at the Python ``__call__`` boundary — before the JIT executor — so the marshalled inputs reach the scripted graph. """ import torch # noqa: PLC0415 import torch.distributed as dist # noqa: PLC0415 wrap_cls = _marshalling_module_cls() mementos: list[tuple[Any, str, Any]] = [] scripted = [ (name, mod) for name, mod in model.named_modules() if name and isinstance(mod, torch.jit.ScriptModule) ] for name, mod in scripted: if any(pat in name for pat in exclude): continue if any(name in t or t in name for t in declared_targets): continue parent_name, _, child = name.rpartition(".") parent = model.get_submodule(parent_name) if parent_name else model if getattr(getattr(parent, child, None), "_nvalchemi_marshal_wrap", False): continue setattr(parent, child, wrap_cls(mod)) mementos.append((parent, child, mod)) # Per-submodule detail stays available at DEBUG; the default path gets a # single rank-0 summary below (this fires for every scripted submodule of # every layer x every rank — e.g. ~30xN for MACE — so a warning per hit # buries the log). logger.debug( "auto-marshalled scripted submodule %r for the distributed path " "(ShardTensor inputs unwrapped to local).", name, ) if mementos: rank = dist.get_rank() if dist.is_initialized() else 0 if rank == 0: logger.info( "auto-marshalled %d scripted submodule(s) for the distributed " "path (ShardTensor inputs unwrapped to local). If a result " "diverges, exclude via DomainConfig.scripted_marshal_exclude or " "declare a JitAdapter; disable auto-discovery with " "scripted_marshal='declared'.", len(mementos), ) return mementos def restore_auto_marshalled(mementos: list[tuple[Any, str, Any]]) -> None: """Undo :func:`auto_marshal_scripted_submodules` (reverse order).""" for parent, child, original in reversed(mementos): setattr(parent, child, original)
[docs] @dataclass(frozen=True) class JitAdapter: """Replace a ``@torch.jit.script``-decorated module-level helper so a ShardTensor can cross it safely on the distributed path. Two modes: * ``mode="marshal"``: wrap the *original* scripted op with :func:`make_marshaller` at install time — unwrap ShardTensor→local, run the scripted op, rewrap. Keeps the op scripted/fused; no hand-written copy. * ``mode="eager"``: swap in ``replacement`` (a hand-written plain-Python equivalent so ShardTensor ``__torch_function__`` fires inside it). The author keeps that copy in sync with upstream. """ module_path: str attr_name: str replacement: Callable[..., Any] | None = None mode: Literal["eager", "marshal"] = "eager" install_site: str = field(default="", compare=False, hash=False) def __post_init__(self) -> None: if not self.install_site: object.__setattr__(self, "install_site", _capture_call_site()) def _target_str(self) -> str: return f"{self.module_path}.{self.attr_name}"
[docs] def install(self) -> dict[str, Any]: """Swap in the replacement at ``module.attr``. ``mode="marshal"``: build a :func:`make_marshaller` around the *current* attribute (the original scripted op) — no hand-written copy needed. ``mode="eager"``: swap in ``self.replacement``. ``replacement=None`` is the declaration-only form: the entry is in the spec for diagnostics, but the wrapper's ``distributed_setup`` hook swaps the attribute (it closes over per-run partition metadata). The registry no-ops here. """ import importlib # noqa: PLC0415 if self.mode == "marshal": module = importlib.import_module(self.module_path) original = getattr(module, self.attr_name) logger.info( "JitAdapter.install: marshalling %s (%s)", self._target_str(), type(original).__name__, ) setattr(module, self.attr_name, make_marshaller(original)) return {"module": module, "original": original} if self.replacement is None: return {"deferred": True} module = importlib.import_module(self.module_path) original = getattr(module, self.attr_name) logger.info( "JitAdapter.install: replacing %s (%s) with %s", self._target_str(), type(original).__name__, getattr(self.replacement, "__qualname__", str(self.replacement)), ) setattr(module, self.attr_name, self.replacement) return {"module": module, "original": original}
[docs] def restore(self, memento: dict[str, Any]) -> None: """Reverse :meth:`install`: put the original attribute back.""" if memento.get("deferred"): return setattr(memento["module"], self.attr_name, memento["original"])
[docs] def describe( self, state: AdapterState = "pending", error: str | None = None ) -> AdapterStatus: """Return an :class:`AdapterStatus` snapshot of this adapter.""" return AdapterStatus( kind="jit", target=self._target_str(), state=state, install_site=self.install_site, error=error, )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-roundtrippable dict.""" return { "kind": "jit", "module_path": self.module_path, "attr_name": self.attr_name, "replacement": _replacement_qualname(self.replacement), "mode": self.mode, }
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "JitAdapter": """Reconstruct a :class:`JitAdapter` from :meth:`to_dict` output.""" return cls( module_path=d["module_path"], attr_name=d["attr_name"], replacement=_resolve_replacement(d.get("replacement")), mode=d.get("mode", "eager"), )
# ---------------------------------------------------------------------- # PythonAdapter — replace a plain-Python helper. # ----------------------------------------------------------------------
[docs] @dataclass(frozen=True) class PythonAdapter: """Replace a plain-Python module-level helper with a distributed-aware version. Unlike :class:`JitAdapter`, the helper isn't ``@torch.jit.script`` — it's a normal Python function whose single-process tensor-layout assumptions break under partition. Canonical case: ``aimnet.nbops.mol_sum`` reading ``mol_idx[-1] + 1`` for its output size. ``replacement`` may be ``None`` if it must be built at install time by the wrapper (e.g. it closes over per-run partition metadata that's only available in ``DistributedModel.__enter__``). Pass a factory via the wrapper's ``distributed_setup`` hook or override :meth:`install` in a subclass. """ module_path: str attr_name: str replacement: Callable[..., Any] | None = None install_site: str = field(default="", compare=False, hash=False) def __post_init__(self) -> None: if not self.install_site: object.__setattr__(self, "install_site", _capture_call_site()) def _target_str(self) -> str: return f"{self.module_path}.{self.attr_name}"
[docs] def install(self) -> dict[str, Any]: """Swap in the plain-Python replacement at ``module.attr``. ``replacement=None`` = declaration-only: the wrapper's ``distributed_setup`` is responsible for swapping the attr. See note on :meth:`JitAdapter.install` for the rationale. """ if self.replacement is None: return {"deferred": True} import importlib # noqa: PLC0415 module = importlib.import_module(self.module_path) original = getattr(module, self.attr_name) logger.info( "PythonAdapter.install: replacing %s with %s", self._target_str(), getattr(self.replacement, "__qualname__", str(self.replacement)), ) setattr(module, self.attr_name, self.replacement) return {"module": module, "original": original}
[docs] def restore(self, memento: dict[str, Any]) -> None: """Reverse :meth:`install`: put the original attribute back.""" if memento.get("deferred"): return setattr(memento["module"], self.attr_name, memento["original"])
[docs] def describe( self, state: AdapterState = "pending", error: str | None = None ) -> AdapterStatus: """Return an :class:`AdapterStatus` snapshot of this adapter.""" return AdapterStatus( kind="python", target=self._target_str(), state=state, install_site=self.install_site, error=error, )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-roundtrippable dict.""" return { "kind": "python", "module_path": self.module_path, "attr_name": self.attr_name, "replacement": _replacement_qualname(self.replacement), }
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "PythonAdapter": """Reconstruct a :class:`PythonAdapter` from :meth:`to_dict` output.""" return cls( module_path=d["module_path"], attr_name=d["attr_name"], replacement=_resolve_replacement(d.get("replacement")), )
[docs] def FunctionAdapter( # noqa: N802 — constructor-style factory func: Any, replacement: Callable[..., Any] | None = None, ) -> PythonAdapter: """Adapt a module-level function named by the **real function object**. Ergonomic constructor for :class:`PythonAdapter`: derives the import path from ``func`` itself, so callers write ``FunctionAdapter(mol_sum, _owned_mol_sum)`` instead of spelling the module path and attribute name as strings. Returns a :class:`PythonAdapter`, so install / restore / serialization are unchanged. Safe by construction: the target is resolved as ``func.__module__`` + ``func.__name__`` and **verified** to be that exact object. A re-exported name (the same function bound under more than one module — e.g. fairchem's ``reduce_node_to_system``) can't be distinguished from the resolved object, so it raises and asks for an explicit ``PythonAdapter(module_path=..., attr_name=...)`` naming the binding. """ import importlib # noqa: PLC0415 module_path = getattr(func, "__module__", None) attr_name = getattr(func, "__name__", None) if not module_path or not attr_name: raise TypeError( "FunctionAdapter expects a module-level function object with " "__module__ and __name__." ) resolved = getattr(importlib.import_module(module_path), attr_name, None) if resolved is not func: raise ValueError( f"FunctionAdapter cannot bind {attr_name!r}: it is not the same " f"object as {module_path}.{attr_name} (likely re-exported under " f"another module). Use PythonAdapter(module_path=..., attr_name=...) " f"to name the exact module binding to patch." ) return PythonAdapter( module_path=module_path, attr_name=attr_name, replacement=replacement )
# ---------------------------------------------------------------------- # MethodAdapter — wrap a class method (call-original), not replace it. # ----------------------------------------------------------------------
[docs] @dataclass(frozen=True) class MethodAdapter: """Wrap a class method: intercept the call, transform an argument, then invoke the original — as opposed to :class:`PythonAdapter` / :class:`JitAdapter`, which *replace* a module-level function outright. Some third-party models need a *method-internal* arg wrap that can't be expressed by replacing a module-level helper. Canonical case: AIMNet2's ``aimnet.modules.aev.ConvSV.forward`` — its ``conv_q`` path (``d2features=False``) indexes a charges-derived arg that has lost ShardTensor metadata through the MLP, so it must be re-wrapped as a sharded ShardTensor before the stock ``a.index_select(0, nbmat.flatten())``, or it reads global ``nbmat`` indices off a rank-local tensor. ``replacement`` is a *wrapping* function ``(original, *args, **kwargs)``: :meth:`install` captures the original (unbound) method and binds it as the first argument, so the replacement can transform args and call through. Reading per-step routing off the method's own arguments (e.g. ConvSV's ``data`` dict, which :meth:`AIMNet2Wrapper.adapt_input` populates) keeps it free of ambient context — the same discipline the PythonAdapter replacements follow. Declared on a spec's ``third_party_helpers`` so the framework's :class:`AdapterRegistry` installs + restores it; no wrapper ``distributed_setup`` hook needed. Normally name the **real imported class**, not a string path:: MethodAdapter(ConvSV, "forward", _rewrap_conv_q) # real class + method The string form ``MethodAdapter("module", "Class", "method", ...)`` is also accepted (disambiguated by the first argument's type), so serialized dicts round-trip unchanged. """ module_path: str class_name: str method_name: str replacement: Callable[..., Any] | None = None mode: Literal["wrap", "marshal"] = "wrap" install_site: str = field(default="", compare=False, hash=False) def __init__( self, target: type | str | None = None, method_or_class: str | None = None, replacement_or_method: Any = None, replacement: Callable[..., Any] | None = None, *, module_path: str | None = None, class_name: str | None = None, method_name: str | None = None, mode: Literal["wrap", "marshal"] = "wrap", install_site: str = "", ) -> None: if isinstance(target, type): # Class form: MethodAdapter(RealClass, "method", replacement?) module_path = target.__module__ class_name = target.__qualname__ method_name = method_or_class if replacement is None: replacement = replacement_or_method elif target is not None: # String positional form: # MethodAdapter("module", "Class", "method", replacement?) module_path = target class_name = method_or_class method_name = replacement_or_method # else: fully-keyword form (module_path=/class_name=/method_name=) if module_path is None or class_name is None or method_name is None: raise TypeError( "MethodAdapter requires a class + method: either " "MethodAdapter(RealClass, 'method', fn) or " "MethodAdapter('module.path', 'Class', 'method')." ) object.__setattr__(self, "module_path", module_path) object.__setattr__(self, "class_name", class_name) object.__setattr__(self, "method_name", method_name) object.__setattr__(self, "replacement", replacement) object.__setattr__(self, "mode", mode) object.__setattr__(self, "install_site", install_site or _capture_call_site()) def _target_str(self) -> str: return f"{self.module_path}.{self.class_name}.{self.method_name}"
[docs] def install(self) -> dict[str, Any]: """Wrap ``class.method`` so calls route through ``replacement``. ``replacement=None`` is the declaration-only form (registry no-ops); present for parity with the other adapters. ``mode="marshal"``: wrap the *whole method* (e.g. e3nn ``SphericalHarmonics.forward``) with :func:`make_marshaller` — the smallest region that resolves BOTH (A) a scripted call inside the method AND (B) a subsequent in-place mutation of a ShardTensor (e.g. ``sh.mul_(cat)``): the marshaller unwraps the ShardTensor input to its local ONCE, so the scripted op and the in-place op both run on a plain local tensor (eager, ``torch.compiler.disable`` graph-break), then the output is re-wrapped. Subsumes a separate ``JitAdapter`` on the inner scripted function. (B is a PyTorch AOT limitation — in-place mutation of a subclass that is a graph input across a graph break — reproduced on stock ``TwoTensor``; keeping the region eager sidesteps it.) """ import functools # noqa: PLC0415 import importlib # noqa: PLC0415 if self.mode == "marshal": cls = getattr(importlib.import_module(self.module_path), self.class_name) original = getattr(cls, self.method_name) marshalled = make_marshaller(original) @functools.wraps(original) def _marshalled_method(*args: Any, **kwargs: Any) -> Any: return marshalled(*args, **kwargs) logger.info("MethodAdapter.install: marshalling %s", self._target_str()) setattr(cls, self.method_name, _marshalled_method) return {"cls": cls, "original": original} if self.replacement is None: return {"deferred": True} cls = getattr(importlib.import_module(self.module_path), self.class_name) original = getattr(cls, self.method_name) replacement = self.replacement @functools.wraps(original) def _wrapped(*args: Any, **kwargs: Any) -> Any: return replacement(original, *args, **kwargs) logger.info( "MethodAdapter.install: wrapping %s with %s", self._target_str(), getattr(replacement, "__qualname__", str(replacement)), ) setattr(cls, self.method_name, _wrapped) return {"cls": cls, "original": original}
[docs] def restore(self, memento: dict[str, Any]) -> None: """Reverse :meth:`install`: put the original method back.""" if memento.get("deferred"): return setattr(memento["cls"], self.method_name, memento["original"])
[docs] def describe( self, state: AdapterState = "pending", error: str | None = None ) -> AdapterStatus: """Return an :class:`AdapterStatus` snapshot of this adapter.""" return AdapterStatus( kind="method", target=self._target_str(), state=state, install_site=self.install_site, error=error, )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-roundtrippable dict.""" return { "kind": "method", "module_path": self.module_path, "class_name": self.class_name, "method_name": self.method_name, "replacement": _replacement_qualname(self.replacement), "mode": self.mode, }
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "MethodAdapter": """Reconstruct a :class:`MethodAdapter` from :meth:`to_dict` output.""" return cls( module_path=d["module_path"], class_name=d["class_name"], method_name=d["method_name"], replacement=_resolve_replacement(d.get("replacement")), mode=d.get("mode", "wrap"), )
# ---------------------------------------------------------------------- # ModuleForwardAdapter — swap a specific module INSTANCE's forward. # ---------------------------------------------------------------------- @dataclass class ModuleForwardAdapter: """Swap one ``nn.Module`` *instance*'s ``forward`` for the DD scope, restore on exit — as opposed to :class:`MethodAdapter`, which swaps a method on the *class* (all instances). Use when the target forward is bound per-instance (a closure monkeypatched onto the object), so a class-level swap can't reach it. Canonical case: a cuequivariance ``conv_tp`` whose fused message-pass forward is set on the instance by ``mace.modules.wrapper_ops.with_cueq_conv_fusion``; under DD that fused kernel hides the gather/scatter from halo correction, so the spec swaps in an external gather + scatter forward (built model-bound, like :func:`neighbor_refresh_adapters`). Because the framework installs spec adapters only inside the distributed scope, ``replacement`` carries no DD branch of its own — single-process keeps the original (fused) forward untouched. Built with a live module instance, so it is rebuilt per-process from the wrapper's ``distribution_spec`` rather than round-tripped through :meth:`to_dict` (the instance can't serialize). """ module: Any replacement: Callable[..., Any] label: str = "module_forward" install_site: str = field(default="", compare=False, hash=False) def __post_init__(self) -> None: if not self.install_site: object.__setattr__(self, "install_site", _capture_call_site()) def _target_str(self) -> str: return ( f"{type(self.module).__module__}.{type(self.module).__qualname__}.forward" ) def install(self) -> dict[str, Any]: """Set ``module.forward = replacement``, capturing the prior binding. ``forward`` is read off the instance ``__dict__`` so restore can tell a per-instance override (put the old callable back) from the inherited class method (drop the instance attribute). """ had = "forward" in self.module.__dict__ prev = self.module.__dict__.get("forward") logger.info("ModuleForwardAdapter.install: swapping %s", self._target_str()) self.module.forward = self.replacement return {"had": had, "prev": prev} def restore(self, memento: dict[str, Any]) -> None: """Reverse :meth:`install`: restore the per-instance forward or, if there was none, drop the instance attribute to fall back to the class method.""" if memento["had"]: self.module.forward = memento["prev"] else: self.module.__dict__.pop("forward", None) def describe( self, state: AdapterState = "pending", error: str | None = None ) -> AdapterStatus: """Return an :class:`AdapterStatus` snapshot of this adapter.""" return AdapterStatus( kind="method", target=self._target_str(), state=state, install_site=self.install_site, error=error, ) def to_dict(self) -> dict[str, Any]: """Best-effort serialization. The bound instance + closure don't round-trip; the wrapper rebuilds this from ``distribution_spec`` per process. Emits a marker so :meth:`DistributionSpec.to_dict` doesn't fail. """ return { "kind": "module_forward", "target": self._target_str(), "label": self.label, } @classmethod def from_dict(cls, d: dict[str, Any]) -> "ModuleForwardAdapter": """Declaration-only reconstruction (install/restore no-op). The real, model-bound adapter is rebuilt by the wrapper's ``distribution_spec``.""" return _DeclaredModuleForwardAdapter(label=d.get("label", "module_forward")) class _DeclaredModuleForwardAdapter(ModuleForwardAdapter): """Deserialized placeholder: no live module, so install/restore no-op.""" def __init__(self, label: str = "module_forward") -> None: object.__setattr__(self, "module", None) object.__setattr__(self, "replacement", lambda *a, **k: None) object.__setattr__(self, "label", label) object.__setattr__(self, "install_site", "") def _target_str(self) -> str: return f"<declared module_forward:{self.label}>" def install(self) -> dict[str, Any]: return {"deferred": True} def restore(self, memento: dict[str, Any]) -> None: return None # Discriminated union of helper-style adapters that go in # ``DistributionSpec.third_party_helpers``. (OpAdapter lives in # ``DistributionSpec.custom_ops`` — different slot, same lifecycle protocol.) ThirdPartyHelper = "JitAdapter | PythonAdapter | MethodAdapter | ModuleForwardAdapter" def _replacement_qualname(fn: Callable | None) -> str | None: """Encode a function reference as ``"<module>:<qualname>"`` for serialization. Returns ``None`` for unresolvable references (closures, lambdas) — caller must rebuild at install time.""" if fn is None: return None mod = getattr(fn, "__module__", None) name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", None) return f"{mod}:{name}" if (mod and name) else None def _resolve_replacement(qualname: str | None) -> Callable | None: """Inverse of :func:`_replacement_qualname`. Best-effort: returns ``None`` if the qualname doesn't resolve. An unresolved replacement installs as a no-op, so the model silently runs uncorrected; that case warns and the caller is expected to rebind from a live spec. """ if not qualname: return None resolved = None if ":" in qualname: import importlib # noqa: PLC0415 mod_path, qual = qualname.split(":", 1) try: obj: Any = importlib.import_module(mod_path) except Exception: obj = None for part in qual.split(".") if obj is not None else (): if part.startswith("<"): obj = None break obj = getattr(obj, part, None) if obj is None: break resolved = obj if resolved is None: warnings.warn( f"Adapter replacement {qualname!r} did not resolve; the adapter will " "install as a no-op. Rebind it from a live spec before use.", UserWarning, stacklevel=3, ) return resolved # Registry mapping serialized "kind" → adapter class. Subclasses that # need to survive ``to_dict``/``from_dict`` round-trips (e.g. the # validator's spawn boundary) register themselves here via # :func:`register_adapter_kind`. _ADAPTER_KIND_REGISTRY: dict[str, type] = {} def register_adapter_kind(kind: str, cls: type) -> None: """Register *cls* as the adapter type for serialized ``"kind": <kind>``. Use this when a custom :class:`PythonAdapter` / :class:`JitAdapter` subclass needs to round-trip through :meth:`MLIPSpec.to_dict` / :meth:`MLIPSpec.from_dict` (e.g. when the validator harness ships the spec across an ``mp.spawn`` boundary). The subclass must: * Override :meth:`to_dict` to emit a unique ``"kind"`` value. * Provide a :meth:`from_dict` ``@classmethod`` that reconstructs the same fields its ``to_dict`` emitted. Re-registration with the same ``kind`` name overrides the prior binding (allows test fixtures to swap implementations cleanly). """ _ADAPTER_KIND_REGISTRY[kind] = cls # Register the built-in kinds. A model that needs a bespoke adapter kind can # subclass one of these and register it via ``register_adapter_kind`` at import. register_adapter_kind("jit", JitAdapter) register_adapter_kind("python", PythonAdapter) register_adapter_kind("method", MethodAdapter) register_adapter_kind("module_forward", ModuleForwardAdapter) def _adapter_from_dict(d: dict[str, Any]) -> "JitAdapter | PythonAdapter": """Discriminate a third-party helper dict by its ``"kind"``, dispatching through :data:`_ADAPTER_KIND_REGISTRY`.""" kind = d.get("kind") cls = _ADAPTER_KIND_REGISTRY.get(kind) if cls is None: raise ValueError( f"unknown third-party helper kind {kind!r}; expected one of " f"{sorted(_ADAPTER_KIND_REGISTRY)}. Subclasses of PythonAdapter " f"/ JitAdapter that introduce new kinds must call " f"``register_adapter_kind(kind, cls)`` at import time." ) return cls.from_dict(d) # ---------------------------------------------------------------------- # AdapterRegistry — owns lifecycle of a set of adapters. # ---------------------------------------------------------------------- # A registered adapter together with its install state. @dataclass class _Handle: adapter: Any # OpAdapter | JitAdapter | PythonAdapter state: AdapterState = "pending" memento: dict[str, Any] | None = None error: str | None = None
[docs] class AdapterRegistry: """Owns the install / restore lifecycle for a set of adapters. :class:`DistributedModel` instantiates a registry on ``__enter__``, calls :meth:`install` with the adapters declared on the spec's :class:`DistributionSpec`, and calls :meth:`restore` on ``__exit__``. ``install`` is fail-fast: if any adapter raises, all previously-installed adapters are rolled back before the exception propagates. ``restore`` is best-effort: failures are logged but don't raise (so a single broken adapter doesn't block teardown of the others). """ def __init__(self) -> None: self._handles: list[_Handle] = []
[docs] def install(self, adapters: Sequence[Any]) -> None: """Install each adapter in order. Rolls back partial state on failure and re-raises.""" for adapter in adapters: handle = _Handle(adapter=adapter) self._handles.append(handle) try: handle.memento = adapter.install() handle.state = "installed" except Exception as e: handle.state = "failed" handle.error = repr(e) logger.error( "AdapterRegistry.install failed for %s: %s", adapter._target_str(), e, ) # Roll back any earlier successful installs and re-raise. self.restore() raise
[docs] def restore(self) -> None: """Restore all installed adapters in reverse order. Failures are logged; never raises.""" for handle in reversed(self._handles): if handle.state != "installed": continue try: handle.adapter.restore(handle.memento) handle.state = "restored" except Exception as e: # noqa: BLE001 handle.error = repr(e) logger.warning( "AdapterRegistry.restore failed for %s: %s", handle.adapter._target_str(), e, )
[docs] def list_active(self) -> list[AdapterStatus]: """Return the introspectable lifecycle status of each adapter registered in this registry.""" return [h.adapter.describe(state=h.state, error=h.error) for h in self._handles]