Source code for heat.core.dndarray

"""Provides Heat's core data structure, the DNDarray, a distributed n-dimensional array"""

from __future__ import annotations
import bisect
import math

import numpy as np
import torch
import warnings

from mpi4py import MPI
from pathlib import Path
from enum import Enum
from typing import Any, Union, TypeVar
from collections.abc import Iterable

warnings.simplefilter("always", ResourceWarning)

# NOTE: heat module imports need to be placed at the very end of the file to avoid cyclic dependencies
__all__ = ["DNDarray"]

Communication = TypeVar("Communication")

# Type aliases
Index = Union[int, slice, type(...), None, torch.Tensor, np.ndarray, "DNDarray"]
Indexer = Union[Index, tuple[Index, ...], list[Index]]


from typing import NamedTuple


class ProcessedKey(NamedTuple):
    """
    A named tuple to store the processed key information for distributed indexing operations.
    """

    key: Any
    op_type: str  # "scalar", "descending_slice", "distr_mask", "local_mask", "local", "distributed"
    output_shape: tuple
    output_split: int | None
    split_key_is_ordered: int
    key_is_mask_like: bool
    out_is_balanced: bool
    root: int | None


def _unwrap_local_key(key: Any, device: torch.device | None = None) -> Any:
    """
    Recursively unwrap local DNDarray or numpy array keys into torch-compatible indexers on correct device.
    """
    if isinstance(key, DNDarray):
        if key.is_distributed():
            raise TypeError("Cannot use distributed DNDarray for local fast-path indexing")
        return key.larray.item() if key.ndim == 0 else key.larray
    if isinstance(key, np.ndarray):
        return torch.from_numpy(key).to(device=device)
    if isinstance(key, tuple):
        if len(key) == 1 and _is_boolean_array(key[0]):
            return _unwrap_local_key(key[0], device=device)
        else:
            return tuple(_unwrap_local_key(k, device=device) for k in key)
    if isinstance(key, list):
        return [_unwrap_local_key(k, device=device) for k in key]
    return key


def _process_scalar_key(
    arr: "DNDarray",
    key: int | "DNDarray" | torch.Tensor | np.ndarray,
    indexed_axis: int,
    return_local_indices: bool | None = False,
) -> tuple[int, int]:
    """
    Private helper function to process a single-item scalar key used for indexing a ``DNDarray``.
    """
    try:
        # is key an ndarray or DNDarray or torch.Tensor?
        key = key.item()
    except AttributeError:
        # key is already an integer, do nothing
        pass
    if not arr.is_distributed():
        root = None
        return key, root
    if arr.split == indexed_axis:
        # adjust negative key
        if key < 0:
            key += arr.gshape[indexed_axis]
        # work out active process
        _, displs = arr.counts_displs()
        root = bisect.bisect_right(displs, key) - 1
        # correct key for rank-specific displacement
        if return_local_indices and arr.comm.rank == root:
            key -= displs[root]
    else:
        root = None
    return key, root


def _is_boolean_array(k: Any) -> bool:
    """Return True if k is a boolean or uint8 array/tensor of any dimension."""
    return hasattr(k, "dtype") and k.dtype in (
        ht_bool,
        ht_uint8,
        torch.bool,
        torch.uint8,
        np.bool_,
        np.uint8,
    )


def _is_boolean_scalar(k: Any) -> bool:
    """Return True if k is a python bool or a 0-D boolean array/tensor."""
    return isinstance(k, bool) or (_is_boolean_array(k) and getattr(k, "ndim", 0) == 0)


def _resolve_duplicate_indices(
    key_in,
    rhs_in: torch.Tensor,
    target_shape: tuple[int, ...],
):
    """
    CUDA-safe handling for duplicate advanced indices:
    enforce NumPy semantics (last assignment wins) by dropping earlier duplicates.
    Works for:
        - key_in: torch.Tensor (indexes axis 0)
        - key_in: tuple/list of torch.Tensors (pure advanced indexing)
    rhs_in must match the indexing result shape.
    """
    # Scalars or single element: no need to deduplicate
    if not torch.is_tensor(rhs_in) or rhs_in.numel() <= 1:
        return key_in, rhs_in

    # Normalize key to either a single tensor or tuple of tensors
    if torch.is_tensor(key_in):
        idx_tensors = (key_in,)
    elif (
        isinstance(key_in, (tuple, list))
        and len(key_in) > 0
        and all(torch.is_tensor(k) for k in key_in)
    ):
        idx_tensors = tuple(key_in)
    else:
        # Not pure advanced-tensor indexing -> don't touch
        return key_in, rhs_in

    device = rhs_in.device

    # Broadcast indices to common shape, then flatten
    try:
        idx_b = torch.broadcast_tensors(*idx_tensors)
    except RuntimeError:
        # If broadcast fails, leave it to PyTorch (will error appropriately)
        return key_in, rhs_in

    pos_shape = idx_b[0].shape
    pos_ndim = len(pos_shape)
    n = idx_b[0].numel()

    idx_flat = [
        torch.where(t < 0, t + int(target_shape[d]), t)
        .to(device=device, dtype=torch.int64)
        .reshape(-1)
        for d, t in enumerate(idx_b)
    ]

    # Build linear index for duplicate detection
    if len(idx_flat) == 1:
        lin = idx_flat[0]
    else:
        lin = idx_flat[0]
        # linearize across the first len(idx_flat) dimensions of the target tensor
        for d in range(1, len(idx_flat)):
            lin = lin * int(target_shape[d]) + idx_flat[d]

    # Determine sorting order (stable sort preserves original order)
    order = torch.argsort(lin, stable=True)
    pos = None

    lin_s = lin[order]

    # Fast path: check adjacent elements in sorted order
    # If all adjacent elements are distinct, there are no duplicates
    if (lin_s[1:] != lin_s[:-1]).all():
        return key_in, rhs_in

    if pos is None:
        pos = torch.arange(n, device=device, dtype=torch.int64)

    pos_s = pos[order]

    is_last = torch.ones_like(lin_s, dtype=torch.bool)
    is_last[:-1] = lin_s[1:] != lin_s[:-1]
    keep_pos = pos_s[is_last]  # positions in original stream

    # Reduce RHS accordingly:
    # Flatten leading "pos_ndim" dims into one, keep trailing dims as payload
    rhs_view = rhs_in.reshape(n, *rhs_in.shape[pos_ndim:])
    rhs_u = rhs_view[keep_pos].reshape(keep_pos.numel(), *rhs_in.shape[pos_ndim:])

    # Reduce indices accordingly (use flattened 1D indices)
    if torch.is_tensor(key_in):
        key_u = idx_flat[0][keep_pos]
        return key_u, rhs_u

    key_u = tuple(t[keep_pos] for t in idx_flat)
    return key_u, rhs_u


def _is_scalar_index(k: Any) -> bool:
    """Return True if k is a non-boolean scalar or 0-D array indexer."""
    return not _is_boolean_scalar(k) and (np.isscalar(k) or getattr(k, "ndim", 1) == 0)


def _normalize_key(key: Indexer, device: torch.device) -> tuple[Any, ...]:
    """
    Standardize the non-DNDarray coordinate indices to PyTorch-friendly key items.

    Returns a tuple of normalized key items.
    """
    # Normalize top-level container to a list
    if isinstance(key, tuple):
        key_list = list(key)
    elif isinstance(key, list):
        # Could be a list of indices: arr[[0, 2]] or arr[0, 2]
        # Try casting to 1D integer tensor if all elements are ints
        try:
            key_list = [torch.tensor(key, device=device)]
        except (RuntimeError, TypeError, ValueError):
            key_list = list(key)
    else:
        key_list = [key]

    normalized = []
    for k in key_list:
        # Convert numpy array to torch tensor on target device
        if isinstance(k, np.ndarray):
            normalized.append(torch.from_numpy(k).to(device=device))

        # Unwrap 0-D scalar DNDarray into a Python scalar
        elif isinstance(k, DNDarray) and k.ndim == 0:
            normalized.append(k.larray.item())

        # Unpack singleton containers like (idx,) often produced by nonzero/where
        elif isinstance(k, (tuple, list)) and len(k) == 1 and isinstance(k[0], DNDarray):
            if k[0].ndim > 0:
                normalized.append(k[0])
            else:
                normalized.append(torch.tensor([k[0].larray.item()], device=device))

        # Sequence of scalar DNDarrays -> unwrap to list of Python scalars
        elif (
            isinstance(k, (tuple, list))
            and len(k) > 0
            and all(isinstance(elem, DNDarray) and elem.ndim == 0 for elem in k)
        ):
            normalized.append(torch.tensor([elem.larray.item() for elem in k], device=device))

        # Catch invalid nested non-scalar DNDarrays early
        elif isinstance(k, (tuple, list)) and any(
            isinstance(elem, DNDarray) and elem.ndim > 0 for elem in k
        ):
            raise TypeError(
                "Nested tuple/list of non-scalar DNDarray indices is not supported. "
                "Pass them as separate indices (e.g. arr[idx0, idx1, ...]) or unwrap "
                "singleton tuples (e.g. idx = idx[0])."
            )

        # Convert non-distributed integer/indexing DNDarrays to local torch.Tensor
        elif isinstance(k, DNDarray) and k.split is None and k.dtype in (types.int32, types.int64):
            normalized.append(k.larray.to(dtype=torch.int64))

        # Ensure torch.Tensor indices are placed on the target device
        elif isinstance(k, torch.Tensor):
            normalized.append(k)

        else:
            # Leave slices, integers, None, Ellipsis,
            # and distributed DNDarrays (ndim >= 1 and split is not None) intact.
            normalized.append(k)

    return tuple(normalized)


def _scalar_early_out(
    arr: "DNDarray",
    key: Any,
    op: str | None,
    return_local_indices: bool | None,
) -> tuple["DNDarray", ProcessedKey]:
    """Resolve early-out for scalar indexers."""
    if arr.ndim == 0 and op == "get":
        raise IndexError(
            "Too many indices for DNDarray: DNDarray is 0-dimensional, but 1 were indexed"
        )

    output_shape = arr.gshape[1:]
    output_split = None if arr.split in (None, 0) else arr.split - 1
    processed_key, root = _process_scalar_key(
        arr, key, indexed_axis=0, return_local_indices=return_local_indices
    )

    return arr, ProcessedKey(
        key=processed_key,
        op_type="scalar",
        output_shape=tuple(output_shape),
        output_split=output_split,
        split_key_is_ordered=1,
        key_is_mask_like=False,
        out_is_balanced=True if output_split is None else arr.balanced,
        root=root,
    )


def _expand_dimensions_and_ellipsis(
    arr: "DNDarray",
    key: list[Any],
    output_shape: list[int],
    split_bookkeeping: list[str | None],
) -> tuple["DNDarray", list[Any], list[int], list[str | None]]:
    """
    Expands ellipses (...) into full slices and inserts singleton dimensions
    for None (newaxis) or 0-D boolean masks.
    """
    add_dims = sum(k is None or _is_boolean_scalar(k) for k in key)
    ellipsis = sum(isinstance(k, type(...)) for k in key)

    if ellipsis > 1:
        raise ValueError("indexing key can only contain 1 Ellipsis (...)")

    if ellipsis:
        expand_key = [slice(None)] * (arr.ndim + add_dims)
        ellipsis_index = key.index(...)
        ellipsis_dims = arr.ndim - (len(key) - ellipsis - add_dims)
        expand_key[:ellipsis_index] = key[:ellipsis_index]
        expand_key[ellipsis_index + ellipsis_dims :] = key[ellipsis_index + 1 :]
        key = expand_key

    while add_dims > 0:
        for i, k in reversed(list(enumerate(key))):
            if k is None or _is_boolean_scalar(k):
                if k is None:
                    key[i] = slice(None)
                else:
                    val = bool(k.item() if hasattr(k, "item") else k)
                    key[i] = slice(None) if val else slice(0, 0)

                insert_pos = i - add_dims + 1
                arr = arr.expand_dims(insert_pos)
                output_shape = output_shape[:insert_pos] + [1] + output_shape[insert_pos:]
                split_bookkeeping = (
                    split_bookkeeping[:insert_pos] + [None] + split_bookkeeping[insert_pos:]
                )
                add_dims -= 1

    return arr, key, output_shape, split_bookkeeping


def _distr_mask_fast_path(arr: "DNDarray", key: Any, op: str | None) -> bool:
    """
    Checks if the indexing operation qualifies for the distributed boolean mask fast path.
    """
    if not arr.is_distributed():
        return False

    if isinstance(key, tuple) and len(key) > arr.split:
        split_key = key[arr.split]
    elif isinstance(key, DNDarray):
        split_key = key
    else:
        split_key = None

    if (
        isinstance(split_key, DNDarray)
        and split_key.dtype in (ht_bool, ht_uint8)
        and split_key.split == arr.split
    ):
        if split_key.gshape == arr.gshape:
            # "get" flattens to 1D; if split > 0, local flattening scrambles global C-order
            return op == "set" or (op == "get" and arr.split == 0)
        elif (
            split_key.ndim == 1 and arr.split == 0 and split_key.gshape == (arr.gshape[arr.split],)
        ):
            return True

    return False


def _resolve_1d_boolean_first_dim(
    arr: "DNDarray", key: tuple[Any, ...] | Any, distr_mask_fast_path: bool
) -> tuple[Any, ...] | Any:
    """
    If key indexes axis 0 with a 1D boolean mask matching the global size of axis 0,
    convert that mask into integer coordinates via nonzero().
    """
    if distr_mask_fast_path or arr.ndim == 0:
        return key

    first = key[0] if isinstance(key, tuple) and len(key) >= 1 else key

    if not isinstance(first, (DNDarray, torch.Tensor)):
        return key

    first_dtype = getattr(first, "dtype", None)
    first_ndim = getattr(first, "ndim", 0)
    first_shape = tuple(getattr(first, "shape", ()))

    if (
        first_ndim == 1
        and first_shape == (arr.gshape[0],)
        and first_dtype in (ht_bool, ht_uint8, torch.bool, torch.uint8)
    ):
        if isinstance(first, DNDarray):
            nz = first.nonzero()
            idx0 = nz[0] if isinstance(nz, tuple) else nz
        elif isinstance(first, torch.Tensor):
            idx0 = torch.nonzero(first, as_tuple=False).flatten()

        return (idx0,) + key[1:] if isinstance(key, tuple) else (idx0,)

    return key


