Source code for nvalchemiops.jax.neighbors._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.


"""Private JAX neighbor-list dispatch helpers."""

from __future__ import annotations

from collections.abc import Iterable

import jax
import jax.numpy as jnp
import numpy as np
import warp as wp
from warp.jax_experimental import jax_kernel

from nvalchemiops.neighbors.base_dispatch import (
    _FLAG_NAMES,
    _OPTION_TARGET_INDICES,
    DEFAULT_BATCH_MAX_NBINS,
    DEFAULT_SINGLE_MAX_NBINS,
    FEATURE_BATCHED,
    FEATURE_CUDA,
    FEATURE_POSITIONS_FLOAT32,
    auto_base_constants,
    finalize_neighbor_list_method,
    get_select_neighbor_list_method_cost_kernel,
    neighbor_list_strategy_run_args,
    optional_outputs_mask,
)
from nvalchemiops.neighbors.base_dispatch import (
    estimate_neighbor_list_costs as _estimate_neighbor_list_costs_wp,
)

__all__ = [
    "_auto_base_method_from_geometry",
    "_auto_method_from_geometry",
    "_reject_unsupported_cluster_tile_combo",
    "estimate_neighbor_list_costs",
    "suggest_neighbor_list_method",
    "synthesize_cell_for_cell_list",
]

_jax_select_method_f32 = jax_kernel(
    get_select_neighbor_list_method_cost_kernel(wp.float32),
    num_outputs=2,
    in_out_argnames=["costs", "flags"],
    enable_backward=False,
)

_jax_select_method_f64 = jax_kernel(
    get_select_neighbor_list_method_cost_kernel(wp.float64),
    num_outputs=2,
    in_out_argnames=["costs", "flags"],
    enable_backward=False,
)


def _is_jax_cpu_array(array: jax.Array) -> bool:
    """Return whether ``array`` is backed by a CPU device."""
    try:
        return all(device.platform == "cpu" for device in array.devices())
    except AttributeError:
        return True


def _jax_selector_cpu_fallback(
    batch_ptr: jax.Array,
    batch_idx: jax.Array | None,
    cell: jax.Array,
    pbc: jax.Array,
    cutoff: float,
    *,
    max_nbins: int | None,
    option_mask: int,
    feature_mask: int,
    target_count: int | None,
) -> list[tuple[str, float]]:
    """Run the shared Warp selector directly on CPU for host-backed JAX arrays."""
    wp_batch_ptr = wp.array(
        np.asarray(jax.device_get(batch_ptr), dtype=np.int32),
        dtype=wp.int32,
        device="cpu",
    )
    wp_batch_idx = (
        wp.array(
            np.asarray(jax.device_get(batch_idx), dtype=np.int32),
            dtype=wp.int32,
            device="cpu",
        )
        if batch_idx is not None
        else None
    )
    cell_np = np.asarray(jax.device_get(cell))
    wp_cell_dtype = wp.mat33d if cell_np.dtype == np.float64 else wp.mat33f
    wp_cell = wp.array(cell_np, dtype=wp_cell_dtype, device="cpu")
    wp_pbc = wp.array(
        np.asarray(jax.device_get(pbc), dtype=np.bool_),
        dtype=wp.bool,
        device="cpu",
    )
    return _estimate_neighbor_list_costs_wp(
        wp_batch_ptr,
        wp_cell,
        wp_pbc,
        cutoff,
        batch_idx=wp_batch_idx,
        max_nbins=max_nbins,
        option_mask=option_mask,
        feature_mask=feature_mask,
        target_count=target_count,
    )


