Source code for nvalchemiops.torch.interactions.electrostatics.pme

# 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 Particle Mesh Ewald (PME)
==============================================

This module provides PyTorch bindings for the Particle Mesh Ewald algorithm,
wrapping Warp kernels with PyTorch custom operators for autograd support.

The PME module has unique challenges - it requires FFT operations that Warp
doesn't support. The Warp layer provides building blocks (Green's function,
energy corrections), but the complete PME workflow must remain in framework
bindings due to FFT dependency on PyTorch.

This module provides a unified GPU-accelerated API for Particle Mesh Ewald that
handles both single-system and batched calculations transparently. PME achieves
:math:`O(N \\log N)` scaling compared to :math:`O(N^2)` for direct summation, making it efficient
for large systems.

The output dtype convention follows ewald.py: public energy, force, and virial
outputs preserve input precision while selected internal reductions use float64
for numerical stability.

API STRUCTURE
=============

Primary APIs (public, with autograd support):
    particle_mesh_ewald(): Complete PME calculation (real + reciprocal)
    pme_reciprocal_space(): Reciprocal-space FFT-based component only

Helper APIs:
    pme_energy_corrections(): Self-energy and background corrections

The batch_idx parameter determines kernel dispatch:
    batch_idx=None -- Single-system kernels
    batch_idx provided -- Batch kernels (multiple independent systems)

``particle_mesh_ewald`` treats energy autograd as the differentiable training
contract; its direct-output flags warn and are deprecated. The
``pme_reciprocal_space`` component intentionally retains direct forces as
no-autograd MD/inference escape hatches. Component charge-gradient, virial,
and hybrid direct outputs are deprecated training-style outputs and warn.

MATHEMATICAL FORMULATION
========================

PME uses B-spline interpolation to assign charges to a mesh, computes the
convolution with the Coulomb kernel efficiently via FFT, then interpolates
back to get energies and forces.

.. math::

    E_{\\text{total}} = E_{\\text{real}} + E_{\\text{reciprocal}} - E_{\\text{self}} - E_{\\text{background}}

Reciprocal-Space Steps:

1. Charge assignment:

.. math::

    Q(x) = \\sum_i q_i M_p(x - r_i)

where :math:`M_p` is the pth-order cardinal B-spline

2. FFT:

.. math::

    \\tilde{Q}(k) = \\text{FFT}[Q(x)]

3. Convolution in k-space:

.. math::

    \\tilde{\\Phi}(k) = \\frac{G(k)}{C^2(k)} \\tilde{Q}(k)

where :math:`G(k) = \\frac{2\\pi}{V} \\frac{\\exp(-k^2/(4\\alpha^2))}{k^2}` and :math:`C(k) = [\\text{sinc products}]^p` is the B-spline correction

4. Inverse FFT for potential and field:

.. math::

    \\begin{aligned}
    \\Phi(x) &= \\text{IFFT}[\\tilde{\\Phi}(k)] \\\\
    E(x) &= \\text{IFFT}[-ik \\tilde{\\Phi}(k)]
    \\end{aligned}

5. Energy and force interpolation:

.. math::

    \\begin{aligned}
    E_i &= q_i \\cdot \\text{interpolate}(\\Phi, r_i) \\\\
    F_i &= q_i \\cdot \\text{interpolate}(E, r_i)
    \\end{aligned}

Corrections:

.. math::

    \\begin{aligned}
    E_{\\text{self}} &= \\sum_i \\frac{\\alpha}{\\sqrt{\\pi}} q_i^2 \\\\
    E_{\\text{background}} &= \\sum_i \\frac{\\pi}{2\\alpha^2 V} q_i Q_{\\text{total}}
    \\end{aligned}

Examples
--------

Automatic parameter estimation::

    >>> from nvalchemiops.torch.interactions.electrostatics import particle_mesh_ewald
    >>> energies = particle_mesh_ewald(
    ...     positions, charges, cell,
    ...     neighbor_list=nl, neighbor_ptr=nl_ptr, neighbor_shifts=shifts,
    ...     accuracy=1e-6,  # alpha and mesh estimated automatically
    ... )
    >>> forces = -torch.autograd.grad(energies.sum(), positions, create_graph=True)[0]

Explicit parameters::

    >>> energies = particle_mesh_ewald(
    ...     positions, charges, cell,
    ...     alpha=0.3,
    ...     mesh_dimensions=(32, 32, 32),
    ...     spline_order=4,
    ...     neighbor_list=nl, neighbor_ptr=nl_ptr, neighbor_shifts=shifts,
    ... )

Batched systems::

    >>> energies = particle_mesh_ewald(
    ...     positions, charges, cells,  # cells shape (B, 3, 3)
    ...     alpha=torch.tensor([0.3, 0.35]),
    ...     batch_idx=batch_idx,
    ...     mesh_dimensions=(32, 32, 32),
    ...     neighbor_list=nl, neighbor_ptr=nl_ptr, neighbor_shifts=shifts,
    ... )

Reciprocal-space only (no real-space)::

    >>> energies = pme_reciprocal_space(
    ...     positions, charges, cell,
    ...     alpha=0.3, mesh_dimensions=(32, 32, 32),
    ... )
References
----------

