Source code for nvalchemiops.dynamics.optimizers.fire2

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

r"""
FIRE2 Optimizer Kernels
=======================

GPU-accelerated Warp kernels for the FIRE2 (Fast Inertial Relaxation
Engine v2) geometry optimizer.

This module provides three highly-fused kernels that implement a complete
FIRE2 step in only **3 kernel launches**, minimizing Python-side and
launch overhead.

The public :func:`fire2_update` helper exposes only the reduction, adaptive
parameter update, and velocity-mixing phase for callers that need a custom
final apply step, such as coupled variable-cell optimization.

FIRE2 ALGORITHM (Guenole et al., 2020)
=======================================

Given positions :math:`\mathbf{r}`, velocities :math:`\mathbf{v}`, and forces
:math:`\mathbf{F}`:

1. Half-step velocity update:
   :math:`\mathbf{v} \leftarrow \mathbf{v} + \mathbf{F} \Delta t`
2. Compute power:  :math:`P = \sum \mathbf{v} \cdot \mathbf{F}`  per system
3. Adaptive parameter update:
   - If :math:`P > 0`: increment counter, optionally grow :math:`\Delta t`,
     shrink :math:`\alpha`
   - If :math:`P \leq 0`: reset counter, shrink :math:`\Delta t`, reset
     :math:`\alpha`
4. Velocity mixing:
   :math:`\mathbf{v} = (1 - \alpha) \mathbf{v}
   + \alpha \sqrt{(\mathbf{v} \cdot \mathbf{v}) / (\mathbf{F} \cdot \mathbf{F})}\, \mathbf{F}`
5. Compute step:  :math:`\Delta \mathbf{r} = \mathbf{v} \Delta t`
6. Uphill correction:
   if :math:`P \leq 0`:
   :math:`\Delta \mathbf{r} = -\frac{1}{2} \Delta t\, \mathbf{v}_\text{mixed}`;
   :math:`\mathbf{v} = 0`
7. Step clamping + position update + coupled :math:`\Delta t` scaling

KERNEL STRUCTURE
================

Kernel 1 (_fire2_reduce_only):
    Runs-based triple inner-product reduction (vf, v.v, f.f) with
    deferred half-step computed in registers only (no velocity write).

Kernel 2 (_fire2_fused_mix_maxnorm):
    Fuses per-system parameter update, deferred half-step, velocity
    mixing, and runs-based max-norm reduction into a single launch.
    Each thread redundantly computes the parameter update for its
    segment from shared read-only inputs, avoiding inter-thread
    synchronization.

Kernel 3 (_fire2_clamp_apply_recompute):
    Recomputes step from mixed velocities, applies step clamping,
    position update, deferred velocity zeroing for uphill systems,
    and coupled dt scaling.

REFERENCES
==========

- Guenole et al. (2020). Comp. Mat. Sci. 175, 109584 (FIRE2)
- Bitzek et al. (2006). Phys. Rev. Lett. 97, 170201 (FIRE)
"""

from __future__ import annotations

import os
from typing import Any

import warp as wp

from nvalchemiops.dynamics.utils.kernel_functions import compute_vf_vv_ff
from nvalchemiops.segment_ops import compute_ept

__all__ = ["fire2_step", "fire2_update"]

# =============================================================================
# Kernel 1: Triple inner-product reduction (deferred half-step)
# =============================================================================

# Tile block size for cooperative reductions
TILE_DIM = int(os.getenv("NVALCHEMIOPS_DYNAMICS_TILE_DIM", 256))