def _selector_batch_ptr_from_geometry(
    positions: jax.Array,
    batch_idx: jax.Array | None,
    batch_ptr: jax.Array | None,
    num_systems: int,
) -> jax.Array:
    """Return an int32 batch pointer for selector metadata."""
    if batch_ptr is not None:
        return batch_ptr.astype(jnp.int32)
    if batch_idx is not None:
        counts = jnp.bincount(batch_idx, length=int(num_systems)).astype(jnp.int32)
        return jnp.concatenate([jnp.zeros(1, dtype=jnp.int32), jnp.cumsum(counts)])

    base = positions.shape[0] // max(int(num_systems), 1)
    counts = jnp.full((int(num_systems),), base, dtype=jnp.int32)
    if num_systems > 0:
        counts = counts.at[0].add(positions.shape[0] - base * int(num_systems))
    return jnp.concatenate([jnp.zeros(1, dtype=jnp.int32), jnp.cumsum(counts)])


def _synthesize_cell_for_geometry(
    positions: jax.Array,
    batch_idx: jax.Array | None,
    batch_ptr: jax.Array,
    cutoff: float,
) -> tuple[jax.Array, jax.Array]:
    """Build non-PBC bounding-box cells for selector metadata."""
    num_systems = int(batch_ptr.shape[0]) - 1
    padding = jnp.asarray(float(cutoff) * 0.1, dtype=positions.dtype)
    if batch_idx is None or num_systems == 1:
        pos_min = jnp.min(positions, axis=0)
        shifted = positions - pos_min
        lengths = jnp.max(shifted, axis=0) + padding
        cell = jnp.diag(lengths).reshape(1, 3, 3)
        if num_systems > 1:
            cell = jnp.broadcast_to(cell, (num_systems, 3, 3))
        pbc = jnp.zeros((num_systems, 3), dtype=jnp.bool_)
        return cell, pbc

    pos_min = jax.ops.segment_min(positions, batch_idx, num_segments=num_systems)
    pos_max = jax.ops.segment_max(positions, batch_idx, num_segments=num_systems)
    counts = batch_ptr[1:] - batch_ptr[:-1]
    lengths = pos_max - pos_min + padding
    lengths = jnp.where(counts[:, None] > 0, lengths, padding)
    cell = lengths[:, :, None] * jnp.eye(3, dtype=positions.dtype)
    pbc = jnp.zeros((num_systems, 3), dtype=jnp.bool_)
    return cell, pbc


