Feature: NumPy-compliant distributed advanced indexing#938
Feature: NumPy-compliant distributed advanced indexing#938ClaudiaComito wants to merge 300 commits into
Conversation
…oltz-analytics/heat into 914_adv-indexing-outshape-outsplit
brownbaerchen
left a comment
There was a problem hiding this comment.
I didn't manage to read everything in this review pass. I think there is still plenty to do, mainly in terms of casting to DNDarray in the beginning in order to avoid excessive checks for various cases, and isolating some cases into their own functions.
| try: | ||
| # is key an ndarray or DNDarray or torch.Tensor? | ||
| key = key.item() | ||
| except AttributeError: | ||
| # key is already an integer, do nothing | ||
| pass |
There was a problem hiding this comment.
| try: | |
| # is key an ndarray or DNDarray or torch.Tensor? | |
| key = key.item() | |
| except AttributeError: | |
| # key is already an integer, do nothing | |
| pass | |
| # cast key to scalar if it is an array | |
| try: | |
| key = key.item() | |
| except AttributeError: | |
| pass |
| # key is already an integer, do nothing | ||
| pass | ||
| if not arr.is_distributed(): | ||
| root = None |
There was a problem hiding this comment.
This is not consistent with the type hint that root is integer
| if arr.comm.rank == root: | ||
| key -= displs[root] | ||
| else: | ||
| root = None |
There was a problem hiding this comment.
Again, not consistent with type hints.
| return_local_indices: bool | None = False, | ||
| ) -> tuple[int, int]: | ||
| """ | ||
| Private helper function to process a single-item scalar key used for indexing a ``DNDarray``. |
There was a problem hiding this comment.
Please give some information about what the return values of this function are.
| 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. |
There was a problem hiding this comment.
Can you provide some examples of what this is doing? It's not clear to me from the title and description alone what this function does and why.
| def is_0d_bool(k): | ||
| if isinstance(k, bool): | ||
| return True | ||
| if hasattr(k, "dtype") and k.dtype in ( | ||
| ht_bool, | ||
| ht_uint8, | ||
| torch.bool, | ||
| torch.uint8, | ||
| np.bool_, | ||
| np.uint8, | ||
| ): | ||
| if getattr(k, "ndim", 1) == 0: | ||
| return True | ||
| return False |
There was a problem hiding this comment.
This could be much simpler if we knew what kind of array k is if k is array like
| if ellipsis > 1: | ||
| raise ValueError("indexing key can only contain 1 Ellipsis (...)") | ||
| if ellipsis: | ||
| # key contains exactly 1 ellipsis |
There was a problem hiding this comment.
| if ellipsis > 1: | |
| raise ValueError("indexing key can only contain 1 Ellipsis (...)") | |
| if ellipsis: | |
| # key contains exactly 1 ellipsis | |
| if ellipsis > 1: | |
| raise ValueError("indexing key can only contain 1 Ellipsis (...)") | |
| elif ellipsis == 1: |
| if k is None: | ||
| key[i] = slice(None) | ||
| else: | ||
| val = bool(k.item() if hasattr(k, "item") else k) |
There was a problem hiding this comment.
This can be simplified if we knew what type k is by previous casting.
| expand_key[:ellipsis_index] = key[:ellipsis_index] | ||
| expand_key[ellipsis_index + ellipsis_dims :] = key[ellipsis_index + 1 :] | ||
| key = expand_key | ||
| while add_dims > 0: |
There was a problem hiding this comment.
Is this while loop needed? Would it be possible to expand all dims at once and setup all slices at once?
| # check for advanced indexing and slices | ||
| advanced_indexing_dims = [] | ||
| advanced_indexing_shapes = [] | ||
| lose_dims = 0 |
JuanPedroGHM
left a comment
There was a problem hiding this comment.
There are a lot of comments from @brownbaerchen that it is unclear if they have been addressed. Other than that, I would say this is ready to go.

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)