|
1 | 1 | """ |
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. |
3 | 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: |
| 4 | +The script runs one of two studies, picked automatically from the MPI launch |
| 5 | +size, each making a single point: |
8 | 6 |
|
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. :: |
13 | 11 |
|
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 |
18 | 13 |
|
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. :: |
20 | 19 |
|
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. |
24 | 23 | """ |
25 | 24 |
|
26 | 25 | import argparse |
| 26 | +import os |
27 | 27 | from time import perf_counter |
28 | 28 |
|
29 | 29 | import numpy as np |
30 | 30 |
|
31 | 31 | 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] |
34 | 39 |
|
35 | 40 |
|
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): |
37 | 47 | """ |
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. |
39 | 50 |
|
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. |
44 | 53 | """ |
45 | | - if clear is None: |
46 | | - call() |
| 54 | + func(*args) |
47 | 55 | times = [] |
48 | 56 | for _ in range(reps): |
49 | | - if clear is not None: |
50 | | - clear() |
51 | | - comm.Barrier() |
| 57 | + if comm is not None: |
| 58 | + comm.Barrier() |
52 | 59 | start = perf_counter() |
53 | | - call() |
| 60 | + func(*args) |
54 | 61 | 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) |
56 | 66 | return float(np.median(times)) |
57 | 67 |
|
58 | 68 |
|
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 |
68 | 73 |
|
69 | | - configuration['mpi'] = True |
70 | 74 |
|
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,)) |
76 | 79 | 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) |
80 | 81 | f.data[:] = full |
81 | 82 |
|
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 |
118 | 112 |
|
119 | 113 | 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)) |
125 | 119 | print(header) |
126 | 120 | 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) |
135 | 166 |
|
136 | 167 |
|
137 | 168 | if __name__ == '__main__': |
|
0 commit comments