def _sanitize_int_indices(k: "DNDarray", dim: int, axis: int, comm: Any, device: Any) -> "DNDarray":
    """
    Validates integer bounds and normalizes negative coordinates for distributed/local DNDarray keys.
    """
    if k.dtype not in (types.int32, types.int64):
        if k.dtype not in (ht_bool, ht_uint8):
            raise IndexError(
                f"arrays used as indices must be of integer (or boolean) type, got {k.dtype}"
            )
        return k

    # Combine local checks into one reduced boolean tensor
    local_flags = torch.tensor(
        [
            ((k.larray < -dim) | (k.larray >= dim)).any(),
            (k.larray < 0).any(),
        ],
        dtype=torch.int32,
        device=device.torch_device,
    )

    do_reduce = comm is not None and getattr(comm, "size", 1) > 1 and k.is_distributed()
    if do_reduce:
        comm.Allreduce(MPI.IN_PLACE, local_flags, op=MPI.SUM)

    invalid_sum = local_flags[0].item()
    has_neg_sum = local_flags[1].item()

    if invalid_sum > 0:
        raise IndexError(f"index out of bounds for axis {axis} with size {dim}")

    if has_neg_sum > 0:
        k_l = k.larray.clone()
        k_l[k_l < 0] += dim
        k = factories.array(
            k_l,
            dtype=k.dtype,
            split=k.split,
            device=device,
            comm=comm,
            copy=False,
        )

    return k


def _process_slice_indexer(
    k: slice,
    dim: int,
    is_split_axis: bool,
    displs: list[int] | None,
    counts: list[int] | None,
    rank: int | None,
    device: torch.device,
    return_local_indices: bool,
) -> tuple[Any, int, int | None, bool | None]:
    """
    Computes local slice or index tensor along an axis, determining output dimension length
    and slice monotonicity ordering.
    """
    if k.step == 0:
        raise ValueError("Slice step cannot be zero")

    start, stop, step = slice(k.start, k.stop, k.step).indices(dim)

    new_key = k
    output_dim_len = 0
    split_key_is_ordered = None
    out_is_balanced = None

    if step < 0 and start > stop:
        # total items in the global descending slice
        output_dim_len = len(range(start, stop, step))

        if is_split_axis:
            split_key_is_ordered = -1
            out_is_balanced = False

            # PyTorch cannot index with negative steps
            # we work with the values in ascending order
            s = -step
            # lowest global coordinate produced by this slice
            min_coord = start - (output_dim_len - 1) * s
            # highest global coordinate produced by this slice
            max_coord = start

            # global index interval [low, high) owned by this MPI process
            low = displs[rank]
            high = low + counts[rank]

            # overlap between the slice value bounds [min_coord, max_coord + 1)
            # and the current process's memory chunk [low, high)
            overlap_start = max(min_coord, low)
            overlap_end = min(max_coord + 1, high)

            # check if there is any overlap at all
            if overlap_start < overlap_end:
                # first item in the progression that is >= overlap_start.
                k_first = (overlap_start - min_coord + s - 1) // s
                g_first = min_coord + k_first * s

                if g_first < overlap_end:
                    # first value is still inside the rank's chunk
                    # calculate how many steps fit in [g_first, overlap_end)
                    steps_local = (overlap_end - 1 - g_first) // s + 1

                    # convert global index to a process-local offset if requested
                    start_idx = g_first - low if return_local_indices else g_first
                    stop_idx = start_idx + steps_local * s

                    # allocate local indices only
                    new_key = slice(start_idx, stop_idx, s)
                else:
                    # slicing skips local chunk completely
                    new_key = slice(0, 0)
            else:
                # local chunk is outside the slice bounds entirely
                new_key = slice(0, 0)
        else:
            # non-split axis: return the descending slice as indices
            new_key = torch.arange(start, stop, step, device=device, dtype=torch.int64)

    elif step > 0 and start < stop:
        output_dim_len = len(range(start, stop, step))

        if is_split_axis:
            split_key_is_ordered = 1
            out_is_balanced = False
            local_arr_end = displs[rank] + counts[rank]
            if stop > displs[rank] and start < local_arr_end:
                index_in_cycle = (displs[rank] - start) % step
                if start >= displs[rank]:
                    local_start = start - displs[rank]
                else:
                    local_start = 0 if index_in_cycle == 0 else step - index_in_cycle
                if stop <= local_arr_end:
                    local_stop = stop - displs[rank]
                else:
                    local_stop = counts[rank]

                new_key = slice(local_start, local_stop, step)
            else:
                new_key = slice(0, 0)
    else:
        new_key = slice(0, 0)
        output_dim_len = 0

    return new_key, output_dim_len, split_key_is_ordered, out_is_balanced


def _reorder_advanced_idx_axes(
    arr: "DNDarray",
    key: list[Any],
    advanced_indexing_dims: list[int],
    advanced_indexing_shapes: list[tuple[int, ...]],
    output_shape: list[int | None],
    split_bookkeeping: list[str | None],
    key_is_mask_like: bool,
) -> tuple["DNDarray", list[Any], list[int | None], list[str | None], tuple[int, ...]]:
    """
    Broadcasts advanced indexing dimensions and rearranges non-consecutive dimensions
    to the front of the array as mandated by NumPy advanced indexing semantics.
    """
    try:
        broadcasted_shape = torch.broadcast_shapes(*advanced_indexing_shapes)
    except RuntimeError:
        raise IndexError(
            "Shape mismatch: indexing arrays could not be broadcast together with shapes: {}".format(
                advanced_indexing_shapes
            )
        )

    add_dims = len(broadcasted_shape) - len(advanced_indexing_dims)
    is_consecutive = (
        len(advanced_indexing_dims) == 1
        or list(range(advanced_indexing_dims[0], advanced_indexing_dims[-1] + 1))
        == advanced_indexing_dims
    )

    if is_consecutive:
        output_shape[
            advanced_indexing_dims[0] : advanced_indexing_dims[0] + len(advanced_indexing_dims)
        ] = broadcasted_shape

        if key_is_mask_like:
            has_split = (
                "split" in split_bookkeeping
                and split_bookkeeping.index("split") in advanced_indexing_dims
            )
            split_bookkeeping[
                advanced_indexing_dims[0] : advanced_indexing_dims[0] + len(advanced_indexing_dims)
            ] = ["split"] if has_split else [None]
        else:
            adv_sb = split_bookkeeping[advanced_indexing_dims[0] : advanced_indexing_dims[-1] + 1]
            new_adv_sb = [None] * len(broadcasted_shape)
            if "split" in adv_sb:
                new_idx = max(0, adv_sb.index("split") + add_dims)
                new_adv_sb[new_idx] = "split"

            split_bookkeeping = (
                split_bookkeeping[: advanced_indexing_dims[0]]
                + new_adv_sb
                + split_bookkeeping[advanced_indexing_dims[-1] + 1 :]
            )
    else:
        # Non-consecutive: transpose to make advanced dims leading and consecutive
        non_adv_ind_dims = [i for i in range(arr.ndim) if i not in advanced_indexing_dims]
        transpose_axes = tuple(advanced_indexing_dims + non_adv_ind_dims)
        arr = arr.transpose(transpose_axes)

        output_shape = [output_shape[i] for i in transpose_axes]
        output_shape[: len(advanced_indexing_dims)] = broadcasted_shape

        split_bookkeeping = [split_bookkeeping[i] for i in transpose_axes]
        adv_sb = split_bookkeeping[: len(advanced_indexing_dims)]
        new_adv_sb = [None] * len(broadcasted_shape)

        if "split" in adv_sb:
            new_idx = max(0, adv_sb.index("split") + add_dims)
            new_adv_sb[new_idx] = "split"

        split_bookkeeping = new_adv_sb + split_bookkeeping[len(advanced_indexing_dims) :]
        key = [key[i] for i in advanced_indexing_dims] + [key[i] for i in non_adv_ind_dims]

    return arr, key, output_shape, split_bookkeeping


def _assess_op_type(
    root: int | None,
    split_key_is_ordered: int,
    distr_mask_fast_path: bool,
    key_is_mask_like: bool,
) -> str:
    """Determine the indexing operation routing category."""
    if root is not None:
        return "scalar"
    if split_key_is_ordered == 0:
        return "distributed"
    if split_key_is_ordered == -1:
        return "descending_slice"
    if distr_mask_fast_path:
        return "distr_mask"
    if key_is_mask_like:
        return "local_mask"
    return "local"


def _sanitize_advanced_keys(
    arr: "DNDarray",
    key: list[Any],
    advanced_indexing_dims: list[int],
    split_key_is_ordered: int,
    key_is_mask_like: bool,
    distr_mask_fast_path: bool,
    counts: tuple | list | None,
    displs: tuple | list | None,
    return_local_indices: bool,
) -> tuple[list[Any], bool]:
    """
    Validates key distribution alignment along the split axis and converts
    all advanced indexing key elements from DNDarrays into local torch.Tensors.
    """
    key = list(key)

    # Detect mask-like conditions (same shape for adv indexing dimensions)
    adv_keys = [key[i] for i in advanced_indexing_dims]
    key_is_mask_like = key_is_mask_like or (
        len(advanced_indexing_dims) > 1
        and all(isinstance(k, DNDarray) for k in adv_keys)
        and len(set(k.shape for k in adv_keys)) == 1
    )

    non_split_dims = [d for d in advanced_indexing_dims if d != arr.split]

    # Align distributions if mask-like
    if key_is_mask_like and arr.split is not None and arr.split in advanced_indexing_dims:
        key_splits = [k.split for k in adv_keys]
        split_pos = advanced_indexing_dims.index(arr.split)
        target_split = key_splits[split_pos]

        if key_splits.count(target_split) != len(key_splits):
            if target_split is not None and key_splits.count(None) == len(key_splits) - 1:
                for i in non_split_dims:
                    key[i] = factories.array(
                        key[i],
                        split=target_split,
                        device=arr.device,
                        comm=arr.comm,
                        copy=None,
                    )
            else:
                raise IndexError(
                    f"Indexing arrays must be distributed along the same dimension, got splits {key_splits}."
                )

    # Extract local torch.Tensors
    if arr.is_distributed() and arr.split in advanced_indexing_dims:
        if distr_mask_fast_path:
            for i in non_split_dims:
                if isinstance(key[i], DNDarray):
                    key[i] = key[i].larray
        elif split_key_is_ordered == 1:
            k = key[arr.split].larray if isinstance(key[arr.split], DNDarray) else key[arr.split]
            rank = arr.comm.rank
            low = displs[rank]
            high = low + counts[rank]

            idx_start = torch.searchsorted(k, low)
            idx_end = torch.searchsorted(k, high)
            k_local = k[idx_start:idx_end]
            if return_local_indices:
                k_local = k_local - low
            key[arr.split] = k_local

            if key_is_mask_like:
                for i in non_split_dims:
                    larr = key[i].larray if isinstance(key[i], DNDarray) else key[i]
                    key[i] = larr[idx_start:idx_end]
            else:
                for i in non_split_dims:
                    if isinstance(key[i], DNDarray):
                        key[i] = key[i].larray
        else:
            # split_key_is_ordered == 0 (unordered indexing)
            for i in advanced_indexing_dims:
                if isinstance(key[i], DNDarray):
                    key[i] = key[i].larray
    else:
        for i in advanced_indexing_dims:
            if isinstance(key[i], DNDarray):
                key[i] = key[i].larray

    return key, key_is_mask_like


