# 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 single-system cluster-pair tile neighbor list.
Mirrors the ``cell_list`` / ``naive`` wrapper pattern: exposes low-level
component ops (``build_cluster_tile_list``, ``query_cluster_tile``,
``query_cluster_tile_coo``) that fill pre-allocated tensors, plus a high-level
convenience entry point ``cluster_tile_neighbor_list``. All torch-side work
(Morton sort, allocation, ``wp.from_torch`` conversion) lives in this
module; the Warp layer at ``nvalchemiops.neighbors.cluster_tile`` only
sees ``wp.array`` inputs.
Scope: single system, orthorhombic or triclinic PBC, float32, any
``N >= 0`` (the wrapper pads internally to a multiple of TILE_GROUP_SIZE).
"""
from typing import TYPE_CHECKING
import torch
import warp as wp
from nvalchemiops.neighbors.cluster_tile import (
TILE_GROUP_SIZE,
estimate_max_tiles_per_group,
)
from nvalchemiops.neighbors.cluster_tile import (
build_cluster_tile_list as wp_build_cluster_tile_list,
)
from nvalchemiops.neighbors.cluster_tile import (
query_cluster_tile as wp_query_cluster_tile,
)
from nvalchemiops.neighbors.cluster_tile import (
query_cluster_tile_coo as wp_query_cluster_tile_coo,
)
from nvalchemiops.neighbors.neighbor_utils import (
NeighborOverflowError,
estimate_max_neighbors,
)
from nvalchemiops.neighbors.neighbor_utils import (
fill_neighbor_matrix_tail as wp_fill_neighbor_matrix_tail,
)
from nvalchemiops.neighbors.neighbor_utils import (
selective_zero_num_neighbors_single as wp_selective_zero_num_neighbors_single,
)
from nvalchemiops.neighbors.output_args import _has_partial_or_pair_outputs
from nvalchemiops.torch.types import get_wp_dtype
if TYPE_CHECKING:
from nvalchemiops.torch.neighbors._autograd import _NeighborForwardOutput
__all__ = [
"TILE_GROUP_SIZE",
"estimate_cluster_tile_list_sizes",
"allocate_cluster_tile_list",
"build_cluster_tile_list",
"query_cluster_tile",
"query_cluster_tile_coo",
"cluster_tile_neighbor_list",
]
@torch.library.custom_op(
"nvalchemiops::_cluster_tile_fill_neighbor_matrix_tail",
mutates_args=("neighbor_matrix",),
)
def _cluster_tile_fill_neighbor_matrix_tail_op(
num_neighbors: torch.Tensor,
neighbor_matrix: torch.Tensor,
n_rows: int,
max_neighbors: int,
fill_value: int,
) -> None:
if max_neighbors <= 0:
return
wp_fill_neighbor_matrix_tail(
wp.from_torch(
num_neighbors, dtype=wp.int32, requires_grad=False, return_ctype=True
),
int(n_rows),
int(max_neighbors),
int(fill_value),
wp.from_torch(
neighbor_matrix, dtype=wp.int32, requires_grad=False, return_ctype=True
),
str(neighbor_matrix.device),
)
@_cluster_tile_fill_neighbor_matrix_tail_op.register_fake
def _(
num_neighbors: torch.Tensor,
neighbor_matrix: torch.Tensor,
n_rows: int,
max_neighbors: int,
fill_value: int,
) -> None:
return None
# =============================================================================
# Sizing + allocation helpers (torch-side, not ``custom_op``-wrapped)
# =============================================================================
[docs]
def estimate_cluster_tile_list_sizes(
total_atoms: int,
max_tiles_per_group: int = 256,
) -> tuple[int, int, int, int]:
"""Estimate allocation sizes for the tile neighbor list state.
Any ``total_atoms >= 0`` is accepted; internally the state is sized
at ``n_padded = ceil(total_atoms / TILE_GROUP_SIZE) * TILE_GROUP_SIZE`` so
the kernels see a 32-aligned layout. Padding slots receive a
sentinel max Morton code (see ``_compute_morton_kernel``) so they
sort to the end and are dropped by the convert/coo kernels'
``i_sorted < natom`` filter.
Parameters
----------
total_atoms : int
Real atom count.
max_tiles_per_group : int, default 256
Upper bound on neighbor groups per row_group (dense-cutoff cap).
Returns
-------
n_padded : int
Padded atom count = ``ceil(total_atoms / TILE_GROUP_SIZE) * TILE_GROUP_SIZE``.
Used to size positions / sorted SoA / sorted_atom_index scratch arrays.
ngroup : int
Number of 32-atom groups: ``n_padded // TILE_GROUP_SIZE``.
ngroup_padded : int
Group-array pad length for in-bounds ``wp.tile_load`` at any
TILE-aligned offset. Multiple of ``TILE_GROUP_SIZE``; at least one
TILE slack over ``ngroup``.
max_tiles : int
Upper bound on the tile-pair list size.
"""
if total_atoms < 0:
raise ValueError(f"total_atoms must be >= 0; got {total_atoms}")
n_padded = (
(total_atoms + TILE_GROUP_SIZE - 1) // TILE_GROUP_SIZE
) * TILE_GROUP_SIZE
if n_padded == 0:
n_padded = TILE_GROUP_SIZE # always reserve at least one tile
ngroup = n_padded // TILE_GROUP_SIZE
ngroup_padded = (
(ngroup + TILE_GROUP_SIZE - 1) // TILE_GROUP_SIZE
) * TILE_GROUP_SIZE
if ngroup_padded == ngroup:
ngroup_padded = ngroup + TILE_GROUP_SIZE
max_tiles = ngroup * min(ngroup, max_tiles_per_group)
return n_padded, ngroup, ngroup_padded, max_tiles
[docs]
def allocate_cluster_tile_list(
total_atoms: int,
device: torch.device,
dtype: torch.dtype = torch.float32,
max_tiles_per_group: int = 256,
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
]:
"""Allocate all state tensors consumed by ``build_cluster_tile_list``.
Sizes each buffer according to the padded atom count and group count
returned by
:func:`nvalchemiops.torch.neighbors.cluster_tile.estimate_cluster_tile_list_sizes`.
Parameters
----------
total_atoms : int
Real atom count. Any value ``>= 0`` is accepted.
device : torch.device
Target device for all allocated tensors.
dtype : torch.dtype, optional
Floating-point dtype for position and bounding-box arrays.
Default is ``torch.float32``.
max_tiles_per_group : int, optional
Upper bound on neighbor groups per row_group used to size the tile
pair buffer. Default is 256.
Returns
-------
sorted_atom_index : torch.Tensor, shape (n_padded,), dtype=int32
Permutation that maps sorted rank to original atom index.
morton_codes : torch.Tensor, shape (n_padded,), dtype=int32
30-bit Morton codes for each atom in sorted order; padding slots
carry a sentinel value ``0x40000000``.
sorted_pos_x : torch.Tensor, shape (n_padded,), dtype=dtype
x-coordinates in Morton-sorted order.
sorted_pos_y : torch.Tensor, shape (n_padded,), dtype=dtype
y-coordinates in Morton-sorted order.
sorted_pos_z : torch.Tensor, shape (n_padded,), dtype=dtype
z-coordinates in Morton-sorted order.
group_ctr_x : torch.Tensor, shape (ngroup_padded,), dtype=dtype
x-component of each group bounding-box centre.
group_ctr_y : torch.Tensor, shape (ngroup_padded,), dtype=dtype
y-component of each group bounding-box centre.
group_ctr_z : torch.Tensor, shape (ngroup_padded,), dtype=dtype
z-component of each group bounding-box centre.
group_ext_x : torch.Tensor, shape (ngroup_padded,), dtype=dtype
x half-extent of each group bounding box.
group_ext_y : torch.Tensor, shape (ngroup_padded,), dtype=dtype
y half-extent of each group bounding box.
group_ext_z : torch.Tensor, shape (ngroup_padded,), dtype=dtype
z half-extent of each group bounding box.
num_tiles : torch.Tensor, shape (1,), dtype=int32
Atomic counter holding the number of emitted tile pairs.
tile_row_group : torch.Tensor, shape (max_tiles,), dtype=int32
Row group index for each emitted tile pair.
tile_col_group : torch.Tensor, shape (max_tiles,), dtype=int32
Column group index for each emitted tile pair.
See Also
--------
:func:`nvalchemiops.torch.neighbors.cluster_tile.estimate_cluster_tile_list_sizes` :
Returns the sizing integers used here.
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list` :
Fills the allocated buffers.
"""
n_padded, ngroup, ngroup_padded, max_tiles = estimate_cluster_tile_list_sizes(
total_atoms,
max_tiles_per_group=max_tiles_per_group,
)
# Scratch arrays sized at the padded layout so non-32-aligned
# ``total_atoms`` is handled inside ``_build_cluster_tile_list_op``.
# Padding slots are populated with sentinel Morton codes (see
# ``_compute_morton_kernel``) and dropped by the convert/coo
# kernels' ``i_sorted < natom`` filter.
sorted_atom_index = torch.empty(n_padded, dtype=torch.int32, device=device)
morton_codes = torch.empty(n_padded, dtype=torch.int32, device=device)
sorted_pos_x = torch.empty(n_padded, dtype=dtype, device=device)
sorted_pos_y = torch.empty(n_padded, dtype=dtype, device=device)
sorted_pos_z = torch.empty(n_padded, dtype=dtype, device=device)
group_ctr_x = torch.zeros(ngroup_padded, dtype=dtype, device=device)
group_ctr_y = torch.zeros(ngroup_padded, dtype=dtype, device=device)
group_ctr_z = torch.zeros(ngroup_padded, dtype=dtype, device=device)
group_ext_x = torch.zeros(ngroup_padded, dtype=dtype, device=device)
group_ext_y = torch.zeros(ngroup_padded, dtype=dtype, device=device)
group_ext_z = torch.zeros(ngroup_padded, dtype=dtype, device=device)
num_tiles = torch.zeros(1, dtype=torch.int32, device=device)
tile_row_group = torch.zeros(max_tiles, dtype=torch.int32, device=device)
tile_col_group = torch.zeros(max_tiles, dtype=torch.int32, device=device)
return (
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
)
# =============================================================================
# Internal helpers
# =============================================================================
def _cell_invcell_from_cell(
cell: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Normalize a ``(3, 3)`` or ``(1, 3, 3)`` cell to ``(cell_3x3,
inv_cell_3x3)`` torch tensors for the Warp kernels.
Accepts any non-degenerate cell (orthorhombic or triclinic).
The downstream Warp kernels use ``_wrap_triclinic`` which is a
strict superset of the old orthorhombic-only path.
"""
if cell.ndim == 3:
if cell.shape[0] != 1:
raise ValueError(
f"single-system cluster_tile expects (1, 3, 3) cell; got {tuple(cell.shape)}"
)
cell_mat = cell[0]
elif cell.ndim == 2:
if cell.shape != (3, 3):
raise ValueError(
f"cell must be (3, 3) or (1, 3, 3); got {tuple(cell.shape)}"
)
cell_mat = cell
else:
raise ValueError(f"cell must be (3, 3) or (1, 3, 3); got {tuple(cell.shape)}")
cell_mat = cell_mat.contiguous()
inv_cell_mat = torch.linalg.inv(cell_mat).contiguous()
return cell_mat, inv_cell_mat
def _cell_volume(cell: torch.Tensor) -> float:
"""Return ``abs(det(cell))`` for a ``(3, 3)`` or ``(1, 3, 3)`` cell."""
cell_mat = cell[0] if cell.ndim == 3 else cell
return float(torch.linalg.det(cell_mat.to(torch.float64)).abs().item())
def _mat33f_from_torch(mat: torch.Tensor):
"""Zero-copy view a ``(1, 3, 3)`` or ``(3, 3)`` torch tensor as a
``wp.array(dtype=wp.mat33f, shape=(1,))``.
These Warp kernels read the cell / inv_cell as length-1
``wp.array(dtype=wp.mat33f)`` and dereference ``cell[0]`` inside the
kernel body, matching the cell_list pattern. This avoids a per-call
host sync.
"""
if mat.ndim == 2:
mat = mat.unsqueeze(0)
return wp.from_torch(
mat.detach().contiguous().to(torch.float32),
dtype=wp.mat33f,
requires_grad=False,
return_ctype=True,
)
# =============================================================================
# Component ops (torch.library.custom_op wrappers)
# =============================================================================
@torch.library.custom_op(
"nvalchemiops::_build_cluster_tile_list",
mutates_args=(
"sorted_atom_index",
"morton_codes",
"sorted_pos_x",
"sorted_pos_y",
"sorted_pos_z",
"group_ctr_x",
"group_ctr_y",
"group_ctr_z",
"group_ext_x",
"group_ext_y",
"group_ext_z",
"num_tiles",
"tile_row_group",
"tile_col_group",
),
)
def _build_cluster_tile_list_op(
positions: torch.Tensor,
cutoff: float,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
morton_codes: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
group_ctr_x: torch.Tensor,
group_ctr_y: torch.Tensor,
group_ctr_z: torch.Tensor,
group_ext_x: torch.Tensor,
group_ext_y: torch.Tensor,
group_ext_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
) -> None:
"""Compute Morton codes + argsort + SoA gather in torch, then run
bbox reduction + tile enumeration on the warp side.
Triclinic-safe via the (cell, inv_cell) pair; orthorhombic cells
just have a diagonal inv_cell and produce the same result as the
pre-triclinic implementation.
See Also
--------
nvalchemiops.neighbors.cluster_tile.build_cluster_tile_list : warp launcher
"""
N = positions.shape[0]
if N == 0:
return
device = positions.device
wp_device = str(device)
wp_dtype = get_wp_dtype(positions.dtype)
num_tiles.zero_()
wp_cell = _mat33f_from_torch(cell)
wp_inv_cell = _mat33f_from_torch(inv_cell)
# ---- Steps 1-3: Morton codes + argsort + gather (torch-side) ----
# Fractional coords via inv_cell; wrap into [0, 1) so the bucket
# produces a deterministic 30-bit Morton code. Orthorhombic cells
# collapse this back to the cheaper diagonal multiply.
#
# Pad to ``n_padded = ceil(N / TILE_GROUP_SIZE) * TILE_GROUP_SIZE`` so the
# kernels see a 32-aligned layout. Padding slots get a sentinel
# max 30-bit Morton code (0x3FFFFFFF) so radix sort places them at
# the end of the sorted layout, where the convert/coo kernels'
# ``i_sorted < natom`` filter naturally drops any pair involving
# them. Padding positions copy the last real atom (any in-cell
# position is safe) so the SoA gather has well-defined reads.
n_padded = int(sorted_atom_index.shape[0])
if N < n_padded:
padded_positions = torch.empty(
(n_padded, 3),
dtype=positions.dtype,
device=positions.device,
)
padded_positions[:N].copy_(positions)
padded_positions[N:].copy_(positions[-1:].expand(n_padded - N, 3))
else:
padded_positions = positions
frac = padded_positions @ inv_cell.T
frac = frac - torch.floor(frac)
bucket = (frac * 1024.0).clamp(0, 1023).to(torch.int32)
ix, iy, iz = bucket.unbind(dim=-1)
def _spread(x: torch.Tensor) -> torch.Tensor:
x = x & 0x3FF
x = (x | (x << 16)) & 0x030000FF
x = (x | (x << 8)) & 0x0300F00F
x = (x | (x << 4)) & 0x030C30C3
x = (x | (x << 2)) & 0x09249249
return x
codes32 = (_spread(iz) << 2) | (_spread(iy) << 1) | _spread(ix)
if N < n_padded:
# Sentinel: one bit above any real 30-bit Morton code. Real
# codes saturate at 0x3FFFFFFF; 0x40000000 sorts after all of
# them. See ``_compute_morton_kernel`` for the matching
# warp-side value.
codes32[N:] = 0x40000000
morton_codes.copy_(codes32)
perm = torch.argsort(codes32)
sorted_atom_index.copy_(perm.to(torch.int32))
sorted_pos = padded_positions[perm].contiguous()
sorted_pos_x.copy_(sorted_pos[:, 0].contiguous())
sorted_pos_y.copy_(sorted_pos[:, 1].contiguous())
sorted_pos_z.copy_(sorted_pos[:, 2].contiguous())
# ---- Step 4: rank2group + group2tile (warp launcher) ----
wp_sorted_pos_x = wp.from_torch(
sorted_pos_x,
dtype=wp_dtype,
return_ctype=True,
)
wp_sorted_pos_y = wp.from_torch(
sorted_pos_y,
dtype=wp_dtype,
return_ctype=True,
)
wp_sorted_pos_z = wp.from_torch(
sorted_pos_z,
dtype=wp_dtype,
return_ctype=True,
)
wp_group_ctr_x = wp.from_torch(group_ctr_x, dtype=wp_dtype, return_ctype=True)
wp_group_ctr_y = wp.from_torch(group_ctr_y, dtype=wp_dtype, return_ctype=True)
wp_group_ctr_z = wp.from_torch(group_ctr_z, dtype=wp_dtype, return_ctype=True)
wp_group_ext_x = wp.from_torch(group_ext_x, dtype=wp_dtype, return_ctype=True)
wp_group_ext_y = wp.from_torch(group_ext_y, dtype=wp_dtype, return_ctype=True)
wp_group_ext_z = wp.from_torch(group_ext_z, dtype=wp_dtype, return_ctype=True)
wp_num_tiles = wp.from_torch(num_tiles, dtype=wp.int32, return_ctype=True)
wp_tile_row_group = wp.from_torch(tile_row_group, dtype=wp.int32, return_ctype=True)
wp_tile_col_group = wp.from_torch(
tile_col_group,
dtype=wp.int32,
return_ctype=True,
)
wp_build_cluster_tile_list(
sorted_pos_x=wp_sorted_pos_x,
sorted_pos_y=wp_sorted_pos_y,
sorted_pos_z=wp_sorted_pos_z,
cell=wp_cell,
inv_cell=wp_inv_cell,
cutoff=float(cutoff),
num_tiles=wp_num_tiles,
tile_row_group=wp_tile_row_group,
tile_col_group=wp_tile_col_group,
wp_dtype=wp_dtype,
device=wp_device,
group_ctr_x_buffer=wp_group_ctr_x,
group_ctr_y_buffer=wp_group_ctr_y,
group_ctr_z_buffer=wp_group_ctr_z,
group_ext_x_buffer=wp_group_ext_x,
group_ext_y_buffer=wp_group_ext_y,
group_ext_z_buffer=wp_group_ext_z,
)
@_build_cluster_tile_list_op.register_fake
def _(
positions: torch.Tensor,
cutoff: float,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
morton_codes: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
group_ctr_x: torch.Tensor,
group_ctr_y: torch.Tensor,
group_ctr_z: torch.Tensor,
group_ext_x: torch.Tensor,
group_ext_y: torch.Tensor,
group_ext_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
) -> None:
return None
[docs]
def build_cluster_tile_list(
positions: torch.Tensor,
cutoff: float,
cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
morton_codes: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
group_ctr_x: torch.Tensor,
group_ctr_y: torch.Tensor,
group_ctr_z: torch.Tensor,
group_ext_x: torch.Tensor,
group_ext_y: torch.Tensor,
group_ext_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
) -> None:
"""Build cluster-tile neighbor list state into pre-allocated tensors.
Normalizes ``cell`` to a ``(3, 3)`` matrix and computes ``inv_cell``,
then runs Morton sort (torch) + Warp bounding-box reduction + Warp
tile-pair enumeration. Triclinic cells are supported. All output
tensors are filled in place.
Parameters
----------
positions : torch.Tensor, shape (N, 3), dtype=float32
Atomic coordinates wrapped to the primary cell. Non-32-aligned ``N``
is padded internally to ``ceil(N / TILE_GROUP_SIZE) * TILE_GROUP_SIZE``.
cutoff : float
Cutoff distance in Cartesian units used for tile-pair pruning.
cell : torch.Tensor, shape (1, 3, 3) or (3, 3), dtype=float32
Any non-degenerate cell (orthorhombic or triclinic).
sorted_atom_index : torch.Tensor, shape (n_padded,), dtype=int32
Output permutation mapping sorted rank to original atom index.
Modified in-place.
morton_codes : torch.Tensor, shape (n_padded,), dtype=int32
Output scratch buffer for 30-bit Morton codes. Modified in-place.
sorted_pos_x : torch.Tensor, shape (n_padded,), dtype=float32
Output x-coordinates in Morton-sorted order. Modified in-place.
sorted_pos_y : torch.Tensor, shape (n_padded,), dtype=float32
Output y-coordinates in Morton-sorted order. Modified in-place.
sorted_pos_z : torch.Tensor, shape (n_padded,), dtype=float32
Output z-coordinates in Morton-sorted order. Modified in-place.
group_ctr_x : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output x-component of group bounding-box centres. Modified in-place.
group_ctr_y : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output y-component of group bounding-box centres. Modified in-place.
group_ctr_z : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output z-component of group bounding-box centres. Modified in-place.
group_ext_x : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output x half-extents of group bounding boxes. Modified in-place.
group_ext_y : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output y half-extents of group bounding boxes. Modified in-place.
group_ext_z : torch.Tensor, shape (ngroup_padded,), dtype=float32
Output z half-extents of group bounding boxes. Modified in-place.
num_tiles : torch.Tensor, shape (1,), dtype=int32
Output atomic counter holding the number of emitted tile pairs.
Reset to zero internally before use. Modified in-place.
tile_row_group : torch.Tensor, shape (max_tiles,), dtype=int32
Output row group index for each emitted tile pair. Modified in-place.
tile_col_group : torch.Tensor, shape (max_tiles,), dtype=int32
Output column group index for each emitted tile pair. Modified in-place.
See Also
--------
:func:`nvalchemiops.torch.neighbors.cluster_tile.allocate_cluster_tile_list` :
Allocates all buffers consumed by this function.
:func:`nvalchemiops.neighbors.cluster_tile.build_cluster_tile_list` :
Warp-level launcher called internally.
"""
if positions.dtype != torch.float32:
raise TypeError("positions must be float32")
cell_mat, inv_cell_mat = _cell_invcell_from_cell(cell)
cell_mat = cell_mat.to(positions.dtype)
inv_cell_mat = inv_cell_mat.to(positions.dtype)
_build_cluster_tile_list_op(
positions,
cutoff,
cell_mat,
inv_cell_mat,
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
)
@torch.library.custom_op(
"nvalchemiops::_query_cluster_tile",
mutates_args=("neighbor_matrix", "neighbor_matrix_shifts", "num_neighbors"),
)
def _query_cluster_tile_op(
cutoff: float,
natom: int,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
neighbor_matrix: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
num_neighbors: torch.Tensor,
n_tiles: int,
) -> None:
# ``n_tiles`` (host-synced emitted-tile count from the caller) sets the
# launch dimension so we don't launch over the full allocated tile
# buffer. The kernel still guards ``tile >= num_tiles[0]`` defensively.
device = sorted_pos_x.device
wp_device = str(device)
wp_dtype = get_wp_dtype(sorted_pos_x.dtype)
wp_cell = _mat33f_from_torch(cell)
wp_inv_cell = _mat33f_from_torch(inv_cell)
wp_query_cluster_tile(
sorted_atom_index=wp.from_torch(
sorted_atom_index, dtype=wp.int32, return_ctype=True
),
sorted_pos_x=wp.from_torch(sorted_pos_x, dtype=wp_dtype, return_ctype=True),
sorted_pos_y=wp.from_torch(sorted_pos_y, dtype=wp_dtype, return_ctype=True),
sorted_pos_z=wp.from_torch(sorted_pos_z, dtype=wp_dtype, return_ctype=True),
num_tiles=wp.from_torch(num_tiles, dtype=wp.int32, return_ctype=True),
tile_row_group=wp.from_torch(tile_row_group, dtype=wp.int32, return_ctype=True),
tile_col_group=wp.from_torch(
tile_col_group,
dtype=wp.int32,
return_ctype=True,
),
cell=wp_cell,
inv_cell=wp_inv_cell,
cutoff=float(cutoff),
natom=int(natom),
neighbor_matrix=wp.from_torch(
neighbor_matrix,
dtype=wp.int32,
return_ctype=True,
),
num_neighbors=wp.from_torch(
num_neighbors,
dtype=wp.int32,
return_ctype=True,
),
neighbor_matrix_shifts=wp.from_torch(
neighbor_matrix_shifts,
dtype=wp.int32,
return_ctype=True,
),
wp_dtype=wp_dtype,
device=wp_device,
n_tiles=int(n_tiles),
)
@_query_cluster_tile_op.register_fake
def _(
cutoff: float,
natom: int,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
neighbor_matrix: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
num_neighbors: torch.Tensor,
n_tiles: int,
) -> None:
return None
[docs]
def query_cluster_tile(
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
cell: torch.Tensor,
cutoff: float,
natom: int,
neighbor_matrix: torch.Tensor,
num_neighbors: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
*,
cutoff2: float | None = None,
neighbor_matrix2: torch.Tensor | None = None,
num_neighbors2: torch.Tensor | None = None,
neighbor_matrix_shifts2: torch.Tensor | None = None,
rebuild_flags: torch.Tensor | None = None,
return_vectors: bool = False,
return_distances: bool = False,
pair_fn: wp.Function | 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:
"""Convert the tile pair list to neighbor_matrix form in place.
Cluster-tile does not support partial neighbor lists; there is no
``target_indices`` kwarg. Use
:func:`nvalchemiops.torch.neighbors.cell_list.cell_list` or
:func:`nvalchemiops.torch.neighbors.naive.naive_neighbor_list` for
partial neighbor lists.
Parameters
----------
sorted_atom_index : torch.Tensor, shape (n_padded,), dtype=int32
Permutation mapping sorted rank to original atom index; output of
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list`.
sorted_pos_x : torch.Tensor, shape (n_padded,), dtype=float32
x-coordinates in Morton-sorted order.
sorted_pos_y : torch.Tensor, shape (n_padded,), dtype=float32
y-coordinates in Morton-sorted order.
sorted_pos_z : torch.Tensor, shape (n_padded,), dtype=float32
z-coordinates in Morton-sorted order.
num_tiles : torch.Tensor, shape (1,), dtype=int32
Device-side tile counter written by
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list`.
tile_row_group : torch.Tensor, shape (max_tiles,), dtype=int32
Row group indices of emitted tile pairs.
tile_col_group : torch.Tensor, shape (max_tiles,), dtype=int32
Column group indices of emitted tile pairs.
cell : torch.Tensor, shape (1, 3, 3) or (3, 3), dtype=float32
Simulation cell matrix (orthorhombic or triclinic).
cutoff : float
Neighbor search cutoff radius in Cartesian units.
natom : int
True atom count (before padding).
neighbor_matrix : torch.Tensor, shape (natom, max_neighbors), dtype=int32
Output neighbor indices. Modified in-place.
num_neighbors : torch.Tensor, shape (natom,), dtype=int32
Output per-atom neighbor counts. Modified in-place.
neighbor_matrix_shifts : torch.Tensor, shape (natom, max_neighbors, 3), dtype=int32
Output per-pair periodic image shift vectors. Modified in-place.
cutoff2 : float, optional
Second cutoff for a dual-cutoff query; fills ``neighbor_matrix2`` /
``num_neighbors2`` / ``neighbor_matrix_shifts2`` when provided.
neighbor_matrix2 : torch.Tensor, shape (natom, max_neighbors), dtype=int32, optional
Second-cutoff output neighbor indices. Modified in-place.
num_neighbors2 : torch.Tensor, shape (natom,), dtype=int32, optional
Second-cutoff per-atom neighbor counts. Modified in-place.
neighbor_matrix_shifts2 : torch.Tensor, shape (natom, max_neighbors, 3), dtype=int32, optional
Second-cutoff per-pair shift vectors. Modified in-place.
rebuild_flags : torch.Tensor, shape (1,), dtype=bool, optional
When ``False``, all output buffers are left unchanged and the call
returns early.
return_vectors : bool, optional
Write per-pair Cartesian displacement vectors to ``neighbor_vectors``.
Default is ``False``.
return_distances : bool, optional
Write per-pair scalar distances to ``neighbor_distances``.
Default is ``False``.
pair_fn : wp.Function, optional
Module-scope Warp ``@wp.func`` of signature
``(r_ij, distance, pair_params, i, j) -> (energy, force)``.
pair_params : torch.Tensor, shape (natom, num_parameters), optional
Per-atom pair-function parameters; required with ``pair_fn``.
neighbor_vectors : torch.Tensor, shape (natom, max_neighbors, 3), optional
Output buffer for per-pair displacement vectors. Modified in-place.
neighbor_distances : torch.Tensor, shape (natom, max_neighbors), optional
Output buffer for per-pair scalar distances. Modified in-place.
pair_energies : torch.Tensor, shape (natom, max_neighbors), optional
Output buffer for per-pair energies; required with ``pair_fn``.
Modified in-place.
pair_forces : torch.Tensor, shape (natom, max_neighbors, 3), optional
Output buffer for per-pair forces; required with ``pair_fn``.
Modified in-place.
See Also
--------
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list` :
Produces the tile state consumed by this function.
:func:`nvalchemiops.torch.neighbors.cluster_tile.query_cluster_tile_coo` :
COO-format alternative that emits a flat pair list instead.
"""
cell_mat, inv_cell_mat = _cell_invcell_from_cell(cell)
cell_mat = cell_mat.to(sorted_pos_x.dtype)
inv_cell_mat = inv_cell_mat.to(sorted_pos_x.dtype)
# Host-sync the emitted-tile count: this tightens the launch dimension
# to the real tiles (vs the full buffer) and lets us raise on tile-buffer
# overflow instead of silently dropping tiles. This ``.item()`` makes the
# matrix path non-CUDA-graph/torch.compile-capturable by design.
tile_capacity = int(tile_row_group.shape[0])
if torch.compiler.is_compiling():
n_tiles = tile_capacity
else:
n_tiles = int(num_tiles.item())
if n_tiles > tile_capacity:
raise NeighborOverflowError(tile_capacity, n_tiles)
feature_path = (
cutoff2 is not None
or rebuild_flags is not None
or _has_partial_or_pair_outputs(
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 feature_path:
if (
pair_fn is None
and pair_params is None
and pair_energies is None
and pair_forces is None
):
return _query_cluster_tile_optional_no_pair_fn_op(
cell_mat,
inv_cell_mat,
natom,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
neighbor_matrix2,
num_neighbors2,
neighbor_matrix_shifts2,
rebuild_flags,
neighbor_vectors,
neighbor_distances,
n_tiles,
cutoff2,
return_vectors,
return_distances,
)
if torch.compiler.is_compiling():
raise NotImplementedError(
"cluster_tile pair_fn outputs are eager-only because callable Warp "
"functions cannot cross a torch.library.custom_op schema boundary.",
)
# Pair outputs are exercised - bypass the torch custom op because it
# cannot carry a callable ``pair_fn``.
_query_cluster_tile_optional(
cell_mat,
inv_cell_mat,
natom,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
cutoff2=cutoff2,
neighbor_matrix2=neighbor_matrix2,
num_neighbors2=num_neighbors2,
neighbor_matrix_shifts2=neighbor_matrix_shifts2,
rebuild_flags=rebuild_flags,
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,
n_tiles=n_tiles,
)
return
_query_cluster_tile_op(
cutoff,
natom,
cell_mat,
inv_cell_mat,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
neighbor_matrix,
neighbor_matrix_shifts,
num_neighbors,
n_tiles,
)
@torch.library.custom_op(
"nvalchemiops::_query_cluster_tile_optional_no_pair_fn",
mutates_args=(
"neighbor_matrix",
"num_neighbors",
"neighbor_matrix_shifts",
"neighbor_matrix2",
"num_neighbors2",
"neighbor_matrix_shifts2",
"neighbor_vectors",
"neighbor_distances",
),
)
def _query_cluster_tile_optional_no_pair_fn_op(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
neighbor_matrix: torch.Tensor,
num_neighbors: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
neighbor_matrix2: torch.Tensor | None,
num_neighbors2: torch.Tensor | None,
neighbor_matrix_shifts2: torch.Tensor | None,
rebuild_flags: torch.Tensor | None,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
n_tiles: int,
cutoff2: float | None,
return_vectors: bool,
return_distances: bool,
) -> None:
_query_cluster_tile_optional(
cell_mat,
inv_cell_mat,
natom,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
n_tiles=n_tiles,
cutoff2=cutoff2,
neighbor_matrix2=neighbor_matrix2,
num_neighbors2=num_neighbors2,
neighbor_matrix_shifts2=neighbor_matrix_shifts2,
rebuild_flags=rebuild_flags,
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,
)
@_query_cluster_tile_optional_no_pair_fn_op.register_fake
def _(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
neighbor_matrix: torch.Tensor,
num_neighbors: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
neighbor_matrix2: torch.Tensor | None,
num_neighbors2: torch.Tensor | None,
neighbor_matrix_shifts2: torch.Tensor | None,
rebuild_flags: torch.Tensor | None,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
n_tiles: int,
cutoff2: float | None,
return_vectors: bool,
return_distances: bool,
) -> None:
return None
def _query_cluster_tile_optional(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
neighbor_matrix: torch.Tensor,
num_neighbors: torch.Tensor,
neighbor_matrix_shifts: torch.Tensor,
*,
n_tiles: int,
cutoff2: float | None,
neighbor_matrix2: torch.Tensor | None,
num_neighbors2: torch.Tensor | None,
neighbor_matrix_shifts2: torch.Tensor | None,
rebuild_flags: 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:
"""Pair-output path: bypass the torch custom op + call warp directly.
Mirrors :func:`nvalchemiops.torch.neighbors.cell_list._query_cell_list_optional`.
Torch custom ops cannot carry a callable ``pair_fn`` across their
schema boundary, so the pair-output path drops down to the warp
launcher with ``wp.from_torch``-wrapped tensors directly. No
host-side ``num_tiles.item()`` sync: the warp kernel guards
per-tile via the device-side ``num_tiles`` array.
"""
device = sorted_pos_x.device
wp_device = str(device)
wp_dtype = get_wp_dtype(sorted_pos_x.dtype)
wp_vec_dtype = wp.vec3f
wp_cell = _mat33f_from_torch(cell_mat)
wp_inv_cell = _mat33f_from_torch(inv_cell_mat)
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
)
wp_query_cluster_tile(
sorted_atom_index=wp.from_torch(
sorted_atom_index, dtype=wp.int32, requires_grad=False
),
sorted_pos_x=wp.from_torch(sorted_pos_x, dtype=wp_dtype, requires_grad=False),
sorted_pos_y=wp.from_torch(sorted_pos_y, dtype=wp_dtype, requires_grad=False),
sorted_pos_z=wp.from_torch(sorted_pos_z, dtype=wp_dtype, requires_grad=False),
num_tiles=wp.from_torch(num_tiles, dtype=wp.int32, requires_grad=False),
tile_row_group=wp.from_torch(
tile_row_group, dtype=wp.int32, requires_grad=False
),
tile_col_group=wp.from_torch(
tile_col_group, dtype=wp.int32, requires_grad=False
),
cell=wp_cell,
inv_cell=wp_inv_cell,
cutoff=float(cutoff),
natom=int(natom),
neighbor_matrix=wp.from_torch(
neighbor_matrix, dtype=wp.int32, requires_grad=False
),
num_neighbors=wp.from_torch(num_neighbors, dtype=wp.int32, requires_grad=False),
neighbor_matrix_shifts=wp.from_torch(
neighbor_matrix_shifts, dtype=wp.int32, requires_grad=False
),
wp_dtype=wp_dtype,
device=wp_device,
n_tiles=int(n_tiles),
cutoff2=cutoff2,
neighbor_matrix2=(
wp.from_torch(neighbor_matrix2, dtype=wp.int32, requires_grad=False)
if neighbor_matrix2 is not None
else None
),
num_neighbors2=(
wp.from_torch(num_neighbors2, dtype=wp.int32, requires_grad=False)
if num_neighbors2 is not None
else None
),
neighbor_matrix_shifts2=(
wp.from_torch(neighbor_matrix_shifts2, dtype=wp.int32, requires_grad=False)
if neighbor_matrix_shifts2 is not None
else None
),
rebuild_flags=(
wp.from_torch(rebuild_flags, dtype=wp.bool, requires_grad=False)
if rebuild_flags is not None
else None
),
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,
)
@torch.library.custom_op(
"nvalchemiops::_query_cluster_tile_coo",
mutates_args=("pair_counter", "coo_list", "coo_shifts"),
)
def _query_cluster_tile_coo_op(
cutoff: float,
natom: int,
max_pairs: int,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
n_tiles: int,
) -> None:
# ``n_tiles`` (host-synced emitted-tile count from the caller) tightens
# the launch dimension; the kernel still guards per-tile defensively.
device = sorted_pos_x.device
wp_device = str(device)
wp_dtype = get_wp_dtype(sorted_pos_x.dtype)
wp_cell = _mat33f_from_torch(cell)
wp_inv_cell = _mat33f_from_torch(inv_cell)
wp_query_cluster_tile_coo(
sorted_atom_index=wp.from_torch(
sorted_atom_index, dtype=wp.int32, return_ctype=True
),
sorted_pos_x=wp.from_torch(sorted_pos_x, dtype=wp_dtype, return_ctype=True),
sorted_pos_y=wp.from_torch(sorted_pos_y, dtype=wp_dtype, return_ctype=True),
sorted_pos_z=wp.from_torch(sorted_pos_z, dtype=wp_dtype, return_ctype=True),
num_tiles=wp.from_torch(num_tiles, dtype=wp.int32, return_ctype=True),
tile_row_group=wp.from_torch(tile_row_group, dtype=wp.int32, return_ctype=True),
tile_col_group=wp.from_torch(
tile_col_group,
dtype=wp.int32,
return_ctype=True,
),
cell=wp_cell,
inv_cell=wp_inv_cell,
cutoff=float(cutoff),
natom=int(natom),
max_pairs=int(max_pairs),
pair_counter=wp.from_torch(
pair_counter,
dtype=wp.int32,
return_ctype=True,
),
coo_list=wp.from_torch(coo_list, dtype=wp.int32, return_ctype=True),
coo_shifts=wp.from_torch(coo_shifts, dtype=wp.int32, return_ctype=True),
wp_dtype=wp_dtype,
device=wp_device,
n_tiles=int(n_tiles),
)
@_query_cluster_tile_coo_op.register_fake
def _(
cutoff: float,
natom: int,
max_pairs: int,
cell: torch.Tensor,
inv_cell: torch.Tensor,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
n_tiles: int,
) -> None:
return None
@torch.library.custom_op(
"nvalchemiops::_query_cluster_tile_coo_optional_no_pair_fn",
mutates_args=(
"pair_counter",
"coo_list",
"coo_shifts",
"neighbor_vectors",
"neighbor_distances",
),
)
def _query_cluster_tile_coo_optional_no_pair_fn_op(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
max_pairs: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
n_tiles: int,
return_vectors: bool,
return_distances: bool,
) -> None:
_query_cluster_tile_coo_optional(
cell_mat,
inv_cell_mat,
natom,
max_pairs,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
pair_counter,
coo_list,
coo_shifts,
n_tiles=n_tiles,
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,
)
@_query_cluster_tile_coo_optional_no_pair_fn_op.register_fake
def _(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
max_pairs: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
neighbor_vectors: torch.Tensor | None,
neighbor_distances: torch.Tensor | None,
n_tiles: int,
return_vectors: bool,
return_distances: bool,
) -> None:
return None
def _query_cluster_tile_coo_optional(
cell_mat: torch.Tensor,
inv_cell_mat: torch.Tensor,
natom: int,
max_pairs: int,
cutoff: float,
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
*,
n_tiles: int,
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:
"""Pair-output COO path: bypass the torch custom op.
``n_tiles`` (host-synced emitted-tile count) sets the launch dimension;
the kernel still guards per-tile defensively.
"""
device = sorted_pos_x.device
wp_device = str(device)
wp_dtype = get_wp_dtype(sorted_pos_x.dtype)
wp_vec_dtype = wp.vec3f
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
)
wp_query_cluster_tile_coo(
sorted_atom_index=wp.from_torch(
sorted_atom_index, dtype=wp.int32, requires_grad=False
),
sorted_pos_x=wp.from_torch(sorted_pos_x, dtype=wp_dtype, requires_grad=False),
sorted_pos_y=wp.from_torch(sorted_pos_y, dtype=wp_dtype, requires_grad=False),
sorted_pos_z=wp.from_torch(sorted_pos_z, dtype=wp_dtype, requires_grad=False),
num_tiles=wp.from_torch(num_tiles, dtype=wp.int32, requires_grad=False),
tile_row_group=wp.from_torch(
tile_row_group, dtype=wp.int32, requires_grad=False
),
tile_col_group=wp.from_torch(
tile_col_group, dtype=wp.int32, requires_grad=False
),
cell=_mat33f_from_torch(cell_mat),
inv_cell=_mat33f_from_torch(inv_cell_mat),
cutoff=float(cutoff),
natom=int(natom),
max_pairs=int(max_pairs),
pair_counter=wp.from_torch(pair_counter, dtype=wp.int32, requires_grad=False),
coo_list=wp.from_torch(coo_list, dtype=wp.int32, requires_grad=False),
coo_shifts=wp.from_torch(coo_shifts, dtype=wp.int32, requires_grad=False),
wp_dtype=wp_dtype,
device=wp_device,
n_tiles=int(n_tiles),
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,
)
[docs]
def query_cluster_tile_coo(
sorted_atom_index: torch.Tensor,
sorted_pos_x: torch.Tensor,
sorted_pos_y: torch.Tensor,
sorted_pos_z: torch.Tensor,
num_tiles: torch.Tensor,
tile_row_group: torch.Tensor,
tile_col_group: torch.Tensor,
cell: torch.Tensor,
cutoff: float,
natom: int,
max_pairs: int,
pair_counter: torch.Tensor,
coo_list: torch.Tensor,
coo_shifts: torch.Tensor,
*,
return_vectors: bool = False,
return_distances: bool = False,
pair_fn: wp.Function | 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:
"""Convert the tile pair list to flat COO format in place.
Cluster-tile does not support partial neighbor lists; there is no
``target_indices`` kwarg. Optional pair outputs use flat COO buffers of
length ``max_pairs`` and are written in the same order as ``coo_list``.
Parameters
----------
sorted_atom_index : torch.Tensor, shape (n_padded,), dtype=int32
Permutation mapping sorted rank to original atom index; output of
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list`.
sorted_pos_x : torch.Tensor, shape (n_padded,), dtype=float32
x-coordinates in Morton-sorted order.
sorted_pos_y : torch.Tensor, shape (n_padded,), dtype=float32
y-coordinates in Morton-sorted order.
sorted_pos_z : torch.Tensor, shape (n_padded,), dtype=float32
z-coordinates in Morton-sorted order.
num_tiles : torch.Tensor, shape (1,), dtype=int32
Device-side tile counter written by
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list`.
tile_row_group : torch.Tensor, shape (max_tiles,), dtype=int32
Row group indices of emitted tile pairs.
tile_col_group : torch.Tensor, shape (max_tiles,), dtype=int32
Column group indices of emitted tile pairs.
cell : torch.Tensor, shape (1, 3, 3) or (3, 3), dtype=float32
Simulation cell matrix (orthorhombic or triclinic).
cutoff : float
Neighbor search cutoff radius in Cartesian units.
natom : int
True atom count (before padding).
max_pairs : int
Allocated capacity of the COO output buffers.
pair_counter : torch.Tensor, shape (1,), dtype=int32
Atomic counter that accumulates the number of written pairs.
Reset to zero internally before use. Modified in-place.
coo_list : torch.Tensor, shape (max_pairs, 2), dtype=int32
Output flat pair list; each row is ``(i, j)``. Modified in-place.
coo_shifts : torch.Tensor, shape (max_pairs, 3), dtype=int32
Output periodic image shift vectors for each pair. Modified in-place.
return_vectors : bool, optional
Write per-pair Cartesian displacement vectors to ``neighbor_vectors``.
Default is ``False``.
return_distances : bool, optional
Write per-pair scalar distances to ``neighbor_distances``.
Default is ``False``.
pair_fn : wp.Function, optional
Module-scope Warp ``@wp.func`` of signature
``(r_ij, distance, pair_params, i, j) -> (energy, force)``.
pair_params : torch.Tensor, shape (natom, num_parameters), optional
Per-atom pair-function parameters; required with ``pair_fn``.
neighbor_vectors : torch.Tensor, shape (max_pairs, 3), optional
Output buffer for per-pair displacement vectors. Modified in-place.
neighbor_distances : torch.Tensor, shape (max_pairs,), optional
Output buffer for per-pair scalar distances. Modified in-place.
pair_energies : torch.Tensor, shape (max_pairs,), optional
Output buffer for per-pair energies; required with ``pair_fn``.
Modified in-place.
pair_forces : torch.Tensor, shape (max_pairs, 3), optional
Output buffer for per-pair forces; required with ``pair_fn``.
Modified in-place.
See Also
--------
:func:`nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list` :
Produces the tile state consumed by this function.
:func:`nvalchemiops.torch.neighbors.cluster_tile.query_cluster_tile` :
Row-padded matrix-format alternative.
"""
cell_mat, inv_cell_mat = _cell_invcell_from_cell(cell)
cell_mat = cell_mat.to(sorted_pos_x.dtype)
inv_cell_mat = inv_cell_mat.to(sorted_pos_x.dtype)
# Host-sync the emitted-tile count to tighten the launch and raise on
# tile-buffer overflow (missing tiles -> missing pairs) instead of
# silently dropping them.
tile_capacity = int(tile_row_group.shape[0])
if torch.compiler.is_compiling():
n_tiles = tile_capacity
else:
n_tiles = int(num_tiles.item())
if n_tiles > tile_capacity:
raise NeighborOverflowError(tile_capacity, n_tiles)
pair_counter.zero_()
if _has_partial_or_pair_outputs(
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 (
pair_fn is None
and pair_params is None
and pair_energies is None
and pair_forces is None
):
return _query_cluster_tile_coo_optional_no_pair_fn_op(
cell_mat,
inv_cell_mat,
natom,
max_pairs,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
pair_counter,
coo_list,
coo_shifts,
neighbor_vectors,
neighbor_distances,
n_tiles,
return_vectors,
return_distances,
)
if torch.compiler.is_compiling():
raise NotImplementedError(
"cluster_tile COO pair_fn outputs are eager-only because callable "
"Warp functions cannot cross a torch.library.custom_op schema boundary.",
)
_query_cluster_tile_coo_optional(
cell_mat,
inv_cell_mat,
natom,
max_pairs,
cutoff,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
pair_counter,
coo_list,
coo_shifts,
n_tiles=n_tiles,
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
_query_cluster_tile_coo_op(
cutoff,
natom,
max_pairs,
cell_mat,
inv_cell_mat,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
pair_counter,
coo_list,
coo_shifts,
n_tiles,
)
# =============================================================================
# High-level convenience
# =============================================================================
def _cluster_tile_pair_outputs_forward(
positions: torch.Tensor,
cell: torch.Tensor,
*,
cutoff: float,
max_neighbors: int,
fill_value: int,
) -> "_NeighborForwardOutput":
"""Forward closure for the torch cluster_tile autograd path."""
from nvalchemiops.torch.neighbors._autograd import (
_flatten_active_pairs,
_NeighborForwardOutput,
)
positions_det = positions.detach()
cell_det = cell.detach()
N = positions_det.shape[0]
device = positions_det.device
(
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
) = allocate_cluster_tile_list(N, device, dtype=positions_det.dtype)
build_cluster_tile_list(
positions_det,
cutoff,
cell_det,
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
)
nm = torch.empty((N, max_neighbors), dtype=torch.int32, device=device)
nn = torch.zeros(N, dtype=torch.int32, device=device)
nms = torch.empty((N, max_neighbors, 3), dtype=torch.int32, device=device)
nv = torch.zeros((N, max_neighbors, 3), dtype=positions_det.dtype, device=device)
nd = torch.zeros((N, max_neighbors), dtype=positions_det.dtype, device=device)
query_cluster_tile(
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
cell_det,
cutoff,
N,
nm,
nn,
nms,
return_vectors=True,
return_distances=True,
neighbor_vectors=nv,
neighbor_distances=nd,
)
if max_neighbors > 0:
_cluster_tile_fill_neighbor_matrix_tail_op(
nn,
nm,
int(N),
int(max_neighbors),
int(fill_value),
)
i_idx, j_idx, shifts_flat, batch_idx_flat, mask = _flatten_active_pairs(
nm,
nn,
nms,
)
K, M = nm.shape
return _NeighborForwardOutput(
distances=nd,
vectors=nv,
extra_outputs=(nm, nn, nms),
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),
)
[docs]
def cluster_tile_neighbor_list(
positions: torch.Tensor,
cutoff: float,
cell: torch.Tensor,
max_neighbors: int | None = None,
fill_value: int | None = None,
format: str = "matrix",
max_pairs: int | None = None,
cutoff2: float | None = None,
rebuild_flags: torch.Tensor | None = None,
# matrix-format outputs
neighbor_matrix: torch.Tensor | None = None,
neighbor_matrix_shifts: torch.Tensor | None = None,
num_neighbors: torch.Tensor | None = None,
neighbor_matrix2: torch.Tensor | None = None,
neighbor_matrix_shifts2: torch.Tensor | None = None,
num_neighbors2: torch.Tensor | None = None,
# coo-format outputs
neighbor_list: torch.Tensor | None = None,
neighbor_list_shifts: torch.Tensor | None = None,
pair_counter: torch.Tensor | None = None,
# scratch buffers
sorted_atom_index: torch.Tensor | None = None,
morton_codes: torch.Tensor | None = None,
sorted_pos_x: torch.Tensor | None = None,
sorted_pos_y: torch.Tensor | None = None,
sorted_pos_z: torch.Tensor | None = None,
group_ctr_x: torch.Tensor | None = None,
group_ctr_y: torch.Tensor | None = None,
group_ctr_z: torch.Tensor | None = None,
group_ext_x: torch.Tensor | None = None,
group_ext_y: torch.Tensor | None = None,
group_ext_z: torch.Tensor | None = None,
num_tiles: torch.Tensor | None = None,
tile_row_group: torch.Tensor | None = None,
tile_col_group: torch.Tensor | None = None,
# Optional matrix outputs / pair-fn surface
return_vectors: bool = False,
return_distances: bool = False,
pair_fn: wp.Function | 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, ...]:
"""Build a cluster-pair tile neighbor list (one-shot convenience).
Single-system PyTorch binding for the cluster-pair tile algorithm.
Runs Morton sort, Warp bounding-box reduction, and tile enumeration,
then emits the result in one of three formats selected by ``format=``.
Supports orthorhombic and triclinic cells alike via
``_wrap_triclinic``. Cluster-tile is CUDA float32 only.
Parameters
----------
positions : torch.Tensor, shape (N, 3), dtype=float32
Atomic coordinates. Any ``N >= 0``; non-32-aligned ``N`` is
supported via internal padding to
``ceil(N / TILE_GROUP_SIZE) * TILE_GROUP_SIZE``. Padding slots
use sentinel Morton codes and are filtered out by the
convert/coo kernels.
cutoff : float
Cutoff distance in Cartesian units. Must be positive.
cutoff2 : float, optional
Matrix-format second cutoff. When provided, the function returns a
second ``(neighbor_matrix2, num_neighbors2, neighbor_matrix_shifts2)``
group for neighbors within ``cutoff2``. Cannot be combined with pair
outputs or COO/tile formats.
cell : torch.Tensor, shape (1, 3, 3) or (3, 3), dtype=float32
Any non-degenerate cell (orthorhombic or triclinic).
max_neighbors : int, optional
Falls back to ``estimate_max_neighbors(cutoff)``. Matrix
format only.
fill_value : int, optional
Matrix sentinel; defaults to ``N``.
format : {"matrix", "coo", "tile"}, default "matrix"
Output representation:
- ``"matrix"``: returns
``(neighbor_matrix, num_neighbors, neighbor_matrix_shifts)`` —
the dense ``(N, max_neighbors)`` row-padded form used by
``cell_list`` and ``naive``.
- ``"coo"``: returns
``(neighbor_list, neighbor_ptr, neighbor_list_shifts)`` — flat
pair list emitted directly by ``query_cluster_tile_coo`` (no matrix
intermediate). ``neighbor_ptr`` is reconstructed from
``bincount(neighbor_list[0])`` (cheap; requires a single CPU
sync on ``pair_counter[0]`` that's needed for the trim
anyway).
- ``"tile"``: returns the native cluster-pair tile state as a
7-tuple
``(num_tiles, tile_row_group, tile_col_group,
sorted_atom_index, sorted_pos_x, sorted_pos_y, sorted_pos_z)``.
No convert kernel is run. Intended for downstream kernels
that consume the tile-pair list directly with shared-memory
tile loads. Tile pairs are group-level half-fill: every
emitted pair has ``tile_col_group[t] >= tile_row_group[t]``.
The consumer chooses atom-level fill.
max_pairs : int, optional
Upper bound for COO output; defaults to ``N * max_neighbors``.
rebuild_flags : torch.Tensor, shape (1,), dtype=bool, optional
Matrix-format selective rebuild flag. Requires previous tile state
and previous matrix outputs. When ``rebuild_flags[0]`` is False,
the previous outputs are returned unchanged.
neighbor_matrix, num_neighbors, neighbor_matrix_shifts : optional
Pre-allocated matrix-format outputs. All-or-nothing only across
the trio; supply all three or none.
neighbor_list, neighbor_list_shifts, pair_counter : optional
Pre-allocated COO-format outputs. Shapes
``(2, max_pairs)``, ``(max_pairs, 3)``, ``(1,)`` int32.
Same all-or-nothing semantics.
sorted_atom_index, morton_codes, sorted_pos_x, sorted_pos_y, sorted_pos_z, group_ctr_x, group_ctr_y, group_ctr_z, group_ext_x, group_ext_y, group_ext_z, num_tiles, tile_row_group, tile_col_group : torch.Tensor, optional
Pre-allocated scratch buffers (shapes as returned by
``allocate_cluster_tile_list``). All-or-nothing: either
provide every scratch buffer or none. The trigger is
``sorted_atom_index``. Reuse is safe — ``num_tiles`` is reset
each call and every other scratch tensor is either fully
overwritten or only read in regions the kernels just wrote.
return_vectors, return_distances : bool, default ``False``
Write per-pair Cartesian displacements / scalar distances to
``neighbor_vectors`` / ``neighbor_distances``.
Matrix format uses ``(N, max_neighbors, ...)`` buffers; COO format
uses flat ``(max_pairs, ...)`` buffers.
pair_fn : callable, optional
Module-scope Warp ``@wp.func`` of signature
``(r_ij, distance, pair_params, i, j) -> (energy, force)``. Writes
per-pair energies / forces to ``pair_energies`` /
``pair_forces``. Matrix format uses row-padded buffers; COO
format uses flat buffers written in pair-list order.
pair_params : torch.Tensor, shape ``(num_atoms, num_parameters)``, optional
Per-atom pair-function parameters; required with ``pair_fn``.
neighbor_vectors, neighbor_distances : torch.Tensor, optional
OUTPUT buffers for per-pair displacements / distances. Matrix
format allocates them when omitted; COO format requires caller-owned
flat buffers.
pair_energies, pair_forces : torch.Tensor, optional
OUTPUT buffers for per-pair energies / forces. Matrix format
allocates them when omitted; COO format requires caller-owned flat
buffers.
Returns
-------
tuple of torch.Tensor
Shape depends on ``format``:
- ``"matrix"`` (default): ``(neighbor_matrix, num_neighbors,
neighbor_matrix_shifts)``, with optional ``(*, distances)`` and/or
``(*, vectors)`` appended when ``return_distances`` /
``return_vectors`` is True, and optional ``(*, pair_energies,
pair_forces)`` when ``pair_fn`` is set. With ``cutoff2``, returns
the primary group followed by the secondary cutoff group.
- ``"coo"``: ``(neighbor_list, neighbor_ptr, neighbor_list_shifts)``.
- ``"tile"``: ``(num_tiles, tile_row_group, tile_col_group,
sorted_atom_index, sorted_pos_x, sorted_pos_y, sorted_pos_z)``.
Notes
-----
- Cluster-tile is CUDA float32 only; float64 ``positions`` is rejected.
- Cluster-tile does not support partial neighbor lists (no
``target_indices`` kwarg).
- The unified
:func:`nvalchemiops.torch.neighbors.neighbor_list` entry point may
select this binding automatically when the selector guards and cost
model prefer it; pass ``method="cluster_tile"`` to force it.
See Also
--------
nvalchemiops.torch.neighbors.batch_cluster_tile_neighbor_list :
Batched companion entry point.
nvalchemiops.torch.neighbors.cluster_tile.build_cluster_tile_list :
Lower-level build step exposed for caching across queries.
nvalchemiops.torch.neighbors.cluster_tile.query_cluster_tile :
Lower-level query step.
"""
if positions.dtype != torch.float32:
raise TypeError("positions must be float32")
if format not in ("matrix", "coo", "tile"):
raise ValueError(
f"format must be 'matrix' | 'coo' | 'tile'; got {format!r}",
)
has_pair_outputs = (
bool(return_vectors)
or bool(return_distances)
or pair_fn is not None
or pair_params is not None
or neighbor_vectors is not None
or neighbor_distances is not None
or pair_energies is not None
or pair_forces is not None
)
dual_cutoff = cutoff2 is not None
selective = rebuild_flags is not None
if has_pair_outputs and format == "tile":
raise NotImplementedError(
"Pair outputs (return_vectors / return_distances / pair_fn) "
"are not supported with format='tile'. Use format='matrix' "
"or format='coo'.",
)
if dual_cutoff:
if format != "matrix":
raise ValueError(
"cluster_tile cutoff2 is supported only with format='matrix'"
)
if has_pair_outputs:
raise ValueError(
"cluster_tile cutoff2 cannot be combined with pair outputs"
)
if selective:
if format != "matrix":
raise ValueError(
"cluster_tile selective rebuild is supported only with format='matrix'"
)
if has_pair_outputs:
raise ValueError(
"cluster_tile selective rebuild cannot be combined with pair outputs"
)
required = {
"num_tiles": num_tiles,
"tile_row_group": tile_row_group,
"tile_col_group": tile_col_group,
"neighbor_matrix": neighbor_matrix,
"num_neighbors": num_neighbors,
"neighbor_matrix_shifts": neighbor_matrix_shifts,
}
if dual_cutoff:
required.update(
{
"neighbor_matrix2": neighbor_matrix2,
"num_neighbors2": num_neighbors2,
"neighbor_matrix_shifts2": neighbor_matrix_shifts2,
}
)
missing = [name for name, value in required.items() if value is None]
if missing:
raise ValueError(
"rebuild_flags requires previous cluster_tile state: "
+ ", ".join(missing)
)
N = positions.shape[0]
device = positions.device
if max_neighbors is None:
max_neighbors = max(
estimate_max_neighbors(cutoff2 if cutoff2 is not None else cutoff),
TILE_GROUP_SIZE,
)
if fill_value is None:
fill_value = N
if selective and not bool(rebuild_flags.flatten()[0].item()):
if dual_cutoff:
return (
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
neighbor_matrix2,
num_neighbors2,
neighbor_matrix_shifts2,
)
return neighbor_matrix, num_neighbors, neighbor_matrix_shifts
# Autograd routing: when only distances/vectors are requested (no
# pair_fn / energies / forces), route through the autograd primitive
# so the per-pair outputs are differentiable w.r.t. positions / cell.
if (
(bool(return_distances) or bool(return_vectors))
and pair_fn is None
and pair_params is None
and pair_energies is None
and pair_forces is None
and format == "matrix"
and not dual_cutoff
and not selective
):
from nvalchemiops.torch.neighbors._autograd import _route_pair_outputs
forward_kwargs = {
"cutoff": cutoff,
"max_neighbors": int(max_neighbors),
"fill_value": int(fill_value),
}
distances_out, vectors_out, nm_out, nn_out, shifts_out = _route_pair_outputs(
positions,
cell,
_cluster_tile_pair_outputs_forward,
forward_kwargs,
)
base = (nm_out, nn_out, shifts_out)
if return_distances and return_vectors:
return (*base, distances_out, vectors_out)
if return_distances:
return (*base, distances_out)
return (*base, vectors_out)
# Tile candidates must cover the *larger* radius so the cutoff2 matrix
# cannot miss pairs in the (cutoff, cutoff2] shell; the query then filters
# each matrix by its own cutoff.
build_cutoff = cutoff if cutoff2 is None else max(float(cutoff), float(cutoff2))
# Allocate scratch if caller didn't supply. ``sorted_atom_index`` is the
# all-or-nothing sentinel.
if sorted_atom_index is None:
cell_volume = float(_cell_volume(cell))
max_tiles_per_group = estimate_max_tiles_per_group(N, build_cutoff, cell_volume)
(
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
) = allocate_cluster_tile_list(
N,
device,
dtype=positions.dtype,
max_tiles_per_group=max_tiles_per_group,
)
build_cluster_tile_list(
positions,
build_cutoff,
cell,
sorted_atom_index,
morton_codes,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
group_ctr_x,
group_ctr_y,
group_ctr_z,
group_ext_x,
group_ext_y,
group_ext_z,
num_tiles,
tile_row_group,
tile_col_group,
)
if format == "tile":
# Raw-tile callers must not receive a silently truncated tile list.
n_tiles = int(num_tiles.item())
tile_capacity = int(tile_row_group.shape[0])
if n_tiles > tile_capacity:
raise NeighborOverflowError(tile_capacity, n_tiles)
return (
num_tiles,
tile_row_group,
tile_col_group,
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
)
if format == "coo":
if max_pairs is None:
max_pairs = N * max_neighbors
# ``query_cluster_tile_coo`` writes row-major (max_pairs, 2); we transpose to
# package-canonical (2, num_pairs) on the way out. Pre-allocation
# kwargs accept the package layout (2, max_pairs); we view-as-flat
# then reshape for the kernel.
if neighbor_list is None:
coo_buf = torch.empty(
(max_pairs, 2),
dtype=torch.int32,
device=device,
)
else:
# Caller passed (2, max_pairs). Use a transposed view; the
# kernel writes row-major into this buffer.
coo_buf = neighbor_list.transpose(0, 1)
if neighbor_list_shifts is None:
neighbor_list_shifts = torch.empty(
(max_pairs, 3),
dtype=torch.int32,
device=device,
)
if pair_counter is None:
pair_counter = torch.zeros(1, dtype=torch.int32, device=device)
query_cluster_tile_coo(
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
cell,
cutoff,
N,
int(max_pairs),
pair_counter,
coo_buf,
neighbor_list_shifts,
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,
)
# Trim to actual pair count and rebuild CSR neighbor_ptr.
# The ``.item()`` is the only sync; it's needed for the slice
# anyway, so the bincount is on a CPU-known-length tensor.
npairs = int(pair_counter.item())
if npairs > int(max_pairs):
raise NeighborOverflowError(int(max_pairs), npairs)
nl = coo_buf[:npairs].transpose(0, 1).contiguous() # (2, npairs)
nls = neighbor_list_shifts[:npairs].contiguous()
per_atom_counts = torch.bincount(nl[0].long(), minlength=N).to(
torch.int32,
)
neighbor_ptr = torch.cat(
[
torch.zeros(1, dtype=torch.int32, device=device),
torch.cumsum(per_atom_counts, dim=0).to(torch.int32),
],
)
return nl, neighbor_ptr, nls
# ``format == "matrix"``: skip-prefill matrix path with tail fill.
if neighbor_matrix is None:
neighbor_matrix = torch.empty(
(N, max_neighbors),
dtype=torch.int32,
device=device,
)
if num_neighbors is None:
num_neighbors = torch.zeros(N, dtype=torch.int32, device=device)
elif selective:
wp_selective_zero_num_neighbors_single(
wp.from_torch(num_neighbors, dtype=wp.int32, return_ctype=True),
wp.from_torch(rebuild_flags, dtype=wp.bool, return_ctype=True),
str(device),
)
else:
num_neighbors.zero_()
if neighbor_matrix_shifts is None:
neighbor_matrix_shifts = torch.empty(
(N, max_neighbors, 3),
dtype=torch.int32,
device=device,
)
if dual_cutoff:
if neighbor_matrix2 is None:
neighbor_matrix2 = torch.empty(
(N, max_neighbors), dtype=torch.int32, device=device
)
if num_neighbors2 is None:
num_neighbors2 = torch.zeros(N, dtype=torch.int32, device=device)
elif selective:
wp_selective_zero_num_neighbors_single(
wp.from_torch(num_neighbors2, dtype=wp.int32, return_ctype=True),
wp.from_torch(rebuild_flags, dtype=wp.bool, return_ctype=True),
str(device),
)
else:
num_neighbors2.zero_()
if neighbor_matrix_shifts2 is None:
neighbor_matrix_shifts2 = torch.empty(
(N, max_neighbors, 3), dtype=torch.int32, device=device
)
# Pair-output buffer allocation: caller may omit any of the four
# OUTPUT buffer kwargs and have them allocated via framework-native
# ``torch.empty`` here. Required-presence rules
# (``return_vectors`` ⇒ ``neighbor_vectors``,
# ``pair_fn`` ⇒ ``pair_{energies,forces}_buffer``) are enforced by
# the warp launcher.
if return_vectors and neighbor_vectors is None:
neighbor_vectors = torch.empty(
(N, max_neighbors, 3),
dtype=positions.dtype,
device=device,
)
if return_distances and neighbor_distances is None:
neighbor_distances = torch.empty(
(N, max_neighbors),
dtype=positions.dtype,
device=device,
)
if pair_fn is not None:
if pair_energies is None:
pair_energies = torch.empty(
(N, max_neighbors),
dtype=positions.dtype,
device=device,
)
if pair_forces is None:
pair_forces = torch.empty(
(N, max_neighbors, 3),
dtype=positions.dtype,
device=device,
)
query_cluster_tile(
sorted_atom_index,
sorted_pos_x,
sorted_pos_y,
sorted_pos_z,
num_tiles,
tile_row_group,
tile_col_group,
cell,
cutoff,
N,
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
cutoff2=cutoff2,
neighbor_matrix2=neighbor_matrix2,
num_neighbors2=num_neighbors2,
neighbor_matrix_shifts2=neighbor_matrix_shifts2,
rebuild_flags=rebuild_flags,
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,
)
max_seen = int(num_neighbors.max().item()) if N > 0 else 0
if max_seen > int(max_neighbors):
raise NeighborOverflowError(int(max_neighbors), max_seen)
if dual_cutoff and num_neighbors2 is not None:
max_seen2 = int(num_neighbors2.max().item()) if N > 0 else 0
if max_seen2 > int(max_neighbors):
raise NeighborOverflowError(int(max_neighbors), max_seen2)
# Skip-prefill tail fill: write ``fill_value`` into the unused columns
# of ``neighbor_matrix``. Pairs with the always-write-shifts kernel
# above to eliminate the per-step ``neighbor_matrix.fill_`` and
# ``neighbor_matrix_shifts.zero_`` ops.
if max_neighbors > 0:
_cluster_tile_fill_neighbor_matrix_tail_op(
num_neighbors,
neighbor_matrix,
int(N),
int(max_neighbors),
int(fill_value),
)
if dual_cutoff:
_cluster_tile_fill_neighbor_matrix_tail_op(
num_neighbors2,
neighbor_matrix2,
int(N),
int(max_neighbors),
int(fill_value),
)
if dual_cutoff:
return (
neighbor_matrix,
num_neighbors,
neighbor_matrix_shifts,
neighbor_matrix2,
num_neighbors2,
neighbor_matrix_shifts2,
)
return neighbor_matrix, num_neighbors, neighbor_matrix_shifts