Source code for nvalchemiops.neighbors.base_dispatch

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


"""Shared helpers for frontend neighbor-list auto dispatch."""

import math
import os
from collections.abc import Iterable
from functools import lru_cache
from typing import Literal

import warp as wp

from nvalchemiops.neighbors.neighbor_utils import DTYPE_INFO_ALL, empty_sentinel

__all__ = [
    "AUTO_BASE_DEFAULTS",
    "DEFAULT_BATCH_MAX_NBINS",
    "DEFAULT_SINGLE_MAX_NBINS",
    "FEATURE_BATCHED",
    "FEATURE_CUDA",
    "FEATURE_POSITIONS_FLOAT32",
    "NEIGHBOR_LIST_STRATEGIES",
    "auto_base_constants",
    "fp64_naive_penalty",
    "finalize_neighbor_list_method",
    "get_select_neighbor_list_method_cost_kernel",
    "neighbor_list_strategy_run_args",
    "optional_outputs_mask",
    "estimate_neighbor_list_costs",
    "suggest_neighbor_list_method",
]

NeighborListMethod = Literal["naive", "cell_list", "cluster_tile"]
CellListStrategy = Literal["atom_centric", "pair_centric"]
AtomCentricPath = Literal["direct", "sorted"]
NaiveStrategy = Literal["scalar", "tile"]

# Cost-model constants (env-overridable, NVALCHEMI_NEIGHLIST_* style).
#
# The selector compares three families using quantities computed from the same
# geometry kernel:
#   naive       ~ per-system direct candidate work, image count, output work
#   cell_list   ~ cell-grid candidate work plus expected output work
#   cluster_tile ~ fully-periodic tile output work when semantically compatible
#
# These are intentionally coarse: the selector is a routing guard, not a timing
# predictor.  It uses volume and expected pairs so density/cutoff regimes drive
# the decision rather than raw atom-count thresholds.
AUTO_BASE_DEFAULTS = {
    "NVALCHEMI_NEIGHLIST_CELL_SHELL": 27.0,
    "NVALCHEMI_NEIGHLIST_CELL_SETUP": 1192450.0,
}

DEFAULT_SINGLE_MAX_NBINS = 524288
"""Default single-system cell-list cell cap."""

DEFAULT_BATCH_MAX_NBINS = 8192
"""Default per-system batched cell-list cell cap."""

FEATURE_CUDA = 1 << 0
"""Selector feature flag: the active device supports CUDA kernels."""

FEATURE_POSITIONS_FLOAT32 = 1 << 1
"""Selector feature flag: positions are float32, required by cluster-tile auto."""

FEATURE_BATCHED = 1 << 2
"""Selector feature flag: the call routes through a batched frontend."""

_MAX_WARP_LINEAR_LAUNCH = 2**31 - 1
_EPS = 1.0e-30
_INF = 3.0e30
# Reference stencil the ``shell`` knob is normalised against (3x3x3).
_SHELL_REFERENCE = 27.0
# Cap on the per-axis stencil radius in the cost model. A cell whose face
# distance is far below the cutoff gives a huge radius; the cost is already
# prohibitive long before this cap, and it keeps every stencil product within
# int64 (and the float-to-int radius conversion in range).
_MAX_STENCIL_RADIUS = 524288.0
# Saturation for the stencil offset count used in launch-size arithmetic.
_MAX_STENCIL_OFFSETS = 16777216

# Naive per-candidate scan and per-pair output write. The scan weight is on a
# float32 basis; float64 is handled by _FP64_NAIVE_PENALTY below.
_NAIVE_SCAN = 0.002
_NAIVE_WRITE = 1.3
# Scalar and tile are indistinguishable per candidate, so break the tie toward
# tile by a margin below the measurement noise rather than leaving the choice
# to enumeration order.
_NAIVE_SCALAR_TIEBREAK = 1.01
_NAIVE_FLOOR = 340700.0

_CELL_ATOM_SCAN = 0.1
_CELL_ATOM_WRITE = 1.5
_CELL_PAIR_WRITE = 0.437
_CELL_PAIR_LAUNCH = 0.3
_CELL_PAIR_SCAN = 0.1
# Pair-centric sorts positions into cell order first, a per-atom pass that
# atom-centric has no equivalent of.
_CELL_PAIR_PER_ATOM = 50.0

# Pair-centric enumerates (cell, neighbour-offset) blocks across the whole
# grid, so the vacuum in a free-boundary bounding box is work it performs;
# atom-centric starts from atoms and never visits an empty cell.
_APERIODIC_PAIR_VACUUM = 2.0

# Naive evaluates N^2 distances in the position dtype, while cell_list is
# dominated by an index-bound sort, so the crossover between them moves with
# the device's FP64 rate for float64 inputs only.
_FP64_NAIVE_PENALTY = 5.0
# SM architectures with ~1:2 FP64 throughput. Unlisted architectures and CPUs
# take the penalised path, which is the conservative direction.
_FAST_FP64_ARCHS = frozenset({70, 72, 80, 90, 100})

# Cluster-tile build is a Morton encode, radix sort, cluster assignment and
# tile scan, so its per-atom term is well above pair-centric's single sort
# pass; its tile queries emit pairs from shared memory, so its per-pair write
# is below pair-centric's.
_CLUSTER_TILE_PIPELINE = 1200000.0
_CLUSTER_TILE_PER_ATOM = 750.0
_CLUSTER_TILE_WRITE = 0.2

# Batched overheads are dominated by per-atom work: the wrap/fill launches
# preceding the search run over atoms, so summed across systems they scale with
# the total atom count however it is partitioned. Only naive and pair-centric
# carry these; atom-centric launches once per batch. The naive per-atom term is
# position-dtype arithmetic, so it takes the same float64 penalty as the scan
# and is stated on the same float32 basis.
_BATCH_NAIVE_PER_SYSTEM = 0.0
_BATCH_NAIVE_PER_ATOM = 20.0
_BATCH_PAIR_PER_SYSTEM = 270.0
_BATCH_PAIR_PER_ATOM = 10.0
_SPHERE_VOLUME_FACTOR = 4.1887902047863905