def _resolve_indexing_state(
    arr: "DNDarray",
    key: Indexer,
    return_local_indices: bool | None = False,
    op: str | None = None,
) -> tuple["DNDarray", ProcessedKey]:
    """
    Private helper function to align the indexing key and the array for distributed indexing operations.
    This function is used internally by both ``__getitem__`` and ``__setitem__`` pipelines.

    After processing the key, the following conditions are guaranteed:
    - Any ellipses (`...`) or newaxis (`None`) objects have been replaced with the appropriate number of slice objects.
    - ``np.ndarray`` and ``DNDarray`` objects have been converted to process-local ``torch.Tensor`` objects.
    - The dimensionality of the key perfectly matches the (potentially modified) ``DNDarray`` it indexes.
    - Negative indices have been wrapped appropriately.

    This function also manipulates ``arr`` if necessary, inserting and/or transposing dimensions as dictated
    by advanced indexing rules. Finally, it calculates the output shape, new split axis, and balanced status
    of the resulting indexed array.

    Parameters
    ----------
    arr : DNDarray
        The ``DNDarray`` to be indexed.
    key : array-like indexer
        The raw key used for indexing.
    return_local_indices : bool, optional
        Whether to map the split-axis indices from global to process-local indices. This is only applied
        when the indexing key along the split dimension is ordered (i.e., ``split_key_is_ordered == 1``).
        Default: ``False``.
    op : str, optional
        The indexing context for which the key is being processed. Can be ``"get"`` for ``__getitem__``
        or ``"set"`` for ``__setitem__``. Default: ``None``.

    Returns
    -------
    tuple
        A tuple containing two elements: ``(arr, processed_key)``.

        - arr (DNDarray):
            The array to be indexed. Its dimensions may have been transposed or expanded if advanced,
            dimensional, or broadcasted indexing was used.
        - processed_key (ProcessedKey):
            A named tuple containing the resolved state required to execute the indexing operation,
            consisting of the following fields:

            - key (tuple): The processed, Torch-compatible index. Note: Indices along the split axis
                are local if ordered indexing is used, but remain global if unordered indexing is required.
            - op_type (str): The categorized indexing routing (``"scalar"``, ``"slice"``,
                ``"descending_slice"``, ``"distr_mask"``, ``"local_mask"``, ``"local"``, or ``"distributed"``).
            - output_shape (tuple): The global shape of the resulting array.
            - output_split (int or None): The split axis of the resulting array.
            - split_key_is_ordered (int): Monotonicity of the split key (``1``: ascending, ``0``: unordered,
                ``-1``: descending).
            - key_is_mask_like (bool): Whether the key acts as a boolean mask.
            - out_is_balanced (bool): Whether the resulting ``DNDarray`` maintains load balance.
            - root (int or None): The root MPI process ID if single-element indexing along the split
                axis isolate data to one rank.
    """
    # early out for scalar key
    if _is_scalar_index(key):
        return _scalar_early_out(
            arr=arr,
            key=key,
            op=op,
            return_local_indices=return_local_indices,
        )

    # normalize key items to torch-friendly types (torch.Tensor, int, slice, None, Ellipsis)
    # NB: distributed DNDarrays are not unwrapped here, they are handled later
    normalized_key = _normalize_key(key, device=arr.device.torch_device)
    # maintain single item when raw key was not passed as a tuple
    key = normalized_key if isinstance(key, tuple) else normalized_key[0]

    # unpack single-element tuple containing a boolean mask (a[(mask,)] same as a[mask])
    if isinstance(key, tuple) and len(key) == 1 and _is_boolean_array(key[0]):
        key = key[0]

    # evaluate if this is a distributed mask aligned with the array
    distr_mask_fast_path = _distr_mask_fast_path(arr, key, op)
    if distr_mask_fast_path and not isinstance(key, tuple):
        return arr, ProcessedKey(
            key=key.larray,
            op_type="distr_mask",
            output_shape=None,
            output_split=0 if op == "get" else arr.split,
            split_key_is_ordered=0,
            key_is_mask_like=True,
            out_is_balanced=False,
            root=None,
        )

    # 1D boolean mask resolution
    key = _resolve_1d_boolean_first_dim(arr, key, distr_mask_fast_path)

    output_shape = list(arr.gshape)
    split_bookkeeping = [None] * arr.ndim
    new_split = arr.split
    arr_is_distributed = False
    if arr.split is not None:
        split_bookkeeping[arr.split] = "split"
        if arr.is_distributed():
            counts, displs = arr.counts_displs()
            arr_is_distributed = True

    advanced_indexing = False
    split_key_is_ordered = 1
    key_is_mask_like = False
    out_is_balanced = True if not arr.is_distributed() else arr.balanced
    root = None

    if isinstance(key, (DNDarray, torch.Tensor)):
        if key.dtype in (ht_bool, ht_uint8, torch.bool, torch.uint8):
            # boolean indexing: shape must be consistent with arr.shape
            key_ndim = key.ndim
            if not tuple(key.shape) == arr.shape[:key_ndim]:
                raise IndexError(
                    "Boolean index of shape {} does not match indexed array of shape {}".format(
                        tuple(key.shape), arr.shape
                    )
                )
            if key_ndim == 0:
                # 0-D boolean mask: keep as 0-D tensor, do not extract non-zero
                key = key.larray if isinstance(key, DNDarray) else key
            else:
                # extract non-zero elements
                try:
                    key = key.nonzero(as_tuple=True)
                except TypeError:
                    key = key.nonzero()

            key_is_mask_like = True
        else:
            # advanced indexing on first dimension: first dim will expand to shape of key
            output_shape = tuple(list(key.shape) + output_shape[1:])
            # adjust split axis accordingly
            if arr_is_distributed:
                if arr.split != 0:
                    # split axis is not affected
                    split_bookkeeping = [None] * key.ndim + split_bookkeeping[1:]
                    new_split = (
                        split_bookkeeping.index("split") if "split" in split_bookkeeping else None
                    )
                    out_is_balanced = arr.balanced
                else:
                    # split axis is affected
                    if key.ndim > 1:
                        key_numel = key.numel()
                        if key_numel == arr.shape[0]:
                            new_split = tuple(key.shape).index(arr.shape[0])
                        else:
                            new_split = key.ndim - 1
                    else:
                        new_split = 0

                    key_is_dist = isinstance(key, DNDarray) and key.is_distributed()
                    if isinstance(key, DNDarray):
                        out_is_balanced = key.balanced
                        key = key.larray
                    else:
                        out_is_balanced = True

                    # normalize negative indices
                    if key.dtype in (torch.int8, torch.int16, torch.int32, torch.int64):
                        dim = arr.gshape[0]
                        if ((key < -dim) | (key >= dim)).any():
                            raise IndexError(f"index out of bounds for axis 0 with size {dim}")
                        key = torch.where(key < 0, key + dim, key)

                    # identify ordered key
                    if key_is_dist or key.ndim > 1:
                        split_key_is_ordered = 0
                    else:
                        split_key_is_ordered = int((key[1:] >= key[:-1]).all().item())

                    # unordered local keys
                    if not split_key_is_ordered and not key_is_dist:
                        out_is_balanced = True

                    # ordered keys
                    if split_key_is_ordered:
                        rank = arr.comm.rank
                        low = displs[rank]
                        high = low + counts[rank]
                        idx_start = torch.searchsorted(key, low)
                        idx_end = torch.searchsorted(key, high)
                        key = key[idx_start:idx_end]
                        if return_local_indices:
                            key = key - low
                        out_is_balanced = False
            else:
                try:
                    out_is_balanced = key.balanced
                    new_split = key.split
                    key = key.larray
                except AttributeError:
                    # torch key, non-distributed indexed array
                    out_is_balanced = True
                    new_split = None

            op_type = _assess_op_type(
                root=root,
                split_key_is_ordered=split_key_is_ordered,
                distr_mask_fast_path=distr_mask_fast_path,
                key_is_mask_like=key_is_mask_like,
            )
            return arr, ProcessedKey(
                key=key,
                op_type=op_type,
                output_shape=tuple(output_shape),
                output_split=new_split,
                split_key_is_ordered=split_key_is_ordered,
                key_is_mask_like=key_is_mask_like,
                out_is_balanced=out_is_balanced,
                root=root,
            )

    if isinstance(key, (tuple, list)):
        key = list(key)
    else:
        key = [key]

    # check for ellipsis, newaxis. NB: (ht.newaxis is None)==True
    arr, key, output_shape, split_bookkeeping = _expand_dimensions_and_ellipsis(
        arr, key, output_shape, split_bookkeeping
    )

    # recalculate new_split, transpose_axes after dimensions manipulation
    new_split = split_bookkeeping.index("split") if "split" in split_bookkeeping else None

    # check for advanced indexing and slices
    advanced_indexing_dims = []
    advanced_indexing_shapes = []

    for i, k in enumerate(key):
        if _is_scalar_index(k):
            try:
                output_shape[i], split_bookkeeping[i] = None, None
            except IndexError:
                raise IndexError(
                    f"Too many indices for DNDarray: DNDarray is {arr.ndim}-dimensional, but {len(key)} dimensions were indexed"
                )
            if i == arr.split:
                key[i], root = _process_scalar_key(
                    arr, k, indexed_axis=i, return_local_indices=return_local_indices
                )
            else:
                key[i], _ = _process_scalar_key(arr, k, indexed_axis=i, return_local_indices=False)
        elif isinstance(k, Iterable) or isinstance(k, DNDarray):
            advanced_indexing = True
            advanced_indexing_dims.append(i)

            is_fast_path_component = distr_mask_fast_path and i == arr.split

            if is_fast_path_component:
                key[i] = k.larray if isinstance(k, DNDarray) else k
                advanced_indexing_shapes.append(tuple(k.shape))
                # skip the rest, local boolean masking along split axis
                continue

            if not isinstance(k, DNDarray):
                k = factories.array(k, device=arr.device, comm=arr.comm, copy=None)

            # normalize negative integer indices (NumPy/PyTorch semantics) and validate bounds
            k = _sanitize_int_indices(
                k=k, dim=arr.gshape[i], axis=i, comm=arr.comm, device=arr.device
            )

            advanced_indexing_shapes.append(k.gshape)
            if arr_is_distributed and i == arr.split:
                if (
                    not k.is_distributed()
                    and k.ndim == 1
                    and (k.larray.numel() <= 1 or (k.larray[1:] >= k.larray[:-1]).all().item())
                ):
                    split_key_is_ordered = 1
                    out_is_balanced = False
                else:
                    split_key_is_ordered = 0
                    out_is_balanced = True
            key[i] = k

        elif isinstance(k, slice) and k != slice(None):
            is_split_axis = arr_is_distributed and new_split == i
            rank = arr.comm.rank if arr_is_distributed else None

            slice_key, dim_len, s_ordered, s_balanced = _process_slice_indexer(
                k=k,
                dim=arr.gshape[i],
                is_split_axis=is_split_axis,
                displs=displs if arr_is_distributed else None,
                counts=counts if arr_is_distributed else None,
                rank=rank,
                device=arr.device.torch_device,
                return_local_indices=return_local_indices,
            )

            key[i] = slice_key
            output_shape[i] = dim_len
            if is_split_axis and s_ordered is not None:
                split_key_is_ordered = s_ordered
                out_is_balanced = s_balanced

    if advanced_indexing:
        key, key_is_mask_like = _sanitize_advanced_keys(
            arr=arr,
            key=key,
            advanced_indexing_dims=advanced_indexing_dims,
            split_key_is_ordered=split_key_is_ordered,
            key_is_mask_like=key_is_mask_like,
            distr_mask_fast_path=distr_mask_fast_path,
            counts=counts if arr_is_distributed else None,
            displs=displs if arr_is_distributed else None,
            return_local_indices=return_local_indices,
        )

        arr, key, output_shape, split_bookkeeping = _reorder_advanced_idx_axes(
            arr=arr,
            key=key,
            advanced_indexing_dims=advanced_indexing_dims,
            advanced_indexing_shapes=advanced_indexing_shapes,
            output_shape=output_shape,
            split_bookkeeping=split_bookkeeping,
            key_is_mask_like=key_is_mask_like,
        )

    # expand key to match the number of dimensions of the DNDarray
    if arr.ndim > len(key):
        key += [slice(None)] * (arr.ndim - len(key))

    while None in output_shape:
        lost_dim = output_shape.index(None)
        output_shape.pop(lost_dim)
        split_bookkeeping.pop(lost_dim)

    output_shape = tuple(output_shape)
    new_split = split_bookkeeping.index("split") if "split" in split_bookkeeping else None

    op_type = _assess_op_type(
        root=root,
        split_key_is_ordered=split_key_is_ordered,
        distr_mask_fast_path=distr_mask_fast_path,
        key_is_mask_like=key_is_mask_like,
    )

    return arr, ProcessedKey(
        key=tuple(key),
        op_type=op_type,
        output_shape=tuple(output_shape),
        output_split=new_split,
        split_key_is_ordered=split_key_is_ordered,
        key_is_mask_like=key_is_mask_like,
        out_is_balanced=out_is_balanced,
        root=root,
    )


