# 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"""L-BFGS Optimizer Kernels
========================
GPU-accelerated Warp kernels for batched L-BFGS geometry optimization.
L-BFGS approximates the inverse Hessian from the last ``history_size``
position/force
differences to pick a direction, then steps along it as far as a ``maxstep``
trust region allows. It reaches a given force tolerance in far fewer force
evaluations than FIRE -- the cost that dominates relaxation with a
machine-learned potential.
Calling convention
------------------
You own the loop and the stopping rule. Each :func:`lbfgs_step` consumes
**exactly one** force evaluation, and every call is an accepted step forming
one curvature pair::
for _ in range(max_steps):
forces = my_model(positions)
if np.linalg.norm(forces.numpy(), axis=1).max() < force_tol:
break
lbfgs_step(positions, forces, state, batch_idx, maxstep=0.2)
Test *before* stepping, as above. After a step ``positions`` hold a new,
unevaluated point and the ``forces`` you passed describe the point before it;
the optimizer keeps that pair in ``x_base``/``force_base``. Positions only
move forward, so a point you have left is never revisited.
The optimizer owns no tolerance and has no terminal state, mirroring FIRE2.
Two things *are* algorithmic and stay inside: a pair with ``s . y`` too small
is discarded, and a direction with ``d0 >= 0`` is replaced by steepest
descent. Neither stops anything.
Nothing here reads an energy, so there is no line search. ``maxstep`` shrinks
``alpha`` rather than clamping each displacement, because the stored pair
``s = alpha * d`` is defined through that relation. Nothing forces the energy
down -- it may rise on a step -- but the force still converges.
Systems stay in lock step in *evaluations* while diverging in *iterations*, so
a batch relaxes in one stream of launches with no per-system host control
flow. A batch converges when its slowest system does; compact the batch on the
host to retire finished systems earlier.
The API is in **forces**, the algorithm in gradients, with ``g = -F`` folded
into each kernel: ``y_history`` holds ``force_base - F``, and a descent
direction satisfies ``force_base . d > 0``.
Relation to FIRE2
-----------------
The decomposition mirrors FIRE2's, so a caller can interpose logic at the same
points. The extra phase exists because the step length must be settled before
the atoms move.
======================== ========================== ========================
Phase FIRE2 L-BFGS
======================== ========================== ========================
per-system reductions ``fire2_reduce`` :func:`_lbfgs_reduce`
state update, no motion ``fire2_update`` :func:`_lbfgs_update`
step-length finalization -- :func:`_lbfgs_prepare_step`
move the atoms ``fire2_apply_step`` :func:`_lbfgs_apply_step`
all of the above ``fire2_step`` :func:`lbfgs_step`
======================== ========================== ========================
That is internal structure. The public surface is the state, the preparation
helpers and one step per call; the phases are importable by name but are
decomposition points, not operations in their own right.
Precision
---------
**Every array follows the coordinate dtype.** ``wp.vec3f`` gives an fp32 state
end to end, ``wp.vec3d`` an fp64 one. There is no mixed configuration -- the
overloads are keyed so a mismatched state is rejected rather than silently
half-converted. Widening a reduction on its own buys nothing measurable here,
since ``y = force_base - F`` is already formed at the coordinate precision.
``curvature_eps`` follows from this. It gates the history on
``ys > eps * sqrt(ss * yy)`` -- a floor on the cosine between ``s`` and ``y``
-- and ``ys`` accumulates at the coordinate precision, so the floor must sit
above the level at which that sum is still signal. Defaults: ``1e-6`` for
float32, ``1e-10`` for float64 (:data:`_CURVATURE_EPS`). Pass a value to
override.
State
-----
:func:`lbfgs_prepare_state` allocates, initializes and validates everything in
one call; calling it again is how you reset. Nothing is allocated per step, so
the step stays capturable in a CUDA graph.
:class:`LBFGSState` is a plain dataclass, so you can also build one from
buffers you already own and check it with :meth:`LBFGSState.validate`, which
compares shapes, dtypes and device without touching the GPU.
**Every array is indexed by its owning entity first**: a per-degree-of-freedom
buffer leads with ``num_packed``, a per-system one with ``num_systems``, and the
history depth is always the *trailing* axis. That is what lets a caller select a
subset of systems, or concatenate two states, with an ordinary gather along
dimension zero -- the operation batched relaxation drivers perform when they
retire converged systems and admit replacements.
Writing ``num_packed`` for the degrees of freedom, ``num_systems`` for the
independent systems and ``history_size`` for the stored pairs:
============================================= =========================== ===========
Buffer Shape dtype
============================================= =========================== ===========
``x_base``, ``force_base``, ``direction`` ``(num_packed,)`` vec3f/vec3d
``s_history``, ``y_history`` ``(num_packed, history)`` vec3f/vec3d
``ys``, ``yy``, ``alpha_hist``, ``beta_hist`` ``(num_systems, history)`` float32/float64
``ss``, ``gg`` ``(num_systems,)`` float32/float64
``d0``, ``dmax``, ``dquad``, ``alpha_step`` ``(num_systems,)`` float32/float64
``iteration``, ``end`` ``(num_systems,)`` int32
``n_loop``, ``history_count`` ``(num_systems,)`` int32
============================================= =========================== ===========
``history`` abbreviates ``history_size`` in the table above only.
Building one by hand means matching the initial contents: **zero everything**,
then ``alpha_step`` to **one** and ``iteration`` to **minus one** (the "never
evaluated" marker).
Variable-cell relaxation adds an :class:`LBFGSCellState` from
:func:`lbfgs_prepare_cell_state`, for the packed path with
``num_packed = num_atoms + 2 * num_systems``:
===================================================== ======================== =============
Buffer Shape dtype
===================================================== ======================== =============
``ref_cell``, ``ref_cell_inv``, ``phi``, ``phi_inv`` ``(num_systems,)`` mat33f/mat33d
``d_phi`` ``(num_systems,)`` mat33f/mat33d
``kappa`` ``(num_systems,)`` float32/float64
``cell_dof_a/b``, ``cell_force_a/b`` ``(num_systems,)`` vec3f/vec3d
``ext_positions``, ``ext_forces`` ``(num_packed,)`` vec3f/vec3d
``ext_batch_idx`` ``(num_packed,)`` int32
``ext_atom_ptr`` ``(num_systems + 1,)`` int32
===================================================== ======================== =============
``kappa`` follows the *coordinate* precision because it scales matrices.
``ref_cell``, ``ref_cell_inv`` and ``kappa`` are the chart; the rest is
scratch.
.. _lbfgs-cell-contract:
The variable-cell contract
--------------------------
**The one authoritative statement of these rules**; the bindings and the user
guide defer here.
*1. Align the cell once, as FIRE2 requires.*
Prepare geometry with
:func:`~nvalchemiops.dynamics.utils.cell_filter.align_cell`, which aligns
the cell and rotates positions consistently. The aligned matrix has zeros
strictly above the diagonal. Torch and JAX reference-cell setup validates
aligned input. Raw Warp setup and subsequent optimization steps assume
alignment; the six-component cell updates preserve it.
*2. Six components, the same six FIRE2 packs.*
``(0,0), (1,0), (2,0) | (1,1), (2,1), (2,2)`` -- two ``vec3`` entries per
system, in that order, matching
:func:`~nvalchemiops.dynamics.utils.cell_filter.pack_positions_with_cell`.
The strictly-upper entries are not represented, so the cell cannot drift
into a rotation. This is why rule 1 is required rather than advisory: in an
unaligned frame those three entries are not the redundant ones.
*3. The reference cell is fixed for the whole relaxation.*
FIRE2 packs the cell itself; L-BFGS packs the deformation gradient
``Phi = H H_ref^-1`` against a fixed reference, scaled by ``kappa`` (ASE's
``UnitCellFilter`` chart). Stored pairs compare cell coordinates *across*
steps, so those coordinates must mean the same thing at every step.
**Re-referencing invalidates every stored pair**: if you rebuild the chart
mid-run, reset the state with :func:`lbfgs_prepare_state` in the same
breath.
*4. The packed topology is derived, not carried.*
``ext_batch_idx`` and ``ext_atom_ptr`` are a function of ``atom_ptr``
alone, so :func:`lbfgs_prepare_cell_state` builds them from the ordinary
atom pointer you already have. They are fields of the state because the
kernels index them, not because they are yours to supply -- and when batch
membership changes they should be **rebuilt** from the new topology rather
than gathered and concatenated along with the rest of the state, since
their values are global offsets that a gather would leave stale.
Ragged batches are unaffected by deriving them: ``atom_ptr`` already
carries each system's atom count, so nothing assumes an even split. Each
system contributes exactly two packed entries whatever its size, so
``num_packed = num_atoms + 2 * num_systems`` holds for any split, and
:meth:`LBFGSCellState.validate` checks it.
*5. Empty systems are rejected here, and supported on the coordinate path.*
``kappa`` scales the cell coordinate against the atomic ones and is divided
into the cell force, so a system with no atoms has no scale to give it.
:func:`lbfgs_prepare_cell_state` rejects the batch, naming the offending
systems, in all three layers. The
coordinate-only path is a different case and stays supported: with no cell
to scale against, zero degrees of freedom is a no-op.
Memory
------
With ``P`` degrees of freedom, ``M`` systems and ``m`` stored pairs -- letters
used only to keep this formula readable::
bytes = (2m + 3) * 3 * sizeof(dof) * P + (4m + 6) * sizeof(dof) * M
+ 4 * 4 * M
At ``history_size = 6`` that is 180 bytes per degree of freedom with float32
coordinates, 360 with float64. The two histories dominate; 3 to 7 is the usual
range.
References
----------
Nocedal, J. "Updating Quasi-Newton Matrices with Limited Storage."
*Math. Comp.* 35 (1980) 773-782.
Liu, D. C. and Nocedal, J. "On the limited memory BFGS method for large scale
optimization." *Math. Program.* 45 (1989) 503-528.
Nocedal, J. and Wright, S. J. *Numerical Optimization*, 2nd ed., chapters 3
and 7."""
from __future__ import annotations
import dataclasses
from typing import Any
import warp as wp
from nvalchemiops.batch_utils import atom_ptr_to_batch_idx
from nvalchemiops.dynamics.utils.cell_filter import extend_atom_ptr
from nvalchemiops.dynamics.utils.cell_utils import compute_cell_inverse
from nvalchemiops.segment_ops import compute_ept
#: The public contract, and nothing else: the two states, the two preparation
#: helpers, and one step per call for each path. Everything a step is built
#: from -- the reductions, the history update, the two-loop recursion, the
#: trust region, the packing, the chart setup and the validators -- is an
#: internal decomposition point rather than an operation in its own right, and
#: is named with a leading underscore accordingly.
__all__ = [
"LBFGSCellState",
"LBFGSState",
"lbfgs_prepare_cell_state",
"lbfgs_prepare_state",
"lbfgs_step",
"lbfgs_step_coord_cell",
]
# =============================================================================
# State container
# =============================================================================
# =============================================================================
# Buffer ordering
#
# Every buffer is owned, allocated and retained by the caller, as with the FIRE
# optimizers. These tuples are the single source of truth for the order the
# public entry points take them in, and exist only so the bindings can validate
# their own signatures against it. They deliberately do not hold arrays.
# =============================================================================
[docs]
@dataclasses.dataclass
class LBFGSState:
"""The optimizer's persistent and scratch arrays, one field per array.
Deliberately transparent: every array a step touches is a named field you
can read, plot or checkpoint. Build one with
:func:`lbfgs_prepare_state`, which allocates and initializes it, or
construct it directly from arrays you already own -- the package never
requires you to use its allocation policy.
Field order is the wire ABI. It is what the PyTorch operator's
``mutates_args`` and the JAX callable's ``in_out_argnames`` are generated
from, so reordering these fields reorders those.
Attributes
----------
x_base, force_base : wp.array, shape (num_packed,), dtype vec3f/vec3d
Last accepted point and the FORCES there. The gradient is
``-force_base``; see the module docstring on the sign convention.
direction : wp.array, shape (num_packed,), dtype vec3f/vec3d
Search direction; a descent direction satisfies ``force_base . d > 0``.
s_history, y_history : wp.array, shape (num_packed, history_size), dtype vec3f/vec3d
Ring buffers of position and *gradient* differences. The degree of
freedom leads; the history slot is the trailing axis.
ys, yy : wp.array, shape (num_systems, history_size), dtype float32/float64
alpha_hist : wp.array, shape (num_systems, history_size), dtype float32/float64
beta_hist : wp.array, shape (num_systems, history_size), dtype float32/float64
Per-slot curvature products and two-loop coefficients.
ss, gg : wp.array, shape (num_systems,), dtype float32/float64
Squared norms of the newest ``s`` and of the packed force.
d0, dmax, dquad : wp.array, shape (num_systems,), dtype float32/float64
Directional derivative and the trust region's linear and quadratic
displacement coefficients.
alpha_step : wp.array, shape (num_systems,), dtype float32/float64
Step length, recomputed from the trust region every call.
iteration, end, n_loop, history_count : wp.array, shape (num_systems,), dtype int32
Per-system control state. There is no ``status``: the optimizer has no
terminal state, and deciding when to stop is the caller's, as it is
for FIRE2.
"""
x_base: Any
force_base: Any
direction: Any
s_history: Any
y_history: Any
ys: Any
yy: Any
alpha_hist: Any
beta_hist: Any
ss: Any
gg: Any
d0: Any
dmax: Any
dquad: Any
alpha_step: Any
iteration: Any
end: Any
n_loop: Any
history_count: Any
@property
def num_dofs(self) -> int:
"""Degrees of freedom the optimizer moves."""
return self.x_base.shape[0]
@property
def num_systems(self) -> int:
"""Independent systems in the batch."""
return self.iteration.shape[0]
@property
def history_size(self) -> int:
"""Stored curvature pairs."""
return self.s_history.shape[1]
[docs]
def validate(self) -> None:
"""Check the fields against each other: shapes, dtypes and device.
Structural only -- no device reads -- so this is sync-free and safe to
call anywhere. Run for you by :func:`lbfgs_prepare_state`; call it
yourself if you built the state from your own arrays.
Three checks. Only *leading* dimensions are compared, because the
component axis is a framework detail: Warp stores a ``vec3`` array as
``(num_packed,)`` while PyTorch and JAX store it as
``(num_packed, 3)`` -- and likewise ``s_history`` is
``(num_packed, history_size)`` against ``(num_packed, history_size, 3)``.
Fields that
hold the same kind of thing must share a dtype, which is what catches
one array left at the wrong precision. And every field must live on
one device.
Sharing a dtype is checked rather than a named one, since the same
state is expressed in three frameworks' type vocabularies. Per-system
scalars follow the coordinate precision: float32 for vec3f coordinates
and float64 for vec3d coordinates. Every field must live on one device.
Raises
------
ValueError
If any field is inconsistent with the rest.
"""
p, m, n = self.num_dofs, self.history_size, self.num_systems
if m < 1:
raise ValueError(f"history size must be >= 1; got {m}")
def check(name, want):
got = tuple(getattr(self, name).shape)[: len(want)]
if got != want:
raise ValueError(
f"LBFGSState.{name} starts with dimensions {got}, expected "
f"{want} for {p} degrees of freedom, {n} systems and "
f"history {m}"
)
for name in ("x_base", "force_base", "direction"):
check(name, (p,))
for name in ("s_history", "y_history"):
check(name, (p, m))
for name in ("ys", "yy", "alpha_hist", "beta_hist"):
check(name, (n, m))
for name in _OPTIMIZER_BUFFERS[9:]:
check(name, (n,))
_check_kinds(self, _STATE_KINDS, "LBFGSState")
[docs]
@dataclasses.dataclass
class LBFGSCellState:
"""The variable-cell chart and its scratch space, one field per array.
The first five fields are configuration and topology, written once before
the first step and read-only thereafter; the rest is scratch the step
rewrites every call. Build one with :func:`lbfgs_prepare_cell_state` or
construct it from your own arrays.
Attributes
----------
ref_cell, ref_cell_inv : wp.array, shape (num_systems,), dtype mat33f/mat33d
Reference cell ``H0`` and its inverse, from
:func:`_lbfgs_set_reference_cell`.
kappa : wp.array, shape (num_systems,), dtype float32/float64
Cell coordinate scaling, from :func:`_lbfgs_cell_kappa`. Matches the
*coordinate* precision, not float64, because it scales matrices.
ext_batch_idx : wp.array, shape (num_packed,), dtype int32
ext_atom_ptr : wp.array, shape (num_systems + 1,), dtype int32
Packed topology. Build these with the generic batch utilities so ragged
batches are expressible.
phi, phi_inv, d_phi : wp.array, shape (num_systems,), dtype mat33f/mat33d
Deformation gradient, its inverse, and its search direction.
cell_dof_a, cell_dof_b : wp.array, shape (num_systems,), dtype vec3f/vec3d
cell_force_a, cell_force_b : wp.array, shape (num_systems,), dtype vec3f/vec3d
The cell's two packed degrees of freedom and their conjugate forces.
ext_positions, ext_forces : wp.array, shape (num_packed,), dtype vec3f/vec3d
The packed coordinate vector and its forces.
"""
ref_cell: Any
ref_cell_inv: Any
kappa: Any
ext_batch_idx: Any
ext_atom_ptr: Any
phi: Any
phi_inv: Any
d_phi: Any
cell_dof_a: Any
cell_dof_b: Any
cell_force_a: Any
cell_force_b: Any
ext_positions: Any
ext_forces: Any
@property
def num_systems(self) -> int:
"""Independent systems in the batch."""
return self.ref_cell.shape[0]
@property
def num_packed_dofs(self) -> int:
"""Packed degrees of freedom, ``num_atoms + 2 * num_systems``."""
return self.ext_positions.shape[0]
[docs]
def validate(self, num_atoms: int | None = None) -> None:
"""Check shapes, dtypes, device and the packed relationship.
Structural only, so no device reads: this confirms that
``P = num_atoms + 2 * num_systems`` and that every array is sized for
it, but not that ``ext_atom_ptr`` holds sensible values -- checking
that would need a device-to-host sync. As with
:meth:`LBFGSState.validate`, fields of the same kind must share a
dtype and the whole state one device. ``kappa`` is exempt from the
dtype grouping: it scales matrices, so it follows the *coordinate*
precision rather than the float64 of the other scalars.
Parameters
----------
num_atoms : int, optional
Atom count. When given, the packed size is checked against it.
Raises
------
ValueError
If any field is inconsistent with the rest.
"""
n, p = self.num_systems, self.num_packed_dofs
def check(name, want):
got = tuple(getattr(self, name).shape)[: len(want)]
if got != want:
raise ValueError(
f"LBFGSCellState.{name} starts with dimensions {got}, "
f"expected {want}"
)
for name in _CELL_BUFFERS:
if name in ("ext_positions", "ext_forces", "ext_batch_idx"):
check(name, (p,))
elif name == "ext_atom_ptr":
check(name, (n + 1,))
else:
check(name, (n,))
_check_kinds(self, _CELL_KINDS, "LBFGSCellState")
if num_atoms is not None and p != num_atoms + 2 * n:
raise ValueError(
f"packed arrays hold {p} degrees of freedom, but {num_atoms} "
f"atoms in {n} systems needs {num_atoms + 2 * n} "
"(num_atoms + 2 * num_systems)"
)
#: Field order of :class:`LBFGSState`. This *is* the ABI: the PyTorch
#: operator's ``mutates_args`` and the JAX callable's ``in_out_argnames`` are
#: both generated from it, and import-time checks pin the hand-written
#: signatures to it.
_OPTIMIZER_BUFFERS: tuple[str, ...] = tuple(
f.name for f in dataclasses.fields(LBFGSState)
)
#: Field order of :class:`LBFGSCellState`.
_CELL_BUFFERS: tuple[str, ...] = tuple(
f.name for f in dataclasses.fields(LBFGSCellState)
)
#: The cell fields the step writes. The first five are read-only
#: configuration and topology, so they are inputs rather than in-out aliases.
_CELL_SCRATCH: tuple[str, ...] = _CELL_BUFFERS[5:]
#: Warp spells one precision as a vector, a matrix and a scalar; these map
#: onto the scalar so the three can be compared. PyTorch and JAX dtypes pass
#: through :func:`_precision_of` unchanged, because theirs already are scalars.
_PRECISION_OF = {
wp.vec3f: wp.float32, wp.mat33f: wp.float32, wp.float32: wp.float32,
wp.vec3d: wp.float64, wp.mat33d: wp.float64, wp.float64: wp.float64,
} # fmt: skip
#: Groups holding integers, which carry no floating-point precision to agree
#: on. Keyed by label rather than by dtype, because ``wp.int32``,
#: ``torch.int32`` and JAX's ``int32`` are three unrelated objects.
_INTEGER_KINDS = frozenset({"per-system integer control fields", "topology indices"})
#: Fields grouped by what they hold, for validation. Every field in a group
#: must share one dtype -- that is the check, rather than a named dtype,
#: because the same state is expressed in Warp, PyTorch and JAX types.
_STATE_KINDS: dict[str, tuple[str, ...]] = {
"per-degree-of-freedom vectors": _OPTIMIZER_BUFFERS[:5],
"per-system scalars": _OPTIMIZER_BUFFERS[5:15],
"per-system integer control fields": _OPTIMIZER_BUFFERS[15:],
}
_CELL_KINDS: dict[str, tuple[str, ...]] = {
"cell matrices": ("ref_cell", "ref_cell_inv", "phi", "phi_inv", "d_phi"),
"packed vectors": (
"cell_dof_a",
"cell_dof_b",
"cell_force_a",
"cell_force_b",
"ext_positions",
"ext_forces",
), # fmt: skip
"topology indices": ("ext_batch_idx", "ext_atom_ptr"),
# Its own group: kappa scales matrices, so it is the scalar counterpart of
# the coordinate precision rather than a companion of any other field. The
# cross-group check below is what ties it to the rest.
"cell scaling": ("kappa",),
}
# The groups are written out by slice and by hand, so pin them to the field
# order. Inserting a field would otherwise shift a slice silently, and the
# dtype check would then compare the wrong arrays.
for _names, _all, _label in (
(_STATE_KINDS, _OPTIMIZER_BUFFERS, "_STATE_KINDS"),
(_CELL_KINDS, _CELL_BUFFERS, "_CELL_KINDS"),
):
_grouped = [n for g in _names.values() for n in g]
_ungrouped = set(_all) - set(_grouped)
if len(_grouped) != len(set(_grouped)) or not set(_grouped) <= set(_all):
# Raised rather than asserted so the guard survives `python -O`.
raise RuntimeError(f"{_label} does not partition its fields: {_grouped}")
if _ungrouped:
raise RuntimeError(f"{_label} leaves fields unchecked: {sorted(_ungrouped)}")
# Pin the flags to real group labels. A renamed label would otherwise drop out
# of this set silently, and its integer dtype would then be compared against
# the floating-point ones as if it were a precision.
_ALL_KIND_LABELS = set(_STATE_KINDS) | set(_CELL_KINDS)
if not _INTEGER_KINDS <= _ALL_KIND_LABELS:
# Raised rather than asserted so the guard survives `python -O`.
raise RuntimeError(
f"_INTEGER_KINDS names groups that do not exist: "
f"{sorted(_INTEGER_KINDS - _ALL_KIND_LABELS)}"
)
def _precision_of(dtype):
"""The scalar precision a coordinate, matrix or scalar dtype implies.
Warp spells the same precision three ways -- ``vec3d``, ``mat33d``,
``float64`` -- while PyTorch and JAX store coordinates in an array whose
dtype already *is* the scalar. Mapping the Warp composites onto their
scalar and passing everything else through unchanged lets one comparison
serve all three frameworks.
"""
return _PRECISION_OF.get(dtype, dtype)
def _check_kinds(state, kinds: dict[str, tuple[str, ...]], cls_name: str) -> None:
"""Check dtypes within and across groups, and the device across the state.
Three checks. Every field in a group shares a dtype. Every group that
carries a floating-point precision agrees on *which* precision, so a state
cannot pair fp64 coordinates with an fp32 scalar group -- that combination
is not registered, and without this it reaches the kernel launcher and
fails there with a dtype error rather than here with a documented one.
And the whole state lives on one device.
All read off the arrays themselves, so this works unchanged for Warp,
PyTorch and JAX. A JAX tracer has no device, so the device check is skipped
for anything that does not expose one rather than failing under ``jit``.
"""
precisions = {}
for label, names in kinds.items():
seen = {getattr(state, n).dtype for n in names}
if len(seen) > 1:
offenders = {n: str(getattr(state, n).dtype) for n in names}
raise ValueError(
f"{cls_name} {label} must share one dtype, got {len(seen)}: {offenders}"
)
if label not in _INTEGER_KINDS:
precisions.setdefault(_precision_of(seen.pop()), []).append(label)
if len(precisions) > 1:
raise ValueError(
f"{cls_name} mixes floating-point precisions: "
+ "; ".join(
f"{p} in {sorted(labels)}"
for p, labels in sorted(precisions.items(), key=lambda kv: str(kv[0]))
)
+ ". Every array follows the coordinate dtype; see the precision "
"section of the module documentation."
)
devices = {}
for f in dataclasses.fields(state):
device = getattr(getattr(state, f.name), "device", None)
if device is not None:
devices.setdefault(str(device), []).append(f.name)
if len(devices) > 1:
raise ValueError(
f"{cls_name} fields are spread across devices {sorted(devices)}: "
+ "; ".join(f"{d}: {sorted(n)}" for d, n in sorted(devices.items()))
)
# -----------------------------------------------------------------------------
# Internal ``n_loop`` sentinels.
#
# ``n_loop`` is the single per-system value every downstream kernel reads to
# decide what work it owes this call. ``n_loop == history_count + 1`` drives
# the two-loop recursion; the negative values below are sentinels.
#
# Every system does a full step on every call. There is no "finished" value,
# because termination is the caller's, exactly as it is for FIRE2.
# -----------------------------------------------------------------------------
_NLOOP_PENDING = -4 # accepted; the (s, y) pair is written but not yet committed
_NLOOP_RESTART = -1 # first step or restart: take a steepest-descent direction
# Stands in for "no trust-region limit". Must be representable in float32 as
# well as float64, since the per-system scalars follow the coordinate dtype.
_BIG = 1.0e30
#: Default relative curvature threshold, chosen per coordinate precision.
#:
#: The guard is ``ys > eps * sqrt(ss * yy)``, i.e. a floor on the cosine
#: between ``s`` and ``y``. ``ys`` is accumulated at the coordinate precision,
#: so the floor has to sit above the level at which that sum is still signal.
#: Measured on deliberately orthogonal pairs (true ``ys`` exactly zero), the
#: computed cosine lands around ``1e-9`` to ``3.5e-8`` in float32 and ``1e-17``
#: in float64. A single default of ``1e-10`` is therefore comfortably above the
#: float64 floor but *below* the float32 one, where it cannot reject anything
#: on the strength of the sign it is testing.
#:
#: The float32 value sits one to three orders of magnitude above its floor and
#: still far below any usable curvature: a pair with ``cos(s, y) < 1e-6``
#: contributes a ``gamma = ys / yy`` so small that it carries no scale. Swept
#: over twelve Lennard-Jones relaxations, every value from ``1e-10`` to
#: ``1e-3`` gave identical evaluation counts, so this costs nothing on a
#: well-conditioned system; it matters where ``ys`` crosses zero.
_CURVATURE_EPS = {wp.float32: 1.0e-6, wp.float64: 1.0e-10}
# =============================================================================
# Device helpers
# =============================================================================
@wp.func
def _alpha_cap(a_lin: Any, b_quad: Any, maxstep: Any):
"""Largest step length whose Cartesian displacement stays within ``maxstep``.
The displacement of an atom is ``alpha * a_lin + alpha**2 * b_quad`` in the
worst case, so bounding it by ``maxstep`` is a quadratic in ``alpha``. On
the coordinate-only path ``b_quad`` is zero and this reduces to
``maxstep / a_lin``.
The positive root is taken in the form
alpha = 2 * maxstep / (a_lin + sqrt(a_lin**2 + 4 * b_quad * maxstep))
rather than the textbook ``(-a_lin + sqrt(...)) / (2 * b_quad)``. The two
are equivalent in exact arithmetic, but the textbook form subtracts two
nearly equal numbers whenever ``b_quad`` is small next to ``a_lin**2``, and
that is the *usual* case here: the quadratic term is second order in the
step. At ``b_quad ~ 1e-17`` the subtraction cancels completely and returns
zero, which would freeze the optimizer with a step length of exactly zero.
The form used here has no subtraction, so it stays accurate all the way
down to ``b_quad = 0``.
A purely quadratic displacement is capped too. With ``a_lin == 0`` the
expression above reduces to ``sqrt(maxstep / b_quad)``, which is the right
bound, so the only case that may go uncapped is a step that does not move
anything: **both** contributions zero. Returning early on ``a_lin == 0``
alone would let a cell whose first-order displacement happens to cancel
take an unbounded step.
A non-positive ``maxstep`` disables the trust region. Generic in the
scalar type, which follows the coordinate precision.
"""
zero = type(a_lin)(0.0)
if maxstep <= zero or (a_lin <= zero and b_quad <= zero):
return type(a_lin)(_BIG)
disc = a_lin * a_lin + type(a_lin)(4.0) * b_quad * maxstep
return (type(a_lin)(2.0) * maxstep) / (a_lin + wp.sqrt(disc))
@wp.func
def _slot(end: wp.int32, back: wp.int32, m: wp.int32) -> wp.int32:
"""Ring-buffer slot holding the ``back``-th newest pair (0 = newest).
``end`` is the next slot that will be written, so the newest committed pair
sits one position behind it.
"""
return ((end - 1 - back) % m + m) % m
# =============================================================================
# Kernel 1: per-system reductions
# =============================================================================
@wp.kernel(enable_backward=False)
def _lbfgs_reduce_kernel(
forces: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
gg: wp.array(dtype=Any),
n_dofs: wp.int32,
elems_per_thread: wp.int32,
):
"""Reduce the packed force norm for one L-BFGS call.
Computes, per system:
``gg``
``f . f`` over the packed degrees of freedom, used to normalize a
steepest-descent direction. This lives in the space the direction lives
in, which on a variable-cell path is *not* Cartesian space.
Thread launch
-------------
One thread per ``elems_per_thread`` consecutive degrees of freedom;
``dim = ceil(n_dofs / elems_per_thread)``. Requires ``batch_idx`` sorted in
non-decreasing order.
Modifies
--------
gg
OUTPUT. Accumulated atomically; the launcher zeroes it first.
"""
tid = wp.tid()
start = tid * elems_per_thread
if start >= n_dofs:
return
stop = wp.min(start + elems_per_thread, n_dofs)
zero = type(gg[0])(0.0)
s_cur = batch_idx[start]
acc_gg = zero
for i in range(start, stop):
s = batch_idx[i]
if s != s_cur:
wp.atomic_add(gg, s_cur, acc_gg)
s_cur = s
acc_gg = zero
fi = forces[i]
acc_gg += type(gg[0])(wp.dot(fi, fi))
wp.atomic_add(gg, s_cur, acc_gg)
@wp.kernel(enable_backward=False)
def _lbfgs_step_decision_kernel(
ys: wp.array(dtype=Any, ndim=2),
yy: wp.array(dtype=Any, ndim=2),
ss: wp.array(dtype=Any),
iteration: wp.array(dtype=wp.int32),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
):
"""Decide what the evaluation just supplied means.
There is no line search, so there is nothing to reject: every evaluation is
an accepted point. Each call therefore forms one secant pair and hands a
direction downstream for one trust-region step. The step length is bounded
by ``maxstep`` rather than chosen by comparing energies, so nothing here
reads an energy at all.
That matters for machine-learned potentials. A model with a direct force
head does not return forces that are the gradient of its energy, and even a
conservative model can have a rough energy surface. An Armijo test compares
total energies while the search direction comes from forces; when the two
disagree the test rejects good steps, and no tuning repairs it because the
predicate is measuring the wrong surface.
Nothing here tests convergence: the optimizer has no terminal state and
every system steps on every call. The caller decides when to stop, as it
does for FIRE2.
This is the only kernel with per-system control flow, which is why it runs
one thread per system rather than per atom.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
iteration, n_loop
Per-system control state.
ys, yy, ss
The candidate history slot is zeroed here, ready for the history
kernels.
"""
tid = wp.tid()
# ---- first evaluation -------------------------------------------------
if iteration[tid] < 0:
# No previous accepted point, so there is no secant pair to form yet.
iteration[tid] = 0
n_loop[tid] = _NLOOP_RESTART
return
# ---- every later evaluation is an accepted step -----------------------
iteration[tid] = iteration[tid] + 1
n_loop[tid] = _NLOOP_PENDING
slot = end[tid]
ys[tid, slot] = type(ss[0])(0.0)
yy[tid, slot] = type(ss[0])(0.0)
ss[tid] = type(ss[0])(0.0)
@wp.kernel(enable_backward=False)
def _lbfgs_history_update_kernel(
positions: wp.array(dtype=Any),
forces: wp.array(dtype=Any),
x_base: wp.array(dtype=Any),
force_base: wp.array(dtype=Any),
s_history: wp.array(dtype=Any, ndim=2),
y_history: wp.array(dtype=Any, ndim=2),
batch_idx: wp.array(dtype=wp.int32),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
ys: wp.array(dtype=Any, ndim=2),
yy: wp.array(dtype=Any, ndim=2),
ss: wp.array(dtype=Any),
n_dofs: wp.int32,
elems_per_thread: wp.int32,
):
"""Record the candidate ``(s, y)`` pair and move the base point forward.
Runs for systems that just accepted a step. It writes the candidate pair
into the slot ``end`` points at and accumulates the three inner products
the curvature test needs. It also refreshes ``x_base`` and ``force_base``
unconditionally, because an accepted point becomes the new base point
whether or not it turns out to be converged.
The pair is only *provisional* at this stage: whether it is kept is decided
by :func:`_lbfgs_history_commit_kernel`, which cannot run earlier because
it needs the inner products this kernel produces.
Thread launch
-------------
One thread per ``elems_per_thread`` consecutive degrees of freedom.
Modifies
--------
s_history, y_history
The slot at ``end`` is overwritten with the candidate pair.
x_base, force_base
Advanced to the accepted point.
ys, yy, ss
OUTPUT. Accumulated atomically into the candidate slot.
"""
tid = wp.tid()
start = tid * elems_per_thread
if start >= n_dofs:
return
stop = wp.min(start + elems_per_thread, n_dofs)
zero = type(ss[0])(0.0)
s_cur = batch_idx[start]
slot = end[s_cur]
active = n_loop[s_cur] == _NLOOP_PENDING
acc_sy = zero
acc_ss = zero
acc_yy = zero
for i in range(start, stop):
s = batch_idx[i]
if s != s_cur:
if active:
wp.atomic_add(ys, s_cur, slot, acc_sy)
wp.atomic_add(ss, s_cur, acc_ss)
wp.atomic_add(yy, s_cur, slot, acc_yy)
s_cur = s
slot = end[s_cur]
active = n_loop[s_cur] == _NLOOP_PENDING
acc_sy = zero
acc_ss = zero
acc_yy = zero
if active:
pi = positions[i]
fi = forces[i]
# s = x - x_base; y = g - g_base = force_base - F (g = -F)
svec = pi - x_base[i]
yvec = force_base[i] - fi
s_history[i, slot] = svec
y_history[i, slot] = yvec
x_base[i] = pi
force_base[i] = fi
acc_sy += type(ss[0])(wp.dot(svec, yvec))
acc_ss += type(ss[0])(wp.dot(svec, svec))
acc_yy += type(ss[0])(wp.dot(yvec, yvec))
if active:
wp.atomic_add(ys, s_cur, slot, acc_sy)
wp.atomic_add(ss, s_cur, acc_ss)
wp.atomic_add(yy, s_cur, slot, acc_yy)
@wp.kernel(enable_backward=False)
def _lbfgs_history_commit_kernel(
ys: wp.array(dtype=Any, ndim=2),
yy: wp.array(dtype=Any, ndim=2),
ss: wp.array(dtype=Any),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
history_count: wp.array(dtype=wp.int32),
m: wp.int32,
curvature_eps: Any,
):
"""Keep or discard the candidate pair, and finalize the loop bound.
A pair is kept only if its curvature ``s . y`` is comfortably positive.
A line search would enforce this through the Wolfe conditions, but in
single precision ``y = g - g_prev`` cancels badly near convergence and the
scaling ``gamma = ys / yy`` can blow up.
Discarding is not free: kernel 3 has already written the candidate into the
slot ``end`` points at, and when the ring is full that slot held the oldest
valid pair. Clamping ``history_count`` to ``m - 1`` removes exactly that
now-destroyed entry from the range the recursion walks. When the ring is
not yet full the slot had never been written, so the clamp does nothing.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
end, n_loop, history_count
Per-system control state.
"""
tid = wp.tid()
if n_loop[tid] != _NLOOP_PENDING:
return
slot = end[tid]
sy = ys[tid, slot]
threshold = curvature_eps * wp.sqrt(ss[tid] * yy[tid, slot])
if sy > threshold:
history_count[tid] = wp.min(history_count[tid] + 1, m)
end[tid] = (slot + 1) % m
else:
history_count[tid] = wp.min(history_count[tid], m - 1)
if history_count[tid] > 0:
n_loop[tid] = history_count[tid] + 1
else:
n_loop[tid] = _NLOOP_RESTART
# =============================================================================
# Kernels 4 and 5: the two-loop recursion
# =============================================================================
@wp.kernel(enable_backward=False)
def _lbfgs_loop1_kernel(
forces: wp.array(dtype=Any),
s_history: wp.array(dtype=Any, ndim=2),
y_history: wp.array(dtype=Any, ndim=2),
direction: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
ys: wp.array(dtype=Any, ndim=2),
yy: wp.array(dtype=Any, ndim=2),
alpha_hist: wp.array(dtype=Any, ndim=2),
beta_hist: wp.array(dtype=Any, ndim=2),
step: wp.int32,
m: wp.int32,
n_dofs: wp.int32,
elems_per_thread: wp.int32,
):
"""First loop of the two-loop recursion, one history vector per launch.
Walks the history newest-first, subtracting each ``alpha_j * y_j`` from the
working vector and applying the initial scaling ``gamma = ys / yy`` on the
final step. Each launch fuses the update for one history vector with the
dot product for the next, so a whole recursion costs one launch per vector
rather than two.
The launch count is fixed at ``m + 1`` regardless of how much history each
system actually has; systems with less simply return early. That keeps the
launch sequence independent of device state, which is what allows the whole
step to be captured in a CUDA graph.
Thread launch
-------------
One thread per ``elems_per_thread`` consecutive degrees of freedom, called
with ``step = 0 .. m``.
Modifies
--------
direction
The working vector ``q``.
alpha_hist, beta_hist
OUTPUT. One coefficient accumulated per launch.
"""
tid = wp.tid()
start = tid * elems_per_thread
if start >= n_dofs:
return
stop = wp.min(start + elems_per_thread, n_dofs)
zero = type(ys[0, 0])(0.0)
s_cur = batch_idx[start]
acc = zero
# Per-system constants, refreshed whenever the segment changes.
nl = n_loop[s_cur]
bound = nl - 1
e = end[s_cur]
active = step < nl and nl > 0
is_last = step == bound
coeff = zero
j_cur = wp.int32(0)
j_prev = wp.int32(0)
gamma = type(ys[0, 0])(1.0)
if active:
if step >= 1:
j_prev = _slot(e, step - 1, m)
coeff = alpha_hist[s_cur, j_prev] / ys[s_cur, j_prev]
if is_last:
j_new = _slot(e, 0, m)
gamma = ys[s_cur, j_new] / yy[s_cur, j_new]
j_cur = _slot(e, bound - 1, m)
else:
j_cur = _slot(e, step, m)
for i in range(start, stop):
s = batch_idx[i]
if s != s_cur:
if active:
if is_last:
wp.atomic_add(beta_hist, s_cur, j_cur, acc)
else:
wp.atomic_add(alpha_hist, s_cur, j_cur, acc)
s_cur = s
acc = zero
nl = n_loop[s_cur]
bound = nl - 1
e = end[s_cur]
active = step < nl and nl > 0
is_last = step == bound
coeff = zero
gamma = type(ys[0, 0])(1.0)
if active:
if step >= 1:
j_prev = _slot(e, step - 1, m)
coeff = alpha_hist[s_cur, j_prev] / ys[s_cur, j_prev]
if is_last:
j_new = _slot(e, 0, m)
gamma = ys[s_cur, j_new] / yy[s_cur, j_new]
j_cur = _slot(e, bound - 1, m)
else:
j_cur = _slot(e, step, m)
if active:
if step == 0:
# q starts at +F, which is -g.
qi = forces[i]
else:
qi = direction[i] - type(direction[i][0])(coeff) * y_history[i, j_prev]
if is_last:
qi = type(qi[0])(gamma) * qi
acc += type(ys[0, 0])(wp.dot(y_history[i, j_cur], qi))
else:
acc += type(ys[0, 0])(wp.dot(s_history[i, j_cur], qi))
direction[i] = qi
if active:
if is_last:
wp.atomic_add(beta_hist, s_cur, j_cur, acc)
else:
wp.atomic_add(alpha_hist, s_cur, j_cur, acc)
@wp.kernel(enable_backward=False)
def _lbfgs_loop2_kernel(
s_history: wp.array(dtype=Any, ndim=2),
y_history: wp.array(dtype=Any, ndim=2),
direction: wp.array(dtype=Any),
force_base: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
ys: wp.array(dtype=Any, ndim=2),
alpha_hist: wp.array(dtype=Any, ndim=2),
beta_hist: wp.array(dtype=Any, ndim=2),
d0: wp.array(dtype=Any),
step: wp.int32,
m: wp.int32,
n_dofs: wp.int32,
elems_per_thread: wp.int32,
):
"""Second loop of the two-loop recursion, one history vector per launch.
Walks the history oldest-first, adding ``(alpha_j - beta_j) * s_j``. The
final launch also accumulates the slope ``d0`` at the base point, so that
quantity needs no extra pass. The trust-region measure is taken separately,
because on a variable-cell path it is not a property of the direction
alone.
Thread launch
-------------
One thread per ``elems_per_thread`` consecutive degrees of freedom, called
with ``step = 1 .. m``.
Modifies
--------
direction
The search direction, complete after the final launch.
beta_hist
OUTPUT. One coefficient accumulated per launch.
d0
OUTPUT. Accumulated on the final launch only.
"""
tid = wp.tid()
start = tid * elems_per_thread
if start >= n_dofs:
return
stop = wp.min(start + elems_per_thread, n_dofs)
zero = type(ys[0, 0])(0.0)
s_cur = batch_idx[start]
acc = zero
nl = n_loop[s_cur]
bound = nl - 1
e = end[s_cur]
active = step < nl and nl > 0
is_last = step == bound
j_apply = wp.int32(0)
j_next = wp.int32(0)
coeff = zero
if active:
j_apply = _slot(e, bound - step, m)
coeff = (alpha_hist[s_cur, j_apply] - beta_hist[s_cur, j_apply]) / ys[
s_cur, j_apply
]
if not is_last:
j_next = _slot(e, bound - step - 1, m)
for i in range(start, stop):
s = batch_idx[i]
if s != s_cur:
if active:
if is_last:
wp.atomic_add(d0, s_cur, acc)
else:
wp.atomic_add(beta_hist, s_cur, j_next, acc)
s_cur = s
acc = zero
nl = n_loop[s_cur]
bound = nl - 1
e = end[s_cur]
active = step < nl and nl > 0
is_last = step == bound
if active:
j_apply = _slot(e, bound - step, m)
coeff = (alpha_hist[s_cur, j_apply] - beta_hist[s_cur, j_apply]) / ys[
s_cur, j_apply
]
if not is_last:
j_next = _slot(e, bound - step - 1, m)
if active:
ri = direction[i] + type(direction[i][0])(coeff) * s_history[i, j_apply]
direction[i] = ri
if is_last:
# d0 = g_base . d = -(force_base . d)
acc -= type(ys[0, 0])(wp.dot(force_base[i], ri))
else:
acc += type(ys[0, 0])(wp.dot(y_history[i, j_next], ri))
if active:
if is_last:
wp.atomic_add(d0, s_cur, acc)
else:
wp.atomic_add(beta_hist, s_cur, j_next, acc)
@wp.kernel(enable_backward=False)
def _lbfgs_restart_check_kernel(
gg: wp.array(dtype=Any),
d0: wp.array(dtype=Any),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
history_count: wp.array(dtype=wp.int32),
):
"""Demote a two-loop direction that does not descend, before it is used.
``d0 >= 0`` means the curvature model has gone bad and the recursion
returned an ascent direction. This must run *after* the two-loop that
produces ``d0`` and *before* the seed kernel that rebuilds ``direction``:
demoting later would leave the rejected direction in place and the atoms
would be moved along it, uphill, which is the opposite of the intent.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
d0, end, n_loop, history_count
Per-system control state.
"""
tid = wp.tid()
if n_loop[tid] > 0 and d0[tid] >= type(d0[0])(0.0):
n_loop[tid] = _NLOOP_RESTART
history_count[tid] = 0
end[tid] = 0
if n_loop[tid] == _NLOOP_RESTART:
# For a normalized steepest-descent direction the slope is exact.
d0[tid] = -wp.sqrt(gg[tid])
@wp.kernel(enable_backward=False)
def _lbfgs_seed_direction_kernel(
forces: wp.array(dtype=Any),
positions: wp.array(dtype=Any),
x_base: wp.array(dtype=Any),
force_base: wp.array(dtype=Any),
direction: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
gg: wp.array(dtype=Any),
):
"""Set a steepest-descent direction and seed the base point.
Runs for systems that are starting fresh, either on the first evaluation or
after a restart. Doing this before the trust region is measured means the
direction is fully determined by the time the step length is chosen, which
matters on a variable-cell path where the displacement a direction produces
cannot be inferred from the force norm alone.
Thread launch
-------------
One thread per degree of freedom; ``dim = n_dofs``.
Modifies
--------
direction
Set to ``F / ||F||`` in the packed space.
x_base, force_base
Seeded to the current point.
"""
tid = wp.tid()
s = batch_idx[tid]
if n_loop[s] != _NLOOP_RESTART:
return
gn = wp.sqrt(gg[s])
if gn > type(gg[0])(0.0):
scale = type(forces[tid][0])(type(gg[0])(1.0) / gn)
direction[tid] = scale * forces[tid]
else:
direction[tid] = type(forces[tid])()
x_base[tid] = positions[tid]
force_base[tid] = forces[tid]
@wp.kernel(enable_backward=False)
def _lbfgs_trust_region_kernel(
direction: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
dmax: wp.array(dtype=Any),
dquad: wp.array(dtype=Any),
n_dofs: wp.int32,
elems_per_thread: wp.int32,
):
"""Measure how far the largest atom moves per unit step length.
On a coordinate-only path the displacement is simply ``alpha * d``, so the
measure is the largest direction magnitude and the quadratic term is zero.
The variable-cell path has its own kernel, because there both the cell and
the positions move and the displacement gains a term in ``alpha**2``.
Thread launch
-------------
One thread per ``elems_per_thread`` consecutive degrees of freedom.
Modifies
--------
dmax, dquad
OUTPUT. Zeroed by the launcher, then accumulated with atomic maxima.
"""
tid = wp.tid()
start = tid * elems_per_thread
if start >= n_dofs:
return
stop = wp.min(start + elems_per_thread, n_dofs)
zero = type(dmax[0])(0.0)
s_cur = batch_idx[start]
loc_max = zero
for i in range(start, stop):
s = batch_idx[i]
if s != s_cur:
wp.atomic_max(dmax, s_cur, loc_max)
s_cur = s
loc_max = zero
loc_max = wp.max(loc_max, type(dmax[0])(wp.length(direction[i])))
wp.atomic_max(dmax, s_cur, loc_max)
# =============================================================================
# Kernels 6 and 7: prepare and apply
# =============================================================================
@wp.kernel(enable_backward=False)
def _lbfgs_prepare_step_kernel(
gg: wp.array(dtype=Any),
d0: wp.array(dtype=Any),
dmax: wp.array(dtype=Any),
dquad: wp.array(dtype=Any),
alpha_step: wp.array(dtype=Any),
end: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
history_count: wp.array(dtype=wp.int32),
maxstep: Any,
):
"""Repair a bad direction and apply the trust region.
Three things happen here, in order.
First, the step length is reset to ``alpha = 1``. Nothing is carried
between calls: the two-loop already scales the direction, so a unit step is
the Newton step, and the trust region below is the only thing that shortens
it. Starting from anything else would quietly degrade the method to a
scaled gradient descent.
Second, a direction that fails the descent test is replaced by steepest
descent. Catching it here, where the direction was produced, guarantees the
state machine never sees a non-descent slope.
Third, the step is shortened so the resulting Cartesian displacement stays
within ``maxstep``. This is the trust region, and it is the sole determinant
of how far the atoms move.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
alpha_step, d0, dmax, dquad, end, history_count, n_loop
Per-system control state.
"""
tid = wp.tid()
# The step length is never carried between calls: the trust region below
# determines it outright, starting from the full quasi-Newton step.
alpha_step[tid] = type(alpha_step[0])(1.0)
cap = _alpha_cap(dmax[tid], dquad[tid], maxstep)
alpha_step[tid] = wp.min(alpha_step[tid], cap)
@wp.kernel(enable_backward=False)
def _lbfgs_apply_step_kernel(
positions: wp.array(dtype=Any),
forces: wp.array(dtype=Any),
x_base: wp.array(dtype=Any),
force_base: wp.array(dtype=Any),
direction: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
n_loop: wp.array(dtype=wp.int32),
gg: wp.array(dtype=Any),
alpha_step: wp.array(dtype=Any),
):
"""Move the positions to the next point, or finish the system off.
Handles the terminal case as well as the ordinary step, so that whatever
the caller reads back is always a geometry the optimizer stands behind,
paired with matching forces in ``force_base``: a geometry that arrived
already converged is left where it is, but its base buffers are seeded so
they describe it.
Thread launch
-------------
One thread per degree of freedom; ``dim = n_dofs``.
Modifies
--------
positions
Advanced to ``x_base + alpha * d``.
direction, x_base, force_base
Seeded when a steepest-descent direction is taken.
"""
tid = wp.tid()
s = batch_idx[tid]
a = type(direction[tid][0])(alpha_step[s])
positions[tid] = x_base[tid] + a * direction[tid]
# =============================================================================
# Kernel overloads
#
# Coordinates may be single or double precision; every per-system scalar is
# scalars follow them, so the overload key is just the vector dtype.
# =============================================================================
_VEC_TYPES = [wp.vec3f, wp.vec3d]
_reduce_overloads = {}
_seed_direction_overloads = {}
_trust_region_overloads = {}
_history_update_overloads = {}
_loop1_overloads = {}
_loop2_overloads = {}
_apply_step_overloads = {}
_step_decision_overloads = {}
_history_commit_overloads = {}
_restart_check_overloads = {}
_prepare_step_overloads = {}
_zero_d0_overloads = {}
_I32 = wp.int32
#: The per-system scalar that goes with each coordinate precision. Every
#: optimizer scalar follows the coordinates, so the overload key stays the
#: vector type alone and the instantiation count does not grow with it.
_SCALAR_FOR = {wp.vec3f: wp.float32, wp.vec3d: wp.float64}
for _v in _VEC_TYPES:
_F64 = _SCALAR_FOR[_v] # named for history; it is the coordinate scalar
_reduce_overloads[_v] = wp.overload(
_lbfgs_reduce_kernel,
[
wp.array(dtype=_v), # forces
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_F64), # gg
_I32, # n_dofs
_I32, # elems_per_thread
],
)
_seed_direction_overloads[_v] = wp.overload(
_lbfgs_seed_direction_kernel,
[
wp.array(dtype=_v), # forces
wp.array(dtype=_v), # positions
wp.array(dtype=_v), # x_base
wp.array(dtype=_v), # force_base
wp.array(dtype=_v), # direction
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_F64), # gg
],
)
_trust_region_overloads[_v] = wp.overload(
_lbfgs_trust_region_kernel,
[
wp.array(dtype=_v), # direction
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_F64), # dmax
wp.array(dtype=_F64), # dquad
_I32, # n_dofs
_I32, # elems_per_thread
],
)
_history_update_overloads[_v] = wp.overload(
_lbfgs_history_update_kernel,
[
wp.array(dtype=_v), # positions
wp.array(dtype=_v), # forces
wp.array(dtype=_v), # x_base
wp.array(dtype=_v), # force_base
wp.array(dtype=_v, ndim=2), # s_history
wp.array(dtype=_v, ndim=2), # y_history
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_F64, ndim=2), # ys
wp.array(dtype=_F64, ndim=2), # yy
wp.array(dtype=_F64), # ss
_I32, # n_dofs
_I32, # elems_per_thread
],
)
_loop1_overloads[_v] = wp.overload(
_lbfgs_loop1_kernel,
[
wp.array(dtype=_v), # forces
wp.array(dtype=_v, ndim=2), # s_history
wp.array(dtype=_v, ndim=2), # y_history
wp.array(dtype=_v), # direction
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_F64, ndim=2), # ys
wp.array(dtype=_F64, ndim=2), # yy
wp.array(dtype=_F64, ndim=2), # alpha_hist
wp.array(dtype=_F64, ndim=2), # beta_hist
_I32, # step
_I32, # m
_I32, # n_dofs
_I32, # elems_per_thread
],
)
_loop2_overloads[_v] = wp.overload(
_lbfgs_loop2_kernel,
[
wp.array(dtype=_v, ndim=2), # s_history
wp.array(dtype=_v, ndim=2), # y_history
wp.array(dtype=_v), # direction
wp.array(dtype=_v), # force_base
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_F64, ndim=2), # ys
wp.array(dtype=_F64, ndim=2), # alpha_hist
wp.array(dtype=_F64, ndim=2), # beta_hist
wp.array(dtype=_F64), # d0
_I32, # step
_I32, # m
_I32, # n_dofs
_I32, # elems_per_thread
],
)
_apply_step_overloads[_v] = wp.overload(
_lbfgs_apply_step_kernel,
[
wp.array(dtype=_v), # positions
wp.array(dtype=_v), # forces
wp.array(dtype=_v), # x_base
wp.array(dtype=_v), # force_base
wp.array(dtype=_v), # direction
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_F64), # gg
wp.array(dtype=_F64), # alpha_step
],
)
_step_decision_overloads[_F64] = wp.overload(
_lbfgs_step_decision_kernel,
[
wp.array(dtype=_F64, ndim=2), # ys
wp.array(dtype=_F64, ndim=2), # yy
wp.array(dtype=_F64), # ss
wp.array(dtype=_I32), # iteration
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
],
)
_history_commit_overloads[_F64] = wp.overload(
_lbfgs_history_commit_kernel,
[
wp.array(dtype=_F64, ndim=2), # ys
wp.array(dtype=_F64, ndim=2), # yy
wp.array(dtype=_F64), # ss
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_I32), # history_count
_I32, # m
_F64, # curvature_eps
],
)
_restart_check_overloads[_F64] = wp.overload(
_lbfgs_restart_check_kernel,
[
wp.array(dtype=_F64), # gg
wp.array(dtype=_F64), # d0
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_I32), # history_count
],
)
_prepare_step_overloads[_F64] = wp.overload(
_lbfgs_prepare_step_kernel,
[
wp.array(dtype=_F64), # gg
wp.array(dtype=_F64), # d0
wp.array(dtype=_F64), # dmax
wp.array(dtype=_F64), # dquad
wp.array(dtype=_F64), # alpha_step
wp.array(dtype=_I32), # end
wp.array(dtype=_I32), # n_loop
wp.array(dtype=_I32), # history_count
_F64, # maxstep
],
)
# =============================================================================
# Public API
# =============================================================================
[docs]
def lbfgs_prepare_state(
num_dofs: int,
num_systems: int,
*,
dtype=wp.vec3d,
history_size: int = 6,
device=None,
) -> LBFGSState:
"""Allocate, initialize and validate a complete optimizer state.
Call this once, before the first step. The two fields that do not start
at zero are set for you -- ``alpha_step`` to one and ``iteration`` to minus
one -- which is the part that is easy to get wrong by hand. Calling it
again is how you reset.
You are not obliged to use it: :class:`LBFGSState` is a plain dataclass, so
you can build one from arrays you already 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 : wp.vec3f or wp.vec3d, optional
Coordinate precision, ``wp.vec3f`` or ``wp.vec3d``. Every per-system
scalar follows it, so ``wp.vec3f`` gives an end-to-end fp32 state and
``wp.vec3d`` an end-to-end fp64 one.
history_size : int, optional
Stored curvature pairs; 3 to 7 is the usual range. Memory is
dominated by the two ``(num_dofs, history_size)`` history buffers.
device : optional
Warp device.
Returns
-------
LBFGSState
"""
if dtype not in _VEC_TYPES:
raise ValueError(f"dtype must be wp.vec3f or wp.vec3d; got {dtype}")
if history_size < 1:
raise ValueError(f"history_size must be >= 1; got {history_size}")
def vec(*shape):
return wp.zeros(shape if len(shape) > 1 else shape[0], dtype=dtype,
device=device) # fmt: skip
scalar = _SCALAR_FOR[dtype]
def sc(*shape):
return wp.zeros(shape if len(shape) > 1 else shape[0], dtype=scalar,
device=device) # fmt: skip
def i32(n):
return wp.zeros(n, dtype=wp.int32, device=device)
state = LBFGSState(
x_base=vec(num_dofs),
force_base=vec(num_dofs),
direction=vec(num_dofs),
s_history=vec(num_dofs, history_size),
y_history=vec(num_dofs, history_size),
ys=sc(num_systems, history_size),
yy=sc(num_systems, history_size),
alpha_hist=sc(num_systems, history_size),
beta_hist=sc(num_systems, history_size),
ss=sc(num_systems),
gg=sc(num_systems),
d0=sc(num_systems),
dmax=sc(num_systems),
dquad=sc(num_systems),
alpha_step=sc(num_systems),
iteration=i32(num_systems),
end=i32(num_systems),
n_loop=i32(num_systems),
history_count=i32(num_systems),
)
# The only two fields whose initial value is not zero.
state.alpha_step.fill_(1.0)
state.iteration.fill_(-1)
state.validate()
return state
[docs]
def lbfgs_prepare_cell_state(
atom_ptr,
cell,
*,
cell_force_scale: float = 1.0,
dtype=wp.vec3d,
device=None,
) -> LBFGSCellState:
"""Build a complete, ready-to-step variable-cell chart from atom topology.
One call. Give it the ordinary ``atom_ptr`` you already have and the
aligned cells, and it derives the packed topology, captures the reference
chart and computes ``kappa``. There is no follow-up call and no state that
needs repairing before the first step.
Ragged batches are unaffected by deriving the topology: ``atom_ptr``
already carries each system's atom count, so nothing here assumes an even
split. What went away is the caller's obligation to build
``ext_atom_ptr`` and ``ext_batch_idx`` by hand.
The cell must already be aligned -- see rule 1 of
:ref:`the variable-cell contract <lbfgs-cell-contract>`. Raw Warp setup
assumes this precondition; the Torch and JAX reference-cell setup helpers
check it before capturing the reference cell.
Parameters
----------
atom_ptr : wp.array(dtype=int32), shape (num_systems + 1,)
CSR-style atom pointer for the batch, the same array the rest of the
package takes. Read on the host once, which is what makes the derived
topology and the per-system counts available.
cell : wp.array(dtype=mat33), shape (num_systems,)
Reference lattice per system, lattice vectors in columns, aligned.
cell_force_scale : float, optional
Multiplier on the atom count in ``kappa``.
dtype : wp.vec3f or wp.vec3d, optional
Coordinate precision, ``wp.vec3f`` or ``wp.vec3d``.
device : optional
Warp device. Defaults to the device ``atom_ptr`` is on.
Returns
-------
LBFGSCellState
Complete: every field is either filled or zeroed scratch.
Raises
------
ValueError
If ``dtype`` is not a supported vector type, if ``atom_ptr`` is not a
valid pointer, or if any system has no atoms.
"""
if dtype not in _VEC_TYPES:
raise ValueError(f"dtype must be wp.vec3f or wp.vec3d; got {dtype}")
if device is None:
device = atom_ptr.device
# A host array handed to a device kernel is a segmentation fault, not an
# error -- the same failure D-39 fixed for step inputs, reachable here
# simply by passing a ``device`` that differs from the inputs. Refuse it
# rather than move the arrays: preparation allocating on one device from
# inputs on another is far more likely a mistake than an intent.
resolved = wp.get_device(device)
for name, array in (("atom_ptr", atom_ptr), ("cell", cell)):
if str(array.device) != str(resolved):
raise ValueError(
f"{name} is on {array.device}, but the state is being built on "
f"{resolved}; preparation does not move inputs between devices"
)
# One host read, at preparation. It gives the sizes to allocate and the
# per-system counts kappa needs, so a caller never assembles either.
ptr = atom_ptr.numpy().tolist()
if len(ptr) < 2:
raise ValueError(
f"atom_ptr must have num_systems + 1 >= 2 entries; got {len(ptr)}"
)
num_systems = len(ptr) - 1
num_atoms = int(ptr[-1])
counts = [int(ptr[s + 1]) - int(ptr[s]) for s in range(num_systems)]
if any(c < 0 for c in counts):
raise ValueError(f"atom_ptr must be non-decreasing; got {ptr}")
mat = _MAT_TYPES[dtype]
scalar = wp.float32 if dtype == wp.vec3f else wp.float64
num_packed = num_atoms + 2 * num_systems
def mats(n):
return wp.zeros(n, dtype=mat, device=device)
def vec(n):
return wp.zeros(n, dtype=dtype, device=device)
# Derived, not carried: both are a function of the topology alone, so they
# are rebuilt here rather than treated as state to concatenate.
ext_atom_ptr = wp.zeros(num_systems + 1, dtype=wp.int32, device=device)
extend_atom_ptr(atom_ptr, ext_atom_ptr, device=device)
ext_batch_idx = wp.zeros(num_packed, dtype=wp.int32, device=device)
atom_ptr_to_batch_idx(ext_atom_ptr, ext_batch_idx)
state = LBFGSCellState(
ref_cell=mats(num_systems),
ref_cell_inv=mats(num_systems),
kappa=wp.zeros(num_systems, dtype=scalar, device=device),
ext_batch_idx=ext_batch_idx,
ext_atom_ptr=ext_atom_ptr,
phi=mats(num_systems),
phi_inv=mats(num_systems),
d_phi=mats(num_systems),
cell_dof_a=vec(num_systems),
cell_dof_b=vec(num_systems),
cell_force_a=vec(num_systems),
cell_force_b=vec(num_systems),
ext_positions=vec(num_packed),
ext_forces=vec(num_packed),
)
state.validate(num_atoms=num_atoms)
# Values, not just shapes: affordable here because preparation runs once.
_check_packed_topology(ext_atom_ptr, ext_batch_idx, num_systems, num_packed)
n_particles = wp.array(counts, dtype=wp.int32, device=device)
_lbfgs_set_reference_cell(cell, state.ref_cell, state.ref_cell_inv)
_lbfgs_cell_kappa(n_particles, state.kappa, cell_force_scale=cell_force_scale)
return state
def _lbfgs_reduce_impl(
forces: wp.array,
direction: wp.array,
batch_idx: wp.array,
gg: wp.array,
) -> None:
"""Compute the per-system reduction for one L-BFGS call.
Only ``gg``, the squared packed force norm, which normalizes a
steepest-descent direction. It is taken over the **packed** arrays because
that is the space the search direction lives in -- on a variable-cell path
that is not Cartesian space.
Nothing Cartesian is reduced here. Convergence is the caller's, so the
quantities a tolerance would be applied to are the caller's to compute.
Normally called for you by :func:`_lbfgs_update`. Call it directly only if
you want to supply the reduction yourself, via ``compute_reductions=False``.
Parameters
----------
forces : wp.array, shape (num_dofs,)
Forces on the packed degrees of freedom.
direction : wp.array, shape (num_dofs,)
Current search direction.
batch_idx : wp.array(dtype=int32), shape (num_dofs,)
Sorted system index for each degree of freedom.
gg : wp.array, shape (num_systems,), dtype float32/float64 matching forces
OUTPUT. Zeroed internally before accumulation.
"""
n_dofs = forces.shape[0]
gg.zero_()
if n_dofs == 0:
return
device = forces.device
ept = compute_ept(n_dofs, max(device.sm_count, 1), True)
wp.launch(
_reduce_overloads[forces.dtype],
dim=(n_dofs + ept - 1) // ept,
inputs=[forces, batch_idx, gg, n_dofs, ept],
device=device,
)
def _resolve_curvature_eps(value, scalar_dtype) -> float:
"""Pick the curvature threshold, defaulting by coordinate precision.
``None`` means "choose for me"; see :data:`_CURVATURE_EPS` for why the
default cannot be a single number across both precisions.
"""
if value is not None:
return float(value)
try:
return _CURVATURE_EPS[scalar_dtype]
except KeyError: # pragma: no cover - guarded by the overload registry
raise ValueError(f"no curvature default for dtype {scalar_dtype}") from None
def _lbfgs_update_impl(
positions: wp.array,
forces: wp.array,
x_base: wp.array,
force_base: wp.array,
direction: wp.array,
batch_idx: wp.array,
s_history: wp.array,
y_history: wp.array,
ys: wp.array,
yy: wp.array,
alpha_hist: wp.array,
beta_hist: wp.array,
ss: wp.array,
gg: wp.array,
d0: wp.array,
dmax: wp.array,
dquad: wp.array,
alpha_step: wp.array,
iteration: wp.array,
end: wp.array,
n_loop: wp.array,
history_count: wp.array,
*,
maxstep: float = 0.2,
curvature_eps: float | None = None,
compute_reductions: bool = True,
measure_trust_region: bool = True,
) -> None:
"""Advance the state machine and produce a search direction.
Runs everything except the position update: the reduction, the state
machine, the history update and its curvature test, and the two-loop
recursion. There is no line search and nothing decides you have finished --
convergence is the caller's. Use it together with
:func:`_lbfgs_prepare_step` and :func:`_lbfgs_apply_step` when you need to
interpose your own logic before the atoms move; :func:`lbfgs_step` chains
all three.
Parameters
----------
positions, forces : wp.array, shape (num_dofs,)
Current geometry and the forces there. ``positions`` are read, not
moved; ``_lbfgs_apply_step`` does the moving.
x_base, force_base, direction : wp.array, shape (num_dofs,)
Last accepted point, its forces, and the current search direction.
batch_idx : wp.array(dtype=int32), shape (num_dofs,)
Sorted system index for each degree of freedom. Required.
s_history, y_history : wp.array, shape (num_dofs, history_size)
Ring buffers of position and gradient differences.
maxstep : float, optional
Largest Cartesian displacement any atom may take in one step. Set to
zero to disable the trust region.
curvature_eps : float, optional
Relative threshold below which a history pair is judged to carry no
usable curvature and 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.
compute_reductions : bool, optional
When ``False``, ``gg`` is taken as given rather than recomputed.
measure_trust_region : bool, optional
When ``False``, ``dmax`` and ``dquad`` are taken as given. Set this if
the displacement a direction produces is not simply its magnitude, as
on a variable-cell path, and supply your own measure before calling
:func:`_lbfgs_prepare_step`.
Raises
------
ValueError
If array lengths disagree or ``batch_idx`` is missing.
See Also
--------
_lbfgs_prepare_step : runs next.
lbfgs_step : chains all three phases.
"""
n_dofs = positions.shape[0]
if forces.shape[0] != n_dofs:
raise ValueError(
f"forces length {forces.shape[0]} != positions length {n_dofs}"
)
if x_base.shape[0] != n_dofs:
raise ValueError(
f"x_base length {x_base.shape[0]} != positions length {n_dofs}"
)
if force_base.shape[0] != n_dofs:
raise ValueError(
f"force_base length {force_base.shape[0]} != positions length {n_dofs}"
)
if direction.shape[0] != n_dofs:
raise ValueError(
f"direction length {direction.shape[0]} != positions length {n_dofs}"
)
if batch_idx is None:
raise ValueError("batch_idx is required for _lbfgs_update")
if batch_idx.shape[0] != n_dofs:
raise ValueError(
f"batch_idx length {batch_idx.shape[0]} != positions length {n_dofs}"
)
if s_history.shape[0] != n_dofs or y_history.shape[0] != n_dofs:
raise ValueError(
f"history buffers must have shape ({n_dofs}, history_size); got "
f"{tuple(s_history.shape)} and {tuple(y_history.shape)}"
)
if s_history.shape[1] != y_history.shape[1]:
raise ValueError("s_history and y_history must share a history size")
if n_dofs == 0:
gg.zero_()
return
m = s_history.shape[1]
if m < 1:
raise ValueError(f"history size must be >= 1; got {m}")
vec_dtype = positions.dtype
device = positions.device
num_systems = iteration.shape[0]
ept = compute_ept(n_dofs, max(device.sm_count, 1), True)
grid = (n_dofs + ept - 1) // ept
if compute_reductions:
_lbfgs_reduce_impl(forces, direction, batch_idx, gg)
wp.launch(
_step_decision_overloads[ss.dtype],
dim=num_systems,
inputs=[ys, yy, ss, iteration, end, n_loop],
device=device,
)
wp.launch(
_history_update_overloads[vec_dtype],
dim=grid,
inputs=[
positions,
forces,
x_base,
force_base,
s_history,
y_history,
batch_idx,
end,
n_loop,
ys,
yy,
ss,
n_dofs,
ept,
],
device=device,
)
wp.launch(
_history_commit_overloads[ss.dtype],
dim=num_systems,
inputs=[
ys,
yy,
ss,
end,
n_loop,
history_count,
m,
ss.dtype(_resolve_curvature_eps(curvature_eps, ss.dtype)),
], # fmt: skip
device=device,
)
# A restarting system needs its direction before the trust region can be
# measured, so seed it here rather than in the apply kernel.
# The two-loop coefficients are accumulated, so they start from zero.
alpha_hist.zero_()
beta_hist.zero_()
d0_pending = d0
# Fixed launch counts keep the sequence independent of device state, which
# is what lets the whole step be captured in a CUDA graph. Systems with
# less history than `m` return immediately.
for step in range(m + 1):
wp.launch(
_loop1_overloads[vec_dtype],
dim=grid,
inputs=[
forces,
s_history,
y_history,
direction,
batch_idx,
end,
n_loop,
ys,
yy,
alpha_hist,
beta_hist,
step,
m,
n_dofs,
ept,
],
device=device,
)
_zero_pending_d0(d0_pending, n_loop, num_systems, device)
for step in range(1, m + 1):
wp.launch(
_loop2_overloads[vec_dtype],
dim=grid,
inputs=[
s_history,
y_history,
direction,
force_base,
batch_idx,
end,
n_loop,
ys,
alpha_hist,
beta_hist,
d0,
step,
m,
n_dofs,
ept,
],
device=device,
)
# An ascent direction is demoted here -- after the two-loop that produces
# `d0`, and before the seed kernel rebuilds `direction`. Demoting any later
# would leave the rejected direction in place for the apply kernel.
wp.launch(
_restart_check_overloads[gg.dtype],
dim=num_systems,
inputs=[gg, d0, end, n_loop, history_count],
device=device,
)
wp.launch(
_seed_direction_overloads[vec_dtype],
dim=n_dofs,
inputs=[
forces,
positions,
x_base,
force_base,
direction,
batch_idx,
n_loop,
gg,
],
device=device,
)
if measure_trust_region:
dmax.zero_()
dquad.zero_()
wp.launch(
_trust_region_overloads[vec_dtype],
dim=grid,
inputs=[direction, batch_idx, dmax, dquad, n_dofs, ept],
device=device,
)
@wp.kernel(enable_backward=False)
def _lbfgs_zero_d0_kernel(
d0: wp.array(dtype=Any),
n_loop: wp.array(dtype=wp.int32),
):
"""Clear the accumulated slope for systems about to run the second loop.
Systems that are only retrying a step keep the slope from the direction
they are still searching along.
Thread launch
-------------
One thread per system.
Modifies
--------
d0
Zeroed for systems with a freshly built direction.
"""
tid = wp.tid()
if n_loop[tid] > 0:
d0[tid] = type(d0[0])(0.0)
for _v in _VEC_TYPES:
_zero_d0_overloads[_SCALAR_FOR[_v]] = wp.overload(
_lbfgs_zero_d0_kernel,
[
wp.array(dtype=_SCALAR_FOR[_v]), # d0
wp.array(dtype=_I32), # n_loop
],
)
def _zero_pending_d0(d0, n_loop, num_systems, device) -> None:
"""Zero ``d0`` only where the second loop is about to accumulate into it."""
wp.launch(
_zero_d0_overloads[d0.dtype],
dim=num_systems,
inputs=[d0, n_loop],
device=device,
)
def _lbfgs_prepare_step_impl(
gg: wp.array,
d0: wp.array,
dmax: wp.array,
dquad: wp.array,
alpha_step: wp.array,
end: wp.array,
n_loop: wp.array,
history_count: wp.array,
*,
maxstep: float = 0.2,
) -> None:
"""Finalize the step length before the atoms move.
Resets the step length, replaces
a non-descent direction with steepest descent, and caps the step so no atom
moves further than ``maxstep``.
Parameters
----------
maxstep : float, optional
Largest Cartesian displacement allowed in one step. Zero disables the
trust region.
See Also
--------
_lbfgs_update : runs before this.
_lbfgs_apply_step : runs after this.
"""
wp.launch(
_prepare_step_overloads[gg.dtype],
dim=gg.shape[0],
inputs=[
gg,
d0,
dmax,
dquad,
alpha_step,
end,
n_loop,
history_count,
gg.dtype(maxstep),
], # fmt: skip
device=gg.device,
)
def _lbfgs_apply_step_impl(
positions: wp.array,
forces: wp.array,
x_base: wp.array,
force_base: wp.array,
direction: wp.array,
batch_idx: wp.array,
n_loop: wp.array,
gg: wp.array,
alpha_step: wp.array,
) -> None:
"""Move the positions to the next trial point.
Every system moves: there is no terminal case, because the optimizer has
no terminal state.
See Also
--------
_lbfgs_prepare_step : runs before this.
"""
n_dofs = positions.shape[0]
if n_dofs == 0:
return
wp.launch(
_apply_step_overloads[positions.dtype],
dim=n_dofs,
inputs=[
positions,
forces,
x_base,
force_base,
direction,
batch_idx,
n_loop,
gg,
alpha_step,
],
device=positions.device,
)
def _lbfgs_step_impl(
positions: wp.array,
forces: wp.array,
x_base: wp.array,
force_base: wp.array,
direction: wp.array,
batch_idx: wp.array,
s_history: wp.array,
y_history: wp.array,
ys: wp.array,
yy: wp.array,
alpha_hist: wp.array,
beta_hist: wp.array,
ss: wp.array,
gg: wp.array,
d0: wp.array,
dmax: wp.array,
dquad: wp.array,
alpha_step: wp.array,
iteration: wp.array,
end: wp.array,
n_loop: wp.array,
history_count: wp.array,
*,
maxstep: float = 0.2,
curvature_eps: float | None = None,
compute_reductions: bool = True,
) -> None:
"""Consume one energy/force evaluation and produce the next trial geometry.
This is the entry point for the common case. Call it once per model
evaluation. It never decides you are finished -- there is no terminal
state and every system takes a step on every call, so testing convergence
and stopping the loop are yours, exactly as they are for FIRE2.
Equivalent to :func:`_lbfgs_update`, :func:`_lbfgs_prepare_step` and
:func:`_lbfgs_apply_step` in sequence.
Parameters
----------
See :func:`_lbfgs_update`; the arguments are identical.
Examples
--------
>>> for _ in range(max_steps):
... forces = model(positions)
... if np.linalg.norm(forces.numpy(), axis=1).max() < force_tol:
... break
... lbfgs_step(positions, forces, state, batch_idx, maxstep=0.2)
"""
_lbfgs_update_impl(
positions=positions,
forces=forces,
x_base=x_base,
force_base=force_base,
direction=direction,
batch_idx=batch_idx,
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,
compute_reductions=compute_reductions,
)
if positions.shape[0] == 0:
return
_lbfgs_prepare_step_impl(
gg=gg,
d0=d0,
dmax=dmax,
dquad=dquad,
alpha_step=alpha_step,
end=end,
n_loop=n_loop,
history_count=history_count,
maxstep=maxstep,
)
_lbfgs_apply_step_impl(
positions=positions,
forces=forces,
x_base=x_base,
force_base=force_base,
direction=direction,
batch_idx=batch_idx,
n_loop=n_loop,
gg=gg,
alpha_step=alpha_step,
)
# =============================================================================
# Variable-cell support
#
# The chart is ASE's UnitCellFilter. Concatenating raw positions with raw cell
# rows would be wrong: the stress-derived cell force is conjugate to an affine
# deformation the atoms ride along with, so the (s, y) pairs would mix spaces.
# With H0 captured once and lattice vectors as columns (r = H s):
#
# Phi = H H0^-1 deformation gradient, identity at the start
# u = Phi^-1 r atom coordinates in the reference frame
# c = kappa * Phi cell coordinates, the six aligned components
# f_u = Phi^T F
# f_c = -(V sigma) Phi^-T / kappa
#
# Scaling the cell coordinate by kappa and dividing its force by the same
# factor keeps g . dx chart-independent, which is what makes the packed pairs
# genuine secant pairs. The two-loop recursion needs no changes.
# =============================================================================
@wp.kernel(enable_backward=False)
def _lbfgs_cell_kappa_kernel(
n_atoms_per_system: wp.array(dtype=wp.int32),
cell_force_scale: Any,
kappa: wp.array(dtype=Any),
):
"""Precompute the per-system cell coordinate scaling.
``kappa`` is stored at the coordinate precision so the packing kernels can
scale matrices with it directly.
Thread launch
-------------
One thread per system.
Modifies
--------
kappa
OUTPUT. ``cell_force_scale * num_atoms``.
"""
tid = wp.tid()
slot = kappa[tid]
# No clamp: a system with no atoms has no cell scale, and inventing one
# would make an unsupported configuration look valid. Empty systems are
# rejected by :func:`_lbfgs_cell_kappa` before this ever runs.
count = n_atoms_per_system[tid]
kappa[tid] = type(slot)(cell_force_scale) * type(slot)(count)
@wp.kernel(enable_backward=False)
def _lbfgs_cell_chart_kernel(
cell: wp.array(dtype=Any),
ref_cell_inv: wp.array(dtype=Any),
stress: wp.array(dtype=Any),
kappa: wp.array(dtype=Any),
phi: wp.array(dtype=Any),
phi_inv: wp.array(dtype=Any),
cell_dof_a: wp.array(dtype=Any),
cell_dof_b: wp.array(dtype=Any),
cell_force_a: wp.array(dtype=Any),
cell_force_b: wp.array(dtype=Any),
have_stress: wp.bool,
):
"""Build the deformation gradient and the packed cell degrees of freedom.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
phi, phi_inv
The deformation gradient ``Phi = H H0^-1`` and its inverse, reused by
the packing, unpacking and trust-region kernels.
cell_dof_a, cell_dof_b, cell_force_a, cell_force_b
The six cell coordinates and their six conjugate force components,
split into two three-vectors each to match the packed layout.
"""
tid = wp.tid()
h = cell[tid]
k = kappa[tid]
p = h * ref_cell_inv[tid]
phi[tid] = p
p_inv = wp.inverse(p)
phi_inv[tid] = p_inv
dof_a = cell_dof_a[tid]
dof_b = cell_dof_b[tid]
dof_a[0] = k * p[0, 0]
dof_a[1] = k * p[1, 0]
dof_a[2] = k * p[2, 0]
dof_b[0] = k * p[1, 1]
dof_b[1] = k * p[2, 1]
dof_b[2] = k * p[2, 2]
cell_dof_a[tid] = dof_a
cell_dof_b[tid] = dof_b
f_a = cell_force_a[tid] - cell_force_a[tid]
f_b = cell_force_b[tid] - cell_force_b[tid]
if have_stress:
# f_c = -(V sigma) Phi^-T / kappa
volume = wp.abs(wp.determinant(h))
force = (-volume / k) * (stress[tid] * wp.transpose(p_inv))
f_a[0] = force[0, 0]
f_a[1] = force[1, 0]
f_a[2] = force[2, 0]
f_b[0] = force[1, 1]
f_b[1] = force[2, 1]
f_b[2] = force[2, 2]
cell_force_a[tid] = f_a
cell_force_b[tid] = f_b
@wp.kernel(enable_backward=False)
def _lbfgs_pack_kernel(
positions: wp.array(dtype=Any),
forces: wp.array(dtype=Any),
phi: wp.array(dtype=Any),
phi_inv: wp.array(dtype=Any),
cell_dof_a: wp.array(dtype=Any),
cell_dof_b: wp.array(dtype=Any),
cell_force_a: wp.array(dtype=Any),
cell_force_b: wp.array(dtype=Any),
ext_batch_idx: wp.array(dtype=wp.int32),
ext_atom_ptr: wp.array(dtype=wp.int32),
ext_positions: wp.array(dtype=Any),
ext_forces: wp.array(dtype=Any),
):
"""Map Cartesian positions and forces into the packed chart.
The extended array interleaves each system's atoms with its two cell
entries, so a single sorted index array covers both and every per-system
reduction picks up the coupled atom-and-cell inner product for free.
Thread launch
-------------
One thread per extended degree of freedom; ``dim = num_atoms + 2 * num_systems``.
Modifies
--------
ext_positions, ext_forces
OUTPUT. The packed coordinates and their conjugate forces.
"""
tid = wp.tid()
s = ext_batch_idx[tid]
cell_start = ext_atom_ptr[s + 1] - wp.int32(2)
if tid >= cell_start:
if tid == cell_start:
ext_positions[tid] = cell_dof_a[s]
ext_forces[tid] = cell_force_a[s]
else:
ext_positions[tid] = cell_dof_b[s]
ext_forces[tid] = cell_force_b[s]
return
atom_i = tid - wp.int32(2) * s
ext_positions[tid] = phi_inv[s] * positions[atom_i]
ext_forces[tid] = wp.transpose(phi[s]) * forces[atom_i]
@wp.kernel(enable_backward=False)
def _lbfgs_unpack_cell_kernel(
ext_positions: wp.array(dtype=Any),
ref_cell: wp.array(dtype=Any),
ext_atom_ptr: wp.array(dtype=wp.int32),
kappa: wp.array(dtype=Any),
phi: wp.array(dtype=Any),
cell: wp.array(dtype=Any),
):
"""Rebuild the cell from its packed coordinates.
Only six components are stored -- the same six FIRE2 packs -- so the cell
keeps its aligned form by construction and cannot drift into a rotation.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
phi
The updated deformation gradient, reused by the atom unpacking.
cell
OUTPUT. ``H = Phi H0``.
"""
tid = wp.tid()
h0 = ref_cell[tid]
k = kappa[tid]
cell_start = ext_atom_ptr[tid + 1] - wp.int32(2)
va = ext_positions[cell_start] / k
vb = ext_positions[cell_start + 1] / k
p = h0 - h0 # a zero matrix at the right precision
p[0, 0] = va[0]
p[1, 0] = va[1]
p[2, 0] = va[2]
p[1, 1] = vb[0]
p[2, 1] = vb[1]
p[2, 2] = vb[2]
phi[tid] = p
cell[tid] = p * h0
@wp.kernel(enable_backward=False)
def _lbfgs_unpack_atoms_kernel(
ext_positions: wp.array(dtype=Any),
phi: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
positions: wp.array(dtype=Any),
):
"""Map packed atom coordinates back to Cartesian positions, ``r = Phi u``.
Thread launch
-------------
One thread per atom; ``dim = num_atoms``.
Modifies
--------
positions
OUTPUT. Cartesian positions.
"""
atom_i = wp.tid()
s = batch_idx[atom_i]
positions[atom_i] = phi[s] * ext_positions[atom_i + 2 * s]
@wp.kernel(enable_backward=False)
def _lbfgs_cell_direction_kernel(
direction: wp.array(dtype=Any),
ext_atom_ptr: wp.array(dtype=wp.int32),
kappa: wp.array(dtype=Any),
d_phi: wp.array(dtype=Any),
):
"""Rebuild the cell part of the search direction as a matrix.
Thread launch
-------------
One thread per system; ``dim = num_systems``.
Modifies
--------
d_phi
The direction's cell block, undone by ``kappa`` so it is a change in
the deformation gradient rather than in the scaled coordinate.
"""
tid = wp.tid()
k = kappa[tid]
cell_start = ext_atom_ptr[tid + 1] - wp.int32(2)
va = direction[cell_start] / k
vb = direction[cell_start + 1] / k
d = d_phi[tid] - d_phi[tid] # a zero matrix at the right precision
d[0, 0] = va[0]
d[1, 0] = va[1]
d[2, 0] = va[2]
d[1, 1] = vb[0]
d[2, 1] = vb[1]
d[2, 2] = vb[2]
d_phi[tid] = d
@wp.kernel(enable_backward=False)
def _lbfgs_cell_trust_region_kernel(
ext_positions: wp.array(dtype=Any),
direction: wp.array(dtype=Any),
phi: wp.array(dtype=Any),
d_phi: wp.array(dtype=Any),
batch_idx: wp.array(dtype=wp.int32),
dmax: wp.array(dtype=Any),
dquad: wp.array(dtype=Any),
):
"""Measure the Cartesian displacement a variable-cell step produces.
Both the cell and the coordinates move, so the displacement of an atom is
dr = alpha * (Phi d_u + d_Phi u) + alpha**2 * (d_Phi d_u)
which is quadratic in the step length, not linear. Reducing the largest
magnitude of each term separately lets the step cap be solved in closed
form; see :func:`_alpha_cap`. Only atoms are measured, because ``maxstep``
is a limit on how far an atom may move.
Thread launch
-------------
One thread per atom; ``dim = num_atoms``.
Modifies
--------
dmax, dquad
OUTPUT. Zeroed by the launcher, then accumulated with atomic maxima.
"""
atom_i = wp.tid()
s = batch_idx[atom_i]
e = atom_i + 2 * s
d_u = direction[e]
u = ext_positions[e]
linear = phi[s] * d_u + d_phi[s] * u
quadratic = d_phi[s] * d_u
wp.atomic_max(dmax, s, type(dmax[0])(wp.length(linear)))
wp.atomic_max(dquad, s, type(dmax[0])(wp.length(quadratic)))
_MAT_TYPES = {wp.vec3f: wp.mat33f, wp.vec3d: wp.mat33d}
_cell_kappa_overloads = {}
_cell_chart_overloads = {}
_pack_overloads = {}
_unpack_cell_overloads = {}
_unpack_atoms_overloads = {}
_cell_direction_overloads = {}
_cell_trust_region_overloads = {}
_SCALAR_OF = _SCALAR_FOR
for _v, _mt in _MAT_TYPES.items():
_sc = _SCALAR_OF[_v]
_cell_kappa_overloads[_v] = wp.overload(
_lbfgs_cell_kappa_kernel,
[
wp.array(dtype=_I32), # n_atoms_per_system
_sc, # cell_force_scale
wp.array(dtype=_sc), # kappa
],
)
_cell_chart_overloads[_v] = wp.overload(
_lbfgs_cell_chart_kernel,
[
wp.array(dtype=_mt), # cell
wp.array(dtype=_mt), # ref_cell_inv
wp.array(dtype=_mt), # stress
wp.array(dtype=_sc), # kappa
wp.array(dtype=_mt), # phi
wp.array(dtype=_mt), # phi_inv
wp.array(dtype=_v), # cell_dof_a
wp.array(dtype=_v), # cell_dof_b
wp.array(dtype=_v), # cell_force_a
wp.array(dtype=_v), # cell_force_b
wp.bool, # have_stress
],
)
_pack_overloads[_v] = wp.overload(
_lbfgs_pack_kernel,
[
wp.array(dtype=_v), # positions
wp.array(dtype=_v), # forces
wp.array(dtype=_mt), # phi
wp.array(dtype=_mt), # phi_inv
wp.array(dtype=_v), # cell_dof_a
wp.array(dtype=_v), # cell_dof_b
wp.array(dtype=_v), # cell_force_a
wp.array(dtype=_v), # cell_force_b
wp.array(dtype=_I32), # ext_batch_idx
wp.array(dtype=_I32), # ext_atom_ptr
wp.array(dtype=_v), # ext_positions
wp.array(dtype=_v), # ext_forces
],
)
_unpack_cell_overloads[_v] = wp.overload(
_lbfgs_unpack_cell_kernel,
[
wp.array(dtype=_v), # ext_positions
wp.array(dtype=_mt), # ref_cell
wp.array(dtype=_I32), # ext_atom_ptr
wp.array(dtype=_sc), # kappa
wp.array(dtype=_mt), # phi
wp.array(dtype=_mt), # cell
],
)
_unpack_atoms_overloads[_v] = wp.overload(
_lbfgs_unpack_atoms_kernel,
[
wp.array(dtype=_v), # ext_positions
wp.array(dtype=_mt), # phi
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_v), # positions
],
)
_cell_direction_overloads[_v] = wp.overload(
_lbfgs_cell_direction_kernel,
[
wp.array(dtype=_v), # direction
wp.array(dtype=_I32), # ext_atom_ptr
wp.array(dtype=_sc), # kappa
wp.array(dtype=_mt), # d_phi
],
)
_cell_trust_region_overloads[_v] = wp.overload(
_lbfgs_cell_trust_region_kernel,
[
wp.array(dtype=_v), # ext_positions
wp.array(dtype=_v), # direction
wp.array(dtype=_mt), # phi
wp.array(dtype=_mt), # d_phi
wp.array(dtype=_I32), # batch_idx
wp.array(dtype=_sc), # dmax
wp.array(dtype=_sc), # dquad
],
)
def _lbfgs_set_reference_cell(
cell: wp.array,
ref_cell: wp.array,
ref_cell_inv: wp.array,
) -> None:
"""Capture the reference cell that defines the variable-cell chart.
The generalized coordinates are measured relative to a cell ``H0`` that is
fixed for the whole relaxation, which is what makes history pairs recorded
at different iterations comparable. Call this once before the first step.
Calling it again re-references the chart and invalidates every stored
``(s, y)`` pair, so restore the optimizer buffers to their required initial
contents if you do.
The reference cell is deliberately not part of that reset: zeroing it, as
the other buffers are zeroed, would leave a singular matrix.
Parameters
----------
cell : wp.array(dtype=mat33), shape (num_systems,)
Current cell, lattice vectors as columns, already aligned by
:func:`~nvalchemiops.dynamics.utils.cell_filter.align_cell`. This raw
Warp helper assumes that precondition and does not check it.
ref_cell, ref_cell_inv : wp.array(dtype=mat33), shape (num_systems,)
OUTPUT. ``H0`` and its inverse.
See Also
--------
:ref:`The variable-cell contract <lbfgs-cell-contract>` : why the reference
is fixed, and what calling this a second time costs.
"""
wp.copy(ref_cell, cell)
compute_cell_inverse(cell, ref_cell_inv, device=str(cell.device))
def _lbfgs_cell_kappa(
n_atoms_per_system: wp.array,
kappa: wp.array,
*,
cell_force_scale: float = 1.0,
) -> None:
"""Fill 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. Larger values make the cell move less per step relative to the
atoms; the usual choice, and the default here, is the atom count.
Depends only on topology, so compute it once and reuse it.
Parameters
----------
n_atoms_per_system : wp.array(dtype=int32), shape (num_systems,)
Atom count per system.
kappa : wp.array, shape (num_systems,)
OUTPUT. Must match the coordinate precision (float32 or float64).
cell_force_scale : float, optional
Multiplier on the atom count.
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; see :func:`_check_no_empty_cell_systems`. If you fill
``kappa`` yourself rather than calling this, keep every entry strictly
positive.
"""
if cell_force_scale <= 0.0:
raise ValueError(f"cell_force_scale must be positive; got {cell_force_scale}")
_check_no_empty_cell_systems(n_atoms_per_system)
vec = wp.vec3f if kappa.dtype == wp.float32 else wp.vec3d
wp.launch(
_cell_kappa_overloads[vec],
dim=kappa.shape[0],
inputs=[n_atoms_per_system, float(cell_force_scale), kappa],
device=kappa.device,
)
def _lbfgs_pack_cell(
positions: wp.array,
forces: wp.array,
cell: wp.array,
stress: wp.array | None,
ref_cell_inv: wp.array,
kappa: wp.array,
ext_batch_idx: wp.array,
ext_atom_ptr: wp.array,
phi: wp.array,
phi_inv: wp.array,
cell_dof_a: wp.array,
cell_dof_b: wp.array,
cell_force_a: wp.array,
cell_force_b: wp.array,
ext_positions: wp.array,
ext_forces: wp.array,
) -> None:
"""Map Cartesian positions, forces, cell and stress into the packed chart.
Parameters
----------
positions, forces : wp.array, shape (num_atoms,)
Cartesian geometry and forces.
cell : wp.array(dtype=mat33), shape (num_systems,)
Current cell, lattice vectors as columns.
stress : wp.array(dtype=mat33) or None
Cauchy stress per system. Without it the cell degrees of freedom get
zero force and the cell will not move.
ref_cell_inv : wp.array(dtype=mat33), shape (num_systems,)
Inverse of the reference cell, from :func:`_lbfgs_set_reference_cell`.
ext_positions, ext_forces : wp.array, shape (num_atoms + 2 * num_systems,)
OUTPUT. The packed arrays the optimizer works on.
kappa : wp.array, shape (num_systems,)
Cell coordinate scaling, from :func:`_lbfgs_cell_kappa`.
See Also
--------
_lbfgs_unpack_cell : the inverse mapping.
"""
device = positions.device
vec_dtype = positions.dtype
num_systems = cell.shape[0]
have_stress = stress is not None
stress_arg = stress if have_stress else cell # unread when have_stress is False
wp.launch(
_cell_chart_overloads[vec_dtype],
dim=num_systems,
inputs=[
cell,
ref_cell_inv,
stress_arg,
kappa,
phi,
phi_inv,
cell_dof_a,
cell_dof_b,
cell_force_a,
cell_force_b,
have_stress,
],
device=device,
)
wp.launch(
_pack_overloads[vec_dtype],
dim=ext_positions.shape[0],
inputs=[
positions,
forces,
phi,
phi_inv,
cell_dof_a,
cell_dof_b,
cell_force_a,
cell_force_b,
ext_batch_idx,
ext_atom_ptr,
ext_positions,
ext_forces,
],
device=device,
)
def _lbfgs_unpack_cell(
ext_positions: wp.array,
ref_cell: wp.array,
kappa: wp.array,
batch_idx: wp.array,
ext_atom_ptr: wp.array,
phi: wp.array,
positions: wp.array,
cell: wp.array,
) -> None:
"""Map packed coordinates back to Cartesian positions and a cell.
Parameters
----------
ext_positions : wp.array, shape (num_atoms + 2 * num_systems,)
Packed coordinates, as advanced by :func:`lbfgs_step`.
ref_cell : wp.array(dtype=mat33), shape (num_systems,)
The reference cell, from :func:`_lbfgs_set_reference_cell`.
positions, cell : wp.array
OUTPUT. Cartesian positions and the updated cell.
See Also
--------
_lbfgs_pack_cell : the forward mapping.
"""
device = ext_positions.device
vec_dtype = ext_positions.dtype
wp.launch(
_unpack_cell_overloads[vec_dtype],
dim=cell.shape[0],
inputs=[
ext_positions,
ref_cell,
ext_atom_ptr,
kappa,
phi,
cell,
],
device=device,
)
wp.launch(
_unpack_atoms_overloads[vec_dtype],
dim=positions.shape[0],
inputs=[ext_positions, phi, batch_idx, positions],
device=device,
)
def _lbfgs_cell_trust_region(
ext_positions: wp.array,
direction: wp.array,
phi: wp.array,
d_phi: wp.array,
batch_idx: wp.array,
ext_atom_ptr: wp.array,
kappa: wp.array,
dmax: wp.array,
dquad: wp.array,
) -> None:
"""Measure the Cartesian displacement a variable-cell direction produces.
Call this between :func:`_lbfgs_update` (with ``measure_trust_region=False``)
and :func:`_lbfgs_prepare_step`. The displacement is quadratic in the step
length because the cell and the coordinates both move, so both the linear
and quadratic terms are measured and the cap is solved in closed form.
Modifies
--------
d_phi
Scratch holding the direction's cell block as a matrix.
dmax, dquad
OUTPUT. The linear and quadratic displacement bounds.
"""
device = ext_positions.device
vec_dtype = ext_positions.dtype
dmax.zero_()
dquad.zero_()
wp.launch(
_cell_direction_overloads[vec_dtype],
dim=d_phi.shape[0],
inputs=[
direction,
ext_atom_ptr,
kappa,
d_phi,
],
device=device,
)
wp.launch(
_cell_trust_region_overloads[vec_dtype],
dim=batch_idx.shape[0],
inputs=[
ext_positions,
direction,
phi,
d_phi,
batch_idx,
dmax,
dquad,
],
device=device,
)
def _lbfgs_step_coord_cell_impl(
positions: wp.array,
forces: wp.array,
cell: wp.array,
stress: wp.array,
batch_idx: wp.array,
x_base: wp.array,
force_base: wp.array,
direction: wp.array,
s_history: wp.array,
y_history: wp.array,
ys: wp.array,
yy: wp.array,
alpha_hist: wp.array,
beta_hist: wp.array,
ss: wp.array,
gg: wp.array,
d0: wp.array,
dmax: wp.array,
dquad: wp.array,
alpha_step: wp.array,
iteration: wp.array,
end: wp.array,
n_loop: wp.array,
history_count: wp.array,
ref_cell: wp.array,
ref_cell_inv: wp.array,
kappa: wp.array,
ext_batch_idx: wp.array,
ext_atom_ptr: wp.array,
phi: wp.array,
phi_inv: wp.array,
d_phi: wp.array,
cell_dof_a: wp.array,
cell_dof_b: wp.array,
cell_force_a: wp.array,
cell_force_b: wp.array,
ext_positions: wp.array,
ext_forces: wp.array,
*,
maxstep: float = 0.2,
curvature_eps: float | None = None,
) -> None:
"""Advance one variable-cell step, relaxing coordinates and cell together.
Maps the geometry into the packed chart, takes one L-BFGS step there, and
maps the result back. Because both blocks live in one coordinate vector,
the two-loop recursion couples them without any special handling.
Consumes exactly one force/stress evaluation, like the coordinate-only
:func:`lbfgs_step`, and like it has no terminal state: deciding when the
forces and stress are small enough is yours.
Align the cell before the first step and keep the reference fixed for the
whole relaxation; build ``ext_batch_idx`` / ``ext_atom_ptr`` yourself so
ragged batches stay expressible. All of that is stated once in
:ref:`the variable-cell contract <lbfgs-cell-contract>`, which this
function follows rather than restates.
Parameters
----------
positions : wp.array, shape (num_atoms,)
Cartesian geometry, advanced in place.
forces : wp.array, shape (num_atoms,)
Cartesian forces at ``positions``. Forces, not gradients.
cell : wp.array(dtype=mat33), shape (num_systems,)
Cell with lattice vectors as columns, advanced in place. Must be
aligned first; see :ref:`the variable-cell contract <lbfgs-cell-contract>`.
stress : wp.array(dtype=mat33), shape (num_systems,)
Cauchy stress per system, which drives the cell degrees of freedom.
batch_idx : wp.array(dtype=int32), shape (num_atoms,)
Sorted system index per atom.
x_base, ..., history_count
The optimizer buffers, sized for ``num_atoms + 2 * num_systems``
degrees of freedom rather than ``num_atoms``. See :func:`_lbfgs_update`
for shapes and required initial contents.
ref_cell, ref_cell_inv : wp.array(dtype=mat33), shape (num_systems,)
The reference cell and its inverse, from
:func:`_lbfgs_set_reference_cell`. Read only.
kappa : wp.array, shape (num_systems,)
Cell coordinate scaling, from :func:`_lbfgs_cell_kappa`. Read only, and
at the coordinate precision rather than float64.
ext_batch_idx : wp.array(dtype=int32), shape (num_atoms + 2 * num_systems,)
Sorted system index for each packed degree of freedom. Read only.
ext_atom_ptr : wp.array(dtype=int32), shape (num_systems + 1,)
Start offset of each system in the packed array, where system ``s``
begins at ``atom_ptr[s] + 2 * s``. Read only.
phi, phi_inv, d_phi : wp.array(dtype=mat33), shape (num_systems,)
Scratch for the deformation gradient and the direction's cell block.
cell_dof_a, cell_dof_b, cell_force_a, cell_force_b : wp.array, shape (num_systems,)
Scratch for the six packed cell coordinates and their conjugate forces.
ext_positions, ext_forces : wp.array, shape (num_atoms + 2 * num_systems,)
Scratch for the packed coordinates and forces.
maxstep : float, optional
Largest Cartesian distance an atom may move in one step. On this path
the displacement is quadratic in the step length, because the cell and
the coordinates both move, so the cap is solved rather than estimated.
See Also
--------
_lbfgs_set_reference_cell : must be called first.
_lbfgs_cell_kappa : must be called first.
lbfgs_step : the coordinate-only equivalent.
"""
_lbfgs_pack_cell(
positions,
forces,
cell,
stress,
ref_cell_inv,
kappa,
ext_batch_idx,
ext_atom_ptr,
phi,
phi_inv,
cell_dof_a,
cell_dof_b,
cell_force_a,
cell_force_b,
ext_positions,
ext_forces,
)
_lbfgs_update_impl(
positions=ext_positions,
forces=ext_forces,
batch_idx=ext_batch_idx,
measure_trust_region=False,
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,
)
# The displacement a direction produces is not its magnitude here, so the
# trust region gets its own measure before the step length is finalized.
_lbfgs_cell_trust_region(
ext_positions,
direction,
phi,
d_phi,
batch_idx,
ext_atom_ptr,
kappa,
dmax,
dquad,
)
_lbfgs_prepare_step_impl(
gg=gg,
d0=d0,
dmax=dmax,
dquad=dquad,
alpha_step=alpha_step,
end=end,
n_loop=n_loop,
history_count=history_count,
maxstep=maxstep,
)
_lbfgs_apply_step_impl(
positions=ext_positions,
forces=ext_forces,
x_base=x_base,
force_base=force_base,
direction=direction,
batch_idx=ext_batch_idx,
n_loop=n_loop,
gg=gg,
alpha_step=alpha_step,
)
_lbfgs_unpack_cell(
ext_positions,
ref_cell,
kappa,
batch_idx,
ext_atom_ptr,
phi,
positions,
cell,
)
# =============================================================================
# Public API: the phases, driven by the state objects
# =============================================================================
def _arrays(state) -> dict:
"""The dataclass's arrays keyed by field name, without copying anything.
Passed through by keyword rather than position: the internal launchers
interleave the buffers with the topology arrays, so a positional splat
would silently misalign them.
"""
return {f.name: getattr(state, f.name) for f in dataclasses.fields(state)}
def _check_no_empty_cell_systems(n_atoms_per_system) -> None:
"""Reject variable-cell systems that contain no atoms.
``kappa`` scales the cell coordinate against the atomic ones and is divided
into the cell force, so a system with no atoms has no scale to give it.
Substituting one would invent a number with no physical basis and make an
unsupported configuration look valid, so this rejects instead.
The coordinate-only path is different and remains supported: zero degrees
of freedom there is simply a no-op, with no cell to scale against. See
:ref:`the variable-cell contract <lbfgs-cell-contract>`.
Reads the counts back to the host, so this is a setup-time check. It runs
from :func:`_lbfgs_cell_kappa`, which is called once.
Raises
------
ValueError
If any system has no atoms.
"""
import numpy as _np
counts = _np.asarray(n_atoms_per_system.numpy())
empty = _np.flatnonzero(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.)"
)
def _check_packed_topology(
ext_atom_ptr, ext_batch_idx, num_systems, num_packed
) -> None:
"""Confirm the packed topology's *values*, not just its shapes.
:meth:`LBFGSCellState.validate` deliberately stops at shapes, because it
runs on every step and reading these arrays means a device-to-host sync.
The values still have to be right -- every kernel on the cell path indexes
the packed array through them -- so they are checked here instead, at
preparation, which runs once.
Checks that ``ext_atom_ptr`` starts at zero, ends at the packed size, never
decreases, leaves room for each system's two cell rows, and that
``ext_batch_idx`` is exactly the system index it implies.
Raises
------
ValueError
If the topology could not have come from ``extend_atom_ptr`` and
``atom_ptr_to_batch_idx``.
"""
import numpy as _np
ptr = _np.asarray(ext_atom_ptr.numpy()).astype(_np.int64)
idx = _np.asarray(ext_batch_idx.numpy()).astype(_np.int64)
if ptr[0] != 0 or ptr[-1] != num_packed:
raise ValueError(
f"ext_atom_ptr must run from 0 to {num_packed}; got {ptr[0]} to "
f"{ptr[-1]}. Build it with extend_atom_ptr(atom_ptr, ext_atom_ptr)."
)
spans = _np.diff(ptr)
if (spans < 0).any():
raise ValueError(f"ext_atom_ptr must be non-decreasing; got {ptr.tolist()}")
short = _np.flatnonzero(spans < 2)
if short.size:
raise ValueError(
f"system(s) {short.tolist()} span fewer than 2 packed entries, but "
"every system owns two cell rows besides its atoms"
)
# Exactly two entries means the two cell rows and no atoms.
atomless = _np.flatnonzero(spans == 2)
if atomless.size:
raise ValueError(
f"system(s) {atomless.tolist()} contain 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 them "
"from the batch."
)
expected = _np.repeat(_np.arange(num_systems, dtype=_np.int64), spans)
if not _np.array_equal(idx, expected):
wrong = int(_np.flatnonzero(idx != expected)[0])
raise ValueError(
f"ext_batch_idx disagrees with ext_atom_ptr at entry {wrong}: "
f"got system {idx[wrong]}, expected {expected[wrong]}. Build it "
"with atom_ptr_to_batch_idx(ext_atom_ptr, ext_batch_idx)."
)
def _check_against_state(state, coordinates=(), indices=(), extra_states=()) -> None:
"""Confirm this call's arrays agree with the prepared state.
Agreeing with each other is not enough. An array whose precision matches
its neighbours but not the state still reaches a kernel typed for the
state, where it fails as a launch error -- or, when the mismatch is the
*device* rather than the dtype, as a segmentation fault.
Shared by all three layers rather than reimplemented per binding:
:func:`_precision_of` maps Warp's ``vec3d``/``mat33d``/``float64`` onto one
scalar and passes PyTorch and JAX dtypes through unchanged, so one
comparison covers them all. Dtypes and devices only -- no device reads.
Parameters
----------
state : LBFGSState
The prepared state defining the expected precision and device.
coordinates : sequence of (str, array)
Arrays carrying the coordinate precision: positions, forces, and on
the variable-cell path the cell and stress.
indices : sequence of (str, array)
Arrays carrying the integer topology, such as ``batch_idx``.
extra_states : sequence of (str, state)
Further states that must agree, such as an :class:`LBFGSCellState`.
Raises
------
ValueError
If any array or state disagrees with ``state``.
"""
want_float = _precision_of(state.x_base.dtype)
want_int = state.iteration.dtype
for name, array in coordinates:
if _precision_of(array.dtype) != want_float:
raise ValueError(
f"{name} has precision {array.dtype}, but the state was "
f"prepared for {state.x_base.dtype}"
)
for name, array in indices:
if array.dtype != want_int:
raise ValueError(
f"{name} has dtype {array.dtype}, but the state's integer "
f"fields are {want_int}"
)
for label, other in extra_states:
if _precision_of(other.ref_cell.dtype) != want_float:
raise ValueError(
f"{label} holds {other.ref_cell.dtype} but the state was "
f"prepared for {state.x_base.dtype}"
)
# A host array handed to a device kernel is a segfault, not an error.
want_device = getattr(state.x_base, "device", None)
if want_device is None: # a JAX tracer exposes no device
return
for name, array in tuple(coordinates) + tuple(indices):
device = getattr(array, "device", None)
if device is not None and str(device) != str(want_device):
raise ValueError(
f"{name} is on {device}, but the state is on {want_device}"
)
for label, other in extra_states:
device = getattr(other.ref_cell, "device", None)
if device is not None and str(device) != str(want_device):
raise ValueError(
f"{label} is on {device}, but the state is on {want_device}"
)
def _check_inputs(positions, forces, batch_idx, state: LBFGSState) -> None:
"""Confirm this call's inputs match the state that was prepared.
Shapes only -- no device reads, so this costs nothing next to the launches
that follow. The state re-checks itself too: it is a plain dataclass whose
fields can be reassigned between steps, and a history buffer that no longer
matches would read out of bounds inside a kernel rather than raise.
"""
state.validate()
_check_against_state(
state,
coordinates=(("positions", positions), ("forces", forces)),
indices=(("batch_idx", batch_idx),),
)
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 forces.shape[0] != positions.shape[0]:
raise ValueError(
f"forces has {forces.shape[0]} entries, positions has {positions.shape[0]}"
)
if batch_idx.shape[0] != positions.shape[0]:
raise ValueError(
f"batch_idx has {batch_idx.shape[0]} entries, positions has "
f"{positions.shape[0]}"
)
def _check_cell_inputs(positions, forces, cell, stress, batch_idx, state, cell_state):
"""Confirm this call's inputs match both prepared states.
The variable-cell counterpart of :func:`_check_inputs`, and as thorough:
the entry point is public, both states are plain dataclasses whose fields
can be reassigned between steps, and the kernels here index a *packed*
array built from three separate topologies. Shapes and dtypes only -- no
device reads -- so it costs nothing next to the launches that follow.
This does not reuse :func:`_check_inputs`: there ``positions`` spans the
whole degree-of-freedom vector, whereas here it holds atoms only and the
state is sized for ``num_atoms + 2 * num_systems``.
"""
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 state.num_dofs != cell_state.num_packed_dofs:
raise ValueError(
f"state is sized for {state.num_dofs} degrees of freedom but the "
f"cell state is packed for {cell_state.num_packed_dofs}"
)
expected = positions.shape[0] + 2 * num_systems
if expected != state.num_dofs:
raise ValueError(
f"{positions.shape[0]} atoms in {num_systems} systems needs "
f"{expected} degrees of freedom, but the state has {state.num_dofs}"
)
if forces.shape[0] != positions.shape[0]:
raise ValueError(
f"forces has {forces.shape[0]} entries, positions has {positions.shape[0]}"
)
if batch_idx.shape[0] != positions.shape[0]:
raise ValueError(
f"batch_idx has {batch_idx.shape[0]} entries, positions has "
f"{positions.shape[0]}"
)
for name, array in (("cell", cell), ("stress", stress)):
if array.shape[0] != num_systems:
raise ValueError(
f"{name} has {array.shape[0]} entries, but there are "
f"{num_systems} systems"
)
# The packed arrays are written from the Cartesian ones, so a precision
# mismatch would surface as a kernel launch failure rather than as this.
if forces.dtype != positions.dtype:
raise ValueError(
f"forces dtype {forces.dtype} != positions dtype {positions.dtype}"
)
if cell.dtype != stress.dtype:
raise ValueError(f"cell dtype {cell.dtype} != stress dtype {stress.dtype}")
devices = {str(a.device) for a in (positions, forces, cell, stress, batch_idx)}
if len(devices) > 1:
raise ValueError(f"inputs are spread across devices {sorted(devices)}")
def _lbfgs_reduce(forces, state: LBFGSState, batch_idx) -> None:
"""Compute the per-system reduction for one call. See :func:`lbfgs_step`."""
_lbfgs_reduce_impl(forces, state.direction, batch_idx, state.gg)
def _lbfgs_update(positions, forces, state: LBFGSState, batch_idx,
**kwargs) -> None: # fmt: skip
"""Advance the state machine and produce a search direction.
Everything except the position update. Use with :func:`_lbfgs_prepare_step`
and :func:`_lbfgs_apply_step` when you need to interpose your own logic
before the atoms move; :func:`lbfgs_step` chains all three.
"""
_check_inputs(positions, forces, batch_idx, state)
_lbfgs_update_impl(
positions=positions, forces=forces, batch_idx=batch_idx,
**_arrays(state), **kwargs,
) # fmt: skip
def _lbfgs_prepare_step(state: LBFGSState, **kwargs) -> None:
"""Repair a bad direction and apply the trust region."""
_lbfgs_prepare_step_impl(
state.gg, state.d0, state.dmax, state.dquad, state.alpha_step,
state.end, state.n_loop, state.history_count, **kwargs,
) # fmt: skip
def _lbfgs_apply_step(positions, forces, state: LBFGSState, batch_idx) -> None:
"""Move the positions to the next point."""
_lbfgs_apply_step_impl(
positions, forces, state.x_base, state.force_base, state.direction,
batch_idx, state.n_loop, state.gg, state.alpha_step,
) # fmt: skip
[docs]
def lbfgs_step(positions, forces, state: LBFGSState, batch_idx,
**kwargs) -> None: # fmt: skip
"""Consume one force evaluation and produce the next geometry.
One history update, one algorithmic restart if the direction stops
descending, and one ``maxstep``-bounded step. It never stops: testing
convergence and ending the loop are yours, as they are for FIRE2.
Parameters
----------
positions, forces : wp.array, shape (num_dofs,), dtype vec3f/vec3d
Current geometry and the forces there. Mutated in place.
state : LBFGSState
From :func:`lbfgs_prepare_state`, or built from your own arrays.
batch_idx : wp.array, shape (num_dofs,), dtype int32
Sorted system index per degree of freedom.
**kwargs
``maxstep``, ``curvature_eps`` and ``compute_reductions``.
"""
_check_inputs(positions, forces, batch_idx, state)
_lbfgs_step_impl(
positions=positions, forces=forces, batch_idx=batch_idx,
**_arrays(state), **kwargs,
) # fmt: skip
[docs]
def lbfgs_step_coord_cell(positions, forces, cell, stress, state: LBFGSState,
cell_state: LBFGSCellState, batch_idx,
**kwargs) -> None: # fmt: skip
"""Advance one variable-cell step, relaxing coordinates and cell together.
``state`` must be sized for ``num_atoms + 2 * num_systems`` degrees of
freedom; ``cell_state`` carries the chart. Call
:func:`_lbfgs_set_reference_cell` and :func:`_lbfgs_cell_kappa` once before
the first step.
Parameters
----------
positions, forces : wp.array, shape (num_atoms,), dtype vec3f/vec3d
Cartesian geometry and the forces there, advanced in place. Forces,
not gradients.
cell, stress : wp.array, shape (num_systems,), dtype mat33f/mat33d
Cell with lattice vectors as columns, advanced in place, and the
Cauchy stress per system, which drives the cell degrees of freedom.
state : LBFGSState
From :func:`lbfgs_prepare_state`, sized for
``num_atoms + 2 * num_systems`` degrees of freedom.
cell_state : LBFGSCellState
From :func:`lbfgs_prepare_cell_state`, or built from your own arrays.
batch_idx : wp.array, shape (num_atoms,), dtype int32
Sorted system index per atom.
**kwargs
``maxstep`` and ``curvature_eps``.
"""
_check_cell_inputs(positions, forces, cell, stress, batch_idx, state, cell_state)
_lbfgs_step_coord_cell_impl(
positions=positions, forces=forces, cell=cell, stress=stress,
batch_idx=batch_idx,
**_arrays(state), **_arrays(cell_state), **kwargs,
) # fmt: skip