# 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 utilities for neighbor list construction.
This module contains PyTorch-specific helper functions for neighbor list operations.
"""
from __future__ import annotations
from typing import Literal
import torch
import warp as wp
from nvalchemiops.neighbors.neighbor_utils import (
NeighborOverflowError,
TileBufferOverflow,
estimate_max_neighbors,
)
from nvalchemiops.neighbors.neighbor_utils import (
compute_naive_num_shifts as wp_compute_naive_num_shifts,
)
from nvalchemiops.torch._warp_op_helpers import scoped_torch_warp_stream
from nvalchemiops.torch.types import get_wp_dtype, get_wp_mat_dtype
__all__ = [
"compute_naive_num_shifts",
"get_neighbor_list_from_neighbor_matrix",
"prepare_batch_idx_ptr",
"allocate_cell_list",
"estimate_max_neighbors",
"synthesize_cell_for_batch",
"synthesize_cell_for_ss",
"NeighborOverflowError",
"TileBufferOverflow",
]
def _raise_if_compiling_host_only(name: str, replacement: str) -> None:
"""Raise a clear error when a host-only helper is traced by Dynamo."""
if torch.compiler.is_compiling() or torch._dynamo.is_compiling():
raise RuntimeError(
f"{name} is a host-only neighbor-list helper and cannot run inside "
f"torch.compile. {replacement}"
)
def _check_tile_buffer_capacity(
counts: torch.Tensor,
capacities: int | torch.Tensor,
*,
segmented: bool = False,
) -> int:
"""Validate cluster-tile buffer capacity without breaking compilation.
Returns the observed compact count in eager execution, the static compact
capacity during compilation, and zero for segmented state.
"""
if torch.compiler.is_compiling():
torch._assert_async(
torch.all(counts <= capacities),
"cluster-tile buffer capacity exceeded",
)
return 0 if segmented or not isinstance(capacities, int) else capacities
if segmented:
overflow = counts > capacities
if bool(overflow.any().item()):
system_index = int(overflow.nonzero(as_tuple=False)[0, 0].item())
max_tiles = int(capacities[system_index].item())
num_tiles = int(counts[system_index].item())
raise TileBufferOverflow(
max_tiles,
num_tiles,
system_index=system_index,
)
return 0
num_tiles = int(counts.max().item()) if counts.numel() > 0 else 0
max_tiles = (
int(capacities.item()) if isinstance(capacities, torch.Tensor) else capacities
)
if num_tiles > max_tiles:
raise TileBufferOverflow(max_tiles, num_tiles)
return num_tiles
def _check_neighbor_capacity(
counts: torch.Tensor,
capacities: int | torch.Tensor,
*,
segmented: bool = False,
kind: Literal["matrix", "coo"] = "matrix",
) -> int:
"""Validate matrix or COO capacity without breaking compilation.
Returns the maximum observed count for a nonsegmented eager call. The
return value is zero for segmented state and is not meaningful while
compiling.
"""
if kind == "matrix":
compiled_message = "cluster-tile neighbor matrix capacity exceeded"
elif kind == "coo":
compiled_message = "cluster-tile COO pair capacity exceeded"
else:
raise ValueError("kind must be 'matrix' or 'coo'")
if torch.compiler.is_compiling():
torch._assert_async(
torch.all(counts <= capacities),
compiled_message,
)
return 0
if segmented:
overflow = counts > capacities
if bool(overflow.any().item()):
system_index = int(overflow.nonzero(as_tuple=False)[0, 0].item())
max_neighbors = int(capacities[system_index].item())
num_neighbors = int(counts[system_index].item())
raise NeighborOverflowError(
max_neighbors,
num_neighbors,
system_index=system_index,
)
return 0
num_neighbors = int(counts.max().item()) if counts.numel() > 0 else 0
max_neighbors = (
int(capacities.item()) if isinstance(capacities, torch.Tensor) else capacities
)
if num_neighbors > max_neighbors:
raise NeighborOverflowError(max_neighbors, num_neighbors)
return num_neighbors
def _prepare_compact_coo_geometry_buffers(
*,
device: torch.device,
dtype: torch.dtype,
capacity: int,
return_vectors: bool,
return_distances: bool,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
allocate_missing: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Allocate or validate compact-COO geometry buffers and sentinels."""
if return_vectors:
if neighbor_vectors is None:
vector_capacity = capacity if allocate_missing else 1
neighbor_vectors = torch.empty(
(vector_capacity, 3), dtype=dtype, device=device
)
elif (
neighbor_vectors.device != device
or neighbor_vectors.dtype != dtype
or neighbor_vectors.ndim != 2
or neighbor_vectors.shape[1] != 3
or neighbor_vectors.shape[0] < capacity
):
raise ValueError(
"neighbor_vectors must have shape (capacity, 3), matching "
"positions dtype and device, with capacity at least max_pairs"
)
if neighbor_vectors.requires_grad:
raise ValueError(
"neighbor_vectors must not require gradients; differentiate the "
"returned geometry instead"
)
else:
neighbor_vectors = torch.empty((1, 3), dtype=dtype, device=device)
if return_distances:
if neighbor_distances is None:
distance_capacity = capacity if allocate_missing else 1
neighbor_distances = torch.empty(
distance_capacity, dtype=dtype, device=device
)
elif (
neighbor_distances.device != device
or neighbor_distances.dtype != dtype
or neighbor_distances.ndim != 1
or neighbor_distances.shape[0] < capacity
):
raise ValueError(
"neighbor_distances must have shape (capacity,), matching "
"positions dtype and device, with capacity at least max_pairs"
)
if neighbor_distances.requires_grad:
raise ValueError(
"neighbor_distances must not require gradients; differentiate the "
"returned geometry instead"
)
else:
neighbor_distances = torch.empty(1, dtype=dtype, device=device)
return neighbor_vectors, neighbor_distances
def _validate_compact_coo_outputs(
*,
device: torch.device,
capacity: int,
neighbor_list: torch.Tensor | None,
neighbor_list_shifts: torch.Tensor | None,
pair_counter: torch.Tensor | None,
) -> None:
"""Validate caller-owned compact-COO outputs before mutation."""
if neighbor_list is not None and (
neighbor_list.device != device
or neighbor_list.dtype != torch.int32
or neighbor_list.ndim != 2
or neighbor_list.shape[0] != 2
or neighbor_list.shape[1] < capacity
):
raise ValueError(
"neighbor_list must have shape (2, capacity), dtype int32, "
"match positions.device, and have capacity at least max_pairs"
)
if neighbor_list_shifts is not None and (
neighbor_list_shifts.device != device
or neighbor_list_shifts.dtype != torch.int32
or neighbor_list_shifts.ndim != 2
or neighbor_list_shifts.shape[1] != 3
or neighbor_list_shifts.shape[0] < capacity
):
raise ValueError(
"neighbor_list_shifts must have shape (capacity, 3), dtype int32, "
"match positions.device, and have capacity at least max_pairs"
)
if pair_counter is not None and (
pair_counter.device != device
or pair_counter.dtype != torch.int32
or pair_counter.shape != (1,)
):
raise ValueError(
"pair_counter must have shape (1,), dtype int32, and match positions.device"
)
@torch.library.custom_op("nvalchemiops::_compact_coo_prefix", mutates_args=())
def _compact_coo_prefix(
pair_count: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
neighbor_vectors: torch.Tensor,
neighbor_distances: torch.Tensor,
capacity: int,
resolved_count: int,
copy_vectors: bool,
copy_distances: bool,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Copy the active compact-COO prefix into exact, non-aliasing outputs."""
num_pairs = (
int(resolved_count)
if resolved_count >= 0
else int(pair_count.reshape(-1)[0].item())
)
copy_length = max(0, min(num_pairs, int(capacity)))
storage_length = max(copy_length, 1)
pairs = coo_list.new_empty((2, storage_length))[:, :copy_length]
shifts = coo_shifts.new_empty((storage_length, 3))[:copy_length]
if copy_length:
pairs.copy_(coo_list[:copy_length].transpose(0, 1))
shifts.copy_(coo_shifts[:copy_length])
vector_length = storage_length if copy_vectors else 1
vectors = neighbor_vectors.new_empty((vector_length, 3))[
: copy_length if copy_vectors else 0
]
if copy_vectors and copy_length:
vectors.copy_(neighbor_vectors[:copy_length])
distance_length = storage_length if copy_distances else 1
distances = neighbor_distances.new_empty(distance_length)[
: copy_length if copy_distances else 0
]
if copy_distances and copy_length:
distances.copy_(neighbor_distances[:copy_length])
return pairs, shifts, vectors, distances
@_compact_coo_prefix.register_fake
def _(
pair_count: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
neighbor_vectors: torch.Tensor,
neighbor_distances: torch.Tensor,
capacity: int,
resolved_count: int,
copy_vectors: bool,
copy_distances: bool,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
if resolved_count >= 0:
num_pairs = min(int(resolved_count), int(capacity))
else:
ctx = torch.library.get_ctx()
num_pairs = ctx.new_dynamic_size(min=0, max=int(capacity))
pairs = coo_list.new_empty((2, num_pairs))
shifts = coo_shifts.new_empty((num_pairs, 3))
vectors = (
neighbor_vectors.new_empty((num_pairs, 3))
if copy_vectors
else neighbor_vectors.new_empty((0, 3))
)
distances = (
neighbor_distances.new_empty((num_pairs,))
if copy_distances
else neighbor_distances.new_empty((0,))
)
return pairs, shifts, vectors, distances
def _validate_pair_params_present(
pair_fn: object,
pair_params: torch.Tensor | None,
) -> None:
"""Validate the torch pair-function parameter contract."""
if pair_fn is not None and pair_params is None:
raise ValueError("pair_params is required when pair_fn is provided")
def _validate_cluster_tile_matrix_outputs(
*,
device: torch.device,
dtype: torch.dtype,
natom: int,
max_neighbors: int,
cutoff2: float | None,
neighbor_matrix2: torch.Tensor | None,
num_neighbors2: torch.Tensor | None,
neighbor_matrix_shifts2: torch.Tensor | None,
return_vectors: bool,
return_distances: bool,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
allocate_missing: bool,
) -> None:
"""Validate optional cluster-tile matrix outputs before mutation."""
def validate(
name: str,
tensor: torch.Tensor,
expected_shape: tuple[int, ...],
expected_dtype: torch.dtype,
) -> None:
if tensor.device != device:
raise ValueError(f"{name} must be on the query device")
if tensor.dtype != expected_dtype or tuple(tensor.shape) != expected_shape:
raise ValueError(
f"{name} must have shape {expected_shape} and dtype "
f"{expected_dtype}; got shape {tuple(tensor.shape)} and "
f"dtype {tensor.dtype}."
)
secondary = (neighbor_matrix2, num_neighbors2, neighbor_matrix_shifts2)
num_secondary = sum(value is not None for value in secondary)
if 0 < num_secondary < len(secondary):
raise ValueError(
"neighbor_matrix2, num_neighbors2, and neighbor_matrix_shifts2 "
"must be supplied together"
)
if cutoff2 is None:
if num_secondary:
raise ValueError("secondary matrix outputs require cutoff2")
elif num_secondary == 0:
if not allocate_missing:
raise ValueError(
"cutoff2 requires neighbor_matrix2, num_neighbors2, and "
"neighbor_matrix_shifts2"
)
else:
validate(
"neighbor_matrix2",
neighbor_matrix2,
(natom, max_neighbors),
torch.int32,
)
validate("num_neighbors2", num_neighbors2, (natom,), torch.int32)
validate(
"neighbor_matrix_shifts2",
neighbor_matrix_shifts2,
(natom, max_neighbors, 3),
torch.int32,
)
geometry = (
(
"neighbor_vectors",
return_vectors,
neighbor_vectors,
(natom, max_neighbors, 3),
),
(
"neighbor_distances",
return_distances,
neighbor_distances,
(natom, max_neighbors),
),
)
for name, enabled, tensor, expected_shape in geometry:
if not enabled:
if tensor is not None:
raise ValueError(f"{name} is only valid when its output is enabled")
continue
if tensor is None:
if allocate_missing:
continue
raise ValueError(f"{name} is required when its output is enabled")
validate(name, tensor, expected_shape, dtype)
if tensor.requires_grad:
raise ValueError(
f"{name} must not require gradients; differentiate the returned "
"geometry instead"
)
def _validate_segmented_coo_structure(
*,
device: torch.device,
num_systems: int,
neighbor_list: torch.Tensor,
neighbor_list_shifts: torch.Tensor,
pair_offsets: torch.Tensor,
pair_counts: torch.Tensor,
rebuild_flags: torch.Tensor | None,
tile_offsets: torch.Tensor | None = None,
tile_counts: torch.Tensor | None = None,
num_tiles: torch.Tensor | None = None,
tile_row_group: torch.Tensor | None = None,
tile_col_group: torch.Tensor | None = None,
tile_system: torch.Tensor | None = None,
) -> int:
"""Validate segmented COO shapes, dtypes, devices, and capacities."""
tensors = {
"neighbor_list": neighbor_list,
"neighbor_list_shifts": neighbor_list_shifts,
"pair_offsets": pair_offsets,
"pair_counts": pair_counts,
}
if rebuild_flags is not None:
tensors["rebuild_flags"] = rebuild_flags
optional_tensors = {
"tile_offsets": tile_offsets,
"tile_counts": tile_counts,
"num_tiles": num_tiles,
"tile_row_group": tile_row_group,
"tile_col_group": tile_col_group,
"tile_system": tile_system,
}
tensors.update(
{name: value for name, value in optional_tensors.items() if value is not None}
)
for name, value in tensors.items():
if value.device != device:
raise ValueError(f"{name} must match positions.device")
if (
neighbor_list.dtype != torch.int32
or neighbor_list.ndim != 2
or neighbor_list.shape[0] != 2
):
raise ValueError("neighbor_list must have shape (2, capacity) and dtype int32")
capacity = int(neighbor_list.shape[1])
if neighbor_list_shifts.dtype != torch.int32 or neighbor_list_shifts.shape != (
capacity,
3,
):
raise ValueError(
"neighbor_list_shifts must have shape (neighbor_list capacity, 3) "
"and dtype int32"
)
if pair_offsets.dtype != torch.int32 or pair_offsets.shape != (num_systems + 1,):
raise ValueError(
"pair_offsets must have shape (num_systems + 1,) and dtype int32"
)
if pair_counts.dtype != torch.int32 or pair_counts.shape != (num_systems,):
raise ValueError("pair_counts must have shape (num_systems,) and dtype int32")
if rebuild_flags is not None and (
rebuild_flags.dtype != torch.bool or rebuild_flags.shape != (num_systems,)
):
raise ValueError("rebuild_flags must have shape (num_systems,) and dtype bool")
tile_values = (tile_offsets, tile_counts, num_tiles, tile_row_group, tile_col_group)
if any(value is not None for value in tile_values):
if any(value is None for value in tile_values):
raise ValueError("segmented COO tile state must be supplied completely")
if (
tile_offsets.dtype != torch.int32
or tile_offsets.shape != (num_systems + 1,)
or tile_counts.dtype != torch.int32
or tile_counts.shape != (num_systems,)
or num_tiles.dtype != torch.int32
or num_tiles.shape != (1,)
):
raise ValueError(
"segmented COO tile metadata has an invalid shape or dtype"
)
if (
tile_row_group.dtype != torch.int32
or tile_col_group.dtype != torch.int32
or tile_row_group.ndim != 1
or tile_col_group.ndim != 1
or tile_row_group.shape != tile_col_group.shape
):
raise ValueError(
"tile row and column buffers must be matching 1D int32 arrays"
)
if tile_system is not None and (
tile_system.dtype != torch.int32
or tile_system.ndim != 1
or tile_system.shape != tile_row_group.shape
):
raise ValueError("tile_system must match tile row and column buffer shapes")
return capacity
def _validate_segmented_coo_values(
*,
neighbor_list: torch.Tensor,
pair_offsets: torch.Tensor,
pair_counts: torch.Tensor,
tile_offsets: torch.Tensor | None = None,
tile_counts: torch.Tensor | None = None,
tile_row_group: torch.Tensor | None = None,
) -> None:
"""Eagerly validate segmented COO metadata values before mutation."""
capacity = int(neighbor_list.shape[1])
pair_offsets_host = pair_offsets.cpu()
pair_counts_host = pair_counts.cpu()
if int(pair_offsets_host[0]) != 0:
raise ValueError("pair_offsets must start at zero")
if bool((pair_offsets_host[1:] < pair_offsets_host[:-1]).any()):
raise ValueError("pair_offsets must be nondecreasing")
if int(pair_offsets_host[-1]) != capacity:
raise ValueError("pair_offsets final value must equal neighbor_list capacity")
pair_capacities = pair_offsets_host[1:] - pair_offsets_host[:-1]
if bool((pair_counts_host < 0).any()) or bool(
(pair_counts_host > pair_capacities).any()
):
raise ValueError("pair_counts must lie within their pair_offsets segments")
if tile_offsets is not None:
tile_offsets_host = tile_offsets.cpu()
tile_counts_host = tile_counts.cpu()
if int(tile_offsets_host[0]) != 0:
raise ValueError("tile_offsets must start at zero")
if bool((tile_offsets_host[1:] < tile_offsets_host[:-1]).any()):
raise ValueError("tile_offsets must be nondecreasing")
if int(tile_offsets_host[-1]) > int(tile_row_group.shape[0]):
raise ValueError("tile_offsets exceed the physical tile-buffer capacity")
tile_capacities = tile_offsets_host[1:] - tile_offsets_host[:-1]
if bool((tile_counts_host < 0).any()) or bool(
(tile_counts_host > tile_capacities).any()
):
raise ValueError("tile_counts must lie within their tile_offsets segments")
def _validate_segmented_coo_state(
*,
device: torch.device,
num_systems: int,
neighbor_list: torch.Tensor,
neighbor_list_shifts: torch.Tensor,
pair_offsets: torch.Tensor,
pair_counts: torch.Tensor,
rebuild_flags: torch.Tensor | None,
tile_offsets: torch.Tensor | None = None,
tile_counts: torch.Tensor | None = None,
num_tiles: torch.Tensor | None = None,
tile_row_group: torch.Tensor | None = None,
tile_col_group: torch.Tensor | None = None,
tile_system: torch.Tensor | None = None,
) -> int:
"""Validate fixed-capacity segmented COO state before a kernel launch."""
capacity = _validate_segmented_coo_structure(
device=device,
num_systems=num_systems,
neighbor_list=neighbor_list,
neighbor_list_shifts=neighbor_list_shifts,
pair_offsets=pair_offsets,
pair_counts=pair_counts,
rebuild_flags=rebuild_flags,
tile_offsets=tile_offsets,
tile_counts=tile_counts,
num_tiles=num_tiles,
tile_row_group=tile_row_group,
tile_col_group=tile_col_group,
tile_system=tile_system,
)
if not torch.compiler.is_compiling():
_validate_segmented_coo_values(
neighbor_list=neighbor_list,
pair_offsets=pair_offsets,
pair_counts=pair_counts,
tile_offsets=tile_offsets,
tile_counts=tile_counts,
tile_row_group=tile_row_group,
)
return capacity
def _normalize_compiled_single_segment_coo_count(
*,
pair_offsets: torch.Tensor,
pair_counts: torch.Tensor,
rebuild_flags: torch.Tensor,
physical_capacity: int,
) -> None:
"""Fail closed for malformed compiled single-segment COO metadata.
This compiled-path helper uses only device-side int32 operations. A true
rebuild asserts when the resulting count exceeds the fixed segment. A
skipped rebuild preserves a valid saved count, while malformed offsets or
an invalid saved count are normalized to zero. This is deliberately not a
generic batched normalizer.
"""
offsets_valid = (pair_offsets[0] == 0) & (pair_offsets[1] == physical_capacity)
rebuilt_count = torch.where(
offsets_valid & rebuild_flags.flatten()[0],
pair_counts,
torch.zeros_like(pair_counts),
)
_check_neighbor_capacity(
rebuilt_count,
physical_capacity,
kind="coo",
)
clamped_count = torch.clamp(pair_counts, min=0, max=physical_capacity)
saved_count_valid = (pair_counts >= 0) & (pair_counts <= physical_capacity)
normalized_counts = torch.where(
offsets_valid & (rebuild_flags.flatten()[0] | saved_count_valid),
clamped_count,
torch.zeros_like(pair_counts),
)
pair_counts.copy_(normalized_counts)
@scoped_torch_warp_stream
def compute_naive_num_shifts(
cell: torch.Tensor,
cutoff: float,
pbc: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, int]:
"""Compute periodic image shifts needed for neighbor searching.
Parameters
----------
cell : torch.Tensor, shape (num_systems, 3, 3)
Cell matrices defining lattice vectors in Cartesian coordinates.
Each 3x3 matrix represents one system's periodic cell.
cutoff : float
Cutoff distance for neighbor searching in Cartesian units.
Must be positive and typically less than half the minimum cell dimension.
pbc : torch.Tensor, shape (num_systems, 3), dtype=bool
Periodic boundary condition flags for each dimension.
True enables periodicity in that direction.
Returns
-------
shift_range : torch.Tensor, shape (num_systems, 3), dtype=int32
Maximum shift indices in each dimension for each system.
num_shifts : torch.Tensor, shape (num_systems,), dtype=int32
Number of periodic shifts for each system.
max_shifts : int
Maximum per-system shift count across all systems.
Raises
------
ValueError
If any per-system shift count exceeds int32 range.
See Also
--------
nvalchemiops.neighbors.neighbor_utils.compute_naive_num_shifts : Core warp launcher
"""
_raise_if_compiling_host_only(
"compute_naive_num_shifts",
"Call it before compiling and pass shift_range_per_dimension, "
"num_shifts_per_system, and max_shifts_per_system to the compiled "
"neighbor-list call.",
)
num_systems = cell.shape[0]
device = cell.device
num_shifts_i32 = torch.empty(num_systems, dtype=torch.int32, device=device)
shift_range = torch.empty((num_systems, 3), dtype=torch.int32, device=device)
wp_dtype = get_wp_dtype(cell.dtype)
wp_mat_dtype = get_wp_mat_dtype(cell.dtype)
wp_device = wp.device_from_torch(device)
wp_cell = wp.from_torch(cell, dtype=wp_mat_dtype, requires_grad=False)
wp_pbc = wp.from_torch(pbc, dtype=wp.bool, requires_grad=False)
wp_num_shifts = wp.from_torch(num_shifts_i32, dtype=wp.int32, requires_grad=False)
wp_shift_range = wp.from_torch(shift_range, dtype=wp.vec3i, requires_grad=False)
wp_compute_naive_num_shifts(
cell=wp_cell,
cutoff=cutoff,
pbc=wp_pbc,
num_shifts=wp_num_shifts,
shift_range=wp_shift_range,
wp_dtype=wp_dtype,
device=str(wp_device),
)
s = shift_range.to(torch.int64)
k1 = 2 * s[:, 1] + 1
k2 = 2 * s[:, 2] + 1
num_shifts_i64 = s[:, 0] * k1 * k2 + s[:, 1] * k2 + s[:, 2] + 1
max_shifts_i64 = num_shifts_i64.max().item() if num_systems > 0 else 0
if max_shifts_i64 > 2**31 - 1:
raise ValueError(
f"Per-system shift count ({max_shifts_i64}) exceeds int32 max "
f"(2^31 - 1). Reduce the cutoff, increase cell size, or use a "
f"cell-list method for very small cells."
)
num_shifts = num_shifts_i64.to(torch.int32)
return shift_range, num_shifts, int(max_shifts_i64)
[docs]
def get_neighbor_list_from_neighbor_matrix(
neighbor_matrix: torch.Tensor,
num_neighbors: torch.Tensor,
neighbor_shift_matrix: torch.Tensor | None = None,
fill_value: int = -1,
) -> (
tuple[torch.Tensor, torch.Tensor] | tuple[torch.Tensor, torch.Tensor, torch.Tensor]
):
"""Convert neighbor matrix format to neighbor list format.
Parameters
----------
neighbor_matrix : torch.Tensor, shape (total_atoms, max_neighbors), dtype=int32
The neighbor matrix with neighbor atom indices.
num_neighbors : torch.Tensor, shape (total_atoms,), dtype=int32
The number of neighbors for each atom.
neighbor_shift_matrix : torch.Tensor | None, shape (total_atoms, max_neighbors, 3), dtype=int32
Optional neighbor shift matrix with periodic shift vectors.
fill_value : int, default=-1
The fill value used in the neighbor matrix to indicate empty slots.
This is used to create a mask from the neighbor matrix.
Returns
-------
neighbor_list : torch.Tensor, shape (2, num_pairs), dtype=int32
The neighbor list in COO format [source_atoms, target_atoms].
neighbor_ptr : torch.Tensor, shape (total_atoms + 1,), dtype=int32
CSR-style pointer array where neighbor_ptr[i]:neighbor_ptr[i+1] gives the range of
neighbors for atom i in the flattened neighbor list.
neighbor_list_shifts : torch.Tensor, shape (num_pairs, 3), dtype=int32
The neighbor shift vectors (only returned if neighbor_shift_matrix is not None).
Raises
------
NeighborOverflowError
If eager execution finds more neighbors than the neighbor matrix can
hold. The exception retains the allocated capacity and the observed
maximum neighbor count in ``max_neighbors`` and ``num_neighbors``.
RuntimeError
If compiled execution finds an insufficient neighbor-matrix capacity.
The compiled assertion is device-side and may be reported
asynchronously by the active device runtime.
Notes
-----
This is a pure PyTorch utility function with no warp dependencies. It converts
from the fixed-width matrix format to the variable-width list format by masking
out fill values and flattening the result. Compiled callers use a device-side
capacity assertion to avoid converting a tensor to a Python scalar. Exact output
allocation through ``nonzero`` is data-dependent and requires host synchronization.
See Also
--------
nvalchemiops.torch.neighbors.naive_neighbor_list : Uses this for format conversion
nvalchemiops.torch.neighbors.cell_list : Uses this for format conversion
"""
# Handle empty case
if num_neighbors.shape[0] == 0:
neighbor_list = torch.zeros(
2, 0, dtype=neighbor_matrix.dtype, device=neighbor_matrix.device
)
neighbor_ptr = torch.zeros(1, dtype=torch.int32, device=neighbor_matrix.device)
if neighbor_shift_matrix is not None:
neighbor_shift_list = torch.empty(
0,
3,
dtype=neighbor_shift_matrix.dtype,
device=neighbor_shift_matrix.device,
)
return neighbor_list, neighbor_ptr, neighbor_shift_list
else:
return neighbor_list, neighbor_ptr
# Validate that the neighbor matrix is large enough. Eager callers retain
# the structured overflow exception; compiled callers need a device-side
# assertion because converting ``max_found`` to a Python scalar would break
# graph capture and would synchronize the device.
max_found = num_neighbors.max()
if torch.compiler.is_compiling():
torch._assert_async(
max_found <= neighbor_matrix.shape[1],
"neighbor matrix capacity is insufficient for the requested COO output",
)
else:
max_found_value = int(max_found.item())
if max_found_value > neighbor_matrix.shape[1]:
raise NeighborOverflowError(
neighbor_matrix.shape[1],
max_found_value,
)
# Create mask and extract neighbor pairs. ``nonzero`` returns the row and
# slot coordinates together, avoiding separate dynamic mask compactions and
# retaining row-major ordering.
mask = neighbor_matrix != fill_value
dtype = neighbor_matrix.dtype
i_idx, slot_idx = mask.nonzero(as_tuple=True)
j_idx = neighbor_matrix[i_idx, slot_idx].to(dtype)
neighbor_list = torch.stack([i_idx.to(dtype), j_idx], dim=0)
# Create CSR-style pointer array
neighbor_ptr = torch.zeros(
num_neighbors.shape[0] + 1, dtype=torch.int32, device=neighbor_matrix.device
)
torch.cumsum(num_neighbors, dim=0, out=neighbor_ptr[1:])
if neighbor_shift_matrix is not None:
neighbor_list_shifts = neighbor_shift_matrix[i_idx, slot_idx]
return neighbor_list, neighbor_ptr, neighbor_list_shifts
else:
return neighbor_list, neighbor_ptr
def coo_pack_pair_geometry(
active_mask: torch.Tensor,
distances: torch.Tensor | None = None,
vectors: torch.Tensor | None = None,
) -> tuple[torch.Tensor | None, torch.Tensor | None]:
"""Repack matrix-layout per-pair geometry into COO order.
``active_mask`` is ``neighbor_matrix != fill_value``. Flattening it in
row-major order yields the active-slot indices in the same order
:func:`get_neighbor_list_from_neighbor_matrix` uses, so the gathered
distances ``(num_pairs,)`` and vectors ``(num_pairs, 3)`` index-align with
the returned neighbor list. ``index_select`` keeps the autograd link.
Parameters
----------
active_mask : torch.Tensor, shape (total_atoms, max_neighbors), dtype=bool
Mask of active neighbor-matrix slots.
distances : torch.Tensor | None, shape (total_atoms, max_neighbors)
Per-pair distances in matrix layout, or ``None``.
vectors : torch.Tensor | None, shape (total_atoms, max_neighbors, 3)
Per-pair displacement vectors in matrix layout, or ``None``.
Returns
-------
tuple of (torch.Tensor | None, torch.Tensor | None)
``(distances, vectors)`` in COO layout, each unchanged if it was
``None``.
"""
flat_active = active_mask.reshape(-1).nonzero(as_tuple=True)[0]
if distances is not None:
distances = distances.reshape(-1).index_select(0, flat_active)
if vectors is not None:
vectors = vectors.reshape(-1, vectors.shape[-1]).index_select(0, flat_active)
return distances, vectors
[docs]
def prepare_batch_idx_ptr(
batch_idx: torch.Tensor | None,
batch_ptr: torch.Tensor | None,
num_atoms: int,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Prepare batch index and pointer tensors from either representation.
Utility function to ensure both batch_idx and batch_ptr are available,
computing one from the other if needed.
Parameters
----------
batch_idx : torch.Tensor | None, shape (total_atoms,), dtype=int32
Tensor indicating the batch index for each atom.
batch_ptr : torch.Tensor | None, shape (num_systems + 1,), dtype=int32
Tensor indicating the start index of each batch in the atom list.
num_atoms : int
Total number of atoms across all systems.
device : torch.device
Device on which to create tensors if needed.
Returns
-------
batch_idx : torch.Tensor, shape (total_atoms,), dtype=int32
Prepared batch index tensor.
batch_ptr : torch.Tensor, shape (num_systems + 1,), dtype=int32
Prepared batch pointer tensor.
Raises
------
ValueError
If both batch_idx and batch_ptr are None.
RuntimeError
If batch_idx length does not match num_atoms (only checked in eager mode).
Notes
-----
This is a pure PyTorch utility function with no warp dependencies. It provides
convenience for batch operations by converting between dense (batch_idx) and
sparse (batch_ptr) batch representations.
The batch_idx size validation is only performed in eager mode to avoid graph
breaks during torch.compile tracing. During compiled execution, mismatched
sizes will result in undefined behavior.
See Also
--------
nvalchemiops.torch.neighbors.batch_naive_neighbor_list : Uses this for batch setup
nvalchemiops.torch.neighbors.batch_cell_list : Uses this for batch setup
"""
if batch_idx is None and batch_ptr is None:
raise ValueError("Either batch_idx or batch_ptr must be provided.")
if batch_ptr is not None and batch_ptr.shape[0] < 2:
raise ValueError("batch_ptr must have length at least 2")
# Validate batch_idx size in eager mode only to avoid graph breaks
if not torch.compiler.is_compiling():
if batch_idx is not None and batch_idx.shape[0] != num_atoms:
raise RuntimeError(
f"batch_idx length ({batch_idx.shape[0]}) does not match "
f"num_atoms ({num_atoms}). batch_idx must have one entry per atom."
)
if batch_idx is None:
num_systems = batch_ptr.shape[0] - 1
num_atoms_per_system = batch_ptr[1:] - batch_ptr[:-1]
batch_idx = torch.repeat_interleave(
torch.arange(num_systems, dtype=torch.int32, device=device),
num_atoms_per_system,
)
elif batch_ptr is None:
num_systems = batch_idx.max() + 1
num_atoms_per_system = torch.bincount(batch_idx, minlength=num_systems)
batch_ptr = torch.zeros(num_systems + 1, dtype=torch.int32, device=device)
torch.cumsum(num_atoms_per_system, dim=0, out=batch_ptr[1:])
return batch_idx, batch_ptr
def synthesize_cell_for_ss(
positions: torch.Tensor,
cutoff: float,
padding_fraction: float = 0.1,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Build an orthorhombic non-PBC cell around ``positions``.
Used by ``neighbor_list(method=..., cell=None)`` so callers without
a real simulation cell still get a tight bounding box. The
returned positions are shifted so the minimum corner is at the
origin; the cell diagonal is the extent plus ``padding_fraction *
cutoff`` so atoms never sit on the boundary.
Parameters
----------
positions : (N, 3) float
Atomic coordinates (any frame).
cutoff : float
Neighbor cutoff; only used to size the boundary padding.
padding_fraction : float, default 0.1
Padding around the bounding box, expressed as a fraction of
``cutoff``.
Returns
-------
positions : (N, 3) float
Same dtype/device as input; shifted so ``min == 0``.
cell : (1, 3, 3) float
Orthorhombic cell whose diagonal is the (padded) extent.
pbc : (3,) bool
``[False, False, False]`` — synthesized cells are non-periodic.
"""
pbc = torch.zeros(3, dtype=torch.bool, device=positions.device)
if positions.shape[0] == 0:
cell = torch.eye(3, dtype=positions.dtype, device=positions.device).reshape(
1, 3, 3
)
return positions, cell, pbc
pos_min = positions.min(dim=0).values
positions = positions - pos_min
pos_max = positions.max(dim=0).values
cell_lengths = pos_max + padding_fraction * cutoff
cell = torch.diag(cell_lengths).reshape(1, 3, 3)
return positions, cell, pbc
def synthesize_cell_for_batch(
positions: torch.Tensor,
batch_idx: torch.Tensor,
batch_ptr: torch.Tensor,
cutoff: float,
padding_fraction: float = 0.1,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Per-system bounding-box cells around ``positions`` for batched inputs.
Companion to :func:`synthesize_cell_for_ss` for the batched
entry points (``batch_cell_list``, ``batch_cluster_tile_neighbor_list``).
For each system, computes the tight ``(min, max)`` bbox via
``scatter_reduce`` and synthesizes an orthorhombic non-PBC cell
with ``padding_fraction * cutoff`` of slack on each side.
Positions are shifted so each system's minimum corner is at the
origin.
Parameters
----------
positions : (total_atoms, 3) float
Concatenated atomic coordinates.
batch_idx : (total_atoms,) int32
System index per atom.
batch_ptr : (num_systems + 1,) int32
CSR offsets — used only to derive ``num_systems``. Must have length at least 2.
cutoff : float
Neighbor cutoff.
padding_fraction : float, default 0.1
Padding around each system's bbox.
Returns
-------
positions : (total_atoms, 3) float
Same dtype/device as input; shifted per-system so each system's
min corner is at the origin.
cell : (num_systems, 3, 3) float
Per-system orthorhombic cells.
pbc : (num_systems, 3) bool
All False — synthesized cells are non-periodic.
"""
if batch_ptr.shape[0] < 2:
raise ValueError("batch_ptr must have length at least 2")
num_systems = int(batch_ptr.shape[0]) - 1
if positions.shape[0] == 0:
cell = (
torch.eye(3, dtype=positions.dtype, device=positions.device)
.reshape(1, 3, 3)
.expand(num_systems, -1, -1)
.contiguous()
)
pbc = torch.zeros((num_systems, 3), dtype=torch.bool, device=positions.device)
return positions, cell, pbc
expanded_idx = batch_idx.unsqueeze(1).expand_as(positions)
pos_min = torch.full(
(num_systems, 3),
float("inf"),
dtype=positions.dtype,
device=positions.device,
)
pos_min.scatter_reduce_(0, expanded_idx, positions, reduce="amin")
pos_max = torch.full(
(num_systems, 3),
float("-inf"),
dtype=positions.dtype,
device=positions.device,
)
pos_max.scatter_reduce_(0, expanded_idx, positions, reduce="amax")
# TODO: switch to segment_ops once #17 is merged
positions = positions - torch.index_select(pos_min, 0, batch_idx)
cell_lengths = pos_max - pos_min + padding_fraction * cutoff
cell = torch.diag_embed(cell_lengths)
pbc = torch.zeros(
(num_systems, 3),
dtype=torch.bool,
device=positions.device,
)
return positions, cell, pbc
[docs]
def allocate_cell_list(
total_atoms: int,
max_total_cells: int,
neighbor_search_radius: torch.Tensor,
device: torch.device,
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
]:
"""Allocate memory tensors for cell list data structures.
Parameters
----------
total_atoms : int
Total number of atoms across all systems.
max_total_cells : int
Maximum number of cells to allocate.
neighbor_search_radius : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32
Radius of neighboring cells to search in each dimension.
device : torch.device
Device on which to create tensors.
Returns
-------
cells_per_dimension : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32
Number of cells in x, y, z directions (to be filled by build_cell_list).
neighbor_search_radius : torch.Tensor, shape (3,) or (num_systems, 3), dtype=int32
Radius of neighboring cells to search (passed through for convenience).
atom_periodic_shifts : torch.Tensor, shape (total_atoms, 3), dtype=int32
Periodic boundary crossings for each atom (to be filled by build_cell_list).
atom_to_cell_mapping : torch.Tensor, shape (total_atoms, 3), dtype=int32
3D cell coordinates for each atom (to be filled by build_cell_list).
atoms_per_cell_count : torch.Tensor, shape (max_total_cells,), dtype=int32
Number of atoms in each cell (to be filled by build_cell_list).
cell_atom_start_indices : torch.Tensor, shape (max_total_cells,), dtype=int32
Starting index in cell_atom_list for each cell (to be filled by build_cell_list).
cell_atom_list : torch.Tensor, shape (total_atoms,), dtype=int32
Flattened list of atom indices organized by cell (to be filled by build_cell_list).
Notes
-----
This is a pure PyTorch utility function with no warp dependencies. It pre-allocates
all tensors needed for cell list construction, supporting both single-system and
batched operations based on the shape of neighbor_search_radius.
See Also
--------
nvalchemiops.neighbors.cell_list.build_cell_list : Warp launcher that uses these tensors
nvalchemiops.torch.neighbors.cell_list.build_cell_list : High-level PyTorch wrapper
nvalchemiops.torch.neighbors.batch_cell_list.batch_build_cell_list : Batched version
"""
if max_total_cells < 0:
raise ValueError(
f"allocate_cell_list: max_total_cells={max_total_cells} < 0 "
"(cell-count overflow or bad estimate)."
)
# Detect number of systems from neighbor_search_radius shape
is_batched = neighbor_search_radius.ndim == 2
num_systems = neighbor_search_radius.shape[0] if is_batched else 1
cells_per_dimension = torch.zeros(
(3,) if not is_batched else (num_systems, 3),
dtype=torch.int32,
device=device,
)
atom_periodic_shifts = torch.zeros(
(total_atoms, 3), dtype=torch.int32, device=device
)
atom_to_cell_mapping = torch.zeros(
(total_atoms, 3), dtype=torch.int32, device=device
)
atoms_per_cell_count = torch.zeros(
(max_total_cells,), dtype=torch.int32, device=device
)
cell_atom_start_indices = torch.zeros(
(max_total_cells,), dtype=torch.int32, device=device
)
cell_atom_list = torch.zeros((total_atoms,), dtype=torch.int32, device=device)
return (
cells_per_dimension,
neighbor_search_radius,
atom_periodic_shifts,
atom_to_cell_mapping,
atoms_per_cell_count,
cell_atom_start_indices,
cell_atom_list,
)