|
| 1 | +# dpnp ASV Benchmarks |
| 2 | + |
| 3 | +Performance benchmarks for [dpnp](https://github.com/IntelPython/dpnp) using |
| 4 | +[Airspeed Velocity (ASV)](https://asv.readthedocs.io/en/stable/). |
| 5 | + |
| 6 | +## Coverage |
| 7 | + |
| 8 | +| File | API | Benchmarks | Params | Sizes | |
| 9 | +|------|-----|------------|--------|-------| |
| 10 | +| `bench_dpbench.py` | `dpnp` (end-to-end workloads) | `BlackScholes`, `L2Norm`, `PairwiseDistance`, `Rambo`, `Gpairs` | `preset`, `precision` | dpBench presets `S`, `M16Gb`, `M`, `L` | |
| 11 | +| `bench_elementwise.py` | `dpnp` vs `numpy` | `Unary` (31 ufuncs), `Binary` (7 ufuncs) | `executor`, `ufunc`, `size`, `dtype` (float only) | 2^16, 2^20, 2^24 | |
| 12 | +| `bench_linalg.py` | `dpnp` vs `numpy` (`dot`, `matmul`, `inner`, `einsum`; contiguous and transposed) | `MatMul` | `executor`, `order`, `dtype` (float and int) | 16 to 1024 square | |
| 13 | +| `bench_linalg.py` | `dpnp.linalg` vs `numpy.linalg` (`det`, `norm`, `solve`, `svd`) | `Linalg` | `executor`, `order`, `dtype` (float only) | 16 to 1024 square | |
| 14 | +| `bench_random.py` | `dpnp.random` vs `numpy.random` | `Sample` (`random_sample`, `standard_normal`) | `executor`, `size` | 2^16, 2^20, 2^24 | |
| 15 | +| `bench_random.py` | `dpnp.random.RandomState` vs `numpy.random.default_rng` | `TypedSample` (uniform, normal) | `executor`, `size`, `dtype` (float only) | 2^16, 2^20, 2^24 | |
| 16 | + |
| 17 | +### dpBench workloads |
| 18 | + |
| 19 | +`bench_dpbench.py` runs a set of dpnp workloads derived from |
| 20 | +[dpBench](https://github.com/IntelPython/dpbench), which live in |
| 21 | +`benchmarks/benchmarks/dpbench/workloads`. They measure the end-to-end time of a |
| 22 | +whole workload rather than of an individual API call. |
| 23 | + |
| 24 | +| Workload | Domain | |
| 25 | +| ------------------- | ------------------ | |
| 26 | +| `black_scholes` | Finance | |
| 27 | +| `l2_norm` | Distance Compute | |
| 28 | +| `pairwise_distance` | Distance Compute | |
| 29 | +| `rambo` | Particle Physics | |
| 30 | +| `gpairs` | Astrophysics | |
| 31 | + |
| 32 | +Host input data is generated and copied to the device the way dpBench does, and |
| 33 | +each kernel ends with `dpnp.synchronize_array_data`, so a single call blocks |
| 34 | +until the device work has finished. dpBench is not a dependency. See |
| 35 | +[`benchmarks/dpbench/README.md`](benchmarks/dpbench/README.md) for the |
| 36 | +source-to-module mapping and the intended differences. |
| 37 | + |
| 38 | +## Device and precision |
| 39 | + |
| 40 | +dpnp allocates on the default SYCL device. Use `ONEAPI_DEVICE_SELECTOR` to |
| 41 | +target a specific one: |
| 42 | + |
| 43 | +```bash |
| 44 | +ONEAPI_DEVICE_SELECTOR=level_zero:gpu asv run \ |
| 45 | + --python=same \ |
| 46 | + --launch-method spawn \ |
| 47 | + --quick |
| 48 | +``` |
| 49 | + |
| 50 | +**The parameter matrix is the same on every machine.** All four dpBench presets |
| 51 | +are declared statically, so a given benchmark has the same parameter set |
| 52 | +everywhere and results are comparable across devices and across the CI pool. |
| 53 | +What varies per device is which of those points *run*: `setup` calls |
| 54 | +`_dpbench_runner.preset_fits` and raises `SkipNotImplemented` for any preset |
| 55 | +whose estimated peak footprint -- the workload's `peak_elements` at the point's |
| 56 | +own precision -- exceeds **0.25** of the device's `global_mem_size`. So a large |
| 57 | +discrete GPU exercises the bigger problem sizes automatically while a small iGPU |
| 58 | +reports `S` and skips the rest, and a skipped point stays visible as a skip |
| 59 | +rather than vanishing from the matrix. Since `single` needs half the memory of |
| 60 | +`double`, it reaches one preset further on a given device. |
| 61 | + |
| 62 | +The cheapest preset always runs. If even that does not fit, it is attempted |
| 63 | +anyway so the failure is a loud allocation error rather than silence. |
| 64 | + |
| 65 | +Note that dpBench's preset names are not ordered by size: `M16Gb` is *smaller* |
| 66 | +than `M` for every workload except `rambo`, where it is larger and equal to `L`. |
| 67 | +Anything that needs the cheapest preset sorts explicitly rather than relying on |
| 68 | +declaration order. |
| 69 | + |
| 70 | +**Both precisions are benchmarked.** Devices without fp64 support (common on |
| 71 | +iGPUs) skip the `double` points via `SkipNotImplemented` rather than failing, so |
| 72 | +such a device still produces `single` results. The `float64` points of |
| 73 | +`bench_elementwise.py` and `bench_linalg.py` skip the same way for the `dpnp` |
| 74 | +executor; the `numpy` executor is unaffected. dpBench's own configs request |
| 75 | +`double` throughout, and that value is kept in each workload's `PRECISION` for |
| 76 | +reference. |
| 77 | + |
| 78 | +`bench_random.py`'s `Sample` skips its `dpnp` points without fp64: those |
| 79 | +functions take no `dtype`, so dpnp would return the device's default float |
| 80 | +against NumPy's `float64`. `TypedSample` covers the same two distributions |
| 81 | +through dpnp's `RandomState` and NumPy's `default_rng`, which do take a `dtype`, |
| 82 | +so `float32` is still compared there. |
| 83 | + |
| 84 | +No benchmark module opens a SYCL queue at import time, so benchmark discovery |
| 85 | +and `asv check` work on a machine with no usable device; only `setup` needs one. |
| 86 | + |
| 87 | +One caveat on comparability: ASV keys results by machine, commit and |
| 88 | +environment, not by device. Benchmarking two devices on the same host therefore |
| 89 | +overwrites one set of results with the other. Give each device its own machine |
| 90 | +name when you do that: |
| 91 | + |
| 92 | +```bash |
| 93 | +ONEAPI_DEVICE_SELECTOR=level_zero:gpu asv run --python=same \ |
| 94 | + --launch-method spawn --machine "$(hostname)-gpu" |
| 95 | +``` |
| 96 | + |
| 97 | +## Notes on Measurement |
| 98 | + |
| 99 | +### Process launch method |
| 100 | + |
| 101 | +**Always pass `--launch-method spawn`.** ASV defaults to a forkserver, which |
| 102 | +`fork()`s a process that has already initialized a SYCL runtime; the SYCL |
| 103 | +runtime is multi-threaded and not fork-safe, so benchmarks may hang until |
| 104 | +`default_benchmark_timeout` expires (reported as `failed`) or fail with |
| 105 | +`USM Allocation` errors on `level_zero` devices. `spawn` starts a fresh |
| 106 | +interpreter per benchmark and avoids this entirely. |
| 107 | + |
| 108 | +### Asynchronous execution |
| 109 | + |
| 110 | +**Every timed body that runs dpnp work must block on it.** dpnp enqueues to a |
| 111 | +SYCL queue and returns before the kernel has run, so a body that does not block |
| 112 | +measures submission rather than execution. On a CPU device a 1024x1024 float32 |
| 113 | +`dot` measured **0.3 ms** unsynchronized against **18 ms** synchronized -- which |
| 114 | +would have reported dpnp as far faster than NumPy's **10 ms** on work where it is |
| 115 | +in fact 1.7x slower. |
| 116 | + |
| 117 | +The dpBench workloads each end with `dpnp.synchronize_array_data`, and the |
| 118 | +comparison suites obtain a synchronizer from `_utils.make_synchronizer` in |
| 119 | +`setup` and pass every result through it (`self.sync(...)`). For the `numpy` |
| 120 | +executor the synchronizer does nothing. |
| 121 | + |
| 122 | +### First-call costs |
| 123 | + |
| 124 | +The first call on a fresh queue pays SYCL kernel/JIT and allocator warmup. |
| 125 | +`WorkloadRunner.setup` therefore runs each workload once before ASV starts |
| 126 | +timing it, so the dpBench suite is warmed explicitly. The `bench_elementwise.py`, |
| 127 | +`bench_linalg.py` and `bench_random.py` suites each run their operation once in |
| 128 | +`setup` as well, so a `--quick` measurement is not dominated by one-time cost. |
| 129 | + |
| 130 | +`--quick` still takes a single sample, so use it to check that benchmarks run |
| 131 | +rather than to compare them: before the explicit warmups a `--quick` |
| 132 | +`dot(a, a.T)` measured 2.6x its contiguous counterpart, where a repeated |
| 133 | +measurement puts it at 0.7x. |
| 134 | + |
| 135 | +### Validation |
| 136 | + |
| 137 | +Each workload ships the NumPy `reference` implementation from dpBench. On the |
| 138 | +cheapest preset, `setup` compares the dpnp results for all `OUTPUT_ARGS` |
| 139 | +against it with `numpy.testing.assert_allclose`, at a tolerance chosen per |
| 140 | +precision (`rtol` 1e-3 / `atol` 1e-4 for `single`, 1e-6 / 1e-9 for `double`). |
| 141 | +`atol` carries as much weight as `rtol`, because some outputs pass through |
| 142 | +zero, where any absolute error is an enormous relative one. A numerically |
| 143 | +wrong kernel therefore fails the benchmark instead of being silently timed, |
| 144 | +and validation runs outside the timed region. |
| 145 | + |
| 146 | +Only the cheapest preset is validated: the reference runs on the host and at the |
| 147 | +larger presets costs far more than the benchmark it guards -- tens of seconds |
| 148 | +for `pairwise_distance` at `M16Gb` -- while checking numerics that do not depend |
| 149 | +on the problem size. |
| 150 | + |
| 151 | +### Noise at small presets |
| 152 | + |
| 153 | +The smallest sizes are dominated by per-call dispatch overhead and are |
| 154 | +noticeably noisier. On a CPU device the run-to-run spread of the median at `S` |
| 155 | +was measured between **2%** and **25%** across workloads, against the **20%** |
| 156 | +`regressions_thresholds` in `asv.conf.json`, whereas the larger presets settled |
| 157 | +to a few percent. Treat `S` as a smoke-test size only and do not use it for |
| 158 | +regression gating; prefer the largest preset the device fits. |
| 159 | + |
| 160 | +## Running Benchmarks |
| 161 | + |
| 162 | +ASV cannot build dpnp -- it is a SYCL/DPC++ extension that requires the Intel |
| 163 | +oneAPI compiler and a lengthy build -- so the benchmarks always run against an |
| 164 | +**existing environment** that already has dpnp installed. A bare `asv run` is |
| 165 | +not supported; always pass `--python=same` or `--environment existing:<python>`. |
| 166 | + |
| 167 | +Create an environment |
| 168 | +[following these instructions](https://intelpython.github.io/dpnp/quick_start_guide.html), |
| 169 | +then install the benchmarking tooling into it: |
| 170 | + |
| 171 | +```bash |
| 172 | +conda install -c conda-forge asv scipy |
| 173 | +``` |
| 174 | + |
| 175 | +`scipy` is needed because `scipy.special.erf` is used by the NumPy reference |
| 176 | +that the `black_scholes` benchmark validates its dpnp results against. It is not |
| 177 | +a dpnp runtime dependency, so it also has to be listed in `requirements.txt`, |
| 178 | +which is what CI installs into the benchmarking environment. Keep that file and |
| 179 | +the `benchmark` extra in `pyproject.toml` in step. |
| 180 | + |
| 181 | +Do **not** use `pip install ".[benchmark]"` for an environment that already has |
| 182 | +dpnp: dpnp is a scikit-build project, so pip reinstalls the `dpnp` package |
| 183 | +itself and triggers a full oneAPI/DPC++ rebuild of the backend just to pull in |
| 184 | +two pure-Python dependencies. The `benchmark` extra in `pyproject.toml` records |
| 185 | +those two dependencies for the case where dpnp is being built from source |
| 186 | +anyway; note that the usual editable-install invocation passes `--no-deps`, so |
| 187 | +it does *not* install them: |
| 188 | + |
| 189 | +```bash |
| 190 | +pip install --no-build-isolation --no-deps -e . |
| 191 | +conda install -c conda-forge asv scipy |
| 192 | +``` |
| 193 | + |
| 194 | +All commands below are run from the `benchmarks/` directory, where |
| 195 | +`asv.conf.json` lives. |
| 196 | + |
| 197 | +Register the machine once. Without this a non-interactive or CI run aborts with |
| 198 | +`No information stored about machine`: |
| 199 | + |
| 200 | +```bash |
| 201 | +asv machine --yes |
| 202 | +``` |
| 203 | + |
| 204 | +Validate the whole suite without running it. This is cheap and catches broken |
| 205 | +signatures and import errors; it accepts no `--bench`, so it is all-or-nothing: |
| 206 | + |
| 207 | +```bash |
| 208 | +asv check --python=same |
| 209 | +``` |
| 210 | + |
| 211 | +Smoke-run the benchmarks, optionally scoped with `--bench`: |
| 212 | + |
| 213 | +```bash |
| 214 | +asv run --python=same --launch-method spawn --quick --bench bench_dpbench |
| 215 | +``` |
| 216 | + |
| 217 | +This only *prints* results. Without `--set-commit-hash` ASV discards them, so |
| 218 | +`asv compare` and `asv publish` will see nothing. |
| 219 | + |
| 220 | +To record results, assert which revision the installed dpnp corresponds to: |
| 221 | + |
| 222 | +```bash |
| 223 | +asv run --python=same --launch-method spawn --set-commit-hash HEAD |
| 224 | +``` |
| 225 | + |
| 226 | +ASV does not verify that claim -- it is your assertion -- and the |
| 227 | +`For dpnp commit ...` progress line prints the branch head rather than the |
| 228 | +value passed, so trust the result filename or `asv show`. |
| 229 | + |
| 230 | +Pointing ASV at an interpreter explicitly works too: |
| 231 | + |
| 232 | +```bash |
| 233 | +asv run --environment existing:/full/conda/path/envs/dpnp_env/bin/python \ |
| 234 | + --launch-method spawn |
| 235 | +``` |
| 236 | + |
| 237 | +### Comparing two revisions |
| 238 | + |
| 239 | +`asv continuous` and any `<commit>` range spec cannot be used here: ASV refuses |
| 240 | +a range spec when it cannot install the project into the environment. Compare |
| 241 | +two recorded runs instead. Rebuild dpnp in the same environment between them, |
| 242 | +and omit `--quick` so the statistics path engages: |
| 243 | + |
| 244 | +```bash |
| 245 | +# against the old build |
| 246 | +asv run --python=same --launch-method spawn --set-commit-hash <old-rev> |
| 247 | +# rebuild/reinstall dpnp, then |
| 248 | +asv run --python=same --launch-method spawn --set-commit-hash <new-rev> |
| 249 | +asv compare <old-rev> <new-rev> |
| 250 | +``` |
| 251 | + |
| 252 | +View recorded results in a browser: |
| 253 | + |
| 254 | +```bash |
| 255 | +asv publish |
| 256 | +asv preview |
| 257 | +``` |
| 258 | + |
| 259 | +The published dashboard only covers the branches listed in `asv.conf.json` |
| 260 | +(`master` and `dev-milestone`). Results recorded for a commit on any other |
| 261 | +branch are dropped with `Couldn't find <hash> in branches (...)`, so use |
| 262 | +`asv compare` for feature-branch and PR work -- it does not consult `branches`. |
| 263 | + |
| 264 | +## Writing new benchmarks |
| 265 | + |
| 266 | +Read ASV's guidelines for writing benchmarks |
| 267 | +[here](https://asv.readthedocs.io/en/stable/writing_benchmarks.html). |
| 268 | + |
| 269 | +Parameter axes shared by two or more `bench_*` modules live in `_utils.py`; |
| 270 | +single-use axes stay in the module that needs them. Two rules keep results |
| 271 | +usable: |
| 272 | + |
| 273 | +* Keep parameter values plain strings, numbers or tuples. A live module or dtype |
| 274 | + object renders as `<module 'dpnp' from '/...'>` in the result tables and embeds |
| 275 | + a local path in the result identity. |
| 276 | +* Keep `params` static. Deriving an axis from the machine makes rows |
| 277 | + incomparable between devices; decide per-device behaviour in `setup` instead, |
| 278 | + by raising `SkipNotImplemented` (see `bench_dpbench._Workload.setup`). |
| 279 | +* Block on dpnp work inside the timed body, or you are timing submission -- see |
| 280 | + [Asynchronous execution](#asynchronous-execution). |
| 281 | + |
| 282 | +To add another dpBench workload, follow |
| 283 | +[`benchmarks/dpbench/README.md`](benchmarks/dpbench/README.md), then add a |
| 284 | +benchmark class for it to `bench_dpbench.py`. Copy an existing one: it is a |
| 285 | +banner, a docstring, a `WORKLOAD` attribute and a one-line `time_*` method -- |
| 286 | +the parameter axes are inherited from `_Workload`. |
0 commit comments