Source code for nvalchemi.distributed.partitioner

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

"""Spatial partitioner for domain decomposition."""

from __future__ import annotations

import math
import warnings

import torch

from nvalchemi.distributed.config import DomainConfig

# Relative slack when deciding how many domains a ghost shell spans, absorbing
# float32 round-off in the derived domain width.
_SPAN_ROUNDING_TOL = 1e-6


[docs] class SpatialPartitioner: """Assigns atoms to spatial sub-domains on a Cartesian grid. The partitioner divides the simulation cell into axis-aligned blocks and maps each atom to the rank that owns its block. Parameters ---------- config : DomainConfig Domain decomposition configuration. cell_matrix : torch.Tensor Cell / box matrix describing the simulation domain. Accepts either the Batch convention ``(1, 3, 3)`` or the raw ``(3, 3)`` shape; leading batch dimensions are squeezed internally. pbc : torch.Tensor Periodic boundary conditions per axis. Accepts either ``(1, 3)`` (Batch convention) or ``(3,)``; leading batch dimensions are squeezed internally. """ #: Incremented whenever the neighbour-rank set changes, so anything derived #: from it can tell that its copy is stale. Class-level so a partitioner #: built without ``__init__`` still carries one. topology_version: int = 0 def __init__( self, config: DomainConfig, cell_matrix: torch.Tensor, pbc: torch.Tensor, ) -> None: self.config = config # Normalize to (3, 3) and (3,) regardless of whether the caller # passed Batch-convention shapes (1, 3, 3) / (1, 3). self.cell_matrix = ( cell_matrix.squeeze(0) if cell_matrix.ndim == 3 else cell_matrix ) self.pbc = pbc.squeeze(0) if pbc.ndim == 2 else pbc # Determine world size from mesh or default to 1. if config.mesh is not None: self.world_size: int = config.mesh.size() else: self.world_size = 1 # Compute cells_per_dimension from cell geometry and cutoff. if config.grid_dims is not None: self.cells_per_dim: tuple[int, int, int] = config.grid_dims else: self.cells_per_dim = self._compute_cells_per_dim(cell_matrix, config.cutoff) # Refine the cell grid if there are fewer cells than ranks. total_cells = ( self.cells_per_dim[0] * self.cells_per_dim[1] * self.cells_per_dim[2] ) if total_cells < self.world_size: self.cells_per_dim = SpatialPartitioner.refine_grid_for_ranks( self.cells_per_dim, self.world_size ) # Compute the rank grid (Px, Py, Pz). self.rank_grid: tuple[int, int, int] = SpatialPartitioner.compute_rank_grid( self.cells_per_dim, self.world_size ) # Balance the partition: round each axis's cell count to a multiple of # its rank-grid factor so the block assignment gives every rank an # equal-width domain. Only for auto-computed grids — an explicit # ``grid_dims`` is the user's deliberate choice and is left untouched. if config.grid_dims is None: self.cells_per_dim = SpatialPartitioner.balance_cells_for_ranks( self.cells_per_dim, self.rank_grid ) # Precompute the ghost reach and the neighbor ranks for every rank. self._span: tuple[int, int, int] = self.neighbor_span() self._neighbor_ranks: dict[int, list[int]] = self._compute_all_neighbor_ranks() # Precompute the cell-matrix inverse used by ``assign_atoms_to_ranks``. # The cell is fixed at construction (NVT/NVE), so caching avoids a # per-step 3x3 inversion in the hot path. The per-call # ``.to(device=, dtype=)`` is a no-op when device+dtype already match. self._inv_cell: torch.Tensor = torch.linalg.inv(self.cell_matrix)
[docs] def update_cell(self, cell_matrix: torch.Tensor) -> None: """Refresh the physical cell when a barostat (NPT/NPH) deforms the box. Recomputes ``cell_matrix`` and its cached inverse; the fractional cell grid (``cells_per_dim``) and rank layout are intentionally kept fixed so rank assignment stays consistent as the box scales — only the physical size of each grid cell changes. Halo regions (``rank_to_cell_bounds`` → cartesian via ``cell_matrix``) and the fractional ghost width scale with the updated cell automatically. Without this, the partitioner keeps the partition-time box: as the cell grows, wrapped positions fall outside it (fractional coords >= 1) and ``assign_atoms_to_ranks`` misroutes atoms. Contraction narrows every domain, so the ghost shell can come to span more than one of them; the neighbour-rank set is rebuilt whenever that happens, and :meth:`neighbor_span` raises if the geometry has contracted past what the halo can express. Parameters ---------- cell_matrix : torch.Tensor The barostat's updated cell, ``(3, 3)`` or ``(1, 3, 3)``. Returns ------- None """ cm = cell_matrix.squeeze(0) if cell_matrix.ndim == 3 else cell_matrix previous_span = getattr(self, "_span", None) self.cell_matrix = cm.detach() self._inv_cell = torch.linalg.inv(self.cell_matrix) self._span = self.neighbor_span() if self._span != previous_span: self._neighbor_ranks = self._compute_all_neighbor_ranks() # Anything derived from the neighbour set — the halo config's peer # list and lattice images — is now stale. self.topology_version += 1
# ------------------------------------------------------------------ # Initialization helpers # ------------------------------------------------------------------ @staticmethod def _compute_cells_per_dim( cell_matrix: torch.Tensor, cutoff: float ) -> tuple[int, int, int]: """Compute cells per dimension from cell geometry and cutoff. Uses the nvalchemiops formula: face_distance = 1.0 / norm(inverse_cell_T[dim]) cells = max(floor(face_distance / cutoff), 1) """ # Normalise to a strict 2D ``(3, 3)`` so torch's ``x.T`` # deprecation warning (fires on any non-2D tensor) doesn't # trip — callers may pass ``(1, 3, 3)`` (Batch convention). if cell_matrix.ndim > 2: cell_matrix = cell_matrix.squeeze(0) inv_cell = torch.linalg.inv(cell_matrix) inv_cell_T = inv_cell.mT # (3, 3) — same as .T for 2D, deprecation-free dims: list[int] = [] for dim in range(3): face_distance = 1.0 / torch.linalg.norm(inv_cell_T[dim]).item() dims.append(max(int(math.floor(face_distance / cutoff)), 1)) return (dims[0], dims[1], dims[2])
[docs] @staticmethod def compute_rank_grid( cells_per_dim: tuple[int, int, int], world_size: int ) -> tuple[int, int, int]: """Compute ``(Px, Py, Pz)`` rank grid minimizing surface area. Enumerates all 3-factor factorizations of *world_size* and picks the one that minimizes the surface-area proxy ``2 * (dx*dy + dy*dz + dx*dz)`` where ``dx = Nx/Px``, etc. """ Nx, Ny, Nz = cells_per_dim best_grid: tuple[int, int, int] | None = None best_surface = float("inf") for Px in range(1, world_size + 1): if world_size % Px != 0: continue remainder = world_size // Px for Py in range(1, remainder + 1): if remainder % Py != 0: continue Pz = remainder // Py dx = Nx / Px dy = Ny / Py dz = Nz / Pz surface = 2.0 * (dx * dy + dy * dz + dx * dz) if surface < best_surface: best_surface = surface best_grid = (Px, Py, Pz) if best_grid is None: raise ValueError("No valid factorization found") return best_grid
[docs] @staticmethod def refine_grid_for_ranks( cells_per_dim: tuple[int, int, int], world_size: int ) -> tuple[int, int, int]: """Subdivide cells until there are at least *world_size* cells. Doubles the smallest dimension iteratively. Warns if total cells remain less than *world_size* after 64 iterations (safety cap). """ Nx, Ny, Nz = cells_per_dim max_iters = 64 for _ in range(max_iters): if Nx * Ny * Nz >= world_size: break # Double the smallest dimension. min_val = min(Nx, Ny, Nz) if Nx == min_val: Nx *= 2 elif Ny == min_val: Ny *= 2 else: Nz *= 2 if Nx * Ny * Nz < world_size: warnings.warn( f"Could not refine cell grid to {world_size} cells; " f"got {Nx * Ny * Nz} cells with grid ({Nx}, {Ny}, {Nz}).", stacklevel=2, ) return (Nx, Ny, Nz)
[docs] @staticmethod def balance_cells_for_ranks( cells_per_dim: tuple[int, int, int], rank_grid: tuple[int, int, int] ) -> tuple[int, int, int]: """Round each axis's cell count to a multiple of its rank-grid factor. The cell->rank block assignment (``cx = ceil(Nx / Px)``) gives the first ranks ``cx`` cells and the last rank the remainder, so when ``Nx`` is not a multiple of ``Px`` the domains are unequal — e.g. 3 cells across 2 ranks splits 2:1. Making ``Nx`` a multiple of ``Px`` gives every rank ``Nx / Px`` equal-width cells. Rounds DOWN to the nearest multiple of ``Pi`` (floored at ``Pi`` so each rank keeps at least one cell). Rounding down — never up — keeps each cell at least as wide as the cutoff-derived size, so cells stay ``>= cutoff``. Axes with a single rank (``Pi == 1``) are unchanged. """ out: list[int] = [] for n_i, p_i in zip(cells_per_dim, rank_grid): out.append(n_i if p_i <= 1 else max(p_i, (n_i // p_i) * p_i)) return (out[0], out[1], out[2])
# ------------------------------------------------------------------ # Cell ↔ rank mapping # ------------------------------------------------------------------
[docs] def cell_to_rank( self, ix: int | torch.Tensor, iy: int | torch.Tensor, iz: int | torch.Tensor ) -> int | torch.Tensor: """Map cell indices ``(ix, iy, iz)`` to the owning rank. Works with both scalar ints and batched :class:`torch.Tensor` inputs. Uses ceiling-division block assignment. """ Nx, Ny, Nz = self.cells_per_dim Px, Py, Pz = self.rank_grid cx = math.ceil(Nx / Px) cy = math.ceil(Ny / Py) cz = math.ceil(Nz / Pz) if isinstance(ix, torch.Tensor): rx = torch.clamp(ix // cx, max=Px - 1) ry = torch.clamp(iy // cy, max=Py - 1) rz = torch.clamp(iz // cz, max=Pz - 1) return rx + Px * (ry + Py * rz) else: rx = min(ix // cx, Px - 1) ry = min(iy // cy, Py - 1) rz = min(iz // cz, Pz - 1) return rx + Px * (ry + Py * rz)
[docs] def keeps_owner( self, positions: "torch.Tensor", owner_rank: int, hysteresis: float, ) -> "torch.Tensor": """Hysteresis-aware ownership test. An atom keeps its owner until it drifts more than ``hysteresis`` (Cartesian Angstrom) past the owner's domain boundary — i.e. it stays while inside the owner's domain expanded by ``hysteresis`` on every axis (PBC-wrapped on periodic axes). This stops the per-step migration thrashing of atoms that merely vibrate across a domain plane. Parameters ---------- positions : torch.Tensor ``[N, 3]`` atom positions in Cartesian coordinates. owner_rank : int The rank whose ownership is being tested. hysteresis : float Cartesian margin (Angstrom) an atom must exceed past the boundary before it loses its owner. Returns ------- torch.Tensor ``[N]`` bool, True where an atom currently owned by ``owner_rank`` should keep that owner. Notes ----- Correctness relies on ``hysteresis <= skin / 2`` (enforced by ``DomainConfig``): a deferred atom plus inter-rebuild drift stays within the owner's halo (``ghost_width = cutoff + skin``), so the owner still has all the atom's neighbors and the neighbor still ghosts the atom. Uses the same fractional-bounds + reciprocal-norm geometry as the halo ghost region it must stay inside. """ import torch # noqa: PLC0415 device, dtype = positions.device, positions.dtype inv = self._inv_cell.to(device=device, dtype=dtype) frac = positions @ inv # (N, 3) fractional coords (row-vector convention) cells = torch.tensor(self.cells_per_dim, device=device, dtype=dtype) lo_cell, hi_cell = self.rank_to_cell_bounds(owner_rank) frac_lo = torch.tensor(lo_cell, device=device, dtype=dtype) / cells # (3,) frac_hi = torch.tensor(hi_cell, device=device, dtype=dtype) / cells # (3,) # Cartesian hysteresis -> fractional per axis: |reciprocal vector| are the # rows of inv(cell).T (matches _ghost_width_fractional). norms = torch.linalg.norm(inv.T, dim=1) # (3,) h_frac = float(hysteresis) * norms # (3,) a = frac_lo - h_frac # expanded-domain lower bound per axis b = frac_hi + h_frac # expanded-domain upper bound per axis pbc = self.pbc.to(device=device) keep = torch.ones(frac.shape[0], dtype=torch.bool, device=device) for d in range(3): fd = frac[:, d] if bool(pbc[d]): # Membership of f (mod 1) in [a, b] on the unit circle; the band # width (hi-lo + 2*hysteresis) is < 1, so test f, f-1, f+1. fm = fd - torch.floor(fd) in_d = ( ((fm >= a[d]) & (fm <= b[d])) | ((fm - 1.0 >= a[d]) & (fm - 1.0 <= b[d])) | ((fm + 1.0 >= a[d]) & (fm + 1.0 <= b[d])) ) else: in_d = (fd >= a[d]) & (fd <= b[d]) keep = keep & in_d return keep
[docs] def rank_to_cell_bounds( self, rank: int ) -> tuple[tuple[int, int, int], tuple[int, int, int]]: """Return cell index bounds ``(lo, hi)`` owned by *rank*. ``lo`` is inclusive, ``hi`` is exclusive. """ Nx, Ny, Nz = self.cells_per_dim Px, Py, Pz = self.rank_grid rx, ry, rz = self.rank_to_grid_coords(rank) cx = math.ceil(Nx / Px) cy = math.ceil(Ny / Py) cz = math.ceil(Nz / Pz) lo = (rx * cx, ry * cy, rz * cz) hi = (min((rx + 1) * cx, Nx), min((ry + 1) * cy, Ny), min((rz + 1) * cz, Nz)) return lo, hi
[docs] def rank_to_grid_coords(self, rank: int) -> tuple[int, int, int]: """Decompose a linear rank index into ``(rx, ry, rz)`` grid coords.""" Px, Py, _Pz = self.rank_grid rx = rank % Px ry = (rank // Px) % Py rz = rank // (Px * Py) return (rx, ry, rz)
# ------------------------------------------------------------------ # Neighbor ranks # ------------------------------------------------------------------
[docs] def domain_widths(self) -> tuple[float, float, float]: """Physical width of one rank's domain along each axis, in Angstroms. Each rank owns ``ceil(N_d / P_d)`` grid cells of width ``face_distance_d / N_d``. Returns ------- tuple[float, float, float] Per-axis domain width. """ cm = self.cell_matrix inv_cell_T = torch.linalg.inv(cm).mT widths: list[float] = [] for d in range(3): face = 1.0 / torch.linalg.norm(inv_cell_T[d]).item() n_d = self.cells_per_dim[d] cells_per_rank = math.ceil(n_d / self.rank_grid[d]) widths.append(cells_per_rank * face / n_d) return (widths[0], widths[1], widths[2])
[docs] def neighbor_span(self) -> tuple[int, int, int]: """Rank offsets the ghost shell reaches along each axis. ``ceil(ghost_width / domain_width)`` — 1 while the ghost fits inside one domain, more once it does not (many ranks, a small cell, or a barostat contracting the box). Communicating only ``+/-1`` in that case silently drops every interaction that spans two or more domains. Returns ------- tuple[int, int, int] Per-axis offset range. Raises ------ ValueError If the shell would have to wrap onto the rank's own periodic image, which the halo exchange cannot express. """ ghost = self.config.effective_ghost_width() widths = self.domain_widths() span: list[int] = [] for d in range(3): # ``domain_widths`` derives the face distance from a float32 matrix # inverse, so a shell that fits exactly lands a few ulp above 1.0 and # would round up to a spurious extra domain. Absorb that before the # ceil; a genuine overshoot is orders of magnitude larger. ratio = (ghost / widths[d]) - _SPAN_ROUNDING_TOL if widths[d] > 0 else 0.0 s = max(1, math.ceil(ratio)) if widths[d] > 0 else 1 p_d = self.rank_grid[d] # An offset of +/-P_d wraps back onto the rank itself, so the shell # would need the rank's own periodic image as a ghost -- something # the neighbour list cannot express. Reaching every OTHER rank is # fine (they simply dedupe), so only s >= P_d is fatal. if bool(self.pbc[d]) and s >= p_d > 1: raise ValueError( f"Ghost width {ghost:.3f} A spans {s} rank domains along " f"axis {d} (domain width {widths[d]:.3f} A, {p_d} ranks on " f"that axis), so the halo would wrap onto the rank's own " f"periodic image. Use fewer ranks, a larger cell, or a " f"smaller cutoff/skin." ) span.append(min(s, p_d - 1) if p_d > 1 else 0) return (span[0], span[1], span[2])
def _compute_all_neighbor_ranks(self) -> dict[int, list[int]]: """Precompute the set of neighbor ranks for every rank.""" Px, Py, Pz = self.rank_grid total_ranks = Px * Py * Pz span = self.neighbor_span() neighbor_map: dict[int, list[int]] = {} for rank in range(total_ranks): neighbor_map[rank] = self._compute_neighbor_ranks_for(rank, span) return neighbor_map def _compute_neighbor_ranks_for( self, rank: int, span: tuple[int, int, int] | None = None ) -> list[int]: """Return the spatial neighbor ranks the ghost shell reaches for *rank*. *span* is the per-axis offset range from :meth:`neighbor_span` (computed on demand when omitted). For PBC dimensions, wrap around. For non-PBC, skip out-of-bounds. """ Px, Py, Pz = self.rank_grid rx, ry, rz = self.rank_to_grid_coords(rank) pbc_x = bool(self.pbc[0]) pbc_y = bool(self.pbc[1]) pbc_z = bool(self.pbc[2]) sx, sy, sz = span if span is not None else self.neighbor_span() neighbors: list[int] = [] for dx in range(-sx, sx + 1): for dy in range(-sy, sy + 1): for dz in range(-sz, sz + 1): if dx == 0 and dy == 0 and dz == 0: continue nx = rx + dx ny = ry + dy nz = rz + dz # Check bounds / wrap for each dimension. if not self._in_bounds_or_wrap(nx, Px, pbc_x): continue if not self._in_bounds_or_wrap(ny, Py, pbc_y): continue if not self._in_bounds_or_wrap(nz, Pz, pbc_z): continue nx = nx % Px ny = ny % Py nz = nz % Pz neighbor_rank = nx + Px * (ny + Py * nz) # Exclude self (can happen when PBC wraps a dimension # that has only 1 rank, e.g. rank_grid (1, 1, 2) with # full PBC — dx=±1 along Px=1 wraps back to self). if neighbor_rank != rank and neighbor_rank not in neighbors: neighbors.append(neighbor_rank) return neighbors @staticmethod def _in_bounds_or_wrap(coord: int, size: int, periodic: bool) -> bool: """Check if a neighbor coordinate is valid, considering PBC.""" if 0 <= coord < size: return True if periodic: return True return False
[docs] def get_neighbor_ranks(self, rank: int) -> list[int]: """Return precomputed neighbor ranks for *rank*.""" return self._neighbor_ranks[rank]
# ------------------------------------------------------------------ # Atom assignment (vectorized) # ------------------------------------------------------------------
[docs] def assign_atoms_to_ranks(self, positions: torch.Tensor) -> torch.Tensor: """Assign each atom to a rank based on its position. Parameters ---------- positions : torch.Tensor ``(N, 3)`` atom positions in Cartesian coordinates. Returns ------- torch.Tensor ``(N,)`` integer tensor of rank assignments. """ device = positions.device dtype = positions.dtype # Fractional coordinates. ``cart = frac @ cell_matrix`` (rows of # cell_matrix = lattice vectors), so ``frac = cart @ inv(cell_matrix)`` # — not ``inv(cell).T``, which gives wrong fractional coords on skew # cells (hex / triclinic) and mis-assigns boundary atoms. inv_cell = self._inv_cell.to(device=device, dtype=dtype) frac = positions @ inv_cell # (N, 3) cells_per_dim_t = torch.tensor(self.cells_per_dim, device=device, dtype=dtype) # Cell coordinates. cell_coords = torch.floor(frac * cells_per_dim_t).to(torch.int64) # PBC wrap for periodic dimensions; clamp for non-periodic. cells_per_dim_int = torch.tensor( self.cells_per_dim, device=device, dtype=torch.int64 ) pbc_mask = self.pbc.to(device=device) # Wrap periodic dims via modulo. wrapped = cell_coords % cells_per_dim_int # Clamp non-periodic dims. clamped = torch.clamp( cell_coords, min=torch.zeros_like(cells_per_dim_int), max=cells_per_dim_int - 1, ) # Select based on pbc mask. cell_coords = torch.where(pbc_mask.unsqueeze(0), wrapped, clamped) # Vectorized cell_to_rank. Nx, Ny, Nz = self.cells_per_dim Px, Py, Pz = self.rank_grid cx = math.ceil(Nx / Px) cy = math.ceil(Ny / Py) cz = math.ceil(Nz / Pz) rx = torch.clamp(cell_coords[:, 0] // cx, max=Px - 1) ry = torch.clamp(cell_coords[:, 1] // cy, max=Py - 1) rz = torch.clamp(cell_coords[:, 2] // cz, max=Pz - 1) ranks = rx + Px * (ry + Py * rz) return ranks
class IndexPartitioner: """Assigns atoms to ranks by contiguous, count-balanced index ranges. A geometry-free alternative to :class:`SpatialPartitioner`: atom ``i`` is owned by the rank holding its slice of ``arange(N)``, split into ``W`` contiguous chunks with the remainder spread over the low ranks. Every rank neighbors every other (no spatial locality), so a decomposition built on this partitioner exchanges across the whole mesh rather than a boundary shell. """ def __init__(self, config: DomainConfig) -> None: self.config = config self.world_size: int = config.mesh.size() if config.mesh is not None else 1 def get_neighbor_ranks(self, rank: int) -> list[int]: """Every other rank: an index partition has no boundary shell. Parameters ---------- rank : int The rank whose neighbours are wanted. Returns ------- list[int] All ranks except *rank*. """ return [r for r in range(self.world_size) if r != rank] def assign_atoms_to_ranks(self, positions: torch.Tensor) -> torch.Tensor: """Assign atoms to ranks in balanced contiguous index blocks. Position-independent, unlike the spatial partitioner: atom order alone decides the owner, so the assignment is stable as atoms move. Parameters ---------- positions : torch.Tensor Atomic positions, read only for their count and device. Returns ------- torch.Tensor Owning rank per atom, shape ``[N]``. """ n = positions.shape[0] counts = self._owned_counts(n) return torch.repeat_interleave( torch.arange(self.world_size, device=positions.device), torch.tensor(counts, device=positions.device), ) def _owned_counts(self, n: int) -> list[int]: w = self.world_size base, rem = divmod(n, w) return [base + (1 if r < rem else 0) for r in range(w)]