Feature: NumPy-compliant distributed advanced indexing - #938
Feature: NumPy-compliant distributed advanced indexing#938ClaudiaComito wants to merge 325 commits into
Conversation
|
just a comment: in the fft-module (if already merged at time merging this PR) some commented-out parts of |
Co-authored-by: Claudia Comito <39374113+ClaudiaComito@users.noreply.github.com> Co-authored-by: Michael Tarnawa <18899420+mtar@users.noreply.github.com>
* nonzero, where changes from #938 * Fixed tests * Small refactoring * Disabling fail-fast * Adapt `eigh` to new `where` API * Update documentation a bit * Streamline `where` and add tests comparing to `numpy.where` * Fix tests * Tiny refactor * Address @mtar's comments * - Added vectorized sorting fucntionality. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * - Added check for zero and one dimensional arrays. - Fallback onto `sort` for one dimensional arrays. * - Add resplit to one dimensional result. * Replace `unique` with `vectorized_sort` in `nonzero` * nonzero, where changes from #938 * Fixed tests * Small refactoring * Adapt `eigh` to new `where` API * Update documentation a bit * Streamline `where` and add tests comparing to `numpy.where` * Fix tests * Tiny refactor * Address @mtar's comments * Replace `unique` with `vectorized_sort` in `nonzero` --------- Co-authored-by: Thomas Baumann <39156931+brownbaerchen@users.noreply.github.com> Co-authored-by: Berkant Palazoglu <berkantpalazoglu03@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
|
||
| @property | ||
| def shape(self) -> tuple[int, ...]: | ||
| def shape(self) -> tuple[int]: |
There was a problem hiding this comment.
Why was the ellipsis removed? This looks incorrect to me.
| 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 "advanced" |
There was a problem hiding this comment.
This never returns "slice", so I think I think those branches of getitem and setitem are never reached.
| root = None | ||
| backwards_transpose_axes = tuple(range(arr.ndim)) | ||
|
|
||
| if isinstance(key, (DNDarray, torch.Tensor)): |
There was a problem hiding this comment.
I am not sure how relevant this is, but I believe this should also check for boolean arrays wrapped in a tuple. In numpy, a[(mask,)] is the same as a[mask]. It is weird non-documented behaviour in numpy, as far as I can tell.
Here is an example that reproduces the error in any number of ranks, as far as I can tell.
import numpy as np
import heat as ht
from mpi4py import MPI
# Numpy
a_np = np.arange(60, dtype=np.float32).reshape(5, 4, 3)
mask_np = a_np > 30
if MPI.COMM_WORLD.Get_rank() == 0:
print("NUMPY")
print(a_np[mask_np])
print(a_np[(mask_np,)])
# Heat
print("HEAT")
a = ht.array(a_np, split=0)
print(a)
mask = a > 30
print(mask)
print(a[mask])
print(a[(mask,)])| class LocalIndex: | ||
| """ | ||
| Indexing class for local operations (primarily for :func:`lloc` function) | ||
| For docs on ``__getitem__`` and ``__setitem__`` see :func:`lloc` | ||
| """ | ||
|
|
||
| def __init__(self, obj): | ||
| self.obj = obj | ||
|
|
||
| def __getitem__(self, key): | ||
| return self.obj[key] | ||
|
|
||
| def __setitem__(self, key, value): | ||
| self.obj[key] = value |
There was a problem hiding this comment.
| class LocalIndex: | |
| """ | |
| Indexing class for local operations (primarily for :func:`lloc` function) | |
| For docs on ``__getitem__`` and ``__setitem__`` see :func:`lloc` | |
| """ | |
| def __init__(self, obj): | |
| self.obj = obj | |
| def __getitem__(self, key): | |
| return self.obj[key] | |
| def __setitem__(self, key, value): | |
| self.obj[key] = value |
this was removed in #2494

