Skip to content

Commit cf7c3f3

Browse files
authored
Merge pull request #2955 from devitocodes/mpi-redistribution-engine
MPI: revamp mpi indexing for full arbitrary indexing support
2 parents 7eefe42 + c194631 commit cf7c3f3

16 files changed

Lines changed: 2756 additions & 675 deletions

File tree

.github/workflows/examples-mpi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
ipcluster start --profile=mpi --engines=mpi -n 4 --daemonize
7272
# A few seconds to ensure workers are ready
7373
sleep 10
74-
pytest -v --nbval examples/mpi
74+
pytest -vvv --nbval examples/mpi
7575
ipcluster stop --profile=mpi
7676
7777
- name: Test seismic examples

devito/data/data.py

Lines changed: 109 additions & 192 deletions
Large diffs are not rendered by default.

devito/data/decomposition.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,14 @@ def index_glb_to_loc(self, *args, rel=True):
296296
loc_min = loc_abs_min - base \
297297
+ np.mod(glb_idx.step - np.mod(base, glb_idx.step),
298298
glb_idx.step)
299-
else: # glb start is given explicitly
299+
elif glb_idx_min >= loc_abs_min:
300+
# The slice starts within this subdomain, so its first
301+
# selected index is the start itself (the stride phase
302+
# begins here, not at loc_abs_min).
303+
loc_min = glb_idx_min - base
304+
else: # the slice started in an earlier subdomain
305+
# Advance to the first in-stride index at or after this
306+
# subdomain's start.
300307
loc_min = loc_abs_min - base \
301308
+ np.mod(glb_idx.step - np.mod(base - glb_idx.start,
302309
glb_idx.step), glb_idx.step)
@@ -319,7 +326,14 @@ def index_glb_to_loc(self, *args, rel=True):
319326
loc_max = top - base \
320327
+ np.mod(glb_idx.step - np.mod(top - glb_max,
321328
glb_idx.step), glb_idx.step)
322-
else:
329+
elif glb_idx_max <= loc_abs_max:
330+
# The slice's high end (its start) is within this
331+
# subdomain, so the first selected index is the start
332+
# itself (the stride phase begins here, not at loc_abs_max).
333+
loc_max = glb_idx_max - base
334+
else: # the slice's high end is in a later subdomain
335+
# Step back to the first in-stride index at or below this
336+
# subdomain's end.
323337
loc_max = top - base \
324338
+ np.mod(glb_idx.step - np.mod(top - glb_idx.start,
325339
glb_idx.step), glb_idx.step)
@@ -555,3 +569,50 @@ def reshape(self, *args):
555569
items = [i + nleft for i in items]
556570

