Source code for neml2.aoti._shim

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

"""Native-Model-compatible HIT shim around the AOTI pybind binding.

Registers ``AOTIModel`` with the native factory so that an HIT block like

    [Models]
      [my_model]
        type = AOTIModel
        artifact_path = '/abs/path/to/aoti/my_model'
      []
    []

loads through ``neml2.load_input`` exactly the same way as any other
native model -- and presents a ``Model``-compatible surface
(``input_spec`` / ``output_spec`` / positional-tuple ``__call__``) so the
native drivers (``TransientDriver``, ``TransientRegression``, ...) can drive
it without special-casing.

This is a thin alias layer. The underlying runtime is the C++
``neml2::aoti::Model`` exposed via the ``_aoti`` pybind module; this shim
only handles HIT loading + typed-wrapper marshalling at the boundary.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING

import torch
from torch import nn

from .. import types as _types
from ..factory import register_neml2_object
from ..schema import HitSchema, dependency, option
from ..types import TensorWrapper
from ..types._boundary import broadcast_to_common_batch, check_tensor
from ._aoti import Model as _BoundModel

if TYPE_CHECKING:
    import nmhit

    from ..factory import _NativeInputFile


[docs] @register_neml2_object("AOTIModel") class AOTIModel(nn.Module): """HIT-loadable wrapper around :class:`neml2.aoti.Model`. Constructed from a HIT ``[Models]`` block with an ``artifact_path`` option pointing at the per-device artifact folder produced by ``neml2-compile`` (the folder holding one ``<device>/`` subfolder per compiled device). The subfolder for the current ``torch.get_default_device()`` is loaded -- so ``neml2-run --device cuda`` (which sets the default device) picks ``cuda/``. Eager and single-device: no dispatch happens here. Plays the native-Model role: ``input_spec`` and ``output_spec`` are populated from the metadata's ``var_type`` fields; ``__call__`` takes ``TensorWrapper`` positional args in ``input_spec`` order, unwraps them to raw tensors, runs the AOTI ``forward`` graph, and wraps each output back in its declared type. Promoted parameters (if any) are *not* part of ``input_spec``; they are reachable through :meth:`named_parameters` (a mutable dict) and :meth:`set_parameter`. The full sensitivity surface -- :meth:`jvp`, :meth:`jacobian`, :meth:`param_jacobian`, :meth:`param_vjp` -- is forwarded to the binding so the py-aoti route matches the others (CLAUDE.md "six evaluation routes" parity). """ #: Inherits from ``nn.Module`` rather than :class:`neml2.model.Model` so #: the bound ``torch::inductor::AOTIModelPackageLoader`` runtime drives #: evaluation; the explicit class attribute keeps it in the [Models] #: section of the syntax catalog despite not subclassing Model. SECTION = "Models" hit = HitSchema( option( "artifact_path", str, "Absolute path to the per-device artifact folder produced by " "``neml2-compile`` (contains one ``<device>/`` subfolder per compiled " "device). The subfolder matching ``torch.get_default_device()`` is loaded.", ), dependency( "solver", "get_solver", "Solver whose convergence / line-search settings configure the implicit " "Newton solve. Schema v4+ no longer bakes these into the artifact; the " "stub ``.i`` carries the ``[Solvers]`` block and it is forwarded to the " "C++ runtime at load. Defaults apply for forward-only models.", default=None, ), )
[docs] @classmethod def from_hit(cls, node: nmhit.Node, factory: _NativeInputFile) -> AOTIModel: artifact_str = node.param_str("artifact_path") # Absolute per `neml2-compile`; tolerate a relative path by resolving it # against the input file's directory. artifact_path = Path(artifact_str) if not artifact_path.is_absolute(): artifact_path = factory._path.parent / artifact_path artifact_path = artifact_path.resolve() # Load the subfolder for the current default device (cpu / cuda). device = torch.get_default_device() device_dir = artifact_path / device.type metas = sorted(device_dir.glob("*_meta.json")) if device_dir.is_dir() else [] if not metas: raise FileNotFoundError( f"AOTIModel({node.path()!r}): no artifact compiled for device " f"{device.type!r} under {artifact_path} (looked in {device_dir}). " f"Recompile with `neml2-compile --device {device.type}` or change the " f"default device." ) if len(metas) > 1: raise RuntimeError( f"AOTIModel({node.path()!r}): multiple '*_meta.json' files in " f"{device_dir}; expected exactly one compiled model." ) model = cls(metas[0]) solver_name = node.param_optional_str("solver", "") if solver_name: model._apply_solver_config(factory.get_solver(solver_name)) return model
def _apply_solver_config(self, solver) -> None: """Forward a Python solver's config to the C++ runtime. Reuses the solver wrapper's own ``_solver_config()`` -- the exact dict the eager path passes to ``newton_solve_eager`` -- so the compiled and eager solves are configured from a single source of truth. """ self._inner.set_solver_config(**solver._solver_config()) def __init__(self, meta_path: str | Path) -> None: super().__init__() meta_path = Path(meta_path) self._inner = _BoundModel(str(meta_path)) # Pull typed-wrapper class info from the metadata. The binding's # `input_names`/`input_sizes` give names + flat sizes; for the # native-driver surface we additionally need the TensorWrapper class # (e.g. Scalar vs SR2) which the metadata records under `var_type`. with open(meta_path) as f: meta = json.load(f) self.input_spec = self._spec_from_meta(meta["inputs"]) self.output_spec = self._spec_from_meta(meta["outputs"]) # Per-input sub-batch shape (empty tuple when the input has none). # ``_broadcast_to_common_batch`` consults this to split each input's # batch axes into (dyn, sub) and broadcast only the dyn portion -- # the sub-batch axis is part of the input's identity and must not # be flattened against a global (no-sub-batch) sibling. self.input_sub_batch: dict[str, tuple[int, ...]] = { info["name"]: tuple(info.get("sub_batch_shape", ())) for info in meta["inputs"] } # Per-input / per-output sub-batch labels (empty tuple when the # variable carries none). Persisted at export time via # :func:`~neml2.cli.aoti_export._var_infos`; re-attached when # wrapping AOTI raw outputs back into typed wrappers so the # per-axis label dispatch (preserved-label storage, BLOCK-aware # matmul) survives the export-and-load round-trip. self.input_labels: dict[str, tuple[str, ...]] = { info["name"]: tuple(info.get("sub_batch_labels", ())) for info in meta["inputs"] } self.output_labels: dict[str, tuple[str, ...]] = { info["name"]: tuple(info.get("sub_batch_labels", ())) for info in meta["outputs"] } @staticmethod def _spec_from_meta(infos: list[dict]) -> dict[str, type[TensorWrapper]]: """Map each metadata ``var_type`` string to the TensorWrapper class.""" spec: dict[str, type[TensorWrapper]] = {} for info in infos: name = info["name"] type_name = info["var_type"] type_cls = getattr(_types, type_name, None) if type_cls is None or not ( isinstance(type_cls, type) and issubclass(type_cls, TensorWrapper) ): raise TypeError( f"AOTIModel: metadata reports var_type={type_name!r} for " f"variable {name!r}, but neml2.types has no such " f"TensorWrapper subclass." ) spec[name] = type_cls return spec def _check_tensor(self, t: torch.Tensor, name: str, *, kind: str) -> None: """Strict device + dtype check; raise TypeError on mismatch. Thin wrapper over the shared :func:`neml2.types._boundary.check_tensor` (the device/dtype validation is identical across the AOTI + eager boundaries). ``kind`` is ``'input'`` or ``'parameter'``; the AOTI-specific remediation guidance is supplied here as the ``hint``. """ target_device = self._inner.device target_dtype = self._inner.dtype hint = ( f"The .pt2 artifact is compile-pinned to " f"(device={target_device}, dtype={target_dtype}) and the runtime " f"refuses to silently coerce -- silent coercion would mask the " f"silent-garbage / SEGV failure mode where a stride-based kernel " f"reads off the end of a mistyped buffer. Cast the tensor " f"explicitly with ``.to(device=..., dtype=...)`` at the call " f"site, or set process-wide defaults (e.g. " f"``torch.set_default_dtype(torch.float64)`` + " f"``torch.set_default_device(...)``) before constructing inputs." ) check_tensor( t, name, target_device, target_dtype, kind=kind, context="AOTIModel", hint=hint )
[docs] def forward(self, *args: TensorWrapper, v=None, v2=None, vh=None): """Drive the AOTI graph. Accepts ``input_spec`` positional args as ``TensorWrapper`` instances (mirroring the native ``ComposedModel`` boundary). Returns a tuple of typed wrappers in ``output_spec`` order -- always a tuple, even for single-output models, so consumers can iterate uniformly. ``v`` / ``v2`` / ``vh`` (the native chain-rule hooks) are accepted only for signature compatibility with other native models; passing them is rejected because the AOTI graph's JVP path is structurally different (it's a separate ``_jvp.pt2`` graph, exposed via the :meth:`jvp` / :meth:`jacobian` methods rather than the typed chain-rule protocol). Drivers that don't need sensitivities -- e.g. ``TransientDriver``, ``TransientRegression`` -- work as-is. """ if v is not None or v2 is not None or vh is not None: raise NotImplementedError( "AOTIModel does not support the native chain-rule v=/v2=/vh= " "arguments. Use the jvp() / jacobian() methods for sensitivities " "(the AOTI derivative graph is a separate compiled artifact)." ) if len(args) != len(self.input_spec): raise TypeError( f"AOTIModel: expected {len(self.input_spec)} positional inputs " f"({list(self.input_spec)}), got {len(args)}." ) # Unwrap typed wrappers to raw torch.Tensors and strictly validate # device + dtype against the artifact's pinned values. An AOTI .pt2 # is compile-pinned to a specific (device, dtype) and the kernel # strides through input buffers assuming those exact widths; a wrong # device dereferences a host pointer in a CUDA kernel (illegal # access), and a wrong dtype reinterprets bytes (silent garbage at # small batches, SEGV at larger ones once the wrong-stride read # crosses past the mapped buffer end). We refuse to silently coerce # because that hides bugs in caller code and makes NEML2-as-library # surprising in larger pipelines -- the caller owns dtype/device # placement, mirroring v2's [Settings]-gated dtype policy. Compare # the v2 main()s (e.g. ``neml2-time.cxx`` calling # ``set_default_dtype(kFloat64)``) -- end applications set defaults, # the model boundary validates. # # Also record per-input ``sub_batch_ndim``: when the caller passes # a TensorWrapper, read it off the instance (the export-time # typed-wrapper trace baked the per-input split into the artifact, # so the caller's sub_batch_ndim is the authoritative source); # for raw torch.Tensor inputs, fall back to the metadata's # ``sub_batch_shape`` (the legacy path). raw_inputs: dict[str, torch.Tensor] = {} per_input_sub_ndim: dict[str, int] = {} for name, arg in zip(self.input_spec, args, strict=True): if isinstance(arg, TensorWrapper): t = arg.data per_input_sub_ndim[name] = arg.sub_batch_ndim elif isinstance(arg, torch.Tensor): t = arg per_input_sub_ndim[name] = len(self.input_sub_batch.get(name, ())) else: raise TypeError( f"AOTIModel: input {name!r} must be a TensorWrapper or " f"torch.Tensor, got {type(arg).__name__}." ) self._check_tensor(t, name, kind="input") raw_inputs[name] = t # Also validate any promoted parameters the caller may have mutated # in place via ``self._inner.named_parameters()``. The .pt2 stores # them pinned to (target_device, target_dtype); if the user # overwrote with a tensor on a different device/dtype, the kernel # would silently dereference garbage. for pname, ptensor in self._inner.named_parameters().items(): self._check_tensor(ptensor, pname, kind="parameter") # Normalize batch shapes. The .pt2 was traced with every input # carrying a single leading batch axis (``_example_inputs_for`` uses # ``zeros(2, *BASE_SHAPE)``) and torch.export installs a *shared* # dynamic Dim across all inputs -- they must agree on that dim at # runtime. Eager NEML2 callers freely pass base-only defaults (e.g. # ``TransientDriver._zero_for_step`` makes an SR2 input of shape # ``(6,)`` for unset history slots) and rely on the leaves' # TensorWrapper arithmetic to broadcast; the .pt2 graph has no such # broadcasting layer. Broadcast everything to the common batch # shape before crossing. raw_inputs, common_dyn = self._broadcast_to_common_batch(raw_inputs, per_input_sub_ndim) raw_outs = self._inner.forward(raw_inputs) # Wrap each output and recover ``sub_batch_ndim`` from the runtime # tensor shape. This is the same arithmetic v2's ``neml2::Tensor`` # did at construction time: # # out.shape == (*dyn, *sub, *base) # dyn = common_dyn (broadcast across all inputs; outputs share) # base = ``BASE_SHAPE`` from the typed wrapper class # sub = whatever's left in the middle # # Solving for ``sub_n = out.ndim - dyn_n - BASE_NDIM``. Not a # heuristic -- there's exactly one structural decomposition that # fits. Without this step the default ``sub_batch_ndim=0`` wrap # mis-classifies a per-site axis as dyn, which breaks downstream # consumers that feed the output back as input (e.g. # ``TransientDriver.advance_step`` using step-N's per-crystal # output as step-(N+1)'s ``~1`` history). # # The assumption that output dyn == input common_dyn holds for # every shape-preserving forward operator NEML2 currently ships; # if a future model collapses batch axes inside the AOTI graph # (e.g. a reduction to a global summary), this would need to be # superseded by per-output sub_batch_shape carried in the # metadata. Cross that bridge when a model needs it. out_wrappers = [] dyn_n = len(common_dyn) for name, type_cls in self.output_spec.items(): raw = raw_outs[name] sub_n = max(raw.ndim - dyn_n - type_cls.BASE_NDIM, 0) # NOTE: sub_batch_labels are persisted in meta.json (see # ``output_labels``) but the static-base ``TensorWrapper`` # subclasses do not accept a labels kwarg today, so the # re-attachment is a no-op. The shim still loads the labels # so that callers can introspect them (see # ``test_aoti_grain_label_round_trip``). Once labels become # load-bearing on static-base wrappers, set them here. out_wrappers.append(type_cls(raw, sub_batch_ndim=sub_n)) return tuple(out_wrappers)
[docs] def jvp( self, inputs: dict[str, torch.Tensor], tangents: dict[str, torch.Tensor], param_overrides: dict[str, torch.Tensor] | None = None, ) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]: """Outputs + the directional derivative ``J @ v`` from the compiled ``jvp`` graph. ``inputs`` / ``tangents`` are ``{variable_name: tensor}`` dicts (same keys as :attr:`input_spec`); ``tangents`` carries the seed direction per input. Returns ``(outputs, jvp)`` keyed by output name -- the same raw-tensor surface the eager runtime exposes. Requires the artifact to carry the derivative graph (compiled with ``neml2-compile ... -d OUT:IN``); otherwise the underlying binding raises. """ return self._inner.jvp(inputs, tangents, param_overrides or {})
[docs] def jacobian( self, inputs: dict[str, torch.Tensor], param_overrides: dict[str, torch.Tensor] | None = None, ) -> tuple[dict[str, torch.Tensor], dict[str, dict[str, torch.Tensor]]]: """Outputs + the per-(output, input) Jacobian blocks from the compiled ``jacobian`` graph. ``inputs`` is a ``{variable_name: tensor}`` dict. Returns ``(outputs, J)`` where ``J[out][in]`` is the block ``d(out)/d(in)``, shaped ``(batch, *out_base, *in_base)``. Like :meth:`jvp`, needs an artifact compiled with ``-d``. """ return self._inner.jacobian(inputs, param_overrides or {})
[docs] def param_jacobian( self, inputs: dict[str, torch.Tensor], param_overrides: dict[str, torch.Tensor] | None = None, ) -> tuple[dict[str, torch.Tensor], dict[str, dict[str, torch.Tensor]]]: """Outputs + the per-(output, parameter) Jacobian blocks ``d(out)/d(param)``. The reverse-mode parameter-derivative counterpart to :meth:`jacobian` (mirrors :meth:`neml2.models.model.Model.param_jacobian` on the other routes): ``inputs`` is a ``{variable_name: tensor}`` dict and the result is ``(outputs, P)`` with ``P[out][param]`` the block ``d(out)/d(param)``, keyed by qualified promoted-parameter name (see :meth:`named_parameters`). Needs an artifact with the parameter promoted (``neml2-compile ... -p NAME``) and its derivative graph (``-d``). """ return self._inner.param_jacobian(inputs, param_overrides or {})
[docs] def param_vjp( self, inputs: dict[str, torch.Tensor], cotangents: dict[str, torch.Tensor], param_overrides: dict[str, torch.Tensor] | None = None, ) -> dict[str, torch.Tensor]: """Reverse-mode parameter VJP -- ``cotangent`` contracted with ``d(out)/d(param)``. The reverse-mode counterpart to :meth:`param_jacobian` (mirrors :meth:`neml2.models.model.Model.param_vjp`): ``inputs`` is a ``{variable_name: tensor}`` dict, ``cotangents`` is keyed by output name, and the result is keyed by qualified promoted-parameter name. Needs a ``-p`` / ``-d`` artifact. """ return self._inner.param_vjp(inputs, cotangents, param_overrides or {})
[docs] def named_parameters(self) -> dict[str, torch.Tensor]: # type: ignore[override] """The promoted parameters as a mutable ``{qualified_name: tensor}`` dict. Overrides ``nn.Module.named_parameters`` -- whose ``(name, Parameter)`` iterator would be empty here, since the shim registers no ``nn.Parameter``s of its own (the calibratable values live in the compiled binding). This returns the binding's promoted-parameter surface, the same dict the eager runtime and the C++ routes expose. Entries may be mutated in place (``m.named_parameters()["elasticity.E"].fill_(...)``) or replaced via :meth:`set_parameter`; the change is seen on the next call. Only parameters promoted at compile time (``neml2-compile ... -p NAME``) appear -- with none promoted the dict is empty. """ return self._inner.named_parameters()
[docs] def set_parameter(self, name: str, value: torch.Tensor) -> None: """Replace a promoted parameter's value (forwarded to the binding). ``name`` is a qualified promoted-parameter name (a key of :meth:`named_parameters`); ``value`` must match the artifact's pinned device/dtype. The new value is used on the next forward / derivative call. """ self._inner.set_parameter(name, value)
def _broadcast_to_common_batch( self, raw_inputs: dict[str, torch.Tensor], per_input_sub_ndim: dict[str, int], ) -> tuple[dict[str, torch.Tensor], torch.Size]: """Bring every input tensor to its declared ``(*dyn, *sub, *base)`` shape, with the dynamic-batch axes broadcast to a single common shape. Thin wrapper over the shared :func:`neml2.types._boundary.broadcast_to_common_batch`; see there for the (dyn, sub, base) per-input split rationale. The per-input ``sub_batch_ndim`` is resolved from the caller's typed wrapper instance (or the legacy metadata ``sub_batch_shape`` when the caller passes a raw tensor) before this call. """ return broadcast_to_common_batch(raw_inputs, self.input_spec, per_input_sub_ndim)