Source code for neml2.es.implicit

# Copyright 2024, UChicago Argonne, LLC
# All Rights Reserved
# Software Name: NEML2 -- the New Engineering material Model Library, version 2
# By: Argonne National Laboratory
# OPEN SOURCE LICENSE (MIT)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

"""AOTI export wrappers for the implicit-segment Newton path.

The linear solve is **un-baked** from the operators (schema v10): each graph
either ASSEMBLES an operator or SOLVES; the C++ runtime chains operator -> solve.
This lets the same residual Jacobian feed a direct solve today or a matrix-free
iterative solver later, and keeps a single solver implementation (the Python
``[Solvers]`` classes, run live for eager and compiled for AOTI).

Operator graphs (assemble; no solve):

- :class:`RHS`          -- ``(*u,*g,*params) -> (*b_groups)`` (residual eval,
                           cheap; every line-search trial).
- :class:`Jacobian`     -- ``(*u,*g,*params) -> (*A_blocks, *b_groups)``
                           (``A = ∂r/∂u`` row-major (residual × unknown) grid +
                           ``b = -r``; the Newton-step operator).
- :class:`JacobianGiven`-- ``(*u,*g,*params) -> (*B_blocks)`` (``B = ∂r/∂g``;
                           the IFT's given-side operator, paired with
                           ``Jacobian``'s ``A``).
- :class:`DrDParam`     -- ``(*u,*g,*params_per_batch) -> (A_dense, ∂r/∂θ)``
                           (dense operators for the parameter sensitivity, via
                           reverse-mode ``torch.autograd.grad`` -- the only AD
                           that lowers through AOTInductor; strict + per-batch
                           parameter, the runtime broadcasts the stored scalar).

Solve graphs (consume operators; no assembly):

- :class:`LinearSolve`     -- ``(*A_blocks, *b_groups) -> (*du_groups)`` (Newton
                              step ``du = A^{-1} b`` via the configured solver).
- :class:`LinearSolveIFT`  -- ``(*A_blocks, *B_blocks) -> *blocks`` (IFT
                              ``du/dg = -A^{-1} B``, one block per ``(unknown,
                              given)`` pair via ``AssembledMatrix.disassemble``;
                              the C++ runtime composes each against ``dg_dmaster``
                              with the same per-pair path a forward segment uses).
- :class:`LinearSolveParam`-- ``(A_dense, ∂r/∂θ) -> *blocks`` (parameter
                              sensitivity ``du/dθ = -A^{-1} ∂r/∂θ``, one dense
                              block per ``(unknown, param)`` pair).

The promoted-parameter tail (``*params``) is empty in the common case (no
``--parameter`` targeting an attribute inside the implicit region); when
present it lists, in graph-call order, the promoted parameters that live
inside the implicit segment's residual model. After
:func:`~neml2.cli.aoti_export._promote_parameters` these appear in
``system.model.input_spec`` but are neither unknowns nor givens, so the
operator wrappers inject them into the per-variable state from the trailing
forward args. The operator graphs take them as the stored scalar (constant
across the solve and the input-Jacobian); ``DrDParam`` takes them per-batch.

The operator graphs take/return per-group raw tensors at the natural
``AssembledVector`` / ``AssembledMatrix`` group shape -- BLOCK groups preserve
their ``sub_batch_shape`` axes, DENSE groups have sub_batch folded into the last
base axis. The solve graphs reconstruct the typed operands from those raw blocks
(:func:`~neml2.es.assembled.group_block_sub_batch_ndim` /
:func:`~neml2.es.assembled.wrap_block_raw`). The C++ runtime maintains
per-variable ``dstate`` for downstream forward composition; the per-variable ↔
per-group conversion happens twice per solve (once at solve start to pack
``u_groups`` / ``g_groups``, once at solve end to unpack converged
``u_groups`` back to ``dstate``). The Newton inner loop is fully per-group.
"""

from __future__ import annotations

from math import prod

import torch
from torch import nn

from neml2.models.chain_rule import ChainRuleDict
from neml2.types import Tensor, TensorWrapper

from ._helpers import _flatten_base, build_identity_seed
from .assembled import (
    AssembledMatrix,
    AssembledVector,
    _build_block_matrix,
    group_block_sub_batch_ndim,
    wrap_block_raw,
    wrap_group_raw,
)
from .axis_layout import AxisLayout
from .system import ModelNonlinearSystem


def enumerate_group_var_names(layout: AxisLayout) -> tuple[tuple[str, ...], ...]:
    """Canonical per-group variable-name iteration order for *layout*.

    Single source of truth for the order in which group tensors are
    enumerated in segment forward signatures and in metadata
    emission. See :func:`~neml2.cli.aoti_export._enumerate_group_infos`
    for the matching emitter side.
    """
    return tuple(tuple(g) for g in layout.groups)