557571
return Decomposition(items, self.local)
572+
573+
def index_decomposition(self, idx):
574+
"""
575+
The decomposition induced by NumPy-indexing this axis with a slice.
576+
577+
Each subdomain keeps the result positions, in index order, of the global
578+
indices it owns. A strided or reversed slice therefore just relabels
579+
which rank owns which result index -- indexing never moves data across
580+
ranks. Unlike `reshape` (which adjusts subdomain *boundaries* and
581+
is used for halos), this follows exact NumPy slicing semantics.
582+
583+
Examples
584+
--------
585+
>>> d = Decomposition([[0, 1, 2, 3], [4, 5, 6, 7]], 0)
586+
>>> d.index_decomposition(slice(None, None, -1))
587+
Decomposition(<<[4,7]>>, [0,3])
588+
>>> d.index_decomposition(slice(None, None, -3))
589+
Decomposition(<<[2,2]>>, [0,1])
590+
"""
591+
if self.glb_max is None:
592+
return Decomposition([np.array([], dtype=np.int64)]*len(self),
593+
self.local)
594+
size = self.glb_max - self.glb_min + 1
595+
596+
# The global indices selected by `idx`, in result order. For a slice
597+
# (the only caller) this is built directly, without materialising the
598+
# whole axis.
599+
if isinstance(idx, slice):
600+
start, stop, step = idx.indices(size)
601+
selected = self.glb_min + np.arange(start, stop, step)
602+
else:
603+
selected = np.arange(self.glb_min, self.glb_min + size)[idx]
604+
605+
# Bucket the selected indices by owning subdomain. A subdomain is a
606+
# contiguous range, so membership is a bounds check in O(len(selected))
607+
# -- avoiding an O(axis) `isin` on every slice. A non-contiguous
608+
# subdomain (not produced by gridding/slicing) falls back to `isin`.
609+
items = []
610+
for sd in self:
611+
if sd.size == 0:
612+
items.append(np.array([], dtype=np.int64))
613+
elif sd[-1] - sd[0] + 1 == sd.size:
614+
owned = (selected >= sd[0]) & (selected <= sd[-1])
615+
items.append(np.nonzero(owned)[0])
616+
else:
617+
items.append(np.nonzero(np.isin(selected, sd))[0])
618+
return Decomposition(items, self.local)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Indexing engine for MPI-distributed arrays.
3+
4+
Indexing an MPI-distributed array is treated as a redistribution between layouts.
5+
The engine is built as four pure layers plus a value object:
6+
7+
* `selection` -- what an index expression means (serial NumPy semantics).
8+
* `layout` -- where a global coordinate physically lives.
9+
* `plan` -- the rank-to-rank routing, built without communication.
10+
* `transport` -- the sparse point-to-point exchange (NBX).
11+
* `exchange` -- `Exchange(data, idx).get()/.put(value)`.
12+
13+
Only `Exchange` is needed by `Data`; the lower layers are independently
14+
testable (in serial, or with toy buffers).
15+
"""
16+
17+
from devito.data.distributed.exchange import Exchange, cached_exchange # noqa
18+
from devito.data.distributed.layout import Layout # noqa
19+
from devito.data.distributed.plan import ExchangePlan # noqa
20+
from devito.data.distributed.redistribution import redistribute_set # noqa
21+
from devito.data.distributed.selection import Selection # noqa
22+
from devito.data.distributed.transport import sparse_exchange # noqa
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Exchange: the value object tying a Selection and a Layout into a reusable plan.
3+
4+
`Exchange(data, idx)` builds the routing for one index expression without any
5+
communication. `get` pulls `data[idx]` and `put` assigns `data[idx] =
6+
value`. Because the plan is communication-free to build and independent of the
7+
data *values*, it can be cached and replayed across calls with the same index
8+
(e.g. sparse injection every timestep), so the steady state is pure
9+
point-to-point with no re-planning. `cached_exchange` provides that cache;
10+
`Data` uses it so `data[idx] = value` is automatically plan-cached.
11+
"""
12+
13+
from functools import lru_cache
14+
15+
import numpy as np
16+
17+
from devito.data.distributed.layout import Layout
18+
from devito.data.distributed.plan import ExchangePlan
19+
from devito.data.distributed.selection import Selection
20+
from devito.tools import as_tuple
21+
22+
__all__ = ['Exchange', 'cached_exchange']
23+
24+
25+
class Exchange:
26+
27+
"""
28+
A reusable redistribution plan for `data[idx]` on distributed `data`.
29+
30+
Parameters
31+
----------
32+
data : Data
33+
The MPI-distributed array being indexed.
34+
idx : index expression
35+
Any NumPy index (scalars, slices, integer arrays, boolean masks).
36+
"""
37+
38+
def __init__(self, data, idx):
39+
# Keep the data reference (not a snapshot): the underlying buffer is
40+
# stable across `f.data` views, so a cached plan always reads/writes the
41+
# current values.
42+
self._data = data
43+
global_shape = tuple(
44+
dec.size if dec is not None else size
45+
for dec, size in zip(data._decomposition, data.shape, strict=True)
46+
)
47+
self._layout = Layout(data._distributor, data._decomposition, global_shape)
48+
self._selection = Selection.from_index(idx, global_shape)
49+
self._plan = ExchangePlan.build(self._selection, self._layout)
50+
51+
def get(self):
52+
"""Return `data[idx]` as a NumPy array."""
53+
return self._plan.get(np.asarray(self._data))
54+
55+
def put(self, value):
56+
"""Assign `data[idx] = value`."""
57+
self._plan.put(np.asarray(self._data), value)
58+
59+
60+
class _ExchangeKey:
61+
62+
"""
63+
Hashable, content-addressed key wrapping `(data, idx)` for plan caching.
64+
65+
Generates a signature from the `data` identity and the `idx` content, so that
66+
the same plan is reused across calls with the same index expression, even if
67+
the `data` object is a different view of the same underlying buffer.
68+
The signature does not include the `data` values,
69+
but only the `data` metadata (shape, decomposition, distributor)
70+
and the `idx` content so that the same plan is reused across calls.
71+
"""
72+
73+
__slots__ = ('data', 'idx', '_sig')
74+
75+
def __init__(self, data, idx):
76+
self.data = data
77+
self.idx = idx
78+
self._sig = _signature(self.data, self.idx)
79+
80+
def __hash__(self):
81+
return hash(self._sig)
82+
83+
def __eq__(self, other):
84+
return self._sig == other._sig
85+
86+
87+
@lru_cache(maxsize=64)
88+
def _build(key):
89+
return Exchange(key.data, key.idx)
90+
91+
92+
def cached_exchange(data, idx):
93+
"""Return a (cached) `Exchange` for `data[idx]`."""
94+
return _build(_ExchangeKey(data, idx))
95+
96+
97+
def _signature(data, idx):
98+
# The decomposition/distributor are keyed by identity: while cached, the key
99+
# keeps them alive so their ids cannot be recycled, and distinct live objects
100+
# always have distinct ids.
101+
sig = [id(data._distributor), id(data._decomposition), tuple(data.shape)]
102+
for component in as_tuple(idx):
103+
if isinstance(component, np.ndarray):
104+
sig.append(('arr', component.shape, component.dtype.str,
105+
component.tobytes()))
106+
elif isinstance(component, slice):
107+
sig.append(('slc', component.start, component.stop, component.step))
108+
elif isinstance(component, (list, tuple)):
109+
arr = np.asarray(component)
110+
sig.append(('seq', arr.shape, arr.dtype.str, arr.tobytes()))
111+
else:
112+
sig.append(('obj', component))
113+
return tuple(sig)

