Source code for nvalchemi.distributed.graph_padder

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

"""Fixed-shape graph padding for compiled domain-decomposed MD.

Under ``torch.compile``, a domain-decomposed model's graph must keep a stable
shape across MD steps, or every step that changes the owned+ghost atom / edge
count triggers a recompile. The fix is to pad each step's graph to fixed
per-rank capacities with inert dead atoms / dead edges that contribute nothing.

This module owns two pieces of that mechanism:

* :class:`GraphPadder` — the protocol a model declares (via
  ``CompilePolicy(graph_padder=...)``) for *how* its graph representation is
  padded to a capacity and stripped back. The framework owns *when* to pad; the
  padder owns the representation-specific ``pad`` / ``unpad``. Built-ins cover
  common representations (COO ``edge_index``, dense ``(N, K)`` neighbor matrix)
  so most models declare nothing.
* :func:`resolve_cap` — the shared grow-only capacity policy.
"""

from __future__ import annotations

import contextvars
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Iterator, Protocol, runtime_checkable

if TYPE_CHECKING:
    from nvalchemi.data import Batch

__all__ = ["COOPadder", "DenseBatchPadder", "GraphPadder", "resolve_cap"]


# Ambient DD group for collective cap agreement. When a group is set (by the
# framework around the compiled-DD padding scope), :func:`resolve_cap`
# all-reduce-MAXes each cap's real count so every rank grows to an *identical*
# cap and the compiled graph recompiles in lockstep. A per-rank-local cap
# desyncs on an uneven partition: one rank crosses a bucket boundary and
# recompiles while its peers keep the cached graph, their halo all_to_all
# sequences drift out of step, and NCCL hangs at the watchdog timeout. Outside
# this scope (eager, single-GPU) caps grow locally — no collective, no change.
_CAP_AGREEMENT: contextvars.ContextVar[tuple[Any, Any] | None] = contextvars.ContextVar(
    "_CAP_AGREEMENT", default=None
)


@contextmanager
def cap_agreement_group(group: Any, device: Any) -> Iterator[None]:
    """Make :func:`resolve_cap` grow caps collectively over ``group``.

    The framework wraps the compiled-DD padding scope in this so every rank
    agrees one cap per key (grow-only, MAX-reduced across ``group``) and stays
    in graph-shape lockstep. ``device`` is where the scalar reduction tensor
    lives (matches the collective backend). A no-op for single-rank groups.
    """
    token = _CAP_AGREEMENT.set((group, device))
    try:
        yield
    finally:
        _CAP_AGREEMENT.reset(token)


