# 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.
"""Assembled vector / matrix blocks on top of :class:`~neml2.types.Tensor`.
v2-parity (V2P-6 rewrite). Each variable group declares a
:data:`~neml2.es.axis_layout.SubBatchStructure` -- either ``"block"`` or
``"dense"`` -- on the owning :class:`AxisLayout`. Assembly behaviour
branches on this flag:
* **Block group**: the group's sub_batch axes are PRESERVED as intermediate
dims on the per-group assembled tensor.
* Vector: ``(*dyn, *sub_batch, group_storage_size)`` -- per-site
independence preserved; the underlying solver batches per site
naturally.
* Matrix block: ``(*dyn, *intmd_row, *intmd_col, row_storage, col_storage)``.
Each side's sub_batch axes appear as intmd dims (in row order then
col order). For a per-grain × per-grain block both sides share the
same per-grain extent and the intmd dims double-stack with the
eye-diagonal storage convention.
* **Dense group**: the group's sub_batch axes are FOLDED into the base
storage at assembly time.
* Vector: ``(*dyn, group_storage_size_with_sub_folded)``.
* Matrix block: ``(*dyn, row_storage, col_storage)``.
Per-block decision in ``_build_group_block``:
* both row group AND col group dense -> dense block (fold everything).
* either side block -> keep that side's sub_batch as intmd on the block.
``AssembledMatrix.__matmul__`` mirrors v2: standard ``mm`` reduces along
the base axes; an additional ``sum`` over the intmd axes fires when the
contracting (inner) group's :data:`SubBatchStructure` is ``"block"``.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from math import prod
from typing import TYPE_CHECKING, overload
import torch
from neml2.types import Tensor, TensorWrapper
from neml2.types import cat as _cat
from ._helpers import _storage_size, _tangent_block_to_trailing_k
from .axis_layout import AxisLayout
if TYPE_CHECKING:
from neml2.models.chain_rule import ChainRuleDict
from neml2.models.model import Model
from .sparse import SparseMatrix, SparseVector
# ---------------------------------------------------------------------------
# AssembledVector
# ---------------------------------------------------------------------------
[docs]
@dataclass
class AssembledVector:
"""Per-group dense vector blocks.
Each group's tensor follows the :data:`SubBatchStructure` of the owning
:class:`AxisLayout`:
* ``"block"`` -> ``(*dyn, *sub_batch, group_storage_size)``,
``base_ndim=1``, ``sub_batch_ndim=len(sub_batch_shape)``.
* ``"dense"`` -> ``(*dyn, group_storage_size_with_sub_folded)``,
``base_ndim=1``, ``sub_batch_ndim=0``.
Arithmetic and dot products forward to :class:`Tensor` ops.
"""
layout: AxisLayout
tensors: list[Tensor]
[docs]
@classmethod
def from_dict(
cls,
layout: AxisLayout,
values: Mapping[str, TensorWrapper],
) -> AssembledVector:
"""Pack typed-wrapper ``values`` (one per variable) into per-group tensors.
For a ``"block"`` group, the per-variable wrappers are flattened to
base then concatenated along base (last) axis -- sub_batch axes
stay as intermediate dims on the resulting Tensor.
For a ``"dense"`` group, each wrapper's sub_batch is folded INTO
the variable's flat base storage before concat: per-variable
contribution becomes ``(*dyn, sub_total * base_size)``, then all
contributions concat to ``(*dyn, sum_var (sub_total * base_size))``.
Per CLAUDE.md rule 1: ``values`` is strictly typed -- raw
``torch.Tensor`` is rejected. External boundaries wrap with the
appropriate ``TensorWrapper`` subclass at the construction site.
"""
tensors: list[Tensor] = []
for gi, group in enumerate(layout.groups):
structure = layout.structure[gi]
parts: list[Tensor] = []
for name in group:
wrapped = values[name]
t = Tensor.from_typed(wrapped).flatten_base()
if structure == "dense" and t.sub_batch_ndim > 0:
t = t.flatten_sub_batch_into_first_base_axis()
parts.append(t)
tensors.append(_cat_along_base(parts))
return cls(layout, tensors)
[docs]
def disassemble(self) -> SparseVector:
"""Unpack per-group tensors back into a :class:`SparseVector`.
Inverse of :meth:`from_dict` -- the per-group split point is the
per-variable storage size; for ``"dense"`` groups the sub_batch
is unfolded back to the declared shape before re-typing.
Returns a :class:`SparseVector` (the typed dual of this object).
The underlying name -> typed-wrapper dict is on
``.values`` for callers that need the raw mapping.
"""
from .sparse import SparseVector # local: avoid circular import
out: dict[str, TensorWrapper] = {}
for gi, group in enumerate(self.layout.groups):
structure = self.layout.structure[gi]
t = self.tensors[gi]
slices = self._split_group(t, group, structure)
for name, part in zip(group, slices, strict=True):
type_cls = self.layout.specs[name]
sb = self.layout.sub_batch_shape(name)
if structure == "dense" and sb:
# Unfold sub_batch from the trailing base axis.
var_base = _storage_size(type_cls)
# part has shape (*dyn, sub_total * var_base); reshape to
# (*dyn, *sub, var_base) then unflatten base.
target = (*part.data.shape[:-1], *tuple(int(s) for s in sb), var_base)
raw = part.data.reshape(target)
new_t = Tensor(
raw,
batch_ndim=part.batch_ndim,
sub_batch_ndim=len(sb),
)
out[name] = new_t.unflatten_base(*type_cls.BASE_SHAPE).as_typed(type_cls)
else:
out[name] = part.unflatten_base(*type_cls.BASE_SHAPE).as_typed(type_cls)
return SparseVector(self.layout, out)
def _split_group(
self,
t: Tensor,
group: tuple[str, ...],
structure: str,
) -> list[Tensor]:
per_var_sizes = [
(
_storage_size(self.layout.specs[n])
* (prod([int(s) for s in self.layout.sub_batch_shape(n)]) or 1)
if structure == "dense"
else _storage_size(self.layout.specs[n])
)
for n in group
]
cuts: list[Tensor] = []
start = 0
flat = t.data
for size in per_var_sizes:
slc = flat[..., start : start + size]
cuts.append(Tensor(slc, batch_ndim=t.batch_ndim, sub_batch_ndim=t.sub_batch_ndim))
start += size
return cuts
def __neg__(self) -> AssembledVector:
return AssembledVector(self.layout, [-t for t in self.tensors])
def __add__(self, other: AssembledVector) -> AssembledVector:
if self.layout != other.layout:
raise ValueError("AssembledVector + requires matching layouts")
return AssembledVector(
self.layout, [a + b for a, b in zip(self.tensors, other.tensors, strict=True)]
)
def __sub__(self, other: AssembledVector) -> AssembledVector:
if self.layout != other.layout:
raise ValueError("AssembledVector - requires matching layouts")
return AssembledVector(
self.layout, [a - b for a, b in zip(self.tensors, other.tensors, strict=True)]
)
[docs]
def group(self, index: int) -> AssembledVector:
return AssembledVector(self.layout.sub_layout(index), [self.tensors[index]])
def wrap_group_raw(
raw: torch.Tensor,
group_names: tuple[str, ...],
structure: str,
layout: AxisLayout,
) -> Tensor:
"""Wrap a per-group raw tensor into a typed dynamic-base :class:`Tensor`.
Inverse of the per-group ``.data`` extraction at the solver / AOTI
framework boundary: it infers ``batch_ndim`` / ``sub_batch_ndim`` from the
group's declared :data:`SubBatchStructure` so the result drops straight
into an :class:`AssembledVector` and round-trips through
:meth:`AssembledVector.disassemble`. Used wherever per-group raw tensors
re-enter the typed world (the eager Newton boundary, the AOTI export
segment inputs).
"""
if not group_names:
return Tensor(raw, batch_ndim=raw.ndim, sub_batch_ndim=0)
first_name = group_names[0]
if structure == "block":
# (*dyn, *sub_batch, group_base_total): base_ndim=1.
sub_ndim = len(layout.sub_batch_shape(first_name))
return Tensor(raw, batch_ndim=raw.ndim - sub_ndim - 1, sub_batch_ndim=sub_ndim)
# DENSE: (*dyn, group_total); sub_batch folded into base.
return Tensor(raw, batch_ndim=raw.ndim - 1, sub_batch_ndim=0)
def group_block_sub_batch_ndim(
row_layout: AxisLayout,
row_gi: int,
col_layout: AxisLayout,
col_gi: int,
) -> int:
"""The ``sub_batch_ndim`` of the assembled ``(row_gi, col_gi)`` block.
Single source of truth mirroring the storage convention in
:func:`_build_group_block` / :func:`_zero_block` /
:func:`_convert_tangent_to_paired_block`: a DENSE side folds its sub_batch
into base (0 intmd axes); a BLOCK side keeps its sub_batch as intmd axes; a
BLOCK+BLOCK pair with identical per-site shape is stored on the compact
paired diagonal (a single per-site axis, not the full grid).
Used to reconstruct a typed :class:`AssembledMatrix` from raw per-block
tensors at the solver graph boundary (:class:`~neml2.es.implicit.LinearSolve`):
only the (structural) ``sub_batch_ndim`` is needed there -- ``batch_ndim``
follows from the runtime tensor's ndim.
"""
rs = row_layout.structure[row_gi]
cs = col_layout.structure[col_gi]
r_sub = tuple(int(s) for s in row_layout.group_sub_batch_shape(row_gi))
c_sub = tuple(int(s) for s in col_layout.group_sub_batch_shape(col_gi))
if rs == "block" and cs == "block" and r_sub == c_sub and len(r_sub) > 0:
return len(r_sub) # paired diagonal (compact)
r_intmd = len(r_sub) if rs == "block" else 0
c_intmd = len(c_sub) if cs == "block" else 0
return r_intmd + c_intmd
def wrap_block_raw(raw: torch.Tensor, sub_batch_ndim: int) -> Tensor:
"""Wrap a raw assembled-matrix block into a typed dynamic-base :class:`Tensor`.
Inverse of the per-block ``.data`` extraction at the solver graph boundary: a
block is ``(*dyn, *sub, row_storage, col_storage)`` (``base_ndim=2``), so
``batch_ndim = raw.ndim - sub_batch_ndim - 2``. ``sub_batch_ndim`` comes from
:func:`group_block_sub_batch_ndim`.
"""
return Tensor(raw, batch_ndim=raw.ndim - sub_batch_ndim - 2, sub_batch_ndim=sub_batch_ndim)
# ---------------------------------------------------------------------------
# AssembledMatrix
# ---------------------------------------------------------------------------
[docs]
@dataclass
class AssembledMatrix:
"""2D grid of per-(row_group, col_group) tensor blocks.
Per-block storage decision:
* both row group AND col group dense -> shape
``(*dyn, row_storage_with_sub_folded, col_storage_with_sub_folded)``;
``base_ndim=2``, ``sub_batch_ndim=0``.
* any side block -> that side's sub_batch is preserved as intmd dims.
Shape ``(*dyn, *intmd_row, *intmd_col, row_storage, col_storage)``;
``base_ndim=2``, ``sub_batch_ndim = len(intmd_row) + len(intmd_col)``.
matmul:
* ``mm(aik, bkj)`` contracts the trailing two base axes.
* if ``col_layout.structure[k] == "block"`` (the inner group is block),
a sub_batch reduction (``sum`` over the intmd dims contributed by
group k) runs after the per-block mm. This is the v2-parity
"block intmd_sum after mm" pattern.
"""
row_layout: AxisLayout
col_layout: AxisLayout
tensors: list[list[Tensor]] = field(default_factory=list)
def __neg__(self) -> AssembledMatrix:
return AssembledMatrix(
self.row_layout,
self.col_layout,
[[-t for t in row] for row in self.tensors],
)
def __add__(self, other: AssembledMatrix) -> AssembledMatrix:
if self.row_layout != other.row_layout or self.col_layout != other.col_layout:
raise ValueError("AssembledMatrix + requires matching layouts")
return AssembledMatrix(
self.row_layout,
self.col_layout,
[
[a + b for a, b in zip(ra, rb, strict=True)]
for ra, rb in zip(self.tensors, other.tensors, strict=True)
],
)
def __sub__(self, other: AssembledMatrix) -> AssembledMatrix:
if self.row_layout != other.row_layout or self.col_layout != other.col_layout:
raise ValueError("AssembledMatrix - requires matching layouts")
return AssembledMatrix(
self.row_layout,
self.col_layout,
[
[a - b for a, b in zip(ra, rb, strict=True)]
for ra, rb in zip(self.tensors, other.tensors, strict=True)
],
)
[docs]
def group(self, i: int, j: int) -> AssembledMatrix:
return AssembledMatrix(
self.row_layout.sub_layout(i),
self.col_layout.sub_layout(j),
[[self.tensors[i][j]]],
)
[docs]
def disassemble(self) -> SparseMatrix:
"""Unpack per-block tensors into a :class:`SparseMatrix`.
Each cell ``cells[row_var][col_var]`` is a typed dynamic-base
:class:`~neml2.types.Tensor` that carries the block's ``batch_ndim``
/ ``sub_batch_ndim`` verbatim. For ``"dense"`` blocks the sub_batch
axes have been folded into base, so the slices are flat in row +
col. Boundary callers (e.g. the pyzag interface) read the
underlying dict-of-dict via ``.cells`` and unwrap to raw ``.data``
at their own framework boundary.
"""
from .sparse import SparseMatrix # local: avoid circular import
out: dict[str, dict[str, Tensor]] = {}
for row_group in self.row_layout.groups:
for name in row_group:
out[name] = {}
for gi, row_group in enumerate(self.row_layout.groups):
row_structure = self.row_layout.structure[gi]
row_sizes = [
self._var_storage(self.row_layout, gi, n, row_structure) for n in row_group
]
for gj, col_group in enumerate(self.col_layout.groups):
col_structure = self.col_layout.structure[gj]
col_sizes = [
self._var_storage(self.col_layout, gj, n, col_structure) for n in col_group
]
t = self.tensors[gi][gj]
# Split last (col) then -2 (row), keeping the typed Tensor
# wrapper so batch_ndim / sub_batch_ndim survive at the
# per-block surface (no .data leakage).
r_start = 0
for ri, rname in enumerate(row_group):
r_size = row_sizes[ri]
c_start = 0
for cj, cname in enumerate(col_group):
c_size = col_sizes[cj]
out[rname][cname] = t.base[
..., r_start : r_start + r_size, c_start : c_start + c_size
]
c_start += c_size
r_start += r_size
return SparseMatrix(self.row_layout, self.col_layout, out)
[docs]
@staticmethod
def select_blocks(
row_layout: AxisLayout,
col_layout: AxisLayout,
blocks: dict[str, dict[str, Tensor]],
) -> AssembledMatrix:
"""Build an :class:`AssembledMatrix` from per-(row_var, col_var) typed blocks.
Inverse of :meth:`disassemble`. Each ``blocks[row_name][col_name]`` is
a typed dynamic-base :class:`~neml2.types.Tensor` with
``base_ndim=2`` and ``base_shape == (row_storage, col_storage)``
sized to match :meth:`_var_storage` for the given layout structure.
Missing (row_var, col_var) pairs are zero-filled.
Only the dense-x-dense case is supported (no intmd sub_batch dims on
either side) -- that's the only path the pyzag interface and the
round-trip test currently exercise. Block-structure layouts would need
the corresponding sub_batch axes to live in each block's storage
and the cat axes shifted accordingly; raise so callers don't
silently get the wrong shape.
"""
for structure in (*row_layout.structure, *col_layout.structure):
if structure != "dense":
raise NotImplementedError(
f"select_blocks only handles dense layouts; got structure={structure!r}"
)
# Find one present block to source the dyn-batch shape, dtype, and
# device for the zero-fill on missing (row_var, col_var) pairs.
ref: Tensor | None = None
for cols in blocks.values():
for t in cols.values():
ref = t
break
if ref is not None:
break
if ref is None:
raise ValueError("select_blocks: empty blocks dict (need at least one to size zeros)")
batch_ndim = ref.batch_ndim
dyn_shape = tuple(ref.batch_shape)
dtype = ref.dtype
device = ref.device
out: list[list[Tensor]] = []
for gi, row_group in enumerate(row_layout.groups):
row_structure = row_layout.structure[gi]
row_sizes = [
AssembledMatrix._var_storage(row_layout, gi, n, row_structure) for n in row_group
]
row_blocks: list[Tensor] = []
for gj, col_group in enumerate(col_layout.groups):
col_structure = col_layout.structure[gj]
col_sizes = [
AssembledMatrix._var_storage(col_layout, gj, n, col_structure)
for n in col_group
]
# Assemble a (sum(row_sizes), sum(col_sizes)) block by laying
# the per-(rname, cname) entries out in row-major order via
# typed base-region cat.
row_strips: list[Tensor] = []
for ri, rname in enumerate(row_group):
r_size = row_sizes[ri]
col_strips: list[Tensor] = []
for cj, cname in enumerate(col_group):
c_size = col_sizes[cj]
b = blocks.get(rname, {}).get(cname)
if b is None:
b = Tensor(
torch.zeros(*dyn_shape, r_size, c_size, dtype=dtype, device=device),
batch_ndim=batch_ndim,
sub_batch_ndim=0,
)
col_strips.append(b)
row_strips.append(_cat([t.base for t in col_strips], dim=-1))
row_blocks.append(_cat([t.base for t in row_strips], dim=-2))
out.append(row_blocks)
return AssembledMatrix(row_layout, col_layout, out)
@staticmethod
def _var_storage(
layout: AxisLayout,
gi: int,
name: str,
structure: str,
) -> int:
type_cls = layout.specs[name]
base = _storage_size(type_cls)
if structure == "dense":
sb = layout.sub_batch_shape(name)
return base * (prod([int(s) for s in sb]) or 1)
return base
@overload
def __matmul__(self, other: AssembledVector) -> AssembledVector: ...
@overload
def __matmul__(self, other: AssembledMatrix) -> AssembledMatrix: ...
def __matmul__(self, other):
"""Block matmul ``C = A @ B`` (matrix RHS) or ``c = A @ b`` (vector RHS).
v2-parity:
``Cij = sum_k mm(Aik, Bkj)`` plus intmd_sum on the result when
``col_layout.structure[k] == "block"`` (reduces the BLOCK k's
intmd axes that survived mm).
"""
if isinstance(other, AssembledVector):
if self.col_layout != other.layout:
raise ValueError(
"AssembledMatrix @ AssembledVector requires matching inner layouts"
)
out_tensors: list[Tensor] = []
for i in range(self.row_layout.ngroup):
acc: Tensor | None = None
for k in range(self.col_layout.ngroup):
aik = self.tensors[i][k]
bk = other.tensors[k]
r = aik @ bk
if self.col_layout.structure[k] == "block":
r = _intmd_sum_k(r, self.col_layout, k)
acc = r if acc is None else acc + r
assert acc is not None
out_tensors.append(acc)
return AssembledVector(self.row_layout, out_tensors)
if not isinstance(other, AssembledMatrix):
raise TypeError(
f"AssembledMatrix @ expected AssembledVector or AssembledMatrix; "
f"got {type(other).__name__}"
)
if self.col_layout != other.row_layout:
raise ValueError("AssembledMatrix @ AssembledMatrix requires matching inner layouts")
out_blocks: list[list[Tensor]] = []
for i in range(self.row_layout.ngroup):
row_blocks: list[Tensor] = []
for j in range(other.col_layout.ngroup):
acc: Tensor | None = None
for k in range(self.col_layout.ngroup):
aik = self.tensors[i][k]
bkj = other.tensors[k][j]
r = aik @ bkj
if self.col_layout.structure[k] == "block":
r = _intmd_sum_k(r, self.col_layout, k)
acc = r if acc is None else acc + r
assert acc is not None
row_blocks.append(acc)
out_blocks.append(row_blocks)
return AssembledMatrix(self.row_layout, other.col_layout, out_blocks)
# ---------------------------------------------------------------------------
# Norms (free functions, mirror v2 names)
# ---------------------------------------------------------------------------
[docs]
def norm(v: AssembledVector) -> torch.Tensor:
"""Batched Euclidean norm over all assembled vector groups."""
return torch.sqrt(norm_sq(v))
[docs]
def norm_sq(v: AssembledVector) -> torch.Tensor:
"""Batched squared Euclidean norm over all assembled vector groups."""
total: torch.Tensor | None = None
for gi, t in enumerate(v.tensors):
# Sum over base axis; for BLOCK groups also sum over intmd sub_batch.
squared = t.data * t.data
# Always sum over base axis.
squared = squared.sum(dim=-1)
if v.layout.structure[gi] == "block":
# Sum over the sub_batch intmd axes (they live at positions
# batch_ndim : batch_ndim + sub_batch_ndim).
for _ in range(t.sub_batch_ndim):
squared = squared.sum(dim=-1)
total = squared if total is None else total + squared
assert total is not None
return total
# ---------------------------------------------------------------------------
# Block assembly from chain-rule tangents
# ---------------------------------------------------------------------------
def _build_block_matrix(
model: Model,
row_layout: AxisLayout,
col_layout: AxisLayout,
v_out: ChainRuleDict,
like_by_row: Mapping[str, Tensor],
) -> AssembledMatrix:
"""Assemble the full block matrix from chain-rule tangents.
Iterates ``(row_group, col_group)`` pairs and dispatches to
:func:`_build_group_block` for each.
"""
blocks: list[list[Tensor]] = []
for i, row_group in enumerate(row_layout.groups):
row_blocks: list[Tensor] = []
for j, col_group in enumerate(col_layout.groups):
block = _build_group_block(
model,
row_group,
col_group,
row_layout,
col_layout,
row_structure=row_layout.structure[i],
col_structure=col_layout.structure[j],
row_gi=i,
col_gi=j,
v_out=v_out,
like_by_row=like_by_row,
)
row_blocks.append(block)
blocks.append(row_blocks)
return AssembledMatrix(row_layout, col_layout, blocks)
def _build_group_block(
model: Model,
row_group: tuple[str, ...],
col_group: tuple[str, ...],
row_layout: AxisLayout,
col_layout: AxisLayout,
row_structure: str,
col_structure: str,
row_gi: int,
col_gi: int,
v_out: ChainRuleDict,
like_by_row: Mapping[str, Tensor],
) -> Tensor:
"""Assemble one ``(row_group, col_group)`` block as a :class:`Tensor`.
Per-(row_var, col_var) tangent is converted to trailing-K form, with
K = (sub_extents × col_base_size) for the col side. The per-variable
contributions are concatenated along the row axis (-2 in base) and
column axis (-1 in base).
Per structure:
* dense row AND dense col: sub_batch of both sides folded into base
(rows fold sub_row into row_base, cols' K is already (sub_col_total *
col_base) from the chain-rule fullify).
* block row: sub_row stays as leading intmd dim on the assembled block.
* block col: K still includes sub_col_total * col_base (no separate
handling needed at assembly; the matmul-side intmd_sum on BLOCK k
reduces in the trailing kp dim).
"""
# Determine row dyn shape from the row group's `like` template.
# All vars in a group share the same dyn shape by construction.
# ``like`` is the typed dynamic-base Tensor returned by
# :func:`_flatten_base`, which collapses base to a single trailing
# axis (BASE_NDIM=0 vars get a unsqueeze; BASE_NDIM>0 vars get
# reshape). The typed wrapper carries its own ``batch_ndim`` --
# use that directly instead of computing from total ndim minus
# BASE_NDIM minus sub, which double-counts for the BASE_NDIM=0
# case (the trailing axis added by ``_flatten_base`` isn't
# reflected in the spec's BASE_NDIM).
first_row = row_group[0]
like = like_by_row[first_row]
row_sb = row_layout.sub_batch_shape(first_row)
row_batch_dyn = tuple(like.shape[: like.batch_ndim])
# Per-row build.
row_parts: list[Tensor] = []
for row_name in row_group:
col_parts: list[Tensor] = []
for col_name in col_group:
col_var_size = _storage_size(col_layout.specs[col_name])
col_sb = col_layout.sub_batch_shape(col_name)
col_sb_total = prod([int(s) for s in col_sb]) or 1
# Expected trailing-K for this col var: sub_col_total * var_base.
expected_K = col_sb_total * col_var_size
# Tangent block for this (row, col) pair.
seed_key = f"{col_name}:rgroup{row_gi}"
t_block = v_out.get(row_name, {}).get(seed_key)
if t_block is None:
# Zero block.
block = _zero_block(
row_batch_dyn,
row_sb if row_structure == "block" else (),
row_structure,
col_structure,
row_name,
col_name,
row_layout,
col_layout,
like,
)
else:
block = _convert_tangent_to_block(
t_block,
row_name,
col_name,
row_layout,
col_layout,
row_structure,
col_structure,
row_gi=row_gi,
col_gi=col_gi,
row_batch_dyn=row_batch_dyn,
expected_K=expected_K,
)
col_parts.append(block)
# Concat per-col contributions on trailing base axis (col K).
row_parts.append(_cat_along_base(col_parts))
# Concat per-row contributions on row axis (-2).
if len(row_parts) == 1:
return row_parts[0]
return _cat_along_row(row_parts)
def _zero_block(
row_batch_dyn: tuple[int, ...],
row_intmd: tuple[int, ...],
row_structure: str,
col_structure: str,
row_name: str,
col_name: str,
row_layout: AxisLayout,
col_layout: AxisLayout,
like: Tensor,
) -> Tensor:
row_var_base = _storage_size(row_layout.specs[row_name])
col_var_base = _storage_size(col_layout.specs[col_name])
col_sb = col_layout.sub_batch_shape(col_name)
col_sb_total = prod([int(s) for s in col_sb]) or 1
row_var_base = _storage_size(row_layout.specs[row_name])
row_sb = row_layout.sub_batch_shape(row_name)
row_sb_total = prod([int(s) for s in row_sb]) or 1
# Paired BLOCK+BLOCK: single per-site intmd axis to match
# _convert_tangent_to_paired_block's storage convention.
paired = (
row_structure == "block"
and col_structure == "block"
and tuple(int(s) for s in row_sb) == tuple(int(s) for s in col_sb)
and len(row_sb) > 0
)
if paired:
site_shape = tuple(int(s) for s in row_sb)
full_shape = (*row_batch_dyn, *site_shape, row_var_base, col_var_base)
return Tensor(
torch.zeros(full_shape, dtype=like.dtype, device=like.device),
batch_ndim=len(row_batch_dyn),
sub_batch_ndim=len(site_shape),
)
if row_structure == "dense":
row_dim = row_var_base * row_sb_total
row_intmd_shape: tuple[int, ...] = ()
else:
row_dim = row_var_base
row_intmd_shape = tuple(int(s) for s in row_sb)
if col_structure == "dense":
col_dim = col_var_base * col_sb_total
col_intmd_shape: tuple[int, ...] = ()
else:
col_dim = col_var_base
col_intmd_shape = tuple(int(s) for s in col_sb)
full_shape = (
*row_batch_dyn,
*row_intmd_shape,
*col_intmd_shape,
row_dim,
col_dim,
)
sub_ndim = len(row_intmd_shape) + len(col_intmd_shape)
return Tensor(
torch.zeros(full_shape, dtype=like.dtype, device=like.device),
batch_ndim=len(row_batch_dyn),
sub_batch_ndim=sub_ndim,
)
def _convert_tangent_to_paired_block(
t_block: TensorWrapper,
paired_sb: tuple[int, ...],
col_var_base: int,
) -> Tensor:
"""Build the paired-site assembled block ``(*dyn, *site, row_base, col_base)``.
For a BLOCK+BLOCK pair with identical per-site sub_batch shape on both
sides, the chain-rule tangent's K_paired axes are broadcast (size 1) in
storage against per-site sub axes -- the per-site Jacobian is the
eye-diagonal of the would-be full (site_row × site_col) grid. Storing
only the diagonal (single paired-site intmd axis) lets the downstream
linear solver batch over sites as independent per-site inversions.
Pipeline (no fullify -- we want to keep K_paired compact, not expand):
1. Materialise any sub_batch broadcast axes so the sub region carries
the per-site values explicitly.
2. Squeeze every K_paired axis (size 1 broadcast against the paired
sub axis) out of leading position.
3. Move the remaining single K_base axis (size ``col_var_base``) to
trailing as the col_base storage axis. Flatten the row base axes
in front of it.
4. Result shape: ``(*dyn, *site, row_base, col_base)``.
"""
# Materialise sub_batch broadcast so the per-site sub axes carry their
# true extent (the chain rule may have left them broadcast against
# paired K).
block_w = t_block
if block_w.sub_batch_state and any(s == "broadcast" for s in block_w.sub_batch_state):
block_w = block_w.materialize()
if block_w.sub_batch_ndim != len(paired_sb):
raise ValueError(
f"_convert_tangent_to_paired_block: tangent sub_batch_ndim={block_w.sub_batch_ndim} "
f"doesn't match paired_sb={paired_sb}"
)
# Drop broadcast K_paired axes -- they are size 1 in storage and the
# paired-site information now lives on the sub axis.
drop_axes = [
i
for i, (s, p) in enumerate(zip(block_w.k_state, block_w.k_pairing, strict=True))
if s == "broadcast" and p is not None and int(block_w.data.shape[i]) == 1
]
data = block_w.data
new_k_state = list(block_w.k_state)
new_k_pairing = list(block_w.k_pairing)
for i in sorted(drop_axes, reverse=True):
data = data.squeeze(i)
new_k_state.pop(i)
new_k_pairing.pop(i)
new_k_ndim = len(new_k_state)
if new_k_ndim == 0:
# Scalar-col or base-trivial case: the chain rule emitted no K_base
# (col_var_base == 1). Insert a leading size-1 axis so the rest of
# the pipeline can treat it as the col_base axis.
if col_var_base != 1:
raise ValueError(
f"_convert_tangent_to_paired_block: tangent has no K_base but "
f"col_var_base={col_var_base} (expected 1 for Scalar cols)"
)
data = data.unsqueeze(0)
new_k_ndim = 1
elif new_k_ndim != 1:
raise ValueError(
f"_convert_tangent_to_paired_block: expected k_ndim=1 after squeezing "
f"paired-broadcast K, got k_ndim={new_k_ndim} (k_state={new_k_state})"
)
if int(data.shape[0]) != col_var_base:
raise ValueError(
f"_convert_tangent_to_paired_block: K_base size {data.shape[0]} "
f"!= col_var_base {col_var_base}"
)
base_ndim = type(block_w).BASE_NDIM
sub_ndim = block_w.sub_batch_ndim
# Layout after squeeze: (K_base, *dyn, *site, *row_base).
# Move K_base to trailing -> (*dyn, *site, *row_base, col_base).
moved = data.movedim(0, -1)
if base_ndim > 1:
leading = moved.shape[: -1 - base_ndim]
site = moved.shape[len(leading) : len(leading) + sub_ndim]
row_part = moved.shape[len(leading) + sub_ndim : -1]
row_total = 1
for s in row_part:
row_total *= int(s)
moved = moved.reshape(*leading, *site, row_total, int(moved.shape[-1]))
elif base_ndim == 0:
moved = moved.unsqueeze(-2)
return Tensor(
moved,
batch_ndim=moved.ndim - 1 - 1 - sub_ndim,
sub_batch_ndim=sub_ndim,
)
def _convert_tangent_to_block(
t_block: TensorWrapper,
row_name: str,
col_name: str,
row_layout: AxisLayout,
col_layout: AxisLayout,
row_structure: str,
col_structure: str,
row_gi: int,
col_gi: int,
row_batch_dyn: tuple[int, ...],
expected_K: int,
) -> Tensor:
"""Convert a chain-rule tangent block to the assembled block shape.
Pipeline:
1. Move K to trailing via :func:`_tangent_block_to_trailing_k`
(which fullifies broadcast K_paired axes internally). Result is a
:class:`Tensor` with shape ``(*dyn, *sub_row, row_base, K)`` and
``K = sub_col_total * col_base`` after fullify.
2. If row_structure == "dense": fold sub_row into row_base. Resulting
shape: ``(*dyn, sub_row_total * row_base, K)``.
3. If col_structure == "block": unflatten the trailing K into
``(*sub_col, col_base)`` so the col intmd dims sit between row and
col base. Resulting shape: ``(*dyn, [*sub_row,] *sub_col,
row_storage, col_base)``.
"""
row_sb = row_layout.sub_batch_shape(row_name)
col_sb = col_layout.sub_batch_shape(col_name)
col_var_base = _storage_size(col_layout.specs[col_name])
col_sb_total = prod([int(s) for s in col_sb]) or 1
# Paired BLOCK+BLOCK: same per-site sub_batch shape on both sides means
# the per-(site_row, site_col) Jacobian is non-zero only on the diagonal
# site_row == site_col. Store a single intmd axis (paired-site) rather
# than a sub_row × sub_col grid of mostly-zero cells -- otherwise the
# downstream torch.linalg.solve sees off-diagonal site cells as
# independent zero (10, 10) batches and reports singular.
if (
row_structure == "block"
and col_structure == "block"
and tuple(int(s) for s in row_sb) == tuple(int(s) for s in col_sb)
and len(row_sb) > 0
):
return _convert_tangent_to_paired_block(
t_block,
paired_sb=tuple(int(s) for s in row_sb),
col_var_base=col_var_base,
)
block = _tangent_block_to_trailing_k(t_block)
# block has trailing-K shape (*dyn, *sub_row, row_base, K)
# where sub_row = block.sub_batch_shape (may be empty if no per-grain row).
# Trailing K should equal expected_K after fullify; if smaller (compact
# tangent never inflated), tile.
actual_K = int(block.data.shape[-1])
if actual_K != expected_K:
if expected_K % actual_K != 0:
raise ValueError(
f"chain-rule block K={actual_K} for ({row_name!r},{col_name!r}) "
f"doesn't divide expected K={expected_K}"
)
repeat = expected_K // actual_K
idx = torch.arange(expected_K, dtype=torch.long, device=block.data.device) % actual_K
del repeat
new_data = block.data[..., idx]
block = Tensor(new_data, batch_ndim=block.batch_ndim, sub_batch_ndim=block.sub_batch_ndim)
# Row sub_batch handling.
have_row_sub = block.sub_batch_ndim > 0
if row_structure == "dense" and have_row_sub:
# Fold sub_row into the row_base axis.
# Shape: (*dyn, *sub_row, row_base, K) -> (*dyn, sub_row_total*row_base, K)
block = block.flatten_sub_batch_into_first_base_axis()
have_row_sub = False
# Col sub_batch handling: if col_structure == "block", unflatten K into
# (*sub_col, col_base) so col intmd dims appear between row and col
# base. The K trailing axis equals col_sb_total * col_var_base.
if col_structure == "block" and col_sb:
# block.data shape: (*dyn, [*sub_row,] row_storage, K=sub_col_total*col_base)
# Reshape K -> (*sub_col, col_base) so the trailing 2+len(sub_col)
# axes become (row_base, *sub_col, col_base). Then permute to swap
# row_base behind sub_col, giving (*dyn, [*sub_row,] *sub_col,
# row_base, col_base).
old_shape = block.data.shape
K_dim = old_shape[-1]
if K_dim != col_sb_total * col_var_base:
raise ValueError(
f"K dim {K_dim} != sub_col_total({col_sb_total}) * col_base({col_var_base})"
)
col_intmd_shape = tuple(int(s) for s in col_sb)
# After reshape, axes look like:
# (..., row_base, sub_col_0, sub_col_1, ..., col_base)
# row_base sits at axis -(2 + len(col_intmd_shape)) of the new tensor.
new_shape = (*old_shape[:-1], *col_intmd_shape, col_var_base)
new_data = block.data.reshape(new_shape)
ndim = new_data.ndim
row_base_axis = ndim - 2 - len(col_intmd_shape)
# Build permutation that moves row_base from row_base_axis to
# position right BEFORE col_base (i.e. ndim - 2). Sub_col axes
# shift left by one to fill the gap.
perm = list(range(ndim))
perm.pop(row_base_axis)
perm.insert(ndim - 2, row_base_axis)
new_data = new_data.permute(perm).contiguous()
block = Tensor(
new_data,
batch_ndim=block.batch_ndim,
sub_batch_ndim=block.sub_batch_ndim + len(col_intmd_shape),
)
# NB: we do NOT broadcast to row_batch_dyn here. The tangent's natural
# batch_shape is what the chain rule produced; cross-block alignment is
# handled by torch broadcasting at matmul / cat time. row_batch_dyn was
# only ever a hint derived from one variable's `like` template; forcing
# the tangent into it caused size-6 vs size-1 conflicts when the
# tangent had legitimate dyn axes from upstream chain-rule combines.
del row_batch_dyn
return block
def _intmd_sum_k(r: Tensor, col_layout: AxisLayout, k: int) -> Tensor:
"""Reduce along the inner-group k's intmd axes on a matmul result.
For BLOCK k_group, the post-mm tensor has the col-side intmd axes
that were inherited from the k group's BLOCK structure. Sum them out.
The intmd axes contributed by the k group are the FIRST k_sub_ndim
axes of the result's sub_batch region (because the matmul preserved
them through broadcasting / inheritance).
"""
k_sb = col_layout.group_sub_batch_shape(k)
n = len(k_sb)
if n == 0 or r.sub_batch_ndim == 0:
return r
# Determine which sub_batch axes correspond to the k group.
# Conservative: sum the last n sub_batch axes of r.
new_data = r.data
sb_start = new_data.ndim - r.data.ndim + r.batch_ndim
del sb_start
# The sub axes sit at positions [batch_ndim : batch_ndim + sub_batch_ndim].
# Sum the LAST n of those (they came from the col k via broadcast).
axes_to_sum = list(range(r.batch_ndim + r.sub_batch_ndim - n, r.batch_ndim + r.sub_batch_ndim))
summed = new_data.sum(dim=axes_to_sum)
return Tensor(
summed,
batch_ndim=r.batch_ndim,
sub_batch_ndim=r.sub_batch_ndim - n,
)
def _broadcast_batch(t: Tensor, target_dyn: tuple[int, ...]) -> Tensor:
"""Broadcast the batch region to ``target_dyn``.
If ``t`` already has the right shape, return unchanged. Excess leading
dims are squeezed when size-1; non-singleton extras raise.
"""
cur = tuple(t.batch_shape)
if cur == target_dyn:
return t
# Add leading size-1 axes if target has more dims.
if len(cur) < len(target_dyn):
pad = len(target_dyn) - len(cur)
new_data = t.data
for _ in range(pad):
new_data = new_data.unsqueeze(0)
return Tensor(
new_data.expand(*target_dyn, *new_data.shape[len(target_dyn) :]).contiguous(),
batch_ndim=len(target_dyn),
sub_batch_ndim=t.sub_batch_ndim,
)
# Trim leading size-1 axes.
n_extra = len(cur) - len(target_dyn)
new_data = t.data
for _ in range(n_extra):
if new_data.shape[0] != 1:
raise ValueError(
f"_broadcast_batch: extra leading dim of size {new_data.shape[0]} "
f"cannot be trimmed (cur={cur}, target={target_dyn})"
)
new_data = new_data.squeeze(0)
new_data = new_data.expand(*target_dyn, *new_data.shape[len(target_dyn) :]).contiguous()
return Tensor(
new_data,
batch_ndim=len(target_dyn),
sub_batch_ndim=t.sub_batch_ndim,
)
def _cat_along_base(parts: list[Tensor]) -> Tensor:
"""Concatenate Tensors along the LAST base axis. All inputs share
batch + sub_batch + non-last-base shapes; only the trailing axis varies.
"""
if not parts:
raise ValueError("_cat_along_base: empty parts list")
if len(parts) == 1:
return parts[0]
# Parts may have different ndims (zero blocks carry the row_batch_dyn
# leading axes, non-zero tangent blocks inherit the chain-rule's
# natural dyn shape). Left-pad each to the max ndim with size-1 axes,
# then broadcast everything except the trailing cat axis.
max_ndim = max(p.data.ndim for p in parts)
padded_data = []
for p in parts:
d = p.data
while d.ndim < max_ndim:
d = d.unsqueeze(0)
padded_data.append(d)
try:
target = torch.broadcast_shapes(*(d.shape[:-1] for d in padded_data))
except RuntimeError as e:
shapes = [tuple(p.data.shape) for p in parts]
raise RuntimeError(
f"_cat_along_base: broadcast failed with parts shapes {shapes}; original error: {e}"
) from e
broadcasted = [d.expand(*target, d.shape[-1]).contiguous() for d in padded_data]
cat_data = torch.cat(broadcasted, dim=-1)
# batch_ndim grows to match max_ndim - sub_batch_ndim - base_ndim
# (here base_ndim is the trailing axis we cat on; sub_batch_ndim is
# whatever the parts share).
parts_sub = parts[0].sub_batch_ndim
# Result base_ndim: base axes are everything after dyn + sub. The
# cat axis is the LAST base axis; preserve part's base_ndim count.
parts_base = parts[0].base_ndim
new_batch_ndim = cat_data.ndim - parts_sub - parts_base
return Tensor(cat_data, batch_ndim=new_batch_ndim, sub_batch_ndim=parts_sub)
def _cat_along_row(parts: list[Tensor]) -> Tensor:
"""Concatenate Tensors along the row axis (-2 in base, base_ndim>=2).
Left-pads parts of different ndim with size-1 leading axes (same
ragged-ndim tolerance as :func:`_cat_along_base`).
"""
if not parts:
raise ValueError("_cat_along_row: empty parts list")
if len(parts) == 1:
return parts[0]
max_ndim = max(p.data.ndim for p in parts)
padded_data = []
for p in parts:
d = p.data
while d.ndim < max_ndim:
d = d.unsqueeze(0)
padded_data.append(d)
target = torch.broadcast_shapes(*(d.shape[:-2] + d.shape[-1:] for d in padded_data))
bcs = []
for d in padded_data:
s = d.shape
full = (*target[:-1], s[-2], target[-1])
bcs.append(d.expand(full).contiguous())
cat_data = torch.cat(bcs, dim=-2)
parts_sub = parts[0].sub_batch_ndim
parts_base = parts[0].base_ndim
new_batch_ndim = cat_data.ndim - parts_sub - parts_base
return Tensor(cat_data, batch_ndim=new_batch_ndim, sub_batch_ndim=parts_sub)
__all__ = [
"AssembledVector",
"AssembledMatrix",
"norm",
"norm_sq",
"wrap_group_raw",
"wrap_block_raw",
"group_block_sub_batch_ndim",
"_build_block_matrix",
]