Skip to content

Commit e0a8d5d

Browse files
committed
examples: Reframe the MPI data-indexing benchmark
The first version pitted distributed devito against single-process NumPy as if competing, which is the wrong comparison. Reframe into two studies, auto-selected by the launch size, each making one point: * serial (no mpirun): the data[idx] wrapper adds only a small overhead over NumPy, with no per-element Python loop; * parallel (mpirun -n N): a routed assignment's cost scales with the volume communicated, while the NumPy reference (pure compute) is unaffected by the rank count. Sweeps the number of indexed/communicated points and prints a table per study.
1 parent 93cad7f commit e0a8d5d

1 file changed

Lines changed: 130 additions & 99 deletions

File tree

examples/mpi/benchmark_data_indexing.py

Lines changed: 130 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,168 @@
11
"""
2-
Benchmark Devito's MPI-distributed data indexing against single-process NumPy.
2+
Benchmark the overhead of Devito's ``data[idx]`` get/set over plain NumPy.
33
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:
4+
The script runs one of two studies, picked automatically from the MPI launch
5+
size, each making a single point:
86
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).
7+
* **Serial** (run without ``mpirun``) -- with MPI off, ``data[idx]`` is a thin
8+
wrapper over NumPy indexing. The benchmark shows the overhead is small: a
9+
fixed cost plus a low per-point cost (vectorised, with no per-element Python
10+
loop), so for realistic operation sizes it stays negligible. ::
1311
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.
12+
python examples/mpi/benchmark_data_indexing.py
1813
19-
Run, for example::
14+
* **Parallel** (run under ``mpirun -n N`` with ``N > 1``) -- a routed
15+
assignment ``data[idx] = values`` moves data point-to-point. The benchmark
16+
sweeps the number of communicated points and shows the added cost scales with
17+
that volume (as expected for communication), while the per-rank NumPy work is
18+
not slowed down -- the NumPy reference time is independent of the rank count. ::
2019
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
20+
DEVITO_MPI=1 mpirun -n 4 python examples/mpi/benchmark_data_indexing.py
21+
22+
Pass ``--reps`` to change the number of timed repetitions.
2423
"""
2524

2625
import argparse
26+
import os
2727
from time import perf_counter
2828

2929
import numpy as np
3030

3131
from devito import Function, Grid, configuration
32-
from devito.data.distributed import exchange as _exchange
33-
from devito.mpi import MPI
32+
33+
# Global axis length used by both studies; large enough to distribute, small
34+
# enough to stay in memory on one process.
35+
AXIS = 1 << 20
36+
37+
# Numbers of indexed/communicated points to sweep.
38+
POINTS = [16, 128, 1024, 8192, 65536]
3439

3540

36-
def _measure(call, reps, comm, clear=None):
41+
def _set(target, idx, vals):
42+
"""Assign ``target[idx] = vals`` (``target`` a NumPy array or ``f.data``)."""
43+
target[idx] = vals
44+
45+
46+
def _median_time(func, args, reps, comm=None):
3747
"""
38-
Median wall time of ``call`` over ``reps`` repetitions.
48+
Median wall time of ``func(*args)`` over ``reps`` repetitions, after a
49+
warm-up call.
3950
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.
51+
Under MPI the per-repetition time is the slowest rank (an ``MPI.MAX``
52+
reduction), since a collective operation finishes only when every rank does.
4453
"""
45-
if clear is None:
46-
call()
54+
func(*args)
4755
times = []
4856
for _ in range(reps):
49-
if clear is not None:
50-
clear()
51-
comm.Barrier()
57+
if comm is not None:
58+
comm.Barrier()
5259
start = perf_counter()
53-
call()
60+
func(*args)
5461
elapsed = perf_counter() - start
55-
times.append(comm.allreduce(elapsed, op=MPI.MAX))
62+
if comm is not None:
63+
from devito.mpi import MPI
64+
elapsed = comm.allreduce(elapsed, op=MPI.MAX)
65+
times.append(elapsed)
5666
return float(np.median(times))
5767

