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.

Four ``nn.Module`` graphs, one per piece of the Newton orchestration:

- :class:`RHS`        -- ``(*u_groups, *g_groups, *params) -> (*b_groups)``
                         (residual eval, cheap; called every line-search
                         trial).
- :class:`NewtonStep` -- ``(*u_groups, *g_groups, *params) ->
                         (*du_groups, *b_groups)`` (Newton step direction +
                         residual at the current iterate).
- :class:`IFT`        -- ``(*u_groups, *g_groups, *params) -> *blocks``
                         (implicit function theorem Jacobian
                         ``du/dg = -A^{-1} B`` at the converged state, emitted
                         as one block per ``(unknown, given)`` pair via
                         ``AssembledMatrix.disassemble``; the C++ runtime
                         composes each block against ``dg_dmaster`` with the
                         same per-pair path a forward segment uses).
- :class:`ParamIFT`   -- ``(*u_groups, *g_groups, *params) -> *blocks``
                         (parameter sensitivity ``du/dθ = -A^{-1} ∂r/∂θ`` at
                         the converged state, one block per ``(unknown,
                         param)`` pair). ``A = ∂r/∂u`` is assembled
                         analytically through the chain rule exactly as in
                         :class:`IFT`; ``∂r/∂θ`` comes from reverse-mode
                         ``torch.autograd.grad`` over the residual w.r.t. the
                         promoted parameter (the only AD that lowers through
                         AOTInductor). The parameter enters this graph as a
                         PER-BATCH input so the reverse pass is per batch
                         element; the C++ runtime broadcasts the stored scalar
                         parameter to the batch before the call.

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
wrappers inject them into the per-variable state from the trailing forward
args. ``RHS`` / ``NewtonStep`` / ``IFT`` take them as the stored scalar
(constant across the solve and the input-Jacobian); ``ParamIFT`` takes them
per-batch.

The first three segments take and 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 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 TensorWrapper

from ._helpers import _flatten_base, build_identity_seed
from .assembled import AssembledMatrix, AssembledVector, _build_block_matrix, 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`.

    Segment subclasses (:class:`RHS`, :class:`NewtonStep`, :class:`IFT`,
    :class:`ParamIFT`) 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:`ParamIFT` 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


[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 NewtonStep(_SystemModule): """Exportable Newton step-direction graph. Contract: ``(*u_groups, *g_groups, *params) -> (*du_groups, *b_groups)`` where ``du_groups`` are the per-unknown-group step directions (in ``ulayout.groups`` order) and ``b_groups`` are the per-residual-group ``b = -r(u)`` at the current iterate (in ``blayout.groups`` order). The C++ runtime applies ``u_groups[i] = u_groups[i] + alpha * du_groups[i]`` per-group for line-search trials via cheap :class:`RHS` evaluations. The promoted-parameter tail is empty in the common case (scalar, constant across the solve). """ def __init__( self, system: ModelNonlinearSystem, linear_solver, param_names: tuple[str, ...] = (), ) -> None: super().__init__(system, param_names) self._linear_solver = linear_solver
[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) du = self._linear_solver.solve(A, b) return (*_vector_to_per_group_raws(du), *_vector_to_per_group_raws(b))
[docs] class IFT(_SystemModule): """Exportable IFT Jacobian $du/dg = -A^{-1} B$ for a converged :class:`ImplicitUpdate`. Contract: ``(*u_groups, *g_groups, *params) -> *blocks`` where each block is one per-variable-pair ``(unknown, given)`` entry of ``-du_dg``, emitted in ``unknown_names`` (outer) × ``given_names`` (inner) order -- matching the ``jacobian_pairs`` metadata in :func:`~neml2.cli.aoti_export._compile_implicit_segment`. The equation system assembles the dense ``A`` / ``B`` (via the model's per-variable chain rule), applies the IFT solve, then ``disassemble()``\\ s the resulting :class:`~neml2.es.assembled.AssembledMatrix` into per-(unknown, given) blocks. Each block keeps its natural per-structure shape (``"dense"`` -> ``(*B, u_storage, g_storage)``; a BLOCK side stays block-diagonal-compact, no N² fold). The C++ runtime then composes these blocks against ``dg_dmaster`` exactly like a forward segment's per-pair Jacobian blocks -- one uniform per-pair path for forward and implicit. The promoted-parameter tail (scalar) lets ``A``/``B`` see the same parameter value the solve used; empty in the common case. """ 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. Emitted in # unknown x given order to match the metadata. self._selected_pairs = selected_pairs
[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, ...]: state = self._state_from_per_group_args(args) seed_names = (*self.unknown_names, *self.given_names) output_state, v_out = self._call_model_from_state(state, seed_names) A = self._assembled_matrix(self.ulayout, v_out, output_state) B = self._assembled_matrix(self.glayout, v_out, output_state) 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() )
[docs] class ParamIFT(_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)))
[docs] 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] 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) # du/dθ = -A^{-1} ∂r/∂θ via a full dense solve (exact for plain batch). du_dtheta = -torch.linalg.solve(A, Bp) # (*batch, U, P) # Slice per-(unknown, param) block (*batch, *u_base, *param_base). Detach: # AOTAutograd drops requires_grad on graph outputs. 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]).detach()) return tuple(blocks)
__all__ = ["RHS", "NewtonStep", "IFT", "ParamIFT", "enumerate_group_var_names"]