devito/data/distributed/layout.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Layout layer: where a global coordinate physically lives.
3+
4+
A `Layout` wraps a distributor and a per-axis decomposition and answers,
5+
for any axis, "which rank owns global index g, and at what local offset". It is
6+
the single bridge between the layout-independent `Selection` and the
7+
physical MPI placement, and it is what makes replicated and distributed axes
8+
look uniform to the planner. All maps are computed locally from replicated
9+
metadata; no communication happens here.
10+
"""
11+
12+
from functools import cached_property
13+
14+
import numpy as np
15+
16+
from devito.tools import prod
17+
18+
__all__ = ['Layout']
19+
20+
21+
class Layout:
22+
23+
"""
24+
Physical placement of a distributed array's axes.
25+
26+
Parameters
27+
----------
28+
distributor : Distributor
29+
Provides the communicator, topology and rank/coord maps.
30+
decomposition : tuple
31+
One entry per array axis: a Decomposition for a distributed axis, or
32+
`None` for a replicated axis.
33+
global_shape : tuple of int
34+
The global array shape (full size on every axis).
35+
"""
36+
37+
def __init__(self, distributor, decomposition, global_shape):
38+
self.distributor = distributor
39+
self.decomposition = tuple(decomposition)
40+
self.global_shape = tuple(global_shape)
41+
42+
@cached_property
43+
def distributed_axes(self):
44+
"""The array axes that are MPI-distributed, in increasing order."""
45+
return tuple(a for a, d in enumerate(self.decomposition) if d is not None)
46+
47+
@cached_property
48+
def replicated_axes(self):
49+
"""The array axes that are replicated on every rank."""
50+
return tuple(a for a, d in enumerate(self.decomposition) if d is None)
51+
52+
@cached_property
53+
def replicated_size(self):
54+
"""Product of the full local sizes of the replicated axes."""
55+
return prod(self.global_shape[a] for a in self.replicated_axes)
56+
57+
def axis_maps(self, axis):
58+
"""
59+
Lookup tables for one distributed axis.
60+
61+
Returns
62+
-------
63+
owner : ndarray
64+
`owner[g]` is the topology sub-rank owning global index `g` along
65+
this axis (`-1` if out of bounds).
66+
local : ndarray
67+
`local[g]` is the offset of `g` within its owner's subdomain.
68+
sizes : ndarray
69+
`sizes[i]` is the number of indices owned by sub-rank `i`.
70+
"""
71+
return self._axis_maps[axis]
72+
73+
@cached_property
74+
def _axis_maps(self):
75+
maps = {}
76+
for axis in self.distributed_axes:
77+
dec = self.decomposition[axis]
78+
gmin = dec.glb_min or 0
79+
n = self.global_shape[axis]
80+
owner = np.full(n, -1, dtype=np.int64)
81+
local = np.full(n, -1, dtype=np.int64)
82+
sizes = np.zeros(len(dec), dtype=np.int64)
83+
for i, sub in enumerate(dec):
84+
pos = np.asarray(sub, dtype=np.int64) - gmin
85+
in_range = (pos >= 0) & (pos < n)
86+
owner[pos[in_range]] = i
87+
local[pos[in_range]] = np.arange(pos.size)[in_range]
88+
sizes[i] = pos.size
89+
maps[axis] = (owner, local, sizes)
90+
return maps
91+
92+
@cached_property
93+
def topology_shape(self):
94+
"""Number of sub-ranks along each distributed axis."""
95+
return tuple(len(self.decomposition[a]) for a in self.distributed_axes)
96+
97+
@cached_property
98+
def coord_to_rank(self):
99+
"""Map a topology coordinate tuple (over distributed axes) to a flat rank.
100+
101+
Grid distributors expose the inverse Cartesian map via `all_coords`;
102+
single-axis distributors (e.g. sparse) lay ranks out linearly, so the
103+
sole sub-rank index is the flat rank.
104+
"""
105+
if hasattr(self.distributor, 'all_coords'):
106+
return {tuple(int(c) for c in coord): r
107+
for r, coord in enumerate(self.distributor.all_coords)}
108+
return {(r,): r for r in range(self.distributor.nprocs)}

0 commit comments

Comments
 (0)