_OPTION_CUTOFF2 = 1 << 0
_OPTION_HALF_FILL = 1 << 1
_OPTION_RETURN_NEIGHBOR_LIST = 1 << 2
_OPTION_TARGET_INDICES = 1 << 3
_OPTION_RETURN_VECTORS = 1 << 4
_OPTION_RETURN_DISTANCES = 1 << 5
_OPTION_USE_PAIR_FN = 1 << 6
_OPTION_REBUILD_FLAGS = 1 << 8
_OPTION_WRAP_POSITIONS_FALSE = 1 << 9

_OPTION_BITS = {
    "cutoff2": _OPTION_CUTOFF2,
    "half_fill": _OPTION_HALF_FILL,
    "return_neighbor_list": _OPTION_RETURN_NEIGHBOR_LIST,
    "target_indices": _OPTION_TARGET_INDICES,
    "return_vectors": _OPTION_RETURN_VECTORS,
    "return_distances": _OPTION_RETURN_DISTANCES,
    "use_pair_fn": _OPTION_USE_PAIR_FN,
    "rebuild_flags": _OPTION_REBUILD_FLAGS,
    "wrap_positions_false": _OPTION_WRAP_POSITIONS_FALSE,
}

_OPTION_ALIASES = {
    "compute_vectors": "return_vectors",
    "vectors": "return_vectors",
    "neighbor_vectors": "return_vectors",
    "compute_distances": "return_distances",
    "distances": "return_distances",
    "neighbor_distances": "return_distances",
    "pair_fn": "use_pair_fn",
    "pair_params": "use_pair_fn",
    "pair_energies": "use_pair_fn",
    "pair_forces": "use_pair_fn",
    "wrap_positions": "wrap_positions_false",
}

_FLAG_NAMES = (
    "invalid_input",
    "naive_unsafe",
    "cell_list_unsafe",
    "cluster_tile_unsupported_options",
    "cluster_tile_unsupported_geometry",
    "cluster_tile_noncontiguous_batch",
    "cluster_tile_image_multiplicity",
    "pair_centric_unsafe",
    "naive_tile_unsafe",
)


# Fine-grained, directly-runnable neighbor-list strategies.  These are the names
# returned by :func:`estimate_neighbor_list_costs` / :func:`suggest_neighbor_list_method`
# and accepted by the public ``neighbor_list(method=...)`` frontends; each maps
# 1:1 to a base method plus its sub-options.
NEIGHBOR_LIST_STRATEGIES = (
    "naive_scalar",
    "naive_tile",
    "cell_list_atom_centric",
    "cell_list_pair_centric",
    "cluster_tile",
)

# name -> (base_method, native_strategy, cell_list_strategy, atom_centric_path).
_STRATEGY_RUN_ARGS: dict[str, tuple[str, str, str, str]] = {
    "naive_scalar": ("naive", "scalar", "auto", "auto"),
    "naive_tile": ("naive", "tile", "auto", "auto"),
    "cell_list_atom_centric": ("cell_list", "auto", "atom_centric", "direct"),
    "cell_list_pair_centric": ("cell_list", "auto", "pair_centric", "sorted"),
    "cluster_tile": ("cluster_tile", "auto", "auto", "auto"),
}


def neighbor_list_strategy_run_args(strategy: str) -> tuple[str, str, str, str]:
    """Resolve a strategy name to its run arguments.

    Parameters
    ----------
    strategy : str
        A name from :data:`NEIGHBOR_LIST_STRATEGIES`, optionally ``batch_``
        prefixed.

    Returns
    -------
    tuple of str
        ``(method, native_strategy, cell_list_strategy, atom_centric_path)``.
        The ``batch_`` prefix, if present, is carried onto ``method``.
    """
    batched = strategy.startswith("batch_")
    base = strategy[len("batch_") :] if batched else strategy
    if base not in _STRATEGY_RUN_ARGS:
        raise ValueError(f"unknown neighbor-list strategy {strategy!r}")
    method, native_strategy, cell_list_strategy, atom_centric_path = _STRATEGY_RUN_ARGS[
        base
    ]
    if batched:
        method = "batch_" + method
    return method, native_strategy, cell_list_strategy, atom_centric_path


def _auto_env_float(name: str) -> float:
    raw = os.environ.get(name)
    try:
        return float(raw) if raw is not None else float(AUTO_BASE_DEFAULTS[name])
    except (TypeError, ValueError):
        return float(AUTO_BASE_DEFAULTS[name])


def auto_base_constants() -> tuple[float, float]:
    """Return the current auto-dispatch constants.

    Returns
    -------
    shell : float
        Multiplicative estimate of cell-list neighbor-shell work.
    setup : float
        Per-system setup floor for cell-list work.

    Notes
    -----
    The values are read from ``NVALCHEMI_NEIGHLIST_CELL_SHELL`` and
    ``NVALCHEMI_NEIGHLIST_CELL_SETUP`` when present, otherwise from
    :data:`AUTO_BASE_DEFAULTS`.
    """
    return (
        _auto_env_float("NVALCHEMI_NEIGHLIST_CELL_SHELL"),
        _auto_env_float("NVALCHEMI_NEIGHLIST_CELL_SETUP"),
    )


def fp64_naive_penalty(device: wp.Device) -> float:
    """Return the float64 scan penalty for ``device``.

    Parameters
    ----------
    device : warp.context.Device
        Device the selector will run on.

    Returns
    -------
    float
        Multiplier applied to the naive per-candidate scan cost when positions
        are float64. ``1.0`` on architectures with fast FP64.

    Notes
    -----
    Overridden by ``NVALCHEMI_NEIGHLIST_FP64_NAIVE_PENALTY`` when set, which is
    the escape hatch for an architecture missing from
    :data:`_FAST_FP64_ARCHS`. A value that is not a finite positive number is
    ignored, so a malformed override cannot remove float64 naive strategies
    from the selector's ranking.

    See Also
    --------
    auto_base_constants : The other env-tunable selector constants.
    """
    raw = os.environ.get("NVALCHEMI_NEIGHLIST_FP64_NAIVE_PENALTY")
    if raw is not None:
        try:
            override = float(raw)
        except (TypeError, ValueError):
            override = None
        if override is not None and math.isfinite(override) and override > 0.0:
            return override
    arch = getattr(device, "arch", None)
    if getattr(device, "is_cuda", False) and arch is not None:
        if int(arch) in _FAST_FP64_ARCHS:
            return 1.0
    return float(_FP64_NAIVE_PENALTY)