[docs] class DNDarray: """ Distributed N-Dimensional array. The core element of Heat. It is composed of PyTorch tensors local to each process. Parameters ---------- array : torch.Tensor Local array elements gshape : tuple[int,...] The global shape of the array dtype : datatype The datatype of the array split : int or None The axis on which the array is divided between processes device : Device The device on which the local arrays are using (cpu or gpu) comm : Communication The communications object for sending and receiving data balanced: bool or None Describes whether the data are evenly distributed across processes. If this information is not available (``self.balanced is None``), it can be gathered via the :func:`is_balanced()` method (requires communication). """ def __init__( self, array: torch.Tensor, gshape: tuple[int, ...], dtype: datatype, split: int | None, device: Device, comm: Communication, balanced: bool, ): self.__array = array self.__gshape = gshape self.__dtype = dtype self.__split = split self.__device = device self.__comm = comm self.__balanced: bool = balanced self.__ishalo = False self.__halo_next: torch.Tensor | None = None self.__halo_prev: torch.Tensor | None = None self.__partitions_dict__ = None self.__lshape_map = None self.__counts_displs = None # check for inconsistencies between torch and heat devices assert str(array.device) == device.torch_device @property def balanced(self) -> bool: """ Boolean value indicating if the DNDarray is balanced between the MPI processes """ return self.__balanced @property def comm(self) -> Communication: """ The :class:`~heat.core.communication.Communication` of the ``DNDarray`` """ return self.__comm @property def device(self) -> Device: """ The :class:`~heat.core.devices.Device` of the ``DNDarray`` """ return self.__device @property def dtype(self) -> datatype: """ The :class:`~heat.core.types.datatype` of the ``DNDarray`` """ return self.__dtype @property def gshape(self) -> tuple: """ Returns the global shape of the ``DNDarray`` across all processes """ return self.__gshape @property def halo_next(self) -> torch.Tensor: """ Returns the halo of the next process """ return self.__halo_next @property def halo_prev(self) -> torch.Tensor: """ Returns the halo of the previous process """ return self.__halo_prev @property def larray(self) -> torch.Tensor: """ Returns the underlying process-local ``torch.Tensor`` of the ``DNDarray`` """ return self.__array @larray.setter def larray(self, array: torch.Tensor): """ Setter for ``self.larray``, the underlying local ``torch.Tensor`` of the ``DNDarray``. Parameters ---------- array : torch.Tensor The new underlying local ``torch.tensor`` of the ``DNDarray`` Warning ----------- Please use this function with care, as it might corrupt/invalidate the metadata in the ``DNDarray`` instance. """ # sanitize tensor input sanitation.sanitize_in_tensor(array) # verify consistency of tensor shape with global DNDarray sanitation.sanitize_lshape(self, array) # set balanced status split = self.split if split is not None and array.shape[split] != self.lshape[split]: self.__balanced = None self.__lshape_map = None self.__counts_displs = None self.__array = array @property def nbytes(self) -> int: """ Returns the number of bytes consumed by the global tensor. Equivalent to property gnbytes. Note ------------ Does not include memory consumed by non-element attributes of the ``DNDarray`` object. """ return self.__array.element_size() * self.size @property def ndim(self) -> int: """ Number of dimensions of the ``DNDarray`` """ return len(self.__gshape) @property def __partitioned__(self) -> dict: """ Return a dictionary containing information useful for working with the partitioned data. These items include the shape of the data on each process, the starting index of the data that a process has, the datatype of the data, the local devices, as well as the global partitioning scheme. An example of the output and shape is shown in :func:`ht.core.DNDarray.create_partition_interface <ht.core.DNDarray.create_partition_interface>`. Returns ------- dictionary with the partition interface """ if self.__partitions_dict__ is None: self.__partitions_dict__ = self.create_partition_interface() return self.__partitions_dict__ @property def size(self) -> int: """ Number of total elements of the ``DNDarray`` """ if self.larray.is_mps: # MPS does not support double precision size = torch.prod( torch.tensor(self.gshape, dtype=torch.float32, device=self.device.torch_device) ) else: size = torch.prod( torch.tensor(self.gshape, dtype=torch.float64, device=self.device.torch_device) ) return size.long().item() @property def gnbytes(self) -> int: """ Returns the number of bytes consumed by the global ``DNDarray`` Note ----------- Does not include memory consumed by non-element attributes of the ``DNDarray`` object. """ return self.nbytes @property def gnumel(self) -> int: """ Returns the number of total elements of the ``DNDarray`` """ return self.size @property def imag(self) -> DNDarray: """ Return the imaginary part of the ``DNDarray``. """ return complex_math.imag(self) @property def lnbytes(self) -> int: """ Returns the number of bytes consumed by the local ``torch.Tensor`` Note ------------------- Does not include memory consumed by non-element attributes of the ``DNDarray`` object. """ return self.__array.element_size() * self.__array.nelement() @property def lnumel(self) -> int: """ Number of elements of the ``DNDarray`` on each process """ return np.prod(self.__array.shape) @property def lshape(self) -> tuple[int]: """ Returns the shape of the ``DNDarray`` on each node """ return tuple(self.__array.shape) @property def lshape_map(self) -> torch.Tensor: """ Returns the lshape map. If it hasn't been previously created then it will be created here. """ return self.create_lshape_map() @property def lloc(self): """Deprecated function for local indexing. Use `DNDarray.larray` for local indexing instead""" # TODO: Remove this entirely by heat v2.5 raise Exception( "`DNDarray.lloc` is deprecated. Use `DNDarray.larray` for local indexing instead." ) @property def real(self) -> DNDarray: """ Return the real part of the ``DNDarray``. """ return complex_math.real(self) @property def shape(self) -> tuple[int]: """ Returns the shape of the ``DNDarray`` as a whole """ return self.__gshape @property def split(self) -> int | None: """ Returns the axis on which the ``DNDarray`` is split """ return self.__split @property def stride(self) -> tuple[int]: """ Returns the steps in each dimension when traversing a ``DNDarray``. torch-like usage: ``self.stride()`` """ return self.__array.stride @property def strides(self) -> tuple[int]: """ Returns bytes to step in each dimension when traversing a ``DNDarray``. numpy-like usage: ``self.strides()`` """ steps = list(self.__array.stride()) try: itemsize = self.__array.untyped_storage().element_size() except AttributeError: itemsize = self.__array.storage().element_size() strides = tuple(step * itemsize for step in steps) return strides @property def array_with_halos(self) -> torch.Tensor: """ Fetch halos of size ``halo_size`` from neighboring ranks and save them in ``self.halo_next``/``self.halo_prev`` in case they are not already stored. If ``halo_size`` differs from the size of already stored halos, the are overwritten. """ return self.__cat_halo() def __prephalo(self, start, end) -> torch.Tensor: """ Extracts the halo indexed by start, end from ``self.array`` in the direction of ``self.split`` Parameters ---------- start : int Start index of the halo extracted from ``self.array`` end : int End index of the halo extracted from ``self.array`` """ ix = [slice(None, None, None)] * len(self.shape) try: ix[self.split] = slice(start, end) except IndexError: print("Indices out of bound") return self.__array[tuple(ix)].clone()
[docs] def get_halo(self, halo_size: int, prev: bool = True, next: bool = True): """ Fetch halos of size ``halo_size`` from neighboring ranks and save them in ``self.halo_next/self.halo_prev``. Parameters ---------- halo_size : int Size of the halo. prev : bool, optional If True, fetch the halo from the previous rank. Default: True. next : bool, optional If True, fetch the halo from the next rank. Default: True. """ if not isinstance(halo_size, int): raise TypeError( f"halo_size needs to be of Python type integer, {type(halo_size)} given" ) if halo_size < 0: raise ValueError( f"halo_size needs to be a non-negative Python integer, {halo_size} given" ) if self.is_distributed() and halo_size > 0: # gather lshapes lshape_map = self.lshape_map rank = self.comm.rank populated_ranks = torch.nonzero(lshape_map[:, self.split]).squeeze().tolist() if rank in populated_ranks: first_rank = populated_ranks[0] last_rank = populated_ranks[-1] if rank != last_rank: next_rank = populated_ranks[populated_ranks.index(rank) + 1] if rank != first_rank: prev_rank = populated_ranks[populated_ranks.index(rank) - 1] else: # if process has no data we ignore it return if (halo_size > self.lshape_map[:, self.split][populated_ranks]).any(): # halo_size is larger than the local size on at least one process raise ValueError( f"halo_size {halo_size} needs to be smaller than chunk-size {self.lshape[self.split]} )" ) a_prev = self.__prephalo(0, halo_size) a_next = self.__prephalo(-halo_size, None) res_prev = None res_next = None req_list = [] # exchange data with next populated process if prev: if rank != last_rank: req_list.append(self.comm.Isend(a_next, next_rank)) if rank != first_rank: res_prev = torch.empty( a_prev.size(), dtype=a_prev.dtype, device=self.device.torch_device ) req_list.append(self.comm.Irecv(res_prev, source=prev_rank)) if next: if rank != first_rank: req_list.append(self.comm.Isend(a_prev, prev_rank)) if rank != last_rank: res_next = torch.empty( a_next.size(), dtype=a_next.dtype, device=self.device.torch_device ) req_list.append(self.comm.Irecv(res_next, source=next_rank)) for req in req_list: req.Wait() self.__halo_next = res_next self.__halo_prev = res_prev self.__ishalo = True
def __cat_halo(self) -> torch.Tensor: """ Return local array concatenated to halos if they are available. """ if not self.is_distributed(): return self.__array return torch.cat( [_ for _ in (self.__halo_prev, self.__array, self.__halo_next) if _ is not None], dim=self.split, )
[docs] def __array__(self) -> np.ndarray: """ Returns a view of the process-local slice of the :class:`DNDarray` as a numpy ndarray, if the ``DNDarray`` resides on CPU. Otherwise, it returns a copy, on CPU, of the process-local slice of ``DNDarray`` as numpy ndarray. """ return self.larray.cpu().__array__()
[docs] def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """ Override NumPy's universal functions. """ import heat # TODO support ufunc method variants if method == "__call__": try: func = getattr(heat, ufunc.__name__) except AttributeError: return NotImplemented return func(*inputs, **kwargs) else: return NotImplemented
[docs] def __array_function__(self, func, types, args, kwargs): """ Augments NumPy's functions. """ import heat try: ht_func = getattr(heat, func.__name__) except AttributeError: return NotImplemented return ht_func(*args, **kwargs)
[docs] def __array_namespace__(self, *, api_version: str | None = None) -> Any: """ Returns an object that has all the array API functions on it. Parameters ---------- api_version : Optional[str] string representing the version of the array API specification to be returned, in ``'YYYY.MM'`` form. If it is ``None`` (default), it returns the namespace corresponding to latest version of the array API specification. """ if api_version is not None and api_version != "2025.12": raise ValueError(f"Unrecognized array API version: {api_version}") import heat return heat
[docs] def astype(self, dtype, copy=True, device: Device = None) -> DNDarray: """ Returns a casted version of this array. Casted array is a new array of the same shape but with given type of this array. If copy is ``True``, the same array is returned instead. Parameters ---------- dtype : datatype Heat type to which the array is cast copy : bool, optional By default the operation returns a copy of this array. If copy is set to ``False`` the cast is performed in-place and this array is returned device: ht.Device, optional The device on which to place the array. If ``None``, keep device. Default: None. """ dtype = canonical_heat_type(dtype) device = self.__device if device is None else devices.sanitize_device(device) if self.__array.is_mps: if dtype == types.float64: # print warning warnings.warn( "MPS does not support float64. Casting to float32 instead.", ResourceWarning, ) dtype = types.float32 elif dtype == types.complex128: # print warning warnings.warn( "MPS does not support complex128. Casting to complex64 instead.", ResourceWarning, ) dtype = types.complex64 casted_array = self.__array.to( device=device.torch_device, dtype=dtype.torch_type(), copy=copy ) if copy: return DNDarray( casted_array, gshape=self.shape, dtype=dtype, split=self.split, device=device, comm=self.comm, balanced=self.balanced, ) self.__array = casted_array self.__dtype = dtype self.__device = device return self
[docs] def balance_(self) -> None: """ Function for balancing a :class:`DNDarray` between all nodes. To determine if this is needed use the :func:`is_balanced()` function. If the ``DNDarray`` is already balanced this function will do nothing. This function modifies the ``DNDarray`` itself and will not return anything. Examples -------- >>> a = ht.zeros((10, 2), split=0) >>> a[:, 0] = ht.arange(10) >>> b = a[3:] [0/2] tensor([[3., 0.], [1/2] tensor([[4., 0.], [5., 0.], [6., 0.]]) [2/2] tensor([[7., 0.], [8., 0.], [9., 0.]]) >>> b.balance_() >>> print(b.gshape, b.lshape) [0/2] (7, 2) (1, 2) [1/2] (7, 2) (3, 2) [2/2] (7, 2) (3, 2) >>> b [0/2] tensor([[3., 0.], [4., 0.], [5., 0.]]) [1/2] tensor([[6., 0.], [7., 0.]]) [2/2] tensor([[8., 0.], [9., 0.]]) >>> print(b.gshape, b.lshape) [0/2] (7, 2) (3, 2) [1/2] (7, 2) (2, 2) [2/2] (7, 2) (2, 2) """ if not self.is_distributed(): self.__balanced = True if self.is_balanced(force_check=True): return self.redistribute_()
[docs] def __bool__(self) -> bool: """ Boolean scalar casting. """ return self.__cast(bool)
def __cast(self, cast_function) -> float | int: """ Implements a generic cast function for ``DNDarray`` objects. Parameters ---------- cast_function : function The actual cast function, e.g. ``float`` or ``int`` Raises ------ TypeError If the ``DNDarray`` object cannot be converted into a scalar. """ if np.prod(self.shape) == 1: if not self.is_distributed(): return cast_function(self.__array) is_empty = np.prod(self.__array.shape) == 0 root = self.comm.allreduce(0 if is_empty else self.comm.rank, op=MPI.SUM) return self.comm.bcast(None if is_empty else cast_function(self.__array), root=root) raise TypeError("only size-1 arrays can be converted to Python scalars")
[docs] def collect_(self, target_rank: int = 0) -> None: """ A method collecting a distributed DNDarray to one MPI rank, chosen by the `target_rank` variable. It is a specific case of the ``redistribute_`` method. Parameters ---------- target_rank : int, optional The rank to which the DNDarray will be collected. Default: 0. Raises ------ TypeError If the target rank is not an integer. ValueError If the target rank is out of bounds. Examples -------- >>> st = ht.ones((50, 81, 67), split=2) >>> print(st.lshape) [0/2] (50, 81, 23) [1/2] (50, 81, 22) [2/2] (50, 81, 22) >>> st.collect_() >>> print(st.lshape) [0/2] (50, 81, 67) [1/2] (50, 81, 0) [2/2] (50, 81, 0) >>> st.collect_(1) >>> print(st.lshape) [0/2] (50, 81, 0) [1/2] (50, 81, 67) [2/2] (50, 81, 0) """ if not isinstance(target_rank, int): raise TypeError(f"target rank must be of type int , but was {type(target_rank)}") if target_rank >= self.comm.size: raise ValueError("target rank is out of bounds") if not self.is_distributed(): return target_map = self.lshape_map.clone() target_map[:, self.split] = 0 target_map[target_rank, self.split] = self.gshape[self.split] self.redistribute_(target_map=target_map)
[docs] def __complex__(self) -> DNDarray: """ Complex scalar casting. """ return self.__cast(complex)
[docs] def counts_displs(self) -> tuple[tuple[int, ...], tuple[int, ...]]: """ Returns actual counts (number of items per process) and displacements (offsets) of the DNDarray. Does not assume load balance. """ if self.split is not None: if self.__counts_displs is not None: return self.__counts_displs if self.__lshape_map is None: self.create_lshape_map() counts = self.__lshape_map[:, self.split] displs = [0] + torch.cumsum(counts, dim=0)[:-1].tolist() res = (tuple(counts.tolist()), tuple(displs)) self.__counts_displs = res return res raise ValueError("Non-distributed DNDarray. Cannot calculate counts and displacements.")
[docs] def cpu(self) -> DNDarray: """ Returns a copy of this object in main memory. If this object is already in main memory, then no copy is performed and the original object is returned. """ self.__array = self.__array.cpu() self.__device = devices.cpu return self
[docs] def create_lshape_map(self, force_check: bool = False) -> torch.Tensor: """ Generate a 'map' of the lshapes of the data on all processes. Units are ``(process rank, lshape)`` Parameters ---------- force_check : bool, optional if False (default) and the lshape map has already been created, use the previous result. Otherwise, create the lshape_map """ if not force_check and self.__lshape_map is not None: return self.__lshape_map.clone() lshape_map = torch.zeros( (self.comm.size, self.ndim), dtype=torch.int64, device=self.device.torch_device ) if not self.is_distributed(): lshape_map[:] = torch.tensor(self.gshape, device=self.device.torch_device) self.__lshape_map = lshape_map return lshape_map.clone() elif self.is_balanced(force_check=True): for i in range(self.comm.size): _, lshape, _ = self.comm.chunk(self.gshape, self.split, rank=i) lshape_map[i, :] = torch.tensor(lshape, device=self.device.torch_device) else: lshape_map[self.comm.rank, :] = torch.tensor( self.lshape, device=self.device.torch_device ) self.comm.Allreduce(MPI.IN_PLACE, lshape_map, MPI.SUM) self.__lshape_map = lshape_map self.__counts_displs = None return lshape_map.clone()
[docs] def create_partition_interface(self): """ Create a partition interface in line with the DPPY proposal. This is subject to change. The intention of this to facilitate the usage of a general format for the referencing of distributed datasets. An example of the output and shape is shown below. __partitioned__ = { 'shape': (27, 3, 2), 'partition_tiling': (4, 1, 1), 'partitions': { (0, 0, 0): { 'start': (0, 0, 0), 'shape': (7, 3, 2), 'data': tensor([...], dtype=torch.int32), 'location': [0], 'dtype': torch.int32, 'device': 'cpu' }, (1, 0, 0): { 'start': (7, 0, 0), 'shape': (7, 3, 2), 'data': None, 'location': [1], 'dtype': torch.int32, 'device': 'cpu' }, (2, 0, 0): { 'start': (14, 0, 0), 'shape': (7, 3, 2), 'data': None, 'location': [2], 'dtype': torch.int32, 'device': 'cpu' }, (3, 0, 0): { 'start': (21, 0, 0), 'shape': (6, 3, 2), 'data': None, 'location': [3], 'dtype': torch.int32, 'device': 'cpu' } }, 'locals': [(rank, 0, 0)], 'get': lambda x: x, } Returns ------- dictionary containing the partition interface as shown above. """ lshape_map = self.create_lshape_map() start_idx_map = torch.zeros_like(lshape_map) part_tiling = [1] * self.ndim lcls = [0] * self.ndim z = torch.tensor([0], device=self.device.torch_device, dtype=torch.int64) if self.split is not None: starts = torch.cat((z, torch.cumsum(lshape_map[:, self.split], dim=0)[:-1]), dim=0) lcls[self.split] = self.comm.rank part_tiling[self.split] = self.comm.size start_idx_map[:, self.split] = starts else: start_idx_map[:] = 0 partitions = {} base_key = [0] * self.ndim for r in range(self.comm.size): if self.split is not None: base_key[self.split] = r dat = None if r != self.comm.rank else self.larray else: dat = self.larray partitions[tuple(base_key)] = { "start": tuple(start_idx_map[r].tolist()), "shape": tuple(lshape_map[r].tolist()), "data": dat, "location": [r], "dtype": self.dtype.torch_type(), "device": self.device.torch_device, } partition_dict = { "shape": self.gshape, "partition_tiling": tuple(part_tiling), "partitions": partitions, "locals": [tuple(lcls)], "get": lambda x: x, } self.__partitions_dict__ = partition_dict return partition_dict
[docs] def __dlpack__( self, *args, **kwargs, ) -> Any: """ Exports the undistributed array for consumption by ``from_dlpack()`` as a DLPack capsule. Any positional arguments ``*args`` and keyword arguments ``**kwargs`` are directly forwarded to torch ``__dlpack__``. Note ---- See `Array API <https://data-apis.org/array-api/2025.12/API_specification/generated/array_api.array.__dlpack__.html>`_ for details and the function signature as implemented by torch. Raises ------ BufferError if the DNDarray is distributed, as this is not supported by DLPack. """ if self.is_distributed(): raise BufferError("DLPack export works for undistributed arrays only.") return self.larray.__dlpack__(*args, **kwargs)
[docs] def __dlpack_device__(self) -> tuple[Enum, int]: """ Returns device type and device ID in DLPack format. Meant for use within ``from_dlpack()``. """ return self.larray.__dlpack_device__()
[docs] def __float__(self) -> float: """ Float scalar casting. See Also -------- :func:`~heat.core.manipulations.flatten` """ return self.__cast(float)
[docs] def fill_diagonal(self, value: float) -> DNDarray: """ Fill the main diagonal of a 2D :class:`DNDarray`. This function modifies the input tensor in-place, and returns the input array. Parameters ---------- value : float The value to be placed in the ``DNDarrays`` main diagonal """ # Todo: make this 3D/nD if len(self.shape) != 2: raise ValueError("Only 2D tensors supported at the moment") if self.is_distributed(): counts, displ, _ = self.comm.counts_displs_shape(self.shape, self.split) k = min(self.shape[0], self.shape[1]) for p in range(self.comm.size): if displ[p] > k: break proc = p if self.comm.rank <= proc: indices = ( displ[self.comm.rank], displ[self.comm.rank + 1] if (self.comm.rank + 1) != self.comm.size else k, ) if self.split == 0: self.larray[:, indices[0] : indices[1]] = self.larray[ :, indices[0] : indices[1] ].fill_diagonal_(value) elif self.split == 1: self.larray[indices[0] : indices[1], :] = self.larray[ indices[0] : indices[1], : ].fill_diagonal_(value) else: self.larray = self.larray.fill_diagonal_(value) return self
def __broadcast_value( self, key: int | tuple[int, ...] | slice, value: "DNDarray", **kwargs, ): """ Broadcasts the assignment DNDarray `value` to the shape of the indexed array `arr[key]` if necessary. """ is_scalar = ( np.isscalar(value) or getattr(value, "ndim", 1) == 0 or (value.shape == (1,) and value.split is None) ) if is_scalar: # no need to broadcast return value, is_scalar # need information on indexed array output_shape = kwargs.get("output_shape", None) indexed_dims = len(output_shape) value_shape = value.shape # check if value needs to be broadcasted if value_shape != output_shape: # assess whether the shapes are compatible, starting from the trailing dimension for i in range(1, min(len(value_shape), len(output_shape)) + 1): if value_shape[-i] != output_shape[-i] and value_shape[-i] != 1: raise ValueError( f"could not broadcast input array from shape {value_shape} into shape {output_shape}" ) # value has more dimensions than indexed array if value.ndim > indexed_dims: # check if all dimensions except the indexed ones are singletons all_singletons = value.shape[: value.ndim - indexed_dims] == (1,) * ( value.ndim - indexed_dims ) if not all_singletons: raise ValueError( f"could not broadcast input array from shape {value_shape} into shape {output_shape}" ) # squeeze out singleton dimensions value = value.squeeze(tuple(range(value.ndim - indexed_dims))) else: while value.ndim < indexed_dims: # broadcasting # expand missing dimensions to align split axis value = value.expand_dims(0) value_shape = tuple(torch.broadcast_shapes(value.shape, output_shape)) return value, is_scalar def __set( self, key: int | tuple[int, ...] | list[int], value: float | "DNDarray" | torch.Tensor, ): """ Setter for not advanced indexing, i.e. when arr[key] is an in-place view of arr. """ # only assign values if key does not contain empty slices if self.larray.numel() == 0: return if torch.is_tensor(key) and key.numel() == 0: return if key == slice(0, 0): return if isinstance(key, tuple) and any( (torch.is_tensor(k) and k.numel() == 0) or k == slice(0, 0) for k in key ): return rhs = value.larray.type(self.dtype.torch_type()) if hasattr(value, "larray") else value key_to_use = key # CUDA: make advanced indexing assignment deterministic for duplicate indices if self.larray.is_cuda: key_to_use, rhs = _resolve_duplicate_indices(key_to_use, rhs, self.larray.shape) self.larray[key_to_use] = rhs return @staticmethod def __advanced_setitem_unordered_local( x_local: torch.Tensor, split_key: torch.Tensor, value_torch: torch.Tensor, *, split_axis: int, value_key_start_dim: int, local_offset: int, local_size: int, value_is_scalar: bool, out_dtype: torch.dtype, base_index: tuple | None = None, ) -> None: """ The function is a helper that updates ``x_local`` in-place according to the logical advanced indexing pattern encoded by ``split_key`` and the broadcasted ``value_torch``. This helper operates exclusively on local ``torch.Tensor`` views: - ``x_local`` is the local slice of the distributed array on this rank. - ``split_key`` contains GLOBAL indices along the split axis. - Only those indices that fall into ``[local_offset, local_offset + local_size)`` are applied on this rank. """ # 1) Local mask: which global indices in `split_key` belong to this rank? global_indices = split_key local_mask = (global_indices >= local_offset) & (global_indices < local_offset + local_size) coord = local_mask.nonzero(as_tuple=True) if coord[0].numel() == 0: # Nothing to do on this rank, exit early. return # 2) Map global → local indices along the split axis global_split_indices = global_indices[coord] local_split_indices = global_split_indices - local_offset # build LHS index for x_local (corresponds to self.larray) lhs_index = list(base_index) lhs_index[split_axis] = local_split_indices lhs_index = tuple(lhs_index) # build RHS index for value_torch if value_is_scalar: rhs = value_torch.to(out_dtype) else: rhs_index = [slice(None)] * value_torch.ndim m = split_key.ndim for d in range(m): rhs_index[value_key_start_dim + d] = coord[d] rhs = value_torch[tuple(rhs_index)].to(out_dtype) if x_local.is_cuda: lhs_index, rhs = _resolve_duplicate_indices(lhs_index, rhs, x_local.shape) x_local[lhs_index] = rhs def __getitem_scalar(self, p: ProcessedKey) -> DNDarray: """ Handles single-element extraction. If the scalar index falls on the split axis, the extracted value is broadcasted from the root process to all others. """ if p.root is not None: # Single-element indexing along split axis if self.comm.rank == p.root: indexed_arr = self.larray[p.key] else: indexed_arr = torch.zeros( p.output_shape, dtype=self.larray.dtype, device=self.device.torch_device ) self.comm.Bcast(indexed_arr, root=p.root) else: indexed_arr = self.larray[p.key] return DNDarray( indexed_arr, gshape=p.output_shape, dtype=self.dtype, split=p.output_split, device=self.device, comm=self.comm, balanced=p.out_is_balanced, ) def __getitem_local(self, p: ProcessedKey) -> "DNDarray": """ Handles process-local indexing (including standard slices and local advanced indices) directly on local array partitions without MPI communication. """ indexed_arr = self.larray[p.key] return DNDarray( indexed_arr, gshape=p.output_shape, dtype=self.dtype, split=p.output_split, device=self.device, comm=self.comm, balanced=p.out_is_balanced, ) def __getitem_descending_slice_distributed(self, p: ProcessedKey) -> DNDarray: """ Handles negative step slicing along the split axis. This is a workaround as torch does not support negative-step slicing. """ from .manipulations import flip # local indexing indexed_arr = self.larray[p.key] # wrap the reversed local chunks into an unbalanced DNDarray intermediate = DNDarray( indexed_arr, gshape=p.output_shape, dtype=self.dtype, split=p.output_split, device=self.device, comm=self.comm, balanced=False, ) # global flip to reflect the descending slice return flip(intermediate, axis=p.output_split) def __getitem_mask(self, p: ProcessedKey) -> "DNDarray": """ Handles fast-path boolean masking. Applies the mask locally without requiring MPI communication during extraction, returning a flattened array distributed along the specified split axis. """ # local masking, then wrap into DNDarray local_mask = p.key local_result = self.larray[local_mask] # calculate gshape local_count = local_result.shape[0] total_count = self.comm.allreduce(local_count, op=MPI.SUM) gshape = (total_count,) + local_result.shape[1:] return DNDarray( local_result, gshape=gshape, dtype=self.dtype, split=p.output_split, device=self.device, comm=self.comm, balanced=False, ) def __getitem_advanced_distributed(self, p: ProcessedKey) -> "DNDarray": """ Handles advanced indexing with unordered global indices. Defers to ``__getitem_unordered`` to resolve data dependencies via an ``Alltoallv`` exchange. """ key = p.key # If key was not distributed, partition it so each rank requests its share of output if p.output_split is not None: if isinstance(key, torch.Tensor) and key.ndim > 0: key_split = p.output_split if key_split < key.ndim and key.shape[key_split] == p.output_shape[p.output_split]: k_dnd = factories.array( key, split=key_split, comm=self.comm, device=self.device ) key = k_dnd.larray elif isinstance(key, tuple): split_k = key[self.split] if isinstance(split_k, torch.Tensor) and split_k.ndim > 0: key_split = 0 if p.key_is_mask_like else split_k.ndim - 1 if split_k.shape[key_split] == p.output_shape[p.output_split]: key_list = list(key) if p.key_is_mask_like: for idx in range(len(key_list)): if isinstance(key_list[idx], torch.Tensor): kd = factories.array( key_list[idx], split=key_split, comm=self.comm, device=self.device, ) key_list[idx] = kd.larray else: kd = factories.array( split_k, split=key_split, comm=self.comm, device=self.device, ) key_list[self.split] = kd.larray key = tuple(key_list) self, indexed_arr = self.__getitem_unordered( key=key, output_shape=p.output_shape, output_split=p.output_split, out_is_balanced=p.out_is_balanced, key_is_mask_like=p.key_is_mask_like, ) return indexed_arr def __getitem_unordered( self, key: tuple, output_shape: tuple, output_split: int, out_is_balanced: bool, key_is_mask_like: bool, ) -> DNDarray: """ Handles the MPI communication (Alltoallv) when the key along the split axis is unordered and indices are global. """ _, displs = self.counts_displs() rank = self.comm.rank key_is_single_tensor = isinstance(key, torch.Tensor) split_key = key if key_is_single_tensor else key[self.split] split_key_flat = split_key.reshape(-1) # Calculate communication split axis for transposing later if key_is_single_tensor or key_is_mask_like: communication_split = 0 else: communication_split = ( output_split - (split_key.ndim - 1) if split_key.ndim > 1 else output_split ) # Step 1: route and send index requests sort_idx, send_counts_t, send_displs_t, recv_counts_t, recv_displs_t = ( self.__prepare_unordered_comm(split_key_flat, displs) ) send_counts = send_counts_t.tolist() send_displs = send_displs_t.tolist() recv_counts = recv_counts_t.tolist() recv_displs = recv_displs_t.tolist() # Expand counts for multidimensional mask coordinates if key_is_mask_like: mask_dims = len(key) idx_send_counts = [c * mask_dims for c in send_counts] idx_send_displs = [d * mask_dims for d in send_displs] idx_recv_counts = [c * mask_dims for c in recv_counts] idx_recv_displs = [d * mask_dims for d in recv_displs] send_indices = torch.stack([k.flatten()[sort_idx] for k in key], dim=1).reshape(-1) recv_indices_flat = torch.empty( sum(idx_recv_counts), dtype=split_key.dtype, device=self.device.torch_device ) else: idx_send_counts, idx_send_displs = send_counts, send_displs idx_recv_counts, idx_recv_displs = recv_counts, recv_displs send_indices = split_key_flat[sort_idx] recv_indices_flat = torch.empty( sum(idx_recv_counts), dtype=split_key.dtype, device=self.device.torch_device ) self.comm.Alltoallv( (send_indices, idx_send_counts, idx_send_displs), (recv_indices_flat, idx_recv_counts, idx_recv_displs), ) if key_is_mask_like: recv_indices = recv_indices_flat.reshape(sum(recv_counts), len(key)) else: recv_indices = recv_indices_flat # Step 2: local data lookup based on received indices if key_is_mask_like: recv_indices[:, self.split] -= displs[rank] lookup_key = tuple(recv_indices[:, i] for i in range(len(key))) local_vals = self.larray[lookup_key] else: recv_indices -= displs[rank] if key_is_single_tensor: local_vals = self.larray[recv_indices] else: lookup_key = list(key) lookup_key[self.split] = recv_indices local_vals = self.larray[tuple(lookup_key)] # Step 3: return data to requesting processes # Ensure the indexed elements are aligned along axis 0 transpose_axes = list(range(local_vals.ndim)) transpose_axes[0], transpose_axes[communication_split] = ( transpose_axes[communication_split], transpose_axes[0], ) local_vals = local_vals.permute(*transpose_axes) feature_shape = list(local_vals.shape[1:]) feature_size = 1 for dim in feature_shape: feature_size *= dim return_send_counts = [c * feature_size for c in recv_counts] return_send_displs = [d * feature_size for d in recv_displs] return_recv_counts = [c * feature_size for c in send_counts] return_recv_displs = [d * feature_size for d in send_displs] send_vals = local_vals.reshape(-1) recv_vals_flat = torch.empty( sum(return_recv_counts), dtype=self.larray.dtype, device=self.device.torch_device ) self.comm.Alltoallv( (send_vals, return_send_counts, return_send_displs), (recv_vals_flat, return_recv_counts, return_recv_displs), ) # Step 4: reshape received values and reorder to match original key order recv_vals = recv_vals_flat.reshape(-1, *feature_shape) # Reverse the sorting applied in Step 1 inv_sort_idx = torch.empty_like(sort_idx) inv_sort_idx[sort_idx] = torch.arange(sort_idx.numel(), device=sort_idx.device) unsorted_vals = recv_vals[inv_sort_idx] # Restore original dimension order final_vals = unsorted_vals.permute(*transpose_axes) # Reshape to match the global output shape expectation if split_key.ndim > 1 and not key_is_mask_like: original_local_shape = ( output_shape[:communication_split] + split_key.shape + output_shape[communication_split + split_key.ndim :] ) final_vals = final_vals.reshape(original_local_shape) indexed_arr = DNDarray( final_vals, gshape=output_shape, dtype=self.dtype, split=output_split, device=self.device, comm=self.comm, balanced=out_is_balanced, ) return self, indexed_arr def __prepare_unordered_comm(self, split_key_flat: torch.Tensor, displs: tuple) -> tuple: """ Helper function for distributed unordered indexing. Determines destination ranks, sorts the key, and computes Alltoallv parameters. """ displs_t = torch.tensor(displs, device=self.device.torch_device) # map global indices to destination ranks dest_ranks = torch.searchsorted(displs_t[1:], split_key_flat, right=True).to(torch.int64) # sort by destination rank to pack memory contiguously sort_idx = torch.argsort(dest_ranks) dest_ranks_sorted = dest_ranks[sort_idx] # calculate send_counts and send_displs send_counts = torch.bincount(dest_ranks_sorted, minlength=self.comm.size).to(torch.int64) send_displs = torch.zeros_like(send_counts) send_displs[1:] = torch.cumsum(send_counts, dim=0)[:-1] # collect and calculate recv_counts and recv_displs recv_counts = torch.empty_like(send_counts) self.comm.Alltoall(send_counts, recv_counts) recv_displs = torch.zeros_like(recv_counts) recv_displs[1:] = recv_counts.cumsum(0)[:-1] return ( sort_idx, send_counts, send_displs, recv_counts, recv_displs, )
[docs] def __getitem__(self, key: Indexer) -> DNDarray: """ Global getter function for DNDarrays. Returns a new DNDarray corresponding to the selection of values from the original DNDarray as specified by `key`. The `key` can be a variety of indexers, including integers, slices, lists, boolean masks, DNDarrays, ndarrays, torch tensors, and a combination thereof. The function determines the appropriate method to retrieve the requested data based on the type and structure of `key`, executing MPI communication if the indexing pattern requires data from multiple processes. Notes ----- The returned DNDarray will have its shape, split, and balanced status determined according to the indexing operation performed. For more details on supported indexing behaviors, see the :doc:`indexing documentation <INDEXING>`. Parameters ---------- key : array-like indexer Indices to get from the ``DNDarray``. Examples -------- >>> a = ht.arange(10, split=0) (1/2) >>> tensor([0, 1, 2, 3, 4], dtype=torch.int32) (2/2) >>> tensor([5, 6, 7, 8, 9], dtype=torch.int32) >>> a[1:6] (1/2) >>> tensor([1, 2, 3, 4], dtype=torch.int32) (2/2) >>> tensor([5], dtype=torch.int32) >>> a = ht.zeros((4, 5), split=0) (1/2) >>> tensor([[0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.]]) (2/2) >>> tensor([[0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.]]) >>> a[1:4, 1] (1/2) >>> tensor([0.]) (2/2) >>> tensor([0., 0.]) """ if key is None: return self.expand_dims(0) if ( key is ... or (isinstance(key, slice) and key == slice(None)) or (isinstance(key, tuple) and key == ()) ): return self # attempt early out for non-distributed arrays if not self.is_distributed(): try: res_tensor = self.larray[_unwrap_local_key(key, device=self.device.torch_device)] return DNDarray( res_tensor, gshape=tuple(res_tensor.shape), dtype=self.dtype, split=None, device=self.device, comm=self.comm, balanced=True, ) except Exception: pass # key processing returns a ProcessedKey namedtuple self, processed_key = _resolve_indexing_state( self, key, return_local_indices=True, op="get" ) # dispatch to appropriate getitem method op = processed_key.op_type if op == "scalar": return self.__getitem_scalar(processed_key) elif op == "distr_mask": return self.__getitem_mask(processed_key) elif op == "distributed": return self.__getitem_advanced_distributed(processed_key) elif op == "descending_slice": return self.__getitem_descending_slice_distributed(processed_key) elif op in ("local_mask", "local"): return self.__getitem_local(processed_key)
if torch.cuda.device_count() > 0: def gpu(self) -> DNDarray: """ Returns a copy of this object in GPU memory. If this object is already in GPU memory, then no copy is performed and the original object is returned. """ self.__array = self.__array.cuda(devices.gpu.torch_device) self.__device = devices.gpu return self
[docs] def __index__(self) -> int: """ Converts a zero-dimensional integer array to a Python ``int`` object. """ if not issubclass(self.dtype, integer): raise TypeError("only integer scalar arrays can be converted to a scalar index") return self.__cast(int)
[docs] def __int__(self) -> int: """ Integer scalar casting. """ return self.__cast(int)
[docs] def is_balanced(self, force_check: bool = False) -> bool: """ Determine if ``self`` is balanced evenly (or as evenly as possible) across all nodes distributed evenly (or as evenly as possible) across all processes. This is equivalent to returning ``self.balanced``. If no information is available (``self.balanced = None``), the balanced status will be assessed via collective communication. Parameters ---------- force_check : bool, optional If True, the balanced status of the ``DNDarray`` will be assessed via collective communication in any case. """ if not self.is_distributed(): self.__balanced = True return self.balanced if not force_check and self.balanced is not None: return self.balanced _, _, chk = self.comm.chunk(self.shape, self.split) test_lshape = tuple([x.stop - x.start for x in chk]) balanced = 1 if test_lshape == self.lshape else 0 out = self.comm.allreduce(balanced, MPI.SUM) balanced = True if out == self.comm.size else False return balanced
[docs] def is_distributed(self) -> bool: """ Determines whether the data of this ``DNDarray`` is distributed across multiple processes. """ return self.split is not None and self.comm.is_distributed()
[docs] def item(self): """ Returns the only element of a 1-element :class:`DNDarray`. Mirror of the pytorch command by the same name. If size of ``DNDarray`` is >1 element, then a ``ValueError`` is raised (by pytorch) Examples -------- >>> import heat as ht >>> x = ht.zeros((1)) >>> x.item() 0.0 """ if self.size > 1: raise ValueError("only one-element DNDarrays can be converted to Python scalars") # make sure the element is on every process self.resplit_(None) return self.__array.item()
[docs] def __len__(self) -> int: """ The length of the ``DNDarray``, i.e. the number of items in the first dimension. """ try: len = self.shape[0] return len except IndexError: raise TypeError("len() of unsized DNDarray")
[docs] def numpy(self) -> np.typing.NDArray[Any]: """ Returns a copy of the :class:`DNDarray` as numpy ndarray. If the ``DNDarray`` resides on the GPU, the underlying data will be copied to the CPU first. If the ``DNDarray`` is distributed, an MPI Allgather operation will be performed before converting to np.ndarray, i.e. each MPI process will end up holding a copy of the entire array in memory. Make sure process memory is sufficient! Examples -------- >>> import heat as ht T1 = ht.random.randn((10,8)) T1.numpy() """ dist = self.copy().resplit_(axis=None) return dist.larray.cpu().numpy()
[docs] def _repr_pretty_(self, p, cycle): """ Pretty print for IPython. """ if cycle: p.text(printing.__str__(self)) else: p.text(printing.__str__(self))
[docs] def __repr__(self) -> str: """ Returns a printable representation of the passed DNDarray, targeting developers. """ return printing.__repr__(self)
[docs] def ravel(self) -> DNDarray: """ Flattens the ``DNDarray``. See Also -------- :func:`~heat.core.manipulations.ravel` Examples -------- >>> a = ht.ones((2, 3), split=0) >>> b = a.ravel() >>> a[0, 0] = 4 >>> b DNDarray([4., 1., 1., 1., 1., 1.], dtype=ht.float32, device=cpu:0, split=0) """ return manipulations.ravel(self)
[docs] def redistribute_( self, lshape_map: torch.Tensor | None = None, target_map: torch.Tensor | None = None ) -> None: """ Redistributes the data of the :class:`DNDarray` *along the split axis* to match the given target map. This function does not modify the non-split dimensions of the ``DNDarray``. This is an abstraction and extension of the balance function. Parameters ---------- lshape_map : torch.Tensor, optional The current lshape of processes. Units are ``[rank, lshape]``. target_map : torch.Tensor, optional The desired distribution across the processes. Units are ``[rank, target lshape]``. Note: the only important parts of the target map are the values along the split axis, values which are not along this axis are there to mimic the shape of the ``lshape_map``. Examples -------- >>> st = ht.ones((50, 81, 67), split=2) >>> target_map = torch.zeros((st.comm.size, 3), dtype=torch.int64) >>> target_map[0, 2] = 67 >>> print(target_map) [0/2] tensor([[ 0, 0, 67], [0/2] [ 0, 0, 0], [0/2] [ 0, 0, 0]], dtype=torch.int32) [1/2] tensor([[ 0, 0, 67], [1/2] [ 0, 0, 0], [1/2] [ 0, 0, 0]], dtype=torch.int32) [2/2] tensor([[ 0, 0, 67], [2/2] [ 0, 0, 0], [2/2] [ 0, 0, 0]], dtype=torch.int32) >>> print(st.lshape) [0/2] (50, 81, 23) [1/2] (50, 81, 22) [2/2] (50, 81, 22) >>> st.redistribute_(target_map=target_map) >>> print(st.lshape) [0/2] (50, 81, 67) [1/2] (50, 81, 0) [2/2] (50, 81, 0) """ if not self.is_distributed(): return snd_dtype = self.dtype.torch_type() # units -> {pr, 1st index, 2nd index} if lshape_map is None: # NOTE: giving an lshape map which is incorrect will result in an incorrect distribution lshape_map = self.create_lshape_map(force_check=True) else: if not isinstance(lshape_map, torch.Tensor): raise TypeError(f"lshape_map must be a torch.Tensor, currently {type(lshape_map)}") if lshape_map.shape != (self.comm.size, len(self.gshape)): raise ValueError( f"lshape_map must have the shape ({self.comm.size}, {len(self.gshape)}), currently {lshape_map.shape}" ) if target_map is None: # if no target map is given then it will balance the tensor _, _, chk = self.comm.chunk(self.shape, self.split) target_map = lshape_map.clone() target_map[..., self.split] = 0 for pr in range(self.comm.size): target_map[pr, self.split] = self.comm.chunk(self.shape, self.split, rank=pr)[1][ self.split ] self.__balanced = True else: sanitation.sanitize_in_tensor(target_map) if target_map[..., self.split].sum() != self.shape[self.split]: raise ValueError( f"Sum along the split axis of the target map must be equal to the shape in that dimension, currently {target_map[..., self.split]}" ) if target_map.shape != (self.comm.size, len(self.gshape)): raise ValueError( f"target_map must have the shape {(self.comm.size, len(self.gshape))}, currently {target_map.shape}" ) # no info on balanced status self.__balanced = False lshape_cumsum = torch.cumsum(lshape_map[..., self.split], dim=0) chunk_cumsum = torch.cat( ( torch.tensor([0], device=self.device.torch_device), torch.cumsum(target_map[..., self.split], dim=0), ), dim=0, ) # need the data start as well for process 0 for rcv_pr in range(self.comm.size - 1): st = chunk_cumsum[rcv_pr].item() sp = chunk_cumsum[rcv_pr + 1].item() # start pr should be the next process with data if lshape_map[rcv_pr, self.split] >= target_map[rcv_pr, self.split]: # if there is more data on the process than the start process than start == stop st_pr = rcv_pr sp_pr = rcv_pr else: # if there is less data on the process than need to get the data from the next data # with data # need processes > rcv_pr with lshape > 0 st_pr = ( torch.nonzero(input=lshape_map[rcv_pr:, self.split] > 0, as_tuple=False)[ 0 ].item() + rcv_pr ) hld = ( torch.nonzero(input=sp <= lshape_cumsum[rcv_pr:], as_tuple=False).flatten() + rcv_pr ) sp_pr = hld[0].item() if hld.numel() > 0 else self.comm.size # st_pr and sp_pr are the processes on which the data sits at the beginning # need to loop from st_pr to sp_pr + 1 and send the pr for snd_pr in range(st_pr, sp_pr + 1): if snd_pr == self.comm.size: break data_required = abs(sp - st - lshape_map[rcv_pr, self.split].item()) send_amt = ( data_required if data_required <= lshape_map[snd_pr, self.split] else lshape_map[snd_pr, self.split] ) if (sp - st) <= lshape_map[rcv_pr, self.split].item() or snd_pr == rcv_pr: send_amt = 0 # send amount is the data still needed by recv if that is available on the snd if send_amt != 0: self.__redistribute_shuffle( snd_pr=snd_pr, send_amt=send_amt, rcv_pr=rcv_pr, snd_dtype=snd_dtype ) lshape_cumsum[snd_pr] -= send_amt lshape_cumsum[rcv_pr] += send_amt lshape_map[rcv_pr, self.split] += send_amt lshape_map[snd_pr, self.split] -= send_amt if lshape_map[rcv_pr, self.split] > target_map[rcv_pr, self.split]: # if there is any data left on the process then send it to the next one send_amt = lshape_map[rcv_pr, self.split] - target_map[rcv_pr, self.split] self.__redistribute_shuffle( snd_pr=rcv_pr, send_amt=send_amt.item(), rcv_pr=rcv_pr + 1, snd_dtype=snd_dtype ) lshape_cumsum[rcv_pr] -= send_amt lshape_cumsum[rcv_pr + 1] += send_amt lshape_map[rcv_pr, self.split] -= send_amt lshape_map[rcv_pr + 1, self.split] += send_amt if any(lshape_map[..., self.split] != target_map[..., self.split]): # sometimes need to call the redistribute once more, # (in the case that the second to last processes needs to get data from +1 and -1) self.redistribute_(lshape_map=lshape_map, target_map=target_map) self.__lshape_map = target_map self.__counts_displs = None
def __redistribute_shuffle( self, snd_pr: int | torch.Tensor, send_amt: int | torch.Tensor, rcv_pr: int | torch.Tensor, snd_dtype: torch.dtype, ): """ Function to abstract the function used during redistribute for shuffling data between processes along the split axis Parameters ---------- snd_pr : int or torch.Tensor Sending process send_amt : int or torch.Tensor Amount of data to be sent by the sending process rcv_pr : int or torch.Tensor Receiving process snd_dtype : torch.dtype Torch type of the data in question """ rank = self.comm.rank send_slice = [slice(None)] * self.ndim keep_slice = [slice(None)] * self.ndim if rank == snd_pr: if snd_pr < rcv_pr: # data passed to a higher rank (off the bottom) send_slice[self.split] = slice( self.lshape[self.split] - send_amt, self.lshape[self.split] ) keep_slice[self.split] = slice(0, self.lshape[self.split] - send_amt) if snd_pr > rcv_pr: # data passed to a lower rank (off the top) send_slice[self.split] = slice(0, send_amt) keep_slice[self.split] = slice(send_amt, self.lshape[self.split]) data = self.__array[tuple(send_slice)].clone() self.comm.Send(data, dest=rcv_pr, tag=685) self.__array = self.__array[tuple(keep_slice)] if rank == rcv_pr: shp = list(self.gshape) shp[self.split] = send_amt data = torch.zeros(shp, dtype=snd_dtype, device=self.device.torch_device) self.comm.Recv(data, source=snd_pr, tag=685) if snd_pr < rcv_pr: # data passed from a lower rank (append to top) self.__array = torch.cat((data, self.__array), dim=self.split) if snd_pr > rcv_pr: # data passed from a higher rank (append to bottom) self.__array = torch.cat((self.__array, data), dim=self.split)
[docs] def resplit_(self, axis: int = None): """ In-place option for resplitting a :class:`DNDarray`. Parameters ---------- axis : int The new split axis, ``None`` denotes gathering, an int will set the new split axis Examples -------- >>> a = ht.zeros( ... ( ... 4, ... 5, ... ), ... split=0, ... ) >>> a.lshape (0/2) (2, 5) (1/2) (2, 5) >>> ht.resplit_(a, None) >>> a.split None >>> a.lshape (0/2) (4, 5) (1/2) (4, 5) >>> a = ht.zeros( ... ( ... 4, ... 5, ... ), ... split=0, ... ) >>> a.lshape (0/2) (2, 5) (1/2) (2, 5) >>> ht.resplit_(a, 1) >>> a.split 1 >>> a.lshape (0/2) (4, 3) (1/2) (4, 2) """ # sanitize the axis to check whether it is in range axis = sanitize_axis(self.shape, axis) self.__partitions_dict__ = None # early out for unchanged content if self.comm.size == 1: self.__split = axis if axis == self.split: return self if axis is None: gathered = torch.empty( self.shape, dtype=self.dtype.torch_type(), device=self.device.torch_device ) counts, displs = self.counts_displs() self.comm.Allgatherv(self.__array, (gathered, counts, displs), recv_axis=self.split) self.__array = gathered self.__split = axis self.__lshape_map = None self.__counts_displs = None return self # tensor needs be split/sliced locally if self.split is None: _, _, slices = self.comm.chunk(self.shape, axis) temp = self.__array[slices] self.__array = torch.empty((1,), device=self.device.torch_device) # necessary to clear storage of local __array self.__array = temp.clone().detach() self.__split = axis self.__lshape_map = None self.__counts_displs = None return self arr_tiles = tiling.SplitTiles(self) new_tiles = tiling.SplitTiles(self) gshape = self.shape new_lshape = list(gshape) new_lshape[axis] = int(arr_tiles.tile_dimensions[axis][self.comm.rank].item()) recv_buffer = torch.empty( tuple(new_lshape), dtype=self.dtype.torch_type(), device=self.device.torch_device ) self._axis2axisResplit( self.larray, self.split, arr_tiles, recv_buffer, axis, new_tiles, self.comm ) self.__array = recv_buffer self.__split = axis self.__lshape_map = None self.__counts_displs = None return self
def __setitem_scalar(self, p: ProcessedKey, value: "DNDarray", value_is_scalar: bool) -> None: if p.root is not None: if self.comm.rank == p.root: self.__set(p.key, value) else: if not value_is_scalar: value = sanitation.sanitize_distribution(value, target=self[p.key]) self.__set(p.key, value) def __setitem_local(self, p: ProcessedKey, value: "DNDarray", value_is_scalar: bool) -> None: """ Handles process-local item assignment (slices and local indices) directly on local partitions. If `value` is distributed, MPI communication might be necessary to align it with the target slice before assignment. """ if value_is_scalar: self.__set(p.key, value) return value_is_distributed = isinstance(value, DNDarray) and value.is_distributed() if not self.is_distributed() and not value_is_distributed: self.__set(p.key, value) return if self.is_distributed(): if not value.is_distributed(): value = factories.array( value.larray, dtype=value.dtype, split=p.output_split, device=self.device, comm=self.comm, ) else: if value.split != p.output_split: raise RuntimeError( f"Cannot assign distributed `value` with split axis {value.split} " f"to indexed DNDarray with split axis {p.output_split}." ) target_shape = torch.tensor( tuple(self.larray[p.key].shape), device=self.device.torch_device ) target_map = torch.zeros( (self.comm.size, len(target_shape)), dtype=torch.int64, device=self.device.torch_device, ) self.comm.Allgather(target_shape, target_map) value.redistribute_(target_map=target_map) self.__set(p.key, value) def __setitem_descending_slice_distributed( self, p: ProcessedKey, value: "DNDarray", value_is_scalar: bool ) -> None: """ Handles assignment via negative-step slicing. Flips the `value` array and redistributes it to align with the descending split key before performing the local assignment. """ if value_is_scalar: self.__set(p.key, value) return flipped_value = manipulations.flip(value, axis=p.output_split) # determine local element count along split axis split_key = p.key[self.split] if isinstance(split_key, slice): step = 1 if split_key.step is None else split_key.step local_count = len(range(split_key.start, split_key.stop, step)) else: local_count = split_key.numel() # gather local slice counts across all ranks to build the target distribution map counts = torch.empty( (self.comm.size, 1), dtype=torch.int64, device=self.device.torch_device ) self.comm.Allgather( torch.tensor([local_count], dtype=torch.int64, device=self.device.torch_device), counts, ) if not flipped_value.is_distributed(): flipped_value = factories.array( flipped_value.larray, dtype=flipped_value.dtype, split=p.output_split, device=self.device, comm=self.comm, ) target_map = flipped_value.lshape_map target_map[:, p.output_split] = counts[:, 0] flipped_value.redistribute_(target_map=target_map) self.__set(p.key, flipped_value) def __setitem_mask(self, p: ProcessedKey, value: "DNDarray", value_is_scalar: bool) -> None: """ Handles assignment using boolean masks. If `value` is distributed, it will be redistributed to match the number of True elements in the local mask before assignment. If `value` is not distributed, it will be assigned directly to the masked positions on each process, with PyTorch handling any necessary broadcasting. """ pytorch_key = p.key if isinstance(pytorch_key, tuple): for k in pytorch_key: if isinstance(k, torch.Tensor) and k.dtype in (torch.bool, torch.uint8): local_mask = k break else: local_mask = pytorch_key if value_is_scalar: if isinstance(value, (int, float, bool, complex)): self.larray[pytorch_key] = value return elif hasattr(value, "larray"): scalar_torch = value.larray.type(self.dtype.torch_type()) else: scalar_torch = torch.as_tensor(value, device=self.device.torch_device).type( self.dtype.torch_type() ) self.larray[pytorch_key] = scalar_torch else: if isinstance(value, DNDarray) and value.is_distributed(): # value should align with local mask value_torch = value.larray rhs = ( value_torch if value_torch.dtype == self.larray.dtype else value_torch.type(self.dtype.torch_type()) ) try: self.larray[pytorch_key] = rhs except RuntimeError as e: raise ValueError( f"Shape mismatch: Cannot assign distributed array with local shape {value.lshape} " f"on rank {self.comm.rank}: {e}" ) from e else: # Value is a non-distributed array -> MPI prefix sum needed value_torch = value.larray # distinguish between exact-shape masks and 1D row-filtering masks is_row_mask = local_mask.ndim == 1 and self.ndim > 1 if not is_row_mask and value_torch.ndim == 1: # N-D mask on N-D array -> flattens into 1D sequence, requires MPI prefix sum local_mask_flat = local_mask.flatten() local_true = int(local_mask_flat.sum().item()) if self.comm.rank == 0: offset = 0 _ = self.comm.exscan(local_true) else: offset = self.comm.exscan(local_true) rhs_local = value_torch[offset : offset + local_true].type( self.dtype.torch_type() ) x_flat = self.larray.view(-1) x_flat[local_mask_flat] = rhs_local else: # PyTorch assigns and broadcasts natively self.larray[pytorch_key] = value_torch.type(self.dtype.torch_type()) def __setitem_advanced_distributed( self, p: ProcessedKey, original_key, value: "DNDarray", value_is_scalar: bool, original_split: int = None, ) -> None: """ Handles advanced indexing assignments where the indexing key is distributed. This method ensures that the value array is properly aligned and redistributed if necessary before performing the local assignment on each process. """ # check distribution status of the indexing key split_key_orig = ( original_key[self.split] if isinstance(original_key, tuple) else original_key ) key_is_distributed = ( isinstance(split_key_orig, DNDarray) and split_key_orig.is_distributed() ) value_is_distributed = isinstance(value, DNDarray) and value.is_distributed() # reject implicit cross-distribution assignments if key_is_distributed and not value_is_distributed and not value_is_scalar: raise ValueError( f"Distribution mismatch: index distributed={key_is_distributed}, value distributed={value_is_distributed}. " "Cannot assign a non-distributed value array using a distributed index. " "Please distribute the value array or use a non-distributed index." ) counts, displs = self.counts_displs() if value_is_distributed: self.__setitem_unordered( key=p.key, key_is_mask_like=p.key_is_mask_like, value=value, key_is_single_tensor=isinstance(p.key, torch.Tensor), counts=counts, displs=displs, rank=self.comm.rank, key_is_distributed=key_is_distributed, ) return rank = self.comm.rank key_is_single_tensor = isinstance(p.key, torch.Tensor) if ( value_is_scalar and isinstance(original_key, tuple) and len(original_key) == self.ndim and all( isinstance(k, DNDarray) and k.ndim == 1 and k.dtype in (types.int32, types.int64) for k in original_key ) ): global_indices = [] for k in original_key: k_full = k.copy() k_full.resplit_(None) global_indices.append(k_full.larray) idx_split_global = global_indices[self.split] local_offset = displs[rank] local_size = counts[rank] mask = (idx_split_global >= local_offset) & ( idx_split_global < local_offset + local_size ) if not mask.any(): return lhs_index = [] for dim, gind in enumerate(global_indices): sel = gind[mask] if dim == self.split: sel = sel - local_offset lhs_index.append(sel) lhs_index = tuple(lhs_index) if hasattr(value, "larray"): scalar_torch = value.larray else: scalar_torch = torch.as_tensor(value, device=self.device.torch_device) scalar_torch = scalar_torch.type(self.dtype.torch_type()) self.larray[lhs_index] = scalar_torch return if key_is_single_tensor: split_key = p.key split_key_flat = split_key.reshape(-1) local_indices = torch.nonzero( (split_key_flat >= displs[rank]) & (split_key_flat < displs[rank] + counts[rank]) ).flatten() if local_indices.numel() > 0: key_local = split_key_flat[local_indices] - displs[rank] if value_is_scalar: rhs = ( value.larray.type(self.dtype.torch_type()) if hasattr(value, "larray") else value ) else: # flatten leading dimensions of value.larray that correspond to the multi-dimensional key rhs_view = value.larray.reshape(-1, *value.larray.shape[split_key.ndim :]) rhs = rhs_view[local_indices].type(self.dtype.torch_type()) if self.larray.is_cuda: key_local, rhs = _resolve_duplicate_indices(key_local, rhs, self.larray.shape) self.larray[key_local] = rhs return if isinstance(original_key, tuple): raw_split_part = original_key[original_split] else: raw_split_part = original_key if isinstance(raw_split_part, DNDarray): split_key = raw_split_part.larray elif isinstance(raw_split_part, torch.Tensor): split_key = raw_split_part else: split_key = p.key[self.split] if isinstance(split_key, DNDarray): split_key = split_key.larray if split_key.dtype == torch.bool: split_key = torch.nonzero(split_key, as_tuple=False).flatten() local_offset = displs[rank] local_size = counts[rank] if hasattr(value, "larray"): value_torch = value.larray else: value_torch = torch.as_tensor(value, device=self.device.torch_device) feature_dims = self.larray.ndim - (self.split + 1) if value_is_scalar: value_key_start_dim = 0 else: value_key_start_dim = value_torch.ndim - split_key.ndim - feature_dims if value_key_start_dim < 0: raise RuntimeError("value_key_start_dim < 0 – inconsistent shapes") local_split_axis = self.split base_index = [slice(None)] * self.larray.ndim if isinstance(original_key, tuple): for dim, k_part in enumerate(original_key): if dim == self.split: continue if isinstance(k_part, DNDarray): base_index[dim] = k_part.larray else: base_index[dim] = k_part self.__advanced_setitem_unordered_local( x_local=self.larray, split_key=split_key, value_torch=value_torch, split_axis=local_split_axis, value_key_start_dim=value_key_start_dim, local_offset=local_offset, local_size=local_size, value_is_scalar=value_is_scalar, out_dtype=self.dtype.torch_type(), base_index=tuple(base_index), ) def __setitem_unordered( self, key: tuple | list | torch.Tensor, key_is_mask_like: bool, value: "DNDarray", key_is_single_tensor: bool, counts: tuple, displs: tuple, rank: int, key_is_distributed: bool = False, ) -> DNDarray: """ Handles the MPI communication when assigning a distributed value to a distributed array with unordered global indices. """ # distribution of `key` and `value` must be aligned if key_is_mask_like: if key_is_distributed: split_key = key[self.split] global_split_key = factories.array( split_key, is_split=0, device=self.device, comm=self.comm, copy=False ) target_map = value.lshape_map target_map[:, value.split] = global_split_key.lshape_map[:, 0] value.redistribute_(target_map=target_map) else: # Key is replicated: slice locally to match value partition directly v_counts, v_displs = value.counts_displs() start = v_displs[rank] end = start + v_counts[rank] key = tuple(k[start:end] if isinstance(k, torch.Tensor) else k for k in key) split_key = key[self.split] else: if key_is_distributed: # redistribute split-axis `key` to match distribution of `value` in one pass if key_is_single_tensor: split_key = key else: split_key = key[self.split] global_split_key = factories.array( split_key, is_split=0, device=self.device, comm=self.comm, copy=False ) target_map = global_split_key.lshape_map target_map[:, 0] = value.lshape_map[:, value.split] global_split_key.redistribute_(target_map=target_map) split_key = global_split_key.larray else: # Key is replicated: slice locally to match value partition directly v_counts, v_displs = value.counts_displs() start = v_displs[rank] end = start + v_counts[rank] if key_is_single_tensor: key = key[start:end] split_key = key else: key_list = list(key) key_list[self.split] = key_list[self.split][start:end] key = tuple(key_list) split_key = key[self.split] # key and value are now aligned # prepare for `value` Alltoallv: # work along axis 0, transpose if necessary transpose_axes = list(range(value.ndim)) transpose_axes[0], transpose_axes[value.split] = ( transpose_axes[value.split], transpose_axes[0], ) value = value.transpose(transpose_axes) split_key_flat = split_key.reshape(-1) sort_idx, send_counts, send_displs, recv_counts, recv_displs = ( self.__prepare_unordered_comm(split_key_flat, displs) ) send_counts_l = send_counts.tolist() send_displs_l = send_displs.tolist() recv_counts_l = recv_counts.tolist() recv_displs_l = recv_displs.tolist() # exchange indices if key_is_mask_like: mask_dims = len(key) idx_send_counts = [c * mask_dims for c in send_counts_l] idx_send_displs = [d * mask_dims for d in send_displs_l] idx_recv_counts = [c * mask_dims for c in recv_counts_l] idx_recv_displs = [d * mask_dims for d in recv_displs_l] send_idx = torch.stack([k.flatten()[sort_idx] for k in key], dim=1).reshape(-1) recv_idx_flat = torch.empty( sum(idx_recv_counts), dtype=split_key.dtype, device=self.device.torch_device ) else: idx_send_counts, idx_send_displs = send_counts_l, send_displs_l idx_recv_counts, idx_recv_displs = recv_counts_l, recv_displs_l send_idx = split_key_flat[sort_idx] recv_idx_flat = torch.empty( sum(idx_recv_counts), dtype=split_key.dtype, device=self.device.torch_device ) self.comm.Alltoallv( (send_idx, idx_send_counts, idx_send_displs), (recv_idx_flat, idx_recv_counts, idx_recv_displs), ) # exchange value trailing_dims_shape = list(value.lshape[1:]) trailing_dims_size = math.prod(trailing_dims_shape) val_send_counts = [c * trailing_dims_size for c in send_counts_l] val_send_displs = [d * trailing_dims_size for d in send_displs_l] val_recv_counts = [c * trailing_dims_size for c in recv_counts_l] val_recv_displs = [d * trailing_dims_size for d in recv_displs_l] send_vals = value.larray[sort_idx].contiguous().reshape(-1) recv_vals_flat = torch.empty( sum(val_recv_counts), dtype=value.larray.dtype, device=self.device.torch_device ) self.comm.Alltoallv( (send_vals, val_send_counts, val_send_displs), (recv_vals_flat, val_recv_counts, val_recv_displs), ) if key_is_mask_like: recv_indices = recv_idx_flat.reshape(sum(recv_counts_l), len(key)) recv_indices[:, 0] -= displs[rank] key = tuple(recv_indices[:, i] for i in range(len(key))) else: # store incoming indices in int 1-D tensor and correct for rank offset recv_indices = recv_idx_flat - displs[rank] # replace split-axis key with incoming local indices if key_is_single_tensor: key = recv_indices else: key = list(key) key[self.split] = recv_indices key = tuple(key) recv_vals = recv_vals_flat.reshape(-1, *trailing_dims_shape) recv_buf = DNDarray( recv_vals.permute(*transpose_axes), gshape=value.gshape, dtype=value.dtype, split=value.split, device=value.device, comm=value.comm, balanced=value.balanced, ) # set local elements of `self` to corresponding elements of `value` self.__set(key, recv_buf) return self
[docs] def __setitem__( self, key: Indexer, value: float | "DNDarray" | torch.Tensor, ): """ Global item setter for DNDarrays. Assigns values to the specified positions in the ``DNDarray``. The `key` can be a variety of indexers, including integers, slices, lists, boolean masks, DNDarrays, ndarrays, torch tensors, or a combination thereof. If a distributed ``DNDarray`` is given as the `value` to be set, this function will automatically attempt to align its distribution scheme (split axis and local shapes) with the target indexed array via MPI communication. If the distributions cannot be safely aligned, a ``ValueError`` or ``RuntimeError`` is raised. Parameters ---------- key : array-like indexer Index/indices to be set value: float | "DNDarray" | torch.Tensor Value to be set to the specified positions in the DNDarray (self) Notes ----- For more details on supported indexing behaviors, see the :doc:`indexing documentation <INDEXING>`. Examples -------- >>> a = ht.zeros((4, 5), split=0) (1/2) >>> tensor([[0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.]]) (2/2) >>> tensor([[0., 0., 0., 0., 0.], [0., 0., 0., 0., 0.]]) >>> a[1:4, 1] = 1 >>> a (1/2) >>> tensor([[0., 0., 0., 0., 0.], [0., 1., 0., 0., 0.]]) (2/2) >>> tensor([[0., 1., 0., 0., 0.], [0., 1., 0., 0., 0.]]) """ if not self.is_distributed() and not ( isinstance(value, DNDarray) and value.is_distributed() ): try: torch_key = _unwrap_local_key(key, device=self.device.torch_device) if isinstance(value, DNDarray): rhs = value.larray.to(self.larray.dtype) elif isinstance(value, torch.Tensor): rhs = value.to(self.larray.dtype) else: rhs = value # Python scalar / float / int if self.larray.is_cuda and torch.is_tensor(rhs): torch_key, rhs = _resolve_duplicate_indices(torch_key, rhs, self.larray.shape) self.larray[torch_key] = rhs return except Exception: pass # bypass factories.array() for primitive types and single-element tensors to avoid unnecessary overhead value_is_primitive = isinstance(value, (int, float, complex, bool)) or ( isinstance(value, torch.Tensor) and value.numel() == 1 and value.ndim == 0 ) if not value_is_primitive and not isinstance(value, DNDarray): value = factories.array(value) original_key = key original_split = self.split self, processed_key = _resolve_indexing_state( self, key, return_local_indices=True, op="set" ) op = processed_key.op_type # match dimensions (except for distr_mask as it perfectly aligns) if op == "distr_mask": value_is_scalar = ( np.isscalar(value) or getattr(value, "ndim", 1) == 0 or (getattr(value, "shape", None) == (1,) and getattr(value, "split", 0) is None) ) else: value, value_is_scalar = self.__broadcast_value( key, value, output_shape=processed_key.output_shape ) # dispatch to the appropriate setter if op == "distr_mask": self.__setitem_mask(processed_key, value, value_is_scalar) elif op == "scalar": self.__setitem_scalar(processed_key, value, value_is_scalar) elif op == "distributed": self.__setitem_advanced_distributed( processed_key, original_key, value, value_is_scalar, original_split=original_split ) elif op == "descending_slice": self.__setitem_descending_slice_distributed(processed_key, value, value_is_scalar) elif op in ("local_mask", "local"): self.__setitem_local(processed_key, value, value_is_scalar)
[docs] def __str__(self) -> str: """ Computes a string representation of the passed ``DNDarray``. """ return printing.__str__(self)
[docs] def to_device(self, device: Device, /, *, stream: int | Any | None = None) -> DNDarray: """ Copy the array from the device on which it currently resides to the specified ``device``. Parameters ---------- device : Device A ``Device`` object. stream : Int or Any, optional Stream object to use during copy. """ if stream is not None: raise ValueError("The stream argument to to_device() is not supported") if device.device_type == "cpu": return self.cpu() elif device.device_type == "gpu": return self.gpu() raise ValueError(f"Unsupported device {device!r}")
[docs] def tolist(self, keepsplit: bool = False) -> list[int | float]: """ Return a copy of the local array data as a (nested) Python list. For scalars, a standard Python number is returned. Parameters ---------- keepsplit: bool Whether the list should be returned locally or globally. Examples -------- >>> a = ht.array([[0, 1], [2, 3]]) >>> a.tolist() [[0, 1], [2, 3]] >>> a = ht.array([[0, 1], [2, 3]], split=0) >>> a.tolist() [[0, 1], [2, 3]] >>> a = ht.array([[0, 1], [2, 3]], split=1) >>> a.tolist(keepsplit=True) (1/2) [[0], [2]] (2/2) [[1], [3]] """ if not keepsplit: return self.resplit(axis=None).__array.tolist() return self.__array.tolist()
[docs] @classmethod def __torch_function__(cls, func, types, args=(), kwargs=None): """ Supports PyTorch's dispatch mechanism. """ import heat if kwargs is None: kwargs = {} try: ht_func = getattr(heat, func.__name__) except AttributeError: return NotImplemented return ht_func(*args, **kwargs)
[docs] def __torch_proxy__(self) -> torch.Tensor: """ Return a 1-element `torch.Tensor` strided as the global `self` shape. Used internally for sanitation purposes. """ return torch.empty(self.gshape, device="meta")
# Heat imports at the end to break cyclic dependencies from . import complex_math from . import devices from . import factories from . import indexing from . import linalg from . import manipulations from . import printing from . import rounding from . import sanitation from . import statistics from . import stride_tricks from . import tiling from . import types from .devices import Device from .stride_tricks import sanitize_axis from .types import datatype, integer, canonical_heat_type from .types import bool as ht_bool, uint8 as ht_uint8