# 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-parallel dynamics wrapper.
Holds a :class:`ShardedBatch` across the step loop and delegates the
per-step model call to a :class:`DistributedModel` — the adapter owns
halo exchange, neighbor-list rebuild, and output consolidation. This
class contributes the orchestration: partition, pre/post-update, atom
migration, and trajectory gather.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import torch
import torch.distributed as dist
from nvalchemi.distributed._core.gather_primitives import mesh_group
from nvalchemi.distributed._dynamics_coordinator import (
DynamicsDistributionCoordinator,
)
from nvalchemi.distributed.config import DomainConfig, HookScope
from nvalchemi.distributed.strategy import (
MigrationPlan,
ParallelizationStrategy,
strategy_for_policy,
)
from nvalchemi.dynamics.base import BaseDynamics, DynamicsStage
from nvalchemi.hooks._context import HookContext
if TYPE_CHECKING:
from nvalchemi.data.batch import Batch
from nvalchemi.distributed.sharded_batch import ShardedBatch
logger = logging.getLogger(__name__)
[docs]
class DomainParallel(BaseDynamics):
"""Wraps any :class:`BaseDynamics` subclass with spatial domain
decomposition.
Flow per step:
1. Outer BEFORE_STEP hooks on owned batch.
2. Inner dynamics ``pre_update`` (velocity-Verlet half-kick) on owned batch.
3. Wrap positions into the periodic box.
4. Sync the updated positions back into the persistent
:class:`ShardedBatch` (``update_from_batch``).
5. ``DistributedModel(sharded)`` — the adapter rebuilds the halo block,
rebuilds NL, runs the wrapper, consolidates owned-shape outputs.
6. Write the consolidated outputs back to the owned batch in-place.
7. Inner dynamics ``post_update`` (velocity-Verlet finalize) on owned batch.
8. Atom migration (``reshard_by_destination``) for atoms that crossed
domain boundaries.
9. Outer AFTER_STEP hooks on owned batch.
Parameters
----------
dynamics
The underlying single-GPU dynamics integrator or optimizer.
config
Domain decomposition configuration.
**kwargs
Forwarded to ``BaseDynamics.__init__`` (``hooks``, ``n_steps``,
``device_type``, ...).
"""
def __init__(
self,
dynamics: BaseDynamics,
config: DomainConfig,
**kwargs: Any,
) -> None:
super().__init__(model=dynamics.model, **kwargs)
self._dynamics: BaseDynamics = dynamics
self._config: DomainConfig = config
# Globalizes thermodynamic state for NHC/NPT/NPH; inert for NVE/Langevin
# and the single-process / world-size-1 paths. Built in partition() once
# the strategy exists (it owns the reductions).
self._thermo: DynamicsDistributionCoordinator | None = None
# Lazy-initialized in partition().
self._strategy: ParallelizationStrategy | None = None
self._sharded_batch: ShardedBatch | None = None
# DistributedModel for an atomic model, or DistributedPipelineModel for a
# composite (PipelineModelWrapper); both share the ShardedBatch->output +
# close() contract the per-step machinery relies on.
self._dist_model: Any = None
self._composite: bool = False
# Runtime state.
self._n_owned: int = 0
self._forces_primed: bool = False
# Pipeline-stage state (2D pipeline x domain). A DomainParallel used as a
# DistributedPipeline stage spans a domain sub-mesh; the group lead does
# the cross-stage isend/irecv while the group scatters/gathers the full
# system to/from its sub-mesh (see the comm-override section below). Inert
# unless the pipeline sets prior_rank/next_rank.
self._pending_input: "Batch | None" = None # first stage's seed system
self._first_stage_seeded: bool = False
self._system_step: int = 0
self._sentinel_sent: bool = False
# Process group for cross-stage hand-offs (the pipeline-dim / leads' group),
# set by DistributedPipeline in grouped mode. ``None`` = default group (the
# single-stage / test paths). Never the world group in a 2-D pipeline —
# NCCL requires consistent op ordering per communicator (see base.py).
self._pipeline_group: Any = None
# Deferred-migration state. The strategy issues an async consensus
# all_reduce at the END of step N and consumes it at the START of step
# N+1, hiding its latency under the intervening hooks + pre_update.
# Migration ordering is unchanged in physical time: atoms that crossed at
# end-of-N still migrate before any compute in N+1.
self._pending_plan: MigrationPlan | None = None
# Rank resolution — prefer mesh, fall back to global dist rank, else 0.
if config.mesh is not None:
try:
self._domain_rank: int = config.mesh.get_local_rank()
except Exception:
self._domain_rank = 0
elif dist.is_initialized():
self._domain_rank = dist.get_rank()
else:
self._domain_rank = 0
# Register shard wrappers for nvalchemiops kernels.
from nvalchemi.distributed.shard_wrappers import register_shard_wrappers
register_shard_wrappers()
# ------------------------------------------------------------------
# Properties delegated to inner dynamics
# ------------------------------------------------------------------
@property
def __needs_keys__(self) -> set[str]: # type: ignore[override]
return self._dynamics.__needs_keys__
@property
def __provides_keys__(self) -> set[str]: # type: ignore[override]
return self._dynamics.__provides_keys__
# ------------------------------------------------------------------
# Partition
# ------------------------------------------------------------------
[docs]
def partition(self, batch: Batch | None) -> Batch:
"""Scatter the full-system batch across ranks and build the
per-step machinery (:class:`ShardedBatch` + :class:`DistributedModel`).
Must be called once before ``run()`` / ``step()``.
Parameters
----------
batch
Full-system batch on rank 0; ``None`` elsewhere. In the
single-process fallback (no distributed init), passes through.
Returns
-------
Batch
This rank's owned local batch (per-atom fields are
``.to_local()`` views of the ShardedBatch's ShardTensors).
"""
from nvalchemi.distributed.distributed_model import DistributedModel
# Single-process fallback — no distribution, just pass through. Gate on
# the default group's world size too (not just ``is_initialized``) so a
# leaked 1-rank process group (e.g. a session-scoped gloo PG under
# pytest) still takes this path rather than the distributed one.
if not dist.is_initialized() or dist.get_world_size() == 1:
if batch is None:
raise ValueError("batch must be provided in single-process mode")
self._n_owned = batch.positions.shape[0]
return batch
mesh = self._config.mesh
# Adapter around the inner dynamics' model. Owns halo exchange, NL rebuild,
# and output consolidation. An atomic model rides ``DistributedModel``; a
# composite (``PipelineModelWrapper``, e.g. MACE+DFTD3 / AIMNet2+PME) can't
# (``DistributedModel`` wraps atomic models only), so it rides
# ``DistributedPipelineModel`` — the same ``ShardedBatch``->output contract,
# doing per-sub-model halo/NL/consolidation internally. Both are halo-based,
# so the strategy is selected from a halo policy either way.
from nvalchemi.models.pipeline import PipelineModelWrapper # noqa: PLC0415
if isinstance(self._dynamics.model, PipelineModelWrapper):
from nvalchemi.distributed._core.storage_policy import ( # noqa: PLC0415
HaloStoragePolicy,
)
from nvalchemi.distributed.distributed_pipeline import ( # noqa: PLC0415
DistributedPipelineModel,
)
self._composite = True
self._dist_model = DistributedPipelineModel(
self._dynamics.model, self._config, compile=self._config.compile
)
policy: Any = HaloStoragePolicy()
else:
self._composite = False
self._dist_model = DistributedModel(
self._dynamics.model,
self._config,
compile=self._config.compile,
)
policy = self._dist_model._spec.distribution.policy
# The parallelization strategy owns data layout, cell tracking,
# migration, and reductions for this run — selected from the (halo)
# storage policy so a new strategy plugs in without a driver type-switch.
self._strategy = strategy_for_policy(policy, self._config, self._domain_rank)
# Coordinator globalizes NHC/NPT/NPH thermodynamic state via the
# strategy's reductions (integrator declares intent; inert otherwise).
self._thermo = DynamicsDistributionCoordinator(self._dynamics, self._strategy)
# Scatter the full batch across the mesh. The strategy chooses the
# partition layout (spatial for halo, contiguous-block for graph
# parallel); ``from_batch`` broadcasts cell/pbc from src and builds the
# partitioner. The persistent ShardedBatch is shared with the halo config
# so migration and halo exchange can't disagree on domain boundaries.
self._sharded_batch = self._strategy.scatter(batch, mesh, self._config, src=0)
self._n_owned = self._sharded_batch.n_owned
return self._sharded_batch.local_batch
# ------------------------------------------------------------------
# Step
# ------------------------------------------------------------------
[docs]
def step(self, batch: Batch) -> tuple[Batch, torch.Tensor | None]:
"""Execute one domain-decomposed dynamics step."""
# Single-process fallback — no distribution set up, delegate to
# the inner dynamics' own step (which fires its own hook chain).
if self._dist_model is None:
return self._dynamics.step(batch)
# Resolve the previous step's deferred migrate-or-not decision.
# The async all_reduce was issued at end-of-previous-step; by
# now it has likely completed in the background. Migrating here
# (start of step N) is physically equivalent to migrating at
# end of step N-1 — atoms that crossed at end-of-N-1 still get
# to their owners before any compute in step N.
batch = self._resolve_pending_migrate(batch)
if not self._forces_primed:
self._prime_forces(batch)
self._forces_primed = True
# 1. Outer BEFORE_STEP hooks.
self._call_hooks(DynamicsStage.BEFORE_STEP, batch)
dyn = self._dynamics
dyn._ensure_state_initialized(batch)
# Globalize per-shard DOF + derived controller masses once the inner
# state exists (no-op for NVE/Langevin and the single-process path).
self._thermo.globalize_dof(batch)
# 2. Pre-update on owned batch (velocity-Verlet half-kick). The reduce
# scope makes NHC/NPT/NPH couple to mesh-global kinetic state.
dyn._call_hooks(DynamicsStage.BEFORE_PRE_UPDATE, batch)
with self._thermo.reduce_scope():
dyn.pre_update(batch)
dyn._call_hooks(DynamicsStage.AFTER_PRE_UPDATE, batch)
# 3. Wrap positions into the periodic box — but ONLY on axes that are
# not spatially partitioned. Wrapping a *partitioned* axis teleports an
# owned atom that has drifted just past the periodic boundary a full box
# length onto the far side, out of its owner's ghost region, so this
# step computes its force with the wrong (missing) neighbors and injects
# energy (migration only corrects ownership next step). On the
# partitioned axis, migration bounds positions instead; the halo design
# tolerates small unwrapped boundary drift (``keeps_owner``) and the
# neighbor build uses minimum-image PBC. Non-partitioned axes have no
# migration to bound them, so they DO need wrapping (safe there — every
# rank spans the full extent of a non-partitioned axis).
self._wrap_owned_positions(batch)
# 4-6. Compute via DistributedModel. ``_distributed_compute``
# fires the inner BEFORE_COMPUTE / AFTER_COMPUTE hooks on the
# correct view (padded for halo-storage, owned for sharded).
self._distributed_compute(batch)
# 7. Post-update (velocity-Verlet finalize).
dyn._call_hooks(DynamicsStage.BEFORE_POST_UPDATE, batch)
with self._thermo.reduce_scope():
dyn.post_update(batch)
dyn._call_hooks(DynamicsStage.AFTER_POST_UPDATE, batch)
# Keep the replicated controller + cell state byte-identical across ranks.
self._thermo.broadcast_state(batch)
# 8. Atom migration — DEFERRED. We dispatch the consensus
# all_reduce here (async); the result is consumed at the start
# of the NEXT step in ``_resolve_pending_migrate``. This hides
# the all_reduce latency under the AFTER_STEP hooks + next
# step's pre_update + halo_exchange instead of forcing a
# CPU↔GPU sync at end-of-step.
self._dispatch_async_migrate_check(batch)
# 9. Outer AFTER_STEP hooks.
self._call_hooks(DynamicsStage.AFTER_STEP, batch)
convergence_due = (
dyn.convergence_hook is not None
and dyn.step_count % dyn.convergence_hook.frequency == 0
)
self.step_count += 1
dyn.step_count += 1
converged = dyn._check_convergence(batch) if convergence_due else None
# Convergence must be a mesh-wide decision: each rank only sees its own
# atoms, so ranks can disagree and take divergent control flow (one stops
# while others continue → collective desync / hang). Every rank reduces
# a per-system mask so an index survives only when every rank reports it.
# DD currently carries one system, but keep this mask per-system so the
# convergence result remains valid if DD later supports batched systems.
if convergence_due and dist.is_initialized() and self._config.mesh is not None:
convergence_mask = torch.zeros(
batch.num_graphs,
device=batch.positions.device,
dtype=torch.int64,
)
if converged is not None:
local_indices = converged.to(
device=convergence_mask.device, dtype=torch.long
)
convergence_mask[local_indices] = 1
dist.all_reduce(
convergence_mask,
op=dist.ReduceOp.MIN,
group=self._strategy.process_group,
)
global_indices = torch.where(convergence_mask.bool())[0]
converged = global_indices if global_indices.numel() > 0 else None
dyn._last_converged = converged
if converged is not None:
dyn._call_hooks(DynamicsStage.ON_CONVERGE, batch)
return batch, converged
# ------------------------------------------------------------------
# Force priming (initial compute before the first integrator step)
# ------------------------------------------------------------------
def _prime_forces(self, batch: Batch) -> None:
"""Run one compute pass to initialize ``batch.forces`` /
``batch.energy`` before the first integrator step.
Velocity-Verlet's first half-kick needs ``batch.forces``; if the
caller didn't supply them, this pass populates them. ``_distributed_compute``
handles halo exchange + hook firing internally.
"""
logger.info("[rank %d] priming forces (initial compute)", self._domain_rank)
self._distributed_compute(batch)
logger.info("[rank %d] force priming complete", self._domain_rank)
# ------------------------------------------------------------------
# Distributed compute: delegate to DistributedModel
# ------------------------------------------------------------------
def _distributed_compute(self, batch: Batch) -> None:
"""Run the model via :class:`DistributedModel` and write the
owned-shape outputs back into *batch* in-place.
Flow:
1. ``update_from_batch`` — sync non-in-place pre_update changes
back into the persistent ``ShardedBatch``.
2. ``halo_exchange`` — populate ``sharded.padded_batch`` with the
refreshed owned + halo atoms.
3. Fire inner ``BEFORE_COMPUTE`` hooks on ``sharded.padded_batch``
— ``NeighborListHook`` et al. see the padded view and write
neighbor data onto it.
4. ``dist_model(sharded)`` — reads the prepared padded batch + NL,
runs the wrapper, consolidates to owned-shape outputs.
5. Fire inner ``AFTER_COMPUTE`` hooks (NaN detectors, etc.).
6. Write outputs back into the owned ``batch`` in-place.
Single-process fallback: delegate to ``dyn.compute(batch)`` with
the owned batch — the inner dynamics' own NL hook fires normally.
"""
dyn = self._dynamics
# Single-process fallback.
if self._sharded_batch is None or self._dist_model is None:
dyn._call_hooks(DynamicsStage.BEFORE_COMPUTE, batch)
dyn.compute(batch)
dyn._call_hooks(DynamicsStage.AFTER_COMPUTE, batch)
return
# 1. Sync owned state back into the persistent ShardedBatch.
self._sharded_batch.update_from_batch(batch)
if self._composite:
# Composite adapter (DistributedPipelineModel) owns per-sub-model halo
# exchange, neighbor rebuild, and owned-shape consolidation internally,
# so it runs directly on the ShardedBatch — no external halo_exchange /
# NL hook. Fire the compute hooks on the owned batch (parity with the
# single-process path; the composite builds its own padded views).
dyn._call_hooks(DynamicsStage.BEFORE_COMPUTE, batch)
outputs = self._dist_model(self._sharded_batch)
dyn._call_hooks(DynamicsStage.AFTER_COMPUTE, batch)
else:
# 2. Populate sharded.padded_batch. ``halo_exchange`` needs the halo
# config which ``DistributedModel`` builds lazily on first call, so
# prime it here before the external exchange.
from nvalchemi.distributed._core.storage_policy import HaloStoragePolicy
if isinstance(
self._dist_model._spec.distribution.policy, HaloStoragePolicy
):
from nvalchemi.distributed.particle_halo import halo_exchange
self._dist_model._ensure_initialized(self._sharded_batch)
halo_exchange(
self._sharded_batch,
self._dist_model._halo_config,
compute_forces=self._dist_model._needs_forces(),
)
compute_batch = self._sharded_batch.padded_batch
else:
compute_batch = batch
# 3. BEFORE_COMPUTE hooks — fire on the view the model will see.
dyn._call_hooks(DynamicsStage.BEFORE_COMPUTE, compute_batch)
# 4. Model forward via the adapter.
outputs = self._dist_model(self._sharded_batch)
# 5. AFTER_COMPUTE hooks.
dyn._call_hooks(DynamicsStage.AFTER_COMPUTE, compute_batch)
# 6. Detach all output tensors before writing to the batch and stashing
# on ``dyn._last_outputs`` (mirrors ``BaseDynamics.compute``). Outputs
# may carry a live ``grad_fn`` from the energy backward; without
# detaching, ``_last_outputs`` would pin the whole forward graph until
# the next step, causing multi-x memory bloat per step. Detach + del
# breaks every reference so the next forward starts clean.
from collections import OrderedDict as _OrderedDict # noqa: PLC0415
detached: dict[str, Any] = _OrderedDict()
for key, value in outputs.items():
if isinstance(value, torch.Tensor):
detached[key] = value.detach()
else:
detached[key] = value
del outputs
# Write owned-shape outputs back to the owned batch in-place.
for out_key, batch_attr in dyn._OUTPUT_KEY_TO_BATCH_ATTR.items():
value = detached.get(out_key)
if value is None or not isinstance(value, torch.Tensor):
continue
target = getattr(batch, batch_attr, None)
if target is None:
setattr(batch, batch_attr, value.clone())
else:
target.copy_(value.view(target.shape))
# Clear ``requires_grad`` on batch tensors that the model
# enabled for autograd (a conservative-force model flips
# ``positions.requires_grad_(True)`` per forward); without
# clearing here, the flag stays on across steps and downstream
# in-place ops (velocity-Verlet half-kick on positions) raise.
# Same fix BaseDynamics.compute applies for the single-rank path.
cfg = dyn.model_config
grad_keys: set[str] = {"positions"}
grad_keys |= cfg.gradient_keys
if cfg.autograd_outputs & cfg.active_outputs:
grad_keys |= cfg.autograd_inputs
for key in grad_keys:
value = getattr(batch, key, None)
if isinstance(value, torch.Tensor) and value.requires_grad:
value.requires_grad_(False)
dyn._last_outputs = detached
# ------------------------------------------------------------------
# Atom migration
# ------------------------------------------------------------------
def _dispatch_async_migrate_check(self, batch: Batch) -> None:
"""Ask the strategy to decide (async) whether atoms crossed a boundary
this step. The result is consumed at the START of the next step in
:meth:`_resolve_pending_migrate`. No-op for strategies that don't
migrate (graph parallel)."""
if self._strategy is None or self._sharded_batch is None:
return
self._pending_plan = self._strategy.plan_migration(self._sharded_batch, batch)
def _resolve_pending_migrate(self, batch: Batch) -> Batch:
"""Consume the previous step's deferred migrate-or-not decision and let
the strategy reshard atoms that crossed a boundary. Called at the START
of every step (after the first); a no-op until the first
``_dispatch_async_migrate_check`` has run and for non-migrating
strategies.
The async dispatch was issued at end-of-previous-step, so by the time we
get here the consensus has typically completed in the background while
the CPU ran AFTER_STEP + next-step pre_update hooks — a near-instant
memory fetch, not a forced GPU sync.
"""
plan = self._pending_plan
if plan is None or not plan.is_pending or self._sharded_batch is None:
return batch
self._pending_plan = None
new_batch = self._strategy.apply_migration(self._sharded_batch, batch, plan)
if new_batch is not batch:
self._n_owned = self._sharded_batch.n_owned
logger.info(
"[rank %d] step %d: migrated atoms (deferred consensus)",
self._domain_rank,
self.step_count,
)
return new_batch
# ------------------------------------------------------------------
# Position wrapping
# ------------------------------------------------------------------
def _wrap_owned_positions(self, batch: Batch) -> None:
"""Wrap owned positions into the box on the NON-partitioned PBC axes.
Reuses the shared warp-kernel wrapper
:func:`nvalchemi.hooks.periodic.wrap_positions_into_cell` (respects
per-dimension periodicity); the partitioned-axis skip is expressed by
zeroing that axis in the PBC mask, so the partitioned axis is left to
migration (wrapping it teleports owned boundary atoms out of ghost
coverage — see the rationale at the call site in :meth:`step`).
"""
cell = getattr(batch, "cell", None)
pbc = getattr(batch, "pbc", None)
if cell is None or pbc is None or not bool(pbc.any()):
return
wrap_pbc = pbc.clone()
rank_grid = getattr(
getattr(self._sharded_batch, "partitioner", None), "rank_grid", None
)
if rank_grid is not None: # spatial partition: don't wrap the split axes
for i, p in enumerate(rank_grid):
if int(p) > 1:
wrap_pbc[..., i] = False
if not bool(wrap_pbc.any()):
return
from nvalchemi.hooks.periodic import wrap_positions_into_cell # noqa: PLC0415
batch_idx = getattr(batch, "batch_idx", None)
if batch_idx is None:
batch_idx = torch.zeros(
batch.positions.shape[0],
dtype=torch.long,
device=batch.positions.device,
)
wrap_positions_into_cell(batch.positions, cell, wrap_pbc, batch_idx)
# ------------------------------------------------------------------
# Gather (trajectory output)
# ------------------------------------------------------------------
[docs]
def gather(self, local_batch: Batch, dst: int = 0) -> Batch | None:
"""Gather the distributed system back into a full :class:`Batch`
on rank *dst*. Returns ``None`` on other ranks.
Single-process fallback: returns ``local_batch`` unchanged.
"""
if not dist.is_initialized() or self._sharded_batch is None:
return local_batch
# Sync the latest local state into the ShardedBatch before gathering.
self._sharded_batch.update_from_batch(local_batch)
return self._sharded_batch.full_batch(dst=dst)
def _gather_all(self, local_batch: Batch) -> Batch:
"""Gather the full system onto **every** rank (for GLOBAL-scope hooks).
Single-process fallback returns ``local_batch`` unchanged. The per-system
``energy`` is already globally reduced + replicated by the forward, so it
is carried through as-is (never re-reduced).
"""
if not dist.is_initialized() or self._sharded_batch is None:
return local_batch
self._sharded_batch.update_from_batch(local_batch)
full = self._sharded_batch.to_global_batch()
if getattr(local_batch, "energy", None) is not None:
full.energy = local_batch.energy.clone()
return full
# ------------------------------------------------------------------
# Hook overrides
# ------------------------------------------------------------------
def _build_context(self, batch: Batch) -> HookContext:
ctx = super()._build_context(batch)
ctx.n_owned = self._n_owned
ctx.domain_mesh = self._config.mesh
ctx.is_domain_parallel = True
ctx.global_cell = (
self._sharded_batch.cell.clone()
if self._sharded_batch is not None
else None
)
return ctx
def _call_hooks(self, stage: DynamicsStage, batch: Batch) -> None:
"""Invoke hooks respecting their ``HookScope``.
- LOCAL: hook sees the per-rank owned batch (no communication).
- GLOBAL: per-system ``energy`` all-reduced before the hook fires.
- RANK_ZERO: system gathered to rank 0; hook runs only there.
"""
ctx = self._build_context(batch)
for hook in self.hooks:
runs_on_stage = getattr(hook, "_runs_on_stage", None)
if runs_on_stage is not None:
if not runs_on_stage(stage):
continue
elif stage != hook.stage:
continue
if self.step_count % hook.frequency != 0:
continue
scope = getattr(hook, "scope", HookScope.LOCAL)
if scope == HookScope.GLOBAL:
# GLOBAL means the hook sees the COMPLETE system. Gather the full
# batch onto every rank (not the local shard) and run the hook on
# the gathered batch. Do NOT re-reduce ``energy``: the forward's
# consolidation already all-reduced it to the global value and
# replicated it per rank, so summing again would multiply it by
# the rank count.
full = self._gather_all(batch)
ctx_full = self._build_context(full) if full is not None else ctx
hook(ctx_full, stage)
elif scope == HookScope.RANK_ZERO:
full_batch = self.gather(batch, dst=0)
if self._domain_rank == 0 and full_batch is not None:
ctx_full = self._build_context(full_batch)
hook(ctx_full, stage)
else:
hook(ctx, stage)
# ------------------------------------------------------------------
# Run
# ------------------------------------------------------------------
[docs]
def run(self, batch: Batch, n_steps: int | None = None) -> Batch:
"""Run the domain-decomposed simulation for *n_steps* steps."""
# Single-process fallback — delegate to the inner dynamics' run.
if self._dist_model is None:
return self._dynamics.run(batch, n_steps=n_steps)
resolved = n_steps if n_steps is not None else self.n_steps
if resolved is None:
raise ValueError(
"No step count provided. Either pass `n_steps` to run() "
"or set it at construction time."
)
self._open_hooks()
try:
if not self._forces_primed:
self._prime_forces(batch)
self._forces_primed = True
for _ in range(resolved):
batch, _converged = self.step(batch)
if (
self.sampler is None
and _converged is not None
and _converged.numel() == batch.num_graphs
):
break
finally:
self._close_hooks()
return batch
# ------------------------------------------------------------------
# Pipeline-stage communication (group-aware _CommunicationMixin override)
# ------------------------------------------------------------------
# A DomainParallel used as a DistributedPipeline stage spans a whole domain
# sub-mesh. The pipeline drives every stage through the identical
# _CommunicationMixin API (_ensure_buffers -> _prestep_sync_buffers -> step ->
# _poststep_sync_buffers, plus done/is_first_stage/is_last_stage); these
# overrides make that API group-aware. The group LEAD (domain-rank 0) performs
# the cross-stage isend/irecv to adjacent stage-groups' leads; the group then
# scatters/gathers the full system to/from its sub-mesh. Non-lead ranks do no
# cross-stage I/O. Granularity: one DD step per pipeline iteration; a system
# graduates on the iteration it finishes (converged, or its step budget spent),
# so a group re-partitions only when a system arrives — never every step.
@property
def _is_group_lead(self) -> bool:
"""Whether this rank is its stage-group's lead (domain-rank 0), the only
rank that transmits full systems across stages."""
return self._domain_rank == 0
def _bcast_group_flag(self, flag: bool) -> bool:
"""Broadcast a bool from the group lead to the whole domain sub-mesh so all
ranks take the same partition/idle control flow (single-process: identity)."""
group = mesh_group(self._config.mesh)
if not dist.is_initialized() or group is None:
return flag
t = torch.tensor([1 if flag else 0], dtype=torch.int32, device=self.device)
dist.broadcast(t, src=dist.get_global_rank(group, 0), group=group)
return bool(t.item())
def _system_finished(self, converged: torch.Tensor | None) -> bool:
"""A resident system leaves this stage when it converges (FIRE) or spends
its per-system step budget (``n_steps``, e.g. an NVT leg)."""
if (
converged is not None
and self.active_batch is not None
and converged.numel() == self.active_batch.num_graphs
):
return True
return self.n_steps is not None and self._system_step >= self.n_steps
def _dd_event(self, msg: str) -> None:
"""Emit a stage-transition line — a system arriving, finishing, or being
handed to another GPU/stage — when ``debug_mode`` is on. This is the "where
is each system and when does it change GPUs/stages" trace for 2-D pipelines.
"""
if not getattr(self, "debug_mode", False):
return
from loguru import logger as _logger
rank = dist.get_rank() if dist.is_initialized() else 0
_logger.info("[DD rank {}] {}", rank, msg)
def _ensure_buffers(self, template: "Batch") -> None:
"""No-op: a DD stage hands off whole systems via ``Batch.send``/``irecv``
(template-driven), not the streaming fixed-capacity send/recv buffers."""
return
def _prestep_sync_buffers(self) -> None:
"""Pull the next system into this stage when idle (whole-system-in-flight).
First stage: seed once from the injected initial batch. Downstream stage:
the lead ``irecv``s the next full system from the prior stage's lead (a
0-graph sentinel means the upstream is exhausted), then the group
``partition``s it across the domain sub-mesh.
"""
if self.active_batch is not None and self.active_batch.num_graphs > 0:
return # still working the current system
if self.prior_rank is None:
# First stage: seed from _pending_input exactly once, then it's spent.
if not self._first_stage_seeded:
self._first_stage_seeded = True
self._system_step = 0
seed = self._pending_input if self._is_group_lead else None
self._pending_input = None
self.active_batch = self.partition(seed)
self._dd_event(
f"seeded initial system → scattered across the domain group "
f"(n_owned={self._n_owned})"
)
else:
self.done = True
self._send_sentinel() # tell the next stage no more systems are coming
self._dd_event("first stage exhausted → done (drain signal sent)")
return
# Downstream stage: lead receives the next full system from the prior lead.
from nvalchemi.data.batch import Batch
received = None
got_system = True
if self._is_group_lead:
received = Batch.irecv(
src=self.prior_rank,
device=self.device,
template=self._recv_template,
group=self._pipeline_group,
).wait()
got_system = received.num_graphs > 0 # 0-graph sentinel = upstream done
got_system = self._bcast_group_flag(got_system)
if not got_system:
self.active_batch = None
self.done = True
self._send_sentinel() # forward the drain signal down the chain
self._dd_event(f"upstream (rank {self.prior_rank}) drained → stage done")
return
self._system_step = 0
self.active_batch = self.partition(received if self._is_group_lead else None)
self._dd_event(
f"received a system from rank {self.prior_rank} → scattered across the "
f"domain group (n_owned={self._n_owned})"
)
def _send_sentinel(self) -> None:
"""Lead sends a one-shot 0-graph batch to the next stage's lead — the drain
signal that unblocks its ``irecv`` and propagates ``done`` down the chain
(``Batch.isend`` of an empty batch ships only the meta header)."""
if self.next_rank is None or not self._is_group_lead or self._sentinel_sent:
return
from nvalchemi.data.batch import Batch
self._sentinel_sent = True
Batch(device=self.device).isend(
dst=self.next_rank, group=self._pipeline_group
).wait()
def _complete_pending_recv(self) -> None:
"""No-op: the lead completes its ``irecv`` inline in
:meth:`_prestep_sync_buffers` (nothing is deferred)."""
return
def _poststep_sync_buffers(
self, converged_indices: torch.Tensor | None = None
) -> None:
"""Graduate the resident system when it finishes this stage: gather it to
the group lead, which ``send``s it to the next stage's lead. The stage then
goes idle so the next system can enter. The last stage (no ``next_rank``)
just retires the finished system — its trajectory is already captured by
hooks/sinks."""
if self.active_batch is None:
return
self._system_step += 1
if not self._system_finished(converged_indices):
return
finished_by_convergence = (
converged_indices is not None
and converged_indices.numel() == self.active_batch.num_graphs
)
reason = (
"converged"
if finished_by_convergence
else f"reached its {self.n_steps}-step budget"
)
if self.next_rank is not None:
full = self.gather(self.active_batch, dst=0)
if self._is_group_lead and full is not None:
full.send(dst=self.next_rank, group=self._pipeline_group)
self._dd_event(
f"system {reason} after {self._system_step} steps → gathered + "
f"handed off to the next stage's lead (rank {self.next_rank})"
)
else:
self._dd_event(
f"system {reason} after {self._system_step} steps → retired "
"(final stage)"
)
self.active_batch = None
self._system_step = 0
# ------------------------------------------------------------------
# Teardown
# ------------------------------------------------------------------
[docs]
def close(self) -> None:
"""Release resources held by the adapter (restores any state its
``distributed_setup`` mutated on the inner wrapper). Safe to call
multiple times."""
# Drain any pending deferred migrate-or-not all_reduce so the
# NCCL work handle doesn't outlive the process group.
if self._pending_plan is not None and self._pending_plan.is_pending:
try:
self._pending_plan.work.wait()
except Exception: # noqa: S110 — teardown best-effort
pass
self._pending_plan = None
if self._dist_model is not None:
self._dist_model.close()
def __enter__(self) -> "DomainParallel":
"""Enter a scope that releases the adapter's setup on exit.
Lets a caller write ``with DomainParallel(...) as dyn: dyn.partition(...);
dyn.run(...)`` so teardown (``close()``) is exception-safe. The process
group / ``DistributedManager`` lifecycle stays at launcher scope."""
return self
def __exit__(self, *exc: object) -> None:
self.close()
def __del__(self) -> None:
try:
self.close()
except Exception: # noqa: S110
pass