5868

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()
69+
def _distinct_indices(npoint):
70+
"""``npoint`` distinct global indices, evenly spread over the axis."""
71+
step = max(1, AXIS // npoint)
72+
return (np.arange(npoint) * step) % AXIS
6873

69-
configuration['mpi'] = True
7074

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
75+
def serial_study(reps):
76+
"""Overhead of ``data[idx]`` over NumPy with MPI off (single process)."""
77+
configuration['mpi'] = False
78+
grid = Grid(shape=(AXIS,))
7679
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+
full = np.arange(AXIS, dtype=f.dtype)
8081
f.data[:] = full
8182

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-
))
83+
print(f"\nSerial overhead of data[idx] = v over NumPy "
84+
f"(axis={AXIS}, reps={reps})\n")
85+
header = ('points'.rjust(10) + 'numpy [us]'.rjust(14)
86+
+ 'devito [us]'.rjust(14) + 'overhead [us]'.rjust(16))
87+
print(header)
88+
print('-' * len(header))
89+
for npoint in POINTS:
90+
idx = _distinct_indices(npoint)
91+
vals = full[idx]
92+
ref = full.copy()
93+
t_np = _median_time(_set, (ref, idx, vals), reps)
94+
t_dv = _median_time(_set, (f.data, idx, vals), reps)
95+
print(str(npoint).rjust(10)
96+
+ format(t_np * 1e6, '.1f').rjust(14)
97+
+ format(t_dv * 1e6, '.1f').rjust(14)
98+
+ format((t_dv - t_np) * 1e6, '.1f').rjust(16))
99+
print("\noverhead = devito - numpy; a small fixed cost plus a low per-point "
100+
"cost (no\nper-element Python loop) -- tens of microseconds at "
101+
"realistic sizes.")
102+
103+
104+
def mpi_study(comm, nprocs, reps):
105+
"""Communication cost of a routed assignment vs the volume exchanged."""
106+
configuration['mpi'] = True
107+
grid = Grid(shape=(AXIS,))
108+
f = Function(name='f', grid=grid, dtype=np.float32)
109+
full = np.arange(AXIS, dtype=f.dtype)
110+
f.data[:] = full
111+
rank = grid.distributor.myrank
118112

119113
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))
114+
print(f"\nRouted assignment data[idx] = v under MPI "
115+
f"(axis={AXIS}, ranks={nprocs}, reps={reps})\n")
116+
header = ('points'.rjust(10) + 'KiB sent'.rjust(12)
117+
+ 'numpy [us]'.rjust(14) + 'devito [us]'.rjust(14)
118+
+ 'comm [us]'.rjust(14))
125119
print(header)
126120
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).")
121+
122+
for npoint in POINTS:
123+
global_idx = _distinct_indices(npoint)
124+
# Each rank supplies the points it would hold in an external layout
125+
# (reversed, so the values cross rank boundaries when routed).
126+
loc_idx = np.array_split(global_idx[::-1], nprocs)[rank]
127+
loc_vals = full[loc_idx]
128+
ref = full.copy()
129+
130+
# NumPy reference: the whole operation on a single process. It is the
131+
# pure-compute cost and is unaffected by the rank count.
132+
t_np = _median_time(_set, (ref, global_idx, full[global_idx]), reps, comm)
133+
# Devito: routed, point-to-point, with the plan cached (steady state).
134+
t_dv = _median_time(_set, (f.data, loc_idx, loc_vals), reps, comm)
135+
136+
if rank == 0:
137+
kib = npoint * full.dtype.itemsize / 1024
138+
print(str(npoint).rjust(10)
139+
+ format(kib, '.1f').rjust(12)
140+
+ format(t_np * 1e6, '.1f').rjust(14)
141+
+ format(t_dv * 1e6, '.1f').rjust(14)
142+
+ format((t_dv - t_np) * 1e6, '.1f').rjust(14))
143+
144+
if rank == 0:
145+
print("\ncomm = devito - numpy; grows with the volume communicated, as "
146+
"expected,\nwhile the numpy reference (pure compute) is unchanged.")
147+
148+
149+
def main():
150+
parser = argparse.ArgumentParser(description=__doc__)
151+
parser.add_argument('--reps', type=int, default=20,
152+
help='timed repetitions per point count (default: 20)')
153+
args = parser.parse_args()
154+
155+
# Detect the launch size without initialising MPI (so the serial study can
156+
# keep MPI off and exercise the pure-NumPy path).
157+
nprocs = int(os.environ.get('OMPI_COMM_WORLD_SIZE',
158+
os.environ.get('PMI_SIZE', '1')))
159+
160+
if nprocs == 1:
161+
serial_study(args.reps)
162+
else:
163+
configuration['mpi'] = True
164+
grid = Grid(shape=(8,)) # initialise the distributor (and MPI)
165+
mpi_study(grid.distributor.comm, nprocs, args.reps)
135166

136167

137168
if __name__ == '__main__':

0 commit comments

Comments
 (0)