Source code for nvalchemiops.jax.lbfgs

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

"""JAX bindings for the batched L-BFGS geometry optimizer.

L-BFGS reaches a given force tolerance in far fewer force evaluations than the
FIRE optimizers -- the cost that dominates relaxation with a machine-learned
potential.

JAX arrays are immutable, so unlike the PyTorch binding these entry points
**return** a new state. Both
:class:`~nvalchemiops.dynamics.optimizers.lbfgs.LBFGSState` and
:class:`~nvalchemiops.dynamics.optimizers.lbfgs.LBFGSCellState` are registered
pytrees, so a state crosses ``jax.jit`` as one argument and one
``donate_argnums`` entry donates every field::

    state = lbfgs_prepare_state(num_atoms, num_systems)

    @functools.partial(jax.jit, donate_argnums=(0, 1))
    def relax_step(positions, state, forces):
        return lbfgs_step_coord(positions, forces, state, batch_idx)

    for _ in range(max_steps):
        forces = model(positions)
        if float(jnp.linalg.norm(forces, axis=1).max()) < force_tol:
            break
        positions, state = relax_step(positions, state, forces)

Test before stepping, as above: the returned ``positions`` are a new,
unevaluated point, and the forces you passed describe the point before it
(kept in ``x_base``/``force_base``). The optimizer owns no tolerance and has
no terminal status, matching FIRE2. Reading a norm back costs a host
synchronization, so test every few steps if that matters.

These operations are **not differentiable**: ``jax.grad`` through a step fails
rather than returning a silently wrong answer.

State
-----
:func:`lbfgs_prepare_state` allocates, initializes and validates the whole
state; in a functional setting resetting and allocating are the same thing, so
calling it again is how you restart. The arrays stay yours -- the state is a
plain dataclass with a ``validate()`` method; see
:mod:`nvalchemiops.dynamics.optimizers.lbfgs` for shapes and required initial
contents.

Every array follows the coordinate dtype, so an fp32 state is fp32 end to end
and **needs no** ``JAX_ENABLE_X64``.

Donation and pointer stability
------------------------------
Every mutable array is an input-output alias, so XLA may reuse each input
buffer for the matching output. Both consequences are about performance:

- **Donate the state.** Without donation JAX copies, doubling peak memory and
  moving the pointers. On the variable-cell path donate ``state`` but not
  ``cell_state``: its five chart fields are read-only and come back unchanged,
  so XLA cannot reuse their buffers.
- **Keep topology out of the donated set.** ``batch_idx`` never changes, so
  close over it.

Under ``JaxCallableGraphMode.WARP`` the step replays as a CUDA graph. The
capture is keyed on input addresses, so a fresh ``forces`` array each step
gives a small working set rather than one graph per call; measurements settle
at four or five. If the count grows without bound, pass
``graph_mode="warp_staged"``, which keys on the call instead at the cost of
one copy per staged array.

Scalars are baked into the compiled call, so changing ``maxstep`` between
steps triggers a recompilation.

See Also
--------
nvalchemiops.dynamics.optimizers.lbfgs : the Warp implementation, which
    documents the algorithm, sign convention and precision policy.
"""

from __future__ import annotations

import dataclasses
import inspect
import math

import jax
import jax.numpy as jnp
import numpy as np
import warp as wp
from jax.experimental import io_callback
from warp import JaxCallableGraphMode, jax_callable

from nvalchemiops.dynamics.optimizers.lbfgs import (
    _CELL_BUFFERS,
    _CELL_SCRATCH,
    _OPTIMIZER_BUFFERS,
    LBFGSCellState,
    LBFGSState,
    _check_against_state,
    _resolve_curvature_eps,
)
from nvalchemiops.dynamics.optimizers.lbfgs import (
    _lbfgs_step_coord_cell_impl as _warp_step_cell,
)
from nvalchemiops.dynamics.optimizers.lbfgs import _lbfgs_step_impl as _warp_step

__all__ = [
    "LBFGSCellState",
    "LBFGSState",
    "lbfgs_prepare_cell_state",
    "lbfgs_prepare_state",
    "lbfgs_step_coord",
    "lbfgs_step_coord_cell",
]

# The states carry nothing but arrays, so every field is a pytree leaf and
# there is no static metadata. Registering them here rather than in the Core
# keeps the Core free of a JAX import.
for _cls in (LBFGSState, LBFGSCellState):
    jax.tree_util.register_dataclass(
        _cls, data_fields=[f.name for f in dataclasses.fields(_cls)], meta_fields=[]
    )

#: The mutable arrays, in the order the callable takes them, which is also the
#: order :func:`lbfgs_step_coord` returns them in.
_LBFGS_IN_OUT_ARGS: tuple[str, ...] = ("positions",) + _OPTIMIZER_BUFFERS

#: JAX coordinate dtype -> the Warp scalar the curvature default is keyed on.
_WP_SCALAR = {jnp.float32: wp.float32, jnp.float64: wp.float64}

_GRAPH_MODES = {
    "none": JaxCallableGraphMode.NONE,
    "warp": JaxCallableGraphMode.WARP,
    "warp_staged": JaxCallableGraphMode.WARP_STAGED,
}