@wp.kernel(enable_backward=False)
def _fire2_reduce_only_kernel(
    velocities: wp.array(dtype=Any),
    forces: wp.array(dtype=Any),
    dt: wp.array(dtype=Any),
    batch_idx: wp.array(dtype=wp.int32),
    vf: wp.array(dtype=Any),
    v_sumsq: wp.array(dtype=Any),
    f_sumsq: wp.array(dtype=Any),
    N: wp.int32,
    elems_per_thread: wp.int32,
):
    r"""Triple inner-product reduction with deferred velocity half-step.

    Computes three inner products per segment without modifying velocities:
    - ``vf[s] = sum(dot(v_upd[i], f[i]) for i where batch_idx[i] == s)``
    - ``v_sumsq[s] = sum(dot(v_upd[i], v_upd[i]) for i where batch_idx[i] == s)``
    - ``f_sumsq[s] = sum(dot(f[i], f[i]) for i where batch_idx[i] == s)``

    where ``v_upd[i] = velocities[i] + forces[i] * dt[batch_idx[i]]``.

    The half-step velocity update is computed in registers only and NOT written
    back to the velocities array. This deferred write is performed by the
    subsequent fused mixing kernel, which algebraically combines the half-step
    with the velocity mixing operation.

    Launch Grid
    -----------
    dim = ceil(N / elems_per_thread)

    Parameters
    ----------
    velocities : wp.array, shape (N,), dtype vec3f/vec3d
        Atomic velocities, read-only (not modified by this kernel).
    forces : wp.array, shape (N,), dtype vec3f/vec3d
        Forces on atoms.
    dt : wp.array, shape (M,), dtype float32/float64
        Per-system timestep (scalar dtype matching vector precision).
    batch_idx : wp.array, shape (N,), dtype int32
        Sorted system index per atom in [0, M).
    vf : wp.array, shape (M,), dtype float32/float64
        OUTPUT: :math:`v_\text{upd} \cdot f` per segment. Zeroed internally before each use.
    v_sumsq : wp.array, shape (M,), dtype float32/float64
        OUTPUT: :math:`v_\text{upd} \cdot v_\text{upd}` per segment. Zeroed internally before each use.
    f_sumsq : wp.array, shape (M,), dtype float32/float64
        OUTPUT: :math:`f \cdot f` per segment. Zeroed internally before each use.
    N : int32
        Total number of atoms.
    elems_per_thread : int32
        Elements processed per thread (auto-tuned based on array size and SM count).

    Notes
    -----
    - batch_idx must be sorted in non-decreasing order for correctness
    - Uses run-length encoding to minimize atomic operations
    - Part of the FIRE2 3-kernel optimization strategy
    - The deferred half-step approach avoids an intermediate velocity write
    """
    t = wp.tid()
    start = t * elems_per_thread
    if start >= N:
        return
    end = wp.min(start + elems_per_thread, N)

    # First element -- compute v_upd in register, do NOT write back
    s_cur = batch_idx[start]
    v_upd = velocities[start] + forces[start] * dt[s_cur]
    acc_vf, acc_vv, acc_ff = compute_vf_vv_ff(v_upd, forces[start])

    for i in range(start + 1, end):
        s = batch_idx[i]
        v_upd = velocities[i] + forces[i] * dt[s]
        val_vf, val_vv, val_ff = compute_vf_vv_ff(v_upd, forces[i])
        if s == s_cur:
            acc_vf = acc_vf + val_vf
            acc_vv = acc_vv + val_vv
            acc_ff = acc_ff + val_ff
        else:
            wp.atomic_add(vf, s_cur, acc_vf)
            wp.atomic_add(v_sumsq, s_cur, acc_vv)
            wp.atomic_add(f_sumsq, s_cur, acc_ff)
            s_cur = s
            acc_vf = val_vf
            acc_vv = val_vv
            acc_ff = val_ff

    wp.atomic_add(vf, s_cur, acc_vf)
    wp.atomic_add(v_sumsq, s_cur, acc_vv)
    wp.atomic_add(f_sumsq, s_cur, acc_ff)


@wp.kernel(enable_backward=False)
def _fire2_reduce_only_tiled_kernel(
    velocities: wp.array(dtype=Any),
    forces: wp.array(dtype=Any),
    dt: wp.array(dtype=Any),
    batch_idx: wp.array(dtype=wp.int32),
    vf: wp.array(dtype=Any),
    v_sumsq: wp.array(dtype=Any),
    f_sumsq: wp.array(dtype=Any),
):
    """Triple inner-product reduction with tile reductions (per-atom processing).

    Computes three inner products per system using block-level tile reductions:
    - vf[s] = sum(dot(v_upd[i], f[i]) for i where batch_idx[i] == s)
    - v_sumsq[s] = sum(dot(v_upd[i], v_upd[i]) for i where batch_idx[i] == s)
    - f_sumsq[s] = sum(dot(f[i], f[i]) for i where batch_idx[i] == s)

    where v_upd[i] = velocities[i] + forces[i] * dt[batch_idx[i]].

    Launch Grid: dim = [N_atoms], block_dim = TILE_DIM

    Notes
    -----
    - Simpler per-atom processing (no RLE complexity)
    - Uses wp.tile() and wp.tile_sum() for cooperative reduction
    - Reduces atomics from N to N/TILE_DIM per system
    """
    atom_idx = wp.tid()
    system_id = batch_idx[atom_idx]

    # Compute deferred half-step in register only
    v_upd = velocities[atom_idx] + forces[atom_idx] * dt[system_id]

    # Compute local contributions
    local_vf, local_vv, local_ff = compute_vf_vv_ff(v_upd, forces[atom_idx])

    # Convert to tiles for block-level reduction
    t_vf = wp.tile(local_vf)
    t_vv = wp.tile(local_vv)
    t_ff = wp.tile(local_ff)

    # Cooperative sum within block
    s_vf = wp.tile_sum(t_vf)
    s_vv = wp.tile_sum(t_vv)
    s_ff = wp.tile_sum(t_ff)

    # Extract scalar values from tile sums
    sum_vf = s_vf[0]
    sum_vv = s_vv[0]
    sum_ff = s_ff[0]

    # Only first thread in block writes (3 atomics per block)
    if atom_idx % TILE_DIM == 0:
        wp.atomic_add(vf, system_id, sum_vf)
        wp.atomic_add(v_sumsq, system_id, sum_vv)
        wp.atomic_add(f_sumsq, system_id, sum_ff)


# =============================================================================
# Kernel 2: Fused param update + deferred halfstep + mix + max-norm
# =============================================================================


