Source code for nvalchemi.distributed.distributed_pipeline

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

"""Domain-decomposed composition of models (``DistributedPipelineModel``).

Runs a :class:`~nvalchemi.models.pipeline.PipelineModelWrapper` (e.g.
MACE + DFT-D3, AIMNet2 + PME) under domain decomposition, giving each
sub-model its own right-sized halo over one shared owned partition rather
than forcing every sub-model onto the largest cutoff's ghost region.

The caller builds one ``ShardedBatch`` at the max cutoff (one owned
partition); the composite rebuilds each sub-model's halo over it via
``ShardedBatch.invalidate_padded_view`` between models, so the owned set is
never recomputed. It mirrors
:class:`~nvalchemi.distributed.distributed_model.DistributedModel`'s
context-manager + ``__call__(sharded_batch)`` contract, so it is drop-in for
``BaseDynamics``.

Three group kinds:

* **Direct-force** (``use_autograd=False``): sub-models whose forces are
  direct per-atom kernel outputs (DFT-D3, Lennard-Jones, Ewald / PME with
  ``hybrid_forces=True``) compose by summing owned-aligned per-atom energies /
  forces / stresses — no cross-model autograd.

* **Shared-autograd** (``use_autograd=True``, e.g. MACE energy → ``-dE/dr``):
  the group force is ``-d(sum E_m)/dr`` over the summed energy. With one shared
  owned partition and no cross-model coupling it decomposes exactly into
  ``sum_m (-dE_m/dr_owned)``, so each sub-model runs its own autograd forward
  (forces enabled) and the owned-aligned results are summed — identical to a
  single shared ``positions`` leaf with one ``backward()``, while reusing each
  model's eager / compile paths.

* **Wired cross-model fields**: a consumer's energy depends on a per-atom field
  the producer makes (e.g. PME's energy on AIMNet2's ``charges``), so the two
  models share one autograd graph and can't run independently. See
  :meth:`_run_wired_autograd_group`.
"""

from __future__ import annotations

from collections import OrderedDict
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from nvalchemi.distributed.config import DomainConfig
    from nvalchemi.distributed.sharded_batch import ShardedBatch
    from nvalchemi.models.pipeline import PipelineModelWrapper

__all__ = ["DistributedPipelineModel"]


