Source code for neml2.types.tensor

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

"""Dynamic-base-shape `Tensor` -- the storage unit for variable-pair Jacobian
blocks at the equation-system / solver layer.

`Tensor` is the Python-native analogue of v2's ``neml2::Tensor``. Unlike the
fixed-base-shape typed wrappers (`Scalar`, `SR2`, ...) where ``BASE_SHAPE`` is
a class invariant, `Tensor` carries ``batch_ndim`` and ``sub_batch_ndim`` as
runtime instance fields; ``base_ndim`` is recovered as
``data.ndim - batch_ndim - sub_batch_ndim``. The same `Tensor` class can
represent a Scalar, an SR2-shaped block, or an arbitrary ``(R_row, R_col)``
Jacobian -- whatever shape the equation-system assembly needs.

Shape decomposition::

    data.shape == (*batch_shape, *sub_batch_shape, *base_shape)
                  └── leading ──┘└── per-site ────┘└── matrix ──┘

`AssembledVector` / `AssembledMatrix` (in `neml2.es`) wrap
`Tensor` instances and forward arithmetic / matmul / solve to its methods --
the dense-vs-block-diagonal solver branch is implicit in `sub_batch_ndim`
(both cases are the same ``torch.linalg.solve`` call; block-diagonal just
has extra leading sub-batch axes that batch naturally).
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from math import prod
from typing import ClassVar, Literal, TypeAlias

import torch
from torch.utils import _pytree as _torch_pytree

from neml2.types._base import TensorWrapper

#: Region selector for ops that need to disambiguate which group of axes
#: a ``dim`` index addresses. ``"batch"`` = leading dynamic-batch axes,
#: ``"sub_batch"`` = per-site axes, ``"base"`` = trailing matrix/vector axes.
AxisKind: TypeAlias = Literal["batch", "sub_batch", "base"]


[docs] @dataclass(frozen=True, eq=False) class Tensor: """Dynamic-base-shape tensor with a ``(batch, sub_batch, base)`` runtime split.""" data: torch.Tensor batch_ndim: int = 0 sub_batch_ndim: int = 0 sub_batch_state: tuple = () sub_batch_meta: tuple = () k_ndim: int = 0 k_state: tuple = () k_pairing: tuple = () def __post_init__(self) -> None: if self.batch_ndim < 0 or self.sub_batch_ndim < 0: raise ValueError( f"Tensor batch_ndim and sub_batch_ndim must be non-negative, " f"got batch_ndim={self.batch_ndim}, sub_batch_ndim={self.sub_batch_ndim}" ) if self.batch_ndim + self.sub_batch_ndim > self.data.ndim: raise ValueError( f"Tensor batch_ndim ({self.batch_ndim}) + sub_batch_ndim " f"({self.sub_batch_ndim}) exceeds data.ndim ({self.data.ndim})" ) # ---- shape decomposition ---- @property def base_ndim(self) -> int: # K axes (chain-rule seed directions) sit ENTIRELY LEFT of # ``batch``; exclude them so base counts only the trailing # region. Without subtracting ``k_ndim`` here, a Tensor that # carries K (e.g. inside ``fullify``) would silently count its # leading K axes as part of base, mis-locating the # ``sub_batch`` region in helpers like # :func:`~neml2.types.functions.fullify` that compute # ``sub_data_axis = data.ndim - base_ndim - sub_batch_ndim``. return self.data.ndim - self.k_ndim - self.batch_ndim - self.sub_batch_ndim @property def ndim(self) -> int: return self.data.ndim @property def shape(self) -> torch.Size: return self.data.shape @property def batch_shape(self) -> torch.Size: return self.data.shape[: self.batch_ndim] @property def sub_batch_shape(self) -> torch.Size: return self.data.shape[self.batch_ndim : self.batch_ndim + self.sub_batch_ndim] @property def base_shape(self) -> torch.Size: start = self.batch_ndim + self.sub_batch_ndim return self.data.shape[start:] @property def dtype(self) -> torch.dtype: return self.data.dtype @property def device(self) -> torch.device: return self.data.device # ---- internal: clone-with-new-data ---- def _rewrap( self, data: torch.Tensor, *, batch_ndim: int | None = None, sub_batch_ndim: int | None = None, sub_batch_state: tuple | None = None, sub_batch_meta: tuple | None = None, k_ndim: int | None = None, k_state: tuple | None = None, k_pairing: tuple | None = None, ) -> Tensor: # Mirror :meth:`TensorWrapper._rewrap` -- inherit each metadata # field independently from ``self`` when the caller doesn't # override. Without this, framework helpers like # :func:`~neml2.types.functions.fullify` that pass explicit # ``sub_batch_state`` / ``k_state`` / ``k_pairing`` updates # crash on the dynamic-base ``Tensor`` even though the same # call works fine on a static-base ``TensorWrapper``. new_sub_ndim = self.sub_batch_ndim if sub_batch_ndim is None else sub_batch_ndim same_sb = new_sub_ndim == self.sub_batch_ndim if sub_batch_state is None: sub_batch_state = self.sub_batch_state if same_sb else () if sub_batch_meta is None: sub_batch_meta = self.sub_batch_meta if same_sb else () new_k_ndim = self.k_ndim if k_ndim is None else k_ndim same_k = new_k_ndim == self.k_ndim if k_state is None: k_state = self.k_state if same_k else () if k_pairing is None: k_pairing = self.k_pairing if same_k else () return Tensor( data, self.batch_ndim if batch_ndim is None else batch_ndim, new_sub_ndim, sub_batch_state=sub_batch_state, sub_batch_meta=sub_batch_meta, k_ndim=new_k_ndim, k_state=k_state, k_pairing=k_pairing, )
[docs] def to(self, *args, **kwargs) -> Tensor: moved = self.data.to(*args, **kwargs) if moved is self.data: return self return self._rewrap(moved)
# ---- factories ----
[docs] @classmethod def from_typed(cls, wrapper: TensorWrapper, *, batch_ndim: int | None = None) -> Tensor: """Construct a `Tensor` view of an existing `TensorWrapper`. The wrapper's ``sub_batch_ndim``, ``BASE_NDIM``, and ``sub_batch_labels`` are carried over verbatim; ``batch_ndim`` defaults to ``data.ndim - sub_batch_ndim - BASE_NDIM`` (i.e. everything not claimed by sub-batch or base). Pass an explicit ``batch_ndim`` to override -- useful when the caller knows the dyn rank but the wrapper's leading axes encode a different layout (e.g. the JVP K-dim). """ base = wrapper.BASE_NDIM sub = wrapper.sub_batch_ndim if batch_ndim is None: batch_ndim = wrapper.data.ndim - sub - base return cls( wrapper.data, batch_ndim, sub, )
[docs] def as_typed(self, cls: type[TensorWrapper]) -> TensorWrapper: """View this `Tensor` as a `TensorWrapper` subclass. Requires the trailing dims of ``data`` to match ``cls.BASE_SHAPE`` exactly. ``sub_batch_ndim`` and ``sub_batch_labels`` are preserved; any leading ``batch_ndim`` becomes the wrapper's dynamic batch. """ if self.base_ndim != cls.BASE_NDIM: raise ValueError( f"Cannot view Tensor with base_ndim={self.base_ndim} as " f"{cls.__name__} (BASE_NDIM={cls.BASE_NDIM})" ) if tuple(self.base_shape) != tuple(cls.BASE_SHAPE): raise ValueError( f"Cannot view Tensor with base_shape={tuple(self.base_shape)} as " f"{cls.__name__} (BASE_SHAPE={cls.BASE_SHAPE})" ) return cls( self.data, sub_batch_ndim=self.sub_batch_ndim, )
[docs] @classmethod def zeros( cls, *, batch_shape: Sequence[int] = (), sub_batch_shape: Sequence[int] = (), base_shape: Sequence[int] = (), dtype: torch.dtype | None = None, device: torch.device | str | None = None, ) -> Tensor: """Zero-filled `Tensor` with explicit region shapes. ``sub_batch_labels`` optionally attaches per-axis labels (length must match ``len(sub_batch_shape)``). Used by the BLOCK-aware ``_build_group_block`` to build zero blocks at the cell's canonical sub_batch shape with the cell's preserved labels. """ shape = (*batch_shape, *sub_batch_shape, *base_shape) data = ( torch.zeros(*shape, dtype=dtype, device=device) if shape else torch.zeros((), dtype=dtype, device=device) ) return cls( data, len(batch_shape), len(sub_batch_shape), )
[docs] @classmethod def zeros_like(cls, other: Tensor) -> Tensor: return cls(torch.zeros_like(other.data), other.batch_ndim, other.sub_batch_ndim)
[docs] def new_zeros( self, *, batch_shape: Sequence[int] | None = None, sub_batch_shape: Sequence[int] | None = None, base_shape: Sequence[int] | None = None, ) -> Tensor: """Allocate a same-dtype, same-device zero `Tensor` with the given region shapes. Any region whose shape is omitted defaults to ``self``'s shape for that region -- handy for building a zero block that mirrors an existing block's dyn / sub-batch layout. """ b = tuple(self.batch_shape) if batch_shape is None else tuple(batch_shape) s = tuple(self.sub_batch_shape) if sub_batch_shape is None else tuple(sub_batch_shape) ba = tuple(self.base_shape) if base_shape is None else tuple(base_shape) shape = (*b, *s, *ba) data = self.data.new_zeros(shape) if shape else self.data.new_zeros(()) return Tensor(data, len(b), len(s))
# ---- arithmetic ---- def _binary(self, other: Tensor | float | int, op_fn) -> Tensor: if isinstance(other, (float, int)): return self._rewrap(op_fn(self.data, other)) if not isinstance(other, Tensor): return NotImplemented # type: ignore[return-value] aa, bb, sb, ba = _align_pair(self, other) # After alignment both operands share (batch_ndim, sub_batch_ndim). return Tensor(op_fn(aa.data, bb.data), ba, sb) def __neg__(self) -> Tensor: return self._rewrap(-self.data) def __add__(self, other) -> Tensor: return self._binary(other, torch.add) def __radd__(self, other) -> Tensor: return self.__add__(other) def __sub__(self, other) -> Tensor: return self._binary(other, torch.sub) def __rsub__(self, other) -> Tensor: if isinstance(other, (float, int)): return self._rewrap(other - self.data) return NotImplemented def __mul__(self, other) -> Tensor: return self._binary(other, torch.mul) def __rmul__(self, other) -> Tensor: return self.__mul__(other) def __truediv__(self, other) -> Tensor: # A tensor denominator that requires grad lowers to a reciprocal whose # backward saves its OUTPUT, which AOTInductor cannot lower under strict # + dynamic-batch export (pytorch/pytorch#187907). Rewrite ``num / den`` # as ``num * reciprocal_ad(den)`` on the AD path; ``x / literal`` and the # off-AD path keep the plain divide (value path unchanged). if isinstance(other, Tensor) and other.data.requires_grad: from neml2.types.functions import reciprocal_ad # noqa: PLC0415 return self._binary(other._rewrap(reciprocal_ad(other.data)), torch.mul) return self._binary(other, torch.div) def __rtruediv__(self, other) -> Tensor: if isinstance(other, (float, int)): data = self.data if data.requires_grad: # reciprocal backward saves its output -> AOTI hazard # (pytorch/pytorch#187907); recompute from input on the AD path. from neml2.types.functions import reciprocal_ad # noqa: PLC0415 return self._rewrap(other * reciprocal_ad(data)) return self._rewrap(other / data) return NotImplemented # ---- matmul / solve ---- def __matmul__(self, other: Tensor) -> Tensor: """Matrix product on the trailing base axes. ``A @ B`` where ``A.base_ndim == 2`` and ``B.base_ndim in (1, 2)``. The leading ``(batch, sub_batch)`` axes broadcast right-aligned; the result has ``base_ndim`` equal to ``B.base_ndim``. Sub-batch handling: * Both operands have the same set of BLOCK axes: standard per-grain batched matmul via ``torch.matmul``. * One operand has BLOCK sub_batch axes the other lacks AND the contraction shapes mismatch by exactly the product of those extra axes: the matmul auto-unfolds the BLOCK side into its first base axis to align (the mxpc-style ``A_sp @ Y`` case where A_sp's per-grain side was already flattened to base by an upstream dense-edge reduction). * Any DENSE sub_batch axis at this point is a bug: assembly should have folded it before storage. We refuse and tell the caller to call ``flatten_sub_batch`` (or assembly's equivalent) first. """ if not isinstance(other, Tensor): return NotImplemented # type: ignore[return-value] if self.base_ndim != 2: raise ValueError(f"Tensor.__matmul__: LHS must have base_ndim=2, got {self.base_ndim}") if other.base_ndim not in (1, 2): raise ValueError( f"Tensor.__matmul__: RHS must have base_ndim in (1, 2), got {other.base_ndim}" ) # Auto-unfold: when the contraction shape would mismatch and the # mismatch can be resolved by folding one operand's BLOCK # sub_batch axes into its first base axis, do it. Try RHS first # (the common mxpc case: LHS A_sp has no sub_batch, RHS Y is # BLOCK and needs unfolding); fall back to LHS otherwise. self_c = int(self.base_shape[-1]) other_c = int(other.base_shape[0]) if self_c != other_c: if other.sub_batch_ndim > self.sub_batch_ndim: # Fold RHS's extra sub_batch axes into RHS's first base. rhs_extra = prod( other.sub_batch_shape[: other.sub_batch_ndim - self.sub_batch_ndim] ) if self_c == rhs_extra * other_c: other = _fold_extra_sub_batch_into_first_base( other, other.sub_batch_ndim - self.sub_batch_ndim ) elif self.sub_batch_ndim > other.sub_batch_ndim: lhs_extra = prod(self.sub_batch_shape[: self.sub_batch_ndim - other.sub_batch_ndim]) # Folding LHS's extra into LHS's first base wouldn't help # the contraction shape (which is LHS's LAST base); we # could fold into LHS's last base instead, but no current # caller needs that path. Defer and error below if still # mismatched after the simpler RHS rule. del lhs_extra aa, bb, sb, ba = _align_pair(self, other) if bb.base_ndim == 1: # Vector RHS: unsqueeze last for matmul, squeeze back. out_data = torch.matmul(aa.data, bb.data.unsqueeze(-1)).squeeze(-1) else: out_data = torch.matmul(aa.data, bb.data) return Tensor(out_data, ba, sb)
[docs] def solve(self, other: Tensor) -> Tensor: """Solve ``self @ x = other`` for ``x`` via ``torch.linalg.solve``. ``self.base_ndim == 2`` (square matrix); ``other.base_ndim in (1, 2)``. ``(batch, sub_batch)`` axes are leading-broadcast through torch's batched solve -- so a block-diagonal system ``(*B, L, n, n) \\ (*B, L, n)`` solves L independent dense LUs per batch element without any explicit dispatch. That is the whole point of the dynamic-base design. Sub-batch dispatch mirrors :meth:`__matmul__`: * Same number of BLOCK axes on both sides: standard batched solve. * RHS has BLOCK axes the LHS lacks AND folding them into RHS's first base axis would align ``A``'s last base with the resulting column dim: do the unfold (the case where ``A`` was a dense per-grain coupling already folded by assembly and ``b`` is per-grain BLOCK). * DENSE sub_batch on either operand at this point is a bug: assembly should have folded it. """ if self.base_ndim != 2: raise ValueError(f"Tensor.solve: A must have base_ndim=2, got {self.base_ndim}") if other.base_ndim not in (1, 2): raise ValueError( f"Tensor.solve: b must have base_ndim in (1, 2), got {other.base_ndim}" ) # Auto-unfold RHS's extra BLOCK sub_batch axes when the column # dim of A wouldn't otherwise align with the row dim of b. The # KWN case: A is (B, N, N) (dense per-grain folded by assembly), # b is (B, N, 1) BLOCK sub_batch on N -- unfold b's BLOCK into # its first base axis to give (B, N, 1) base / sub=0, then # standard solve. self_c = int(self.base_shape[-1]) other_first_base = int(other.base_shape[0]) if other.base_ndim >= 1 else 1 if self_c != other_first_base and other.sub_batch_ndim > self.sub_batch_ndim: extra = other.sub_batch_ndim - self.sub_batch_ndim rhs_extra_total = prod(other.sub_batch_shape[:extra]) if self_c == rhs_extra_total * other_first_base: other = _fold_extra_sub_batch_into_first_base(other, extra) aa, bb, sb, ba = _align_pair(self, other) if bb.base_ndim == 1: out_data = torch.linalg.solve(aa.data, bb.data.unsqueeze(-1)).squeeze(-1) else: out_data = torch.linalg.solve(aa.data, bb.data) return Tensor(out_data, ba, sb)
# ---- reductions over base ---- # # ``sum``, ``dot``, ``norm``, ``norm_sq`` live in # :mod:`neml2.types.functions` as free functions on ``t.base`` views. # Per NEML2 convention, mathematical ops that don't modify type # invariants are free functions; only shape / invariant manipulators # stay as methods on the type. # ---- reshape ----
[docs] def flatten_base(self) -> Tensor: """Collapse all base axes into one trailing axis; result has ``base_ndim=1``.""" if self.base_ndim == 0: return Tensor(self.data.unsqueeze(-1), self.batch_ndim, self.sub_batch_ndim) if self.base_ndim == 1: return self new_data = self.data.reshape(*self.batch_shape, *self.sub_batch_shape, -1) return Tensor(new_data, self.batch_ndim, self.sub_batch_ndim)
[docs] def unflatten_base(self, *shape: int) -> Tensor: """Reshape the base region to ``shape``. Total base storage must match.""" target = (*self.batch_shape, *self.sub_batch_shape, *shape) new_data = self.data.reshape(target) return Tensor(new_data, self.batch_ndim, self.sub_batch_ndim)
[docs] def flatten_sub_batch(self) -> Tensor: """Absorb sub-batch axes into a NEW leading base axis. Result has ``sub_batch_ndim=0`` and ``base_ndim`` increased by 1 (the new leading base axis equals ``prod(sub_batch_shape)``). Use to materialise a block-diagonal-storage Tensor as one flat matrix when the downstream consumer demands base-only storage. """ if self.sub_batch_ndim == 0: return self sub_total = 1 for s in self.sub_batch_shape: sub_total *= int(s) new_data = self.data.reshape(*self.batch_shape, sub_total, *self.base_shape) # Sub-batch axes were folded into a new leading base axis. The # ``"dense"`` distinction disappears at this point: the resulting # base-leading axis carries the per-grain stride explicitly. return Tensor(new_data, self.batch_ndim, 0)
[docs] def fold_preserving( self, preserved_idx: list[int], canonical_order: tuple[str, ...], other_idx: list[int], fold_size: int, ) -> Tensor: """Permute preserved sub_batch axes to leading position (in ``canonical_order``), then fold (remaining_sub + base) into a single trailing axis. Result has ``batch_ndim`` unchanged, ``sub_batch_ndim = len(canonical_order)``, ``sub_batch_labels = canonical_order``, ``base_ndim=1``, ``base[0] = fold_size``. Used by :meth:`~neml2.es.AssembledVector.from_dict`'s preserved-label storage path to keep declared per-axis sub_batch dims as real sub_batch on the assembled slab while folding the rest. """ dyn_ndim = self.batch_ndim sb_ndim = self.sub_batch_ndim base_ndim = self.base_ndim # Defensive: callers (assembly path) compute preserved_idx / # other_idx from the LAYOUT's sub_batch_shape, but this Tensor's # sub_batch_ndim is what actually drives the permutation. A mismatch # means the wrapper was built with a different sub_batch_ndim than # the layout expects -- usually because some upstream rewrap forgot # to thread sub_batch_ndim through. Raise informatively so the # offending construction site is easy to find. max_idx = max(preserved_idx) if preserved_idx else -1 if other_idx: max_idx = max(max_idx, max(other_idx)) if max_idx >= sb_ndim: raise ValueError( f"Tensor.fold_preserving: wrapper sub_batch_ndim={sb_ndim} " f"too low for requested preserved_idx={preserved_idx} " f"other_idx={other_idx}. Caller layout expects " f"sub_batch_ndim >= {max_idx + 1}. Check that the wrapper " f"feeding from_dict was built with the right sub_batch_ndim." ) perm = list(range(dyn_ndim)) perm += [dyn_ndim + i for i in preserved_idx] perm += [dyn_ndim + i for i in other_idx] perm += [dyn_ndim + sb_ndim + i for i in range(base_ndim)] permuted = self.data.permute(perm).contiguous() preserved_extents = tuple(int(self.sub_batch_shape[i]) for i in preserved_idx) folded = permuted.reshape(*permuted.shape[:dyn_ndim], *preserved_extents, fold_size) return Tensor( folded, batch_ndim=dyn_ndim, sub_batch_ndim=len(canonical_order), )
[docs] def flatten_sub_batch_into_first_base_axis(self) -> Tensor: """Fold sub_batch axes INTO (not beside) the first base axis. Result has ``sub_batch_ndim=0`` and ``base_ndim`` unchanged; the first base axis grows from ``base_shape[0]`` to ``prod(sub_batch) * base_shape[0]``. Distinct from :meth:`flatten_sub_batch` which inserts the collapsed sub_batch as a NEW leading base axis. This is the inverse of the assembly's "BLOCK-compact preserves per-grain structure" decision: when downstream code needs the fully-unfolded form (e.g. a global-row matmul against per-grain col blocks), call this. Requires ``base_ndim >= 1``. """ if self.sub_batch_ndim == 0: return self if self.base_ndim == 0: raise ValueError( "flatten_sub_batch_into_first_base_axis: base_ndim=0; use " "``flatten_sub_batch`` to add a new base axis instead." ) sub_total = 1 for s in self.sub_batch_shape: sub_total *= int(s) first_base = int(self.base_shape[0]) rest_base = tuple(int(s) for s in self.base_shape[1:]) new_data = self.data.reshape(*self.batch_shape, sub_total * first_base, *rest_base) return Tensor(new_data, self.batch_ndim, 0)
# ---- region views ---- @property def batch(self) -> _BatchView: """View into the leading (dynamic) batch region. Ops preserve ``sub_batch_ndim``. Supports ``unsqueeze`` / ``squeeze`` / ``expand`` / ``broadcast_to`` / ``cat``.""" return _BatchView(self) @property def sub_batch(self) -> _SubBatchView: """View into the per-site sub-batch region. Ops adjust ``sub_batch_ndim``.""" return _SubBatchView(self) @property def base(self) -> _BaseView: """View into the trailing base region. Unlike :class:`TensorWrapper`, the base region is mutable on a dynamic-base `Tensor` -- ``expand`` / ``unsqueeze`` / ``squeeze`` / ``cat`` all work.""" return _BaseView(self) # ---- repr ---- def __repr__(self) -> str: return ( f"Tensor(batch_shape={tuple(self.batch_shape)}, " f"sub_batch_shape={tuple(self.sub_batch_shape)}, " f"base_shape={tuple(self.base_shape)}, dtype={self.dtype}, device={self.device})" )
def _align_pair(a: Tensor, b: Tensor) -> tuple[Tensor, Tensor, int, int]: """Pad two `Tensor`s to a common ``(batch_ndim, sub_batch_ndim)`` for binary ops. Right-aligned broadcast: the operand with smaller ``batch_ndim`` gets size-1 axes inserted at the *front* of its batch region; the operand with smaller ``sub_batch_ndim`` gets size-1 axes inserted at the *front* of its sub-batch region (i.e. right after the padded batch). Newly-inserted sub_batch axes default to ``"block"`` kind. Returns ``(a_aligned, b_aligned, sub_batch_ndim, batch_ndim)`` of the common shape. """ sb = max(a.sub_batch_ndim, b.sub_batch_ndim) ba = max(a.batch_ndim, b.batch_ndim) return _pad_to(a, ba, sb), _pad_to(b, ba, sb), sb, ba def _fold_extra_sub_batch_into_first_base(t: Tensor, n_axes: int) -> Tensor: """Fold ``n_axes`` leading sub_batch axes into the first base axis. Used by matmul's auto-unfold path: when one operand has more sub_batch axes than the other and the contraction shape mismatches by exactly the product of the extra axes, fold them into the operand's first base axis so the matmul aligns. The remaining sub_batch axes (and their labels) shift down by ``n_axes``. """ if n_axes <= 0: return t if n_axes > t.sub_batch_ndim: raise ValueError( f"_fold_extra_sub_batch_into_first_base: requested {n_axes} fold but " f"only {t.sub_batch_ndim} sub_batch axes available" ) if t.base_ndim == 0: raise ValueError("_fold_extra_sub_batch_into_first_base: base_ndim=0; nowhere to fold") folded_extents = tuple(int(s) for s in t.sub_batch_shape[:n_axes]) fold_total = prod(folded_extents) if folded_extents else 1 first_base = int(t.base_shape[0]) rest_base = tuple(int(s) for s in t.base_shape[1:]) keep_extents = tuple(int(s) for s in t.sub_batch_shape[n_axes:]) new_shape = (*t.batch_shape, *keep_extents, fold_total * first_base, *rest_base) new_data = t.data.reshape(*new_shape) return Tensor( new_data, t.batch_ndim, t.sub_batch_ndim - n_axes, ) def _pad_to(t: Tensor, batch_ndim: int, sub_batch_ndim: int) -> Tensor: """Insert size-1 axes to bring ``t`` up to the target region ndims. New sub_batch axes are inserted at the FRONT (right-aligned broadcast) and inherit anonymous labels; the original axes' labels shift to the end of the sub_batch region. """ if t.batch_ndim == batch_ndim and t.sub_batch_ndim == sub_batch_ndim: return t cur = t.data sb_pad = sub_batch_ndim - t.sub_batch_ndim if sb_pad < 0: raise ValueError( f"_pad_to: cannot shrink sub_batch_ndim from {t.sub_batch_ndim} to {sub_batch_ndim}" ) for _ in range(sb_pad): cur = cur.unsqueeze(t.batch_ndim) ba_pad = batch_ndim - t.batch_ndim if ba_pad < 0: raise ValueError(f"_pad_to: cannot shrink batch_ndim from {t.batch_ndim} to {batch_ndim}") for _ in range(ba_pad): cur = cur.unsqueeze(0) return Tensor(cur, batch_ndim, sub_batch_ndim) # --------------------------------------------------------------------------- # Region views -- transient handles exposing shape-manipulating ops over # one of the three regions (batch, sub_batch, base) of a :class:`Tensor`. # Mirror :mod:`neml2.types._base`'s view hierarchy, adapted for the # dynamic-base layout where ``base_ndim`` is also runtime-mutable. # --------------------------------------------------------------------------- class _RegionView: """Common machinery for the three :class:`Tensor` region views. Subclasses define ``_REGION`` (human-readable name for error messages) and ``_bounds()`` (``(start, length)`` slice into ``data.shape``) and ``_new_ndims(axes_added, axes_removed)`` (how an op updates the parent ``(batch_ndim, sub_batch_ndim)`` pair). """ _REGION: ClassVar[str] __slots__ = ("_t",) def __init__(self, t: Tensor) -> None: self._t = t # ---- subclass hooks ---- def _bounds(self) -> tuple[int, int]: raise NotImplementedError def _new_ndims(self, *, axes_added: int = 0, axes_removed: int = 0) -> tuple[int, int]: raise NotImplementedError # ---- read-only properties ---- @property def shape(self) -> torch.Size: start, length = self._bounds() if length == 0: return torch.Size(()) return self._t.data.shape[start : start + length] @property def ndim(self) -> int: return self._bounds()[1] # ---- region-relative dim resolution ---- def _resolve_dim(self, dim: int) -> int: """Region-relative ``dim`` -> absolute axis on ``data.shape``.""" n = self.ndim if n == 0: raise ValueError(f"Tensor: {self._REGION} region is empty (no axis to address)") if dim < 0: dim += n if dim < 0 or dim >= n: raise IndexError( f"Tensor: {self._REGION} dim {dim} out of range for {self._REGION}_ndim={n}" ) start, _ = self._bounds() return start + dim def _resolve_insert_dim(self, dim: int) -> int: """Region-relative insertion point in ``[0, ndim]``.""" n = self.ndim if dim < 0: dim += n + 1 if dim < 0 or dim > n: raise IndexError( f"Tensor: {self._REGION} insert dim {dim} out of range for {self._REGION}_ndim={n}" ) start, _ = self._bounds() return start + dim # ---- shared mutators ---- # # All shape-changing region ops route through ``self._t._rewrap`` so # ``sub_batch_labels`` inheritance follows the single rule defined on # ``Tensor._rewrap`` (preserve when ``sub_batch_ndim`` is unchanged, # drop otherwise). No per-op label threading needed. def unsqueeze(self, dim: int = -1, n: int = 1) -> Tensor: """Insert ``n`` size-1 axes at region-relative position ``dim``.""" if n < 0: raise ValueError(f"Tensor.{self._REGION}.unsqueeze: n must be >= 0, got {n}") if n == 0: return self._t insert_at = self._resolve_insert_dim(dim) new_data = self._t.data for _ in range(n): new_data = new_data.unsqueeze(insert_at) ba, sb = self._new_ndims(axes_added=n) return self._t._rewrap(new_data, batch_ndim=ba, sub_batch_ndim=sb) def squeeze(self, dim: int = -1) -> Tensor: """Drop a size-1 axis at region-relative position ``dim``.""" axis = self._resolve_dim(dim) if self._t.data.shape[axis] != 1: raise ValueError( f"Tensor.{self._REGION}.squeeze: axis {dim} has size " f"{self._t.data.shape[axis]}, expected 1" ) new_data = self._t.data.squeeze(axis) ba, sb = self._new_ndims(axes_removed=1) return self._t._rewrap(new_data, batch_ndim=ba, sub_batch_ndim=sb) def expand(self, *shape: int) -> Tensor: """Make this region's shape equal to ``shape``. Left-pads with size-1 axes if the current region rank is less than ``len(shape)``, then broadcasts via ``torch.Tensor.expand``. Result is made ``contiguous`` so downstream reshapes don't trip on strides. ``len(shape) < current ndim`` is an error (use ``squeeze`` to drop axes explicitly). """ cur = self.shape target = tuple(shape) if len(target) < len(cur): raise ValueError( f"Tensor.{self._REGION}.expand: target {target} has fewer dims " f"than current {tuple(cur)}; squeeze first if dropping axes is intended" ) pad = len(target) - len(cur) start, length = self._bounds() new_data = self._t.data for _ in range(pad): new_data = new_data.unsqueeze(start) before = self._t.data.shape[:start] after = self._t.data.shape[start + length :] full_target = (*before, *target, *after) new_data = new_data.expand(full_target).contiguous() ba, sb = self._new_ndims(axes_added=pad) return self._t._rewrap(new_data, batch_ndim=ba, sub_batch_ndim=sb) def broadcast_to(self, *shape: int) -> Tensor: """Like ``expand`` but also trims leading size-1 axes when ``len(shape) < self.ndim``. Useful when the parent ``Tensor`` carries placeholder size-1 axes (e.g. an identity-seed scaffold) that should be removed to match a shorter target. Non-singleton excess axes raise. """ cur = self.shape target = tuple(shape) if tuple(cur) == target: return self._t if len(target) >= len(cur): return self.expand(*target) # Need to trim leading axes; each must be size 1. excess = len(cur) - len(target) for s in cur[:excess]: if int(s) != 1: raise ValueError( f"Tensor.{self._REGION}.broadcast_to: extra leading dim of " f"size {int(s)} cannot be trimmed (target has fewer dims)" ) start, _ = self._bounds() new_data = self._t.data for _ in range(excess): new_data = new_data.squeeze(start) ba, sb = self._new_ndims(axes_removed=excess) trimmed = self._t._rewrap(new_data, batch_ndim=ba, sub_batch_ndim=sb) # After trimming, the view's bounds are different (region is shorter); # rebuild the view on the new Tensor to expand to the final target. view_cls = type(self) return view_cls(trimmed).expand(*target) def __getitem__(self, key) -> Tensor: """Index / slice within this region only. The ``key`` addresses the region's axes; pre-region and post-region axes pass through as ``:`` (all). Supports ``Ellipsis`` (expands to fill the region) and ``None`` (inserts a new region axis). Returns a :class:`Tensor` with the region's ndim updated to reflect the operation (integer indices remove an axis, ``None`` adds one). Batch / sub-batch / base counts on the other regions stay put. Example:: tensor.base[..., -1] # last element of the last base axis tensor.base[..., 1:3] # slice the last base axis tensor.sub_batch[0] # pick the first sub-batch site """ if not isinstance(key, tuple): key = (key,) start, length = self._bounds() # Expand any Ellipsis inside the user's key to slice(None) fills. if Ellipsis in key: n_ellipsis = sum(1 for k in key if k is Ellipsis) if n_ellipsis > 1: raise IndexError(f"Tensor.{self._REGION}.__getitem__: at most one Ellipsis allowed") explicit_axes = sum(1 for k in key if k is not Ellipsis and k is not None) n_fill = length - explicit_axes if n_fill < 0: raise IndexError( f"Tensor.{self._REGION}.__getitem__: key has more indices " f"({explicit_axes}) than region ndim ({length})" ) idx = key.index(Ellipsis) key = key[:idx] + (slice(None),) * n_fill + key[idx + 1 :] # Build the full data-side key: pre-region : , user's key, post-region : pre = (slice(None),) * start post = (slice(None),) * (self._t.data.ndim - start - length) full_key = pre + key + post new_data = self._t.data[full_key] # Count how the region's ndim changed: int indices remove an axis, # `None` inserts one; slices keep it. added = sum(1 for k in key if k is None) removed = sum(1 for k in key if isinstance(k, int)) ba, sb = self._new_ndims(axes_added=added, axes_removed=removed) return self._t._rewrap(new_data, batch_ndim=ba, sub_batch_ndim=sb) class _BatchView(_RegionView): """View into the leading (dynamic) batch region. Ops preserve ``sub_batch_ndim``.""" _REGION = "batch" def _bounds(self) -> tuple[int, int]: return (0, self._t.batch_ndim) def _new_ndims(self, *, axes_added: int = 0, axes_removed: int = 0) -> tuple[int, int]: return (self._t.batch_ndim + axes_added - axes_removed, self._t.sub_batch_ndim) class _SubBatchView(_RegionView): """View into the per-site sub-batch region. Ops adjust ``sub_batch_ndim``.""" _REGION = "sub_batch" def _bounds(self) -> tuple[int, int]: return (self._t.batch_ndim, self._t.sub_batch_ndim) def _new_ndims(self, *, axes_added: int = 0, axes_removed: int = 0) -> tuple[int, int]: return (self._t.batch_ndim, self._t.sub_batch_ndim + axes_added - axes_removed) class _BaseView(_RegionView): """View into the trailing base region. Unlike :class:`TensorWrapper`'s fixed-base view, the base region on a dynamic-base `Tensor` is mutable via ``expand`` / ``unsqueeze`` / ``squeeze`` / ``cat`` / ``transpose``.""" _REGION = "base" def _bounds(self) -> tuple[int, int]: return (self._t.batch_ndim + self._t.sub_batch_ndim, self._t.base_ndim) def _new_ndims(self, *, axes_added: int = 0, axes_removed: int = 0) -> tuple[int, int]: # New base axes don't shift batch / sub-batch counts. del axes_added, axes_removed return (self._t.batch_ndim, self._t.sub_batch_ndim) def transpose(self, dim0: int = -2, dim1: int = -1) -> Tensor: """Swap two base axes -- the matrix-transpose case for a ``base_ndim >= 2`` Tensor. Defaults to the trailing two axes.""" if self._t.base_ndim < 2: raise ValueError( f"Tensor.base.transpose requires base_ndim >= 2; got base_ndim={self._t.base_ndim}" ) ax0 = self._resolve_dim(dim0) ax1 = self._resolve_dim(dim1) new_data = self._t.data.transpose(ax0, ax1) return Tensor(new_data, self._t.batch_ndim, self._t.sub_batch_ndim) # --------------------------------------------------------------------------- # Free functions: cat / stack over region views. # Match the existing convention in :mod:`neml2.types.functions` -- the view # carries the axis-region information, so the call site reads # ``cat([a.base, b.base, c.base], dim=-1)``. # --------------------------------------------------------------------------- def cat(views: Sequence[_RegionView], dim: int = -1) -> Tensor: """Concatenate :class:`Tensor`s along a region-relative axis. All views must be the same region kind (all ``.batch``, all ``.sub_batch``, or all ``.base``) over Tensors that share ``batch_ndim`` / ``sub_batch_ndim`` AND per-axis ``sub_batch_labels``. The cat-axis size is the only thing allowed to vary. """ if not views: raise ValueError("cat: views must be non-empty") first = views[0] region_cls = type(first) head = first._t for v in views[1:]: if type(v) is not region_cls: raise TypeError( f"cat: heterogeneous region views {region_cls.__name__} vs {type(v).__name__}" ) t = v._t if t.batch_ndim != head.batch_ndim or t.sub_batch_ndim != head.sub_batch_ndim: raise ValueError( f"cat: mismatched region ndims " f"({head.batch_ndim, head.sub_batch_ndim}) vs " f"({t.batch_ndim, t.sub_batch_ndim})" ) axis = first._resolve_dim(dim) new_data = torch.cat([v._t.data for v in views], dim=axis) return Tensor(new_data, head.batch_ndim, head.sub_batch_ndim) def stack(views: Sequence[_RegionView], dim: int = 0) -> Tensor: """Stack :class:`Tensor`s along a NEW region-relative axis. All views must be the same region kind over Tensors that share concrete region ndims and data shape. The new axis is inserted at region-relative position ``dim``; the parent region's ndim grows by one (so stacking on a ``.sub_batch`` view bumps ``sub_batch_ndim``). A new sub_batch axis from a sub_batch stack inherits an anonymous label. """ if not views: raise ValueError("stack: views must be non-empty") first = views[0] region_cls = type(first) head = first._t for v in views[1:]: if type(v) is not region_cls: raise TypeError( f"stack: heterogeneous region views {region_cls.__name__} vs {type(v).__name__}" ) t = v._t if t.batch_ndim != head.batch_ndim or t.sub_batch_ndim != head.sub_batch_ndim: raise ValueError( f"stack: mismatched region ndims " f"({head.batch_ndim, head.sub_batch_ndim}) vs " f"({t.batch_ndim, t.sub_batch_ndim})" ) if t.data.shape != head.data.shape: raise ValueError( f"stack: mismatched data shapes {tuple(head.data.shape)} vs {tuple(t.data.shape)}" ) axis = first._resolve_insert_dim(dim) new_data = torch.stack([v._t.data for v in views], dim=axis) ba, sb = first._new_ndims(axes_added=1) # When stacking onto sub_batch, insert an anonymous label entry at # the new axis position; other region stacks leave labels unchanged. return Tensor(new_data, ba, sb) # Register Tensor as a pytree dataclass leaf. Following the same convention # as the typed wrappers (`neml2/types/_pytree.py`), the integer shape-metadata # fields are dropped from the pytree -- they reset to the dataclass defaults # on unflatten. Callers that need to round-trip the metadata across a pytree # boundary reconstruct it from a known source (the AxisLayout, or the # ``_meta.json`` AOTI manifest); the exported graph operates on raw tensors # whose shapes already encode the batch / sub-batch / base split. _torch_pytree.register_dataclass( Tensor, field_names=["data"], drop_field_names=[ "batch_ndim", "sub_batch_ndim", "sub_batch_state", "sub_batch_meta", "k_ndim", "k_state", "k_pairing", ], serialized_type_name="neml2.types.Tensor", )