@wp.kernel(enable_backward=False)
def _fire2_fused_mix_maxnorm_kernel(
    velocities: wp.array(dtype=Any),
    forces: wp.array(dtype=Any),
    dt: wp.array(dtype=Any),
    batch_idx: wp.array(dtype=wp.int32),
    vf: wp.array(dtype=Any),
    v_sumsq: wp.array(dtype=Any),
    f_sumsq: wp.array(dtype=Any),
    alpha: wp.array(dtype=Any),
    nsteps_inc: wp.array(dtype=wp.int32),
    max_norm: wp.array(dtype=Any),
    compute_max_norm: wp.bool,
    N: wp.int32,
    elems_per_thread: wp.int32,
    delaystep: wp.int32,
    dtgrow: Any,
    dtshrink: Any,
    alphashrink: Any,
    alpha0: Any,
    tmax: Any,
    tmin: Any,
):
    r"""Fused adaptive parameter update, deferred half-step, velocity mixing, and max-norm reduction.

    This kernel performs four operations in a single launch:

    1. **Adaptive parameter update** (per-segment, redundantly computed):
       - If ``vf[s] > 0`` (downhill): increment counter, optionally grow dt, shrink alpha
       - If ``vf[s] <= 0`` (uphill): reset counter, shrink dt, reset alpha

    2. **Deferred half-step + velocity mixing** (algebraically combined):
       ``v[i] = mix_a * v[i] + (mix_a * dt_old + mix_b) * f[i]``
       where ``mix_a = 1 - alpha``, ``mix_b = alpha * sqrt(v.v / f.f)``

    3. **State updates** (first atom per segment writes):
       Updates ``alpha[s]``, ``dt[s]``, and ``nsteps_inc[s]``

    4. **Max-norm reduction** (run-length encoded):
       Computes ``max_norm[s] = max(||step[i]|| for i where batch_idx[i] == s)``
       where step depends on uphill/downhill status

    Each thread redundantly computes the per-system parameter update from shared
    read-only inputs (vf, v_sumsq, f_sumsq), avoiding inter-thread synchronization.

    Launch Grid
    -----------
    dim = ceil(N / elems_per_thread)

    Parameters
    ----------
    velocities : wp.array, shape (N,), dtype vec3f/vec3d
        Atomic velocities, modified in-place. Must hold pre-halfstep values
        (kernel 1 did not modify them).
    forces : wp.array, shape (N,), dtype vec3f/vec3d
        Forces on atoms (read-only).
    dt : wp.array, shape (M,), dtype float32/float64
        Per-system timestep. Modified in-place by first atom per segment.
    batch_idx : wp.array, shape (N,), dtype int32
        Sorted system index per atom in [0, M).
    vf : wp.array, shape (M,), dtype float32/float64
        :math:`v \cdot f` inner product per segment from kernel 1 (read-only).
    v_sumsq : wp.array, shape (M,), dtype float32/float64
        :math:`v \cdot v` inner product per segment from kernel 1 (read-only).
    f_sumsq : wp.array, shape (M,), dtype float32/float64
        :math:`f \cdot f` inner product per segment from kernel 1 (read-only).
    alpha : wp.array, shape (M,), dtype float32/float64
        FIRE2 mixing parameter. Modified in-place by first atom per segment.
    nsteps_inc : wp.array, shape (M,), dtype int32
        Consecutive positive-power step counter. Modified by first atom per segment.
    max_norm : wp.array, shape (M,), dtype float32/float64
        OUTPUT: Maximum step norm per segment. Zeroed internally before each use.
    compute_max_norm : bool
        If true, compute and write ``max_norm``. If false, skip the max-norm
        reduction while still applying the FIRE2 velocity and state updates.
    N : int32
        Total number of atoms.
    elems_per_thread : int32
        Elements processed per thread (auto-tuned based on array size).
    delaystep : int32
        Minimum consecutive positive steps before dt growth.
    dtgrow : float32/float64
        Timestep growth factor (typically 1.05).
    dtshrink : float32/float64
        Timestep shrink factor (typically 0.75).
    alphashrink : float32/float64
        Alpha decay factor (typically 0.985).
    alpha0 : float32/float64
        Alpha reset value (typically 0.09).
    tmax : float32/float64
        Maximum allowed timestep.
    tmin : float32/float64
        Minimum allowed timestep.

    Notes
    -----
    - batch_idx must be sorted in non-decreasing order
    - Only the first atom in each segment writes to alpha, dt, nsteps_inc
    - Parameter updates are computed redundantly by each thread to avoid synchronization
    - The algebraic combination of half-step and mixing eliminates one velocity write
    - For uphill systems (vf <= 0), step norm uses factor -0.5 for the correction
    """
    t = wp.tid()
    start = t * elems_per_thread
    if start >= N:
        return
    end = wp.min(start + elems_per_thread, N)

    s_cur = batch_idx[start]

    # --- Redundant param-update computation for the first segment ---
    _vf = vf[s_cur]
    _vv = v_sumsq[s_cur]
    _ff = f_sumsq[s_cur]
    _a = alpha[s_cur]
    _dt = dt[s_cur]
    dt_old = _dt  # pre-update dt for the deferred half-step

    zero = type(_dt)(0.0)
    one = type(_dt)(1.0)
    w_inc = _vf > zero

    if w_inc:
        _nsi = nsteps_inc[s_cur] + 1
        if _nsi > delaystep:
            _dt = wp.min(dtgrow * _dt, tmax)
            _a = alphashrink * _a
    else:
        _nsi = 0
        _a = alpha0
        _dt = wp.max(dtshrink * _dt, tmin)

    # First atom per segment writes updated params
    if start == 0 or batch_idx[start - 1] != s_cur:
        alpha[s_cur] = _a
        dt[s_cur] = _dt
        nsteps_inc[s_cur] = _nsi

    if _ff > zero:
        ratio = wp.sqrt(_vv / _ff)
    else:
        ratio = zero
    mix_a = one - _a
    mix_b = _a * ratio
    w_dec = not w_inc

    # --- Process first element: deferred halfstep + mix (algebraic combo) ---
    f_coeff = mix_a * dt_old + mix_b
    velocities[start] = mix_a * velocities[start] + f_coeff * forces[start]
    max_val = zero
    if compute_max_norm:
        if w_dec:
            max_val = wp.length(type(_dt)(-0.5) * _dt * velocities[start])
        else:
            max_val = wp.length(_dt * velocities[start])

    for i in range(start + 1, end):
        s = batch_idx[i]
        if s != s_cur:
            # Flush max_norm for previous segment
            if compute_max_norm:
                wp.atomic_max(max_norm, s_cur, max_val)
            s_cur = s

            # --- Redundant param-update computation for new segment ---
            _vf = vf[s]
            _vv = v_sumsq[s]
            _ff = f_sumsq[s]
            _a = alpha[s]
            _dt = dt[s]
            dt_old = _dt

            w_inc = _vf > zero
            if w_inc:
                _nsi = nsteps_inc[s] + 1
                if _nsi > delaystep:
                    _dt = wp.min(dtgrow * _dt, tmax)
                    _a = alphashrink * _a
            else:
                _nsi = 0
                _a = alpha0
                _dt = wp.max(dtshrink * _dt, tmin)

            if batch_idx[i - 1] != s:
                alpha[s] = _a
                dt[s] = _dt
                nsteps_inc[s] = _nsi

            if _ff > zero:
                ratio = wp.sqrt(_vv / _ff)
            else:
                ratio = zero
            mix_a = one - _a
            mix_b = _a * ratio
            w_dec = not w_inc
            f_coeff = mix_a * dt_old + mix_b
            max_val = type(_dt)(0.0)

        # Deferred halfstep + mix (algebraic combo)
        velocities[i] = mix_a * velocities[i] + f_coeff * forces[i]
        if compute_max_norm:
            if w_dec:
                norm = wp.length(type(_dt)(-0.5) * _dt * velocities[i])
            else:
                norm = wp.length(_dt * velocities[i])
            max_val = wp.max(max_val, norm)

    if compute_max_norm:
        wp.atomic_max(max_norm, s_cur, max_val)