[docs] class DistributedPipelineModel: """Domain decomposition of a composed (pipeline) model. Parameters ---------- pipeline : PipelineModelWrapper The composed model. Its groups / steps supply the ordered sub-models; each sub-model's ``model_config.neighbor_config.cutoff`` sets that model's ghost width. domain_config : DomainConfig Base config carrying the mesh + skin. Per-model configs are derived from it by overriding ``cutoff`` with each sub-model's cutoff. The caller should build the shared :class:`ShardedBatch` at (at least) the **max** sub-model cutoff so the one owned partition's cells hold every model's ghost layer. Notes ----- The composite does not build the ``ShardedBatch`` — the caller does (once, at the max cutoff), exactly as for a single :class:`DistributedModel`. The composite only orchestrates per-model halos over it and sums the results. """ def __init__( self, pipeline: "PipelineModelWrapper", domain_config: "DomainConfig", *, compile: bool = False, compile_kwargs: "dict | None" = None, ) -> None: from nvalchemi.models.pipeline import PipelineModelWrapper # noqa: PLC0415 if not isinstance(pipeline, PipelineModelWrapper): raise TypeError( "DistributedPipelineModel expects a PipelineModelWrapper; for a " "single model use DistributedModel." ) self.pipeline = pipeline self.domain_config = domain_config self.additive_keys = pipeline.additive_keys # Only sub-models with an autograd-force compiled path (MACE, AIMNet2) # compile; kernel-force models (DFTD3 / PME / Ewald / LJ) and the # composite glue always run eager. self._compile = bool(compile) self._compile_kwargs = compile_kwargs self._closed = False # Per-group plans: "per_step" (no cross-step field dependency — run each # sub-model independently and sum) or "wired" (a later step consumes a # per-atom field an earlier step produces, e.g. PME needs AIMNet2's # charges — one coupled autograd graph). Per-step plans hold persistent # DistributedModel instances so compiled graphs survive across MD steps. self._group_plans: list[dict[str, Any]] = [ self._plan_group(g) for g in pipeline.groups ] # Fields always present on a Batch, so never a cross-step wired dependency. _BATCH_FIELDS = frozenset( { "positions", "atomic_numbers", "atomic_masses", "cell", "pbc", "energy", "forces", } ) def _model_cfg(self, step: Any) -> "DomainConfig": """Per-model :class:`DomainConfig` (this sub-model's ghost width).""" return self.domain_config.model_copy( update={"cutoff": step.model.model_config.neighbor_config.cutoff} ) @staticmethod def _compile_capable(step: Any) -> bool: """Whether a sub-model declares an autograd-force compiled path. Compile capability is a model property (strategy-agnostic), so the default (halo) spec is sufficient here.""" _ds = getattr(step.model, "distribution_spec", None) spec = _ds() if callable(_ds) else _ds cp = getattr(spec, "compile", None) return bool(cp is not None and getattr(cp, "forces_via_autograd", False)) def _make_dist_model(self, step: Any, cfg: "DomainConfig") -> Any: """Build a (possibly compiled) persistent :class:`DistributedModel`.""" from nvalchemi.distributed.distributed_model import ( # noqa: PLC0415 DistributedModel, ) compiled = self._compile and self._compile_capable(step) return DistributedModel( step.model, cfg, compile=compiled, compile_kwargs=self._compile_kwargs if compiled else None, ), compiled def _plan_group(self, group: Any) -> dict[str, Any]: """Classify a pipeline group as per-step or wired. A *wired* group has a step whose ``required_inputs`` (excluding always- present batch fields) is produced — after any ``PipelineStep.wire`` rename — by an earlier step in the same group. Only one such producer->consumer field over a two-step group is supported; anything richer raises ``NotImplementedError``. """ produced: dict[str, tuple[Any, str]] = {} wired: list[tuple[Any, str, Any, str]] = [] for step in group.steps: needed = set(step.model.model_config.required_inputs) - self._BATCH_FIELDS for f in needed: if f in produced: p_step, p_out = produced[f] wired.append((p_step, p_out, step, f)) for out_key in step.model.model_config.outputs: produced[step.wire.get(out_key, out_key)] = (step, out_key) if not wired: steps = [] for s in group.steps: cfg = self._model_cfg(s) dm, compiled = self._make_dist_model(s, cfg) steps.append( { "step": s, "use_autograd": group.use_autograd, "dm": dm, "compiled": compiled, } ) return {"kind": "per_step", "steps": steps} if not group.use_autograd: raise NotImplementedError( "DistributedPipelineModel supports wired cross-model fields only " "in shared-autograd groups (use_autograd=True); the charge " "pathway (e.g. AIMNet2 -> PME) needs the combined-energy autograd." ) if len(wired) != 1 or len(group.steps) != 2: raise NotImplementedError( "DistributedPipelineModel C3 supports exactly one producer->" "consumer wired field over a two-step group; got " f"{len(wired)} wired field(s) over {len(group.steps)} steps." ) producer, out_key, consumer, field = wired[0] # Pinned so the leaf survives each model's own halo refresh. The wired # field and ``cell`` are excluded: the group differentiates the first # explicitly, and the strain leaves carry the second. grad_fields = {"positions"} for step in (producer, consumer): mc = step.model.model_config grad_fields |= set(mc.autograd_inputs) | set(mc.gradient_keys) grad_fields -= {field, out_key, "cell"} # Built once and retained, like the per-step path: a model rebuilt each # forward loses its grown shape caps and its compiled-region cache, so a # compiled wired run would recompile every step. producer_cfg = self._model_cfg(producer) consumer_cfg = self._model_cfg(consumer) producer_dm, _ = self._make_dist_model(producer, producer_cfg) consumer_dm, consumer_compiled = self._make_dist_model(consumer, consumer_cfg) return { "kind": "wired", "grad_fields": sorted(grad_fields), "producer": producer, "consumer": consumer, "producer_out_key": out_key, "field": field, "producer_cfg": producer_cfg, "consumer_cfg": consumer_cfg, "producer_dm": producer_dm, "consumer_dm": consumer_dm, "consumer_compiled": consumer_compiled, } # ------------------------------------------------------------------ # Context-manager contract (mirrors DistributedModel for drop-in use) # ------------------------------------------------------------------ def __enter__(self) -> "DistributedPipelineModel": return self def __exit__(self, *_exc: Any) -> None: self.close()
[docs] def close(self) -> None: """Tear down the persistent per-model :class:`DistributedModel` instances (restoring their adapters / compiled state). Idempotent.""" if self._closed: return for plan in self._group_plans: for item in plan.get("steps", ()): item["dm"].close() for key in ("producer_dm", "consumer_dm"): dm = plan.get(key) if dm is not None: dm.close() self._closed = True
def __del__(self) -> None: try: self.close() except Exception: # noqa: S110 pass # ------------------------------------------------------------------ # Forward # ------------------------------------------------------------------ def __call__(self, sharded: "ShardedBatch") -> dict[str, Any]: """Run each sub-model over the shared owned partition; sum the results. For every sub-model: drop the previous model's ghost layer (``invalidate_padded_view``) so the halo is rebuilt at *this* model's ghost width, run it through a :class:`DistributedModel`, and accumulate its owned-aligned outputs. All sub-models see the same owned atoms, so per-atom forces line up by owned index and per-system energies / stresses add directly. For a **shared-autograd** sub-model the per-model ``active_outputs`` is temporarily widened to include the group's derivative keys (``forces`` / ``stress``) so the sub-model's own forward emits them via autograd; summing those is exactly the group's ``-d(sum E_m)/dr``. Parameters ---------- sharded : ShardedBatch The shared partition, built once by the caller at the max cutoff. Returns ------- dict[str, Any] Summed outputs (``energy`` / ``forces`` / ``stress`` over ``additive_keys``); any non-additive key takes the first sub-model that produced it. """ per_model: list[dict[str, Any]] = [] for plan in self._group_plans: if plan["kind"] == "wired": per_model.append(self._run_wired_autograd_group(sharded, plan)) continue for item in plan["steps"]: step = item["step"] # Each sub-model rebuilds its own halo over the shared owned set. sharded.invalidate_padded_view() mc = step.model.model_config saved_active = mc.active_outputs # A compiled sub-model already emits forces via its compiled # energy-autograd path, so only widen active_outputs when eager. if item["use_autograd"] and not item["compiled"]: mc.active_outputs = self._autograd_active_outputs(step) try: per_model.append(item["dm"](sharded)) finally: mc.active_outputs = saved_active return self._combine(per_model) def _run_wired_autograd_group( self, sharded: "ShardedBatch", plan: dict[str, Any] ) -> dict[str, Any]: """Run a wired producer->consumer shared-autograd group. The consumer's energy depends on a per-atom field the producer computes (e.g. PME's energy on AIMNet2's ``charges``), so the two models form one coupled autograd graph. The total force is:: F = -dE_prod/dr (producer's own energy) - (dE_cons/dfield)(dfield/dr) (the cross-model chain) - dE_cons/dr|_field (consumer's own force, at fixed field) Realized as: run the producer for energy + field only (no forces, so its graph is retained), with grad-bearing positions via a ``compute_forces=True`` halo. Run the consumer with its ghost field gathered from the producer's owned values through the autograd-aware ``wired_fields`` exchange, taking its own forces and the field gradient ``dE_cons/dfield`` on the producer's owned atoms. One backward through the producer over ``E_prod + <field, dE_cons/dfield>`` yields the first two force terms; that gradient is sent back to the owning ranks and the per-rank replication from the two all-reduced energies is divided out; the consumer's owned kernel forces are then added. Stress follows the same split. A symmetric per-system strain leaf pinned on the ``ShardedBatch`` deforms the producer's padded geometry, so the same surrogate backward yields ``dE_prod/d eps`` and the cross-model chain ``(dE_cons/dfield)(dfield/d eps)``. The consumer's remaining ``dE_cons/d eps`` at fixed field is its own stress output, consolidated by its declared output rule. Sub-models therefore emit their own derivatives here, where the single-process pipeline would strip them and take one outer backward. That is the same trade the non-wired shared-autograd group already makes (see the module docstring): each model's validated eager / compile path produces its owned-aligned share, and the composite adds only the one term no single model can — the cross-model chain. """ import torch # noqa: PLC0415 from nvalchemi.distributed.helpers import to_local # noqa: PLC0415 from nvalchemi.distributed.particle_halo import ( # noqa: PLC0415 halo_exchange, ) producer = plan["producer"].model consumer = plan["consumer"].model out_key = plan["producer_out_key"] field = plan["field"] want_stress = "stress" in self.pipeline.model_config.active_outputs prod_mc = producer.model_config saved_prod = prod_mc.active_outputs prod_mc.active_outputs = {"energy", out_key} strain = None strain_cell = None pos_leaf_marked = False try: sharded.invalidate_padded_view() pdm = plan["producer_dm"] # Build the producer halo with grad-bearing positions so the # energy / field graph reaches them, even though the producer # emits no forces of its own. pdm._ensure_initialized(sharded) # Differentiate the owned positions, not the padded view: a # halo refresh mints a fresh leaf and would orphan a padded one. leaves: dict[str, Any] = {} for name in plan["grad_fields"]: src = getattr(sharded, name, None) if src is None: raise ValueError( f"{name!r} is differentiated by a model in this wired " "group but is not a per-atom field on the batch, so no " "autograd leaf can be pinned for it." ) leaves[name] = to_local(src).detach().clone().requires_grad_(True) pos_leaf = leaves["positions"] sharded.grad_fields = leaves pos_leaf_marked = True if want_stress: # Two leaves so the position and cell halves of the virial # can be read separately; both are per-rank partials. _shape = (int(sharded.num_graphs), 3, 3) _kw = {"dtype": pos_leaf.dtype, "device": pos_leaf.device} strain = torch.zeros(*_shape, requires_grad=True, **_kw) strain_cell = torch.zeros(*_shape, requires_grad=True, **_kw) sharded.grad_strain = (strain, strain_cell) halo_exchange(sharded, pdm._halo_config, compute_forces=True) prod_out = pdm(sharded) e_prod = prod_out["energy"] owned_field = prod_out[out_key] prod_pos_leaf = pos_leaf prod_halo_cfg = pdm._halo_config world_size = pdm._world_size or 1 # Consumer: direct kernel force + energy differentiable in the # wired field, whose ghost values are gathered (autograd-aware) # from the producer's owned values. sharded.invalidate_padded_view() cons_mc = consumer.model_config saved_cons = cons_mc.active_outputs # The consumer's own strain-autograd virial is the fixed-field # term this split needs, already consolidated by its output rule. if want_stress and "stress" not in cons_mc.outputs: raise NotImplementedError( f"{type(consumer).__name__} consumes the wired field " f"'{field}' but produces no stress, so the group's virial " "would silently omit its contribution. Drop 'stress' from " "the pipeline's active_outputs, or use a consumer that " "emits it." ) cons_mc.active_outputs = ( {"energy", "forces", "stress"} if want_stress else {"energy", "forces"} ) cons_stress = None try: # ``extra_grad_inputs`` keeps the graph alive for this # group's backward. cdm = plan["consumer_dm"] cdm._ensure_initialized(sharded) # Only the producer's leaf stays pinned; it carries the chain. sharded.grad_strain = None halo_exchange(sharded, cdm._halo_config, compute_forces=True) cons_out = cdm(sharded, wired_fields={field: owned_field}) e_cons = cons_out["energy"] f_cons_direct = cons_out.get("forces") cons_stress = cons_out.get("stress") # Either model may read a pinned field, so sum both backwards. _extra = [n for n in plan["grad_fields"] if n != "positions"] _fw = cons_out.get("_extra_grads") if _fw: # Reuse the framework's dE/dfield: a second backward # through a compiled graph returns a wrong chain term. de_dfield = _fw[0] cons_extra_grads = {} if _extra: _extra_grads = torch.autograd.grad( [e_cons.sum()], [leaves[n] for n in _extra], retain_graph=True, allow_unused=True, ) cons_extra_grads = dict(zip(_extra, _extra_grads)) else: _cg = torch.autograd.grad( [e_cons.sum()], [owned_field] + [leaves[n] for n in _extra], retain_graph=True, allow_unused=True, ) de_dfield = _cg[0] cons_extra_grads = dict(zip(_extra, _cg[1:])) finally: cons_mc.active_outputs = saved_cons sharded.grad_strain = (strain, strain_cell) if want_stress else None # One backward through the producer for -dE_prod/dr and the chain # -(dE_cons/dfield)(dfield/dr) together. extra_names = [n for n in plan["grad_fields"] if n != "positions"] grad_inputs = [prod_pos_leaf] if strain is not None: grad_inputs += [strain, strain_cell] grad_inputs += [leaves[n] for n in extra_names] surrogate = e_prod.sum() if de_dfield is not None: surrogate = surrogate + (owned_field * de_dfield.detach()).sum() grads = torch.autograd.grad( [surrogate], grad_inputs, retain_graph=False, allow_unused=True, ) g_pos = grads[0] g_strain = grads[1] if strain is not None else None g_strain_cell = grads[2] if strain is not None else None # The leaf is the owned tensor, so ghost gradients are already routed. n_core = 1 if strain is None else 3 extra_grads = {} for i, name in enumerate(extra_names): parts = [grads[n_core + i], cons_extra_grads.get(name)] live = [p for p in parts if p is not None] extra_grads[name] = None if not live else sum(live[1:], live[0]) finally: prod_mc.active_outputs = saved_prod if pos_leaf_marked: sharded.grad_fields = None sharded.grad_strain = None forces = None if g_pos is not None: # Taken against the owned leaf, so the halo reverse has already run. forces = -(to_local(g_pos) / world_size) if f_cons_direct is not None: forces = f_cons_direct if forces is None else forces + f_cons_direct # The producer's leaf carries its own virial and the cross-model chain, # both per-rank partials of the replicated group energy: divide out that # replication, then sum across ranks. Every rank walks the term whether # or not its own gradient came back populated, because the reduction is # a collective. The consumer's virial arrives already consolidated. # # A strain leaf of ours for the consumer looks symmetric but is not. # Measured against a single-process reference, its position terms # consolidate at 1/world_size but PME's cell term does not, and no single # coefficient fits: the cell reaches PME's energy both through the # all-reduced charge mesh and directly through the reciprocal kernel each # rank evaluates on that mesh, and those two routes do not share a # cross-rank reduction. stress = None volume = torch.det(to_local(sharded.cell)).abs().reshape(-1, 1, 1) if want_stress: from nvalchemi.distributed._core.gather_primitives import ( # noqa: PLC0415 all_reduce_sum_over_mesh as _all_reduce_sum_over_mesh, ) zeros = torch.zeros( sharded.num_graphs, 3, 3, dtype=volume.dtype, device=volume.device ) for g in (g_strain, g_strain_cell): local = zeros if g is None else to_local(g).detach().reshape(-1, 3, 3) term = ( _all_reduce_sum_over_mesh(local / world_size, prod_halo_cfg.mesh) / volume ) stress = term if stress is None else stress + term if cons_stress is not None: _cs = to_local(cons_stress).detach().reshape(-1, 3, 3) stress = _cs if stress is None else stress + _cs out: dict[str, Any] = OrderedDict() out["energy"] = (e_prod + e_cons).detach() for name, g in extra_grads.items(): if g is not None: # d(group energy)/d(field), owned-aligned. The world-size divisor # is the same replicated-energy factor the forces carry. out[f"d_energy_d_{name}"] = (to_local(g) / world_size).detach() if forces is not None: out["forces"] = forces.detach() if stress is not None: out["stress"] = stress.detach() return out def _autograd_active_outputs(self, step: Any) -> set[str]: """Widen a shared-autograd sub-model's ``active_outputs`` to emit the group's derivatives via its own autograd. Where the single-process pipeline strips ``forces`` / ``stress`` from a sub-model and computes them once from the summed energy, the distributed composite instead has each sub-model produce them, so the owned-aligned per-model forces sum to the group force. Only keys the pipeline produces *and* the sub-model can emit are added; ``energy`` is always kept. """ base = set(step.model.model_config.active_outputs) | {"energy"} wanted = {"forces", "stress"} & set(self.pipeline.model_config.active_outputs) producible = set(step.model.model_config.outputs) return base | (wanted & producible) def _combine(self, per_model: list[dict[str, Any]]) -> dict[str, Any]: """Sum owned-aligned additive outputs across sub-models.""" out: dict[str, Any] = OrderedDict() seen: list[str] = [] for result in per_model: for key in result: if key not in seen: seen.append(key) for key in seen: vals = [r[key] for r in per_model if key in r and r[key] is not None] if not vals: continue if key in self.additive_keys: acc = vals[0] for v in vals[1:]: acc = acc + v out[key] = acc else: out[key] = vals[0] return out