class _SystemModule(nn.Module):
    """Tensor-only export surface for a frozen :class:`ModelNonlinearSystem`.

    The operator subclasses (:class:`RHS`, :class:`Jacobian`,
    :class:`JacobianGiven`, :class:`DrDParam`) take per-group raw tensors at the
    graph signature -- ``forward(*u_groups, *g_groups, *params)`` where the leading
    ``len(unknown_groups)`` positional args are the unknown groups (in
    ``ulayout.groups`` order), the next ``len(given_groups)`` are the given
    groups (in ``glayout.groups`` order), and the trailing ``len(param_names)``
    are the promoted parameters that live inside the implicit residual (in
    ``param_names`` order; empty in the common no-promotion case).
    """

    def __init__(self, system: ModelNonlinearSystem, param_names: tuple[str, ...] = ()) -> None:
        super().__init__()
        self.model = system.model
        self.ulayout = system.ulayout
        self.glayout = system.glayout
        self.blayout = system.blayout
        self.unknown_names = tuple(system.unknown_names)
        self.given_names = tuple(system.given_names)
        self.residual_names = tuple(system.residual_names)
        # Per-group variable names (canonical iteration order for the
        # per-group tensors in forward args / returns).
        self.unknown_groups: tuple[tuple[str, ...], ...] = enumerate_group_var_names(self.ulayout)
        self.given_groups: tuple[tuple[str, ...], ...] = enumerate_group_var_names(self.glayout)
        self.residual_groups: tuple[tuple[str, ...], ...] = enumerate_group_var_names(self.blayout)
        self.input_names = tuple(system.model.input_spec)
        self.output_names = tuple(system.model.output_spec)
        self.dyn_ndim: dict[str, int] = dict(system._dynamic_batch_ndim)
        self.sub_batch_shapes = dict(system._sub_batch_shapes)
        # Promoted parameters threaded as a positional tail after the givens.
        # After ``_promote_parameters`` these are in ``model.input_spec`` but
        # are neither unknowns nor givens; ``_state_from_per_group_args``
        # injects them into the per-variable state from the trailing args so
        # ``_call_model_from_state`` (which iterates the full input_spec) finds
        # them. ``param_types`` is looked up from the (post-promotion) spec.
        self.param_names = tuple(param_names)
        self.param_types: tuple[type[TensorWrapper], ...] = tuple(
            system.model.input_spec[p] for p in self.param_names
        )

    def _state_from_per_group_args(
        self, args: tuple[torch.Tensor, ...]
    ) -> dict[str, TensorWrapper]:
        """Split positional per-group inputs into a per-variable typed state.

        Inputs:
            ``args`` -- ``(*u_groups, *g_groups, *params)`` raw tensors. The
            first ``len(self.unknown_groups)`` are unknown groups (in
            ``ulayout.groups`` order); the next ``len(self.given_groups)`` are
            given groups (in ``glayout.groups`` order); the trailing
            ``len(self.param_names)`` are the promoted parameters (in
            ``param_names`` order).

        The per-group → per-variable split is delegated to
        :meth:`AssembledVector.disassemble`, which already handles both
        BLOCK (preserve sub_batch axes, narrow per-var base) and DENSE
        (unfold sub_batch from trailing base, narrow per-var
        ``var_size``) groups. The traced graph contains the narrows /
        reshapes as standard torch ops, compiled in by Inductor. Promoted
        parameters are injected verbatim (wrapped to their typed class);
        wrapping preserves any autograd graph on the incoming tensor so the
        :class:`DrDParam` reverse pass can differentiate through them.
        """
        n_u = len(self.unknown_groups)
        n_g = len(self.given_groups)
        u_group_raws = args[:n_u]
        g_group_raws = args[n_u : n_u + n_g]
        param_raws = args[n_u + n_g :]
        # AssembledVector takes a list of typed dynamic-base ``Tensor``
        # wrappers, one per group. The raws coming in here are already the
        # group tensor data; :func:`wrap_group_raw` re-attaches the right
        # batch_ndim / sub_batch_ndim per group so disassemble interprets
        # them correctly.
        u_tensors = [
            wrap_group_raw(raw, gnames, structure, self.ulayout)
            for raw, gnames, structure in zip(
                u_group_raws, self.unknown_groups, self.ulayout.structure, strict=True
            )
        ]
        g_tensors = [
            wrap_group_raw(raw, gnames, structure, self.glayout)
            for raw, gnames, structure in zip(
                g_group_raws, self.given_groups, self.glayout.structure, strict=True
            )
        ]
        u_vec = AssembledVector(self.ulayout, u_tensors)
        g_vec = AssembledVector(self.glayout, g_tensors)
        state: dict[str, TensorWrapper] = {}
        # ``.values`` is the plain ``{name: wrapper}`` dict; pass it (not the
        # SparseVector itself) to ``dict.update`` so strict export traces it -- a
        # SparseVector iterates KEYS, which Dynamo's update tries to unpack as pairs.
        state.update(u_vec.disassemble().values)
        state.update(g_vec.disassemble().values)
        # Inject the promoted-parameter tail. Wrap each raw to its typed class
        # (wrapping preserves any incoming autograd graph -- the ParamIFT
        # reverse pass relies on it). No sub_batch: promoted parameters are
        # plain-batch (the implicit-promotion guard rejects sub-batched ones).
        for name, type_cls, raw in zip(self.param_names, self.param_types, param_raws, strict=True):
            state[name] = raw if isinstance(raw, type_cls) else type_cls(raw)
        return state

    def _call_model_from_state(
        self,
        state: dict[str, TensorWrapper],
        seed_names: tuple[str, ...],
    ) -> tuple[dict[str, TensorWrapper], ChainRuleDict]:
        """Build chain-rule seed (if any) and call the model on the typed state."""
        # Shared seed builder (same one ModelNonlinearSystem uses) so the
        # exported graph and the native eager assembly cannot drift -- notably
        # the dynamic-batch left-padding that this wrapper previously omitted.
        seed: ChainRuleDict | None = (
            build_identity_seed(
                state,
                seed_names,
                len(self.residual_groups),
                self.model.input_spec,
                self.sub_batch_shapes,
            )
            if seed_names
            else None
        )

        args = tuple(state[name] for name in self.input_names)
        result = self.model(*args, v=seed) if seed is not None else self.model(*args)
        result_tuple = result if isinstance(result, tuple) else (result,)
        if seed is None:
            output_values = result_tuple
            v_out: ChainRuleDict = {}
        else:
            output_values = result_tuple[:-1]
            v_out = result_tuple[-1]
        # Keep model outputs as typed wrappers (rule 1: no raw-tensor leaks).
        # Leaves that return raw get rewrapped via the declared output spec.
        output_state: dict[str, TensorWrapper] = {}
        for name, value in zip(self.output_names, output_values, strict=True):
            if isinstance(value, TensorWrapper):
                output_state[name] = value
            else:
                output_state[name] = self.model.output_spec[name](value)
        return output_state, v_out

    def _assembled_matrix(
        self,
        col_layout: AxisLayout,
        v_out: ChainRuleDict,
        output_state: dict[str, TensorWrapper],
    ) -> AssembledMatrix:
        """Multi-group AssembledMatrix via the sub-batch-aware block builder."""
        residual_values = {name: output_state[name] for name in self.residual_names}
        like_by_row = {
            name: _flatten_base(residual_values[name], self.model.output_spec[name])
            for name in self.residual_names
        }
        return _build_block_matrix(
            self.model,
            self.blayout,
            col_layout,
            v_out,
            like_by_row,
        )

    def _assembled_b(self, output_state: dict[str, TensorWrapper]) -> AssembledVector:
        """Multi-group b = -r as ``AssembledVector`` (per-group tensors)."""
        residual_values = {name: output_state[name] for name in self.residual_names}
        b = AssembledVector.from_dict(self.blayout, residual_values)
        return -b


