Source code for nvalchemiops.torch.neighbors.batch_cell_list

# SPDX-FileCopyrightText: Copyright (c) 2025 - 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.

"""PyTorch bindings for batched cell list neighbor construction.

The torch wrapper auto-selects between two batch query kernels:

* **atom-centric** (:mod:`nvalchemiops.neighbors.batch_cell_list`) -
  baseline 1 thread/atom; thread-local-counter optimisation.  Best at
  large total atoms with small per-system cutoff (cutoff=6 MLIP regime
  with many systems).
* **pair-centric** (:func:`nvalchemiops.neighbors.batch_cell_list.batch_query_cell_list_pair_centric_sorted`) -
  one block per ``(source_cell, outer_offset)``; per-emit
  ``atomic_add(num_neighbors, atom_i, 1)`` trades thread-local-counter
  for ``ncell x n_outer`` parallelism.  Best at moderate-to-large
  cutoff and / or few-large-systems batches.

Auto-select uses sync-free quantities (``total_atoms``, ``num_systems``,
``cutoff``); the ``total_cells`` Python int is already paid by
:func:`estimate_batch_cell_list_sizes` at allocation time.  Defaults
are calibrated empirically; overrides are exposed via environment
variables - see :func:`select_batch_cell_list_strategy`.
"""

from __future__ import annotations

import warnings

import torch
import warp as wp

from nvalchemiops.neighbors.cell_list import (
    batch_build_cell_list as wp_batch_build_cell_list,
)
from nvalchemiops.neighbors.cell_list import (
    batch_query_cell_list as wp_batch_query_cell_list,
)
from nvalchemiops.neighbors.cell_list import (
    compute_batch_pair_centric_n_outer,
    get_build_cell_list_kernel,
    is_pair_centric_parallelism_sufficient,
    select_batch_cell_list_strategy,
)
from nvalchemiops.neighbors.neighbor_utils import empty_sentinel, estimate_max_neighbors
from nvalchemiops.neighbors.neighbor_utils import (
    fill_neighbor_matrix_tail as wp_fill_neighbor_matrix_tail,
)
from nvalchemiops.neighbors.output_args import (
    _has_partial_or_pair_outputs,
)
from nvalchemiops.torch._warp_op_helpers import register_noop_fake
from nvalchemiops.torch.neighbors._autograd import (
    _flatten_active_pairs,
    _NeighborForwardOutput,
    _route_pair_outputs,
)
from nvalchemiops.torch.neighbors._compiled_pair_fn import (
    CompiledPairFn,
    is_compiled_pair_fn,
)
from nvalchemiops.torch.neighbors.neighbor_utils import (
    _validate_pair_params_present,
    allocate_cell_list,
    coo_pack_pair_geometry,
    get_neighbor_list_from_neighbor_matrix,
)
from nvalchemiops.torch.types import get_wp_dtype, get_wp_mat_dtype, get_wp_vec_dtype

__all__ = [
    "estimate_batch_cell_list_sizes",
    "batch_build_cell_list",
    "batch_query_cell_list",
    "batch_cell_list",
]


def _resolve_atom_centric_path(atom_centric_path: str) -> str:
    """Resolve an atom-centric path argument; ``"auto"`` defaults to ``"direct"``."""
    if atom_centric_path == "auto":
        return "direct"
    if atom_centric_path in {"direct", "sorted"}:
        return atom_centric_path
    raise ValueError(
        "atom_centric_path must be 'auto' | 'direct' | 'sorted', "
        f"got {atom_centric_path!r}",
    )


def _max_radius_tuple(neighbor_search_radius: torch.Tensor) -> tuple[int, int, int]:
    """Return cross-system maximum cell-search radii as a launch tuple."""
    radius = neighbor_search_radius.max(dim=0).values
    return (int(radius[0].item()), int(radius[1].item()), int(radius[2].item()))