def _wp_scalar_from_cell_dtype(cell_dtype: type) -> type:
    """Return the Warp scalar dtype for a mat33 cell dtype."""
    for scalar_dtype, (_, mat_dtype) in DTYPE_INFO_ALL.items():
        if cell_dtype == mat_dtype:
            return scalar_dtype
    raise ValueError(f"Unsupported cell dtype: {cell_dtype!r}")


def _safe_int(value) -> int:
    """Convert host scalar-like values to ``int``."""
    return int(value.item() if hasattr(value, "item") else value)


def _safe_float(value) -> float:
    """Convert host scalar-like values to ``float``."""
    return float(value.item() if hasattr(value, "item") else value)


def _option_is_active(value: object) -> bool:
    """Return whether a public-style selector option should set a bit."""
    if value is None or value is False:
        return False
    return True


def optional_outputs_mask(
    optional_outputs: Iterable[str] | None = None,
    **public_kwargs: object,
) -> int:
    """Encode public neighbor-list option names into a selector bitmask.

    Parameters
    ----------
    optional_outputs : iterable of str, optional
        Public-style option names.  Supported names include ``"cutoff2"``,
        ``"half_fill"``, ``"return_neighbor_list"``, ``"target_indices"``,
        ``"return_vectors"``, ``"return_distances"``, ``"use_pair_fn"``, and
        ``"rebuild_flags"``.  Aliases matching common public buffers such as
        ``"neighbor_vectors"`` and ``"pair_fn"`` are accepted.
    **public_kwargs
        Public neighbor-list keyword names mapped to their values.  Truthy or
        non-``None`` values set the corresponding feasibility bit.  For
        ``wrap_positions``, a value of ``False`` sets
        ``"wrap_positions_false"``.

    Returns
    -------
    int
        Bitmask consumed by the Warp selector kernel.
    """
    mask = 0
    for name in optional_outputs or ():
        canonical = _OPTION_ALIASES.get(str(name), str(name))
        try:
            mask |= _OPTION_BITS[canonical]
        except KeyError as exc:
            raise ValueError(f"Unknown optional output option: {name!r}") from exc

    for name, value in public_kwargs.items():
        canonical = _OPTION_ALIASES.get(name, name)
        if canonical == "wrap_positions_false":
            if value is False:
                mask |= _OPTION_BITS[canonical]
            continue
        if canonical not in _OPTION_BITS:
            raise ValueError(f"Unknown optional output option: {name!r}")
        if _option_is_active(value):
            mask |= _OPTION_BITS[canonical]
    return int(mask)


def finalize_neighbor_list_method(costs, flags) -> list[tuple[str, float]]:
    """Reduce selector costs and flags to feasible strategies, cheapest-first.

    Parameters
    ----------
    costs : array-like, shape (5,)
        ``(naive_scalar, naive_tile, cell_atom, cell_pair, cluster_tile)``.
    flags : array-like, shape (9,)
        Invalid-input and per-strategy feasibility flags.

    Returns
    -------
    list of (str, float)
        Feasible strategy names (from :data:`NEIGHBOR_LIST_STRATEGIES`) paired
        with their relative estimated cost (lower is faster), sorted ascending.

    Raises
    ------
    ValueError
        If the selector reported invalid input.
    RuntimeError
        If no strategy satisfies the safety and feasibility guards.
    """
    raw_flags = [_safe_int(flags[i]) for i in range(min(len(flags), len(_FLAG_NAMES)))]
    # Pad to the full flag set so a short ``flags`` argument cannot IndexError on
    # the fixed indices used below; a missing flag defaults to 0 (feasible).
    while len(raw_flags) < len(_FLAG_NAMES):
        raw_flags.append(0)
    if raw_flags[0] != 0:
        raise ValueError("invalid batch_ptr, cutoff, or max_nbins for auto dispatch")

    raw_costs = [_safe_float(costs[i]) for i in range(min(len(costs), 5))]
    while len(raw_costs) < 5:
        raw_costs.append(_INF)

    naive_ok = raw_flags[1] == 0
    cell_ok = raw_flags[2] == 0
    cluster_ok = (
        raw_flags[3] == 0
        and raw_flags[4] == 0
        and raw_flags[5] == 0
        and raw_flags[6] == 0
    )

    candidates: list[tuple[str, float]] = []
    if naive_ok:
        candidates.append(("naive_scalar", raw_costs[0]))
        if raw_flags[8] == 0:
            candidates.append(("naive_tile", raw_costs[1]))
    if cell_ok:
        candidates.append(("cell_list_atom_centric", raw_costs[2]))
        if raw_flags[7] == 0:
            candidates.append(("cell_list_pair_centric", raw_costs[3]))
    if cluster_ok:
        candidates.append(("cluster_tile", raw_costs[4]))

    feasible = [(name, cost) for name, cost in candidates if cost < _INF * 0.5]
    if not feasible:
        raise RuntimeError(
            "No neighbor-list method fits the current safety and feasibility guards"
        )
    feasible.sort(key=lambda item: item[1])
    return feasible