def _lbfgs_body_f32(
    forces: wp.array(dtype=wp.vec3f),
    batch_idx: wp.array(dtype=wp.int32),
    positions: wp.array(dtype=wp.vec3f),
    x_base: wp.array(dtype=wp.vec3f),
    force_base: wp.array(dtype=wp.vec3f),
    direction: wp.array(dtype=wp.vec3f),
    s_history: wp.array(dtype=wp.vec3f, ndim=2),
    y_history: wp.array(dtype=wp.vec3f, ndim=2),
    ys: wp.array(dtype=wp.float32, ndim=2),
    yy: wp.array(dtype=wp.float32, ndim=2),
    alpha_hist: wp.array(dtype=wp.float32, ndim=2),
    beta_hist: wp.array(dtype=wp.float32, ndim=2),
    ss: wp.array(dtype=wp.float32),
    gg: wp.array(dtype=wp.float32),
    d0: wp.array(dtype=wp.float32),
    dmax: wp.array(dtype=wp.float32),
    dquad: wp.array(dtype=wp.float32),
    alpha_step: wp.array(dtype=wp.float32),
    iteration: wp.array(dtype=wp.int32),
    end: wp.array(dtype=wp.int32),
    n_loop: wp.array(dtype=wp.int32),
    history_count: wp.array(dtype=wp.int32),
    maxstep: wp.float32,
    curvature_eps: wp.float32,
) -> None:
    """Advance one L-BFGS step on f32 coordinates.

    Every mutable array is listed before the static scalars and named in
    ``_LBFGS_IN_OUT_ARGS``, so XLA aliases each one to the matching output and
    the step can be written as if it mutated in place.
    """
    _warp_step(
        positions=positions,
        forces=forces,
        batch_idx=batch_idx,
        x_base=x_base,
        force_base=force_base,
        direction=direction,
        s_history=s_history,
        y_history=y_history,
        ys=ys,
        yy=yy,
        alpha_hist=alpha_hist,
        beta_hist=beta_hist,
        ss=ss,
        gg=gg,
        d0=d0,
        dmax=dmax,
        dquad=dquad,
        alpha_step=alpha_step,
        iteration=iteration,
        end=end,
        n_loop=n_loop,
        history_count=history_count,
        maxstep=maxstep,
        curvature_eps=curvature_eps,
    )


def _lbfgs_body_f64(
    forces: wp.array(dtype=wp.vec3d),
    batch_idx: wp.array(dtype=wp.int32),
    positions: wp.array(dtype=wp.vec3d),
    x_base: wp.array(dtype=wp.vec3d),
    force_base: wp.array(dtype=wp.vec3d),
    direction: wp.array(dtype=wp.vec3d),
    s_history: wp.array(dtype=wp.vec3d, ndim=2),
    y_history: wp.array(dtype=wp.vec3d, ndim=2),
    ys: wp.array(dtype=wp.float64, ndim=2),
    yy: wp.array(dtype=wp.float64, ndim=2),
    alpha_hist: wp.array(dtype=wp.float64, ndim=2),
    beta_hist: wp.array(dtype=wp.float64, ndim=2),
    ss: wp.array(dtype=wp.float64),
    gg: wp.array(dtype=wp.float64),
    d0: wp.array(dtype=wp.float64),
    dmax: wp.array(dtype=wp.float64),
    dquad: wp.array(dtype=wp.float64),
    alpha_step: wp.array(dtype=wp.float64),
    iteration: wp.array(dtype=wp.int32),
    end: wp.array(dtype=wp.int32),
    n_loop: wp.array(dtype=wp.int32),
    history_count: wp.array(dtype=wp.int32),
    maxstep: wp.float64,
    curvature_eps: wp.float64,
) -> None:
    """Advance one L-BFGS step on f64 coordinates.

    Every mutable array is listed before the static scalars and named in
    ``_LBFGS_IN_OUT_ARGS``, so XLA aliases each one to the matching output and
    the step can be written as if it mutated in place.
    """
    _warp_step(
        positions=positions,
        forces=forces,
        batch_idx=batch_idx,
        x_base=x_base,
        force_base=force_base,
        direction=direction,
        s_history=s_history,
        y_history=y_history,
        ys=ys,
        yy=yy,
        alpha_hist=alpha_hist,
        beta_hist=beta_hist,
        ss=ss,
        gg=gg,
        d0=d0,
        dmax=dmax,
        dquad=dquad,
        alpha_step=alpha_step,
        iteration=iteration,
        end=end,
        n_loop=n_loop,
        history_count=history_count,
        maxstep=maxstep,
        curvature_eps=curvature_eps,
    )


_BODIES = {jnp.float32: _lbfgs_body_f32, jnp.float64: _lbfgs_body_f64}