def _vector_to_per_group_raws(vec: AssembledVector) -> tuple[torch.Tensor, ...]:
    """Extract per-group raw tensors from an AssembledVector.

    Single ``.data`` extract per group at the AOTI segment-output
    boundary -- the legitimate framework-imposed exception case from
    CLAUDE.md rule 2.
    """
    return tuple(t.data for t in vec.tensors)  # data-ok AOTI


def _matrix_to_per_block_raws(mat: AssembledMatrix) -> tuple[torch.Tensor, ...]:
    """Row-major per-``(row_group, col_group)`` raw blocks of an AssembledMatrix.

    The legitimate framework-imposed ``.data`` exception at the AOTI
    segment-output boundary (CLAUDE.md rule 2), mirroring
    :func:`_vector_to_per_group_raws` for the matrix operator.
    """
    return tuple(
        mat.tensors[i][j].data  # data-ok AOTI
        for i in range(mat.row_layout.ngroup)
        for j in range(mat.col_layout.ngroup)
    )


def krylov_solve_raw(
    A_raw: torch.Tensor,
    b_raw: torch.Tensor,
    *,
    method: str,
    restart: int,
    max_its: int,
    abs_tol: float,
    rel_tol: float,
) -> torch.Tensor:
    """Solve ``A x = b`` over an ALREADY-ASSEMBLED dense operator ``A`` via the
    shared C++ matrix-free Krylov loop (``matvec(v) = A @ v``, identity
    preconditioner) -- the Python mirror of ``krylov.h::krylov_solve_dense`` used
    by the eager derivative solves. ``A_raw`` is ``(*batch, N, N)``; ``b_raw`` is
    ``(*batch, N)`` (vector) or ``(*batch, N, M)`` (matrix); the return matches
    ``b_raw``. Leading batch dims are flattened for the loop; a matrix RHS is
    solved column by column. Raw-tensor in/out: this IS the krylov-binding
    framework boundary (callers pass tensors already unwrapped at an AOTI /
    autograd.Function boundary).
    """
    from neml2.aoti._aoti import krylov_solve_linear  # noqa: PLC0415

    n = A_raw.shape[-1]
    a_flat = A_raw.reshape(-1, n, n)

    def matvec(v: torch.Tensor) -> torch.Tensor:  # (Bflat, N) -> (Bflat, N)
        return torch.matmul(a_flat, v.unsqueeze(-1)).squeeze(-1)

    def _one(rhs_flat: torch.Tensor) -> torch.Tensor:  # (Bflat, N) -> (Bflat, N)
        return krylov_solve_linear(
            matvec,
            rhs_flat,
            method=method,
            restart=restart,
            max_its=max_its,
            abs_tol=abs_tol,
            rel_tol=rel_tol,
        )

    if b_raw.dim() == A_raw.dim() - 1:  # vector RHS (*batch, N)
        return _one(b_raw.reshape(-1, n)).reshape(b_raw.shape)
    # Matrix RHS (*batch, N, M): solve each column, reassemble.
    m = b_raw.shape[-1]
    b_flat = b_raw.reshape(-1, n, m)
    cols = [_one(b_flat[..., j].contiguous()) for j in range(m)]
    return torch.stack(cols, dim=-1).reshape(b_raw.shape)


def krylov_solve_assembled(
    A: AssembledMatrix,
    b: AssembledVector | AssembledMatrix,
    *,
    method: str,
    restart: int,
    max_its: int,
    abs_tol: float,
    rel_tol: float,
) -> AssembledVector | AssembledMatrix:
    """Typed ``A x = b`` over the assembled single-dense-group operator via
    :func:`krylov_solve_raw`, returning a typed ``AssembledVector`` /
    ``AssembledMatrix`` matching ``b`` (the same surface as ``DenseLU.solve``).
    Used by the eager iterative linear solvers' ``.solve`` for the input-IFT.
    The ``.data`` extract / rewrap is the krylov-binding framework boundary.
    """
    if A.row_layout.ngroup != 1 or A.col_layout.ngroup != 1:
        raise ValueError(
            "iterative linear solve requires a single dense group "
            f"(A has {A.row_layout.ngroup}x{A.col_layout.ngroup} groups)"
        )
    a_raw = A.tensors[0][0].data  # data-ok krylov boundary

    def _solve(rhs_raw: torch.Tensor) -> torch.Tensor:
        return krylov_solve_raw(
            a_raw,
            rhs_raw,
            method=method,
            restart=restart,
            max_its=max_its,
            abs_tol=abs_tol,
            rel_tol=rel_tol,
        )

    if isinstance(b, AssembledVector):
        if b.layout.ngroup != 1:
            raise ValueError("iterative linear solve requires a single-group vector RHS")
        b_t = b.tensors[0]
        x = _solve(b_t.data)  # data-ok krylov boundary
        return AssembledVector(
            A.col_layout,
            [Tensor(x, batch_ndim=b_t.batch_ndim, sub_batch_ndim=b_t.sub_batch_ndim)],
        )
    if b.row_layout.ngroup != 1 or b.col_layout.ngroup != 1:
        raise ValueError("iterative linear solve requires a single-group matrix RHS")
    b_t = b.tensors[0][0]
    x = _solve(b_t.data)  # data-ok krylov boundary
    return AssembledMatrix(
        A.col_layout,
        b.col_layout,
        [[Tensor(x, batch_ndim=b_t.batch_ndim, sub_batch_ndim=b_t.sub_batch_ndim)]],
    )