# =============================================================================
# Kernel 3: Step recompute + clamping + position update + velocity zeroing
# =============================================================================


@wp.kernel(enable_backward=False)
def _fire2_clamp_apply_recompute_kernel(
    positions: wp.array(dtype=Any),
    velocities: wp.array(dtype=Any),
    dt: wp.array(dtype=Any),
    batch_idx: wp.array(dtype=wp.int32),
    max_norm: wp.array(dtype=Any),
    vf: wp.array(dtype=Any),
    maxstep: Any,
):
    r"""Step recomputation, clamping, position update, velocity zeroing, and coupled dt scaling.

    This kernel performs the final operations of the FIRE2 step:

    1. **Step recomputation** from mixed velocities (avoids storing step buffer)
    2. **Uphill correction**: For ``vf[s] <= 0``, applies ``step = -0.5 * dt * v``
    3. **Step clamping**: Scales step by ``min(1.0, maxstep / max_norm[s])``
    4. **Position update**: ``positions[i] += clamped_step``
    5. **Velocity zeroing**: Sets ``velocities[i] = 0`` for uphill systems
    6. **Coupled dt scaling**: Scales ``dt[s]`` by the same clamping factor

    The algorithm:
    ```
    local_dt = dt[s]  (snapshot before any thread modifies it)
    inv = min(1.0, maxstep / max_norm[s])
    if vf[s] <= 0:  # uphill
        step = -0.5 * local_dt * v[i]
        v[i] = 0
    else:  # downhill
        step = local_dt * v[i]
    positions[i] += step * inv
    if first_atom_in_segment:
        dt[s] = local_dt * inv
    ```

    Launch Grid
    -----------
    dim = N (total atoms)

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype vec3f/vec3d
        Atomic positions, modified in-place.
    velocities : wp.array, shape (N,), dtype vec3f/vec3d
        Atomic velocities, modified in-place (zeroed for uphill systems).
    dt : wp.array, shape (M,), dtype float32/float64
        Per-system timestep, modified in-place by first atom per segment
        (clamped proportionally to step scaling).
    batch_idx : wp.array, shape (N,), dtype int32
        Sorted system index per atom in [0, M).
    max_norm : wp.array, shape (M,), dtype float32/float64
        Maximum step norm per segment from kernel 2.
    vf : wp.array, shape (M,), dtype float32/float64
        :math:`v \cdot f` inner product per segment from kernel 1. System is uphill if vf[s] <= 0.
    maxstep : float32/float64
        Maximum allowed step size (FIRE2 hyperparameter).

    Notes
    -----
    - Each thread reads dt[s] before any thread writes to avoid race conditions
    - Only the first atom in each segment (batch_idx[i-1] != batch_idx[i]) writes dt[s]
    - Velocity zeroing for uphill systems is deferred to this kernel for efficiency
    - Coupled dt scaling ensures consistency between step size and timestep
    - The -0.5 factor for uphill correction is part of the FIRE2 algorithm
    """
    tid = wp.tid()
    s = batch_idx[tid]
    # Snapshot dt before any thread writes to it (race-condition guard)
    local_dt = dt[s]
    mn = max_norm[s]
    inv = wp.min(type(mn)(1.0), maxstep / mn)

    if vf[s] <= type(mn)(0.0):
        local_step = type(mn)(-0.5) * local_dt * velocities[tid]
        velocities[tid] = type(velocities[tid])()
    else:
        local_step = local_dt * velocities[tid]

    positions[tid] = positions[tid] + local_step * inv
    # Only first atom in segment updates dt (idx is sorted)
    if tid == 0 or batch_idx[tid - 1] != s:
        dt[s] = local_dt * inv