def synthesize_cell_for_cell_list(
    positions: jax.Array,
    cutoff: float,
    batch_idx: jax.Array | None = None,
    batch_ptr: jax.Array | None = None,
    num_systems: int = 1,
) -> tuple[jax.Array, jax.Array, jax.Array]:
    """Build shifted non-PBC bounding-box cells for JAX cell-list dispatch.

    Translates each system's atoms so their bounding box starts at the origin,
    then returns an axis-aligned cell sized to that bounding box plus a small
    ``cutoff``-proportional padding. All PBC flags are set to ``False``.

    Parameters
    ----------
    positions : jax.Array, shape (N, 3)
        Atomic positions in Cartesian space.
    cutoff : float
        Neighbour cutoff radius; used to compute the bounding-box padding
        (``cutoff * 0.1``).
    batch_idx : jax.Array, shape (N,), dtype=int32, optional
        System index per atom. Pass ``None`` for single-system mode or when
        ``batch_ptr`` is provided instead.
    batch_ptr : jax.Array, shape (num_systems + 1,), dtype=int32, optional
        CSR-style cumulative atom counts. Must satisfy
        ``batch_ptr.shape[0] >= 2``. When both ``batch_idx`` and ``batch_ptr``
        are ``None`` all atoms are treated as one system.
    num_systems : int, optional
        Number of systems. Ignored when ``batch_ptr`` is provided (inferred
        from its length). Default is ``1``.

    Returns
    -------
    shifted : jax.Array, shape (N, 3)
        Positions shifted so each system's bounding box starts at the origin.
    cell : jax.Array, shape (3, 3) or (num_systems, 3, 3)
        Axis-aligned bounding-box cell matrix per system. Shape is ``(3, 3)``
        for a single system without ``batch_idx``/``batch_ptr``, otherwise
        ``(num_systems, 3, 3)``.
    pbc : jax.Array, shape (3,) or (num_systems, 3), dtype=bool
        All-``False`` PBC flags. Shape mirrors ``cell``.

    See Also
    --------
    :func:`nvalchemiops.jax.neighbors._dispatch.estimate_neighbor_list_costs` : Estimate strategy costs using a cell and PBC array.
    """
    if batch_ptr is not None and int(batch_ptr.shape[0]) < 2:
        raise ValueError("batch_ptr must have length at least 2")
    num_systems = int(num_systems)
    if positions.shape[0] == 0:
        cell = jnp.tile(jnp.eye(3, dtype=positions.dtype), (num_systems, 1, 1))
        pbc = jnp.zeros((num_systems, 3), dtype=jnp.bool_)
        return positions, cell, pbc

    padding = jnp.asarray(float(cutoff) * 0.1, dtype=positions.dtype)
    if batch_idx is None and batch_ptr is not None:
        counts = batch_ptr[1:] - batch_ptr[:-1]
        batch_idx = jnp.repeat(jnp.arange(num_systems, dtype=jnp.int32), counts)

    if batch_idx is None or num_systems == 1:
        pos_min = jnp.min(positions, axis=0)
        shifted = positions - pos_min
        lengths = jnp.max(shifted, axis=0) + padding
        cell = jnp.diag(lengths).reshape(1, 3, 3)
        if num_systems > 1:
            cell = jnp.broadcast_to(cell, (num_systems, 3, 3))
            pbc = jnp.zeros((num_systems, 3), dtype=jnp.bool_)
        else:
            pbc = jnp.zeros((3,), dtype=jnp.bool_)
        return shifted, cell, pbc

    pos_min = jax.ops.segment_min(positions, batch_idx, num_segments=num_systems)
    pos_max = jax.ops.segment_max(positions, batch_idx, num_segments=num_systems)
    counts = (
        batch_ptr[1:] - batch_ptr[:-1]
        if batch_ptr is not None
        else jnp.bincount(batch_idx, length=num_systems)
    )
    lengths = pos_max - pos_min + padding
    lengths = jnp.where(counts[:, None] > 0, lengths, padding)
    shifted = positions - pos_min[batch_idx]
    cell = lengths[:, :, None] * jnp.eye(3, dtype=positions.dtype)
    pbc = jnp.zeros((num_systems, 3), dtype=jnp.bool_)
    return shifted, cell, pbc