[docs] class RHS(_SystemModule): """Exportable residual graph. Contract: ``(*u_groups, *g_groups, *params) -> (*b_groups)`` -- per-group raw tensors. ``b_group = -r_group`` for each residual group; the C++ runtime computes a per-batch convergence norm by reducing each group tensor over its trailing sub_batch + base axes and summing across groups (no per-variable narrow on the hot path). The promoted-parameter tail (scalar; constant across the solve) is empty in the common case. """
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: state = self._state_from_per_group_args(args) output_state, _ = self._call_model_from_state(state, ()) b = self._assembled_b(output_state) return _vector_to_per_group_raws(b)
[docs] class Jacobian(_SystemModule): """Exportable residual-Jacobian operator graph. Contract: ``(*u_groups, *g_groups, *params) -> (*A_blocks, *b_groups)`` where ``A_blocks`` is the row-major ``(residual_group × unknown_group)`` grid of ``A = ∂r/∂u`` and ``b_groups`` is ``b = -r`` at the current iterate. The linear solve is NOT baked here -- it lives in the separate :class:`LinearSolve` graph, so the same operator can feed a direct solve today or a matrix-free iterative solver later. The C++ runtime chains ``Jacobian -> LinearSolve`` for the Newton step (``step``) and reuses the ``b_groups`` tail for the line-search residual. The promoted-parameter tail is empty in the common case. """
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: state = self._state_from_per_group_args(args) output_state, v_out = self._call_model_from_state(state, self.unknown_names) A = self._assembled_matrix(self.ulayout, v_out, output_state) b = self._assembled_b(output_state) return (*_matrix_to_per_block_raws(A), *_vector_to_per_group_raws(b))
[docs] class Matvec(_SystemModule): """Exportable matrix-free residual jvp: ``(*u, *g, *params, *v) -> (*Jv)``. ``J.v = ∂r/∂u . v`` at fixed ``(u, g, params)``, where ``v`` is a tangent in the unknown space (same per-group layout as ``u``) and ``Jv`` is in the residual space (``blayout``). Never assembles ``A`` -- it threads the single direction ``v`` through the ``forward(v=)`` chain rule (one forward pass + one pushforward), the matvec an iterative/Krylov linear solver calls each inner iteration. This is the matrix-free counterpart of :class:`Jacobian` (which assembles the full ``A``): ``Matvec(u,g,v)`` equals ``Jacobian(u,g).A @ v``, but at O(N^2) with no O(N^3) factorization downstream. The C++ Krylov runtime drives it (compiled loader on the AOTI route, callback on the eager route); both feed the shared ``krylov_solve``. """
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: from ..types._boundary import assemble_jvp_outputs, leading_k1_seed # noqa: PLC0415 n_u = len(self.unknown_groups) n_g = len(self.given_groups) n_p = len(self.param_names) u_raws = args[:n_u] g_raws = args[n_u : n_u + n_g] p_raws = args[n_u + n_g : n_u + n_g + n_p] v_raws = args[n_u + n_g + n_p :] state = self._state_from_per_group_args((*u_raws, *g_raws, *p_raws)) # v arrives per-unknown-group (ulayout); disassemble to per-variable typed # tangents and seed each unknown with a single K=1 direction. v_tensors = [ wrap_group_raw(raw, gnames, structure, self.ulayout) for raw, gnames, structure in zip( v_raws, self.unknown_groups, self.ulayout.structure, strict=True ) ] v_typed = AssembledVector(self.ulayout, v_tensors).disassemble().values seed = {name: {name: leading_k1_seed(v_typed[name])} for name in self.unknown_names} args_typed = tuple(state[name] for name in self.input_names) result = self.model(*args_typed, v=seed) result_tuple = result if isinstance(result, tuple) else (result,) typed_outs, v_out = result_tuple[:-1], result_tuple[-1] raw_jvp = assemble_jvp_outputs(v_out, tuple(typed_outs), list(self.output_names)) output_state = dict(zip(self.output_names, typed_outs, strict=True)) Jv = AssembledVector.from_dict( self.blayout, { r: type(output_state[r])(raw_jvp[r], sub_batch_ndim=output_state[r].sub_batch_ndim) for r in self.residual_names }, ) return _vector_to_per_group_raws(Jv)
class _PrecondSetup(_SystemModule): """Base for a preconditioner's compiled *setup* graph: assemble the single dense unknown group's Jacobian ``A = ∂r/∂u`` as one ``(Bflat, N, N)`` block. v1 targets a single dense unknown group. Concrete setups (Full / Jacobi / BlockJacobi) factor / select from ``A`` and return the preconditioner *state* (raw graph outputs) that the matching apply consumes. (A future targeted seed-side assembly -- computing only the blocks a preconditioner needs so dead-code elimination prunes the rest -- keeps this same setup->state interface.) """ def _dense_a(self, args: tuple[torch.Tensor, ...]) -> torch.Tensor: if len(self.unknown_groups) != 1: raise ValueError( "preconditioned Krylov currently requires a single dense unknown " "group (v1); use preconditioner=none for multi-group systems." ) state = self._state_from_per_group_args(args) output_state, v_out = self._call_model_from_state(state, self.unknown_names) A = self._assembled_matrix(self.ulayout, v_out, output_state) a = _matrix_to_per_block_raws(A)[0] # single group -> (*B, N, N) n = a.shape[-1] return a.reshape(-1, n, n) # (Bflat, N, N) class FullPrecondSetup(_PrecondSetup): """Full preconditioner setup: ``(*u,*g,*params) -> (LU, pivots)`` = the LU factorization of the assembled Jacobian (M = A, so M^-1 is the exact solve).""" def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: A = self._dense_a(args) LU, piv = torch.linalg.lu_factor(A) return LU, piv class JacobiPrecondSetup(_PrecondSetup): """Jacobi preconditioner setup: ``(*u,*g,*params) -> (1/diag(A),)`` with a sign-preserving floor so a ~0 pivot cannot produce an inf apply.""" def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: A = self._dense_a(args) diag = torch.diagonal(A, dim1=-2, dim2=-1) # (Bflat, N) eps = torch.finfo(diag.dtype).eps sign = torch.where(diag.sign() == 0, torch.ones_like(diag), diag.sign()) safe = torch.where(diag.abs() < eps, sign * eps, diag) return (1.0 / safe,) class BlockJacobiPrecondSetup(_PrecondSetup): """Block-Jacobi preconditioner setup: ``(*u,*g,*params) -> (*inv_blocks)`` = the inverse of each per-variable on-diagonal block of ``A`` (one tensor per unknown variable, in ``unknown_names`` order).""" def __init__( self, system: ModelNonlinearSystem, block_sizes: tuple[int, ...], param_names: tuple[str, ...] = (), ) -> None: super().__init__(system, param_names) self._block_sizes = tuple(int(s) for s in block_sizes) def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: A = self._dense_a(args) # (Bflat, N, N) invs: list[torch.Tensor] = [] o = 0 for s in self._block_sizes: invs.append(torch.linalg.inv(A[:, o : o + s, o : o + s])) o += s return tuple(invs) class FullPrecondApply(nn.Module): """``(LU, pivots, r_flat) -> z_flat`` = ``A^-1 r`` via the cached LU.""" def forward(self, LU: torch.Tensor, piv: torch.Tensor, r_flat: torch.Tensor) -> torch.Tensor: return torch.linalg.lu_solve(LU, piv, r_flat.unsqueeze(-1)).squeeze(-1) class JacobiPrecondApply(nn.Module): """``(diag_recip, r_flat) -> z_flat`` = elementwise ``r / diag(A)``.""" def forward(self, diag_recip: torch.Tensor, r_flat: torch.Tensor) -> torch.Tensor: return r_flat * diag_recip class BlockJacobiPrecondApply(nn.Module): """``(*inv_blocks, r_flat) -> z_flat`` = block-diagonal apply of the per-variable inverses to the matching slices of the flat residual.""" def __init__(self, block_sizes: tuple[int, ...]) -> None: super().__init__() self._block_sizes = tuple(int(s) for s in block_sizes) def forward(self, *args: torch.Tensor) -> torch.Tensor: *invs, r_flat = args pieces: list[torch.Tensor] = [] o = 0 for inv, s in zip(invs, self._block_sizes, strict=True): # Batched block matvec inv @ r_block; explicit matmul (einsum is # guard-blocked inside an active Model.forward -- the preconditioner # apply runs during the ImplicitUpdate Newton solve). piece = torch.matmul(inv, r_flat[:, o : o + s].unsqueeze(-1)).squeeze(-1) pieces.append(piece) o += s return torch.cat(pieces, dim=-1)
[docs] class LinearSolve(_SystemModule): """Exportable linear-solve graph: ``(*A_blocks, *b_groups) -> (*du_groups)``. Reconstructs the typed ``AssembledMatrix`` ``A`` (row_layout = residuals, col_layout = unknowns) and ``AssembledVector`` ``b`` from the raw per-group blocks emitted by :class:`Jacobian`, then applies the configured Python linear solver (``DenseLU`` / ``SchurComplement``). Un-baked from :class:`Jacobian` so the solver is a separate, swappable stage and the same operator can feed an iterative solver. Per-block ``sub_batch_ndim`` is a structural constant of the layouts (:func:`~neml2.es.assembled.group_block_sub_batch_ndim`); ``batch_ndim`` follows from the runtime tensor ndim, so this graph is batch-size agnostic. The promoted-parameter tail carries no state here (the solve is pure linear algebra over the assembled operators) but is accepted for a uniform signature. """ def __init__( self, system: ModelNonlinearSystem, linear_solver, param_names: tuple[str, ...] = (), ) -> None: super().__init__(system, param_names) self._linear_solver = linear_solver self._n_r = len(self.residual_groups) self._n_u = len(self.unknown_groups) # Structural sub_batch_ndim per (residual_group, unknown_group) A block -- # the reconstruction key at the raw solver boundary. self._block_sub_ndims: list[list[int]] = [ [group_block_sub_batch_ndim(self.blayout, i, self.ulayout, j) for j in range(self._n_u)] for i in range(self._n_r) ]
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: n_a = self._n_r * self._n_u a_raws = args[:n_a] b_raws = args[n_a : n_a + self._n_r] a_blocks = [ [ wrap_block_raw(a_raws[i * self._n_u + j], self._block_sub_ndims[i][j]) for j in range(self._n_u) ] for i in range(self._n_r) ] A = AssembledMatrix(self.blayout, self.ulayout, a_blocks) b_tensors = [ wrap_group_raw(raw, gnames, structure, self.blayout) for raw, gnames, structure in zip( b_raws, self.residual_groups, self.blayout.structure, strict=True ) ] b = AssembledVector(self.blayout, b_tensors) du = self._linear_solver.solve(A, b) return _vector_to_per_group_raws(du)
[docs] class JacobianGiven(_SystemModule): """Exportable ``∂r/∂g`` operator graph -- the IFT's given-side Jacobian. Contract: ``(*u_groups, *g_groups, *params) -> (*B_blocks)`` where ``B_blocks`` is the row-major ``(residual_group × given_group)`` grid of ``B = ∂r/∂g`` at the converged state. The un-baked given-side companion of :class:`Jacobian` (which supplies ``A = ∂r/∂u``): the C++ runtime runs ``Jacobian`` (for ``A``) + ``JacobianGiven`` (for ``B``) at the converged point and feeds both to :class:`LinearSolveIFT` for the implicit-function-theorem solve ``du/dg = -A^{-1} B``. Seeds the givens only. The promoted-parameter tail is the stored scalar (constant across the input Jacobian). """
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: state = self._state_from_per_group_args(args) output_state, v_out = self._call_model_from_state(state, self.given_names) B = self._assembled_matrix(self.glayout, v_out, output_state) return _matrix_to_per_block_raws(B)
[docs] class LinearSolveIFT(_SystemModule): """Exportable IFT solve: ``(*A_blocks, *B_blocks) -> *du_dg_pair_blocks``. Reconstructs the typed ``A = ∂r/∂u`` (blayout × ulayout, from :class:`Jacobian`) and ``B = ∂r/∂g`` (blayout × glayout, from :class:`JacobianGiven`) from the raw per-group blocks, applies the configured linear solver (``du/dg = -A^{-1} B``, matrix RHS), then disassembles into per-``(unknown, given)`` blocks in :meth:`emitted_pairs` order -- matching the ``jacobian_pairs`` metadata. Shares the same solver as the forward Newton step (un-baked from the operators). The emitted pairs are plain-batch (guarded at compile time), but ``A`` itself may carry BLOCK unknown groups; per-block ``sub_batch_ndim`` follows the layout convention (:func:`~neml2.es.assembled.group_block_sub_batch_ndim`). """ def __init__( self, system: ModelNonlinearSystem, linear_solver, selected_pairs: set[tuple[str, str]] | None = None, param_names: tuple[str, ...] = (), ) -> None: super().__init__(system, param_names) self._linear_solver = linear_solver # Local (unknown, given) pairs to emit; ``None`` = all. self._selected_pairs = selected_pairs self._n_r = len(self.residual_groups) self._n_u = len(self.unknown_groups) self._n_g = len(self.given_groups) self._a_sub: list[list[int]] = [ [group_block_sub_batch_ndim(self.blayout, i, self.ulayout, j) for j in range(self._n_u)] for i in range(self._n_r) ] self._b_sub: list[list[int]] = [ [group_block_sub_batch_ndim(self.blayout, i, self.glayout, j) for j in range(self._n_g)] for i in range(self._n_r) ]
[docs] def emitted_pairs(self) -> list[tuple[str, str]]: """The (unknown, given) pairs this graph emits, in emission order.""" return [ (u, g) for u in self.unknown_names for g in self.given_names if self._selected_pairs is None or (u, g) in self._selected_pairs ]
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: n_a = self._n_r * self._n_u n_b = self._n_r * self._n_g a_raws = args[:n_a] b_raws = args[n_a : n_a + n_b] A = AssembledMatrix( self.blayout, self.ulayout, [ [ wrap_block_raw(a_raws[i * self._n_u + j], self._a_sub[i][j]) for j in range(self._n_u) ] for i in range(self._n_r) ], ) B = AssembledMatrix( self.blayout, self.glayout, [ [ wrap_block_raw(b_raws[i * self._n_g + j], self._b_sub[i][j]) for j in range(self._n_g) ] for i in range(self._n_r) ], ) du_dg = self._linear_solver.solve(A, B) cells = (-du_dg).disassemble().cells # Emit per-(unknown, given) raw blocks in the canonical order. The ``.data`` # reads are the legitimate AOTI segment-output boundary. return tuple( cells[u][g].data # data-ok AOTI for (u, g) in self.emitted_pairs() )
class _ParamIFTBase(_SystemModule): r"""Exportable parameter sensitivity $du/d\theta = -A^{-1}\,\partial r/\partial\theta$ for a converged :class:`ImplicitUpdate`. Contract: ``(*u_groups, *g_groups, *params) -> *blocks`` where each block is one per-variable-pair ``(unknown, param)`` entry of ``du_dθ``, emitted in ``unknown_names`` (outer) × ``param_names`` (inner) order -- matching the ``param_jacobian_pairs`` metadata in :func:`~neml2.cli.aoti_export._compile_implicit_segment`. The implicit AOTI path assembles its forward Jacobian analytically (no autograd ``Function`` to backprop through), so the parameter sensitivity of the converged solution is obtained by differentiating the implicit constraint ``r(u(θ), g, θ) = 0``: .. math:: \frac{du}{d\theta} = -A^{-1}\,\frac{\partial r}{\partial\theta}, \qquad A = \frac{\partial r}{\partial u}. Unlike the other three segment graphs, this one is compiled under ``strict=True`` (the only mode in which ``torch.autograd.grad`` lowers through AOTInductor), and the strict dynamo tracer does NOT tolerate the generator-heavy equation-system assembly machinery (``AssembledVector`` / ``AssembledMatrix`` / the chain rule). So this graph is deliberately self-contained: it reconstructs the typed model inputs by plain ``narrow`` / ``reshape`` (offsets precomputed in ``__init__``), runs the residual model forward ONCE, and forms BOTH ``A = ∂r/∂u`` and ``∂r/∂θ`` from reverse-mode ``torch.autograd.grad`` over the flat residual vector (``A`` via the unknown leaves, ``∂r/∂θ`` via the parameter leaves -- the residual Jacobian computed by reverse-mode IS the same ``A`` the Newton solve used). It then solves the full dense system ``A · du/dθ = -∂r/∂θ`` with ``torch.linalg.solve``. Plain-batch only (the implicit-promotion guard rejects sub-batched unknowns / givens / params), so the unknown and residual storage flatten to a single dense ``(*batch, U)`` / ``(*batch, R)`` vector with ``R == U`` and the full dense solve is exact -- the per-group Schur structure is only a forward-solve optimization and is not needed for the one-shot sensitivity. The promoted parameter enters PER-BATCH (``(*dyn, *param_base)``) so the reverse pass yields a per-batch-element ``∂r/∂θ`` (no summation across the batch); the C++ runtime broadcasts the stored scalar parameter to the runtime batch before the call, mirroring the forward dense parameter-Jacobian graph. Cost: ``R`` reverse passes (one per residual component), independent of the number of parameters. """ def __init__( self, system: ModelNonlinearSystem, linear_solver, param_names: tuple[str, ...], selected_pairs: set[tuple[str, str]] | None = None, ) -> None: super().__init__(system, param_names) # The dense solve is done in-graph with torch.linalg.solve; the # configured linear solver (Schur etc.) is a forward-solve optimization # not needed for the one-shot sensitivity, so it is intentionally unused. del linear_solver # Local (unknown, param) pairs to emit; ``None`` = all. Emitted in # unknown x param order to match the metadata. self._selected_pairs = selected_pairs spec = system.model.input_spec out_spec = system.model.output_spec def _storage(base: tuple[int, ...]) -> int: return prod(base) if base else 1 # Flat unknown layout (group order, then var order within group) -- the # COLUMN order of A and the ROW order of du/dθ. Each group tensor arrives # as (*batch, group_storage); per-var narrow offsets are within-group. # Plain-batch => DENSE groups => var storage == prod(base) (no sub-batch). self._u_groups_meta: list[list[tuple[str, type, tuple[int, ...], int]]] = [] self._u_flat: list[tuple[str, tuple[int, ...], int, int]] = [] # name, base, storage, off u_off = 0 for group in self.unknown_groups: gmeta: list[tuple[str, type, tuple[int, ...], int]] = [] for name in group: tc = spec[name] base = tuple(int(s) for s in tc.BASE_SHAPE) st = _storage(base) gmeta.append((name, tc, base, st)) self._u_flat.append((name, base, st, u_off)) u_off += st self._u_groups_meta.append(gmeta) self._u_total = u_off # Flat given layout (values; no grad). Group order, then var order. self._g_groups_meta: list[list[tuple[str, type, tuple[int, ...], int]]] = [] for group in self.given_groups: gmeta = [] for name in group: tc = spec[name] base = tuple(int(s) for s in tc.BASE_SHAPE) gmeta.append((name, tc, base, _storage(base))) self._g_groups_meta.append(gmeta) # Flat residual layout in residual_groups order (matches the unknown # group/var order one-for-one, so A is square with row k <-> unknown k). self._r_flat: list[tuple[str, int]] = [ (rname, _storage(tuple(int(s) for s in out_spec[rname].BASE_SHAPE))) for rgroup in self.residual_groups for rname in rgroup ] self._r_total = sum(st for _, st in self._r_flat) # Per-param flat metadata (base shape + storage), input_spec / tail order. self._param_meta: list[tuple[str, tuple[int, ...], int]] = [] for name in self.param_names: base = tuple(int(s) for s in spec[name].BASE_SHAPE) self._param_meta.append((name, base, _storage(base))) def emitted_param_pairs(self) -> list[tuple[str, str]]: """The (unknown, param) pairs this graph emits, in emission order.""" return [ (u, p) for u in self.unknown_names for p in self.param_names if self._selected_pairs is None or (u, p) in self._selected_pairs ] # This body executes only inside `torch.export`'s Dynamo trace when the # ParamIFT graph is compiled; Dynamo runs transformed bytecode, so coverage.py # never sees these source lines even though the AOTI implicit parameter- # derivative tests exercise it end-to-end (compile + run + FD check). Hence the # coverage exclusion on the def below.
[docs] class DrDParam(_ParamIFTBase): """Exportable operator graph for the implicit parameter sensitivity. Contract: ``(*u_groups, *g_groups, *params_per_batch) -> (A_dense, Bp)`` where ``A = ∂r/∂u`` is the dense ``(*batch, R, U)`` residual Jacobian and ``Bp = ∂r/∂θ`` the dense ``(*batch, R, P)`` parameter Jacobian, both formed by reverse-mode ``torch.autograd.grad`` over the flat residual (the only AD that lowers through AOTInductor -- so this graph, like the old ``ParamIFT``, is compiled strict + ``trace_autograd_ops``). The solve is un-baked into :class:`LinearSolveParam`. Plain-batch only (the implicit-promotion guard rejects sub-batched unknowns / givens / params, so ``R == U`` and the system is a single dense block). """
[docs] def forward(self, *args: torch.Tensor) -> tuple[torch.Tensor, ...]: # pragma: no cover n_u = len(self.unknown_groups) n_g = len(self.given_groups) u_group_raws = args[:n_u] g_group_raws = args[n_u : n_u + n_g] param_raws = args[n_u + n_g :] batch = tuple(u_group_raws[0].shape[:-1]) # (*batch, group_storage) # Re-leaf each unknown group + each per-batch parameter so autograd.grad # has fresh leaves (a graph input is not a grad leaf until cloned + # requires_grad_). Unknown leaves -> A = ∂r/∂u; param leaves -> ∂r/∂θ. u_leaves = [t.clone().requires_grad_(True) for t in u_group_raws] param_leaves: list[torch.Tensor] = [] for type_cls, raw in zip(self.param_types, param_raws, strict=True): # (*batch, *param_base); .data is the AOTI input boundary unwrap. r = raw.data if isinstance(raw, type_cls) else raw # data-ok AOTI param_leaves.append(r.clone().requires_grad_(True)) # Reconstruct the typed per-variable state by plain narrow / reshape. state: dict[str, TensorWrapper] = {} for leaf, gmeta in zip(u_leaves, self._u_groups_meta, strict=True): off = 0 for name, tc, base, st in gmeta: part = leaf.narrow(-1, off, st).reshape(*batch, *base) state[name] = tc(part) off += st for raw, gmeta in zip(g_group_raws, self._g_groups_meta, strict=True): off = 0 for name, tc, base, st in gmeta: part = raw.narrow(-1, off, st).reshape(*batch, *base) state[name] = tc(part) off += st for (name, _base, _st), leaf in zip(self._param_meta, param_leaves, strict=True): state[name] = self.model.input_spec[name](leaf) # Plain forward (no chain rule): the residual values carry the autograd # graph w.r.t. both the unknown leaves and the parameter leaves. args_in = tuple(state[name] for name in self.input_names) result = self.model(*args_in) result_tuple = result if isinstance(result, tuple) else (result,) out_state: dict[str, TensorWrapper] = {} for name, value in zip(self.output_names, result_tuple, strict=True): out_state[name] = ( value if isinstance(value, TensorWrapper) else self.model.output_spec[name](value) ) # Flat residual vector r (*batch, R) in unknown-corresponding order. The # ``.data`` reads are the AOTI boundary unwrap (this file is the export # boundary; the residual values carry the autograd graph through .data). r_parts = [ out_state[rname].data.reshape(*batch, st) # data-ok AOTI for rname, st in self._r_flat ] r_flat = torch.cat(r_parts, dim=-1) if len(r_parts) > 1 else r_parts[0] R = self._r_total # Reverse-mode: one pass per residual component builds the rows of both # A = ∂r/∂u (*batch, R, U) and Bp = ∂r/∂θ (*batch, R, P). a_rows: list[torch.Tensor] = [] bp_rows: list[torch.Tensor] = [] for k in range(R): seed = torch.zeros(*batch, R, dtype=r_flat.dtype, device=r_flat.device) seed[..., k] = 1.0 grads = torch.autograd.grad( r_flat, [*u_leaves, *param_leaves], grad_outputs=seed, retain_graph=True, allow_unused=True, ) ug = grads[: len(u_leaves)] pg = grads[len(u_leaves) :] a_row = torch.cat( [ g if g is not None else torch.zeros_like(leaf) for g, leaf in zip(ug, u_leaves, strict=True) ], dim=-1, ) # (*batch, U) a_rows.append(a_row) p_cols = [ (g if g is not None else torch.zeros_like(leaf)).reshape(*batch, st) for g, leaf, (_n, _b, st) in zip(pg, param_leaves, self._param_meta, strict=True) ] # (*batch, P) bp_rows.append(torch.cat(p_cols, dim=-1) if len(p_cols) > 1 else p_cols[0]) A = torch.stack(a_rows, dim=-2) # (*batch, R, U), R == U Bp = torch.stack(bp_rows, dim=-2) # (*batch, R, P) # Emit the dense operators; the solve lives in LinearSolveParam. Detach: # AOTAutograd drops requires_grad on graph outputs. return A.detach(), Bp.detach()
[docs] class LinearSolveParam(_ParamIFTBase): """Exportable parameter-sensitivity solve: ``(A_dense, Bp) -> *du_dparam_blocks``. Solves ``A du/dθ = -∂r/∂θ`` with a dense ``torch.linalg.solve`` (exact for the plain-batch square system :class:`DrDParam` emits) and slices per-``(unknown, param)`` block in :meth:`emitted_param_pairs` order -- matching the ``param_jacobian_pairs`` metadata. Un-baked from :class:`DrDParam`; pure linear algebra (plain compile). """
[docs] def forward(self, A: torch.Tensor, Bp: torch.Tensor) -> tuple[torch.Tensor, ...]: batch = tuple(A.shape[:-2]) # du/dθ = -A^{-1} ∂r/∂θ via a full dense solve (exact for plain batch). du_dtheta = -torch.linalg.solve(A, Bp) # (*batch, U, P) u_off = {name: off for name, _b, _st, off in self._u_flat} u_base = {name: base for name, base, _st, _off in self._u_flat} u_storage = {name: st for name, _b, st, _off in self._u_flat} p_off: dict[str, int] = {} p_base: dict[str, tuple[int, ...]] = {} p_storage: dict[str, int] = {} off = 0 for name, base, st in self._param_meta: p_off[name] = off p_base[name] = base p_storage[name] = st off += st blocks: list[torch.Tensor] = [] for u, p in self.emitted_param_pairs(): sub = du_dtheta.narrow(-2, u_off[u], u_storage[u]).narrow(-1, p_off[p], p_storage[p]) blocks.append(sub.reshape(*batch, *u_base[u], *p_base[p])) return tuple(blocks)
__all__ = [ "RHS", "Jacobian", "LinearSolve", "JacobianGiven", "LinearSolveIFT", "DrDParam", "LinearSolveParam", "enumerate_group_var_names", ]