# The bodies are written out by hand, so pin their parameter names to the
# canonical buffer order. A reordering would silently swap two arrays, and
# neither warp nor XLA would notice.
# forces, batch_idx, positions
_STATE_SLICE = slice(3, 3 + len(_OPTIMIZER_BUFFERS))
for _name, _body in (("f32", _lbfgs_body_f32), ("f64", _lbfgs_body_f64)):
    _params = tuple(inspect.signature(_body).parameters)[_STATE_SLICE]
    if _params != _OPTIMIZER_BUFFERS:
        raise RuntimeError(
            f"_OPTIMIZER_BUFFERS and _lbfgs_body_{_name} parameters have diverged:\n"
            f"  expected: {_OPTIMIZER_BUFFERS}\n"
            f"  body:     {_params}"
        )

_CALLABLES: dict[tuple, object] = {}


def _get_callable(dtype, graph_mode: str):
    """Return the registered callable for a dtype, building it on first use.

    Registration is deferred so that importing this module does not compile
    anything or touch the GPU.
    """
    key = (jnp.dtype(dtype).type, graph_mode)
    if key not in _CALLABLES:
        body = _BODIES.get(key[0])
        if body is None:
            raise ValueError(
                f"positions must be float32 or float64; got {jnp.dtype(dtype)}"
            )
        if graph_mode not in _GRAPH_MODES:
            raise ValueError(
                f"graph_mode must be one of {sorted(_GRAPH_MODES)}; got {graph_mode!r}"
            )
        kwargs = {}
        if graph_mode == "warp_staged":
            # Stage only the arrays that change identity every step; staging the
            # history buffers would copy megabytes per call for no benefit.
            kwargs["stage_in_argnames"] = ["forces"]
        _CALLABLES[key] = jax_callable(
            body,
            num_outputs=len(_LBFGS_IN_OUT_ARGS),
            in_out_argnames=list(_LBFGS_IN_OUT_ARGS),
            graph_mode=_GRAPH_MODES[graph_mode],
            **kwargs,
        )
    return _CALLABLES[key]