[docs] def estimate_batch_cell_list_sizes( cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, max_nbins: int = 8192, min_cells_per_dimension: int = 4, ) -> tuple[int, torch.Tensor]: """Estimate memory allocation sizes for batch cell list construction. Analyzes a batch of systems to determine conservative memory allocation requirements for torch.compile-friendly batch cell list building. Uses system sizes, cutoff distance, and safety factors to prevent overflow. Parameters ---------- cell : torch.Tensor, shape (num_systems, 3, 3) Unit cell matrices for each system in the batch. pbc : torch.Tensor, shape (num_systems, 3), dtype=bool Periodic boundary condition flags for each system and dimension. cutoff : float Neighbor search cutoff distance. max_nbins : int, default=8192 Maximum number of cells to allocate per system. min_cells_per_dimension : int, default=4 Minimum adaptive cell count per periodic dimension. Returns ------- max_total_cells_across_batch : int Estimated maximum total cells needed across all systems combined. neighbor_search_radius : torch.Tensor, shape (num_systems, 3), dtype=int32 Radius of neighboring cells to search for each system. Notes ----- - Currently, only unit cells with a positive determinant (i.e. with positive volume) are supported. For non-periodic systems, pass an identity cell. - Estimates assume roughly uniform atomic distribution within each system - Cell sizes are determined by the smallest cutoff to ensure neighbor completeness - For degenerate cells or empty systems, returns conservative fallback values See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_build_cell_list : Core warp launcher allocate_cell_list : Allocates tensors based on these estimates batch_build_cell_list : High-level wrapper that uses these estimates """ if max_nbins <= 0: raise ValueError("max_nbins must be positive") if cell.numel() > 0 and torch.any(cell.det().abs() == 0.0): raise RuntimeError( "Cells with volume == 0.0 detected and are not supported." " Please pass unit cells with `det(cell) != 0.0`." ) num_systems = cell.shape[0] if num_systems == 0 or cutoff <= 0: return 1, torch.zeros((num_systems, 3), device=cell.device, dtype=torch.int32) dtype = cell.dtype device = cell.device wp_device = str(device) wp_dtype = get_wp_dtype(dtype) wp_mat_dtype = get_wp_mat_dtype(dtype) wp_cell = wp.from_torch( cell, dtype=wp_mat_dtype, requires_grad=False, return_ctype=True ) wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False, return_ctype=True) max_total_cells = torch.zeros(num_systems, device=device, dtype=torch.int32) wp_max_total_cells = wp.from_torch( max_total_cells, dtype=wp.int32, requires_grad=False, return_ctype=True ) neighbor_search_radius = torch.zeros( (num_systems, 3), dtype=torch.int32, device=device ) wp_neighbor_search_radius = wp.from_torch( neighbor_search_radius, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp.launch( get_build_cell_list_kernel( "estimate_sizes", wp_dtype, batched=True, min_cells_per_dimension=int(min_cells_per_dimension), ), dim=num_systems, inputs=[ wp_cell, empty_sentinel(1, wp.bool, wp_device), wp_pbc, wp_dtype(cutoff), max_nbins, wp_max_total_cells, empty_sentinel(1, wp.int32, wp_device), wp_neighbor_search_radius, ], device=wp_device, ) total_cells = int(max_total_cells.sum().item()) # Each system contributes >= 1 cell, so a sum below num_systems means a bad # (overflowed) count that must not reach the allocator. if total_cells < num_systems: raise RuntimeError( "estimate_batch_cell_list_sizes computed a non-positive cell count " f"(total cells summed over {num_systems} system(s) = {total_cells}) " f"at cutoff={cutoff}. Each system must contribute at least one cell; " "check for degenerate or excessively large cells." ) return ( total_cells, neighbor_search_radius, )
@torch.library.custom_op( "nvalchemiops::batch_build_cell_list", mutates_args=( "cells_per_dimension", "atom_periodic_shifts", "atom_to_cell_mapping", "atoms_per_cell_count", "cell_atom_start_indices", "cell_atom_list", ), ) def _batch_build_cell_list_op( positions: torch.Tensor, cutoff: float, cell: torch.Tensor, pbc: torch.Tensor, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, min_cells_per_dimension: int = 4, ) -> None: """Internal custom op for building batch spatial cell lists. This function is torch compilable. See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_build_cell_list : Core warp launcher batch_build_cell_list : High-level wrapper function """ device = positions.device num_systems = cell.shape[0] # Handle empty case if positions.shape[0] == 0 or cutoff <= 0: return # Get warp dtype of input tensors wp_dtype = get_wp_dtype(positions.dtype) wp_vec_dtype = get_wp_vec_dtype(positions.dtype) wp_mat_dtype = get_wp_mat_dtype(positions.dtype) wp_device = str(device) # Convert to warp arrays wp_positions = wp.from_torch( positions, dtype=wp_vec_dtype, requires_grad=False, return_ctype=True ) wp_cell = wp.from_torch( cell, dtype=wp_mat_dtype, requires_grad=False, return_ctype=True ) wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False, return_ctype=True) wp_batch_idx = wp.from_torch( batch_idx.to(dtype=torch.int32), dtype=wp.int32, requires_grad=False, return_ctype=True, ) wp_cells_per_dimension = wp.from_torch( cells_per_dimension, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) # Allocate cell_offsets internally (shape num_systems, not num_systems+1) cell_offsets = torch.zeros(num_systems, dtype=torch.int32, device=device) wp_cell_offsets = wp.from_torch(cell_offsets, dtype=wp.int32, requires_grad=False) # Allocate cells_per_system scratch buffer cells_per_system = torch.zeros(num_systems, dtype=torch.int32, device=device) wp_cells_per_system = wp.from_torch( cells_per_system, dtype=wp.int32, requires_grad=False ) wp_atom_periodic_shifts = wp.from_torch( atom_periodic_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atom_to_cell_mapping = wp.from_torch( atom_to_cell_mapping, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) # underlying warp launcher relies on Python API for array_scan # so `return_ctype` is omitted wp_atoms_per_cell_count = wp.from_torch( atoms_per_cell_count, dtype=wp.int32, requires_grad=False ) wp_cell_atom_start_indices = wp.from_torch( cell_atom_start_indices, dtype=wp.int32, requires_grad=False ) wp_cell_atom_list = wp.from_torch( cell_atom_list, dtype=wp.int32, requires_grad=False, return_ctype=True ) # Zero atoms_per_cell_count before building atoms_per_cell_count.zero_() # Call core warp launcher wp_batch_build_cell_list( positions=wp_positions, cell=wp_cell, pbc=wp_pbc, cutoff=cutoff, batch_idx=wp_batch_idx, cells_per_dimension=wp_cells_per_dimension, cell_offsets=wp_cell_offsets, cells_per_system=wp_cells_per_system, atom_periodic_shifts=wp_atom_periodic_shifts, atom_to_cell_mapping=wp_atom_to_cell_mapping, atoms_per_cell_count=wp_atoms_per_cell_count, cell_atom_start_indices=wp_cell_atom_start_indices, cell_atom_list=wp_cell_atom_list, wp_dtype=wp_dtype, device=wp_device, min_cells_per_dimension=int(min_cells_per_dimension), ) @_batch_build_cell_list_op.register_fake def _( positions: torch.Tensor, cutoff: float, cell: torch.Tensor, pbc: torch.Tensor, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, min_cells_per_dimension: int = 4, ) -> None: return None
[docs] def batch_build_cell_list( positions: torch.Tensor, cutoff: float, cell: torch.Tensor, pbc: torch.Tensor, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, min_cells_per_dimension: int = 4, ) -> None: """Build batch spatial cell lists with fixed allocation sizes for torch.compile compatibility. This function is torch compilable. Parameters ---------- positions : torch.Tensor, shape (total_atoms, 3) Concatenated atomic coordinates for all systems in the batch. cutoff : float Neighbor search cutoff distance. cell : torch.Tensor, shape (num_systems, 3, 3) Unit cell matrices for each system in the batch. pbc : torch.Tensor, shape (num_systems, 3), dtype=bool Periodic boundary condition flags for each system and dimension. batch_idx : torch.Tensor, shape (total_atoms,), dtype=int32 System index for each atom. cells_per_dimension : torch.Tensor, shape (num_systems, 3), dtype=int32 OUTPUT: Number of cells in x, y, z directions for each system. neighbor_search_radius : torch.Tensor, shape (num_systems, 3), dtype=int32 Radius of neighboring cells to search in each dimension. Passed through from allocate_cell_list for API continuity but not used in this function. atom_periodic_shifts : torch.Tensor, shape (total_atoms, 3), dtype=int32 OUTPUT: Periodic boundary crossings for each atom across all systems. atom_to_cell_mapping : torch.Tensor, shape (total_atoms, 3), dtype=int32 OUTPUT: 3D cell coordinates assigned to each atom across all systems. atoms_per_cell_count : torch.Tensor, shape (max_total_cells,), dtype=int32 OUTPUT: Number of atoms in each cell across all systems. cell_atom_start_indices : torch.Tensor, shape (max_total_cells,), dtype=int32 OUTPUT: Starting index in global cell arrays for each system (CSR format). cell_atom_list : torch.Tensor, shape (total_atoms,), dtype=int32 OUTPUT: Flattened list of atom indices organized by cell across all systems. min_cells_per_dimension : int, default=4 Minimum adaptive cell count per periodic dimension. See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_build_cell_list : Core warp launcher estimate_batch_cell_list_sizes : Estimate memory requirements batch_query_cell_list : Query the built cell list for neighbors batch_cell_list : High-level function that builds and queries in one call """ return _batch_build_cell_list_op( positions, cutoff, cell, pbc, batch_idx, cells_per_dimension, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, min_cells_per_dimension, )
@torch.library.custom_op( "nvalchemiops::batch_query_cell_list", mutates_args=("neighbor_matrix", "neighbor_matrix_shifts", "num_neighbors"), ) def _batch_query_cell_list_op( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, half_fill: bool = False, fill_value: int | None = None, algorithm: str = "auto", atom_centric_path: str = "auto", ) -> None: """Internal custom op for querying batch spatial cell lists to build neighbor matrices. This function is torch compilable. When ``fill_value`` is provided, the op writes ``fill_value`` into ``neighbor_matrix[i, num_neighbors[i]..max_neighbors-1]`` after the query kernel (CUDA only), letting callers skip the upstream ``neighbor_matrix.fill_(fill_value) + neighbor_matrix_shifts.zero_()`` prefills. Mirrors the single-system skip-prefill design. ``strategy`` mirrors the single-system :func:`cell_list` knob: - ``"auto"`` (default) - apply :func:`select_batch_cell_list_strategy`. - ``"atom_centric"`` - force atom-centric. - ``"pair_centric"`` - force pair-centric (CUDA only; CPU raises). See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_query_cell_list : Core warp launcher batch_query_cell_list : High-level wrapper function """ device = positions.device strategy = algorithm num_systems = cell.shape[0] # Handle empty case if positions.shape[0] == 0 or cutoff <= 0: return # Get warp dtypes and arrays wp_dtype = get_wp_dtype(positions.dtype) wp_vec_dtype = get_wp_vec_dtype(positions.dtype) wp_mat_dtype = get_wp_mat_dtype(positions.dtype) wp_device = str(device) wp_positions = wp.from_torch( positions, dtype=wp_vec_dtype, requires_grad=False, return_ctype=True ) wp_cell = wp.from_torch( cell, dtype=wp_mat_dtype, requires_grad=False, return_ctype=True ) wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False, return_ctype=True) wp_batch_idx = wp.from_torch( batch_idx.to(dtype=torch.int32), dtype=wp.int32, requires_grad=False, return_ctype=True, ) wp_cells_per_dimension = wp.from_torch( cells_per_dimension, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_neighbor_search_radius = wp.from_torch( neighbor_search_radius, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) # cell_offsets[i] = sum of cells for systems 0..i-1 cells_per_system = cells_per_dimension.prod(dim=1) cell_offsets = torch.zeros(num_systems, dtype=torch.int32, device=device) if num_systems > 1: torch.cumsum(cells_per_system[:-1], dim=0, out=cell_offsets[1:]) # cell_offsets[0] is already 0 from zeros initialization wp_cell_offsets = wp.from_torch( cell_offsets, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_atom_periodic_shifts = wp.from_torch( atom_periodic_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atom_to_cell_mapping = wp.from_torch( atom_to_cell_mapping, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atoms_per_cell_count = wp.from_torch( atoms_per_cell_count, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_start_indices = wp.from_torch( cell_atom_start_indices, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_list = wp.from_torch( cell_atom_list, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix = wp.from_torch( neighbor_matrix, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix_shifts = wp.from_torch( neighbor_matrix_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_num_neighbors = wp.from_torch( num_neighbors, dtype=wp.int32, requires_grad=False, return_ctype=True ) # Atom-centric vs pair-centric (pair-centric is CUDA-only). total_atoms = positions.shape[0] atom_centric_path = _resolve_atom_centric_path(atom_centric_path) cpu_only = device.type != "cuda" if strategy == "auto": use_pair_centric = (not cpu_only) and ( select_batch_cell_list_strategy( total_atoms=int(total_atoms), num_systems=int(num_systems), cutoff=float(cutoff), ) == "pair_centric" ) elif strategy == "atom_centric": use_pair_centric = False elif strategy == "pair_centric": if cpu_only: raise ValueError( "strategy='pair_centric' is not supported on CPU " "(kernels use CUDA block scheduling). Pass 'auto' or " "'atom_centric' instead.", ) use_pair_centric = True else: raise ValueError( f"strategy must be 'auto' | 'atom_centric' | 'pair_centric', " f"got {strategy!r}", ) wp_sorted_pos = None wp_sorted_shifts = None wp_cells_per_system = None wp_cell_to_system = None total_cells = None n_outer = None R_max = None if use_pair_centric: total_cells = int(cells_per_system.sum().item()) R_max = _max_radius_tuple(neighbor_search_radius) n_outer = compute_batch_pair_centric_n_outer(R_max, bool(half_fill)) if strategy == "auto" and not is_pair_centric_parallelism_sufficient( int(total_atoms), total_cells, n_outer ): use_pair_centric = False total_cells = None n_outer = None R_max = None else: wp_cells_per_system = wp.from_torch( cells_per_system.to(dtype=torch.int32), dtype=wp.int32, return_ctype=True, ) cell_to_system_t = torch.zeros( max(total_cells, 1), dtype=torch.int32, device=device ) wp_cell_to_system = wp.from_torch( cell_to_system_t, dtype=wp.int32, return_ctype=True ) if use_pair_centric or atom_centric_path == "sorted": sorted_positions_t = torch.empty( (int(total_atoms), 3), dtype=positions.dtype, device=device ) sorted_shifts_t = torch.empty( (int(total_atoms), 3), dtype=torch.int32, device=device ) wp_sorted_pos = wp.from_torch( sorted_positions_t, dtype=wp_vec_dtype, return_ctype=True ) wp_sorted_shifts = wp.from_torch( sorted_shifts_t, dtype=wp.vec3i, return_ctype=True ) wp_batch_query_cell_list( positions=wp_positions, cell=wp_cell, pbc=wp_pbc, cutoff=cutoff, batch_idx=wp_batch_idx, cells_per_dimension=wp_cells_per_dimension, neighbor_search_radius=wp_neighbor_search_radius, cell_offsets=wp_cell_offsets, atom_periodic_shifts=wp_atom_periodic_shifts, atom_to_cell_mapping=wp_atom_to_cell_mapping, atoms_per_cell_count=wp_atoms_per_cell_count, cell_atom_start_indices=wp_cell_atom_start_indices, cell_atom_list=wp_cell_atom_list, sorted_positions=wp_sorted_pos, sorted_atom_periodic_shifts=wp_sorted_shifts, neighbor_matrix=wp_neighbor_matrix, neighbor_matrix_shifts=wp_neighbor_matrix_shifts, num_neighbors=wp_num_neighbors, rebuild_flags=None, wp_dtype=wp_dtype, device=wp_device, half_fill=half_fill, strategy="pair_centric" if use_pair_centric else "atom_centric", atom_centric_path=atom_centric_path, cells_per_system=wp_cells_per_system, cell_to_system=wp_cell_to_system, total_cells=total_cells, n_outer=n_outer, R_max=R_max, ) # Coalesced tail fill (CUDA only - the kernel uses wp.launch_tiled # which silently mis-runs on CPU; CPU callers prefill in # ``batch_cell_list`` above). Mirrors the single-system pattern # in ``_query_cell_list_op``. if fill_value is not None and wp_device != "cpu": max_neighbors = int(neighbor_matrix.shape[1]) if max_neighbors > 0: wp_fill_neighbor_matrix_tail( wp_num_neighbors, # Row count must be the OUTPUT matrix's row count, not # ``total_atoms``: the ``target_indices`` (partial) path writes # compact ``num_targets`` rows, so ``total_atoms`` would launch # the tail-fill out of bounds over rows [num_targets, N). int(neighbor_matrix.shape[0]), max_neighbors, int(fill_value), wp_neighbor_matrix, wp_device, ) @torch.library.custom_op( "nvalchemiops::batch_query_cell_list_selective", mutates_args=("neighbor_matrix", "neighbor_matrix_shifts", "num_neighbors"), ) def _batch_query_cell_list_selective_op( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, rebuild_flags: torch.Tensor, half_fill: bool = False, atom_centric_path: str = "auto", ) -> None: """Internal custom op for querying batch cell lists with per-system selective skip. Only systems with rebuild_flags[i] == True are recomputed on the GPU. Existing neighbor data for non-rebuilt systems is preserved without CPU-GPU sync. This function is torch compilable. See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_query_cell_list : Core warp launcher batch_query_cell_list : High-level wrapper function """ device = positions.device num_systems = cell.shape[0] if positions.shape[0] == 0 or cutoff <= 0: return wp_dtype = get_wp_dtype(positions.dtype) wp_vec_dtype = get_wp_vec_dtype(positions.dtype) wp_mat_dtype = get_wp_mat_dtype(positions.dtype) wp_device = str(device) wp_positions = wp.from_torch( positions, dtype=wp_vec_dtype, requires_grad=False, return_ctype=True ) wp_cell = wp.from_torch( cell, dtype=wp_mat_dtype, requires_grad=False, return_ctype=True ) wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False, return_ctype=True) wp_batch_idx = wp.from_torch( batch_idx.to(dtype=torch.int32), dtype=wp.int32, requires_grad=False, return_ctype=True, ) wp_cells_per_dimension = wp.from_torch( cells_per_dimension, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_neighbor_search_radius = wp.from_torch( neighbor_search_radius, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) cells_per_system = cells_per_dimension.prod(dim=1) cell_offsets = torch.zeros(num_systems, dtype=torch.int32, device=device) if num_systems > 1: torch.cumsum(cells_per_system[:-1], dim=0, out=cell_offsets[1:]) wp_cell_offsets = wp.from_torch( cell_offsets, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_atom_periodic_shifts = wp.from_torch( atom_periodic_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atom_to_cell_mapping = wp.from_torch( atom_to_cell_mapping, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atoms_per_cell_count = wp.from_torch( atoms_per_cell_count, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_start_indices = wp.from_torch( cell_atom_start_indices, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_list = wp.from_torch( cell_atom_list, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix = wp.from_torch( neighbor_matrix, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix_shifts = wp.from_torch( neighbor_matrix_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_num_neighbors = wp.from_torch( num_neighbors, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_rebuild_flags = wp.from_torch( rebuild_flags, dtype=wp.bool, requires_grad=False, return_ctype=True ) wp_sorted_pos = None wp_sorted_shifts = None atom_centric_path = _resolve_atom_centric_path(atom_centric_path) wp_batch_query_cell_list( positions=wp_positions, cell=wp_cell, pbc=wp_pbc, cutoff=cutoff, batch_idx=wp_batch_idx, cells_per_dimension=wp_cells_per_dimension, neighbor_search_radius=wp_neighbor_search_radius, cell_offsets=wp_cell_offsets, atom_periodic_shifts=wp_atom_periodic_shifts, atom_to_cell_mapping=wp_atom_to_cell_mapping, atoms_per_cell_count=wp_atoms_per_cell_count, cell_atom_start_indices=wp_cell_atom_start_indices, cell_atom_list=wp_cell_atom_list, sorted_positions=wp_sorted_pos, sorted_atom_periodic_shifts=wp_sorted_shifts, neighbor_matrix=wp_neighbor_matrix, neighbor_matrix_shifts=wp_neighbor_matrix_shifts, num_neighbors=wp_num_neighbors, wp_dtype=wp_dtype, device=wp_device, half_fill=half_fill, rebuild_flags=wp_rebuild_flags, atom_centric_path=atom_centric_path, )
[docs] def batch_query_cell_list( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, half_fill: bool = False, rebuild_flags: torch.Tensor | None = None, fill_value: int | None = None, strategy: str = "auto", atom_centric_path: str = "auto", target_indices: torch.Tensor | None = None, return_vectors: bool = False, return_distances: bool = False, pair_fn: wp.Function | CompiledPairFn | None = None, pair_params: torch.Tensor | None = None, neighbor_vectors: torch.Tensor | None = None, neighbor_distances: torch.Tensor | None = None, pair_energies: torch.Tensor | None = None, pair_forces: torch.Tensor | None = None, ) -> None: """Query batch spatial cell lists to build neighbor matrices for multiple systems. Parameters ---------- positions : torch.Tensor, shape (total_atoms, 3) Concatenated Cartesian coordinates for all systems in the batch. cell : torch.Tensor, shape (num_systems, 3, 3) Unit cell matrices for each system in the batch. pbc : torch.Tensor, shape (num_systems, 3), dtype=bool Periodic boundary condition flags. cutoff : float Neighbor search cutoff distance. batch_idx : torch.Tensor, shape (total_atoms,), dtype=int32 System index for each atom. cells_per_dimension : torch.Tensor, shape (num_systems, 3), dtype=int32 Number of cells in x, y, z directions for each system. neighbor_search_radius : torch.Tensor, shape (num_systems, 3), dtype=int32 Radius of neighboring cells to search. atom_periodic_shifts : torch.Tensor, shape (total_atoms, 3), dtype=int32 Periodic boundary crossings per atom from batch_build_cell_list. atom_to_cell_mapping : torch.Tensor, shape (total_atoms, 3), dtype=int32 3D cell coordinates per atom from batch_build_cell_list. atoms_per_cell_count : torch.Tensor, shape (max_total_cells,), dtype=int32 Number of atoms per cell from batch_build_cell_list. cell_atom_start_indices : torch.Tensor, shape (max_total_cells,), dtype=int32 Starting index per cell from batch_build_cell_list. cell_atom_list : torch.Tensor, shape (total_atoms,), dtype=int32 Atom list organized by cell from batch_build_cell_list. neighbor_matrix : torch.Tensor, shape (total_atoms, max_neighbors), dtype=int32 OUTPUT: Neighbor matrix to be filled. neighbor_matrix_shifts : torch.Tensor, shape (total_atoms, max_neighbors, 3), dtype=int32 OUTPUT: Shift vectors for each neighbor relationship. num_neighbors : torch.Tensor, shape (total_atoms,), dtype=int32 OUTPUT: Number of neighbors per atom. half_fill : bool, default=False If True, only store half of the neighbor relationships. rebuild_flags : torch.Tensor, shape (num_systems,), dtype=torch.bool, optional Per-system rebuild flags. If provided, only systems with True are processed on the GPU; existing neighbor data for other systems is preserved. fill_value : int, optional If provided AND ``rebuild_flags`` is None, the operation writes ``fill_value`` into the unused-column tail of ``neighbor_matrix`` after the kernel runs (CUDA only), letting callers skip the ``neighbor_matrix.fill_(fill_value) + neighbor_matrix_shifts.zero_()`` prefills. Mirrors the single-system skip-prefill design. strategy : {"auto", "atom_centric", "pair_centric"}, default "auto" Forces one of the two warp-level batch cell-list kernels. ``"auto"`` applies the sync-free dispatch rule (:func:`select_batch_cell_list_strategy`). Pair-centric requires CUDA. atom_centric_path : {"auto", "direct", "sorted"}, default "auto" Selects the atom-centric implementation path. ``"auto"`` resolves to ``"direct"``. target_indices : torch.Tensor, shape (num_targets,), dtype=int32, optional If provided, only query neighbors for the subset of atoms listed. The output ``neighbor_matrix`` and ``num_neighbors`` will have ``num_targets`` rows rather than ``total_atoms`` rows. return_vectors : bool, default=False If True and ``neighbor_vectors`` is provided, write per-neighbor displacement vectors into ``neighbor_vectors``. return_distances : bool, default=False If True and ``neighbor_distances`` is provided, write per-neighbor distances into ``neighbor_distances``. pair_fn : wp.Function or CompiledPairFn, optional Warp function called for each active pair inside the kernel. Must be provided together with ``pair_params``. pair_params : torch.Tensor, optional Per-atom parameters passed to ``pair_fn``. Shape and dtype are determined by ``pair_fn``. neighbor_vectors : torch.Tensor, shape (num_rows, max_neighbors, 3), optional Pre-allocated output buffer for per-neighbor displacement vectors. Required when ``return_vectors=True``. neighbor_distances : torch.Tensor, shape (num_rows, max_neighbors), optional Pre-allocated output buffer for per-neighbor distances. Required when ``return_distances=True``. pair_energies : torch.Tensor, shape (num_rows, max_neighbors), optional Pre-allocated output buffer for per-pair energies written by ``pair_fn``. pair_forces : torch.Tensor, shape (num_rows, max_neighbors, 3), optional Pre-allocated output buffer for per-pair forces written by ``pair_fn``. See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_query_cell_list : Core warp launcher batch_build_cell_list : Builds the cell list data structures batch_cell_list : High-level function that builds and queries in one call """ if _has_partial_or_pair_outputs( target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=pair_fn, pair_params=pair_params, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=pair_energies, pair_forces=pair_forces, ): _validate_pair_params_present(pair_fn, pair_params) if ( pair_fn is None and pair_params is None and pair_energies is None and pair_forces is None ): return _batch_query_cell_list_optional_no_pair_fn_op( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, rebuild_flags, target_indices, neighbor_vectors, neighbor_distances, half_fill, fill_value, strategy, atom_centric_path, return_vectors, return_distances, ) if is_compiled_pair_fn(pair_fn): op = pair_fn.get_or_register( "batch_query_cell_list_optional_pair", _register_compiled_batch_query_cell_list_optional_pair_op, ) return op( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, rebuild_flags, target_indices, neighbor_vectors, neighbor_distances, pair_params, pair_energies, pair_forces, half_fill, fill_value, strategy, atom_centric_path, return_vectors, return_distances, ) if torch.compiler.is_compiling(): raise NotImplementedError( "batch_cell_list pair_fn outputs are eager-only because callable " "Warp functions cannot cross a torch.library.custom_op schema boundary.", ) # Optional per-neighbor outputs bypass the torch custom op (which # cannot carry a callable ``pair_fn``) and call the warp factory # directly while preserving the requested strategy. _batch_query_cell_list_optional( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill=half_fill, rebuild_flags=rebuild_flags, fill_value=fill_value, strategy=strategy, atom_centric_path=atom_centric_path, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=pair_fn, pair_params=pair_params, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=pair_energies, pair_forces=pair_forces, ) return None if rebuild_flags is None: return _batch_query_cell_list_op( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill, fill_value, strategy, atom_centric_path, ) return _batch_query_cell_list_selective_op( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, rebuild_flags, half_fill, atom_centric_path, )
@_batch_query_cell_list_op.register_fake def _( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, half_fill: bool = False, fill_value: int | None = None, algorithm: str = "auto", atom_centric_path: str = "auto", ) -> None: return None @_batch_query_cell_list_selective_op.register_fake def _( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, rebuild_flags: torch.Tensor, half_fill: bool = False, atom_centric_path: str = "auto", ) -> None: return None @torch.library.custom_op( "nvalchemiops::batch_query_cell_list_optional_no_pair_fn", mutates_args=( "neighbor_matrix", "neighbor_matrix_shifts", "num_neighbors", "neighbor_vectors", "neighbor_distances", ), ) def _batch_query_cell_list_optional_no_pair_fn_op( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, rebuild_flags: torch.Tensor | None, target_indices: torch.Tensor | None, neighbor_vectors: torch.Tensor | None, neighbor_distances: torch.Tensor | None, half_fill: bool, fill_value: int | None, strategy: str, atom_centric_path: str, return_vectors: bool, return_distances: bool, ) -> None: _batch_query_cell_list_optional( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill=half_fill, rebuild_flags=rebuild_flags, fill_value=fill_value, strategy=strategy, atom_centric_path=atom_centric_path, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=None, pair_params=None, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=None, pair_forces=None, ) @_batch_query_cell_list_optional_no_pair_fn_op.register_fake def _( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, rebuild_flags: torch.Tensor | None, target_indices: torch.Tensor | None, neighbor_vectors: torch.Tensor | None, neighbor_distances: torch.Tensor | None, half_fill: bool, fill_value: int | None, strategy: str, atom_centric_path: str, return_vectors: bool, return_distances: bool, ) -> None: return None def _register_compiled_batch_query_cell_list_optional_pair_op(compiled: CompiledPairFn): """Register a pair_fn-specialized batch cell-list query custom op.""" @torch.library.custom_op( f"nvalchemiops::{compiled.op_name('batch_query_cell_list_optional_pair')}", mutates_args=( "neighbor_matrix", "neighbor_matrix_shifts", "num_neighbors", "neighbor_vectors", "neighbor_distances", "pair_energies", "pair_forces", ), ) def _compiled_batch_query_cell_list_optional_pair( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, rebuild_flags: torch.Tensor | None, target_indices: torch.Tensor | None, neighbor_vectors: torch.Tensor, neighbor_distances: torch.Tensor, pair_params: torch.Tensor, pair_energies: torch.Tensor, pair_forces: torch.Tensor, half_fill: bool, fill_value: int | None, strategy: str, atom_centric_path: str, return_vectors: bool, return_distances: bool, ) -> None: _batch_query_cell_list_optional( positions, cell, pbc, cutoff, batch_idx, cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill=half_fill, rebuild_flags=rebuild_flags, fill_value=fill_value, strategy=strategy, atom_centric_path=atom_centric_path, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=compiled.pair_fn, pair_params=pair_params, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=pair_energies, pair_forces=pair_forces, ) register_noop_fake(_compiled_batch_query_cell_list_optional_pair) return _compiled_batch_query_cell_list_optional_pair def _batch_query_cell_list_optional( positions: torch.Tensor, cell: torch.Tensor, pbc: torch.Tensor, cutoff: float, batch_idx: torch.Tensor, cells_per_dimension: torch.Tensor, neighbor_search_radius: torch.Tensor, atom_periodic_shifts: torch.Tensor, atom_to_cell_mapping: torch.Tensor, atoms_per_cell_count: torch.Tensor, cell_atom_start_indices: torch.Tensor, cell_atom_list: torch.Tensor, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, *, half_fill: bool, rebuild_flags: torch.Tensor | None, fill_value: int | None, strategy: str, atom_centric_path: str, target_indices: torch.Tensor | None, return_vectors: bool, return_distances: bool, pair_fn: wp.Function | None, pair_params: torch.Tensor | None, neighbor_vectors: torch.Tensor | None, neighbor_distances: torch.Tensor | None, pair_energies: torch.Tensor | None, pair_forces: torch.Tensor | None, ) -> None: """Route to the warp factory when optional per-neighbor outputs are used. Bypasses the torch ``@torch.library.custom_op`` boundary (which cannot carry a callable ``pair_fn``) and calls :func:`wp_batch_query_cell_list` directly. Caller-supplied buffers are converted via :func:`wp.from_torch`; omitted scratch is allocated fresh as torch tensors for this call. """ device = positions.device num_systems = cell.shape[0] total_atoms = positions.shape[0] if total_atoms == 0 or cutoff <= 0: return # The query writes one output row per source atom: ``num_targets`` compact # rows when ``target_indices`` is given, else ``total_atoms``. Validate the # caller-owned output buffers cover that many rows *before* launching, so an # undersized (e.g. compact ``target_indices``) buffer raises a clean error # instead of an out-of-bounds device write that corrupts the CUDA context. n_out_rows = ( int(target_indices.shape[0]) if target_indices is not None else total_atoms ) if int(neighbor_matrix.shape[0]) < n_out_rows: raise ValueError( f"neighbor_matrix has {int(neighbor_matrix.shape[0])} rows but the " f"{'partial target_indices' if target_indices is not None else 'full'}" f" query writes {n_out_rows} rows; allocate at least that many." ) if int(num_neighbors.shape[0]) < int(neighbor_matrix.shape[0]): raise ValueError( "num_neighbors must have at least as many rows as neighbor_matrix " f"(got {int(num_neighbors.shape[0])} vs {int(neighbor_matrix.shape[0])})." ) wp_dtype = get_wp_dtype(positions.dtype) wp_vec_dtype = get_wp_vec_dtype(positions.dtype) wp_mat_dtype = get_wp_mat_dtype(positions.dtype) wp_device = str(device) wp_positions = wp.from_torch( positions, dtype=wp_vec_dtype, requires_grad=False, return_ctype=True ) wp_cell = wp.from_torch( cell, dtype=wp_mat_dtype, requires_grad=False, return_ctype=True ) wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False, return_ctype=True) wp_batch_idx = wp.from_torch( batch_idx.to(dtype=torch.int32), dtype=wp.int32, requires_grad=False, return_ctype=True, ) wp_cells_per_dimension = wp.from_torch( cells_per_dimension, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_neighbor_search_radius = wp.from_torch( neighbor_search_radius, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) cells_per_system = cells_per_dimension.prod(dim=1) cell_offsets = torch.zeros(num_systems, dtype=torch.int32, device=device) if num_systems > 1: torch.cumsum(cells_per_system[:-1], dim=0, out=cell_offsets[1:]) wp_cell_offsets = wp.from_torch( cell_offsets, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_atom_periodic_shifts = wp.from_torch( atom_periodic_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atom_to_cell_mapping = wp.from_torch( atom_to_cell_mapping, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_atoms_per_cell_count = wp.from_torch( atoms_per_cell_count, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_start_indices = wp.from_torch( cell_atom_start_indices, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_cell_atom_list = wp.from_torch( cell_atom_list, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix = wp.from_torch( neighbor_matrix, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_neighbor_matrix_shifts = wp.from_torch( neighbor_matrix_shifts, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_num_neighbors = wp.from_torch( num_neighbors, dtype=wp.int32, requires_grad=False, return_ctype=True ) wp_sorted_pos = None wp_sorted_shifts = None if rebuild_flags is not None: wp_rebuild_flags = wp.from_torch( rebuild_flags, dtype=wp.bool, requires_grad=False, return_ctype=True ) else: wp_rebuild_flags = None wp_target_indices = ( wp.from_torch( target_indices, dtype=wp.int32, requires_grad=False, return_ctype=True ) if target_indices is not None else None ) # Pair-output buffers are validated by ``_prepare_pair_output_args`` in the # launcher (which dereferences ``pair_params.dtype``), so they must be real # Warp arrays, not ``return_ctype`` launch structs. ``wp.from_torch`` without # ``return_ctype`` still aliases the torch tensor zero-copy, so kernel writes # land in the output buffers. wp_pair_params = ( wp.from_torch(pair_params, dtype=wp_dtype, requires_grad=False) if pair_params is not None else None ) wp_neighbor_vectors = ( wp.from_torch(neighbor_vectors, dtype=wp_vec_dtype, requires_grad=False) if neighbor_vectors is not None else None ) wp_neighbor_distances = ( wp.from_torch(neighbor_distances, dtype=wp_dtype, requires_grad=False) if neighbor_distances is not None else None ) wp_pair_energies = ( wp.from_torch(pair_energies, dtype=wp_dtype, requires_grad=False) if pair_energies is not None else None ) wp_pair_forces = ( wp.from_torch(pair_forces, dtype=wp_vec_dtype, requires_grad=False) if pair_forces is not None else None ) atom_centric_path = _resolve_atom_centric_path(atom_centric_path) cpu_only = device.type != "cuda" if strategy == "auto": use_pair_centric = (not cpu_only) and ( select_batch_cell_list_strategy( total_atoms=int(total_atoms), num_systems=int(num_systems), cutoff=float(cutoff), ) == "pair_centric" ) elif strategy == "atom_centric": use_pair_centric = False elif strategy == "pair_centric": if cpu_only: raise ValueError( "strategy='pair_centric' is not supported on CPU " "(kernels use CUDA block scheduling). Pass 'auto' or " "'atom_centric' instead.", ) use_pair_centric = True else: raise ValueError( f"strategy must be 'auto' | 'atom_centric' | 'pair_centric', " f"got {strategy!r}", ) wp_cells_per_system = None wp_cell_to_system = None total_cells = None n_outer = None R_max = None if use_pair_centric: total_cells = int(cells_per_system.sum().item()) R_max = _max_radius_tuple(neighbor_search_radius) n_outer = compute_batch_pair_centric_n_outer(R_max, bool(half_fill)) if strategy == "auto" and not is_pair_centric_parallelism_sufficient( int(total_atoms), total_cells, n_outer ): use_pair_centric = False total_cells = None n_outer = None R_max = None else: wp_cells_per_system = wp.from_torch( cells_per_system.to(dtype=torch.int32), dtype=wp.int32, requires_grad=False, return_ctype=True, ) cell_to_system_t = torch.zeros( max(total_cells, 1), dtype=torch.int32, device=device ) wp_cell_to_system = wp.from_torch( cell_to_system_t, dtype=wp.int32, requires_grad=False, return_ctype=True, ) if use_pair_centric or atom_centric_path == "sorted": sorted_positions_t = torch.empty( (int(total_atoms), 3), dtype=positions.dtype, device=device ) sorted_shifts_t = torch.empty( (int(total_atoms), 3), dtype=torch.int32, device=device ) wp_sorted_pos = wp.from_torch( sorted_positions_t, dtype=wp_vec_dtype, requires_grad=False, return_ctype=True, ) wp_sorted_shifts = wp.from_torch( sorted_shifts_t, dtype=wp.vec3i, requires_grad=False, return_ctype=True ) wp_batch_query_cell_list( positions=wp_positions, cell=wp_cell, pbc=wp_pbc, cutoff=float(cutoff), batch_idx=wp_batch_idx, cells_per_dimension=wp_cells_per_dimension, neighbor_search_radius=wp_neighbor_search_radius, cell_offsets=wp_cell_offsets, atom_periodic_shifts=wp_atom_periodic_shifts, atom_to_cell_mapping=wp_atom_to_cell_mapping, atoms_per_cell_count=wp_atoms_per_cell_count, cell_atom_start_indices=wp_cell_atom_start_indices, cell_atom_list=wp_cell_atom_list, sorted_positions=wp_sorted_pos, sorted_atom_periodic_shifts=wp_sorted_shifts, neighbor_matrix=wp_neighbor_matrix, neighbor_matrix_shifts=wp_neighbor_matrix_shifts, num_neighbors=wp_num_neighbors, rebuild_flags=wp_rebuild_flags, wp_dtype=wp_dtype, device=wp_device, half_fill=bool(half_fill), strategy="pair_centric" if use_pair_centric else "atom_centric", atom_centric_path=atom_centric_path, cells_per_system=wp_cells_per_system, cell_to_system=wp_cell_to_system, total_cells=total_cells, n_outer=n_outer, R_max=R_max, target_indices=wp_target_indices, return_vectors=bool(return_vectors), return_distances=bool(return_distances), pair_fn=pair_fn, pair_params=wp_pair_params, neighbor_vectors=wp_neighbor_vectors, neighbor_distances=wp_neighbor_distances, pair_energies=wp_pair_energies, pair_forces=wp_pair_forces, ) if fill_value is not None and rebuild_flags is None and wp_device != "cpu": max_neighbors = int(neighbor_matrix.shape[1]) if max_neighbors > 0: wp_fill_neighbor_matrix_tail( wp_num_neighbors, # Row count must be the OUTPUT matrix's row count, not # ``total_atoms``: the ``target_indices`` (partial) path writes # compact ``num_targets`` rows, so ``total_atoms`` would launch # the tail-fill out of bounds over rows [num_targets, N). int(neighbor_matrix.shape[0]), max_neighbors, int(fill_value), wp_neighbor_matrix, wp_device, )
[docs] def batch_cell_list( positions: torch.Tensor, cutoff: float, cell: torch.Tensor, pbc: torch.Tensor, batch_idx: torch.Tensor, max_neighbors: int | None = None, half_fill: bool = False, fill_value: int | None = None, return_neighbor_list: bool = False, neighbor_matrix: torch.Tensor | None = None, neighbor_matrix_shifts: torch.Tensor | None = None, num_neighbors: torch.Tensor | None = None, cells_per_dimension: torch.Tensor | None = None, neighbor_search_radius: torch.Tensor | None = None, cell_offsets: torch.Tensor | None = None, atom_periodic_shifts: torch.Tensor | None = None, atom_to_cell_mapping: torch.Tensor | None = None, atoms_per_cell_count: torch.Tensor | None = None, cell_atom_start_indices: torch.Tensor | None = None, cell_atom_list: torch.Tensor | None = None, rebuild_flags: torch.Tensor | None = None, strategy: str = "auto", atom_centric_path: str = "auto", target_indices: torch.Tensor | None = None, return_vectors: bool = False, return_distances: bool = False, pair_fn: wp.Function | CompiledPairFn | None = None, pair_params: torch.Tensor | None = None, neighbor_vectors: torch.Tensor | None = None, neighbor_distances: torch.Tensor | None = None, pair_energies: torch.Tensor | None = None, pair_forces: torch.Tensor | None = None, ) -> ( tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor] | tuple[torch.Tensor, torch.Tensor, torch.Tensor] | tuple[torch.Tensor, torch.Tensor] ): """Build complete batch neighbor matrices using spatial cell list acceleration. High-level convenience function that processes multiple systems simultaneously. Automatically estimates memory requirements, builds batch spatial cell list data structures, and queries them to produce complete neighbor matrices for all systems. Parameters ---------- positions : torch.Tensor, shape (total_atoms, 3) Concatenated atomic coordinates for all systems in the batch. cutoff : float Neighbor search cutoff distance. cell : torch.Tensor, shape (num_systems, 3, 3) Unit cell matrices for each system in the batch. pbc : torch.Tensor, shape (num_systems, 3), dtype=bool Periodic boundary condition flags for each system and dimension. batch_idx : torch.Tensor, shape (total_atoms,), dtype=int32 System index for each atom. max_neighbors : int or None, optional Maximum number of neighbors per atom. If None, automatically estimated. half_fill : bool, default=False If True, only fill half of the neighbor matrix. fill_value : int | None, optional Value to use for padding empty neighbor slots in the matrix. Default is total_atoms. return_neighbor_list : bool, optional - default=False If True, convert the neighbor matrix to a neighbor list (idx_i, idx_j) format. cells_per_dimension : torch.Tensor, shape (num_systems, 3), dtype=int32, optional Pre-allocated tensor for cell dimensions. neighbor_search_radius : torch.Tensor, shape (num_systems, 3), dtype=int32, optional Pre-allocated tensor for search radius. atom_periodic_shifts : torch.Tensor, shape (total_atoms, 3), dtype=int32, optional Pre-allocated tensor for periodic shifts. atom_to_cell_mapping : torch.Tensor, shape (total_atoms, 3), dtype=int32, optional Pre-allocated tensor for cell mapping. atoms_per_cell_count : torch.Tensor, shape (max_total_cells,), dtype=int32, optional Pre-allocated tensor for atom counts. cell_atom_start_indices : torch.Tensor, shape (max_total_cells,), dtype=int32, optional Pre-allocated tensor for start indices. cell_atom_list : torch.Tensor, shape (total_atoms,), dtype=int32, optional Pre-allocated tensor for atom list. cell_offsets : torch.Tensor, shape (num_systems,), dtype=int32, optional Accepted for API compatibility; computed internally and not used from this argument. rebuild_flags : torch.Tensor, shape (num_systems,), dtype=torch.bool, optional Per-system rebuild flags produced by ``batch_cell_list_needs_rebuild``. If provided, only systems where rebuild_flags[i] is True are recomputed; existing data in ``neighbor_matrix`` and ``num_neighbors`` is preserved for non-rebuilt systems entirely on the GPU (no CPU-GPU sync). When this is used, pre-allocated ``neighbor_matrix`` and ``num_neighbors`` tensors must be provided and will not be globally zeroed - only rebuilt-system entries are reset. Returns ------- results : tuple of torch.Tensor Variable-length tuple with neighbor data in matrix or list format. See Also -------- nvalchemiops.neighbors.batch_cell_list.batch_build_cell_list : Core warp launcher for building nvalchemiops.neighbors.batch_cell_list.batch_query_cell_list : Core warp launcher for querying batch_naive_neighbor_list : O(N^2) method for small systems """ total_atoms = positions.shape[0] device = positions.device if device == "cpu": warnings.warn( "The CPU version of `batch_cell_list` is known to experience" " issues with memory allocation and under investigation. Please" " ensure tensor provided as `positions` is on GPU." ) if is_compiled_pair_fn(pair_fn) and torch.compiler.is_compiling(): if return_neighbor_list: raise NotImplementedError( "CompiledPairFn supports torch.compile(fullgraph=True) for " "matrix neighbor-list output only; use return_neighbor_list=False.", ) missing = [ name for name, value in ( ("neighbor_matrix", neighbor_matrix), ("neighbor_matrix_shifts", neighbor_matrix_shifts), ("num_neighbors", num_neighbors), ("cells_per_dimension", cells_per_dimension), ("neighbor_search_radius", neighbor_search_radius), ("atom_periodic_shifts", atom_periodic_shifts), ("atom_to_cell_mapping", atom_to_cell_mapping), ("atoms_per_cell_count", atoms_per_cell_count), ("cell_atom_start_indices", cell_atom_start_indices), ("cell_atom_list", cell_atom_list), ("neighbor_vectors", neighbor_vectors), ("neighbor_distances", neighbor_distances), ("pair_params", pair_params), ("pair_energies", pair_energies), ("pair_forces", pair_forces), ) if value is None ] if missing: raise ValueError( "CompiledPairFn under torch.compile(fullgraph=True) requires " "fixed-shape caller-provided buffers/metadata; missing " f"{', '.join(missing)}.", ) _validate_pair_params_present(pair_fn, pair_params) if fill_value is None: fill_value = total_atoms num_rows = ( int(target_indices.shape[0]) if target_indices is not None else total_atoms ) # Handle empty case if total_atoms <= 0 or cutoff <= 0: if return_neighbor_list: return ( torch.zeros((2, 0), dtype=torch.int32, device=device), torch.zeros((num_rows + 1,), dtype=torch.int32, device=device), torch.zeros((0, 3), dtype=torch.int32, device=device), ) else: return ( torch.full((num_rows, 0), fill_value, dtype=torch.int32, device=device), torch.zeros((num_rows,), dtype=torch.int32, device=device), torch.zeros((num_rows, 0, 3), dtype=torch.int32, device=device), ) if max_neighbors is None and neighbor_matrix is None: max_neighbors = estimate_max_neighbors(cutoff) # CPU prefills; CUDA tail-fills (``wp.launch_tiled`` mis-runs on CPU). is_cpu = device.type == "cpu" if neighbor_matrix is None: if is_cpu: neighbor_matrix = torch.full( (num_rows, max_neighbors), fill_value, dtype=torch.int32, device=device, ) else: neighbor_matrix = torch.empty( (num_rows, max_neighbors), dtype=torch.int32, device=device ) elif is_cpu and rebuild_flags is None: neighbor_matrix.fill_(fill_value) if neighbor_matrix_shifts is None: neighbor_matrix_shifts = torch.empty( (num_rows, max_neighbors, 3), dtype=torch.int32, device=device ) if num_neighbors is None: num_neighbors = torch.zeros((num_rows,), dtype=torch.int32, device=device) elif rebuild_flags is None: num_neighbors.zero_() # Allocate cell list if needed. Explicit atom-centric queries use the # legacy 1-cell minimum; auto/pair-centric keep the current 4-cell policy. allocated_cell_list = ( cells_per_dimension is None or neighbor_search_radius is None or atom_periodic_shifts is None or atom_to_cell_mapping is None or atoms_per_cell_count is None or cell_atom_start_indices is None or cell_atom_list is None ) cell_list_min_cells = 1 if strategy == "atom_centric" else 4 if allocated_cell_list: max_total_cells, neighbor_search_radius = estimate_batch_cell_list_sizes( cell, pbc, cutoff, min_cells_per_dimension=cell_list_min_cells, ) ( cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, ) = allocate_cell_list( total_atoms, max_total_cells, neighbor_search_radius, device, ) cell_list_cache = ( cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, ) else: # Caller-provided caches are assumed to have been sized with the # default public estimate policy. cell_list_min_cells = 4 # atoms_per_cell_count is atomic_add'd; the rest are fully overwritten. atoms_per_cell_count.zero_() cell_list_cache = ( cells_per_dimension, neighbor_search_radius, atom_periodic_shifts, atom_to_cell_mapping, atoms_per_cell_count, cell_atom_start_indices, cell_atom_list, ) # Build batch cell list with fixed allocations batch_build_cell_list( positions, cutoff, cell, pbc, batch_idx, *cell_list_cache, min_cells_per_dimension=cell_list_min_cells, ) if return_vectors or return_distances or pair_fn is not None: # Pair_fn receives distance/vector values as local kernel variables; # these matrix buffers are only public geometry outputs. if return_distances and neighbor_distances is None: neighbor_distances = torch.zeros( (num_rows, max_neighbors), dtype=positions.dtype, device=device ) if return_vectors and neighbor_vectors is None: neighbor_vectors = torch.zeros( (num_rows, max_neighbors, 3), dtype=positions.dtype, device=device, ) # ``pair_fn`` energy/force buffers are optional: allocate them like the # neighbor matrix when not supplied, so they can be returned. if pair_fn is not None and pair_energies is None: pair_energies = torch.zeros( (num_rows, max_neighbors), dtype=positions.dtype, device=device ) if pair_fn is not None and pair_forces is None: pair_forces = torch.zeros( (num_rows, max_neighbors, 3), dtype=positions.dtype, device=device ) forward_kwargs = { "cutoff": cutoff, "pbc": pbc, "batch_idx": batch_idx, "cell_list_cache": cell_list_cache, "neighbor_matrix": neighbor_matrix, "neighbor_matrix_shifts": neighbor_matrix_shifts, "num_neighbors": num_neighbors, "half_fill": half_fill, "rebuild_flags": rebuild_flags, "fill_value": fill_value, "strategy": strategy, "atom_centric_path": atom_centric_path, "target_indices": target_indices, "return_vectors": return_vectors, "return_distances": return_distances, "pair_fn": pair_fn, "pair_params": pair_params, "neighbor_vectors": neighbor_vectors, "neighbor_distances": neighbor_distances, "pair_energies": pair_energies, "pair_forces": pair_forces, } distances_out, vectors_out, nm_out, nn_out, shifts_out = _route_pair_outputs( positions, cell, _batch_cell_list_query_forward, forward_kwargs, ) if return_neighbor_list: nl, nptr, nl_shifts = get_neighbor_list_from_neighbor_matrix( nm_out, num_neighbors=nn_out, neighbor_shift_matrix=shifts_out, fill_value=fill_value, ) base = (nl, nptr, nl_shifts) # Repack per-pair outputs into the same COO order as ``nl`` so they # index-align with it; ``index_select`` keeps the autograd link. # ``pair_fn`` also fills the caller's matrix buffers in place. active = nm_out != fill_value distances_out, vectors_out = coo_pack_pair_geometry( active, distances_out, vectors_out ) pe_out, pf_out = coo_pack_pair_geometry(active, pair_energies, pair_forces) else: base = (nm_out, nn_out, shifts_out) pe_out, pf_out = pair_energies, pair_forces tail: list[torch.Tensor] = [] if return_distances: tail.append(distances_out) if return_vectors: tail.append(vectors_out) if pair_fn is not None: tail.extend((pe_out, pf_out)) return (*base, *tail) # Query neighbor lists batch_query_cell_list( positions, cell, pbc, cutoff, batch_idx, *cell_list_cache, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill, rebuild_flags, fill_value, strategy, atom_centric_path, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=pair_fn, pair_params=pair_params, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=pair_energies, pair_forces=pair_forces, ) if return_neighbor_list: neighbor_list, neighbor_ptr, neighbor_list_shifts = ( get_neighbor_list_from_neighbor_matrix( neighbor_matrix, num_neighbors=num_neighbors, neighbor_shift_matrix=neighbor_matrix_shifts, fill_value=fill_value, ) ) return neighbor_list, neighbor_ptr, neighbor_list_shifts else: return neighbor_matrix, num_neighbors, neighbor_matrix_shifts
def _batch_cell_list_query_forward( positions: torch.Tensor, cell: torch.Tensor | None, *, cutoff: float, pbc: torch.Tensor, batch_idx: torch.Tensor, cell_list_cache: tuple, neighbor_matrix: torch.Tensor, neighbor_matrix_shifts: torch.Tensor, num_neighbors: torch.Tensor, half_fill: bool, rebuild_flags: torch.Tensor | None, fill_value: int, strategy: str, atom_centric_path: str, target_indices: torch.Tensor | None, return_vectors: bool, return_distances: bool, pair_fn, pair_params: torch.Tensor | None, neighbor_vectors: torch.Tensor | None, neighbor_distances: torch.Tensor | None, pair_energies: torch.Tensor | None, pair_forces: torch.Tensor | None, ) -> _NeighborForwardOutput: """Forward closure consumed by ``_NeighborDistanceVectorFn`` (batched).""" batch_query_cell_list( positions, cell, pbc, cutoff, batch_idx, *cell_list_cache, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, half_fill, rebuild_flags, fill_value, strategy, atom_centric_path, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, pair_fn=pair_fn, pair_params=pair_params, neighbor_vectors=neighbor_vectors, neighbor_distances=neighbor_distances, pair_energies=pair_energies, pair_forces=pair_forces, ) i_idx, j_idx, shifts_flat, batch_idx_flat, mask = _flatten_active_pairs( neighbor_matrix, num_neighbors, neighbor_matrix_shifts, target_indices=target_indices, batch_idx=batch_idx, ) K, M = neighbor_matrix.shape return _NeighborForwardOutput( distances=neighbor_distances, vectors=neighbor_vectors, extra_outputs=(neighbor_matrix, num_neighbors, neighbor_matrix_shifts), i_idx_flat=i_idx, j_idx_flat=j_idx, shifts_flat=shifts_flat, batch_idx_flat=batch_idx_flat, active_mask=mask, matrix_shape=(K, M), )