Skip to content

Commit 9cb66bc

Browse files
committed
examples: Add MPI data-indexing benchmark vs NumPy
Times distributed data[idx] get/set against single-process NumPy on the full domain, for a routed scatter assignment, the matching routed read, and a local strided slice. Routed cases report cold (plan construction) and warm (cached plan) timings, so the steady-state cost is visible.
1 parent 834732b commit 9cb66bc

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""
2+
Benchmark Devito's MPI-distributed data indexing against single-process NumPy.
3+
4+
Devito turns ``data[idx]`` get/set on an MPI-distributed array into the minimal
5+
point-to-point exchange (see ``devito/data/distributed``). This script measures
6+
that machinery against the equivalent operation on a single-process NumPy array
7+
holding the full domain, for three representative cases:
8+
9+
* a routed scatter assignment ``data[idx] = values`` (global-index labelled,
10+
the path that moves data across ranks);
11+
* the matching routed read ``data[idx]``;
12+
* a basic strided slice ``data[::2]`` (local/induced, no communication).
13+
14+
For the routed cases the distributed timing is reported both *cold* (first call,
15+
including plan construction) and *warm* (cached plan -- the steady state of a
16+
time loop, e.g. sparse injection every step). The NumPy column is the
17+
single-process reference for the same logical operation on the full domain.
18+
19+
Run, for example::
20+
21+
DEVITO_MPI=1 mpirun -n 4 python examples/mpi/benchmark_data_indexing.py
22+
DEVITO_MPI=1 mpirun -n 4 python examples/mpi/benchmark_data_indexing.py \\
23+
--shape 1024 1024 --reps 50
24+
"""
25+
26+
import argparse
27+
from time import perf_counter
28+
29+
import numpy as np
30+
31+
from devito import Function, Grid, configuration
32+
from devito.data.distributed import exchange as _exchange
33+
from devito.mpi import MPI
34+
35+
36+
def _measure(call, reps, comm, clear=None):
37+
"""
38+
Median wall time of ``call`` over ``reps`` repetitions.
39+
40+
The wall time of one repetition is the slowest rank (an ``MPI.MAX`` reduce
41+
of the per-rank time). With ``clear`` given it is invoked before every timed
42+
call to drop the plan cache, measuring the cold path; otherwise one warm-up
43+
call is made first and the cached path is measured.
44+
"""
45+
if clear is None:
46+
call()
47+
times = []
48+
for _ in range(reps):
49+
if clear is not None:
50+
clear()
51+
comm.Barrier()
52+
start = perf_counter()
53+
call()
54+
elapsed = perf_counter() - start
55+
times.append(comm.allreduce(elapsed, op=MPI.MAX))
56+
return float(np.median(times))
57+
58+
59+
def main():
60+
parser = argparse.ArgumentParser(description=__doc__)
61+
parser.add_argument('--shape', type=int, nargs='+', default=[512, 512],
62+
help='global grid shape (default: 512 512)')
63+
parser.add_argument('--reps', type=int, default=30,
64+
help='timed repetitions per operation (default: 30)')
65+
parser.add_argument('--npoint', type=int, default=None,
66+
help='routed points (default: an eighth of axis 0)')
67+
args = parser.parse_args()
68+
69+
configuration['mpi'] = True
70+
71+
shape = tuple(args.shape)
72+
# Creating the Grid initialises the MPI distributor (and MPI itself)
73+
grid = Grid(shape=shape)
74+
comm = grid.distributor.comm
75+
rank, nprocs = grid.distributor.myrank, grid.distributor.nprocs
76+
f = Function(name='f', grid=grid, dtype=np.float32)
77+
78+
# Full replicated reference array; also the NumPy single-process baseline
79+
full = np.arange(int(np.prod(shape)), dtype=f.dtype).reshape(shape)
80+
f.data[:] = full
81+
82+
# Global row indices to route (reversed, so they cross rank boundaries);
83+
# each rank contributes the slice it would hold in an external layout
84+
npoint = args.npoint or max(1, shape[0] // 8)
85+
glb_idx = np.arange(shape[0])[::-1][:npoint]
86+
loc_idx = np.array_split(glb_idx, nprocs)[rank]
87+
loc_vals = full[loc_idx]
88+
ref = full.copy()
89+
90+
clear = _exchange._build.cache_clear
91+
92+
rows = []
93+
94+
# 1. Routed scatter assignment: data[idx] = values
95+
rows.append((
96+
'scatter data[idx] = v',
97+
_measure(lambda: ref.__setitem__(glb_idx, full[glb_idx]), args.reps, comm),
98+
_measure(lambda: f.data.__setitem__(loc_idx, loc_vals), args.reps, comm,
99+
clear=clear),
100+
_measure(lambda: f.data.__setitem__(loc_idx, loc_vals), args.reps, comm),
101+
))
102+
103+
# 2. Routed gather read: data[idx]
104+
rows.append((
105+
'gather data[idx]',
106+
_measure(lambda: ref[glb_idx], args.reps, comm),
107+
_measure(lambda: f.data[loc_idx], args.reps, comm, clear=clear),
108+
_measure(lambda: f.data[loc_idx], args.reps, comm),
109+
))
110+
111+
# 3. Basic strided slice (local/induced, no communication)
112+
rows.append((
113+
'slice data[::2]',
114+
_measure(lambda: ref[::2, ::2], args.reps, comm),
115+
None,
116+
_measure(lambda: f.data[::2, ::2], args.reps, comm),
117+
))
118+
119+
if rank == 0:
120+
print(f"\nMPI distributed data indexing vs NumPy "
121+
f"(shape={shape}, ranks={nprocs}, "
122+
f"routed points={npoint}, reps={args.reps})\n")
123+
header = ('operation'.ljust(24) + 'numpy [us]'.rjust(14)
124+
+ 'devito cold [us]'.rjust(18) + 'devito warm [us]'.rjust(18))
125+
print(header)
126+
print('-' * len(header))
127+
for name, np_t, cold, warm in rows:
128+
cold_s = '-' if cold is None else format(cold * 1e6, '.1f')
129+
print(name.ljust(24)
130+
+ format(np_t * 1e6, '.1f').rjust(14)
131+
+ cold_s.rjust(18)
132+
+ format(warm * 1e6, '.1f').rjust(18))
133+
print("\ncold = first call (plan construction); "
134+
"warm = cached plan (steady state of a time loop).")
135+
136+
137+
if __name__ == '__main__':
138+
main()

0 commit comments

Comments
 (0)