@lru_cache(maxsize=None)
def get_select_neighbor_list_method_cost_kernel(wp_dtype: type) -> wp.Kernel:
    """Return the shared guarded auto-dispatch cost kernel.

    Parameters
    ----------
    wp_dtype : type
        Warp scalar dtype for cell matrices, usually ``wp.float32`` or
        ``wp.float64``.

    Returns
    -------
    wp.Kernel
        Kernel computing method costs, sub-option costs, and feasibility flags.
    """
    if wp_dtype not in DTYPE_INFO_ALL:
        raise ValueError(f"Unsupported dtype: {wp_dtype!r}")
    _, mat_dtype = DTYPE_INFO_ALL[wp_dtype]

    @wp.kernel(enable_backward=False)
    def _kernel(
        batch_ptr: wp.array(dtype=wp.int32),
        batch_idx: wp.array(dtype=wp.int32),
        batch_idx_is_provided: bool,
        cell: wp.array(dtype=mat_dtype),
        pbc_single: wp.array(dtype=wp.bool),
        pbc_batch: wp.array2d(dtype=wp.bool),
        pbc_is_batched: bool,
        cutoff: wp.float32,
        shell: wp.float32,
        setup: wp.float32,
        fp64_scan_penalty: wp.float32,
        max_nbins: wp.int32,
        max_launch_size: wp.int64,
        option_mask: wp.int32,
        feature_mask: wp.int32,
        target_count: wp.int32,
        costs: wp.array(dtype=wp.float32),
        flags: wp.array(dtype=wp.int32),
    ) -> None:
        """Compute dispatch costs, sub-option costs, and safety flags.

        Parameters
        ----------
        batch_ptr : wp.array, shape (num_systems + 1,), dtype=wp.int32
            Cumulative per-system atom counts.
        batch_idx : wp.array, shape (total_atoms,), dtype=wp.int32
            Optional dense system ids.  Zero-size sentinel when absent.
        batch_idx_is_provided : bool
            Whether ``batch_idx`` should be validated for contiguous layout.
        cell : wp.array, shape (num_systems,), dtype=wp.mat33*
            Per-system cell matrices.
        pbc_single : wp.array, shape (3,), dtype=wp.bool
            Shared PBC flags. Zero-size sentinel when ``pbc_is_batched=True``.
        pbc_batch : wp.array, shape (num_systems, 3), dtype=wp.bool
            Per-system PBC flags. Zero-size sentinel when
            ``pbc_is_batched=False``.
        pbc_is_batched : bool
            Selects ``pbc_batch`` vs ``pbc_single``.
        cutoff : float
            Neighbor-list cutoff.
        shell : float
            Cell-list shell work multiplier.
        setup : float
            Cell-list per-system setup cost.
        fp64_scan_penalty : float
            Multiplier applied to the naive scan cost for float64 positions.
        max_nbins : int
            Cell-list per-system cap used by the selected frontend.
        max_launch_size : int
            Conservative one-dimensional launch-size guard.
        option_mask : int
            Public-option feasibility bits from :func:`optional_outputs_mask`.
        feature_mask : int
            Device/dtype/frontend feature bits.
        target_count : int
            Number of compact central rows when ``target_indices`` is active.
            Zero means "all atoms".
        costs : wp.array, shape (5,), dtype=wp.float32
            OUTPUT: ``(naive_scalar, naive_tile, cell_atom, cell_pair,
            cluster_tile)``.
        flags : wp.array, shape (9,), dtype=wp.int32
            OUTPUT: invalid input and method/sub-option feasibility flags.

        Returns
        -------
        None
            The kernel has no return value; results are written in place to the
            ``costs`` and ``flags`` output arrays.

        Notes
        -----
        - Thread launch: ``max(num_systems, total_atoms when batch_idx is
          supplied)``.
        - Modifies: ``costs`` and ``flags`` by atomic updates.

        See Also
        --------
        finalize_neighbor_list_method : Reduce these costs/flags to a method.
        optional_outputs_mask : Builds the ``option_mask`` consumed here.
        """
        tid = wp.tid()
        num_systems = wp.int32(cell.shape[0])

        has_cuda = (feature_mask & wp.int32(FEATURE_CUDA)) != wp.int32(0)
        is_float32 = (feature_mask & wp.int32(FEATURE_POSITIONS_FLOAT32)) != wp.int32(0)
        is_batched = (feature_mask & wp.int32(FEATURE_BATCHED)) != wp.int32(0)

        has_cutoff2 = (option_mask & wp.int32(_OPTION_CUTOFF2)) != wp.int32(0)
        half_fill = (option_mask & wp.int32(_OPTION_HALF_FILL)) != wp.int32(0)
        return_list = (
            option_mask & wp.int32(_OPTION_RETURN_NEIGHBOR_LIST)
        ) != wp.int32(0)
        has_target_indices = (
            option_mask & wp.int32(_OPTION_TARGET_INDICES)
        ) != wp.int32(0)
        has_vectors = (option_mask & wp.int32(_OPTION_RETURN_VECTORS)) != wp.int32(0)
        has_distances = (option_mask & wp.int32(_OPTION_RETURN_DISTANCES)) != wp.int32(
            0
        )
        use_pair_fn = (option_mask & wp.int32(_OPTION_USE_PAIR_FN)) != wp.int32(0)
        has_rebuild_flags = (option_mask & wp.int32(_OPTION_REBUILD_FLAGS)) != wp.int32(
            0
        )
        wrap_positions_false = (
            option_mask & wp.int32(_OPTION_WRAP_POSITIONS_FALSE)
        ) != wp.int32(0)
        has_pair_outputs = has_vectors or has_distances or use_pair_fn

        total_atoms = batch_ptr[num_systems]
        if tid == 0:
            if cutoff <= wp.float32(0.0) or max_nbins <= wp.int32(0):
                wp.atomic_max(flags, 0, wp.int32(1))
            if batch_ptr[0] != wp.int32(0) or total_atoms < wp.int32(0):
                wp.atomic_max(flags, 0, wp.int32(1))
            if has_cutoff2:
                wp.atomic_max(flags, 2, wp.int32(1))

        if batch_idx_is_provided and tid < total_atoms:
            atom_system = batch_idx[tid]
            if atom_system < wp.int32(0) or atom_system >= num_systems:
                wp.atomic_max(flags, 0, wp.int32(1))
                wp.atomic_max(flags, 5, wp.int32(1))
            else:
                if tid < batch_ptr[atom_system] or tid >= batch_ptr[atom_system + 1]:
                    wp.atomic_max(flags, 5, wp.int32(1))

        if tid >= num_systems:
            return

        start = batch_ptr[tid]
        end = batch_ptr[tid + 1]
        n_atoms = end - start
        if n_atoms < wp.int32(0):
            wp.atomic_max(flags, 0, wp.int32(1))
            n_atoms = wp.int32(0)

        cell_i = cell[tid]
        cell_volume = (
            cell_i[0, 0] * (cell_i[1, 1] * cell_i[2, 2] - cell_i[1, 2] * cell_i[2, 1])
            - cell_i[0, 1] * (cell_i[1, 0] * cell_i[2, 2] - cell_i[1, 2] * cell_i[2, 0])
            + cell_i[0, 2] * (cell_i[1, 0] * cell_i[2, 1] - cell_i[1, 1] * cell_i[2, 0])
        )
        volume = wp.float32(wp.abs(cell_volume))

        pbc_x = wp.bool(False)
        pbc_y = wp.bool(False)
        pbc_z = wp.bool(False)
        if pbc_is_batched:
            pbc_x = pbc_batch[tid, 0]
            pbc_y = pbc_batch[tid, 1]
            pbc_z = pbc_batch[tid, 2]
        else:
            pbc_x = pbc_single[0]
            pbc_y = pbc_single[1]
            pbc_z = pbc_single[2]
        any_pbc = pbc_x or pbc_y or pbc_z
        all_pbc = pbc_x and pbc_y and pbc_z

        n_float = wp.float32(n_atoms)
        active_atoms = n_atoms
        if has_target_indices:
            active_atoms = wp.int32(
                wp.ceil(
                    wp.float32(n_atoms)
                    * wp.float32(max(target_count, wp.int32(0)))
                    / wp.float32(max(total_atoms, wp.int32(1)))
                )
            )
            active_atoms = min(active_atoms, n_atoms)
        active_float = wp.float32(active_atoms)
        n_sq = n_float * n_float
        # Naive candidate count: each active central atom is tested against
        # all ``n`` atoms, so active_n_work ~ N^2 distance checks. n_pairs_cap =
        # n*(n-1) is the exact upper bound on real pairs.
        active_n_work = active_float * n_float
        n_pairs_cap = wp.max(
            n_float * wp.float32(max(n_atoms - wp.int32(1), 0)), wp.float32(0.0)
        )

        if volume <= wp.float32(_EPS):
            wp.atomic_max(flags, 2, wp.int32(1))
            wp.atomic_max(flags, 4, wp.int32(1))
            if any_pbc:
                wp.atomic_max(flags, 1, wp.int32(1))
            wp.atomic_add(costs, 0, n_sq)
            wp.atomic_add(costs, 1, n_sq)
            wp.atomic_add(costs, 2, wp.float32(_INF))
            wp.atomic_add(costs, 3, wp.float32(_INF))
            wp.atomic_add(costs, 4, wp.float32(_INF))
            return

        inverse_cell_transpose = wp.transpose(wp.inverse(cell_i))
        face_distance_x = wp.float32(1.0) / wp.float32(
            wp.length(inverse_cell_transpose[0])
        )
        face_distance_y = wp.float32(1.0) / wp.float32(
            wp.length(inverse_cell_transpose[1])
        )
        face_distance_z = wp.float32(1.0) / wp.float32(
            wp.length(inverse_cell_transpose[2])
        )
        min_face_distance = wp.min(
            face_distance_x, wp.min(face_distance_y, face_distance_z)
        )
        if min_face_distance <= wp.float32(_EPS):
            wp.atomic_max(flags, 2, wp.int32(1))
            wp.atomic_max(flags, 4, wp.int32(1))
            return

        shift_x = wp.int32(0)
        shift_y = wp.int32(0)
        shift_z = wp.int32(0)
        if any_pbc:
            if pbc_x:
                shift_x = wp.int32(
                    wp.ceil(wp.float32(wp.length(inverse_cell_transpose[0])) * cutoff)
                )
            if pbc_y:
                shift_y = wp.int32(
                    wp.ceil(wp.float32(wp.length(inverse_cell_transpose[1])) * cutoff)
                )
            if pbc_z:
                shift_z = wp.int32(
                    wp.ceil(wp.float32(wp.length(inverse_cell_transpose[2])) * cutoff)
                )

        shift_count = (
            wp.int64(shift_x) * wp.int64(2 * shift_y + 1) * wp.int64(2 * shift_z + 1)
            + wp.int64(shift_y) * wp.int64(2 * shift_z + 1)
            + wp.int64(shift_z)
            + wp.int64(1)
        )
        if shift_count <= wp.int64(0):
            wp.atomic_max(flags, 1, wp.int32(1))
            shift_count = wp.int64(1)
        if shift_count > max_launch_size:
            wp.atomic_max(flags, 1, wp.int32(1))
        if shift_count * wp.int64(max(total_atoms, wp.int32(1))) > max_launch_size:
            wp.atomic_max(flags, 1, wp.int32(1))
        if (
            shift_count
            * wp.int64(max(n_atoms, wp.int32(1)))
            * wp.int64(max(num_systems, wp.int32(1)))
            > max_launch_size
        ):
            wp.atomic_max(flags, 1, wp.int32(1))

        # Naive viability bound: above ~8e9 candidate pairs
        # (active_n_work * shift_count) naive stops being competitive with
        # cell_list, so mark it unsafe.
        if active_n_work * wp.float32(shift_count) > wp.float32(8.0e9):
            wp.atomic_max(flags, 1, wp.int32(1))

        cutoff_volume = cutoff * cutoff * cutoff
        # Floor against float32 underflow for tiny cutoffs so the cell-cost
        # division below (volume / cutoff_volume) cannot produce inf/NaN.
        cutoff_volume = wp.max(cutoff_volume, wp.float32(_EPS))
        density = n_float / wp.max(volume, wp.float32(_EPS))
        # Expected output work, shared by every method: neighbors per atom
        # (density * cutoff-sphere volume) times the active central atoms,
        # capped by the exact pair count for non-periodic and halved for half_fill.
        expected_neighbors = density * wp.float32(_SPHERE_VOLUME_FACTOR) * cutoff_volume
        expected_pairs = active_float * expected_neighbors
        if not any_pbc:
            expected_pairs = wp.min(expected_pairs, wp.min(n_pairs_cap, active_n_work))
        if half_fill:
            expected_pairs = expected_pairs * wp.float32(0.5)

        # This kernel accumulates per system, so constants here are charged
        # per system; the batched naive path launches once for the whole batch,
        # so its floor is amortised instead.
        system_share = wp.float32(1.0) / wp.float32(max(num_systems, wp.int32(1)))
        # Supplied by the host for this device; applies to float64 only.
        precision_scale = wp.float32(1.0)
        if not is_float32:
            precision_scale = fp64_scan_penalty

        pair_vacuum = wp.float32(1.0)
        if not any_pbc:
            pair_vacuum = wp.float32(_APERIODIC_PAIR_VACUUM)

        naive_setup = wp.float32(_NAIVE_FLOOR) * system_share
        if is_batched:
            naive_setup = (
                naive_setup
                + wp.float32(_BATCH_NAIVE_PER_SYSTEM)
                + wp.float32(_BATCH_NAIVE_PER_ATOM) * precision_scale * n_float
            )
        # Naive cost = per-candidate scan + per-pair output write + setup.
        # The scalar path pays for global loads where the tile path reuses
        # shared memory, so it carries the heavier scan weight.
        scalar_cost = (
            wp.float32(_NAIVE_SCAN * _NAIVE_SCALAR_TIEBREAK)
            * precision_scale
            * active_n_work
            * wp.float32(shift_count)
            + wp.float32(_NAIVE_WRITE) * expected_pairs
            + naive_setup
        )
        tile_cost = (
            wp.float32(_NAIVE_SCAN)
            * precision_scale
            * active_n_work
            * wp.float32(shift_count)
            + wp.float32(_NAIVE_WRITE) * expected_pairs
            + naive_setup
        )
        if (
            (not has_cuda)
            or has_pair_outputs
            or (any_pbc and is_batched and wrap_positions_false)
        ):
            wp.atomic_max(flags, 8, wp.int32(1))
        wp.atomic_add(costs, 0, scalar_cost)
        wp.atomic_add(costs, 1, tile_cost)

        cells_per_dimension = wp.vec3i(0, 0, 0)
        ADAPTIVE_MIN_CELLS = wp.int32(4)
        # Clamp the float->int cast so an extreme face_distance/cutoff ratio
        # cannot overflow int32; the halving loop below reduces the count to
        # <= max_nbins regardless, so capping per axis here is lossless.
        max_cells_f = wp.float32(max_nbins)
        cells_per_dimension[0] = max(
            wp.int32(wp.min(face_distance_x / cutoff, max_cells_f)), 1
        )
        cells_per_dimension[1] = max(
            wp.int32(wp.min(face_distance_y / cutoff, max_cells_f)), 1
        )
        cells_per_dimension[2] = max(
            wp.int32(wp.min(face_distance_z / cutoff, max_cells_f)), 1
        )

        for dim in range(3):
            pbc_dim = pbc_z
            if dim == 0:
                pbc_dim = pbc_x
            elif dim == 1:
                pbc_dim = pbc_y
            if pbc_dim or cells_per_dimension[dim] > wp.int32(1):
                while cells_per_dimension[dim] < ADAPTIVE_MIN_CELLS:
                    cells_per_dimension[dim] = cells_per_dimension[dim] * wp.int32(2)

        # Accumulate the cell-count product in int64 so a large grid cannot wrap
        # around int32 and slip past the guards below; the halving loop reduces
        # it to <= max_nbins, which always fits int32.
        total_cells_i64 = (
            wp.int64(cells_per_dimension[0])
            * wp.int64(cells_per_dimension[1])
            * wp.int64(cells_per_dimension[2])
        )
        while total_cells_i64 > wp.int64(max_nbins):
            for dim in range(3):
                cells_per_dimension[dim] = max(
                    cells_per_dimension[dim] // wp.int32(2), wp.int32(1)
                )
            total_cells_i64 = (
                wp.int64(cells_per_dimension[0])
                * wp.int64(cells_per_dimension[1])
                * wp.int64(cells_per_dimension[2])
            )
        total_cells_i32 = wp.int32(total_cells_i64)
        if total_cells_i32 <= wp.int32(0):
            wp.atomic_max(flags, 2, wp.int32(1))
            total_cells_i32 = wp.int32(1)
        if (
            wp.int64(total_cells_i32) * wp.int64(max(num_systems, wp.int32(1)))
            > max_launch_size
        ):
            wp.atomic_max(flags, 2, wp.int32(1))

        radius_x = wp.int32(
            wp.min(
                wp.ceil(cutoff * wp.float32(cells_per_dimension[0]) / face_distance_x),
                wp.float32(_MAX_STENCIL_RADIUS),
            )
        )
        radius_y = wp.int32(
            wp.min(
                wp.ceil(cutoff * wp.float32(cells_per_dimension[1]) / face_distance_y),
                wp.float32(_MAX_STENCIL_RADIUS),
            )
        )
        radius_z = wp.int32(
            wp.min(
                wp.ceil(cutoff * wp.float32(cells_per_dimension[2]) / face_distance_z),
                wp.float32(_MAX_STENCIL_RADIUS),
            )
        )
        radius_x = max(radius_x, wp.int32(0))
        radius_y = max(radius_y, wp.int32(0))
        radius_z = max(radius_z, wp.int32(0))

        # Candidates per source atom = stencil cells * atoms per cell, both
        # taken from the grid that is actually built rather than from a fixed
        # stencil and an idealised cell count.
        span_x = wp.int64(2) * wp.int64(radius_x) + wp.int64(1)
        span_y = wp.int64(2) * wp.int64(radius_y) + wp.int64(1)
        span_z = wp.int64(2) * wp.int64(radius_z) + wp.int64(1)
        stencil_cells = wp.float32(span_x * span_y * span_z)
        cost_cells = wp.max(wp.float32(total_cells_i32), wp.float32(1.0))
        grid_work = (
            (shell / wp.float32(_SHELL_REFERENCE))
            * stencil_cells
            * active_n_work
            / cost_cells
        )
        # The grid build is a single batched launch, so ``setup`` is amortised
        # rather than charged once per system.
        cell_atom_cost = (
            setup * system_share
            + wp.float32(_CELL_ATOM_SCAN) * grid_work
            + wp.float32(_CELL_ATOM_WRITE) * expected_pairs
        )
        n_outer = (
            wp.int64(radius_x) * span_y * span_z
            + wp.int64(radius_y) * span_z
            + wp.int64(radius_z)
        )
        if not half_fill:
            n_outer = span_x * span_y * span_z - wp.int64(1)
        n_outer = wp.min(n_outer, wp.int64(_MAX_STENCIL_OFFSETS))
        # Pair-centric launches one 64-thread block per (cell, neighbor-offset);
        # n_outer = stencil offsets (forward half only when half_fill).
        # pair_launch ~ total threads; pair_blocks ~ block count.
        pair_launch = wp.int64(total_cells_i32) * (n_outer + wp.int64(1)) * wp.int64(64)
        atom_blocks = wp.int64(max(n_atoms, wp.int32(1)) + wp.int32(63)) // wp.int64(64)
        pair_blocks = wp.int64(total_cells_i32) * (n_outer + wp.int64(1))
        if (not has_cuda) or pair_blocks < atom_blocks:
            wp.atomic_max(flags, 7, wp.int32(1))
        pair_launch_effective = wp.min(pair_launch, max_launch_size)
        # Pair-centric: cheaper per pair (0.55 write, 0.5 scan) but pays a
        # per-block launch term (0.025 * pair_launch_effective) for its many
        # small blocks (capped when coarsening is required).
        # Pair-centric launches per (cell, neighbour-offset), so every system
        # adds its own blocks and its floor stays per-system.
        cell_pair_cost = (
            setup * system_share
            + wp.float32(_CELL_PAIR_PER_ATOM) * n_float
            + (
                wp.float32(_BATCH_PAIR_PER_SYSTEM)
                + wp.float32(_BATCH_PAIR_PER_ATOM) * n_float
            )
            * wp.float32(is_batched)
            + wp.float32(_CELL_PAIR_WRITE) * expected_pairs
            + wp.float32(_CELL_PAIR_LAUNCH) * wp.float32(pair_launch_effective)
            + wp.float32(_CELL_PAIR_SCAN) * grid_work * pair_vacuum
        )
        wp.atomic_add(costs, 2, cell_atom_cost)
        wp.atomic_add(costs, 3, cell_pair_cost)

        if (
            half_fill
            or has_target_indices
            or use_pair_fn
            or has_rebuild_flags
            or (has_cutoff2 and (return_list or has_vectors or has_distances))
        ):
            wp.atomic_max(flags, 3, wp.int32(1))
        if (not has_cuda) or (not is_float32) or (not all_pbc):
            wp.atomic_max(flags, 4, wp.int32(1))
        if cutoff > wp.float32(0.5) * min_face_distance:
            wp.atomic_max(flags, 6, wp.int32(1))
        if is_batched and batch_idx_is_provided:
            # The per-atom validation above sets flag 5 when the dense labels
            # do not match the contiguous ranges implied by batch_ptr.
            pass

        cluster_large_enough = wp.bool(False)
        if is_batched:
            cluster_large_enough = total_atoms >= wp.int32(4096)
        else:
            cluster_large_enough = expected_neighbors >= wp.float32(512.0)
        if not cluster_large_enough:
            wp.atomic_max(flags, 4, wp.int32(1))

        # Cluster-tile one-time pipeline floor (Morton encode, sort, cluster
        # build, tile scan), added once per batch rather than per-system.
        if tid == 0:
            wp.atomic_add(costs, 4, wp.float32(_CLUSTER_TILE_PIPELINE))
        # Per-atom build plus the tile-query output write.
        cluster_cost = (
            wp.float32(_CLUSTER_TILE_WRITE) * expected_pairs
            + wp.float32(_CLUSTER_TILE_PER_ATOM) * n_float
            + setup
        )
        wp.atomic_add(costs, 4, cluster_cost)

    return _kernel