- Essmann et al. (1995). J. Chem. Phys. 103, 8577 (SPME paper)
- Darden et al. (1993). J. Chem. Phys. 98, 10089 (Original PME)
- torchpme: https://github.com/lab-cosmo/torch-pme (Reference implementation)
"""

import math
import warnings
from contextlib import nullcontext

import torch
import warp as wp

from nvalchemiops.interactions.electrostatics.pme_kernels import (
    batch_pme_energy_corrections_with_charge_grad as _batch_pme_energy_corrections_with_charge_grad_warp,
)
from nvalchemiops.interactions.electrostatics.pme_kernels import (
    pme_energy_corrections_with_charge_grad as _pme_energy_corrections_with_charge_grad_warp,
)
from nvalchemiops.interactions.electrostatics.pme_kernels import (
    pme_virial_bg_correction as _pme_virial_bg_correction_warp,
)
from nvalchemiops.interactions.electrostatics.pme_kernels import (
    pme_virial_bg_correction_backward as _pme_virial_bg_correction_backward_warp,
)
from nvalchemiops.torch._warp_op_helpers import (
    attach_simple_backward,
    register_warp_op_chain,
)
from nvalchemiops.torch.interactions.electrostatics._registration import (
    ensure_electrostatics_ops_registered,
)
from nvalchemiops.torch.interactions.electrostatics._util import (
    _build_electrostatic_result,
    _combine_electrostatic_outputs,
    _compiled_direct_output_deprecation_signal,
    _component_direct_output_deprecation_msg,
    _detach_setup_tensor,
    _direct_output_deprecation_msg,
    _has_potentially_geometry_dependent_charges,
    _InjectCachedEvalGrad,
    _InjectCachedEvalGradWithFallback,
    _InjectChargeGrad,
    _is_uniform_cotangent,
    _unpack_electrostatic_outputs,
)
from nvalchemiops.torch.interactions.electrostatics.ewald import (
    ewald_real_space,
)
from nvalchemiops.torch.interactions.electrostatics.k_vectors import (
    generate_k_vectors_pme,
)
from nvalchemiops.torch.interactions.electrostatics.parameters import (
    estimate_pme_mesh_dimensions,
    estimate_pme_parameters,
    mesh_spacing_to_dimensions,
)
from nvalchemiops.torch.interactions.electrostatics.slab import (
    _prepare_pbc_for_slab,
)
from nvalchemiops.torch.interactions.electrostatics.slab import (
    compute_slab_correction as _compute_slab_correction,
)
from nvalchemiops.torch.spline import (
    spline_gather,
    spline_gather_with_force,
    spline_spread,
)
from nvalchemiops.torch.types import get_wp_dtype

_PME_OPS_REGISTERED = False

# Mathematical constants
PI = math.pi
TWOPI = 2.0 * PI
FOURPI = 4.0 * PI


###########################################################################################
########################### Helper Functions ##############################################
###########################################################################################


def _prepare_alpha(
    alpha: float | torch.Tensor,
    num_systems: int,
    dtype: torch.dtype,
    device: torch.device,
) -> torch.Tensor:
    """Convert alpha to a per-system tensor.

    Parameters
    ----------
    alpha : float or torch.Tensor
        Ewald splitting parameter. Can be:
        - A scalar float (broadcast to all systems)
        - A 0-d tensor (broadcast to all systems)
        - A 1-d tensor of shape (num_systems,) for per-system values
    num_systems : int
        Number of systems in the batch.
    dtype : torch.dtype
        Target dtype (typically float64).
    device : torch.device
        Target device.

    Returns
    -------
    torch.Tensor, shape (num_systems,)
        Per-system alpha values.
    """
    if isinstance(alpha, (int, float)):
        return torch.full((num_systems,), float(alpha), dtype=dtype, device=device)
    elif isinstance(alpha, torch.Tensor):
        if alpha.dim() == 0:
            return alpha.expand(num_systems).to(dtype=dtype, device=device)
        elif alpha.shape[0] != num_systems:
            raise ValueError(
                f"alpha has {alpha.shape[0]} values but there are {num_systems} systems"
            )
        return alpha.to(dtype=dtype, device=device)
    else:
        raise TypeError(f"alpha must be float or torch.Tensor, got {type(alpha)}")


def _prepare_cell(cell: torch.Tensor) -> tuple[torch.Tensor, int]:
    """Ensure cell is 3D (B, 3, 3) and return number of systems.

    Parameters
    ----------
    cell : torch.Tensor
        Unit cell matrix. Shape (3, 3) for single system or (B, 3, 3) for batch.

    Returns
    -------
    cell : torch.Tensor, shape (B, 3, 3)
        Cell with batch dimension.
    num_systems : int
        Number of systems (B).
    """
    if cell.dim() == 2:
        cell = cell.unsqueeze(0)
    return cell, cell.shape[0]


def _materialize_complex(tensor: torch.Tensor) -> torch.Tensor:
    """Force a fresh complex tensor for compiled FFT consumers."""
    if not tensor.is_complex():
        return tensor
    return torch.complex(tensor.real, tensor.imag)


def _vec2_wp_dtype_for(real_dtype: torch.dtype):
    """Map torch real dtype to the corresponding Warp vec2 type."""
    import warp as _wp

    return _wp.vec2f if real_dtype == torch.float32 else _wp.vec2d


def _pme_scoped_warp_stream(device: torch.device):
    """Bind Warp's current stream to PyTorch's current CUDA stream.

    Required for ``torch.cuda.graph`` capture so Warp kernel launches end
    up on the stream being captured rather than Warp's default stream.
    """
    if device.type != "cuda":
        return nullcontext()
    torch_stream = torch.cuda.current_stream(device)
    return wp.ScopedStream(wp.stream_from_torch(torch_stream))


def _wp_from_torch(tensor: torch.Tensor, dtype):
    """``wp.from_torch`` with shadow-gradient allocation disabled.

    Default ``wp.from_torch`` inherits ``requires_grad`` from the source
    tensor and allocates a Warp-side gradient buffer when True. That
    allocation breaks ``torch.cuda.graph`` capture
    (``cudaErrorStreamCaptureInvalidated``). Our autograd.Functions own
    the backward, so the shadow grad is unused — force ``requires_grad=False``.
    """
    return wp.from_torch(tensor, dtype=dtype, requires_grad=False)


[docs] def compute_bspline_moduli_1d( miller_indices: torch.Tensor, mesh_N: int, spline_order: int, ) -> torch.Tensor: """Precompute the 1D B-spline modulus LUT for one PME mesh axis. Returns ``b[i] = sinc(m_i / N)^spline_order`` for each miller index ``m_i`` (with ``sinc(x) = sin(pi*x)/(pi*x)``, ``sinc(0) = 1``). The three-axis product ``b_x[i] * b_y[j] * b_z[k]`` is the B-spline structure factor consumed by the factory-backed convolve kernel after a 1e-10 clamp + square. Precomputing the LUT lets the convolve kernel replace three sinc transcendentals + an order-dependent power loop per (i, j, k) thread with three reads + two multiplies. Parameters ---------- miller_indices : torch.Tensor, shape (M,) Integer Miller indices along one mesh axis, typically produced by ``torch.fft.fftfreq`` or ``torch.fft.rfftfreq`` scaled by ``mesh_N``. mesh_N : int Number of mesh points along this axis. spline_order : int B-spline interpolation order ``p``. The modulus is :math:`\\operatorname{sinc}(m/N)^p`. Returns ------- torch.Tensor, shape (M,) 1D B-spline modulus values, one per Miller index. Same dtype as ``miller_indices``. """ # sinc(x) for x in [-0.5, 0.5] is bounded in [2/pi, 1], so s^spline_order # (for orders 2-6) stays well within fp32 range. Stay in the input dtype # to avoid an fp32 -> fp64 -> fp32 round-trip every call. arg = miller_indices / float(mesh_N) s = torch.special.sinc(arg) return s**spline_order
def pme_green_structure_factor( k_squared: torch.Tensor, mesh_dimensions: tuple[int, int, int], alpha: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: r"""Compute the PME Green's function and B-spline structure-factor correction. Compatibility entry point for the public PME API. Returns the volume-normalized Coulomb Green's function and the squared B-spline structure factor used for PME deconvolution: .. math:: G(k) = \frac{2\pi}{V} \frac{\exp(-k^2/(4\alpha^2))}{k^2}, \qquad C^2(k) = \left[\operatorname{sinc}(m_x/N_x)\,\operatorname{sinc}(m_y/N_y)\, \operatorname{sinc}(m_z/N_z)\right]^{2p} with :math:`G(0)=0` (tin-foil boundary) and ``p = spline_order``. Parameters ---------- k_squared : torch.Tensor ``|k|^2`` at each rfft grid point: ``(Nx, Ny, Nz_rfft)`` (single) or ``(B, Nx, Ny, Nz_rfft)`` (batch). mesh_dimensions : tuple[int, int, int] Full mesh ``(Nx, Ny, Nz)`` before rfft. alpha : torch.Tensor Ewald splitting parameter, shape ``(1,)`` or ``(B,)``. cell : torch.Tensor Unit cell(s): ``(3, 3)``, ``(1, 3, 3)``, or ``(B, 3, 3)``. spline_order : int, default=4 B-spline interpolation order. batch_idx : torch.Tensor | None, default=None When provided, ``k_squared``/``alpha``/``cell`` are treated as batched. Returns ------- green_function : torch.Tensor Volume-normalized :math:`G(k)`, same shape as ``k_squared``. structure_factor_sq : torch.Tensor :math:`C^2(k)`, shape ``(Nx, Ny, Nz_rfft)`` (mesh-only, shared across batch). """ mesh_nx, mesh_ny, mesh_nz = mesh_dimensions device = k_squared.device input_dtype = k_squared.dtype cell3 = cell if cell.dim() == 3 else cell.unsqueeze(0) volume = torch.abs(torch.linalg.det(cell3)).to(input_dtype) alpha_flat = alpha.reshape(-1).to(input_dtype) ksq_safe = torch.where(k_squared < 1e-10, torch.ones_like(k_squared), k_squared) if batch_idx is None: inv_4a2 = 1.0 / (4.0 * alpha_flat[0] * alpha_flat[0]) green = (2.0 * torch.pi / volume[0]) * torch.exp(-ksq_safe * inv_4a2) / ksq_safe else: b = k_squared.shape[0] inv_4a2 = (1.0 / (4.0 * alpha_flat * alpha_flat)).view(b, 1, 1, 1) vol_b = volume.view(b, 1, 1, 1) green = (2.0 * torch.pi / vol_b) * torch.exp(-ksq_safe * inv_4a2) / ksq_safe green = torch.where(k_squared < 1e-10, torch.zeros_like(green), green) miller_x = torch.fft.fftfreq( mesh_nx, d=1.0 / mesh_nx, device=device, dtype=input_dtype ) miller_y = torch.fft.fftfreq( mesh_ny, d=1.0 / mesh_ny, device=device, dtype=input_dtype ) miller_z = torch.fft.rfftfreq( mesh_nz, d=1.0 / mesh_nz, device=device, dtype=input_dtype ) c = ( compute_bspline_moduli_1d(miller_x, mesh_nx, spline_order)[:, None, None] * compute_bspline_moduli_1d(miller_y, mesh_ny, spline_order)[None, :, None] * compute_bspline_moduli_1d(miller_z, mesh_nz, spline_order)[None, None, :] ) structure_factor_sq = c.clamp_min(1e-10) ** 2 return green, structure_factor_sq def _pme_convolve_forward( mesh_fft: torch.Tensor, k_squared: torch.Tensor, moduli_x: torch.Tensor, moduli_y: torch.Tensor, moduli_z: torch.Tensor, alpha: torch.Tensor, volume: torch.Tensor, is_batch: bool, ) -> torch.Tensor: """Run the fused Warp convolve kernel on ``mesh_fft``. No autograd here — callers wrap this in ``_PMEFusedConvolve`` for the autograd-aware version. ``moduli_x/y/z`` are precomputed 1D B-spline modulus LUTs (``sinc(m/N)^spline_order`` per axis); see ``compute_bspline_moduli_1d``. """ from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_convolve as _batch_pme_convolve, ) from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_convolve as _pme_convolve, ) device = wp.device_from_torch(mesh_fft.device) real_dtype = torch.float32 if mesh_fft.dtype == torch.complex64 else torch.float64 wp_dtype = wp.float32 if real_dtype == torch.float32 else wp.float64 wp_vec2 = _vec2_wp_dtype_for(real_dtype) # generate_k_vectors_pme squeezes the batch dim when B=1 — restore it for # the batch kernel, which expects (B, nx, ny, nz_r). We track whether we # had to add a dim so we can squeeze the output back to the caller's shape. squeeze_output = False if is_batch and k_squared.dim() == 3: k_squared = k_squared.unsqueeze(0) if is_batch and mesh_fft.dim() == 3: mesh_fft = mesh_fft.unsqueeze(0) squeeze_output = True # `.resolve_conj()` materializes any pending lazy conjugation (autograd of # complex ops can hand us such tensors), which `view_as_real` doesn't # accept directly. mesh_fft_real = torch.view_as_real(mesh_fft.resolve_conj()).contiguous() convolved_real = torch.empty_like(mesh_fft_real) # Skip redundant .to()/.contiguous() when inputs are already in the right # form. At small N these calls dominate CPU dispatch time (~25 aten::to per # iter contribute ~0.9 ms at N=8k mesh=64^3 before this change). def _as(t): if t.dtype != real_dtype: t = t.to(real_dtype) if not t.is_contiguous(): t = t.contiguous() return t wp_mesh_fft = _wp_from_torch(mesh_fft_real, dtype=wp_vec2) wp_convolved = _wp_from_torch(convolved_real, dtype=wp_vec2) wp_k_squared = _wp_from_torch(_as(k_squared), dtype=wp_dtype) wp_bx = _wp_from_torch(_as(moduli_x), dtype=wp_dtype) wp_by = _wp_from_torch(_as(moduli_y), dtype=wp_dtype) wp_bz = _wp_from_torch(_as(moduli_z), dtype=wp_dtype) # alpha / volume: 0-d scalars or 1-d (1,) for single-system; (B,) for batch. alpha_in = _as(alpha) volume_in = _as(volume) if alpha_in.dim() == 0: alpha_in = alpha_in.reshape(1) if volume_in.dim() == 0: volume_in = volume_in.reshape(1) wp_alpha = _wp_from_torch(alpha_in, dtype=wp_dtype) wp_volume = _wp_from_torch(volume_in, dtype=wp_dtype) with _pme_scoped_warp_stream(mesh_fft.device): if is_batch: _batch_pme_convolve( wp_mesh_fft, wp_k_squared, wp_bx, wp_by, wp_bz, wp_alpha, wp_volume, wp_convolved, wp_dtype=wp_dtype, device=device, ) else: _pme_convolve( wp_mesh_fft, wp_k_squared, wp_bx, wp_by, wp_bz, wp_alpha, wp_volume, wp_convolved, wp_dtype=wp_dtype, device=device, ) out = torch.view_as_complex(convolved_real) if squeeze_output: out = out.squeeze(0) return out def _pme_convolve_backward( mesh_fft: torch.Tensor, grad_convolved: torch.Tensor, k_squared: torch.Tensor, moduli_x: torch.Tensor, moduli_y: torch.Tensor, moduli_z: torch.Tensor, alpha: torch.Tensor, volume: torch.Tensor, is_batch: bool, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: """Explicit backward for the fused PME convolve. Returns ``(grad_mesh_fft, grad_alpha, grad_volume, grad_k_squared)`` produced by a single Warp kernel that walks the k-space mesh once. See the kernel docstring in ``pme_kernels.py`` for the analytical derivatives. ``grad_k_squared`` is required because the Green's function uses :math:`k^2` (which itself depends on cell via the reciprocal lattice) and the per-cell gradient chain needs to flow through :math:`k^2`. Layout: ``alpha`` and ``volume`` may be scalar (0-d) or shape ``(1,)`` for single-system; shape ``(B,)`` for batch. Grad shape matches input. """ from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_convolve_backward as _batch_pme_convolve_backward, ) from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_convolve_backward as _pme_convolve_backward_launch, ) device = wp.device_from_torch(mesh_fft.device) real_dtype = torch.float32 if mesh_fft.dtype == torch.complex64 else torch.float64 wp_dtype = wp.float32 if real_dtype == torch.float32 else wp.float64 wp_vec2 = _vec2_wp_dtype_for(real_dtype) # Match shape conventions from _pme_convolve_forward (batch + B=1 squeeze). # Track the k_squared unsqueeze independently of the mesh: for a single # system, mesh_fft can arrive already 4D (rfftn keeps the batch dim) while # k_squared is 3D, so squeezing grad_k_squared must key off its own flag # (else grad_k_squared is returned 4D for a 3D input and torch.compile, # which trusts the fake's 3D shape, asserts). squeeze_output = False k_sq_unsqueezed = False if is_batch and k_squared.dim() == 3: k_squared = k_squared.unsqueeze(0) k_sq_unsqueezed = True if is_batch and mesh_fft.dim() == 3: mesh_fft = mesh_fft.unsqueeze(0) squeeze_output = True if is_batch and grad_convolved.dim() == 3: grad_convolved = grad_convolved.unsqueeze(0) mesh_fft_real = torch.view_as_real(mesh_fft.resolve_conj()).contiguous() grad_conv_real = torch.view_as_real(grad_convolved.resolve_conj()).contiguous() grad_mesh_fft_real = torch.empty_like(mesh_fft_real) # alpha / volume always passed as length>=1 arrays (kernel reads index 0 # or batch_idx). grad_alpha / grad_volume zero-initialized to match. def _as(t): if t.dtype != real_dtype: t = t.to(real_dtype) if not t.is_contiguous(): t = t.contiguous() return t alpha_in = _as(alpha) volume_in = _as(volume) if alpha_in.dim() == 0: alpha_in = alpha_in.reshape(1) if volume_in.dim() == 0: volume_in = volume_in.reshape(1) B = alpha_in.shape[0] grad_alpha = torch.zeros(B, dtype=real_dtype, device=mesh_fft.device) grad_volume = torch.zeros(B, dtype=real_dtype, device=mesh_fft.device) # grad_k_squared has the same shape as k_squared (already unsqueezed above). grad_k_squared = torch.empty_like(_as(k_squared)) wp_mesh_fft = _wp_from_torch(mesh_fft_real, dtype=wp_vec2) wp_grad_conv = _wp_from_torch(grad_conv_real, dtype=wp_vec2) wp_grad_mesh = _wp_from_torch(grad_mesh_fft_real, dtype=wp_vec2) wp_k_squared = _wp_from_torch(_as(k_squared), dtype=wp_dtype) wp_grad_k_squared = _wp_from_torch(grad_k_squared, dtype=wp_dtype) wp_bx = _wp_from_torch(_as(moduli_x), dtype=wp_dtype) wp_by = _wp_from_torch(_as(moduli_y), dtype=wp_dtype) wp_bz = _wp_from_torch(_as(moduli_z), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha_in, dtype=wp_dtype) wp_volume = _wp_from_torch(volume_in, dtype=wp_dtype) wp_grad_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_grad_volume = _wp_from_torch(grad_volume, dtype=wp_dtype) with _pme_scoped_warp_stream(mesh_fft.device): if is_batch: _batch_pme_convolve_backward( wp_mesh_fft, wp_grad_conv, wp_k_squared, wp_bx, wp_by, wp_bz, wp_alpha, wp_volume, wp_grad_mesh, wp_grad_alpha, wp_grad_volume, wp_grad_k_squared, wp_dtype=wp_dtype, device=device, ) else: _pme_convolve_backward_launch( wp_mesh_fft, wp_grad_conv, wp_k_squared, wp_bx, wp_by, wp_bz, wp_alpha, wp_volume, wp_grad_mesh, wp_grad_alpha, wp_grad_volume, wp_grad_k_squared, wp_dtype=wp_dtype, device=device, ) grad_mesh_fft = torch.view_as_complex(grad_mesh_fft_real) if squeeze_output: grad_mesh_fft = grad_mesh_fft.squeeze(0) if k_sq_unsqueezed: grad_k_squared = grad_k_squared.squeeze(0) return grad_mesh_fft, grad_alpha, grad_volume, grad_k_squared def _pme_convolve_double_backward( k_squared: torch.Tensor, h_grad_mesh: torch.Tensor, h_grad_alpha: torch.Tensor, h_grad_volume: torch.Tensor, h_grad_ksq: torch.Tensor, mesh_fft: torch.Tensor, grad_convolved: torch.Tensor, moduli_x: torch.Tensor, moduli_y: torch.Tensor, moduli_z: torch.Tensor, alpha: torch.Tensor, volume: torch.Tensor, is_batch: bool, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: """Second-order node for the fused PME convolve backward. The convolve is LINEAR in ``mesh_fft``; every first-backward output is bilinear in ``(mesh_fft, grad_convolved)`` with constant per-k coefficients, so the position-relevant second-order terms are themselves linear. Given the cotangents on the four backward outputs (``h_grad_mesh``, ``h_grad_alpha``, ``h_grad_volume``, ``h_grad_ksq``), returns grads w.r.t. the backward op's five differentiable inputs (``mesh_fft``, ``grad_convolved``, ``k_squared``, ``alpha``, ``volume``) — backward positions ``(0, 1, 2, 6, 7)``. The real ``k_squared`` is placed at arg-0 (ahead of the complex ``h_grad_mesh`` cotangent) to mirror the backward op's complex-arg-0 torch.compile/inductor workaround. ``grad_mesh_fft_out`` (dL/dmesh_fft) and ``grad_grad_convolved`` (dL/dgrad_convolved) carry the force-loss second order. The ``k_squared`` / ``alpha`` / ``volume`` second-order grads carry the cell/stress second order (:math:`k^2` and V are functions of the cell; PyTorch maps them to cell outside this op). """ from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_convolve_double_backward as _batch_dbwd_launch, ) from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_convolve_double_backward as _dbwd_launch, ) device = wp.device_from_torch(mesh_fft.device) real_dtype = torch.float32 if mesh_fft.dtype == torch.complex64 else torch.float64 wp_dtype = wp.float32 if real_dtype == torch.float32 else wp.float64 wp_vec2 = _vec2_wp_dtype_for(real_dtype) # Track the k_squared unsqueeze independently of the mesh: k_squared can # arrive 3D for a single system while mesh_fft is already 4D, so # grad_k_squared_out must be squeezed by its own flag to keep the returned # rank equal to the input (matching the fake, which torch.compile trusts). squeeze_output = False k_sq_unsqueezed = False if is_batch and k_squared.dim() == 3: k_squared = k_squared.unsqueeze(0) k_sq_unsqueezed = True if is_batch and mesh_fft.dim() == 3: mesh_fft = mesh_fft.unsqueeze(0) squeeze_output = True if is_batch and grad_convolved.dim() == 3: grad_convolved = grad_convolved.unsqueeze(0) if is_batch and h_grad_mesh.dim() == 3: h_grad_mesh = h_grad_mesh.unsqueeze(0) if is_batch and h_grad_ksq.dim() == 3: h_grad_ksq = h_grad_ksq.unsqueeze(0) def _as(t): if t.dtype != real_dtype: t = t.to(real_dtype) if not t.is_contiguous(): t = t.contiguous() return t mesh_fft_real = torch.view_as_real(mesh_fft.resolve_conj()).contiguous() grad_conv_real = torch.view_as_real(grad_convolved.resolve_conj()).contiguous() h_grad_mesh_real = torch.view_as_real(h_grad_mesh.resolve_conj()).contiguous() grad_mesh_out_real = torch.empty_like(mesh_fft_real) grad_grad_conv_real = torch.empty_like(mesh_fft_real) grad_k_squared_out = torch.zeros_like(_as(k_squared)) alpha_in = _as(alpha) volume_in = _as(volume) h_a_in = _as(h_grad_alpha) h_v_in = _as(h_grad_volume) if alpha_in.dim() == 0: alpha_in = alpha_in.reshape(1) if volume_in.dim() == 0: volume_in = volume_in.reshape(1) if h_a_in.dim() == 0: h_a_in = h_a_in.reshape(1) if h_v_in.dim() == 0: h_v_in = h_v_in.reshape(1) B = alpha_in.shape[0] grad_alpha_out = torch.zeros(B, dtype=real_dtype, device=mesh_fft.device) grad_volume_out = torch.zeros(B, dtype=real_dtype, device=mesh_fft.device) wp_h_grad_mesh = _wp_from_torch(h_grad_mesh_real, dtype=wp_vec2) wp_h_alpha = _wp_from_torch(h_a_in, dtype=wp_dtype) wp_h_volume = _wp_from_torch(h_v_in, dtype=wp_dtype) wp_h_grad_ksq = _wp_from_torch(_as(h_grad_ksq), dtype=wp_dtype) wp_mesh_fft = _wp_from_torch(mesh_fft_real, dtype=wp_vec2) wp_grad_conv = _wp_from_torch(grad_conv_real, dtype=wp_vec2) wp_k_squared = _wp_from_torch(_as(k_squared), dtype=wp_dtype) wp_bx = _wp_from_torch(_as(moduli_x), dtype=wp_dtype) wp_by = _wp_from_torch(_as(moduli_y), dtype=wp_dtype) wp_bz = _wp_from_torch(_as(moduli_z), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha_in, dtype=wp_dtype) wp_volume = _wp_from_torch(volume_in, dtype=wp_dtype) wp_grad_mesh_out = _wp_from_torch(grad_mesh_out_real, dtype=wp_vec2) wp_grad_grad_conv = _wp_from_torch(grad_grad_conv_real, dtype=wp_vec2) wp_grad_ksq_out = _wp_from_torch(grad_k_squared_out, dtype=wp_dtype) wp_grad_alpha_out = _wp_from_torch(grad_alpha_out, dtype=wp_dtype) wp_grad_volume_out = _wp_from_torch(grad_volume_out, dtype=wp_dtype) launch = _batch_dbwd_launch if is_batch else _dbwd_launch with _pme_scoped_warp_stream(mesh_fft.device): launch( wp_h_grad_mesh, wp_h_alpha, wp_h_volume, wp_h_grad_ksq, wp_mesh_fft, wp_grad_conv, wp_k_squared, wp_bx, wp_by, wp_bz, wp_alpha, wp_volume, wp_grad_mesh_out, wp_grad_grad_conv, wp_grad_ksq_out, wp_grad_alpha_out, wp_grad_volume_out, wp_dtype=wp_dtype, device=device, ) grad_mesh_fft_out = torch.view_as_complex(grad_mesh_out_real) grad_grad_convolved = torch.view_as_complex(grad_grad_conv_real) if squeeze_output: grad_mesh_fft_out = grad_mesh_fft_out.squeeze(0) grad_grad_convolved = grad_grad_convolved.squeeze(0) if k_sq_unsqueezed: grad_k_squared_out = grad_k_squared_out.squeeze(0) return ( grad_mesh_fft_out, grad_grad_convolved, grad_k_squared_out, grad_alpha_out, grad_volume_out, ) def _convolve_double_backward_fake( k_squared, h_grad_mesh, h_grad_alpha, h_grad_volume, h_grad_ksq, mesh_fft, grad_convolved, moduli_x, moduli_y, moduli_z, alpha, volume, is_batch, ): real_dtype = torch.float32 if mesh_fft.dtype == torch.complex64 else torch.float64 B = alpha.shape[0] if alpha.dim() >= 1 else 1 # Meta/shape function: only shapes+dtypes matter for tracing, so the values # are placeholders (the real values come from the kernel launch). return ( torch.empty_like(mesh_fft), # grad_mesh_fft (dL/dmesh_fft) torch.empty_like(grad_convolved), # grad_grad_convolved (dL/dgrad_convolved) torch.zeros_like(k_squared, dtype=real_dtype), # grad_k_squared (dL/ds) torch.zeros(B, dtype=real_dtype, device=mesh_fft.device), # grad_alpha torch.zeros(B, dtype=real_dtype, device=mesh_fft.device), # grad_volume ) # Backward signature is ``(mesh_fft, grad_convolved, ...)`` (mesh_fft first, # not cotangents-first) to work around an AOT-autograd/inductor complex # codegen bug that produces ~1% wrong grads when a complex cotangent is in # arg-0 under torch.compile fullgraph=True. def _convolve_backward_fake( mesh_fft, grad_convolved, k_squared, moduli_x, moduli_y, moduli_z, alpha, volume, is_batch, ): real_dtype = torch.float32 if mesh_fft.dtype == torch.complex64 else torch.float64 B = alpha.shape[0] if alpha.dim() >= 1 else 1 return ( torch.empty_like(mesh_fft), # grad_mesh_fft torch.zeros(B, dtype=real_dtype, device=mesh_fft.device), # grad_alpha torch.zeros(B, dtype=real_dtype, device=mesh_fft.device), # grad_volume torch.empty_like(k_squared, dtype=real_dtype), # grad_k_squared ) def _convolve_forward_fake(mesh_fft, *_): # Launcher returns natural-contiguous; caller is responsible for passing # a contiguous mesh_fft so the fake stride matches the real call. return torch.empty( mesh_fft.shape, dtype=mesh_fft.dtype, device=mesh_fft.device, ) # Second-order autograd for the convolve. The convolve is LINEAR in mesh_fft and # every first-backward output is bilinear in (mesh_fft, grad_convolved) with # constant per-k coefficients, so the position-relevant second-order terms # (dL/dmesh_fft, dL/dgrad_convolved) are linear — see ``_pme_convolve_double_backward``. # A dedicated double-backward kernel handles all four backward-output cotangents. # The alpha/volume/k_squared second-order gradients carry the cell/stress terms # because k² and V are functions of the cell. # # The double-backward op signature leads with the real ``k_squared`` (arg-0) # ahead of the complex ``h_grad_mesh`` cotangent, mirroring the backward op's # complex-arg-0 inductor workaround. ``second_order_backward_args`` maps the # backward node's (cotangents g, full inputs f) to that ordering; the backward # op's inputs are f = (mesh_fft, grad_convolved, k_squared, moduli_x, moduli_y, # moduli_z, alpha, volume, is_batch) and its outputs' cotangents are # g = (h_grad_mesh, h_grad_alpha, h_grad_volume, h_grad_ksq). ########################################################################################### ########################### PME Energy Corrections Custom Ops ############################# ########################################################################################### ########################################################################################### ###### Explicit Warp-backed backward chain for energy_corrections ########################## ########################################################################################### # # Forward/backward kernels come from the factory-backed PME corrections # component for both single-system and batched launches. # # Wiring forward+backward via ``register_warp_op_chain`` + # ``register_autograd``: # * is CUDA-graph-capture safe (no token tensor); # * gives torch a registered backward formula needed for # ``create_graph=True`` chains. # # Double-backward is registered on top of this via the second-order # Warp kernel further down. def _energy_corrections_forward_launch( raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> torch.Tensor: """Single-system forward launch only (no autograd plumbing).""" from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_energy_corrections as _ec_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) num_atoms = raw_energies.shape[0] corrected = torch.zeros(num_atoms, dtype=input_dtype, device=raw_energies.device) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_charges = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_volume = _wp_from_torch(volume.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot = _wp_from_torch(total_charge.to(input_dtype).contiguous(), dtype=wp_dtype) wp_corrected = _wp_from_torch(corrected, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _ec_launch( wp_raw, wp_charges, wp_volume, wp_alpha, wp_qtot, wp_corrected, wp_dtype=wp_dtype, device=device, ) return corrected def _energy_corrections_backward_launch( grad_E: torch.Tensor, raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> tuple[torch.Tensor, ...]: """Single-system backward launch — returns the 5 input grads.""" from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_energy_corrections_backward as _ec_backward_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) grad_raw = torch.empty_like(raw_energies) grad_charges = torch.empty_like(charges, dtype=input_dtype) grad_volume = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) grad_alpha = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) grad_qtot = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) wp_gE = _wp_from_torch(grad_E.contiguous(), dtype=wp_dtype) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_vol = _wp_from_torch(volume.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot_in = _wp_from_torch( total_charge.to(input_dtype).contiguous(), dtype=wp_dtype ) wp_g_raw = _wp_from_torch(grad_raw, dtype=wp_dtype) wp_g_chg = _wp_from_torch(grad_charges, dtype=wp_dtype) wp_g_vol = _wp_from_torch(grad_volume, dtype=wp_dtype) wp_g_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_g_qtot = _wp_from_torch(grad_qtot, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _ec_backward_launch( wp_gE, wp_raw, wp_chg, wp_vol, wp_alpha, wp_qtot_in, wp_g_raw, wp_g_chg, wp_g_vol, wp_g_alpha, wp_g_qtot, wp_dtype=wp_dtype, device=device, ) return grad_raw, grad_charges, grad_volume, grad_alpha, grad_qtot def _energy_corrections_double_backward_launch( h_raw: torch.Tensor, h_chg: torch.Tensor, h_vol: torch.Tensor, h_alpha: torch.Tensor, h_qtot: torch.Tensor, grad_E: torch.Tensor, raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> tuple[torch.Tensor, ...]: """Single-system 2nd-order launcher — returns 6 grads.""" from nvalchemiops.interactions.electrostatics.pme_kernels import ( pme_energy_corrections_double_backward as _ec_dbwd_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) grad_grad_E = torch.empty_like(grad_E) grad_raw = torch.empty_like(raw_energies) grad_charges = torch.empty_like(charges, dtype=input_dtype) grad_volume = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) grad_alpha = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) grad_qtot = torch.zeros(1, dtype=input_dtype, device=raw_energies.device) wp_h_raw = _wp_from_torch(h_raw.contiguous(), dtype=wp_dtype) wp_h_chg = _wp_from_torch(h_chg.contiguous(), dtype=wp_dtype) wp_h_vol = _wp_from_torch(h_vol.contiguous(), dtype=wp_dtype) wp_h_alpha = _wp_from_torch(h_alpha.contiguous(), dtype=wp_dtype) wp_h_qtot = _wp_from_torch(h_qtot.contiguous(), dtype=wp_dtype) wp_gE = _wp_from_torch(grad_E.contiguous(), dtype=wp_dtype) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_vol = _wp_from_torch(volume.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot_in = _wp_from_torch( total_charge.to(input_dtype).contiguous(), dtype=wp_dtype ) wp_g_gE = _wp_from_torch(grad_grad_E, dtype=wp_dtype) wp_g_raw = _wp_from_torch(grad_raw, dtype=wp_dtype) wp_g_chg = _wp_from_torch(grad_charges, dtype=wp_dtype) wp_g_vol = _wp_from_torch(grad_volume, dtype=wp_dtype) wp_g_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_g_qtot = _wp_from_torch(grad_qtot, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _ec_dbwd_launch( wp_h_raw, wp_h_chg, wp_h_vol, wp_h_alpha, wp_h_qtot, wp_gE, wp_raw, wp_chg, wp_vol, wp_alpha, wp_qtot_in, wp_g_gE, wp_g_raw, wp_g_chg, wp_g_vol, wp_g_alpha, wp_g_qtot, wp_dtype=wp_dtype, device=device, ) return grad_grad_E, grad_raw, grad_charges, grad_volume, grad_alpha, grad_qtot def _pme_energy_corrections( raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> torch.Tensor: """Internal: single-system energy corrections via the registered custom op.""" register_pme_ops() return torch.ops.nvalchemiops.pme_energy_corrections( raw_energies, charges.to(raw_energies.dtype), volume.to(raw_energies.dtype), alpha.to(raw_energies.dtype), total_charge.to(raw_energies.dtype), ) def _batch_energy_corrections_forward_launch( raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> torch.Tensor: from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_energy_corrections as _batch_ec_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) n = raw_energies.shape[0] corrected = torch.zeros(n, dtype=input_dtype, device=raw_energies.device) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_bidx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_vol = _wp_from_torch(volumes.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot = _wp_from_torch(total_charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_corrected = _wp_from_torch(corrected, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _batch_ec_launch( wp_raw, wp_chg, wp_bidx, wp_vol, wp_alpha, wp_qtot, wp_corrected, wp_dtype=wp_dtype, device=device, ) return corrected def _batch_energy_corrections_backward_launch( grad_E: torch.Tensor, raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> tuple[torch.Tensor, ...]: from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_energy_corrections_backward as _batch_ec_backward_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) B = volumes.shape[0] grad_raw = torch.empty_like(raw_energies) grad_charges = torch.empty_like(charges, dtype=input_dtype) grad_volumes = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) grad_alpha = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) grad_qtots = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) wp_gE = _wp_from_torch(grad_E.contiguous(), dtype=wp_dtype) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_bidx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_vol = _wp_from_torch(volumes.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot_in = _wp_from_torch( total_charges.to(input_dtype).contiguous(), dtype=wp_dtype ) wp_g_raw = _wp_from_torch(grad_raw, dtype=wp_dtype) wp_g_chg = _wp_from_torch(grad_charges, dtype=wp_dtype) wp_g_vol = _wp_from_torch(grad_volumes, dtype=wp_dtype) wp_g_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_g_qtot = _wp_from_torch(grad_qtots, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _batch_ec_backward_launch( wp_gE, wp_raw, wp_chg, wp_bidx, wp_vol, wp_alpha, wp_qtot_in, wp_g_raw, wp_g_chg, wp_g_vol, wp_g_alpha, wp_g_qtot, wp_dtype=wp_dtype, device=device, ) return grad_raw, grad_charges, grad_volumes, grad_alpha, grad_qtots def _batch_energy_corrections_double_backward_launch( h_raw: torch.Tensor, h_chg: torch.Tensor, h_vol: torch.Tensor, h_alpha: torch.Tensor, h_qtot: torch.Tensor, grad_E: torch.Tensor, raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> tuple[torch.Tensor, ...]: from nvalchemiops.interactions.electrostatics.pme_kernels import ( batch_pme_energy_corrections_double_backward as _batch_ec_dbwd_launch, ) device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) B = volumes.shape[0] grad_grad_E = torch.empty_like(grad_E) grad_raw = torch.empty_like(raw_energies) grad_charges = torch.empty_like(charges, dtype=input_dtype) grad_volumes = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) grad_alpha = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) grad_qtots = torch.zeros(B, dtype=input_dtype, device=raw_energies.device) wp_h_raw = _wp_from_torch(h_raw.contiguous(), dtype=wp_dtype) wp_h_chg = _wp_from_torch(h_chg.contiguous(), dtype=wp_dtype) wp_h_vol = _wp_from_torch(h_vol.contiguous(), dtype=wp_dtype) wp_h_alpha = _wp_from_torch(h_alpha.contiguous(), dtype=wp_dtype) wp_h_qtot = _wp_from_torch(h_qtot.contiguous(), dtype=wp_dtype) wp_gE = _wp_from_torch(grad_E.contiguous(), dtype=wp_dtype) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_bidx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_vol = _wp_from_torch(volumes.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot_in = _wp_from_torch( total_charges.to(input_dtype).contiguous(), dtype=wp_dtype ) wp_g_gE = _wp_from_torch(grad_grad_E, dtype=wp_dtype) wp_g_raw = _wp_from_torch(grad_raw, dtype=wp_dtype) wp_g_chg = _wp_from_torch(grad_charges, dtype=wp_dtype) wp_g_vol = _wp_from_torch(grad_volumes, dtype=wp_dtype) wp_g_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_g_qtot = _wp_from_torch(grad_qtots, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _batch_ec_dbwd_launch( wp_h_raw, wp_h_chg, wp_h_vol, wp_h_alpha, wp_h_qtot, wp_gE, wp_raw, wp_chg, wp_bidx, wp_vol, wp_alpha, wp_qtot_in, wp_g_gE, wp_g_raw, wp_g_chg, wp_g_vol, wp_g_alpha, wp_g_qtot, wp_dtype=wp_dtype, device=device, ) return grad_grad_E, grad_raw, grad_charges, grad_volumes, grad_alpha, grad_qtots def _batch_pme_energy_corrections( raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> torch.Tensor: """Internal: batched energy corrections via the registered custom op.""" register_pme_ops() return torch.ops.nvalchemiops.pme_energy_corrections_batch( raw_energies, charges.to(raw_energies.dtype), batch_idx, volumes.to(raw_energies.dtype), alpha.to(raw_energies.dtype), total_charges.to(raw_energies.dtype), ) def _energy_corrections_charge_grad_forward_launch( raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: """Single-system forward launch for fused corrected_energies + charge_gradients. Pure forward — no autograd plumbing. The charge_gradient output is used by ``_InjectChargeGrad`` (which doesn't backprop into it), so we treat it as non-differentiable in the wrapping ``Function``. """ device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) num_atoms = raw_energies.shape[0] corrected_energies = torch.zeros( num_atoms, dtype=input_dtype, device=raw_energies.device ) charge_gradients = torch.zeros( num_atoms, dtype=input_dtype, device=raw_energies.device ) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_charges = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_volume = _wp_from_torch(volume.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtot = _wp_from_torch(total_charge.to(input_dtype).contiguous(), dtype=wp_dtype) wp_corrected = _wp_from_torch(corrected_energies, dtype=wp_dtype) wp_charge_grads = _wp_from_torch(charge_gradients, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _pme_energy_corrections_with_charge_grad_warp( wp_raw, wp_charges, wp_volume, wp_alpha, wp_qtot, wp_corrected, wp_charge_grads, wp_dtype, device=device, ) return corrected_energies, charge_gradients # --------------------------------------------------------------------------- # Single-system energy_corrections_with_charge_grad as torch.library.custom_op. # # The kernel returns (corrected_energies, charge_gradients) in one pass: # charge_gradients = analytical ∂E_total/∂q_i. The second output is consumed # downstream by ``_InjectChargeGrad`` (the dsf.py-style autograd.Function in # ``_util.py``) which returns None for its grad — so we treat charge_gradients # as non-differentiable here, exactly mirroring the prior autograd.Function. # # The backward of this op delegates to the regular pme_energy_corrections_ # backward op (charge_grad cotangent is ignored), so no new backward kernel # is needed. def _pme_energy_corrections_with_charge_grad( raw_energies: torch.Tensor, charges: torch.Tensor, volume: torch.Tensor, alpha: torch.Tensor, total_charge: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: """Internal: single-system fused corrections + analytical charge gradient.""" register_pme_ops() return torch.ops.nvalchemiops.pme_energy_corrections_with_charge_grad( raw_energies, charges.to(raw_energies.dtype), volume.to(raw_energies.dtype), alpha.to(raw_energies.dtype), total_charge.to(raw_energies.dtype), ) def _batch_energy_corrections_charge_grad_forward_launch( raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: """Batched forward launch for fused corrected_energies + charge_gradients.""" device = wp.device_from_torch(raw_energies.device) input_dtype = raw_energies.dtype wp_dtype = get_wp_dtype(input_dtype) num_atoms = raw_energies.shape[0] corrected_energies = torch.zeros( num_atoms, dtype=input_dtype, device=raw_energies.device ) charge_gradients = torch.zeros( num_atoms, dtype=input_dtype, device=raw_energies.device ) wp_raw = _wp_from_torch(raw_energies.contiguous(), dtype=wp_dtype) wp_charges = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype) wp_bidx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_volumes = _wp_from_torch(volumes.to(input_dtype).contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.to(input_dtype).contiguous(), dtype=wp_dtype) wp_qtots = _wp_from_torch( total_charges.to(input_dtype).contiguous(), dtype=wp_dtype ) wp_corrected = _wp_from_torch(corrected_energies, dtype=wp_dtype) wp_charge_grads = _wp_from_torch(charge_gradients, dtype=wp_dtype) with _pme_scoped_warp_stream(raw_energies.device): _batch_pme_energy_corrections_with_charge_grad_warp( wp_raw, wp_charges, wp_bidx, wp_volumes, wp_alpha, wp_qtots, wp_corrected, wp_charge_grads, wp_dtype, device=device, ) return corrected_energies, charge_gradients def _batch_pme_energy_corrections_with_charge_grad( raw_energies: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, volumes: torch.Tensor, alpha: torch.Tensor, total_charges: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: """Internal: batched fused corrections + analytical charge gradient.""" register_pme_ops() return torch.ops.nvalchemiops.pme_energy_corrections_with_charge_grad_batch( raw_energies, charges.to(raw_energies.dtype), batch_idx, volumes.to(raw_energies.dtype), alpha.to(raw_energies.dtype), total_charges.to(raw_energies.dtype), ) def pme_energy_corrections( raw_energies: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: torch.Tensor, batch_idx: torch.Tensor | None = None, volume: torch.Tensor | None = None, ) -> torch.Tensor: """Apply self-energy and background corrections to PME energies. Converts raw interpolated potential to energy and subtracts corrections: .. math:: E_i = q_i \\phi_i - E_{\\text{self},i} - E_{\\text{background},i} Self-energy correction (removes Gaussian self-interaction): .. math:: E_{\\text{self},i} = \\frac{\\alpha}{\\sqrt{\\pi}} q_i^2 Background correction (for non-neutral systems): .. math:: E_{\\text{background},i} = \\frac{\\pi}{2\\alpha^2 V} q_i Q_{\\text{total}} Parameters ---------- raw_energies : torch.Tensor, shape (N,) or (N_total,) Raw potential values :math:`\\phi_i` from mesh interpolation. charges : torch.Tensor, shape (N,) or (N_total,) Atomic charges. cell : torch.Tensor Unit cell matrices. - Single-system: shape (3, 3) or (1, 3, 3) - Batch: shape (B, 3, 3) alpha : torch.Tensor Ewald splitting parameter. - Single-system: shape (1,) - Batch: shape (B,) batch_idx : torch.Tensor | None, default=None System index for each atom. If provided, uses batch kernels. Returns ------- corrected_energies : torch.Tensor, shape (N,) or (N_total,) Final per-atom reciprocal-space energy with corrections applied. Notes ----- - For neutral systems, background correction is zero - Matches torchpme's self_contribution and background_correction formulas - Supports both float32 and float64 dtypes """ ensure_electrostatics_ops_registered() input_dtype = raw_energies.dtype if batch_idx is None: # Single system - ensure tensors are 1D for kernel indexing total_charge = charges.sum().reshape(1) if volume is None: volume = torch.abs(torch.det(cell)).reshape(1) else: volume = volume.reshape(1) result = _pme_energy_corrections( raw_energies, charges.to(input_dtype), volume.to(input_dtype), alpha.to(input_dtype), total_charge.to(input_dtype), ) else: # Batch num_systems = cell.shape[0] if volume is None: volumes = torch.abs(torch.linalg.det(cell)).to(input_dtype) else: volumes = volume.to(input_dtype) # Compute total charge per system total_charges = torch.zeros( num_systems, dtype=input_dtype, device=raw_energies.device ) total_charges.scatter_add_(0, batch_idx, charges.to(input_dtype)) result = _batch_pme_energy_corrections( raw_energies, charges.to(input_dtype), batch_idx, volumes, alpha.to(input_dtype), total_charges, ) return result def pme_energy_corrections_with_charge_grad( raw_energies: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: torch.Tensor, batch_idx: torch.Tensor | None = None, volume: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: """Apply corrections and compute charge gradients for PME energies. Computes both corrected energies and analytical charge gradients: .. math:: E_i = q_i \\phi_i - E_{\\text{self},i} - E_{\\text{background},i} .. math:: \\frac{\\partial E}{\\partial q_i} = 2\\phi_i - \\frac{2\\alpha}{\\sqrt{\\pi}} q_i - \\frac{\\pi}{\\alpha^2 V} Q_{\\text{total}} The factor of 2 on :math:`\\phi_i` arises because changing :math:`q_i` affects both the direct energy term :math:`q_i \\phi_i` and all other potentials through the structure factor :math:`\\sum_j q_j \\, \\partial\\phi_j/\\partial q_i = \\phi_i`. Parameters ---------- raw_energies : torch.Tensor, shape (N,) or (N_total,) Raw potential values :math:`\\phi_i` from mesh interpolation. charges : torch.Tensor, shape (N,) or (N_total,) Atomic charges. cell : torch.Tensor Unit cell matrices. - Single-system: shape (3, 3) or (1, 3, 3) - Batch: shape (B, 3, 3) alpha : torch.Tensor Ewald splitting parameter. - Single-system: shape (1,) - Batch: shape (B,) batch_idx : torch.Tensor | None, default=None System index for each atom. If provided, uses batch kernels. Returns ------- corrected_energies : torch.Tensor, shape (N,) or (N_total,) Final per-atom reciprocal-space energy with corrections applied. charge_gradients : torch.Tensor, shape (N,) or (N_total,) Analytical charge gradients :math:`\\partial E/\\partial q_i`. """ ensure_electrostatics_ops_registered() input_dtype = raw_energies.dtype if batch_idx is None: # Single system total_charge = charges.sum().reshape(1) if volume is None: volume = torch.abs(torch.det(cell)).reshape(1) else: volume = volume.reshape(1) return _pme_energy_corrections_with_charge_grad( raw_energies, charges.to(input_dtype), volume.to(input_dtype), alpha.to(input_dtype), total_charge.to(input_dtype), ) else: # Batch num_systems = cell.shape[0] if volume is None: volumes = torch.abs(torch.linalg.det(cell)).to(input_dtype) else: volumes = volume.to(input_dtype) # Compute total charge per system total_charges = torch.zeros( num_systems, dtype=input_dtype, device=raw_energies.device ) total_charges.scatter_add_(0, batch_idx, charges.to(input_dtype)) return _batch_pme_energy_corrections_with_charge_grad( raw_energies, charges.to(input_dtype), batch_idx, volumes, alpha.to(input_dtype), total_charges, ) ########################################################################################### ########################### Virial Background Correction ################################## ########################################################################################### # Functional warp-backed op returning ``virial_in - E_bg(s)·I`` with # analytic backward through charges / cell / alpha. def _virial_bg_correction_forward_launch( charges: torch.Tensor, batch_idx: torch.Tensor, cell: torch.Tensor, volume: torch.Tensor, use_supplied_volume: bool, alpha: torch.Tensor, virial_in: torch.Tensor, ) -> torch.Tensor: real_dtype = virial_in.dtype wp_dtype = wp.float32 if real_dtype == torch.float32 else wp.float64 device = wp.device_from_torch(virial_in.device) virial_out = torch.empty_like(virial_in) total_charges = torch.zeros( virial_in.shape[0], dtype=real_dtype, device=virial_in.device, ) wp_charges = _wp_from_torch(charges.contiguous(), dtype=wp_dtype) wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_cell = _wp_from_torch(cell.contiguous(), dtype=wp_dtype) wp_volume = _wp_from_torch(volume.contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.contiguous(), dtype=wp_dtype) wp_total = _wp_from_torch(total_charges, dtype=wp_dtype) wp_virial_in = _wp_from_torch(virial_in.contiguous(), dtype=wp_dtype) wp_virial_out = _wp_from_torch(virial_out, dtype=wp_dtype) with _pme_scoped_warp_stream(virial_in.device): _pme_virial_bg_correction_warp( charges=wp_charges, batch_idx=wp_batch_idx, cell=wp_cell, volume=wp_volume, use_supplied_volume=use_supplied_volume, alpha=wp_alpha, total_charges=wp_total, virial_in=wp_virial_in, virial_out=wp_virial_out, wp_dtype=wp_dtype, device=device, ) return virial_out def _virial_bg_correction_backward_launch( grad_virial: torch.Tensor, charges: torch.Tensor, batch_idx: torch.Tensor, cell: torch.Tensor, volume: torch.Tensor, use_supplied_volume: bool, alpha: torch.Tensor, virial_in: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: real_dtype = virial_in.dtype wp_dtype = wp.float32 if real_dtype == torch.float32 else wp.float64 device = wp.device_from_torch(virial_in.device) n = charges.shape[0] B = virial_in.shape[0] total_charges = torch.zeros(B, dtype=real_dtype, device=virial_in.device) grad_total_charges = torch.zeros(B, dtype=real_dtype, device=virial_in.device) grad_charges = torch.empty(n, dtype=real_dtype, device=virial_in.device) grad_alpha = torch.empty(B, dtype=real_dtype, device=virial_in.device) grad_cell = torch.empty_like(cell) wp_gV = _wp_from_torch(grad_virial.contiguous(), dtype=wp_dtype) wp_charges = _wp_from_torch(charges.contiguous(), dtype=wp_dtype) wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32) wp_cell = _wp_from_torch(cell.contiguous(), dtype=wp_dtype) wp_volume = _wp_from_torch(volume.contiguous(), dtype=wp_dtype) wp_alpha = _wp_from_torch(alpha.contiguous(), dtype=wp_dtype) wp_total = _wp_from_torch(total_charges, dtype=wp_dtype) wp_g_total = _wp_from_torch(grad_total_charges, dtype=wp_dtype) wp_g_chg = _wp_from_torch(grad_charges, dtype=wp_dtype) wp_g_alpha = _wp_from_torch(grad_alpha, dtype=wp_dtype) wp_g_cell = _wp_from_torch(grad_cell, dtype=wp_dtype) with _pme_scoped_warp_stream(virial_in.device): _pme_virial_bg_correction_backward_warp( grad_virial=wp_gV, charges=wp_charges, batch_idx=wp_batch_idx, cell=wp_cell, volume=wp_volume, use_supplied_volume=use_supplied_volume, alpha=wp_alpha, total_charges=wp_total, grad_total_charges=wp_g_total, grad_charges=wp_g_chg, grad_alpha=wp_g_alpha, grad_cell=wp_g_cell, wp_dtype=wp_dtype, device=device, ) # ``virial_out = virial_in - E_bg·I`` makes the cotangent w.r.t. # ``virial_in`` the identity image of ``grad_virial``. Return a clone # so the output tensor does not alias the input cotangent — PyTorch's # custom_op runtime rejects any input↔output aliasing in backwards. return grad_charges, grad_cell, grad_alpha, grad_virial.clone() def register_pme_ops() -> None: """Register PME Torch custom ops once.""" global _PME_OPS_REGISTERED if _PME_OPS_REGISTERED: return register_warp_op_chain( name="nvalchemiops::pme_fused_convolve", forward=_pme_convolve_forward, forward_fake=_convolve_forward_fake, backward=_pme_convolve_backward, backward_fake=_convolve_backward_fake, backward_return_arity=4, diff_input_positions=(0, 5, 6, 1), n_forward_inputs=8, backward_args=lambda g, f: ( f[0], g[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7], ), double_backward=_pme_convolve_double_backward, double_backward_fake=_convolve_double_backward_fake, double_backward_return_arity=5, second_order_diff_positions=(0, 1, 2, 6, 7), n_backward_inputs=9, second_order_backward_args=lambda g, f: ( f[2], g[0], g[1], g[2], g[3], f[0], f[1], f[3], f[4], f[5], f[6], f[7], f[8], ), ) register_warp_op_chain( name="nvalchemiops::pme_energy_corrections", forward=_energy_corrections_forward_launch, backward=_energy_corrections_backward_launch, double_backward=_energy_corrections_double_backward_launch, diff_input_positions=(0, 1, 2, 3, 4), n_forward_inputs=5, second_order_diff_positions=(0, 1, 2, 3, 4, 5), n_backward_inputs=6, ) register_warp_op_chain( name="nvalchemiops::pme_energy_corrections_batch", forward=_batch_energy_corrections_forward_launch, backward=_batch_energy_corrections_backward_launch, double_backward=_batch_energy_corrections_double_backward_launch, diff_input_positions=(0, 1, 3, 4, 5), n_forward_inputs=6, second_order_diff_positions=(0, 1, 2, 4, 5, 6), n_backward_inputs=7, batch_match=True, ) register_warp_op_chain( name="nvalchemiops::pme_energy_corrections_with_charge_grad", forward=_energy_corrections_charge_grad_forward_launch, forward_return_arity=2, forward_fake=lambda raw, *_: (torch.empty_like(raw), torch.empty_like(raw)), ) attach_simple_backward( "nvalchemiops::pme_energy_corrections_with_charge_grad", torch.ops.nvalchemiops.pme_energy_corrections_backward, diff_input_positions=(0, 1, 2, 3, 4), n_forward_inputs=5, propagate_outputs=(0,), ) register_warp_op_chain( name="nvalchemiops::pme_energy_corrections_with_charge_grad_batch", forward=_batch_energy_corrections_charge_grad_forward_launch, forward_return_arity=2, forward_fake=lambda raw, *_: (torch.empty_like(raw), torch.empty_like(raw)), ) attach_simple_backward( "nvalchemiops::pme_energy_corrections_with_charge_grad_batch", torch.ops.nvalchemiops.pme_energy_corrections_batch_backward, diff_input_positions=(0, 1, 3, 4, 5), n_forward_inputs=6, batch_match=True, propagate_outputs=(0,), ) register_warp_op_chain( name="nvalchemiops::pme_virial_bg_correction", forward=_virial_bg_correction_forward_launch, backward=_virial_bg_correction_backward_launch, diff_input_positions=(0, 2, 5, 6), # charges, cell, alpha, virial_in n_forward_inputs=7, forward_fake=lambda charges, batch_idx, cell, volume, use_supplied_volume, alpha, virial_in: (torch.empty_like(virial_in)), batch_match=True, ) _PME_OPS_REGISTERED = True ########################################################################################### ########################### Unified PME Reciprocal Space ################################## ########################################################################################### def _compute_pme_reciprocal_virial( mesh_fft_raw: torch.Tensor, convolved_mesh: torch.Tensor, k_vectors: torch.Tensor, k_squared: torch.Tensor, alpha: torch.Tensor, mesh_dimensions: tuple[int, int, int], is_batch: bool, device: torch.device, dtype: torch.dtype, ) -> torch.Tensor: r"""Compute PME reciprocal-space virial tensor in k-space. Uses the exact spectral pair from the pipeline (mesh_fft_raw before deconvolution, and convolved_mesh after Green's function multiplication) to compute the per-k energy density directly via Parseval's theorem. The virial per k-point is W_ab(k) = E_k * sigma_ab(k) where: - E_k = prefactor * weight(k) * Re(mesh_fft_raw(k) * convolved_mesh(k)*) - sigma_ab(k) = delta_ab - 2*k_a*k_b/k^2 * (1 + k^2/(4*alpha^2)) (sign reflects :math:`W = -dE/d\varepsilon` convention) Parameters ---------- mesh_fft_raw : torch.Tensor Raw rfftn output before B-spline deconvolution. Shape (nx, ny, nz//2+1) or (B, nx, ny, nz//2+1), complex. convolved_mesh : torch.Tensor Deconvolved mesh FFT multiplied by Green's function: (mesh_fft/B^2)*G. Shape matching mesh_fft_raw. k_vectors : torch.Tensor k-vectors on the mesh. Shape (..., nx, ny, nz//2+1, 3). k_squared : torch.Tensor |k|^2. Shape (..., nx, ny, nz//2+1). alpha : torch.Tensor Ewald splitting parameter. mesh_dimensions : tuple (nx, ny, nz). is_batch : bool Whether this is a batched calculation. device : torch.device Computation device. dtype : torch.dtype Output dtype. Returns ------- virial : torch.Tensor, shape (B, 3, 3) or (1, 3, 3) Per-system virial tensor. """ mesh_nx, mesh_ny, mesh_nz = mesh_dimensions # Per-k energy density from exact pipeline spectral pair. # Re(mesh_fft_raw * convolved_mesh*) = |mesh_fft_raw|^2 * G / B^2 # # Explicit complex/real dtype mapping is needed because `dtype` is a # real-valued dtype (float32 or float64) but the FFT mesh data is complex. # PyTorch has no implicit real-to-complex dtype promotion, so we map # float32 -> complex64 and float64 -> complex128 explicitly. complex_dtype = torch.complex64 if dtype == torch.float32 else torch.complex128 acc_dtype = dtype # real accumulation dtype matches input precision fft_raw_cast = mesh_fft_raw.to(complex_dtype) conv_cast = convolved_mesh.to(complex_dtype) energy_density = (fft_raw_cast * conv_cast.conj()).real # Weight for rfft symmetry: 2 for interior k_z, 1 for boundary weight = torch.full_like(energy_density, 2.0) weight[..., 0] = 1.0 # k_z = 0 if mesh_nz % 2 == 0: weight[..., -1] = 1.0 # k_z = nz//2 (Nyquist) # Weighted energy density weighted_energy = weight * energy_density # Virial W = -dE/dε, so sigma_ab = delta_ab - 2*k_a*k_b/k^2 * (1 + k^2/(4*alpha^2)) k_sq_acc = k_squared.to(acc_dtype) alpha_acc = alpha.to(acc_dtype) # generate_k_vectors_pme squeezes the batch dim when B=1; restore it so # the batched einsum and sum_dims=(1,2,3) operate on the correct axes. if is_batch and k_sq_acc.dim() == 3: k_sq_acc = k_sq_acc.unsqueeze(0) # Handle alpha broadcasting: alpha may be (B,) for batch if is_batch and alpha_acc.dim() == 1: alpha_view = alpha_acc.view(-1, 1, 1, 1) else: alpha_view = alpha_acc.view(-1) if alpha_acc.dim() == 0 else alpha_acc exp_factor = 0.25 / (alpha_view**2) # Avoid division by zero at k=0 safe_k_sq = k_sq_acc.clamp(min=1e-30) k_factor = 2.0 * (1.0 + k_sq_acc * exp_factor) / safe_k_sq # Zero out k=0 contribution (no virial from k=0) k_mask = k_sq_acc > 1e-10 # Six per-component weighted reductions instead of an einsum: the # einsum's (M=N=3, K=mesh_size) shape hits a slow cuBLAS sgemm corner. # virial_ab = sum_k masked_energy * (delta_ab - k_factor * k_a * k_b) k_vecs_acc = k_vectors.to(acc_dtype) # (..., nx, ny, nz//2+1, 3) if is_batch and k_vecs_acc.dim() == 4: k_vecs_acc = k_vecs_acc.unsqueeze(0) masked_energy = weighted_energy * k_mask # (..., nx, ny, nz//2+1) masked_energy_kf = masked_energy * k_factor # (..., nx, ny, nz//2+1) # Sum dimensions depend on batch vs single if is_batch: sum_dims = (1, 2, 3) else: sum_dims = (0, 1, 2) # Trace term: delta_ab * sum_k masked_energy trace_term = masked_energy.sum(dim=sum_dims) # scalar or (B,) # kk term components — six symmetric (a,b) reductions in one expression. kx = k_vecs_acc[..., 0] ky = k_vecs_acc[..., 1] kz = k_vecs_acc[..., 2] xx = (kx * kx * masked_energy_kf).sum(dim=sum_dims) yy = (ky * ky * masked_energy_kf).sum(dim=sum_dims) zz = (kz * kz * masked_energy_kf).sum(dim=sum_dims) xy = (kx * ky * masked_energy_kf).sum(dim=sum_dims) xz = (kx * kz * masked_energy_kf).sum(dim=sum_dims) yz = (ky * kz * masked_energy_kf).sum(dim=sum_dims) eye = torch.eye(3, device=device, dtype=acc_dtype) if is_batch: # Assemble symmetric (B, 3, 3) tensor. kk_term = torch.stack( [ torch.stack([xx, xy, xz], dim=-1), torch.stack([xy, yy, yz], dim=-1), torch.stack([xz, yz, zz], dim=-1), ], dim=-2, ) virial = eye * trace_term[:, None, None] - kk_term # (B, 3, 3) else: kk_term = torch.stack( [ torch.stack([xx, xy, xz]), torch.stack([xy, yy, yz]), torch.stack([xz, yz, zz]), ], ) # (3, 3) virial = (eye * trace_term - kk_term).unsqueeze(0) # (1, 3, 3) return virial.to(dtype) def _pme_cell_grad_from_virial( positions: torch.Tensor, dEdR: torch.Tensor, cell: torch.Tensor, virial: torch.Tensor, batch_idx: torch.Tensor | None, cell_inv_t: torch.Tensor | None = None, ) -> torch.Tensor: """Convert strain virial plus ``dE/dR`` into partial ``dE/dcell``. Direct PME virial is ``W = -dE/dstrain`` for simultaneous row-vector displacement of positions and cell. The eval fastpath returns partial gradients for the actual autograd inputs, so solve ``cell.T @ dE/dcell = -W - positions.T @ dE/dR`` per system. """ cell_3d = cell if cell.dim() == 3 else cell.unsqueeze(0) num_systems = cell_3d.shape[0] pos_term = torch.zeros( num_systems, 3, 3, device=positions.device, dtype=torch.float64, ) outer = positions.to(torch.float64).unsqueeze(2) * dEdR.to(torch.float64).unsqueeze( 1 ) if batch_idx is None: if outer.numel(): pos_term[0] = outer.sum(dim=0) else: pos_term = pos_term.index_add(0, batch_idx.to(torch.long), outer) target = -virial.to(torch.float64) - pos_term if cell_inv_t is not None: inv_t_3d = _normalize_cell_inv_t_cache(cell_inv_t).to(torch.float64) return torch.matmul(inv_t_3d, target).to(cell.dtype) return torch.linalg.solve(cell_3d.transpose(-1, -2).to(torch.float64), target).to( cell.dtype ) class _PMEReciprocalCachedFirstGrad(torch.autograd.Function): """PME reciprocal energy with detached first-derivative eval caches.""" @staticmethod def forward( ctx, positions, charges, cell, alpha, batch_idx, k_vectors, k_squared, volume, cell_inv_t, moduli_x, moduli_y, moduli_z, mesh_dimensions, spline_order, need_pos, need_charge, need_cell, ): """Compute energy and direct first-derivative states.""" need_forces = bool(need_pos) or bool(need_cell) need_charges = bool(need_charge) need_virial = bool(need_cell) impl_out = _pme_reciprocal_space_impl( positions.detach(), charges.detach(), cell.detach(), alpha.detach(), mesh_dimensions, spline_order, batch_idx.detach() if batch_idx is not None else None, compute_forces=need_forces, compute_charge_gradients=need_charges, compute_virial=need_virial, k_vectors=k_vectors.detach() if k_vectors is not None else None, k_squared=k_squared.detach() if k_squared is not None else None, volume=volume.detach() if volume is not None else None, cell_inv_t=cell_inv_t.detach() if cell_inv_t is not None else None, moduli_x=moduli_x.detach() if moduli_x is not None else None, moduli_y=moduli_y.detach() if moduli_y is not None else None, moduli_z=moduli_z.detach() if moduli_z is not None else None, return_cell_inv_t=need_virial, ) if need_virial: energies, forces, charge_grads, virial, cached_cell_inv_t = impl_out else: energies, forces, charge_grads, virial = impl_out cached_cell_inv_t = None cached_dEdR = -forces if need_forces else None cached_dEdq = charge_grads if need_charges else None cached_dEdcell = None if need_virial: cached_dEdcell = _pme_cell_grad_from_virial( positions.detach(), cached_dEdR, cell.detach(), virial, batch_idx, cached_cell_inv_t, ) ctx.save_for_backward( positions, charges, cell, alpha, batch_idx, k_vectors, k_squared, volume, cell_inv_t, moduli_x, moduli_y, moduli_z, cached_dEdR, cached_dEdq, cached_dEdcell, ) ctx.mesh_dimensions = mesh_dimensions ctx.spline_order = spline_order ctx.need_pos = bool(need_pos) ctx.need_charge = bool(need_charge) ctx.need_cell = bool(need_cell) return energies @staticmethod def backward(ctx, grad_energy): """Return cached first gradients or recompute for higher-order fallback.""" create_graph = torch.is_grad_enabled() ( positions, charges, cell, alpha, batch_idx, k_vectors, k_squared, volume, cell_inv_t, moduli_x, moduli_y, moduli_z, cached_dEdR, cached_dEdq, cached_dEdcell, ) = ctx.saved_tensors if create_graph or not _is_uniform_cotangent(grad_energy): if _has_potentially_geometry_dependent_charges(positions, charges): if create_graph: diff_inputs = [] diff_names = [] for name, tensor in ( ("positions", positions), ("cell", cell), ("alpha", alpha), ): if tensor.requires_grad: diff_inputs.append(tensor) diff_names.append(name) with torch.enable_grad(): recomputed, _forces, _charge_grads, _virial = ( _pme_reciprocal_space_impl( positions, charges, cell, alpha, ctx.mesh_dimensions, ctx.spline_order, batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) ) if diff_inputs: diff_grads = torch.autograd.grad( recomputed, tuple(diff_inputs), grad_outputs=grad_energy, allow_unused=True, create_graph=True, ) grad_map = dict(zip(diff_names, diff_grads, strict=True)) else: grad_map = {} return ( grad_map.get("positions"), None, grad_map.get("cell"), grad_map.get("alpha"), None, None, None, None, None, None, None, None, None, None, None, None, None, ) partial_inputs = [] partial_names = [] for name, tensor in ( ("positions", positions), ("cell", cell), ("alpha", alpha), ): if tensor.requires_grad: partial_inputs.append(tensor) partial_names.append(name) with torch.enable_grad(): partial_map = {} if partial_inputs: recomputed_partial, _forces, _charge_grads, _virial = ( _pme_reciprocal_space_impl( positions, charges.detach(), cell, alpha, ctx.mesh_dimensions, ctx.spline_order, batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) ) partial_grads = torch.autograd.grad( recomputed_partial, tuple(partial_inputs), grad_outputs=grad_energy, allow_unused=True, create_graph=create_graph, ) partial_map = dict( zip(partial_names, partial_grads, strict=True) ) grad_charges = None if charges.requires_grad: recomputed_charge, _forces, _charge_grads, _virial = ( _pme_reciprocal_space_impl( positions, charges, cell, alpha, ctx.mesh_dimensions, ctx.spline_order, batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) ) (grad_charges,) = torch.autograd.grad( recomputed_charge, charges, grad_outputs=grad_energy, allow_unused=True, create_graph=create_graph, ) return ( partial_map.get("positions"), grad_charges, partial_map.get("cell"), partial_map.get("alpha"), None, None, None, None, None, None, None, None, None, None, None, None, None, ) with torch.enable_grad(): recomputed, _forces, _charge_grads, _virial = ( _pme_reciprocal_space_impl( positions, charges, cell, alpha, ctx.mesh_dimensions, ctx.spline_order, batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) ) diff_inputs = [] diff_names = [] for name, tensor in ( ("positions", positions), ("charges", charges), ("cell", cell), ("alpha", alpha), ): if tensor.requires_grad: diff_inputs.append(tensor) diff_names.append(name) diff_grads = torch.autograd.grad( recomputed, tuple(diff_inputs), grad_outputs=grad_energy, allow_unused=True, create_graph=create_graph, ) grad_map = dict(zip(diff_names, diff_grads, strict=True)) grad_positions = grad_map.get("positions") grad_charges = grad_map.get("charges") grad_cell = grad_map.get("cell") grad_alpha = grad_map.get("alpha") return ( grad_positions, grad_charges, grad_cell, grad_alpha, None, None, None, None, None, None, None, None, None, None, None, None, None, ) grad = grad_energy.reshape(-1) atom_scale = grad[0] if batch_idx is None: system_scale = atom_scale else: system_scale = atom_scale grad_positions = cached_dEdR * atom_scale if ctx.need_pos else None grad_charges = cached_dEdq * atom_scale if ctx.need_charge else None grad_cell = cached_dEdcell * system_scale if ctx.need_cell else None return ( grad_positions, grad_charges, grad_cell, None, None, None, None, None, None, None, None, None, None, None, None, None, None, ) def _pme_reciprocal_cached_first_grad( positions: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: torch.Tensor, mesh_dimensions: tuple[int, int, int], spline_order: int, batch_idx: torch.Tensor | None, k_vectors: torch.Tensor | None, k_squared: torch.Tensor | None, volume: torch.Tensor | None, cell_inv_t: torch.Tensor | None, moduli_x: torch.Tensor | None, moduli_y: torch.Tensor | None, moduli_z: torch.Tensor | None, *, need_pos: bool, need_charge: bool, need_cell: bool, ) -> torch.Tensor: """Run the private first-order cached PME reciprocal energy path.""" return _PMEReciprocalCachedFirstGrad.apply( positions, charges, cell, alpha, batch_idx if batch_idx is not None else None, k_vectors if k_vectors is not None else None, k_squared if k_squared is not None else None, volume if volume is not None else None, cell_inv_t if cell_inv_t is not None else None, moduli_x if moduli_x is not None else None, moduli_y if moduli_y is not None else None, moduli_z if moduli_z is not None else None, mesh_dimensions, spline_order, need_pos, need_charge, need_cell, ) def _normalize_cell_inv_t_cache( cell_inv_t: torch.Tensor | None, ) -> torch.Tensor | None: """Normalize optional single-system ``cell_inv_t`` cache to batched shape.""" if cell_inv_t is not None and cell_inv_t.dim() == 2: return cell_inv_t.unsqueeze(0) return cell_inv_t def _pme_reciprocal_space_impl( positions: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: torch.Tensor, mesh_dimensions: tuple[int, int, int], spline_order: int, batch_idx: torch.Tensor | None, compute_forces: bool = False, compute_charge_gradients: bool = False, compute_virial: bool = False, k_vectors: torch.Tensor | None = None, k_squared: torch.Tensor | None = None, volume: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, moduli_x: torch.Tensor | None = None, moduli_y: torch.Tensor | None = None, moduli_z: torch.Tensor | None = None, hybrid_forces: bool = False, cache_forces: bool = False, cache_charge_gradients: bool = False, cache_virial: bool = False, return_cell_inv_t: bool = False, ) -> ( tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None, torch.Tensor | None] | tuple[ torch.Tensor, torch.Tensor | None, torch.Tensor | None, torch.Tensor | None, torch.Tensor, ] ): """Internal implementation of PME reciprocal space calculation. Uses unified spline functions from nvalchemiops.spline for charge assignment and potential interpolation, and Warp kernels for Green's function and corrections. Supports both float32 and float64 dtypes - all operations are performed in the input dtype without conversion. """ device = positions.device input_dtype = positions.dtype num_atoms = positions.shape[0] is_batch = batch_idx is not None fft_dims = (1, 2, 3) if is_batch else (0, 1, 2) volume_is_supplied = volume is not None if hybrid_forces: compute_charge_gradients = True alpha = _detach_setup_tensor(alpha) k_vectors = _detach_setup_tensor(k_vectors) k_squared = _detach_setup_tensor(k_squared) volume = _detach_setup_tensor(volume) cell_inv_t = _normalize_cell_inv_t_cache(_detach_setup_tensor(cell_inv_t)) moduli_x = _detach_setup_tensor(moduli_x) moduli_y = _detach_setup_tensor(moduli_y) moduli_z = _detach_setup_tensor(moduli_z) if num_atoms == 0: energies = torch.zeros(num_atoms, device=device, dtype=input_dtype) forces = ( torch.zeros(num_atoms, 3, device=device, dtype=input_dtype) if compute_forces else None ) charge_grads = ( torch.zeros(num_atoms, device=device, dtype=input_dtype) if compute_charge_gradients else None ) num_systems = cell.shape[0] if is_batch else 1 virial = ( torch.zeros(num_systems, 3, 3, device=device, dtype=input_dtype) if compute_virial else None ) if return_cell_inv_t: if cell_inv_t is None: cell_3d = cell if cell.dim() == 3 else cell.unsqueeze(0) cell_inv = torch.linalg.inv_ex(cell_3d)[0] cell_inv_t = cell_inv.transpose(-1, -2).contiguous() return energies, forces, charge_grads, virial, cell_inv_t return energies, forces, charge_grads, virial mesh_nx, mesh_ny, mesh_nz = mesh_dimensions # In hybrid mode, detach positions/charges/cell to sever autograd paths # through the spline/FFT chain. Charge gradients are attached via # straight-through trick after the forward pass. pos_spline = positions.detach() if hybrid_forces else positions chg_spline = charges.detach() if hybrid_forces else charges cell_spline = cell.detach() if hybrid_forces else cell if hybrid_forces and cell_inv_t is not None: cell_inv_t = cell_inv_t.detach() # Cell inverse + transpose: callers in MD loops can pass these in via the # cell_inv_t= kwarg to skip recomputation (typical NVT case). When provided, # we still need the un-transposed inverse for `reciprocal_cell`; derive it # back from the transpose so the caller only has to pass one tensor. if cell_inv_t is None: cell_inv = torch.linalg.inv_ex(cell_spline)[0] cell_inv_t = cell_inv.transpose(-1, -2).contiguous() else: cell_inv = cell_inv_t.transpose(-1, -2) reciprocal_cell = TWOPI * cell_inv # Step 1: Charge assignment using unified spline_spread API mesh_grid = spline_spread( pos_spline, chg_spline, cell_spline, mesh_dims=(mesh_nx, mesh_ny, mesh_nz), spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) # Step 3: Generate k-space grid and compute Green's function + structure factor # Green's function: G(k) = 2*pi * exp(-k^2/(4*alpha^2)) / (V * k^2) # (includes 1/2 pair-counting factor; see pme_kernels.py) # Use precomputed k_vectors/k_squared if provided, otherwise generate them if k_vectors is None or k_squared is None: k_vectors, k_squared = generate_k_vectors_pme( cell_spline, mesh_dimensions=mesh_dimensions, reciprocal_cell=reciprocal_cell, ) if hybrid_forces: k_vectors = k_vectors.detach() k_squared = k_squared.detach() alpha_gsf = alpha.detach() if hybrid_forces else alpha # Precomputed 1D B-spline modulus LUTs feed the fused convolve. Caller # can supply moduli_x/y/z to skip the fftfreq + sinc^p rebuild every # call (depends only on mesh + spline_order). if moduli_x is None or moduli_y is None or moduli_z is None: miller_x = torch.fft.fftfreq( mesh_nx, d=1.0 / mesh_nx, device=device, dtype=input_dtype ) miller_y = torch.fft.fftfreq( mesh_ny, d=1.0 / mesh_ny, device=device, dtype=input_dtype ) miller_z = torch.fft.rfftfreq( mesh_nz, d=1.0 / mesh_nz, device=device, dtype=input_dtype ) moduli_x = compute_bspline_moduli_1d(miller_x, mesh_nx, spline_order) moduli_y = compute_bspline_moduli_1d(miller_y, mesh_ny, spline_order) moduli_z = compute_bspline_moduli_1d(miller_z, mesh_nz, spline_order) # Volume: caller can supply via `volume=` kwarg (MD steady-state path); # otherwise compute from cell. if volume is None: cell_for_vol = ( cell_spline if cell_spline.dim() == 3 else cell_spline.unsqueeze(0) ) volume = torch.abs(torch.linalg.det(cell_for_vol)).to(input_dtype) else: if volume.dim() == 0: volume = volume.reshape(1) if hybrid_forces: volume = volume.detach() # FFT → fused convolve → inverse FFT. Both torch.fft.rfftn/irfftn and # the ``pme_fused_convolve`` custom op are fullgraph-traceable, so # there's no compile-vs-eager split anymore. # # cuFFT emits non-contiguous output; under torch.compile we must copy # to match the convolve launcher's stride contract, in eager we don't. mesh_fft = torch.fft.rfftn(mesh_grid, norm="backward", dim=fft_dims) if torch.compiler.is_compiling(): mesh_fft = mesh_fft.contiguous() need_virial_output = compute_virial or cache_virial mesh_fft_raw = mesh_fft if need_virial_output else None register_pme_ops() convolved_mesh = torch.ops.nvalchemiops.pme_fused_convolve( mesh_fft, k_squared, moduli_x, moduli_y, moduli_z, alpha_gsf, volume, is_batch, ) potential_mesh = torch.fft.irfftn( convolved_mesh, norm="forward", s=mesh_dimensions, dim=fft_dims ).to(input_dtype) # When forces are requested, the fused gather-with-force kernel # writes potential + spline-derivative force in one stencil walk. if compute_forces: raw_energies, gathered_force = spline_gather_with_force( pos_spline, chg_spline, potential_mesh, cell_spline, spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) else: raw_energies = spline_gather( pos_spline, potential_mesh, cell_spline, spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) gathered_force = None # Step 7: Apply corrections using Warp kernel # Reuse the `volume` computed above so the corrections path skips another # ``torch.linalg.det`` (which dispatches getrf/trsm/laswp on the 3x3 cell). charge_grads = None if compute_charge_gradients: reciprocal_energies, charge_grads = pme_energy_corrections_with_charge_grad( raw_energies, chg_spline, cell_spline, alpha, batch_idx, volume=volume, ) else: reciprocal_energies = pme_energy_corrections( raw_energies, chg_spline, cell_spline, alpha, batch_idx, volume=volume, ) if cache_charge_gradients: with torch.no_grad(): _, charge_grads = pme_energy_corrections_with_charge_grad( raw_energies.detach(), chg_spline.detach(), cell_spline.detach(), alpha.detach(), batch_idx, volume=volume.detach(), ) # Step 8: Compute virial before forces to allow early release of mesh_fft_raw # (virial needs mesh_fft_raw; forces only need convolved_mesh) virial = None if need_virial_output: if compute_virial: virial = _compute_pme_reciprocal_virial( mesh_fft_raw=mesh_fft_raw, convolved_mesh=convolved_mesh, k_vectors=k_vectors, k_squared=k_squared, alpha=alpha, mesh_dimensions=mesh_dimensions, is_batch=is_batch, device=device, dtype=input_dtype, ) else: with torch.no_grad(): virial = _compute_pme_reciprocal_virial( mesh_fft_raw=mesh_fft_raw.detach(), convolved_mesh=convolved_mesh.detach(), k_vectors=k_vectors.detach(), k_squared=k_squared.detach(), alpha=alpha.detach(), mesh_dimensions=mesh_dimensions, is_batch=is_batch, device=device, dtype=input_dtype, ) del mesh_fft_raw # Free before force field meshes are allocated # Background virial correction for non-neutral systems. # E_bg = π Q² / (2 α² V) is subtracted from energy; since # dE_bg/dε = -E_bg I (volume derivative), the virial contribution # is W_bg = -E_bg I. Single-system fans out via batch_idx=zeros. bg_batch_idx = ( batch_idx if is_batch else torch.zeros( chg_spline.shape[0], dtype=torch.int32, device=device, ) ) register_pme_ops() virial = torch.ops.nvalchemiops.pme_virial_bg_correction( ( chg_spline.to(input_dtype) if compute_virial else chg_spline.detach().to(input_dtype) ), bg_batch_idx, ( cell_spline.to(input_dtype) if compute_virial else cell_spline.detach().to(input_dtype) ), volume.to(input_dtype), volume_is_supplied, ( alpha.to(input_dtype) if compute_virial else alpha.detach().to(input_dtype) ), virial, ) # Step 9: Forces from the fused gather above. # gathered_force is -q * ∇Φ in Cartesian coordinates; the 2× scaling # accounts for the 1/2 pair-counting factor baked into the Green's # function (G = 2π/(V k²) instead of 4π/(V k²)). forces = None if compute_forces: # 2× scaling absorbs the 1/2 pair-counting factor baked into the # Green's function (G = 2π/(V k²) instead of 4π/(V k²)). forces = 2.0 * gathered_force elif cache_forces: with torch.no_grad(): _, cached_gathered_force = spline_gather_with_force( positions.detach(), charges.detach(), potential_mesh.detach(), cell.detach(), spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t.detach(), ) forces = 2.0 * cached_gathered_force if hybrid_forces and charges.requires_grad: def _fallback(p, q, c): fallback_energies, _forces, _charge_grads, _virial = ( _pme_reciprocal_space_impl( p, q, c, alpha, mesh_dimensions, spline_order, batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, hybrid_forces=False, ) ) return fallback_energies reciprocal_energies = _InjectCachedEvalGradWithFallback.apply( reciprocal_energies, positions, charges, cell, None, charge_grads.detach(), None, batch_idx, _fallback, ) if return_cell_inv_t: return reciprocal_energies, forces, charge_grads, virial, cell_inv_t return reciprocal_energies, forces, charge_grads, virial
[docs] def pme_reciprocal_space( positions: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: float | torch.Tensor, mesh_dimensions: tuple[int, int, int] | None = None, mesh_spacing: float | None = None, spline_order: int = 4, batch_idx: torch.Tensor | None = None, k_vectors: torch.Tensor | None = None, k_squared: torch.Tensor | None = None, compute_forces: bool = False, compute_charge_gradients: bool = False, compute_virial: bool = False, hybrid_forces: bool = False, *, cell_inv_t: torch.Tensor | None = None, volume: torch.Tensor | None = None, moduli_x: torch.Tensor | None = None, moduli_y: torch.Tensor | None = None, moduli_z: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, ...]: """Compute PME reciprocal-space energy and optionally forces and/or charge gradients. Performs the FFT-based reciprocal-space calculation using the Particle Mesh Ewald algorithm. This achieves :math:`O(N \\log N)` scaling through: 1. B-spline charge interpolation to mesh (spreading) 2. FFT of charge mesh to reciprocal space 3. Convolution with raw Green's function and B-spline deconvolution 4. Inverse FFT back to real space (potential mesh) 5. B-spline interpolation of potential to atoms (gathering) 6. Self-energy and background corrections Formula ------- The reciprocal-space energy is computed via the mesh potential: .. math:: \\varphi_{\\text{mesh}}(k) = \\frac{G(k)}{C^2(k)} \\rho_{\\text{mesh}}(k) where: - :math:`G(k) = (2\\pi/(V k^2)) \\times \\exp(-k^2/(4\\alpha^2))` is the volume-normalized PME Green's function used by this implementation - :math:`C^2(k)` is the squared B-spline structure factor - :math:`\\rho_{\\text{mesh}}(k)` is the FFT of interpolated charges Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic coordinates. Supports float32 or float64 dtype. charges : torch.Tensor, shape (N,) Atomic partial charges in elementary charge units. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrices with lattice vectors as rows. Shape (3, 3) is automatically promoted to (1, 3, 3). alpha : float or torch.Tensor Ewald splitting parameter controlling real/reciprocal space balance. - float: Same :math:`\\alpha` for all systems - Tensor shape (B,): Per-system :math:`\\alpha` values mesh_dimensions : tuple[int, int, int], optional Explicit FFT mesh dimensions (nx, ny, nz). Power-of-2 values are optimal for FFT performance. Either mesh_dimensions or mesh_spacing must be provided. mesh_spacing : float, optional Target mesh spacing in same units as cell. Mesh dimensions computed as ceil(cell_length / mesh_spacing). Typical value: ~1 Å. This setup path reads cell lengths into Python integers; pass explicit ``mesh_dimensions`` when cell-dependent mesh sizing is not desired. spline_order : int, default=4 B-spline interpolation order. Higher orders are more accurate but slower. - 4: Cubic B-splines (good balance, most common) - 5-6: Higher accuracy for demanding applications - Must be >= 3 for smooth interpolation batch_idx : torch.Tensor, shape (N,), dtype=int32, optional System index for each atom (0 to B-1). Determines kernel dispatch: - None: Single-system optimized kernels - Provided: Batched kernels for multiple independent systems When provided, atoms must be grouped by system: ``batch_idx`` must be contiguous, nondecreasing, and use system IDs ``0..B-1``. k_vectors : torch.Tensor, shape (nx, ny, nz//2+1, 3), optional Precomputed k-vectors from ``generate_k_vectors_pme``. Providing this along with k_squared skips k-vector generation (~15% speedup). Can be precomputed once and reused when cell and mesh are unchanged. When supplied while ``cell.requires_grad`` is true, the cache is assumed to correspond to the current ``cell``. k_squared : torch.Tensor, shape (nx, ny, nz//2+1), optional Precomputed :math:`|k|^2` values. Must be provided together with k_vectors. PME metadata tensors are setup constants and are detached from public autograd outputs. compute_forces : bool, default=False Whether to compute explicit component reciprocal-space forces. This direct output is kept for no-autograd MD/inference use; use energy autograd for differentiable training. compute_charge_gradients : bool, default=False Whether to compute explicit component charge gradients :math:`\\partial E/\\partial q_i`. This direct output follows the same no-autograd contract as ``compute_forces``. compute_virial : bool, default=False Whether to compute the component virial tensor ``W = -dE/d(displacement)`` for the row-vector displacement recipe. Stress = ``-virial / volume``. hybrid_forces : bool, default=False When True, positions and cell are detached from the autograd graph and charge gradients are attached to the energy via a straight-through trick. Forces and virial are forward-only (not differentiable). See :func:`ewald_real_space` for details. Returns ------- energies : torch.Tensor, shape (N,) Per-atom reciprocal-space energy (includes self and background corrections). forces : torch.Tensor, shape (N, 3), optional Direct reciprocal-space forces. Only returned if compute_forces=True. charge_gradients : torch.Tensor, shape (N,), optional Direct charge gradients :math:`\\partial E_{\\text{recip}}/\\partial q_i`. Only returned if compute_charge_gradients=True. virial : torch.Tensor, shape (1, 3, 3) or (B, 3, 3), optional Virial tensor. Only returned if compute_virial=True. Always last in tuple. Note ---- Internal reductions use float64 where needed for numerical stability. Returned energies, forces, and virials match the input dtype. Energy gradients are part of the public contract only for ``positions``, ``charges``, and ``cell``. Caller-supplied reciprocal metadata such as ``k_vectors``, ``k_squared``, ``volume``, and ``cell_inv_t`` is treated as static setup state that corresponds to the current ``cell``. When ``charges`` is a non-leaf tensor that may depend on ``positions`` (:math:`q = q(R)`), ordinary first-order losses may use cached partial derivatives and let PyTorch apply :math:`\\partial E/\\partial q \\cdot \\mathrm{d}q/\\mathrm{d}R` once. Weighted losses and higher-order derivatives recompute safe partials or connected gradients as needed to avoid double-counting that chain term (issue #115). ``torch.compile`` is supported by the public wrapper tests, although custom Warp operators and FFTs can still limit compiler fusion for PME workloads. Enabled output flags are appended in order: energies, [forces], [charge_gradients], [virial]. A single output is returned unwrapped; multiple outputs are returned as a tuple. Raises ------ ValueError If neither mesh_dimensions nor mesh_spacing is provided. Examples -------- Energy only with explicit mesh dimensions:: >>> energies = pme_reciprocal_space( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... ) >>> total_recip_energy = energies.sum() With forces using mesh spacing:: >>> energies, forces = pme_reciprocal_space( ... positions, charges, cell, ... alpha=0.3, mesh_spacing=1.0, ... compute_forces=True, ... ) Precomputed k-vectors for MD loop (fixed cell):: >>> from nvalchemiops.torch.interactions.electrostatics import generate_k_vectors_pme >>> mesh_dims = (32, 32, 32) >>> k_vectors, k_squared = generate_k_vectors_pme(cell, mesh_dims) >>> for step in range(num_steps): ... energies = pme_reciprocal_space( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=mesh_dims, ... k_vectors=k_vectors, k_squared=k_squared, ... ) With charge gradients for ML training:: >>> energies, charge_grads = pme_reciprocal_space( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... compute_charge_gradients=True, ... ) See Also -------- particle_mesh_ewald : Complete PME calculation (real + reciprocal). generate_k_vectors_pme : Generate k-vectors for this function. """ component_deprecated_flags = tuple( name for name, enabled in ( ("compute_charge_gradients", compute_charge_gradients), ("compute_virial", compute_virial), ("hybrid_forces", hybrid_forces), ) if enabled ) if component_deprecated_flags and not torch.compiler.is_compiling(): warnings.warn( _component_direct_output_deprecation_msg( "pme_reciprocal_space", component_deprecated_flags ), DeprecationWarning, stacklevel=2, ) ensure_electrostatics_ops_registered() cell, num_systems = _prepare_cell(cell) alpha_tensor = _detach_setup_tensor( _prepare_alpha(alpha, num_systems, torch.float64, positions.device) ) # Determine mesh dimensions if mesh_dimensions is None: if mesh_spacing is None: raise ValueError("Either mesh_dimensions or mesh_spacing must be provided") cell_lengths = torch.norm(cell[0], dim=1) mesh_dimensions = tuple( int(torch.ceil(length / mesh_spacing).item()) for length in cell_lengths ) k_vectors = _detach_setup_tensor(k_vectors) k_squared = _detach_setup_tensor(k_squared) volume = _detach_setup_tensor(volume) cell_inv_t = _normalize_cell_inv_t_cache(_detach_setup_tensor(cell_inv_t)) moduli_x = _detach_setup_tensor(moduli_x) moduli_y = _detach_setup_tensor(moduli_y) moduli_z = _detach_setup_tensor(moduli_z) position_grad = bool(positions.requires_grad) charge_grad = bool(charges.requires_grad) cell_grad = bool(cell.requires_grad) output_grad_requested = compute_forces or compute_charge_gradients or compute_virial # q(R) workloads may keep this cached-first path: _PMEReciprocalCachedFirstGrad.backward # routes non-uniform/create_graph cases through safe partial recompute instead of # returning connected position and charge gradients from the same graph. use_cached_first_grad = ( not output_grad_requested and not hybrid_forces and not torch.compiler.is_compiling() and not alpha_tensor.requires_grad and (position_grad or charge_grad or cell_grad) ) if use_cached_first_grad: return _pme_reciprocal_cached_first_grad( positions, charges, cell, alpha_tensor, mesh_dimensions, spline_order, batch_idx, k_vectors, k_squared, volume, cell_inv_t, moduli_x, moduli_y, moduli_z, need_pos=position_grad, need_charge=charge_grad, need_cell=cell_grad, ) # Deprecated direct-output calls still return a differentiable energy. For # ordinary uniform first-order losses, consume the direct derivatives already # produced for those outputs instead of traversing the full spline/FFT graph. # Weighted losses and create_graph=True fall through to the original graph in # _InjectCachedEvalGrad.backward. need_cached_pos = position_grad and output_grad_requested and not hybrid_forces need_cached_charge = ( charge_grad and not hybrid_forces and (output_grad_requested or not (position_grad or cell_grad)) ) need_cached_cell = cell_grad and output_grad_requested and not hybrid_forces need_force_cache = need_cached_pos or need_cached_cell # Energy is the single differentiable output. The eager graph remains present # for create_graph / non-uniform-cotangent cases; direct derivative caches are # attached below only for uniform first-order eval. impl_out = _pme_reciprocal_space_impl( positions, charges, cell, alpha_tensor, mesh_dimensions, spline_order, batch_idx, compute_forces=compute_forces, compute_charge_gradients=compute_charge_gradients, compute_virial=compute_virial, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, hybrid_forces=hybrid_forces, cache_forces=need_force_cache and not compute_forces, cache_charge_gradients=need_cached_charge and not compute_charge_gradients, cache_virial=need_cached_cell and not compute_virial, return_cell_inv_t=need_cached_cell, ) if need_cached_cell: energies, forces, charge_grads, virial, cached_cell_inv_t = impl_out else: energies, forces, charge_grads, virial = impl_out cached_cell_inv_t = None if need_cached_pos or need_cached_charge or need_cached_cell: cached_dEdR = -forces.detach() if need_cached_pos else None cached_dEdq = charge_grads.detach() if need_cached_charge else None cached_dEdcell = None if need_cached_cell: dEdR_for_cell = -forces.detach() cached_dEdcell = _pme_cell_grad_from_virial( positions.detach(), dEdR_for_cell, cell.detach(), virial.detach(), batch_idx, cached_cell_inv_t, ) energies = _InjectCachedEvalGrad.apply( energies, positions, charges, cell, cached_dEdR, cached_dEdq, cached_dEdcell, batch_idx, ) # Build return tuple based on flags match (compute_forces, compute_charge_gradients, compute_virial): case (True, True, True): return energies, forces, charge_grads, virial case (True, True, False): return energies, forces, charge_grads case (True, False, True): return energies, forces, virial case (True, False, False): return energies, forces case (False, True, True): return energies, charge_grads, virial case (False, True, False): return energies, charge_grads case (False, False, True): return energies, virial case _: return energies
########################################################################################### ########################### Unified PME API ############################################### ###########################################################################################
[docs] def particle_mesh_ewald( positions: torch.Tensor, charges: torch.Tensor, cell: torch.Tensor, alpha: float | torch.Tensor | None = None, mesh_spacing: float | None = None, mesh_dimensions: tuple[int, int, int] | None = None, spline_order: int = 4, batch_idx: torch.Tensor | None = None, k_vectors: torch.Tensor | None = None, k_squared: torch.Tensor | None = None, neighbor_list: torch.Tensor | None = None, neighbor_ptr: torch.Tensor | None = None, neighbor_shifts: torch.Tensor | None = None, neighbor_matrix: torch.Tensor | None = None, neighbor_matrix_shifts: torch.Tensor | None = None, mask_value: int | None = None, compute_forces: bool = False, compute_charge_gradients: bool = False, compute_virial: bool = False, accuracy: float = 1e-6, hybrid_forces: bool = False, pbc: torch.Tensor | None = None, slab_correction: bool = False, *, cell_inv_t: torch.Tensor | None = None, volume: torch.Tensor | None = None, moduli_x: torch.Tensor | None = None, moduli_y: torch.Tensor | None = None, moduli_z: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, ...]: """Complete Particle Mesh Ewald (PME) calculation for long-range electrostatics. Computes total Coulomb energy using the PME method, which achieves :math:`O(N \\log N)` scaling through FFT-based reciprocal space calculations. Combines: 1. Real-space contribution (short-range, erfc-damped) 2. Reciprocal-space contribution (long-range, FFT + B-spline interpolation) 3. Self-energy and background corrections Total Energy Formula: .. math:: E_{\\text{total}} = E_{\\text{real}} + E_{\\text{reciprocal}} - E_{\\text{self}} - E_{\\text{background}} where: .. math:: \\begin{aligned} E_{\\text{real}} &= \\frac{1}{2} \\sum_{i \\neq j} q_i q_j \\frac{\\operatorname{erfc}(\\alpha r_{ij})}{r_{ij}} \\\\ E_{\\text{reciprocal}} &= \\text{FFT-based smooth long-range contribution} \\\\ E_{\\text{self}} &= \\sum_i \\frac{\\alpha}{\\sqrt{\\pi}} q_i^2 \\\\ E_{\\text{background}} &= \\frac{\\pi}{2\\alpha^2 V} Q_{\\text{total}}^2 \\end{aligned} Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic coordinates. Supports float32 or float64 dtype. charges : torch.Tensor, shape (N,) Atomic partial charges in elementary charge units. cell : torch.Tensor, shape (3, 3) or (B, 3, 3) Unit cell matrices with lattice vectors as rows. Shape (3, 3) is automatically promoted to (1, 3, 3) for single-system mode. alpha : float, torch.Tensor, or None, default=None Ewald splitting parameter controlling real/reciprocal space balance. - float: Same :math:`\\alpha` for all systems - Tensor shape (B,): Per-system :math:`\\alpha` values - None: Automatically estimated using Kolafa-Perram formula Larger :math:`\\alpha` shifts more computation to reciprocal space. mesh_spacing : float, optional Target mesh spacing in same units as cell (typically Å). Mesh dimensions computed as ceil(cell_length / mesh_spacing). Typical value: 0.8-1.2 Å. This setup path reads cell lengths into Python integers; pass explicit ``mesh_dimensions`` when cell-dependent mesh sizing is not desired. mesh_dimensions : tuple[int, int, int], optional Explicit FFT mesh dimensions (nx, ny, nz). Power-of-2 values recommended for optimal FFT performance. If None and mesh_spacing is None, computed from accuracy parameter. spline_order : int, default=4 B-spline interpolation order. Higher orders are more accurate but slower. - 4: Cubic B-splines (standard, good accuracy/speed balance) - 5-6: Higher accuracy for demanding applications batch_idx : torch.Tensor, shape (N,), dtype=int32, optional System index for each atom (0 to B-1). Determines execution mode: - None: Single-system optimized kernels - Provided: Batched kernels for multiple independent systems When provided, atoms must be grouped by system: ``batch_idx`` must be contiguous, nondecreasing, and use system IDs ``0..B-1``. k_vectors : torch.Tensor, shape (nx, ny, nz//2+1, 3), optional Precomputed k-vectors from ``generate_k_vectors_pme``. Providing this along with k_squared skips k-vector generation (~15% speedup). Useful for fixed-cell MD simulations (NVT/NVE). When supplied while ``cell.requires_grad`` is true, the cache is assumed to correspond to the current ``cell``. k_squared : torch.Tensor, shape (nx, ny, nz//2+1), optional Precomputed :math:`|k|^2` values. Must be provided together with k_vectors. cell_inv_t : torch.Tensor, shape (3, 3) or (B, 3, 3), optional Precomputed transposed cell inverse :math:`(M^{-1})^T`. When supplied, the reciprocal-space path skips the per-call ``torch.linalg.inv`` of the cell (which dispatches getrf/trsm/laswp on the 3x3 cell every iteration). This is a setup constant for fixed-cell calls and is assumed to correspond to the current ``cell`` when supplied while ``cell.requires_grad`` is true. volume : torch.Tensor, shape (1,) or (B,), optional Precomputed cell volume :math:`|\\det(M)|`. When supplied, both the Green's-function normalization and the self/background correction skip ``torch.linalg.det`` (which also dispatches getrf under the hood). Same fixed-cell use-case as ``cell_inv_t``. moduli_x, moduli_y, moduli_z : torch.Tensor, optional Precomputed 1D B-spline modulus LUTs (``sinc(m/N)^spline_order`` per axis) from ``compute_bspline_moduli_1d``. When supplied, the reciprocal-space path skips the per-call ``fftfreq + sinc^p`` rebuild. The moduli only depend on mesh dimension + spline order, so callers can precompute them once for repeated calls with the same mesh and spline order. neighbor_list : torch.Tensor, shape (2, M), dtype=int32, optional Neighbor pairs for real-space in COO format. Row 0 = source indices, row 1 = target indices. Mutually exclusive with neighbor_matrix. neighbor_ptr : torch.Tensor, shape (N+1,), dtype=int32, optional CSR row pointers for neighbor_list. neighbor_ptr[i] gives the starting index in neighbor_list for atom i's neighbors. Required with neighbor_list. neighbor_shifts : torch.Tensor, shape (M, 3), dtype=int32, optional Periodic image shifts for neighbor_list. Required with neighbor_list. neighbor_matrix : torch.Tensor, shape (N, max_neighbors), dtype=int32, optional Dense neighbor matrix format. Entry [i, k] = j means j is k-th neighbor of i. Invalid entries should be set to mask_value. Mutually exclusive with neighbor_list. neighbor_matrix_shifts : torch.Tensor, shape (N, max_neighbors, 3), dtype=int32, optional Periodic image shifts for neighbor_matrix. Required with neighbor_matrix. mask_value : int, optional Value indicating invalid entries in neighbor_matrix. Defaults to N. compute_forces : bool, default=False .. deprecated:: 0.4.0 Deprecated direct-output flag. Compute energy and use ``torch.autograd.grad`` for differentiable forces. compute_charge_gradients : bool, default=False .. deprecated:: 0.4.0 Deprecated direct-output flag. Compute energy and use ``torch.autograd.grad`` for :math:`\\partial E/\\partial q_i`. compute_virial : bool, default=False .. deprecated:: 0.4.0 Deprecated direct-output flag for the virial tensor ``W = -dE/d(displacement)``. Stress = -virial / volume. accuracy : float, default=1e-6 Target relative accuracy for automatic parameter estimation (:math:`\\alpha`, mesh dims). Only used when alpha or mesh_dimensions is None. Smaller values increase accuracy but also computational cost. hybrid_forces : bool, default=False When True, positions and cell are detached from the autograd graph and charge gradients are attached to the energy via a straight-through trick. Forces and virial are forward-only (not differentiable). See :func:`ewald_real_space` for details. pbc : torch.Tensor, shape (3,) or (B, 3), optional Per-system periodic boundary conditions for slab correction. Required when ``slab_correction=True``. Each row has True for periodic directions and False for the non-periodic slab direction. Batched slab correction requires explicit shape (B, 3). slab_correction : bool, default=False Whether to add the two-dimensional Yeh-Berkowitz / Ballenegger slab correction to the 3D-periodic PME result. This is only available for the full PME interface; use :func:`compute_slab_correction` explicitly when manually composing ``ewald_real_space`` and ``pme_reciprocal_space``. Returns ------- energies : torch.Tensor, shape (N,) Per-atom contribution to total PME energy. Sum gives total energy. forces : torch.Tensor, shape (N, 3), optional .. deprecated:: 0.4.0 Deprecated direct forces. Only returned if compute_forces=True. charge_gradients : torch.Tensor, shape (N,), optional .. deprecated:: 0.4.0 Deprecated direct charge gradients :math:`\\partial E/\\partial q_i`. Only returned if compute_charge_gradients=True. virial : torch.Tensor, shape (1, 3, 3) or (B, 3, 3), optional Virial tensor. Only returned if compute_virial=True. Always last in tuple. Note ---- Internal reductions use float64 where needed for numerical stability. Returned energies, forces, and virials match the input dtype. Energy gradients are part of the public contract only for ``positions``, ``charges``, and ``cell``. Caller-supplied reciprocal metadata such as ``k_vectors``, ``k_squared``, ``volume``, and ``cell_inv_t`` is treated as static setup state that corresponds to the current ``cell``. When ``charges`` is a non-leaf tensor that may depend on ``positions`` (:math:`q = q(R)`), ordinary first-order losses may use cached partial derivatives and let PyTorch apply :math:`\\partial E/\\partial q \\cdot \\mathrm{d}q/\\mathrm{d}R` once. Weighted losses and higher-order derivatives recompute safe partials or connected gradients as needed to avoid double-counting that chain term (issue #115). Enabled output flags are appended in order: energies, [forces], [charge_gradients], [virial]. A single output is returned unwrapped; multiple outputs are returned as a tuple. Raises ------ ValueError If neither neighbor_list nor neighbor_matrix is provided for real-space. TypeError If alpha has an unsupported type. Examples -------- Automatic parameter estimation (recommended for most cases):: >>> energies = particle_mesh_ewald( ... positions, charges, cell, ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... accuracy=1e-6, ... ) >>> total_energy = energies.sum() Explicit parameters for reproducibility:: >>> energies = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... spline_order=4, ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) >>> forces = -torch.autograd.grad(energies.sum(), positions, create_graph=True)[0] Using mesh spacing for automatic mesh sizing:: >>> energies = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_spacing=1.0, # ~1 Å spacing ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) Batched systems (multiple independent structures):: >>> # positions: concatenated atoms from all systems >>> # batch_idx: [0,0,0,0, 1,1,1,1, 2,2,2,2] for 4 atoms x 3 systems >>> energies = particle_mesh_ewald( ... positions, charges, cells, # cells shape (3, 3, 3) ... alpha=torch.tensor([0.3, 0.35, 0.3]), ... batch_idx=batch_idx, ... mesh_dimensions=(32, 32, 32), ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) Precomputed k-vectors for MD loop (fixed cell):: >>> from nvalchemiops.torch.interactions.electrostatics import generate_k_vectors_pme >>> mesh_dims = (32, 32, 32) >>> k_vectors, k_squared = generate_k_vectors_pme(cell, mesh_dims) >>> for step in range(num_steps): ... energies = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=mesh_dims, ... k_vectors=k_vectors, k_squared=k_squared, ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) With charge gradients for ML training:: >>> charges.requires_grad_(True) >>> energies = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) >>> charge_grads = torch.autograd.grad(energies.sum(), charges, create_graph=True)[0] PME with slab correction:: >>> pbc_slab = torch.tensor([[True, True, False]], device=positions.device) >>> energies, forces = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... compute_forces=True, ... pbc=pbc_slab, ... slab_correction=True, ... ) Using PyTorch autograd:: >>> positions.requires_grad_(True) >>> energies = particle_mesh_ewald( ... positions, charges, cell, ... alpha=0.3, mesh_dimensions=(32, 32, 32), ... neighbor_list=nl, neighbor_ptr=nptr, neighbor_shifts=shifts, ... ) >>> total_energy = energies.sum() >>> total_energy.backward() >>> autograd_forces = -positions.grad # Should match explicit forces Notes ----- Automatic Parameter Estimation (when alpha is None): Uses Kolafa-Perram formula: .. math:: \\begin{aligned} \\eta &= \\frac{(V^2 / N)^{1/6}}{\\sqrt{2\\pi}} \\\\ \\alpha &= \\frac{1}{2\\eta} \\end{aligned} Mesh dimensions (when mesh_dimensions is None): .. math:: n_x = \\left\\lceil \\frac{2 \\alpha L_x}{3 \\varepsilon^{1/5}} \\right\\rceil Autograd Support: All inputs (positions, charges, cell) support gradient computation. See Also -------- pme_reciprocal_space : Reciprocal-space component only ewald_real_space : Real-space component (used internally) estimate_pme_parameters : Automatic parameter estimation PMEParameters : Container for PME parameters """ if compute_forces or compute_virial or compute_charge_gradients or hybrid_forces: if torch.compiler.is_compiling(): _compiled_direct_output_deprecation_signal("particle_mesh_ewald") else: warnings.warn( _direct_output_deprecation_msg("particle_mesh_ewald"), DeprecationWarning, stacklevel=2, ) ensure_electrostatics_ops_registered() num_atoms = positions.shape[0] # Prepare cell cell, num_systems = _prepare_cell(cell) if slab_correction: pbc = _prepare_pbc_for_slab(pbc, num_systems, positions.device) # Estimate parameters if not provided if alpha is None: params = estimate_pme_parameters(positions, cell, batch_idx, accuracy) alpha = params.alpha if mesh_dimensions is None and mesh_spacing is None: mesh_dimensions = tuple(params.mesh_dimensions) # Unpack the tuple # Prepare alpha tensor alpha = _prepare_alpha(alpha, num_systems, positions.dtype, positions.device) if mask_value is None: mask_value = num_atoms # Determine mesh dimensions if mesh_dimensions is None: if mesh_spacing is not None: mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing) else: # Use accuracy-based estimation mesh_dimensions = estimate_pme_mesh_dimensions(cell, alpha, accuracy) output_grad_requested = compute_forces or compute_charge_gradients or compute_virial differentiable_inputs = ( positions.requires_grad or charges.requires_grad or cell.requires_grad ) if ( not output_grad_requested and not hybrid_forces and not slab_correction and not torch.compiler.is_compiling() and differentiable_inputs and not alpha.requires_grad ): need_pos = positions.requires_grad need_charge = charges.requires_grad need_cell = cell.requires_grad need_forces = need_pos or need_cell cached_cell_inv_t = None def _compute_detached_components(): nonlocal cached_cell_inv_t rs_out = ewald_real_space( positions=positions.detach(), charges=charges.detach(), cell=cell.detach(), alpha=alpha.detach(), neighbor_list=neighbor_list, neighbor_ptr=neighbor_ptr, neighbor_shifts=neighbor_shifts, neighbor_matrix=neighbor_matrix, neighbor_matrix_shifts=neighbor_matrix_shifts, mask_value=mask_value, batch_idx=batch_idx, compute_forces=need_forces, compute_charge_gradients=need_charge, compute_virial=need_cell, ) rec_impl_out = _pme_reciprocal_space_impl( positions.detach(), charges.detach(), cell.detach(), alpha.detach(), mesh_dimensions, spline_order, batch_idx, compute_forces=need_forces, compute_charge_gradients=need_charge, compute_virial=need_cell, k_vectors=k_vectors, k_squared=k_squared, volume=volume, cell_inv_t=cell_inv_t, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, return_cell_inv_t=need_cell, ) if need_cell: ( rec_energies, rec_forces, rec_charge_grads, rec_virial, cached_cell_inv_t, ) = rec_impl_out else: rec_energies, rec_forces, rec_charge_grads, rec_virial = rec_impl_out rec_out = _build_electrostatic_result( rec_energies, rec_forces, rec_charge_grads, rec_virial, need_forces, need_charge, need_cell, ) return rs_out, rec_out if torch.compiler.is_compiling(): rs, rec = _compute_detached_components() else: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"The component direct-output flag\(s\).*", category=DeprecationWarning, ) rs, rec = _compute_detached_components() direct_outputs = _combine_electrostatic_outputs( rs, rec, None, need_forces, need_charge, need_cell, ) energies, forces, charge_grads, virial = _unpack_electrostatic_outputs( direct_outputs, need_forces, need_charge, need_cell, ) dEdR = -forces.detach() if forces is not None else None cached_dEdcell = None if need_cell: cached_dEdcell = _pme_cell_grad_from_virial( positions.detach(), dEdR, cell.detach(), virial.detach(), batch_idx, cached_cell_inv_t, ) def _fallback(p, q, c): rs_energy = ewald_real_space( positions=p, charges=q, cell=c, alpha=alpha, neighbor_list=neighbor_list, neighbor_ptr=neighbor_ptr, neighbor_shifts=neighbor_shifts, neighbor_matrix=neighbor_matrix, neighbor_matrix_shifts=neighbor_matrix_shifts, mask_value=mask_value, batch_idx=batch_idx, ) rec_energy = pme_reciprocal_space( positions=p, charges=q, cell=c, alpha=alpha, mesh_dimensions=mesh_dimensions, spline_order=spline_order, batch_idx=batch_idx, k_vectors=k_vectors, k_squared=k_squared, cell_inv_t=cell_inv_t, volume=volume, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) return rs_energy + rec_energy # q(R): shared fallback recompute detaches charges for geometry partials and # returns dE/dq separately so PyTorch chains dq/dR exactly once (issue #115). return _InjectCachedEvalGradWithFallback.apply( energies, positions, charges, cell, dEdR if need_pos else None, charge_grads.detach() if charge_grads is not None else None, cached_dEdcell, batch_idx, _fallback, ) if hybrid_forces and charges.requires_grad and not slab_correction: detached_charges = charges.detach() def _compute_hybrid_detached_components(): rs_out = ewald_real_space( positions=positions, charges=detached_charges, cell=cell, alpha=alpha, neighbor_list=neighbor_list, neighbor_ptr=neighbor_ptr, neighbor_shifts=neighbor_shifts, neighbor_matrix=neighbor_matrix, neighbor_matrix_shifts=neighbor_matrix_shifts, mask_value=mask_value, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=True, compute_virial=compute_virial, hybrid_forces=True, ) rec_out = pme_reciprocal_space( positions=positions, charges=detached_charges, cell=cell, alpha=alpha, mesh_dimensions=mesh_dimensions, spline_order=spline_order, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=True, compute_virial=compute_virial, k_vectors=k_vectors, k_squared=k_squared, cell_inv_t=cell_inv_t, volume=volume, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, hybrid_forces=True, ) return rs_out, rec_out if torch.compiler.is_compiling(): rs, rec = _compute_hybrid_detached_components() else: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"The component direct-output flag\(s\).*", category=DeprecationWarning, ) rs, rec = _compute_hybrid_detached_components() real_energies, real_forces, real_charge_grads, real_virial = ( _unpack_electrostatic_outputs(rs, compute_forces, True, compute_virial) ) rec_energies, rec_forces, rec_charge_grads, rec_virial = ( _unpack_electrostatic_outputs(rec, compute_forces, True, compute_virial) ) energies = real_energies + rec_energies forces = ( real_forces + rec_forces if compute_forces and real_forces is not None and rec_forces is not None else None ) charge_grads = real_charge_grads + rec_charge_grads virial = ( real_virial + rec_virial if compute_virial and real_virial is not None and rec_virial is not None else None ) def _fallback(p, q, c): rs_energy = ewald_real_space( positions=p, charges=q, cell=c, alpha=alpha, neighbor_list=neighbor_list, neighbor_ptr=neighbor_ptr, neighbor_shifts=neighbor_shifts, neighbor_matrix=neighbor_matrix, neighbor_matrix_shifts=neighbor_matrix_shifts, mask_value=mask_value, batch_idx=batch_idx, ) rec_energy = pme_reciprocal_space( positions=p, charges=q, cell=c, alpha=alpha, mesh_dimensions=mesh_dimensions, spline_order=spline_order, batch_idx=batch_idx, k_vectors=k_vectors, k_squared=k_squared, cell_inv_t=cell_inv_t, volume=volume, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, ) return rs_energy + rec_energy energies = _InjectCachedEvalGradWithFallback.apply( energies, positions, charges, cell, None, charge_grads.detach(), None, batch_idx, _fallback, ) return _build_electrostatic_result( energies, forces, charge_grads, virial, compute_forces, compute_charge_gradients, compute_virial, ) def _compute_components(): # Compute real-space contribution rs = ewald_real_space( positions=positions, charges=charges, cell=cell, alpha=alpha, neighbor_list=neighbor_list, neighbor_ptr=neighbor_ptr, neighbor_shifts=neighbor_shifts, neighbor_matrix=neighbor_matrix, neighbor_matrix_shifts=neighbor_matrix_shifts, mask_value=mask_value, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=compute_charge_gradients, compute_virial=compute_virial, hybrid_forces=hybrid_forces, ) # Compute reciprocal-space contribution rec = pme_reciprocal_space( positions=positions, charges=charges, cell=cell, alpha=alpha, mesh_dimensions=mesh_dimensions, spline_order=spline_order, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=compute_charge_gradients, compute_virial=compute_virial, k_vectors=k_vectors, k_squared=k_squared, cell_inv_t=cell_inv_t, volume=volume, moduli_x=moduli_x, moduli_y=moduli_y, moduli_z=moduli_z, hybrid_forces=hybrid_forces, ) return rs, rec suppress_component_warnings = ( compute_charge_gradients or compute_virial or hybrid_forces ) if suppress_component_warnings and not torch.compiler.is_compiling(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"The component direct-output flag\(s\).*", category=DeprecationWarning, ) rs, rec = _compute_components() else: rs, rec = _compute_components() slab_result: torch.Tensor | tuple[torch.Tensor, ...] | None = None if slab_correction: if hybrid_forces: slab_out = _compute_slab_correction( positions.detach(), charges.detach(), cell.detach(), pbc, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=True, compute_virial=compute_virial, ) slab_energies, slab_forces, slab_charge_grads, slab_virial = ( _unpack_electrostatic_outputs( slab_out, compute_forces, compute_charge_gradients=True, compute_virial=compute_virial, ) ) if charges.requires_grad: slab_energies = _compute_slab_correction( positions, charges, cell, pbc, batch_idx=batch_idx, compute_forces=False, compute_charge_gradients=False, compute_virial=False, ) slab_energies = _InjectChargeGrad.apply( slab_energies, charges, slab_charge_grads, batch_idx ) slab_result = _build_electrostatic_result( slab_energies, slab_forces, slab_charge_grads, slab_virial, compute_forces, compute_charge_gradients, compute_virial, ) else: slab_result = _compute_slab_correction( positions, charges, cell, pbc, batch_idx=batch_idx, compute_forces=compute_forces, compute_charge_gradients=compute_charge_gradients, compute_virial=compute_virial, ) return _combine_electrostatic_outputs( rs, rec, slab_result, compute_forces, compute_charge_gradients, compute_virial, )
__all__ = [ # Public APIs "particle_mesh_ewald", "pme_reciprocal_space", "pme_energy_corrections", "pme_energy_corrections_with_charge_grad", "pme_green_structure_factor", "compute_bspline_moduli_1d", ]