[docs] def lbfgs_prepare_state( num_dofs: int, num_systems: int, *, dtype=jnp.float64, history_size: int = 6, ) -> LBFGSState: """Allocate, initialize and validate a complete optimizer state. Call this once, before the first step. The three fields that do not start at zero are set for you -- ``alpha_step`` to one, ``iteration`` to minus one. Calling it again is how you reset. :class:`LBFGSState` is a registered pytree, so the whole thing can be passed through ``jax.jit``, donated, and carried by ``lax.while_loop``. You are not obliged to use this function: build one from arrays you own and call :meth:`LBFGSState.validate` yourself. Parameters ---------- num_dofs : int Degrees of freedom the optimizer moves. On the variable-cell path this is ``num_atoms + 2 * num_systems``, not the atom count. num_systems : int Independent systems in the batch. dtype : optional Coordinate precision, float32 or float64. Every per-system scalar follows it, so this selects an end-to-end fp32 or fp64 state -- and an fp32 state needs no ``JAX_ENABLE_X64``. history_size : int, optional Stored curvature pairs ``m``; 3 to 7 is the usual range. Returns ------- LBFGSState """ if jnp.dtype(dtype).type not in _BODIES: raise ValueError(f"dtype must be float32 or float64; got {jnp.dtype(dtype)}") if history_size < 1: raise ValueError(f"history_size must be >= 1; got {history_size}") m, n = history_size, num_systems sc = jnp.dtype(dtype) z64 = lambda *s: jnp.zeros(s, sc) # noqa: E731 state = LBFGSState( x_base=jnp.zeros((num_dofs, 3), dtype), force_base=jnp.zeros((num_dofs, 3), dtype), direction=jnp.zeros((num_dofs, 3), dtype), s_history=jnp.zeros((num_dofs, m, 3), dtype), y_history=jnp.zeros((num_dofs, m, 3), dtype), ys=z64(n, m), yy=z64(n, m), alpha_hist=z64(n, m), beta_hist=z64(n, m), ss=z64(n), gg=z64(n), d0=z64(n), dmax=z64(n), dquad=z64(n), # The only three fields whose initial value is not zero. alpha_step=jnp.ones(n, sc), iteration=jnp.full(n, -1, jnp.int32), end=jnp.zeros(n, jnp.int32), n_loop=jnp.zeros(n, jnp.int32), history_count=jnp.zeros(n, jnp.int32), ) # fmt: skip state.validate() return state
[docs] def lbfgs_step_coord( positions: jax.Array, forces: jax.Array, state: LBFGSState, batch_idx: jax.Array, *, maxstep: float = 0.2, curvature_eps: float | None = None, graph_mode: str = "warp", ) -> tuple[jax.Array, LBFGSState]: """Advance one batched L-BFGS step, consuming one force evaluation. Returns new arrays; nothing is mutated in place. Donate ``positions`` and ``state`` so XLA can reuse their memory. Parameters ---------- positions : jax.Array, shape (num_atoms, 3) Current geometry. forces : jax.Array, shape (num_atoms, 3) Forces at ``positions``. Forces, not gradients. state : LBFGSState From :func:`lbfgs_prepare_state`, or built from your own arrays. batch_idx : jax.Array, shape (num_atoms,), dtype int32 Sorted system index per atom. Static topology, so close over it. maxstep : float, optional Largest distance an atom may move in one step. Zero disables the trust region. curvature_eps : float, optional Relative threshold below which a curvature pair is discarded. Defaults to ``1e-6`` for float32 coordinates and ``1e-10`` for float64: ``ys`` is accumulated at the coordinate precision, so one value cannot serve both. graph_mode : {"warp", "warp_staged", "none"}, optional How the step is captured. ``"warp"`` replays a CUDA graph and is the default; ``"warp_staged"`` keys the capture on the call rather than on buffer addresses, which helps if the capture count grows without bound; ``"none"`` disables capture. Returns ------- positions : jax.Array The advanced geometry. state : LBFGSState The advanced state. The input state is unchanged. Raises ------ ValueError If this call's inputs are incompatible with the state. """ _validate(positions, forces, batch_idx, state) call = _get_callable(positions.dtype, graph_mode) out = call( forces, batch_idx, positions, *(getattr(state, name) for name in _OPTIMIZER_BUFFERS), float(maxstep), float(_resolve_curvature_eps(curvature_eps, _WP_SCALAR[jnp.dtype(positions.dtype).type])), ) # fmt: skip return out[0], LBFGSState(**dict(zip(_OPTIMIZER_BUFFERS, out[1:], strict=True)))
def _validate(positions, forces, batch_idx, state) -> None: """Confirm this call's inputs match the state. The state checked its own internal consistency when it was built, so only the arrays that arrive fresh each call are re-checked here. """ state.validate() _check_against_state( state, coordinates=(("positions", positions), ("forces", forces)), indices=(("batch_idx", batch_idx),), ) if jnp.dtype(positions.dtype).type not in _BODIES: raise ValueError(f"positions must be float32 or float64; got {positions.dtype}") if forces.shape != positions.shape: raise ValueError( f"forces shape {forces.shape} != positions shape {positions.shape}" ) if forces.dtype != positions.dtype: raise ValueError( f"forces dtype {forces.dtype} != positions dtype {positions.dtype}" ) if positions.shape[0] != state.num_dofs: raise ValueError( f"positions has {positions.shape[0]} degrees of freedom but the " f"state was prepared for {state.num_dofs}" ) if batch_idx.shape[0] != positions.shape[0]: raise ValueError( f"batch_idx length {batch_idx.shape[0]} != positions length " f"{positions.shape[0]}" ) def _lbfgs_cell_body_f32( forces: wp.array(dtype=wp.vec3f), stress: wp.array(dtype=wp.mat33f), batch_idx: wp.array(dtype=wp.int32), positions: wp.array(dtype=wp.vec3f), cell: wp.array(dtype=wp.mat33f), x_base: wp.array(dtype=wp.vec3f), force_base: wp.array(dtype=wp.vec3f), direction: wp.array(dtype=wp.vec3f), s_history: wp.array(dtype=wp.vec3f, ndim=2), y_history: wp.array(dtype=wp.vec3f, ndim=2), ys: wp.array(dtype=wp.float32, ndim=2), yy: wp.array(dtype=wp.float32, ndim=2), alpha_hist: wp.array(dtype=wp.float32, ndim=2), beta_hist: wp.array(dtype=wp.float32, ndim=2), ss: wp.array(dtype=wp.float32), gg: wp.array(dtype=wp.float32), d0: wp.array(dtype=wp.float32), dmax: wp.array(dtype=wp.float32), dquad: wp.array(dtype=wp.float32), alpha_step: wp.array(dtype=wp.float32), iteration: wp.array(dtype=wp.int32), end: wp.array(dtype=wp.int32), n_loop: wp.array(dtype=wp.int32), history_count: wp.array(dtype=wp.int32), ref_cell: wp.array(dtype=wp.mat33f), ref_cell_inv: wp.array(dtype=wp.mat33f), kappa: wp.array(dtype=wp.float32), ext_batch_idx: wp.array(dtype=wp.int32), ext_atom_ptr: wp.array(dtype=wp.int32), phi: wp.array(dtype=wp.mat33f), phi_inv: wp.array(dtype=wp.mat33f), d_phi: wp.array(dtype=wp.mat33f), cell_dof_a: wp.array(dtype=wp.vec3f), cell_dof_b: wp.array(dtype=wp.vec3f), cell_force_a: wp.array(dtype=wp.vec3f), cell_force_b: wp.array(dtype=wp.vec3f), ext_positions: wp.array(dtype=wp.vec3f), ext_forces: wp.array(dtype=wp.vec3f), maxstep: wp.float32, curvature_eps: wp.float32, ) -> None: """Advance one variable-cell L-BFGS step on f32 coordinates. The first five cell arrays are read-only configuration and topology, so they are plain inputs; everything else is aliased through ``_CELL_IN_OUT_ARGS``. """ _warp_step_cell( positions=positions, forces=forces, cell=cell, stress=stress, batch_idx=batch_idx, x_base=x_base, force_base=force_base, direction=direction, s_history=s_history, y_history=y_history, ys=ys, yy=yy, alpha_hist=alpha_hist, beta_hist=beta_hist, ss=ss, gg=gg, d0=d0, dmax=dmax, dquad=dquad, alpha_step=alpha_step, iteration=iteration, end=end, n_loop=n_loop, history_count=history_count, ref_cell=ref_cell, ref_cell_inv=ref_cell_inv, kappa=kappa, ext_batch_idx=ext_batch_idx, ext_atom_ptr=ext_atom_ptr, phi=phi, phi_inv=phi_inv, d_phi=d_phi, cell_dof_a=cell_dof_a, cell_dof_b=cell_dof_b, cell_force_a=cell_force_a, cell_force_b=cell_force_b, ext_positions=ext_positions, ext_forces=ext_forces, maxstep=maxstep, curvature_eps=curvature_eps, ) def _lbfgs_cell_body_f64( forces: wp.array(dtype=wp.vec3d), stress: wp.array(dtype=wp.mat33d), batch_idx: wp.array(dtype=wp.int32), positions: wp.array(dtype=wp.vec3d), cell: wp.array(dtype=wp.mat33d), x_base: wp.array(dtype=wp.vec3d), force_base: wp.array(dtype=wp.vec3d), direction: wp.array(dtype=wp.vec3d), s_history: wp.array(dtype=wp.vec3d, ndim=2), y_history: wp.array(dtype=wp.vec3d, ndim=2), ys: wp.array(dtype=wp.float64, ndim=2), yy: wp.array(dtype=wp.float64, ndim=2), alpha_hist: wp.array(dtype=wp.float64, ndim=2), beta_hist: wp.array(dtype=wp.float64, ndim=2), ss: wp.array(dtype=wp.float64), gg: wp.array(dtype=wp.float64), d0: wp.array(dtype=wp.float64), dmax: wp.array(dtype=wp.float64), dquad: wp.array(dtype=wp.float64), alpha_step: wp.array(dtype=wp.float64), iteration: wp.array(dtype=wp.int32), end: wp.array(dtype=wp.int32), n_loop: wp.array(dtype=wp.int32), history_count: wp.array(dtype=wp.int32), ref_cell: wp.array(dtype=wp.mat33d), ref_cell_inv: wp.array(dtype=wp.mat33d), kappa: wp.array(dtype=wp.float64), ext_batch_idx: wp.array(dtype=wp.int32), ext_atom_ptr: wp.array(dtype=wp.int32), phi: wp.array(dtype=wp.mat33d), phi_inv: wp.array(dtype=wp.mat33d), d_phi: wp.array(dtype=wp.mat33d), cell_dof_a: wp.array(dtype=wp.vec3d), cell_dof_b: wp.array(dtype=wp.vec3d), cell_force_a: wp.array(dtype=wp.vec3d), cell_force_b: wp.array(dtype=wp.vec3d), ext_positions: wp.array(dtype=wp.vec3d), ext_forces: wp.array(dtype=wp.vec3d), maxstep: wp.float64, curvature_eps: wp.float64, ) -> None: """Advance one variable-cell L-BFGS step on f64 coordinates. The first five cell arrays are read-only configuration and topology, so they are plain inputs; everything else is aliased through ``_CELL_IN_OUT_ARGS``. """ _warp_step_cell( positions=positions, forces=forces, cell=cell, stress=stress, batch_idx=batch_idx, x_base=x_base, force_base=force_base, direction=direction, s_history=s_history, y_history=y_history, ys=ys, yy=yy, alpha_hist=alpha_hist, beta_hist=beta_hist, ss=ss, gg=gg, d0=d0, dmax=dmax, dquad=dquad, alpha_step=alpha_step, iteration=iteration, end=end, n_loop=n_loop, history_count=history_count, ref_cell=ref_cell, ref_cell_inv=ref_cell_inv, kappa=kappa, ext_batch_idx=ext_batch_idx, ext_atom_ptr=ext_atom_ptr, phi=phi, phi_inv=phi_inv, d_phi=d_phi, cell_dof_a=cell_dof_a, cell_dof_b=cell_dof_b, cell_force_a=cell_force_a, cell_force_b=cell_force_b, ext_positions=ext_positions, ext_forces=ext_forces, maxstep=maxstep, curvature_eps=curvature_eps, ) #: Everything the variable-cell step writes, in return order. ``ref_cell``, #: ``ref_cell_inv``, ``kappa``, ``ext_batch_idx`` and ``ext_atom_ptr`` are #: read-only configuration and topology, so they are not aliased and not #: returned. _CELL_IN_OUT_ARGS: tuple[str, ...] = ( ("positions", "cell") + _OPTIMIZER_BUFFERS + _CELL_SCRATCH ) _CELL_BODIES = {jnp.float32: _lbfgs_cell_body_f32, jnp.float64: _lbfgs_cell_body_f64} for _name, _body in (("f32", _lbfgs_cell_body_f32), ("f64", _lbfgs_cell_body_f64)): _p = tuple(inspect.signature(_body).parameters) _n = len(_OPTIMIZER_BUFFERS) if _p[5 : 5 + _n] != _OPTIMIZER_BUFFERS: raise RuntimeError( f"_OPTIMIZER_BUFFERS and _lbfgs_cell_body_{_name} have diverged" ) if _p[5 + _n : 5 + _n + len(_CELL_BUFFERS)] != _CELL_BUFFERS: raise RuntimeError(f"_CELL_BUFFERS and _lbfgs_cell_body_{_name} have diverged") _CELL_CALLABLES: dict[tuple, object] = {} def _get_cell_callable(dtype, graph_mode: str): """Return the registered variable-cell callable, building it on first use.""" key = (jnp.dtype(dtype).type, graph_mode) if key not in _CELL_CALLABLES: body = _CELL_BODIES.get(key[0]) if body is None: raise ValueError( f"positions must be float32 or float64; got {jnp.dtype(dtype)}" ) if graph_mode not in _GRAPH_MODES: raise ValueError( f"graph_mode must be one of {sorted(_GRAPH_MODES)}; got {graph_mode!r}" ) kwargs = {} if graph_mode == "warp_staged": kwargs["stage_in_argnames"] = ["forces", "stress"] _CELL_CALLABLES[key] = jax_callable( body, num_outputs=len(_CELL_IN_OUT_ARGS), in_out_argnames=list(_CELL_IN_OUT_ARGS), graph_mode=_GRAPH_MODES[graph_mode], **kwargs, ) return _CELL_CALLABLES[key] _CELL_ALIGNMENT_ERROR = ( "cell must be aligned: entries strictly above the diagonal must be zero. " "Call nvalchemiops.dynamics.utils.cell_filter.align_cell once before " "reference-cell setup." ) def _lbfgs_set_reference_cell( cell: jax.Array, *, alignment_atol: float | None = None ) -> tuple[jax.Array, jax.Array]: """Capture the reference cell that defines the variable-cell chart. Coordinates are measured relative to a cell held fixed for the whole relaxation, which is what makes history pairs from different iterations comparable. Call this once before the first step. Calling it again re-references the chart and invalidates every stored curvature pair, so rebuild the optimizer buffers in their initial state if you do. The reference is returned as an independent copy. Sharing a buffer with the caller's live ``cell`` would make it impossible to donate both to the same jitted step, because XLA rejects the same buffer being donated twice. Parameters ---------- cell : jax.Array, shape (num_systems, 3, 3) Current cell, lattice vectors as columns. Alignment is checked before capturing the reference cell. alignment_atol : float, optional Absolute tolerance for entries strictly above the cell diagonal. Defaults to ``1e-4`` for float32 cells and ``1e-8`` for float64 cells. Returns ------- ref_cell, ref_cell_inv : jax.Array, shape (num_systems, 3, 3) Pass these to every :func:`lbfgs_step_coord_cell` call. They are read-only, so do not donate them. Notes ----- Eager calls raise ``ValueError`` for an unaligned cell. Under ``jax.jit``, a scalar validity check runs through a host callback; XLA reports callback failures as a runtime error. The CPU backend must be enabled to report the alignment error from the host callback, including when cell arrays are on a GPU. When jitting this helper with an explicit ``alignment_atol``, mark that keyword static. ``lbfgs_prepare_cell_state`` itself remains an eager setup helper. """ if cell.ndim != 3 or cell.shape[-2:] != (3, 3): raise ValueError(f"cell must have shape (num_systems, 3, 3); got {cell.shape}") cell_dtype = jnp.dtype(cell.dtype) if cell_dtype.type not in _CELL_BODIES: raise ValueError(f"cell must be float32 or float64; got {cell.dtype}") if alignment_atol is None: alignment_atol = 1e-4 if cell_dtype == jnp.dtype(jnp.float32) else 1e-8 else: try: alignment_atol = float(alignment_atol) except (TypeError, ValueError, OverflowError) as exc: raise ValueError( f"alignment_atol must be finite and non-negative; got {alignment_atol!r}" ) from exc if not math.isfinite(alignment_atol) or alignment_atol < 0.0: raise ValueError( f"alignment_atol must be finite and non-negative; got {alignment_atol}" ) max_upper = jnp.max(jnp.abs(jnp.triu(cell, k=1)), axis=(-2, -1)) aligned = jnp.all(max_upper <= alignment_atol) if isinstance(aligned, jax.core.Tracer): def report_unaligned(value): def fail_if_unaligned(valid): if not bool(valid): raise ValueError(_CELL_ALIGNMENT_ERROR) io_callback(fail_if_unaligned, None, value, ordered=True) return jnp.asarray(0, dtype=jnp.int32) jax.lax.cond( aligned, lambda _: jnp.asarray(0, dtype=jnp.int32), report_unaligned, aligned, ) elif not bool(aligned): raise ValueError(_CELL_ALIGNMENT_ERROR) return jnp.array(cell, copy=True), jnp.linalg.inv(cell) def _lbfgs_cell_kappa( n_particles: jax.Array, *, dtype, cell_force_scale: float = 1.0, ) -> jax.Array: """Return the per-system cell coordinate scaling. The cell coordinate is ``kappa * Phi`` and its conjugate force is divided by the same ``kappa``, which is what keeps ``g . dx`` independent of the scaling. Depends only on topology, so compute it once and reuse it. Parameters ---------- n_particles : jax.Array, shape (num_systems,), dtype int32 Atom count per system. cell_force_scale : float, optional Multiplier on the atom count. Larger values make the cell move less per step relative to the atoms; ``1 / atoms_per_system`` puts the two on a comparable footing. dtype : jnp.float32 or jnp.float64 Result precision. **Required**, and must match the *coordinates*: ``kappa`` scales matrices, so unlike the other per-system scalars it is not float64. There is no coordinate array here to infer it from, and guessing float64 would silently hand an fp32 caller a buffer the cell step rejects. The Warp and PyTorch helpers take no ``dtype`` because they write into an array the caller already allocated. Returns ------- jax.Array, shape (num_systems,) Notes ----- ``kappa`` is divided into the cell force, so it must never be zero. A batch containing a system with no atoms is rejected rather than given an invented scale, matching the Warp and PyTorch layers; see the variable-cell contract in :mod:`nvalchemiops.dynamics.optimizers.lbfgs`. """ if cell_force_scale <= 0.0: raise ValueError(f"cell_force_scale must be positive; got {cell_force_scale}") dtype = jnp.dtype(dtype).type if dtype not in _CELL_BODIES: raise ValueError(f"dtype must be float32 or float64; got {dtype}") counts = jnp.asarray(n_particles) # A setup-time check, so concretizing is fine. Under ``jit`` the counts are # a tracer with no value to inspect; skip rather than fail, as the device # checks in ``validate`` do. if not isinstance(counts, jax.core.Tracer): empty = np.flatnonzero(np.asarray(counts) <= 0) if empty.size: raise ValueError( f"system(s) {empty.tolist()} have no atoms, which the " "variable-cell path does not support: kappa scales the cell " "against the atoms, so there is no scale to give them. Drop " "the empty systems from the batch. (Empty input on the " "coordinate-only path is fine and is a no-op.)" ) return counts.astype(dtype) * dtype(cell_force_scale)
[docs] def lbfgs_prepare_cell_state( atom_ptr: jax.Array, cell: jax.Array, *, cell_force_scale: float = 1.0, dtype=jnp.float64, alignment_atol: float | None = None, ) -> LBFGSCellState: """Build a complete, ready-to-step variable-cell chart from atom topology. One call: give it the ordinary ``atom_ptr`` and aligned cells, and it derives the packed topology, captures the reference chart and computes ``kappa``. Alignment is checked before the reference cell is captured. Ragged batches are unaffected -- ``atom_ptr`` already carries each system's atom count, so nothing here assumes an even split. Parameters ---------- atom_ptr : jax.Array, shape (num_systems + 1,), dtype int32 CSR-style atom pointer for the batch. cell : jax.Array, shape (num_systems, 3, 3) Reference lattice per system, aligned. cell_force_scale : float, optional Multiplier on the atom count in ``kappa``. dtype : optional Coordinate precision; must match the coordinates and the state. alignment_atol : float, optional Absolute tolerance for entries strictly above the cell diagonal. Defaults to ``1e-4`` for float32 cells and ``1e-8`` for float64 cells. Returns ------- LBFGSCellState See Also -------- nvalchemiops.dynamics.optimizers.lbfgs : the variable-cell contract, including the requirement that the cells be aligned first. """ if jnp.dtype(dtype).type not in _CELL_BODIES: raise ValueError(f"dtype must be float32 or float64; got {jnp.dtype(dtype)}") ptr = [int(v) for v in np.asarray(atom_ptr)] if len(ptr) < 2: raise ValueError( f"atom_ptr must have num_systems + 1 >= 2 entries; got {len(ptr)}" ) n = len(ptr) - 1 num_atoms = ptr[-1] counts = [ptr[s + 1] - ptr[s] for s in range(n)] if any(c < 0 for c in counts): raise ValueError(f"atom_ptr must be non-decreasing; got {ptr}") packed = num_atoms + 2 * n # Derived, not carried: a function of the topology alone. Built with jnp # rather than through Warp, as the rest of this binding's helpers are. ext_atom_ptr = jnp.asarray([ptr[s] + 2 * s for s in range(n + 1)], jnp.int32) ext_batch_idx = jnp.repeat( jnp.arange(n, dtype=jnp.int32), jnp.asarray(counts, jnp.int32) + 2, total_repeat_length=packed, ) mat = lambda: jnp.zeros((n, 3, 3), dtype) # noqa: E731 ref_cell, ref_cell_inv = _lbfgs_set_reference_cell( cell, alignment_atol=alignment_atol ) state = LBFGSCellState( ref_cell=ref_cell, ref_cell_inv=ref_cell_inv, kappa=_lbfgs_cell_kappa( jnp.asarray(counts, jnp.int32), dtype=dtype, cell_force_scale=cell_force_scale, ), ext_batch_idx=ext_batch_idx, ext_atom_ptr=ext_atom_ptr, phi=mat(), phi_inv=mat(), d_phi=mat(), cell_dof_a=jnp.zeros((n, 3), dtype), cell_dof_b=jnp.zeros((n, 3), dtype), cell_force_a=jnp.zeros((n, 3), dtype), cell_force_b=jnp.zeros((n, 3), dtype), ext_positions=jnp.zeros((packed, 3), dtype), ext_forces=jnp.zeros((packed, 3), dtype), ) # fmt: skip state.validate(num_atoms=num_atoms) return state
[docs] def lbfgs_step_coord_cell( positions: jax.Array, cell: jax.Array, forces: jax.Array, stress: jax.Array, state: LBFGSState, cell_state: LBFGSCellState, batch_idx: jax.Array, *, maxstep: float = 0.2, curvature_eps: float | None = None, graph_mode: str = "warp", ) -> tuple[jax.Array, jax.Array, LBFGSState, LBFGSCellState]: """Advance one variable-cell step, relaxing coordinates and cell together. Returns new arrays; nothing is mutated. Donate everything that comes back so XLA can reuse the memory. ``state`` must be sized for ``num_atoms + 2 * num_systems`` degrees of freedom, since the cell contributes two entries per system. Fill ``cell_state`` from :func:`_lbfgs_set_reference_cell` and :func:`_lbfgs_cell_kappa` once before the first step. Parameters ---------- cell : jax.Array, shape (num_systems, 3, 3) Lattice vectors as columns, aligned by ``align_cell`` before the first step. See :ref:`the variable-cell contract <lbfgs-cell-contract>` in :mod:`nvalchemiops.dynamics.optimizers.lbfgs`, which is the single authoritative statement of the cell rules. stress : jax.Array, shape (num_systems, 3, 3) Cauchy stress, which drives the cell degrees of freedom. state, cell_state : LBFGSState, LBFGSCellState From the preparation functions, or built from your own arrays. Returns ------- positions, cell : jax.Array The advanced geometry and lattice. state, cell_state : LBFGSState, LBFGSCellState The advanced states. The chart fields of ``cell_state`` come back unchanged; only its scratch fields are rewritten. See Also -------- _lbfgs_set_reference_cell : must be called first. _lbfgs_cell_kappa : must be called first. lbfgs_step_coord : the coordinate-only equivalent. """ _validate_cell(positions, forces, cell, stress, batch_idx, state, cell_state) call = _get_cell_callable(positions.dtype, graph_mode) out = call( forces, stress, batch_idx, positions, cell, *(getattr(state, name) for name in _OPTIMIZER_BUFFERS), *(getattr(cell_state, name) for name in _CELL_BUFFERS), float(maxstep), float(_resolve_curvature_eps(curvature_eps, _WP_SCALAR[jnp.dtype(positions.dtype).type])), ) # fmt: skip n_opt = len(_OPTIMIZER_BUFFERS) new_state = LBFGSState( **dict(zip(_OPTIMIZER_BUFFERS, out[2 : 2 + n_opt], strict=True)) ) # Only the scratch fields come back; the chart and topology are read-only. new_cell = dataclasses.replace( cell_state, **dict(zip(_CELL_SCRATCH, out[2 + n_opt :], strict=True)) ) return out[0], out[1], new_state, new_cell
def _validate_cell( positions, forces, cell, stress, batch_idx, state, cell_state ) -> None: """Confirm this call's inputs match both states. The degree-of-freedom check differs from the coordinate path, so this does not reuse :func:`_validate`: the state is sized for the packed array, not for the atoms. """ state.validate() cell_state.validate(num_atoms=positions.shape[0]) _check_against_state( state, coordinates=( ("positions", positions), ("forces", forces), ("cell", cell), ("stress", stress), ), # fmt: skip indices=(("batch_idx", batch_idx),), extra_states=(("cell_state", cell_state),), ) num_systems = state.num_systems if jnp.dtype(positions.dtype).type not in _CELL_BODIES: raise ValueError(f"positions must be float32 or float64; got {positions.dtype}") if forces.shape != positions.shape: raise ValueError( f"forces shape {forces.shape} != positions shape {positions.shape}" ) if forces.dtype != positions.dtype: raise ValueError( f"forces dtype {forces.dtype} != positions dtype {positions.dtype}" ) if batch_idx.shape[0] != positions.shape[0]: raise ValueError( f"batch_idx length {batch_idx.shape[0]} != positions length " f"{positions.shape[0]}" ) if cell.shape != (num_systems, 3, 3): raise ValueError( f"cell must have shape ({num_systems}, 3, 3); got {cell.shape}" ) if stress.shape != cell.shape: raise ValueError(f"stress shape {stress.shape} != cell shape {cell.shape}") if cell.dtype != positions.dtype or stress.dtype != positions.dtype: raise ValueError("cell and stress must share the dtype of positions") expected = positions.shape[0] + 2 * num_systems if state.num_dofs != expected: raise ValueError( f"state is sized for {state.num_dofs} degrees of freedom, but the " f"variable-cell path needs {expected} (num_atoms + 2 * num_systems)" )