# =============================================================================
# Overloads
# =============================================================================

_T = [wp.float32, wp.float64]
_V = [wp.vec3f, wp.vec3d]

_fire2_reduce_only_overloads = {}
_fire2_reduce_only_tiled_overloads = {}
_fire2_fused_mix_maxnorm_overloads = {}
_fire2_clamp_apply_recompute_overloads = {}

for _t, _v in zip(_T, _V):
    _fire2_reduce_only_overloads[_v] = wp.overload(
        _fire2_reduce_only_kernel,
        [
            wp.array(dtype=_v),  # velocities
            wp.array(dtype=_v),  # forces
            wp.array(dtype=_t),  # dt
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=_t),  # vf
            wp.array(dtype=_t),  # v_sumsq
            wp.array(dtype=_t),  # f_sumsq
            wp.int32,  # N
            wp.int32,  # elems_per_thread
        ],
    )

    _fire2_reduce_only_tiled_overloads[_v] = wp.overload(
        _fire2_reduce_only_tiled_kernel,
        [
            wp.array(dtype=_v),  # velocities
            wp.array(dtype=_v),  # forces
            wp.array(dtype=_t),  # dt
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=_t),  # vf
            wp.array(dtype=_t),  # v_sumsq
            wp.array(dtype=_t),  # f_sumsq
        ],
    )

    _fire2_fused_mix_maxnorm_overloads[_v] = wp.overload(
        _fire2_fused_mix_maxnorm_kernel,
        [
            wp.array(dtype=_v),  # velocities
            wp.array(dtype=_v),  # forces
            wp.array(dtype=_t),  # dt
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=_t),  # vf
            wp.array(dtype=_t),  # v_sumsq
            wp.array(dtype=_t),  # f_sumsq
            wp.array(dtype=_t),  # alpha
            wp.array(dtype=wp.int32),  # nsteps_inc
            wp.array(dtype=_t),  # max_norm
            wp.bool,  # compute_max_norm
            wp.int32,  # N
            wp.int32,  # elems_per_thread
            wp.int32,  # delaystep
            _t,  # dtgrow
            _t,  # dtshrink
            _t,  # alphashrink
            _t,  # alpha0
            _t,  # tmax
            _t,  # tmin
        ],
    )

    _fire2_clamp_apply_recompute_overloads[_v] = wp.overload(
        _fire2_clamp_apply_recompute_kernel,
        [
            wp.array(dtype=_v),  # positions
            wp.array(dtype=_v),  # velocities
            wp.array(dtype=_t),  # dt
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=_t),  # max_norm
            wp.array(dtype=_t),  # vf (v.f inner product)
            _t,  # maxstep
        ],
    )


# =============================================================================
# Public API
# =============================================================================