[docs] @runtime_checkable class GraphPadder(Protocol): """How a model's graph representation is padded to a fixed capacity. Declared on ``CompilePolicy.graph_padder``; the default is :class:`COOPadder`. :meth:`pad` runs before the compiled forward and :meth:`unpad` on the raw output, so the wrapper stays distribution-agnostic and the compiled graph shape stays constant. The padder owns capacity resolution: it is handed a mutable ``cap_state`` dict (persistent across MD steps) and sizes its own caps with :func:`resolve_cap`. This matters because *when* a capacity becomes knowable is representation-specific — a COO graph knows its atom/edge counts up front, but a model that rebuilds its graph inside :meth:`pad` only learns its edge count partway through. Padding must be inert: dead atoms carry no contribution (e.g. ``Z=0``, parked beyond any cutoff) and dead edges have a zero envelope (e.g. a self-loop longer than the cutoff, or a non-degenerate image so spherical harmonics don't ``NaN``). The owned-only output consolidation drops the dead rows regardless, but they must not perturb the real atoms' values. """
[docs] def pad(self, data: Any, cap_state: dict[str, int], cap_atoms: bool = True) -> Any: """Return ``data`` padded to fixed per-rank capacities. Resolve the capacities with :func:`resolve_cap` against ``cap_state`` (grow-only, persistent across steps), then pad ``data`` and return it. ``cap_atoms`` (strategy-supplied) selects whether the atom dim is capped too or only edges: halo caps both (owned+ghost fluctuate); a graph-parallel node partition caps edges only (fixed atom set). Padders whose layout only ever caps atoms may ignore it. """ ...
[docs] def unpad(self, output: Any, n_real: int | None = None) -> Any: """Drop the dead-atom / dead-edge rows from a raw model output. ``n_real`` overrides the real row count to strip to; pass it on paths where no :meth:`pad` ran (e.g. eager / sharded), else leave it ``None`` to use the count the matching :meth:`pad` recorded. """ ...
[docs] def resolve_cap( state: dict[str, int], key: str, real: int, *, initial_factor: float, grow_factor: float = 1.30, stride: int = 16, extra: int = 0, strict_gt: bool = True, ) -> int: """Grow-only fixed-shape capacity for ``key``, ``>= real + extra``. A cap is sized on first sight with ``initial_factor`` headroom, regrows with ``grow_factor`` only when the real count would overflow, and is always rounded up to a multiple of ``stride`` so small MD-step fluctuation lands in the same bucket — keeping the compiled graph from recompiling. The cap only ever grows, so a hot path reuses one compiled graph. Parameters ---------- state Mutable dict holding the persistent caps across forwards (the caller owns its lifetime). key Which capacity (e.g. ``"atoms"`` / ``"edges"`` / ``"max_send"``). real The real count needed this step (before padding). initial_factor Headroom multiplier applied the first time ``key`` is sized, set to cover the equilibrated peak from the first compile (e.g. edges climb ~25% through equilibration, atoms barely move). grow_factor Headroom multiplier applied on a later overflow. stride Bucket size; the cap is rounded up to a multiple of this (16 for kernel-friendly shapes; coarser counts that swing more use a larger one). extra Slots reserved beyond ``real`` (e.g. UMA reserves 2 for the dead-edge anchor pair). strict_gt Overflow test: ``real + extra > cap`` when True (the default; edges / send), or ``>= cap`` when False (atoms, which need a strictly-larger cap because the dead row sits at ``cap - 1``). Returns ------- int The (possibly grown) capacity for ``key``, recorded in ``state``. """ need = real + extra # Collective agreement: under a compiled-DD pad scope, grow every rank's cap # off the *global* max real count so the resolved cap (and thus the padded # graph shape) is identical on all ranks. Bucketing a per-rank-local count # would let one rank cross a boundary and recompile alone -> halo all_to_all # desync -> hang. Runs eagerly (framework side), so the .item() sync never # reaches the compiled trace. _agree = _CAP_AGREEMENT.get() if _agree is not None: import torch # noqa: PLC0415 import torch.distributed as dist # noqa: PLC0415 _group, _device = _agree if dist.is_initialized() and dist.get_world_size(_group) > 1: _t = torch.tensor([need], device=_device, dtype=torch.int64) dist.all_reduce(_t, op=dist.ReduceOp.MAX, group=_group) need = int(_t.item()) def _bucket(x: int, factor: float) -> int: return ((int(x * factor) + 1 + stride - 1) // stride) * stride cap = state.get(key) if cap is None: cap = _bucket(need, initial_factor) state[key] = cap return cap overflow = need > cap if strict_gt else need >= cap if overflow: cap = max(stride, _bucket(need, grow_factor)) state[key] = cap return cap
[docs] class COOPadder: """Built-in :class:`GraphPadder` for COO ``edge_index`` graphs. The inferred default: a model whose halo-padded graph is an ordinary :class:`~nvalchemi.data.Batch` with per-atom fields and a per-edge ``neighbor_list`` (COO endpoints) declares nothing. It works on the abstract ``Batch`` storage groups, so it is model-agnostic. Atom / edge counts are knowable up front, so :meth:`pad` resolves both caps from ``cap_state`` before padding: ``"atoms"`` (1.15 initial headroom, ``strict_gt=False`` since the dead node sits at the last slot) and ``"edges"`` (1.35 initial headroom — edge count climbs ~25% through equilibration). Both regrow x1.30 on overflow, stride 16. Layout: * Per-atom fields -> ``n_cap``: appended rows carry zeros and join the last graph (their node outputs are dropped by the owned-only consolidation). * Per-edge fields -> ``e_cap``: invalid edges (sentinel rows with endpoint ``>= n_real``) and the fill are routed to an isolated dead node (the last row, ``n_cap - 1``) as self-loops. ``neighbor_list_shifts`` for those rows uses the ``[1, 0, 0]`` image so the edge vector is non-degenerate (a zero vector ``NaN``\\ s through spherical harmonics). The dead node is referenced by no real edge and masked out of every owned output. :meth:`unpad` is a no-op: the owned-only output consolidation already drops the dead / ghost rows. The framework restores the transient padded storage separately (the padded ``Batch`` is reused in place across MD steps). """
[docs] def pad( self, data: "Batch", cap_state: dict[str, int], cap_atoms: bool = True ) -> "Batch": # noqa: ARG002 (halo padder always caps atoms) """Resolve atom / edge caps from ``cap_state`` and pad the halo-padded ``Batch`` to them. Mutates ``data`` in place and returns it; ``None`` is a safe no-op.""" if data is None: return data n_cap = resolve_cap( cap_state, "atoms", data.num_nodes, initial_factor=1.15, grow_factor=1.30, stride=16, strict_gt=False, ) e_cap = resolve_cap( cap_state, "edges", data.num_edges, initial_factor=1.35, grow_factor=1.30, stride=16, ) return _pad_coo_to_caps(data, n_cap, e_cap)
[docs] def unpad(self, output: Any, n_real: int | None = None) -> Any: """No-op: the owned-only output consolidation drops the dead rows.""" return output
def _pad_coo_to_caps(data: "Batch", n_cap: int, e_cap: int) -> "Batch": """Pad a halo-padded COO ``Batch`` to ``n_cap`` atoms / ``e_cap`` edges. Shared by :meth:`COOPadder.pad` (resolves the caps first) and ``ShardedBatch.pad_padded_view_to_caps`` (handed explicit caps). Mutates ``data`` in place and returns it; raises on cap overflow. """ import torch # noqa: PLC0415 from nvalchemi.data.level_storage import ( # noqa: PLC0415 SegmentedLevelStorage, ) pb = data if pb is None: return data n_real = pb.num_nodes e_real = pb.num_edges if n_real >= n_cap: raise RuntimeError(f"atom pad cap overflow: n_padded={n_real} >= n_cap={n_cap}") if e_real > e_cap: raise RuntimeError(f"edge pad cap overflow: E={e_real} > e_cap={e_cap}") dead = n_cap - 1 pad_n = n_cap - n_real # per-atom fields -> n_cap (zero pad rows, joined to last graph) atoms = pb._atoms_group new_atom_data = { k: torch.cat( [atoms[k], atoms[k].new_zeros((pad_n,) + tuple(atoms[k].shape[1:]))], dim=0, ) for k in atoms.keys() } n_graphs = len(atoms) sl = atoms.segment_lengths[:n_graphs].clone() sl[-1] = sl[-1] + pad_n pb._storage.groups["atoms"] = SegmentedLevelStorage( data=new_atom_data, device=atoms.device, attr_map=atoms.attr_map, segment_lengths=sl, ) # per-edge fields -> e_cap (invalid/pad -> isolated dead self-loop) edges = pb._edges_group if edges is None or edges.num_elements() == 0: return data nl = edges["neighbor_list"] # [E, 2]; sentinel endpoints == n_real invalid = (nl >= n_real).any(dim=1) pad_e = e_cap - e_real new_edge_data: dict[str, Any] = {} for k in edges.keys(): t = edges[k] trailing = tuple(t.shape[1:]) if k == "neighbor_list": routed = torch.where(invalid.unsqueeze(1), torch.full_like(t, dead), t) fill = t.new_full((pad_e,) + trailing, dead) new_edge_data[k] = torch.cat([routed, fill], dim=0) elif k == "neighbor_list_shifts": # nonzero image for dead/invalid edges (zero vector -> NaN). unit = t.new_zeros(trailing).reshape(-1) if unit.numel(): unit[0] = 1 unit = unit.reshape(trailing) routed = torch.where(invalid.unsqueeze(1), unit.expand_as(t), t) fill = unit.unsqueeze(0).expand((pad_e,) + trailing) new_edge_data[k] = torch.cat([routed, fill], dim=0) else: fill = t.new_zeros((pad_e,) + trailing) new_edge_data[k] = torch.cat([t, fill], dim=0) n_edge_seg = len(edges) esl = edges.segment_lengths[:n_edge_seg].clone() esl[-1] = esl[-1] + pad_e pb._storage.groups["edges"] = SegmentedLevelStorage( data=new_edge_data, device=edges.device, attr_map=edges.attr_map, segment_lengths=esl, ) return data # Sentinel: pad this per-system label with the last system's index (value # depends on the system count, not a constant). _LAST_SYSTEM = object()
[docs] class DensePadder: """Built-in :class:`GraphPadder` for dense ``(N, K)`` neighbor-matrix graphs. The dense counterpart of :class:`COOPadder`: the graph is an ``(N, K)`` neighbor matrix that rides the atom dimension (no separate edge dim). Pads the per-atom row fields to a fixed atom capacity and repoints the neighbor matrix's padding sentinel to an isolated dead atom (the last row); ``unpad`` slices the dead rows off the per-atom outputs. Parametrized by the model's field names: ``count_key`` (field whose row count is the atom count), ``nbmat_key`` (the neighbor matrix), ``row_pads`` (per-atom field -> pad fill value; pass :data:`LAST_SYSTEM` to pad a per-system label with the last system's index), and ``atom_output_keys`` (per-atom outputs that get dead rows stripped in :meth:`unpad`). Layout assumption: the input's last pre-pad row is the model's own padding/sentinel atom, so the real atom count is ``n_rows - 1`` and neighbor entries ``>= n_rows - 1`` are the sentinel — both get repointed to the dead row. """ LAST_SYSTEM = _LAST_SYSTEM def __init__( self, *, count_key: str, nbmat_key: str, row_pads: dict[str, Any], atom_output_keys: tuple[str, ...] = (), n_systems_key: str | None = None, cap_key: str = "atoms", initial_factor: float = 1.15, grow_factor: float = 1.15, stride: int = 16, ) -> None: self.count_key = count_key self.nbmat_key = nbmat_key self.row_pads = dict(row_pads) self.atom_output_keys = tuple(atom_output_keys) self.n_systems_key = n_systems_key self.cap_key = cap_key self.initial_factor = initial_factor self.grow_factor = grow_factor self.stride = stride # Owned+ghost atom count of the last padded graph (== n_rows - 1, the # model's sentinel/pad index), stashed in pad() for unpad(). self._n_real: int | None = None
[docs] def pad( self, data: dict[str, Any], cap_state: dict[str, int], cap_atoms: bool = True ) -> dict[str, Any]: # noqa: ARG002 (halo padder always caps atoms) """Resolve the atom cap from ``cap_state`` and pad the dense fields. ``data`` is the model's plain-tensor input dict; returns a shallow copy with the row fields + neighbor matrix padded to the atom cap. """ import torch # noqa: PLC0415 n_cur = int(data[self.count_key].shape[0]) n_cap = resolve_cap( cap_state, self.cap_key, n_cur, initial_factor=self.initial_factor, grow_factor=self.grow_factor, stride=self.stride, strict_gt=False, ) dead = n_cap - 1 sent_old = n_cur - 1 self._n_real = sent_old n_sys = int(data.get(self.n_systems_key, 1)) if self.n_systems_key else 1 def _pad_rows(t: Any, fill: Any) -> Any: if t is None or not hasattr(t, "shape"): return t p = n_cap - int(t.shape[0]) if p <= 0: return t return torch.cat([t, t.new_full((p,) + tuple(t.shape[1:]), fill)], dim=0) out = dict(data) for key, fill in self.row_pads.items(): if key not in out: continue fill_val = (n_sys - 1) if fill is _LAST_SYSTEM else fill out[key] = _pad_rows(out[key], fill_val) nb = out.get(self.nbmat_key) if nb is not None: # Repoint the old sentinel (entries >= sent_old) to the dead row, # then fill pad rows with dead self-refs; masking (slot == dead) # drops them from every owned output. nb = torch.where(nb >= sent_old, torch.full_like(nb, dead), nb) p = n_cap - int(nb.shape[0]) if p > 0: nb = torch.cat( [nb, nb.new_full((p,) + tuple(nb.shape[1:]), dead)], dim=0 ) out[self.nbmat_key] = nb return out
[docs] def unpad( self, output: dict[str, Any], n_real: int | None = None ) -> dict[str, Any]: """Slice the dead-atom rows off the per-atom outputs. Strips to ``n_real`` when given (eager / sharded paths, where no :meth:`pad` ran), else to the count the matching :meth:`pad` stashed. """ n = n_real if n_real is not None else self._n_real if n is None: return output for key in self.atom_output_keys: t = output.get(key) if t is not None and hasattr(t, "shape") and t.shape[0] > n: output[key] = t[:n] return output
[docs] class DenseBatchPadder: """Built-in :class:`GraphPadder` for dense ``(N, K)`` neighbor-matrix :class:`~nvalchemi.data.Batch`\\ es (AIMNet2). The batch-level counterpart of :class:`DensePadder`. The framework compiles the whole ``wrapper.forward``, so the fixed-shape padding must land on the halo-padded ``Batch`` *before* ``adapt_input`` runs — the same seam :class:`COOPadder` uses. This padder pads the atom-level storage group to a fixed atom capacity with inert dead atoms (zeros, ``Z=0``, joined to the last graph) and repoints the ``neighbor_matrix`` sentinel so no real atom ever sees a dead atom as a neighbor. ``adapt_input`` then appends its own padding atom on top of this fixed-shape batch, so the compiled model input keeps a constant ``(n_cap + 1, …)`` shape across MD steps. The sentinel is repointed to ``n_cap`` — the index of that appended pad atom — so aimnet's ``calc_masks`` masks every dead / sentinel neighbor slot to zero. :meth:`unpad` is a no-op: the owned-only ``mol_sum`` (masked by ``n_owned``) drops the dead rows from the energy, and the per-atom force output is sliced by the framework's output consolidation. The framework restores the transient padded storage separately (the padded ``Batch`` is reused in place across MD steps). Parameters ---------- nbmat_key : str, default ``"neighbor_matrix"`` The dense neighbor-matrix node field whose padding sentinel (unused slots, set to the pre-pad node count) must be repointed to the appended pad-atom index. initial_factor, grow_factor, stride Forwarded to :func:`resolve_cap` for the ``"atoms"`` capacity. """ def __init__( self, *, nbmat_key: str = "neighbor_matrix", initial_factor: float = 1.15, grow_factor: float = 1.30, stride: int = 16, ) -> None: self.nbmat_key = nbmat_key self.initial_factor = initial_factor self.grow_factor = grow_factor self.stride = stride
[docs] def pad( self, data: "Batch", cap_state: dict[str, int], cap_atoms: bool = True ) -> "Batch": # noqa: ARG002 (halo padder always caps atoms) """Resolve the atom cap from ``cap_state`` and pad the halo-padded ``Batch`` to it. Mutates ``data`` in place and returns it; ``None`` is a safe no-op.""" if data is None: return data n_cap = resolve_cap( cap_state, "atoms", data.num_nodes, initial_factor=self.initial_factor, grow_factor=self.grow_factor, stride=self.stride, strict_gt=False, ) return _pad_dense_batch_to_cap(data, n_cap, self.nbmat_key)
[docs] def unpad(self, output: Any, n_real: int | None = None) -> Any: """No-op: the owned-only mol_sum + output consolidation drop dead rows.""" return output
def _pad_dense_batch_to_cap(data: "Batch", n_cap: int, nbmat_key: str) -> "Batch": """Pad a halo-padded dense-nbmat ``Batch`` to ``n_cap`` atoms. Pads every atom-level node field to ``n_cap`` (zero pad rows, joined to the last graph) and repoints the ``neighbor_matrix`` sentinel (entries ``>= n_real``) to ``n_cap`` — the index of the pad atom ``adapt_input`` appends — so dead / unused neighbor slots are masked by aimnet's ``calc_masks``. Dead rows self-reference ``n_cap`` too. Mutates ``data`` in place and returns it; raises on cap overflow. """ import torch # noqa: PLC0415 from nvalchemi.data.level_storage import ( # noqa: PLC0415 SegmentedLevelStorage, ) pb = data n_real = pb.num_nodes if n_real >= n_cap: raise RuntimeError(f"atom pad cap overflow: n_padded={n_real} >= n_cap={n_cap}") pad_n = n_cap - n_real sentinel = n_cap # the pad atom adapt_input appends sits at index n_cap atoms = pb._atoms_group new_atom_data: dict[str, Any] = {} for k in atoms.keys(): t = atoms[k] trailing = tuple(t.shape[1:]) if k == nbmat_key: # Repoint unused/sentinel slots (>= n_real) to the future pad atom, # then fill dead rows with pad-atom self-refs. calc_masks masks them # all to zero, so no real atom sees a dead atom as a neighbor. repointed = torch.where(t >= n_real, torch.full_like(t, sentinel), t) fill = t.new_full((pad_n,) + trailing, sentinel) new_atom_data[k] = torch.cat([repointed, fill], dim=0) else: new_atom_data[k] = torch.cat([t, t.new_zeros((pad_n,) + trailing)], dim=0) n_graphs = len(atoms) sl = atoms.segment_lengths[:n_graphs].clone() sl[-1] = sl[-1] + pad_n pb._storage.groups["atoms"] = SegmentedLevelStorage( data=new_atom_data, device=atoms.device, attr_map=atoms.attr_map, segment_lengths=sl, ) return data