def _validate_selector_inputs(
    batch_ptr: wp.array,
    cell: wp.array,
    pbc: wp.array,
    cutoff: float,
    batch_idx: wp.array | None,
) -> int:
    """Validate shared selector inputs and return ``num_systems``."""
    if batch_ptr.dtype != wp.int32:
        raise ValueError("batch_ptr must be a wp.array with dtype=wp.int32")
    if batch_ptr.ndim != 1:
        raise ValueError("batch_ptr must be a 1-D wp.array")
    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 cell.shape[0] != num_systems:
        raise ValueError("cell must contain one matrix per system")
    if pbc.dtype != wp.bool:
        raise ValueError("pbc must be a wp.array with dtype=wp.bool")
    if pbc.ndim not in (1, 2):
        raise ValueError("pbc must have shape (3,) or (num_systems, 3)")
    if pbc.ndim == 1 and pbc.shape[0] != 3:
        raise ValueError("pbc must have shape (3,) or (num_systems, 3)")
    if pbc.ndim == 2 and (pbc.shape[0] != num_systems or pbc.shape[1] != 3):
        raise ValueError("pbc must have shape (3,) or (num_systems, 3)")
    device = str(batch_ptr.device)
    if str(cell.device) != device or str(pbc.device) != device:
        raise ValueError("batch_ptr, cell, and pbc must be on the same device")
    if batch_idx is not None:
        if batch_idx.dtype != wp.int32 or batch_idx.ndim != 1:
            raise ValueError("batch_idx must be a 1-D wp.array with dtype=wp.int32")
        if str(batch_idx.device) != device:
            raise ValueError("batch_idx must be on the same device as batch_ptr")
    if not float(cutoff) > 0.0:
        raise ValueError("cutoff must be positive")
    return num_systems