[docs] def fire2_step( # Per-atom arrays (N,) positions: wp.array, velocities: wp.array, forces: wp.array, batch_idx: wp.array, # Per-system state (M,) alpha: wp.array, dt: wp.array, nsteps_inc: wp.array, # Scratch buffers (M,) vf: wp.array, v_sumsq: wp.array, f_sumsq: wp.array, max_norm: wp.array, # Hyperparameters (Python scalars) delaystep: int = 60, dtgrow: float = 1.05, dtshrink: float = 0.75, alphashrink: float = 0.985, alpha0: float = 0.09, tmax: float = 0.08, tmin: float = 0.005, maxstep: float = 0.1, compute_reductions: bool = True, ) -> None: r"""Complete FIRE2 optimization step. Modifies *positions*, *velocities*, *alpha*, *dt*, and *nsteps_inc* in-place. Runs the full FIRE2 scheme of Guenole et al. (2020): the leapfrog half-step is deferred into the reduction, so the per-system power is measured on the post-kick velocity :math:`\mathbf{v} + \Delta t\,\mathbf{F}`: .. math:: P = \sum_i (\mathbf{v}_i + \Delta t\,\mathbf{F}_i) \cdot \mathbf{F}_i,\quad vv = \sum_i \lVert \mathbf{v}_i + \Delta t\,\mathbf{F}_i \rVert^2,\quad ff = \sum_i \mathbf{F}_i \cdot \mathbf{F}_i The half-step and the FIRE2 mixing are then fused (no intermediate velocity write): .. math:: \mathbf{v} = (1-\alpha)\,\mathbf{v} + \big[(1-\alpha)\,\Delta t + \alpha\sqrt{vv/ff}\big]\,\mathbf{F} Displacement is :math:`\Delta\mathbf{r} = \Delta t\,\mathbf{v}` when :math:`P > 0`; for uphill systems (:math:`P \le 0`) the FIRE2 correction :math:`\Delta\mathbf{r} = -\tfrac{1}{2}\Delta t\,\mathbf{v}` is applied and the velocity is then zeroed. The step is capped by :math:`\min(1, \text{maxstep}/\lVert\text{step}\rVert)` per system, and :math:`\Delta t` is scaled by the same factor. This differs from :func:`~nvalchemiops.dynamics.optimizers.fire.fire_step` by the deferred half-step, the modified mixing coefficient, and the half-back uphill correction (FIRE simply zeroes velocity uphill). Parameters ---------- positions : wp.array, shape (N,), dtype vec3f/vec3d Atomic positions. velocities : wp.array, shape (N,), dtype vec3f/vec3d Atomic velocities. forces : wp.array, shape (N,), dtype vec3f/vec3d Forces on atoms (read-only). batch_idx : wp.array, shape (N,), dtype int32 Sorted system index per atom. Required for non-empty inputs. alpha : wp.array, shape (M,), dtype float* FIRE2 mixing parameter. dt : wp.array, shape (M,), dtype float* Per-system timestep. nsteps_inc : wp.array, shape (M,), dtype int32 Consecutive positive-power step counter. vf, v_sumsq, f_sumsq, max_norm : wp.array, shape (M,), dtype float* Scratch buffers for reductions. Zeroed internally before each use. delaystep : int Minimum positive steps before dt growth. dtgrow, dtshrink : float Timestep growth/shrink factors. alphashrink : float Alpha decay factor. alpha0 : float Alpha reset value. tmax, tmin : float Timestep bounds. maxstep : float Maximum step magnitude per system. compute_reductions : bool, default True If True, recompute ``vf``/``v_sumsq``/``f_sumsq`` internally. If False, use the caller-supplied values for the mixing and parameter update. Note that the final ``maxstep`` clamp still uses the ``max_norm`` produced by this call's mixing kernel; for a clamp reduced across a caller-side partition, drive the mixing via ``fire2_update`` and apply the clamp after reducing ``max_norm``. Notes ----- - ``batch_idx`` must be sorted; segment reductions assume contiguous atom ranges per system. Examples -------- >>> fire2_step(positions, velocities, forces, batch_idx, ... alpha, dt, nsteps_inc, ... vf, v_sumsq, f_sumsq, max_norm) """ N = positions.shape[0] if velocities.shape[0] != N: raise ValueError( f"velocities length {velocities.shape[0]} != positions length {N}" ) if forces.shape[0] != N: raise ValueError(f"forces length {forces.shape[0]} != positions length {N}") if batch_idx is not None and batch_idx.shape[0] != N: raise ValueError( f"batch_idx length {batch_idx.shape[0]} != positions length {N}" ) if N == 0: vf.zero_() v_sumsq.zero_() f_sumsq.zero_() max_norm.zero_() return if batch_idx is None: raise ValueError("batch_idx is required for fire2_step") vec_dtype = positions.dtype device = positions.device fire2_update( velocities=velocities, forces=forces, batch_idx=batch_idx, alpha=alpha, dt=dt, nsteps_inc=nsteps_inc, vf=vf, v_sumsq=v_sumsq, f_sumsq=f_sumsq, max_norm=max_norm, delaystep=delaystep, dtgrow=dtgrow, dtshrink=dtshrink, alphashrink=alphashrink, alpha0=alpha0, tmax=tmax, tmin=tmin, compute_reductions=compute_reductions, ) # Kernel 3: recompute step + clamp + position update + velocity zeroing wp.launch( _fire2_clamp_apply_recompute_overloads[vec_dtype], dim=N, inputs=[ positions, velocities, dt, batch_idx, max_norm, vf, # vf holds v.f; uphill if <= 0 maxstep, ], device=device, )
def fire2_reduce( velocities: wp.array, forces: wp.array, dt: wp.array, batch_idx: wp.array, vf: wp.array, v_sumsq: wp.array, f_sumsq: wp.array, ) -> None: r"""Fill the FIRE2 per-system reductions over the half-stepped velocities. Computes, per system :math:`s`: .. math:: vf[s] &= \sum_i (\mathbf{v}_i + \mathbf{F}_i \Delta t[s]) \cdot \mathbf{F}_i \\ v_{sumsq}[s] &= \sum_i (\mathbf{v}_i + \mathbf{F}_i \Delta t[s]) \cdot (\mathbf{v}_i + \mathbf{F}_i \Delta t[s]) \\ f_{sumsq}[s] &= \sum_i \mathbf{F}_i \cdot \mathbf{F}_i — the same reduction ``fire2_update`` performs internally (the :math:`\mathbf{v} + \mathbf{F} \Delta t` half-step is applied in registers, velocities are not modified). Exposed so a caller can build the reductions over a subset of the degrees of freedom and combine them. Parameters ---------- velocities, forces : wp.array, shape (N,), dtype vec3f/vec3d Velocities and forces (read-only). dt : wp.array, shape (M,), dtype float* Per-system timestep used for the half-step. batch_idx : wp.array, shape (N,), dtype int32 Sorted system index per element. vf, v_sumsq, f_sumsq : wp.array, shape (M,), dtype float* Outputs. Zeroed internally before accumulation. """ vf.zero_() v_sumsq.zero_() f_sumsq.zero_() N = velocities.shape[0] if N == 0: return if batch_idx is None: raise ValueError("batch_idx is required for fire2_reduce") vec_dtype = velocities.dtype device = velocities.device sm = max(device.sm_count, 1) ept = compute_ept(N, sm, True) dim = (N + ept - 1) // ept wp.launch( _fire2_reduce_only_overloads[vec_dtype], dim=dim, inputs=[velocities, forces, dt, batch_idx, vf, v_sumsq, f_sumsq, N, ept], device=device, ) def fire2_apply_step( positions: wp.array, velocities: wp.array, dt: wp.array, batch_idx: wp.array, max_norm: wp.array, vf: wp.array, maxstep: float = 0.1, ) -> None: r"""Apply the final FIRE2 clamp + position update from mixed velocities. This is the third phase of :func:`fire2_step`, exposed on its own so a caller can interpose between the velocity mix and the displacement clamp. For each atom it recomputes the raw step from the (already mixed) velocity — :math:`\mathbf{v}\,\Delta t` downhill, :math:`-\tfrac{1}{2}\Delta t\,\mathbf{v}` uphill — clamps it by :math:`\min(1, \text{maxstep} / \text{max\_norm}[s])`, applies .. math:: \mathbf{r} \leftarrow \mathbf{r} + \min\!\left(1, \tfrac{\text{maxstep}}{\text{max\_norm}[s]}\right) \Delta\mathbf{r}, scales ``dt`` by the same factor, and zeroes velocities for uphill systems (:math:`vf[s] \leq 0`). Pairing with :func:`fire2_update` reproduces :func:`fire2_step` exactly:: fire2_update(velocities, forces, batch_idx, alpha, dt, nsteps_inc, vf, v_sumsq, f_sumsq, max_norm, ...) # mix, fill max_norm fire2_apply_step(positions, velocities, dt, batch_idx, max_norm, vf, maxstep=maxstep) Splitting the two lets a caller post-process ``max_norm`` (e.g. combine the per-partition maxima with a MAX reduction) before the clamp so every partition scales the step identically. Parameters ---------- positions : wp.array, shape (N,), dtype vec3f/vec3d Atomic positions, modified in-place. velocities : wp.array, shape (N,), dtype vec3f/vec3d Mixed velocities (from ``fire2_update``), modified in-place (zeroed for uphill systems). dt : wp.array, shape (M,), dtype float* Per-system timestep, scaled in-place by the clamp factor. batch_idx : wp.array, shape (N,), dtype int32 Sorted system index per atom. max_norm : wp.array, shape (M,), dtype float* Maximum step norm per system used for the clamp. Supply the post-processed value here to override the one ``fire2_update`` produced. vf : wp.array, shape (M,), dtype float* Per-system :math:`\mathbf{v} \cdot \mathbf{F}`; a system is uphill when :math:`vf[s] \leq 0`. maxstep : float Maximum allowed step size. """ N = positions.shape[0] if N == 0: return if batch_idx is None: raise ValueError("batch_idx is required for fire2_apply_step") vec_dtype = positions.dtype device = positions.device wp.launch( _fire2_clamp_apply_recompute_overloads[vec_dtype], dim=N, inputs=[positions, velocities, dt, batch_idx, max_norm, vf, maxstep], device=device, )
[docs] def fire2_update( velocities: wp.array, forces: wp.array, batch_idx: wp.array, alpha: wp.array, dt: wp.array, nsteps_inc: wp.array, vf: wp.array, v_sumsq: wp.array, f_sumsq: wp.array, max_norm: wp.array, *, delaystep: int = 60, dtgrow: float = 1.05, dtshrink: float = 0.75, alphashrink: float = 0.985, alpha0: float = 0.09, tmax: float = 0.08, tmin: float = 0.005, compute_max_norm: bool = True, compute_reductions: bool = True, ) -> None: r"""Run FIRE2 reduction, parameter update, and velocity mixing only. This low-level helper updates ``velocities``, ``alpha``, ``dt``, and ``nsteps_inc`` in-place, and refreshes ``vf``, ``v_sumsq``, ``f_sumsq``, and, by default, ``max_norm``. It deliberately does not apply positions, clamp the final displacement, or zero velocities on uphill systems. The FIRE2 power is measured on the deferred half-step velocity, and mixing fuses that half-step with the FIRE2 rule: .. math:: P = \sum_i (\mathbf{v}_i + \Delta t\,\mathbf{F}_i) \cdot \mathbf{F}_i,\qquad \mathbf{v} = (1-\alpha)\,\mathbf{v} + \big[(1-\alpha)\,\Delta t + \alpha\sqrt{vv/ff}\big]\,\mathbf{F} where :math:`vv = \sum_i \lVert \mathbf{v}_i + \Delta t\,\mathbf{F}_i \rVert^2` and :math:`ff = \sum_i \mathbf{F}_i \cdot \mathbf{F}_i`. When ``compute_max_norm`` is set, ``max_norm`` records :math:`\max_i \lVert \Delta t\,\mathbf{v}_i \rVert` downhill and :math:`\max_i \lVert -\tfrac{1}{2}\Delta t\,\mathbf{v}_i \rVert` uphill for a later clamp. Parameters ---------- velocities : wp.array, shape (N,), dtype vec3f/vec3d Generalized velocities, modified in-place with FIRE2 velocity mixing. forces : wp.array, shape (N,), dtype vec3f/vec3d Generalized forces. Read-only. batch_idx : wp.array, shape (N,), dtype int32 Sorted system index for each generalized degree of freedom. Required for non-empty inputs. alpha : wp.array, shape (M,), dtype float32/float64 FIRE2 mixing parameter, modified in-place. dt : wp.array, shape (M,), dtype float32/float64 Per-system timestep, modified in-place by FIRE2 growth/shrink rules. nsteps_inc : wp.array, shape (M,), dtype int32 Consecutive positive-power step counter, modified in-place. vf : wp.array, shape (M,), dtype float32/float64 Scratch buffer for per-system :math:`\mathbf{v} \cdot \mathbf{F}` power. Zeroed internally. v_sumsq : wp.array, shape (M,), dtype float32/float64 Scratch buffer for per-system :math:`\mathbf{v} \cdot \mathbf{v}` after the half-step. Zeroed internally. f_sumsq : wp.array, shape (M,), dtype float32/float64 Scratch buffer for per-system :math:`\mathbf{F} \cdot \mathbf{F}`. Zeroed internally. max_norm : wp.array, shape (M,), dtype float32/float64 Scratch buffer for the maximum raw final-step norm after mixing, using :math:`\Delta t\, \mathbf{v}` downhill and :math:`-\frac{1}{2} \Delta t\, \mathbf{v}` uphill. Zeroed internally when ``compute_max_norm`` is true. Custom final apply phases may pass ``compute_max_norm=False`` and recompute this buffer for their own displacement definition. delaystep : int Minimum positive-power steps before timestep growth. dtgrow, dtshrink : float Timestep growth/shrink factors. alphashrink : float Alpha decay factor after enough positive-power steps. alpha0 : float Alpha reset value for uphill systems. tmax, tmin : float Timestep bounds. compute_max_norm : bool, default True Whether to compute the extended-DOF maximum raw final-step norm into ``max_norm``. Set to false when a custom final apply phase will overwrite ``max_norm`` with a different physical displacement norm. compute_reductions : bool, default True If True, recompute ``vf``/``v_sumsq``/``f_sumsq`` internally from the post-half-step velocities and forces. If False, use the caller-supplied values already in those buffers for the mixing and parameter update (they are not zeroed). ``max_norm`` is produced by the mixing kernel and is unaffected by this flag; a caller that needs the displacement clamp reduced across a caller-side partition should leave the final clamp to itself (see ``compute_max_norm`` and the Notes). Notes ----- Callers that use this helper directly must finish the FIRE2 step themselves. For systems with :math:`vf \leq 0`, the final apply phase must use the uphill correction :math:`\Delta \mathbf{r} = -\frac{1}{2} \Delta t\, \mathbf{v}` and zero the corresponding velocities after applying the step. Downhill systems use :math:`\Delta \mathbf{r} = \Delta t\, \mathbf{v}`. Any final ``maxstep`` clamp must be applied by the caller and should scale ``dt`` consistently with the accepted step. """ N = velocities.shape[0] if forces.shape[0] != N: raise ValueError(f"forces length {forces.shape[0]} != velocities length {N}") if batch_idx is not None and batch_idx.shape[0] != N: raise ValueError( f"batch_idx length {batch_idx.shape[0]} != velocities length {N}" ) M = alpha.shape[0] if dt.shape[0] != M: raise ValueError(f"dt length {dt.shape[0]} != alpha length {M}") if nsteps_inc.shape[0] != M: raise ValueError(f"nsteps_inc length {nsteps_inc.shape[0]} != alpha length {M}") vec_dtype = velocities.dtype device = velocities.device if compute_reductions: vf.zero_() v_sumsq.zero_() f_sumsq.zero_() if compute_max_norm or N == 0: max_norm.zero_() if N == 0: return if batch_idx is None: raise ValueError("batch_idx is required for FIRE2 updates") sm = max(device.sm_count, 1) if compute_reductions: ept1 = compute_ept(N, sm, True) dim1 = (N + ept1 - 1) // ept1 wp.launch( _fire2_reduce_only_overloads[vec_dtype], dim=dim1, inputs=[velocities, forces, dt, batch_idx, vf, v_sumsq, f_sumsq, N, ept1], device=device, ) ept2 = compute_ept(N, sm, True) dim2 = (N + ept2 - 1) // ept2 wp.launch( _fire2_fused_mix_maxnorm_overloads[vec_dtype], dim=dim2, inputs=[ velocities, forces, dt, batch_idx, vf, v_sumsq, f_sumsq, alpha, nsteps_inc, max_norm, compute_max_norm, N, ept2, delaystep, dtgrow, dtshrink, alphashrink, alpha0, tmax, tmin, ], device=device, )