Description
TL;DR
This PR replaces Heat's legacy, local-only indexing with 100% NumPy-API-compliant advanced indexing capabilities across distributed nodes.
You can now seamlessly use boolean masks, integer arrays, negative-step slices etc. on distributed arrays without manual data shuffling:
This pull request introduces a significant overhaul of distributed indexing within
dndarray.py, specifically targeting the__getitem__and__setitem__methods.The logic has been completely refactored to identify zero-communication paths ("early out") for standard slices, while routing heavy, unordered (non-sequential) advanced indexing through highly optimized MPI collective communication.
Also,
indexing.nonzero(), the kwargas_tuplehas been introduced (default:True) to comply with the Numpy API while giving users the choice to switch to torch-style output (2-D array).Main changes (LAST UPDATE 13.6.2026)
dndarray.py_resolve_indexing_statehelper. This function torch-proofs allkeyinputs, handles broadcasting, aligns array dimension to indexed shape, and determines the indexing operation type for later dispatching. Returns a structuredProcessedKeyNamedTuple.__getitem__and__setitem__functions. They are now wrappers that call the resolution state and dispatch to dedicated methods (e.g.,__getitem_scalar,__setitem_mask,__getitem_advanced_local).MPI.Alltoallvfor cross-rank data fetching and assignment (__getitem_unorderedand__setitem_unordered)._resolve_duplicate_indicesto guarantee NumPy-compliant "last assignment wins" semantics when using advanced indexing with duplicate indices on GPUs (thanks @Hakdag97 ).__getitem_descending_slice_distributedand its setter counterpart).__broadcast_valuehelper to automatically broadcast assigned values to perfectly match the target slice or boolean mask shape during__setitem__operations.__torch_proxy__to explicitly track thesplitaxis natively within the tensor's named dimensions for safer split axis tracking during dimensions-changing operations.doc/source/and added it to the .rst indexindexing.pynonzeroto return a tuple of 1DDNDarrays by default (one array per dimension) instead of a single 2D coordinate matrix (Numpy API compliance).as_tupleargument tononzeroto allow toggling between the new NumPy-style tuple output (True) and the legacy Torch-style 2D array output (False).where(cond)to rely on nonzero and return consistent output (tuple of 1-D arrays) independently of split axisThe following table is from
doc/source/INDEXING.mdarray[key]array[key]splitaxis and balanced status directly from the distributed key.array[key]Yes for slices/masks. Non-sequential local advanced indices are automatically distributed across the split axis under the hood.
array[key]distr_maskfast-path or triggers__getitem_unorderedfor cross-node MPI collective fetching.array[key] = valarray[key] = valarray[key] = valvalue's split axis doesn't match the target's split axis, aRuntimeErroris raised. If they do match,valueis dynamically load-balanced (redistribute_) to match the target's chunk sizes before assignment.array[key] = valarray[key] = valarray[key] = valvalueis redistributed to matchkey. For integer arrays,keyis redistributed to matchvalue. Both are followed by anAlltoallvshuffle.Note: Extracting a single element along the split axis will collapse that dimension, resulting in
split=None.Internal getitem/setitem routing logic
LAST UPDATE 13.6.2026
graph TD Start((Receive Key)) --> CheckScalar{Is key a pure scalar<br/>and not boolean?} CheckScalar -- Yes --> EvalRoot{Compute root} EvalRoot --> OpScalar[op_type = 'scalar'] CheckScalar -- No --> CheckFastPath{Matches distr_mask<br/>fast path?} CheckFastPath -- Yes & not tuple --> OpDistrMask1[op_type = 'distr_mask'] CheckFastPath -- No / Tuple --> Normalize[Normalize keys, extract bounds,<br/>check dimensionality & broadcast] Normalize --> FinalRouting{Evaluate Key State} FinalRouting -->|root is not None| OpScalar2[op_type = 'scalar'] FinalRouting -->|split_key_is_ordered == 0| OpDist[op_type = 'distributed'<br/>Unordered MPI Communication] FinalRouting -->|split_key_is_ordered == -1| OpDesc[op_type = 'descending_slice'] FinalRouting -->|key_is_mask_like == True| MaskTypeCheck{distr_mask_fast_path?} MaskTypeCheck -- Yes --> OpDistrMask2[op_type = 'distr_mask'] MaskTypeCheck -- No --> OpLocalMask[op_type = 'local_mask'] FinalRouting -->|Default / Pure Slices / Ordered| OpAdv[op_type = 'advanced'<br/>Local Fast Path] %% Map to actual handlers subgraph Handlers [Target Routing Methods] OpScalar & OpScalar2 --> H_Scalar[__getitem_scalar<br/>__setitem_scalar] OpDist --> H_Dist[__getitem_advanced_distributed<br/>__setitem_advanced_distributed] OpDesc --> H_Desc[__getitem_descending_slice_distributed<br/>__setitem_descending_slice_distributed] OpDistrMask1 & OpDistrMask2 --> H_DistMask[__getitem_mask<br/>__setitem_mask] OpLocalMask --> H_LocalMask[__getitem_advanced_local<br/>__setitem_advanced_local] OpAdv --> H_Adv[__getitem_advanced_local<br/>__setitem_advanced_local] end %% Styling classDef target fill:#d4edda,stroke:#28a745,stroke-width:2px; class H_Scalar,H_Dist,H_Desc,H_DistMask,H_LocalMask,H_Adv target;Memory footprint
Scaling behaviour
Issue/s resolved: #703 #914 #918 #1012 #1019 #2135 #1816 #824
Type of change
nonzero()is now Numpy-compliant by default and returns a tuple of 1-D arrays)Memory requirements
Performance
Due Diligence
Does this change modify the behaviour of other functions? If so, which?
yes, everything that relied on the legacy indexing quirks (fixed) and everything that relied on 2D output from
nonzero()(also fixed)