OneScience's picture
Upload folder using huggingface_hub
35cdf53 verified
Raw
History Blame Contribute Delete
14.1 kB
"""Array view class and utilities."""
from collections.abc import Sequence
import dataclasses
import math
import operator
from types import EllipsisType # pylint: disable=g-importing-member
from typing import Any, Self, TypeAlias, TypeVar
import jax
import jax.experimental
from jax.experimental import pallas as pl
import jax.numpy as jnp
from jax.typing import ArrayLike # pylint: disable=g-importing-member
from jaxtyping import Int # pylint: disable=g-importing-member
import numpy as np
ArrayT: TypeAlias = Any
ScalarInt: TypeAlias = (
Int[ArrayT, ""] | Int[np.generic, ""] | Int[jnp.generic, ""]
)
Indexer: TypeAlias = int | ScalarInt | slice | pl.Slice | EllipsisType
@jax.tree_util.register_pytree_node_class
@dataclasses.dataclass(frozen=True)
class ArrayView:
"""A strided view of a JAX array."""
base: jax.Array
_: dataclasses.KW_ONLY
# These are set by `__post_init__` so `None` value is never seen after init.
shape: tuple[int, ...] = None # type: ignore
strides: tuple[int, ...] = None # type: ignore
offset: int | ScalarInt = 0
flatten_base: bool = True
def __post_init__(self):
if self.shape is None:
object.__setattr__(self, "shape", self.base.shape)
if self.strides is None:
object.__setattr__(self, "strides", pl.strides_from_shape(self.shape))
if len(self.shape) != len(self.strides):
raise ValueError("`shape` and `strides` must have the same length.")
# Within `jax.vjp`, we can get non-`Array` values here (such as `object`).
if isinstance(self.base, jax.Array):
if isinstance(self.offset, int):
if not (0 <= self.offset < max(self.base.size, 1)):
raise ValueError("Invalid `offset`.")
if self.flatten_base:
if len(self.base.shape) != 1:
object.__setattr__(self, "base", self.base.reshape((-1,)))
def tree_flatten(self):
if isinstance(self.offset, int):
return (self.base,), (self.offset, self.shape, self.strides)
return (self.base, self.offset), (self.shape, self.strides)
@classmethod
def tree_unflatten(cls, aux, children) -> Self:
base, offset, shape, strides = (*children, *aux)
return cls(base, shape=shape, strides=strides, offset=offset)
@property
def dtype(self) -> jnp.dtype:
return self.base.dtype
@property
def size(self) -> int:
return math.prod(self.shape)
@property
def ndim(self) -> int:
return len(self.shape)
@property
def T(self) -> Self: # pylint: disable=invalid-name
return self.transpose()
@property
def _index_dtype(self) -> jax.typing.DTypeLike:
i32_max = jnp.iinfo(jnp.int32).max
return jnp.int32 if (self.base.size <= i32_max) else jnp.int64
@property
def offsets(self) -> jax.Array:
"""Returns array of offsets into `base` for each element."""
with jax.experimental.enable_x64():
idxs = jnp.indices(self.shape, sparse=True, dtype=self._index_dtype)
return self.offset + sum(s * idx for s, idx in zip(self.strides, idxs))
def astype(self, dtype: jax.typing.DTypeLike) -> Self:
return self._replace(base=self.base.astype(dtype))
def broadcast_to_rank(self, rank: int) -> Self:
"""Returns a new view with the specified rank."""
if rank < self.ndim:
raise ValueError(f"Cannot broadcast to lower rank: {rank} < {self.ndim}.")
shape = (1,) * (rank - self.ndim) + self.shape
strides = (0,) * (rank - self.ndim) + self.strides
return self._replace(shape=shape, strides=strides)
def broadcast_to(self, shape: tuple[int, ...]) -> Self:
"""Returns a new view with the specified shape."""
view = self.broadcast_to_rank(len(shape))
strides = []
for dim_size, stride, target_size in zip(
view.shape, view.strides, shape, strict=True
):
if dim_size == target_size:
strides.append(stride)
elif dim_size == 1:
strides.append(0)
else:
raise ValueError(f"Cannot broadcast {self.shape} to {shape}.")
return self._replace(shape=shape, strides=strides)
def collapse(
self, start: int, stop: int | None = None, *, allow_copy: bool = False
) -> Self:
"""Returns a new view with the axis range collapsed into one axis."""
lo, hi, _ = slice(start, stop).indices(self.ndim)
if hi < lo:
raise ValueError(
"Invalid dimension range passed to collapse: "
f"{self.shape} [{start}:{stop}]"
)
shape = self.shape[:lo] + (-1,) + self.shape[hi:]
return self.reshape(shape, allow_copy=allow_copy)
def reshape(self, shape: Sequence[int], *, allow_copy: bool = False) -> Self:
"""Returns a new view with the specified shape."""
try:
return self._reshape(tuple(shape))
except ValueError:
if not allow_copy:
raise
return type(self)(jnp.array(self)).reshape(shape)
def _reshape(self, shape: tuple[int, ...]) -> Self:
"""Returns a new view with the specified shape."""
if (num_minus_one_dims := shape.count(-1)) > 0:
if num_minus_one_dims > 1:
raise ValueError("`shape` may only contain a single `-1` dimension.")
pos = shape.index(-1)
shape = list(shape)
shape[pos] = self.size // math.prod(d for d in shape if d != -1)
if math.prod(shape) != self.size:
raise ValueError("Mismatched number of elements.")
# Logic copied from `numpy` C++ code.
# Remove axes with length 1, to simplify logic below.
old_shape = [d for d in self.shape if d != 1]
old_strides = [s for i, s in enumerate(self.strides) if self.shape[i] != 1]
strides = [0] * len(shape)
# Axes currently being worked upon.
old_start, old_stop = 0, 1
new_start, new_stop = 0, 1
while (old_start < len(old_shape)) and (new_start < len(shape)):
old_axes_prod = old_shape[old_start]
new_axes_prod = shape[new_start]
while old_axes_prod != new_axes_prod:
if old_axes_prod < new_axes_prod:
old_axes_prod *= old_shape[old_stop]
old_stop += 1
else:
new_axes_prod *= shape[new_stop]
new_stop += 1
# Check if original axes can be combined.
for i in range(old_start, old_stop - 1):
if old_strides[i] != old_shape[i + 1] * old_strides[i + 1]:
raise ValueError("Cannot combine axes non-contiguous in memory.")
# Calculate new strides.
strides[new_stop - 1] = old_strides[old_stop - 1]
for i in range(new_stop - 1, new_start, -1):
strides[i - 1] = strides[i] * shape[i]
old_start, old_stop = old_stop, old_stop + 1
new_start, new_stop = new_stop, new_stop + 1
return self._replace(shape=shape, strides=strides)
def split(
self, indices_or_sections: int | Sequence[int], axis: int = 0
) -> tuple[Self, ...]:
"""Splits the view into multiple slice views."""
if isinstance(indices_or_sections, int):
if self.shape[axis] % indices_or_sections != 0:
raise ValueError("Axis size is not divisible by number of sections.")
chunk = self.shape[axis] // indices_or_sections
indices_or_sections = [i * chunk for i in range(1, indices_or_sections)]
los = (0, *indices_or_sections)
his = (*indices_or_sections, None)
slice_prefix = (slice(None),) * _canonicalize_axis(axis, self.ndim)
return tuple(self[*slice_prefix, slice(lo, hi)] for lo, hi in zip(los, his))
def swapaxes(self, axis1: int, axis2: int) -> Self:
"""Returns a new view with the specified axis swapped."""
axes = list(range(self.ndim))
axes[axis1], axes[axis2] = axes[axis2], axes[axis1]
return self.transpose(axes)
def moveaxis(self, source: int, destination: int) -> Self:
"""Returns a new view with the specified axis moved."""
source, destination = source % self.ndim, destination % self.ndim
axes = list(range(self.ndim))
del axes[source]
axes.insert(destination, source)
return self.transpose(axes)
def transpose(self, axes: Sequence[int] | None = None) -> Self:
"""Returns a new view with the specified axes order."""
if axes is None:
axes = tuple(reversed(range(self.ndim)))
if len(axes) != self.ndim:
raise ValueError("`axes` must have the same dimensionality as the array.")
shape = tuple(self.shape[a] for a in axes)
strides = tuple(self.strides[a] for a in axes)
return self._replace(shape=shape, strides=strides)
def __getitem__(self, idxs: Indexer | tuple[Indexer, ...]) -> Self:
if not isinstance(idxs, tuple):
idxs = (idxs,)
if len(idxs) > self.ndim:
raise ValueError("Too many slice indices.")
num_ellipses = idxs.count(Ellipsis)
if num_ellipses > 1:
raise ValueError("Multiple `...` are not supported.")
elif num_ellipses == 0:
idxs += (Ellipsis,) # `[a:b]` is equivalent to `[a:b, ...]`.
# Replace `...` with slices that take the entirety of the missing axes.
ellipsis_idx = idxs.index(Ellipsis)
ellipsis_slices = (slice(None),) * (self.ndim - len(idxs) + 1)
idxs = idxs[:ellipsis_idx] + ellipsis_slices + idxs[ellipsis_idx + 1 :]
shape = []
strides = []
with jax.experimental.enable_x64():
def as_index(x):
return x.astype(self._index_dtype) if isinstance(x, jax.Array) else x
offset = as_index(self.offset)
for idx, dim, stride in zip(idxs, self.shape, self.strides, strict=True):
if isinstance(idx, int):
if not (-dim <= idx < dim):
raise ValueError("Slice index out of range.")
offset += stride * (idx % dim)
elif isinstance(idx, ScalarInt):
offset += stride * as_index(idx)
elif isinstance(idx, slice):
start, stop, step = idx.indices(dim)
if step >= 0:
shape.append(pl.cdiv(stop - start, step))
else:
shape.append(pl.cdiv(start - stop, -step))
strides.append(stride * step)
offset += stride * start
elif isinstance(idx, pl.Slice):
shape.append(idx.size)
strides.append(stride * idx.stride)
offset += stride * as_index(idx.start)
else:
raise ValueError(f"Unexpected indexer: {idx}")
return self._replace(shape=shape, strides=strides, offset=offset)
def _replace(self, **kwargs) -> Self:
if "shape" in kwargs:
kwargs["shape"] = tuple(kwargs["shape"])
if "strides" in kwargs:
kwargs["strides"] = tuple(kwargs["strides"])
return dataclasses.replace(self, **kwargs)
def set(self, value: ArrayLike | "ArrayView") -> Self:
"""Returns a new view with the views values set to `value`."""
if any(s == 0 for s in self.strides):
raise ValueError("Cannot set values on a broadcasted array.")
# Try to just transpose the value, if possible.
major_to_minor = np.argsort(-np.array(self.strides), kind="stable")
value = jnp.array(value)
value_transposed = value.transpose(major_to_minor)
if (
self.transpose(major_to_minor).strides
== ArrayView(value_transposed).strides
):
base = jax.lax.dynamic_update_slice(
self.base, value_transposed.flatten(), (self.offset,)
)
else:
base = self.base.at[self.offsets].set(value)
return self._replace(base=base)
def __jax_array__(self) -> jax.Array:
"""Returns values as a dense array."""
# Try to express using transpose, slice, and reshape, to encourage XLA to
# fuse into other ops, rather than materialising the values. Otherwise,
# fall back to using a gather.
if (self.ndim == 0) or any(s < 0 for s in self.strides):
return self.base[self.offsets]
major_to_minor = np.argsort(-np.array(self.strides), kind="stable")
# Construct a shape that gives us the correct strides.
bcast_axes = []
shape = []
for axis in major_to_minor[::-1]: # minor to major
stride = self.strides[axis]
if stride == 0:
bcast_axes.append(axis)
shape.append(1)
continue
if stride % math.prod(shape) != 0:
raise ValueError("Cannot express as a reshape, then slice.")
shape.append(stride // math.prod(shape))
if self.base.size % math.prod(shape) != 0:
return self.base[self.offsets]
shape = [self.base.size // math.prod(shape), *reversed(shape)]
slice_sizes = [
*(1 if a in bcast_axes else self.shape[a] for a in major_to_minor),
1,
]
if shape[0] == self.shape[major_to_minor[0]]:
needs_offset_slice = False
elif not isinstance(self.offset, int):
needs_offset_slice = True
else:
start_indices = np.unravel_index(self.offset, shape)
end_indices = [s + size for s, size in zip(start_indices, slice_sizes)]
needs_offset_slice = any(e > dim for e, dim in zip(end_indices, shape))
if needs_offset_slice:
shape[0] = self.shape[major_to_minor[0]]
size = math.prod(shape)
# The pad is necessary to ensure that the dynamic slice is in range.
vals = jnp.pad(self.base, (0, size))
vals = jax.lax.dynamic_slice(vals, (self.offset,), (size,))
start_indices = [0] * len(shape)
else:
vals = self.base
start_indices = jnp.unravel_index(self.offset, shape)
vals = vals.reshape(shape)
vals = jax.lax.dynamic_slice(vals, start_indices, slice_sizes)[..., 0]
# Move axes from their physical ordering to their logical ordering.
vals = vals.transpose(np.argsort(major_to_minor))
return jnp.broadcast_to(vals, self.shape)
def as_array_view(x: jax.Array | ArrayView) -> ArrayView:
return x if isinstance(x, ArrayView) else ArrayView(x)
T = TypeVar("T", jax.Array, ArrayView)
def zeros_like(x: T) -> T:
if isinstance(x, ArrayView):
return x._replace(base=jnp.zeros_like(x.base))
return jnp.zeros_like(x)
def _canonicalize_axis(axis, num_dims) -> int:
"""Canonicalize an axis in [-num_dims, num_dims) to [0, num_dims)."""
axis = operator.index(axis)
if not -num_dims <= axis < num_dims:
raise ValueError(
f"axis {axis} is out of bounds for array of dimension {num_dims}"
)
if axis < 0:
axis = axis + num_dims
return axis