def _normalize_selector_cell_pbc(
    cell: jax.Array,
    pbc: jax.Array,
    num_systems: int,
) -> tuple[jax.Array, jax.Array]:
    """Return selector metadata with one cell per system."""
    if cell.ndim == 2:
        cell = cell[jnp.newaxis, :, :]
    if cell.ndim != 3 or cell.shape[-2:] != (3, 3):
        raise ValueError("cell must have shape (3, 3) or (num_systems, 3, 3)")
    if cell.shape[0] == 1 and num_systems > 1:
        cell = jnp.broadcast_to(cell, (num_systems, 3, 3))
    if cell.shape[0] != num_systems:
        raise ValueError("cell must contain one matrix per system")

    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] == 1 and num_systems > 1:
        pbc = jnp.broadcast_to(pbc, (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)")
    return cell, pbc.astype(jnp.bool_)


[docs] def estimate_neighbor_list_costs( batch_ptr: jax.Array, cell: jax.Array, pbc: jax.Array, cutoff: float, *, batch_idx: jax.Array | None = None, max_nbins: int | None = None, optional_outputs: Iterable[str] | None = None, cutoff2: float | None = None, half_fill: bool = False, return_neighbor_list: bool = False, target_indices: jax.Array | None = None, return_vectors: bool = False, return_distances: bool = False, use_pair_fn: bool = False, rebuild_flags: jax.Array | None = None, wrap_positions: bool = True, positions_dtype=None, ) -> list[tuple[str, float]]: """Report feasible JAX neighbor-list strategies and their estimated cost. Parameters ---------- batch_ptr : jax.Array, shape (num_systems + 1,), dtype=jnp.int32 Cumulative atom counts. ``batch_ptr[-1]`` is the total atom count. cell : jax.Array, shape (3, 3) or (num_systems, 3, 3) Per-system cells, or one shared cell to broadcast. pbc : jax.Array, shape (3,) or (num_systems, 3), dtype=bool Shared or per-system PBC flags. cutoff : float Neighbor cutoff. max_nbins : int, optional Per-system cell-list cell cap. Defaults to the same cap used by the active single-system or batched frontend. Returns ------- list of (str, float) Feasible strategies and their relative estimated cost (lower is faster), sorted cheapest-first. Host-only: this syncs a tiny selector result, so call it outside ``jax.jit`` and pass the chosen name as an explicit ``method=``. """ if batch_ptr.ndim != 1: raise ValueError("batch_ptr must be a 1-D array") if int(batch_ptr.shape[0]) < 2: raise ValueError("batch_ptr must have length at least 2") num_systems = int(batch_ptr.shape[0]) - 1 cell, pbc = _normalize_selector_cell_pbc(cell, pbc, num_systems) batch_ptr = batch_ptr.astype(jnp.int32) batch_idx_is_provided = batch_idx is not None if batch_idx is not None: batch_idx = batch_idx.astype(jnp.int32) 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") costs = jnp.zeros(5, dtype=jnp.float32) flags = jnp.zeros(len(_FLAG_NAMES), dtype=jnp.int32) shell, setup = auto_base_constants() pbc_is_batched = pbc.ndim == 2 pbc_single = pbc if not pbc_is_batched else jnp.zeros((0,), dtype=jnp.bool_) pbc_batch = pbc if pbc_is_batched else jnp.zeros((0, 3), dtype=jnp.bool_) batch_idx_arg = ( batch_idx if batch_idx is not None else jnp.zeros((0,), dtype=jnp.int32) ) feature_mask = 0 try: if ( "gpu" in str(cell.devices()).lower() or "cuda" in str(cell.devices()).lower() ): feature_mask |= FEATURE_CUDA except (AttributeError, TypeError): pass position_dtype = cell.dtype if positions_dtype is None else positions_dtype if position_dtype == jnp.float32: feature_mask |= FEATURE_POSITIONS_FLOAT32 if num_systems > 1: feature_mask |= FEATURE_BATCHED options = optional_outputs_mask( optional_outputs, cutoff2=cutoff2, half_fill=half_fill, return_neighbor_list=return_neighbor_list, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, use_pair_fn=use_pair_fn, rebuild_flags=rebuild_flags, wrap_positions=wrap_positions, ) target_count = int(target_indices.shape[0]) if target_indices is not None else 0 target_count_arg = target_count if target_indices is not None else None if options & _OPTION_TARGET_INDICES and target_indices is None: raise ValueError( "target_count is required when target_indices is included in " "optional_outputs" ) if _is_jax_cpu_array(cell): return _jax_selector_cpu_fallback( batch_ptr, batch_idx, cell, pbc, cutoff, max_nbins=max_nbins, option_mask=options, feature_mask=feature_mask, target_count=target_count_arg, ) if cell.dtype == jnp.float64: kernel = _jax_select_method_f64 cell = cell.astype(jnp.float64) else: kernel = _jax_select_method_f32 cell = cell.astype(jnp.float32) costs, flags = kernel( batch_ptr, batch_idx_arg, batch_idx_is_provided, cell, pbc_single, pbc_batch, pbc_is_batched, float(cutoff), float(shell), float(setup), int(max_nbins), int(2**31 - 1), int(options), int(feature_mask), int(target_count), costs, flags, launch_dims=( max(num_systems, int(batch_idx.shape[0]) if batch_idx is not None else 0), ), ) strategies = finalize_neighbor_list_method( jax.device_get(costs), jax.device_get(flags) ) 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 JAX neighbor-list strategy name. Thin wrapper over :func:`nvalchemiops.jax.neighbors._dispatch.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 ``jax.jit`` and pass the result as an explicit ``method=`` argument. Parameters ---------- *args Positional arguments forwarded to :func:`nvalchemiops.jax.neighbors._dispatch.estimate_neighbor_list_costs`. **kwargs Keyword arguments forwarded to :func:`nvalchemiops.jax.neighbors._dispatch.estimate_neighbor_list_costs`. Returns ------- str Name of the cheapest feasible strategy, e.g. ``"cell_list_atom_centric"`` or ``"batch_naive_tile"``. See Also -------- :func:`nvalchemiops.jax.neighbors._dispatch.estimate_neighbor_list_costs` : Full ranked list of feasible strategies with costs. """ return estimate_neighbor_list_costs(*args, **kwargs)[0][0]
def _reject_unsupported_cluster_tile_combo( pbc: jax.Array | None, half_fill: bool, ) -> None: """Raise ``NotImplementedError`` for unsupported explicit cluster-tile combos.""" if pbc is None: raise NotImplementedError( "method='cluster_tile' / 'batch_cluster_tile' is " "fundamentally PBC-implicit; non-periodic systems (pbc=None) " "are not supported. Use method='naive' or 'cell_list', " "or pass a cell with fully periodic pbc." ) try: all_periodic = bool(jax.device_get(jnp.all(pbc))) except RuntimeError: all_periodic = True if not all_periodic: raise NotImplementedError( "method='cluster_tile' / 'batch_cluster_tile' is " "fundamentally PBC-implicit; pbc with any False entry " "is not supported. Use method='naive' or 'cell_list'." ) if half_fill: raise NotImplementedError( "method='cluster_tile' / 'batch_cluster_tile' uses " "tile-level upper-triangular iteration; atom-level " "half_fill=True is not supported. Use method='naive' or " "'cell_list' for half-fill output." ) def _auto_method_from_geometry( positions: jax.Array, cutoff: float, cell: jax.Array | None, pbc: jax.Array | None, batch_idx: jax.Array | None, batch_ptr: jax.Array | None, num_systems: int, *, optional_outputs: Iterable[str] | None = None, cutoff2: float | None = None, half_fill: bool = False, return_neighbor_list: bool = False, target_indices: jax.Array | None = None, return_vectors: bool = False, return_distances: bool = False, use_pair_fn: bool = False, rebuild_flags: jax.Array | None = None, wrap_positions: bool = True, ) -> str: """Return the cheapest base strategy name for the geometry (no ``batch_``). The ``batch_`` prefix is added by the caller from ``num_systems``; this returns the unbatched fine-grained name, e.g. ``"naive_tile"``. """ if positions.shape[0] == 0: return "cell_list_atom_centric" selector_batch_ptr = _selector_batch_ptr_from_geometry( positions, batch_idx, batch_ptr, num_systems ) if cell is None: selector_cell, selector_pbc = _synthesize_cell_for_geometry( positions, batch_idx, selector_batch_ptr, cutoff ) else: if pbc is None: raise ValueError("pbc is required when cell is provided") selector_cell = cell selector_pbc = pbc name = suggest_neighbor_list_method( selector_batch_ptr, selector_cell, selector_pbc, cutoff, batch_idx=batch_idx, optional_outputs=optional_outputs, cutoff2=cutoff2, half_fill=half_fill, return_neighbor_list=return_neighbor_list, target_indices=target_indices, return_vectors=return_vectors, return_distances=return_distances, use_pair_fn=use_pair_fn, rebuild_flags=rebuild_flags, wrap_positions=wrap_positions, positions_dtype=positions.dtype, ) return name[len("batch_") :] if name.startswith("batch_") else name def _auto_base_method_from_geometry( positions: jax.Array, cutoff: float, cell: jax.Array | None, pbc: jax.Array | None, batch_idx: jax.Array | None, batch_ptr: jax.Array | None, num_systems: int, ) -> str: """Pick the base method (``naive`` / ``cell_list`` / ``cluster_tile``).""" name = _auto_method_from_geometry( positions, cutoff, cell, pbc, batch_idx, batch_ptr, num_systems ) return neighbor_list_strategy_run_args(name)[0]