def _default_feature_mask(batch_ptr: wp.array, cell: wp.array, num_systems: int) -> int:
    """Return feature bits inferred by the shared Warp selector."""
    mask = 0
    if "cuda" in str(batch_ptr.device).lower():
        mask |= FEATURE_CUDA
    if _wp_scalar_from_cell_dtype(cell.dtype) == wp.float32:
        mask |= FEATURE_POSITIONS_FLOAT32
    if num_systems > 1:
        mask |= FEATURE_BATCHED
    return mask


[docs] def estimate_neighbor_list_costs( batch_ptr: wp.array, cell: wp.array, pbc: wp.array, cutoff: float, *, batch_idx: wp.array | None = None, max_nbins: int | None = None, max_launch_size: int = _MAX_WARP_LINEAR_LAUNCH, optional_outputs: Iterable[str] | None = None, option_mask: int = 0, feature_mask: int | None = None, target_count: int | None = None, ) -> list[tuple[str, float]]: """Report feasible neighbor-list strategies and their estimated cost. Parameters ---------- batch_ptr : wp.array, shape (num_systems + 1,), dtype=wp.int32 Cumulative atom counts. The final entry is the total atom count. cell : wp.array, shape (num_systems,), dtype=wp.mat33* Per-system cell matrices. Non-periodic callers should pass a synthesized bounding-box cell. pbc : wp.array, shape (3,) or (num_systems, 3), dtype=wp.bool Shared or per-system periodic-boundary flags. cutoff : float Neighbor cutoff. For dual-cutoff routing, pass the larger cutoff. batch_idx : wp.array, optional Dense per-atom batch ids. When provided, the selector validates that the labels match the contiguous ranges implied by ``batch_ptr`` before allowing auto cluster-tile. max_nbins : int, optional Per-system cell-list cap. Defaults to the single-system cap when ``num_systems == 1`` and the batched cap otherwise. max_launch_size : int, default=2**31 - 1 Conservative launch-size guard used by current neighbor-list kernels. optional_outputs : iterable of str, optional Public-style neighbor-list option names, encoded with :func:`optional_outputs_mask`. option_mask : int, default=0 Pre-encoded option bits to OR with ``optional_outputs``. feature_mask : int, optional Device/dtype/frontend feature bits. When omitted, CUDA and cell dtype are inferred from the Warp arrays. target_count : int, optional Number of compact central rows requested by ``target_indices``. When omitted, the selector scores all atoms. Frontends should pass ``len(target_indices)`` when the public ``target_indices`` kwarg is active. Returns ------- list of (str, float) Feasible strategies (from :data:`NEIGHBOR_LIST_STRATEGIES`) and their relative estimated cost (lower is faster), sorted cheapest-first. Batched inputs (``num_systems > 1``) return ``batch_`` prefixed names. Notes ----- The returned costs are *relative* (arbitrary units): only their ordering is meaningful, so compare them to each other, not to a wall-clock time. The model approximates algorithmic work (candidate pairs, neighbors written, launch overhead) and is **hardware-independent** -- the true crossover between strategies shifts with the device, so when the top costs are within a small factor the predicted best may be marginally slower than a close runner-up; benchmark the top few on your hardware in that case. This launches one Warp kernel over systems (and over atoms when validating ``batch_idx`` contiguity) and reads back five costs plus nine flags, so it is **host-only**: call it outside ``torch.compile`` / ``jax.jit`` and pass the chosen name as an explicit ``method=`` to run compiled. """ num_systems = _validate_selector_inputs(batch_ptr, cell, pbc, cutoff, batch_idx) if max_nbins is None: max_nbins = ( DEFAULT_SINGLE_MAX_NBINS if num_systems == 1 else DEFAULT_BATCH_MAX_NBINS ) if int(max_nbins) <= 0: raise ValueError("max_nbins must be positive") device = batch_ptr.device costs = wp.zeros(5, dtype=wp.float32, device=device) flags = wp.zeros(len(_FLAG_NAMES), dtype=wp.int32, device=device) shell, setup = auto_base_constants() fp64_scan_penalty = fp64_naive_penalty(device) pbc_is_batched = pbc.ndim == 2 pbc_single = pbc if not pbc_is_batched else empty_sentinel(1, wp.bool, device) pbc_batch = pbc if pbc_is_batched else empty_sentinel(2, wp.bool, device) batch_idx_is_provided = batch_idx is not None batch_idx_arg = ( batch_idx if batch_idx is not None else empty_sentinel(1, wp.int32, device) ) wp_dtype = _wp_scalar_from_cell_dtype(cell.dtype) feature_bits = _default_feature_mask(batch_ptr, cell, num_systems) if feature_mask is not None: feature_bits |= int(feature_mask) options = int(option_mask) | optional_outputs_mask(optional_outputs) if options & _OPTION_TARGET_INDICES and target_count is None: raise ValueError( "target_count is required when target_indices is included in " "optional_outputs" ) target_count_int = 0 if target_count is None else int(target_count) launch_dim = max( num_systems, int(batch_idx.shape[0]) if batch_idx is not None else 0 ) wp.launch( get_select_neighbor_list_method_cost_kernel(wp_dtype), dim=launch_dim, inputs=[ batch_ptr, batch_idx_arg, batch_idx_is_provided, cell, pbc_single, pbc_batch, pbc_is_batched, wp.float32(float(cutoff)), wp.float32(float(shell)), wp.float32(float(setup)), wp.float32(float(fp64_scan_penalty)), wp.int32(int(max_nbins)), wp.int64(int(max_launch_size)), wp.int32(options), wp.int32(feature_bits), wp.int32(target_count_int), costs, flags, ], device=device, ) strategies = finalize_neighbor_list_method(costs.numpy(), flags.numpy()) if num_systems > 1: strategies = [("batch_" + name, cost) for name, cost in strategies] return strategies
[docs] def suggest_neighbor_list_method(*args, **kwargs) -> str: """Return the cheapest feasible neighbor-list strategy name. Thin wrapper over :func:`estimate_neighbor_list_costs` returning only the top-ranked strategy name. Accepts the same arguments and carries the same host-only sync caveat: call outside ``torch.compile`` / ``jax.jit`` and pass the result as an explicit ``method=`` argument. Parameters ---------- *args Positional arguments forwarded to :func:`estimate_neighbor_list_costs`. **kwargs Keyword arguments forwarded to :func:`estimate_neighbor_list_costs`. Returns ------- str Name of the cheapest feasible strategy, e.g. ``"cell_list_atom_centric"`` or ``"batch_naive_tile"``. See Also -------- estimate_neighbor_list_costs : Full ranked list of feasible strategies with costs. """ return estimate_neighbor_list_costs(*args, **kwargs)[0][0]