scope-profiler 0.2.3__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/PKG-INFO +16 -1
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/README.md +15 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/pyproject.toml +1 -1
- scope_profiler-0.2.4/src/scope_profiler/mpi_launch.py +119 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/post_processing.py +48 -7
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/profile_config.py +11 -12
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/profile_manager.py +8 -0
- scope_profiler-0.2.4/src/scope_profiler/speedscope_export.py +271 -0
- scope_profiler-0.2.4/src/scope_profiler/tests/check_mpi_launch.py +66 -0
- scope_profiler-0.2.4/src/scope_profiler/tests/test_speedscope_export.py +227 -0
- scope_profiler-0.2.4/src/scope_profiler/tests/unit/__init__.py +0 -0
- scope_profiler-0.2.4/src/scope_profiler/tests/unit/test_mpi_launch.py +90 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/PKG-INFO +16 -1
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/SOURCES.txt +7 -1
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/setup.cfg +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/__init__.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/__main__.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/h5reader.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/inspection.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/metadata.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/mpi_region.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/plotting_scripts.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/prof_export.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/region.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/region_profiler.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/summary.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/__init__.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/examples.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/examples_pylikwid.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/pylikwid_readme.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_app.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_buffer_growth.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_inspection.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_metadata.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_mpi.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_overhead.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_post_processing.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_prof_export.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_reader_api.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_readme.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_repeated_finalize.py +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/dependency_links.txt +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/entry_points.txt +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/requires.txt +0 -0
- {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scope-profiler
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Profile code regions in python, optionally with LIKWID markers.
|
|
5
5
|
Author: Max
|
|
6
6
|
Project-URL: Source, https://github.com/max-models/scope-profiler
|
|
@@ -445,3 +445,18 @@ Regions become "functions": `cumtime` is a region's total wall time and
|
|
|
445
445
|
`tottime` is that minus the time spent in its nested regions. One file is
|
|
446
446
|
written per exported rank, since `.prof` has no notion of ranks — see
|
|
447
447
|
[the CLI docs](docs/source/cli.md) for the caveats of the reconstruction.
|
|
448
|
+
|
|
449
|
+
### Viewing a run in speedscope
|
|
450
|
+
|
|
451
|
+
`--export-speedscope` writes the run as a
|
|
452
|
+
[speedscope](https://www.speedscope.app) JSON file. Unlike `.prof`, it keeps
|
|
453
|
+
every individual call, so the timeline shows the run as it happened:
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
scope-profiler pproc profiling_data.h5 -o figures --export-speedscope --skip-plot-images
|
|
457
|
+
npx speedscope figures/profile.speedscope.json # or drop the file on speedscope.app
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
One file is written per input, holding one profile per exported rank, all
|
|
461
|
+
sharing a time origin so ranks stay aligned. See
|
|
462
|
+
[the CLI docs](docs/source/cli.md) for details.
|
|
@@ -398,3 +398,18 @@ Regions become "functions": `cumtime` is a region's total wall time and
|
|
|
398
398
|
`tottime` is that minus the time spent in its nested regions. One file is
|
|
399
399
|
written per exported rank, since `.prof` has no notion of ranks — see
|
|
400
400
|
[the CLI docs](docs/source/cli.md) for the caveats of the reconstruction.
|
|
401
|
+
|
|
402
|
+
### Viewing a run in speedscope
|
|
403
|
+
|
|
404
|
+
`--export-speedscope` writes the run as a
|
|
405
|
+
[speedscope](https://www.speedscope.app) JSON file. Unlike `.prof`, it keeps
|
|
406
|
+
every individual call, so the timeline shows the run as it happened:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
scope-profiler pproc profiling_data.h5 -o figures --export-speedscope --skip-plot-images
|
|
410
|
+
npx speedscope figures/profile.speedscope.json # or drop the file on speedscope.app
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
One file is written per input, holding one profile per exported rank, all
|
|
414
|
+
sharing a time origin so ranks stay aligned. See
|
|
415
|
+
[the CLI docs](docs/source/cli.md) for details.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Detection of whether the process was launched by an MPI launcher.
|
|
2
|
+
|
|
3
|
+
Importing ``mpi4py.MPI`` calls ``MPI_Init``, and any collective (``bcast``,
|
|
4
|
+
``Barrier``, ...) issued afterwards costs something even on a single process.
|
|
5
|
+
A plain ``python script.py`` run should therefore never touch MPI at all, even
|
|
6
|
+
when mpi4py happens to be installed. This module answers the only question
|
|
7
|
+
that decides it: was this process started by ``mpirun``/``mpiexec``/``srun``
|
|
8
|
+
(or an equivalent launcher)?
|
|
9
|
+
|
|
10
|
+
The answer is read from the environment the launcher sets up, so it is
|
|
11
|
+
available before mpi4py is imported.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# Per-process variables exported by the process managers behind the common
|
|
18
|
+
# launchers. Each is set only for processes started *by* the launcher, so the
|
|
19
|
+
# presence of any one of them means "this rank belongs to an MPI job".
|
|
20
|
+
# SLURM_PROCID is deliberately absent: it is also set for the script of a
|
|
21
|
+
# plain `sbatch` job, which is not an MPI launch. `srun` is covered by the
|
|
22
|
+
# PMI/PMIX variables its MPI plugin exports.
|
|
23
|
+
_LAUNCHER_ENV_VARS = (
|
|
24
|
+
"OMPI_COMM_WORLD_RANK", # Open MPI (and derivatives: Spectrum, ...)
|
|
25
|
+
"PMI_RANK", # MPICH, Intel MPI, MS-MPI, Cray, srun (pmi2)
|
|
26
|
+
"PMIX_RANK", # PMIx, used by srun --mpi=pmix and Open MPI 5
|
|
27
|
+
"MV2_COMM_WORLD_RANK", # MVAPICH2
|
|
28
|
+
"MPI_LOCALRANKID", # Hydra (mpiexec.hydra)
|
|
29
|
+
"ALPS_APP_PE", # Cray ALPS aprun
|
|
30
|
+
"PALS_RANKID", # Cray PALS palsrun
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Escape hatch: force the decision either way without touching code, e.g. for
|
|
34
|
+
# a launcher whose variables are not listed above.
|
|
35
|
+
_OVERRIDE_ENV_VAR = "SCOPE_PROFILER_MPI"
|
|
36
|
+
|
|
37
|
+
_TRUE_VALUES = ("1", "true", "yes", "on")
|
|
38
|
+
_FALSE_VALUES = ("0", "false", "no", "off")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _override() -> bool | None:
|
|
42
|
+
"""Value of ``SCOPE_PROFILER_MPI``, or None if unset/unrecognized."""
|
|
43
|
+
value = os.environ.get(_OVERRIDE_ENV_VAR)
|
|
44
|
+
if value is None:
|
|
45
|
+
return None
|
|
46
|
+
value = value.strip().lower()
|
|
47
|
+
if value in _TRUE_VALUES:
|
|
48
|
+
return True
|
|
49
|
+
if value in _FALSE_VALUES:
|
|
50
|
+
return False
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def launched_under_mpi() -> bool:
|
|
55
|
+
"""Whether this process was started by an MPI launcher.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
bool
|
|
60
|
+
True if a launcher's per-rank environment variable is present, or if
|
|
61
|
+
the application itself already initialized MPI (in which case using
|
|
62
|
+
the communicator is free). ``SCOPE_PROFILER_MPI=0``/``1`` overrides
|
|
63
|
+
the detection.
|
|
64
|
+
"""
|
|
65
|
+
override = _override()
|
|
66
|
+
if override is not None:
|
|
67
|
+
return override
|
|
68
|
+
|
|
69
|
+
if any(var in os.environ for var in _LAUNCHER_ENV_VARS):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
# The application may have initialized MPI itself (embedded interpreter,
|
|
73
|
+
# or an explicit `from mpi4py import MPI`). Only inspect mpi4py if it is
|
|
74
|
+
# already imported: importing it here is exactly what must be avoided.
|
|
75
|
+
mpi_module = sys.modules.get("mpi4py.MPI")
|
|
76
|
+
if mpi_module is not None:
|
|
77
|
+
try:
|
|
78
|
+
return bool(mpi_module.Is_initialized())
|
|
79
|
+
except AttributeError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_comm(use_mpi: bool | None = None):
|
|
86
|
+
"""Return ``MPI.COMM_WORLD``, or None when MPI must not be used.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
use_mpi : bool or None, optional
|
|
91
|
+
None (default) decides via :func:`launched_under_mpi`. True forces the
|
|
92
|
+
communicator (and fails loudly if mpi4py is missing); False disables
|
|
93
|
+
MPI unconditionally.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
mpi4py.MPI.Intracomm or None
|
|
98
|
+
The world communicator, or None if this is not an MPI run or mpi4py
|
|
99
|
+
is unavailable.
|
|
100
|
+
"""
|
|
101
|
+
if use_mpi is False:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if use_mpi is None and not launched_under_mpi():
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
from mpi4py import MPI
|
|
109
|
+
except ImportError:
|
|
110
|
+
if use_mpi:
|
|
111
|
+
raise ImportError(
|
|
112
|
+
"MPI profiling was requested (use_mpi=True) but mpi4py is not "
|
|
113
|
+
"installed."
|
|
114
|
+
)
|
|
115
|
+
# Launched by mpirun without mpi4py available: fall back to treating
|
|
116
|
+
# this rank as a standalone process rather than failing the run.
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
return MPI.COMM_WORLD
|
|
@@ -14,6 +14,7 @@ from scope_profiler.plotting_scripts import (
|
|
|
14
14
|
write_region_statistics_json,
|
|
15
15
|
)
|
|
16
16
|
from scope_profiler.prof_export import export_prof
|
|
17
|
+
from scope_profiler.speedscope_export import export_speedscope
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def parse_ranks(spec: str, verbose: bool = False) -> list[int]:
|
|
@@ -168,14 +169,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
169
|
"-o/--output."
|
|
169
170
|
),
|
|
170
171
|
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--export-speedscope",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help=(
|
|
176
|
+
"Also write a profile.speedscope.json file holding one profile per "
|
|
177
|
+
"exported rank, viewable at https://www.speedscope.app (or with "
|
|
178
|
+
"`npx speedscope profile.speedscope.json`). Unlike --export-prof "
|
|
179
|
+
"this keeps every individual call, so the timeline shows the run "
|
|
180
|
+
"as it happened. The call graph is reconstructed from region "
|
|
181
|
+
"nesting; only ranks selected with --ranks are exported (default: "
|
|
182
|
+
"rank 0). Requires -o/--output."
|
|
183
|
+
),
|
|
184
|
+
)
|
|
171
185
|
parser.add_argument(
|
|
172
186
|
"--skip-plot-images",
|
|
173
187
|
action="store_true",
|
|
174
188
|
help=(
|
|
175
189
|
"Do not render/save the PNG plot images, only the outputs from "
|
|
176
|
-
"--export-data/--export-prof. Useful when
|
|
177
|
-
"entirely client-side (e.g. with Plotly) from
|
|
178
|
-
"
|
|
190
|
+
"--export-data/--export-prof/--export-speedscope. Useful when "
|
|
191
|
+
"charts are rendered entirely client-side (e.g. with Plotly) from "
|
|
192
|
+
"the exported data. Requires one of those export options."
|
|
179
193
|
),
|
|
180
194
|
)
|
|
181
195
|
return parser
|
|
@@ -219,8 +233,15 @@ def main(argv: list[str] | None = None):
|
|
|
219
233
|
if args.export_prof and not args.output:
|
|
220
234
|
parser.error("--export-prof requires -o/--output.")
|
|
221
235
|
|
|
222
|
-
if args.
|
|
223
|
-
parser.error("--
|
|
236
|
+
if args.export_speedscope and not args.output:
|
|
237
|
+
parser.error("--export-speedscope requires -o/--output.")
|
|
238
|
+
|
|
239
|
+
exports_requested = args.export_data or args.export_prof or args.export_speedscope
|
|
240
|
+
if args.skip_plot_images and not exports_requested:
|
|
241
|
+
parser.error(
|
|
242
|
+
"--skip-plot-images requires --export-data, --export-prof or "
|
|
243
|
+
"--export-speedscope."
|
|
244
|
+
)
|
|
224
245
|
|
|
225
246
|
if args.ranks:
|
|
226
247
|
ranks = []
|
|
@@ -261,6 +282,8 @@ def main(argv: list[str] | None = None):
|
|
|
261
282
|
speedup_data_path = None
|
|
262
283
|
prof_path = None
|
|
263
284
|
prof_paths: list = []
|
|
285
|
+
speedscope_path = None
|
|
286
|
+
speedscope_paths: list = []
|
|
264
287
|
durations_paths: list = []
|
|
265
288
|
if args.output:
|
|
266
289
|
os.makedirs(args.output, exist_ok=True)
|
|
@@ -287,10 +310,12 @@ def main(argv: list[str] | None = None):
|
|
|
287
310
|
)
|
|
288
311
|
if args.export_prof:
|
|
289
312
|
prof_path = os.path.join(args.output, "profile.prof")
|
|
313
|
+
if args.export_speedscope:
|
|
314
|
+
speedscope_path = os.path.join(args.output, "profile.speedscope.json")
|
|
290
315
|
|
|
291
316
|
# --skip-plot-images still needs the plotting functions to produce the
|
|
292
|
-
# --export-data files, but
|
|
293
|
-
# backend at all.
|
|
317
|
+
# --export-data files, but an export-only run should not touch the
|
|
318
|
+
# plotting backend at all.
|
|
294
319
|
render_plots = not args.skip_plot_images or args.export_data
|
|
295
320
|
|
|
296
321
|
if args.export_prof:
|
|
@@ -303,6 +328,16 @@ def main(argv: list[str] | None = None):
|
|
|
303
328
|
verbose=False,
|
|
304
329
|
)
|
|
305
330
|
|
|
331
|
+
if args.export_speedscope:
|
|
332
|
+
speedscope_paths = export_speedscope(
|
|
333
|
+
profiling_data=readers,
|
|
334
|
+
filepath=speedscope_path,
|
|
335
|
+
ranks=args.ranks,
|
|
336
|
+
include=args.include,
|
|
337
|
+
exclude=args.exclude,
|
|
338
|
+
verbose=False,
|
|
339
|
+
)
|
|
340
|
+
|
|
306
341
|
if render_plots:
|
|
307
342
|
plot_gantt(
|
|
308
343
|
profiling_data=readers,
|
|
@@ -382,12 +417,18 @@ def main(argv: list[str] | None = None):
|
|
|
382
417
|
durations_data_path,
|
|
383
418
|
speedup_data_path,
|
|
384
419
|
*prof_paths,
|
|
420
|
+
*speedscope_paths,
|
|
385
421
|
)
|
|
386
422
|
if path
|
|
387
423
|
]
|
|
388
424
|
print("Outputs saved to:\n " + "\n ".join(saved))
|
|
389
425
|
if prof_paths:
|
|
390
426
|
print(f"\nView a .prof file with: snakeviz {prof_paths[0]}")
|
|
427
|
+
if speedscope_paths:
|
|
428
|
+
print(
|
|
429
|
+
f"\nView {speedscope_paths[0]} at https://www.speedscope.app "
|
|
430
|
+
"(or: npx speedscope <file>)"
|
|
431
|
+
)
|
|
391
432
|
|
|
392
433
|
|
|
393
434
|
if __name__ == "__main__":
|
|
@@ -6,18 +6,11 @@ from time import perf_counter_ns
|
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
from scope_profiler.metadata import collect_metadata
|
|
9
|
+
from scope_profiler.mpi_launch import get_comm
|
|
9
10
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from mpi4py.MPI import Intercomm
|
|
12
13
|
|
|
13
|
-
try:
|
|
14
|
-
from mpi4py import MPI
|
|
15
|
-
|
|
16
|
-
_MPI_AVAILABLE = True
|
|
17
|
-
except ImportError:
|
|
18
|
-
MPI = None
|
|
19
|
-
_MPI_AVAILABLE = False
|
|
20
|
-
|
|
21
14
|
# try:
|
|
22
15
|
# import pylikwid
|
|
23
16
|
# _PYLIKWID_AVAILABLE = True
|
|
@@ -72,6 +65,7 @@ class ProfilingConfig:
|
|
|
72
65
|
flush_to_disk: bool = True,
|
|
73
66
|
buffer_limit: int = 1024,
|
|
74
67
|
file_path: str = "profiling_data.h5",
|
|
68
|
+
use_mpi: bool | None = None,
|
|
75
69
|
):
|
|
76
70
|
"""Initialize the profiling configuration.
|
|
77
71
|
|
|
@@ -94,6 +88,11 @@ class ProfilingConfig:
|
|
|
94
88
|
The buffers grow on demand, so this is a starting size, not a cap.
|
|
95
89
|
file_path : str
|
|
96
90
|
Global output file path for combined profiling data.
|
|
91
|
+
use_mpi : bool or None
|
|
92
|
+
Whether to use MPI collectives. None (default) enables them only
|
|
93
|
+
when the process was started by an MPI launcher (mpirun/mpiexec/
|
|
94
|
+
srun/...); see :mod:`scope_profiler.mpi_launch`. True forces MPI
|
|
95
|
+
on, False forces it off.
|
|
97
96
|
"""
|
|
98
97
|
|
|
99
98
|
if self._initialized:
|
|
@@ -101,10 +100,10 @@ class ProfilingConfig:
|
|
|
101
100
|
|
|
102
101
|
self._config_creation_time = perf_counter_ns()
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
# Serial runs must not import mpi4py (which would call MPI_Init) nor
|
|
104
|
+
# issue any collective, so the communicator stays None unless this
|
|
105
|
+
# process really is part of an MPI job.
|
|
106
|
+
self._comm = get_comm(use_mpi)
|
|
108
107
|
self._profiling_activated = profiling_activated
|
|
109
108
|
self._use_likwid = use_likwid
|
|
110
109
|
self._use_line_profiler = use_line_profiler
|
|
@@ -485,6 +485,7 @@ class ProfileManager:
|
|
|
485
485
|
flush_to_disk: bool = True,
|
|
486
486
|
buffer_limit: int = 1024,
|
|
487
487
|
file_path: str = "profiling_data.h5",
|
|
488
|
+
use_mpi: bool | None = None,
|
|
488
489
|
):
|
|
489
490
|
"""
|
|
490
491
|
Initialize and configure the profiling system.
|
|
@@ -513,6 +514,12 @@ class ProfileManager:
|
|
|
513
514
|
repeated reallocation.
|
|
514
515
|
file_path : str, optional
|
|
515
516
|
Path to the output profiling data file (default: "profiling_data.h5").
|
|
517
|
+
use_mpi : bool or None, optional
|
|
518
|
+
Whether to use MPI collectives (default: None). None auto-detects:
|
|
519
|
+
MPI is used only when the process was launched by mpirun/mpiexec/
|
|
520
|
+
srun or an equivalent launcher, so a plain ``python script.py``
|
|
521
|
+
run never imports mpi4py or calls into MPI. True forces MPI on,
|
|
522
|
+
False forces it off.
|
|
516
523
|
"""
|
|
517
524
|
ProfilingConfig().reset()
|
|
518
525
|
config = ProfilingConfig(
|
|
@@ -524,6 +531,7 @@ class ProfileManager:
|
|
|
524
531
|
flush_to_disk=flush_to_disk,
|
|
525
532
|
buffer_limit=buffer_limit,
|
|
526
533
|
file_path=file_path,
|
|
534
|
+
use_mpi=use_mpi,
|
|
527
535
|
)
|
|
528
536
|
cls.set_config(config=config)
|
|
529
537
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Export merged HDF5 profiling data to the speedscope JSON format.
|
|
2
|
+
|
|
3
|
+
A speedscope file is a single JSON document holding a table of frames and one
|
|
4
|
+
or more profiles referring to it. The profiles written here are *evented*: each
|
|
5
|
+
call contributes an open (``O``) and a close (``C``) event carrying a timestamp,
|
|
6
|
+
which preserves every individual call rather than the aggregate a ``.prof``
|
|
7
|
+
file keeps. Drop the file on https://www.speedscope.app to view it.
|
|
8
|
+
|
|
9
|
+
Regions carry no call graph of their own, so the caller/callee relations are
|
|
10
|
+
reconstructed from timestamp containment - the same reconstruction the flame
|
|
11
|
+
chart and the ``.prof`` export use
|
|
12
|
+
(:func:`~scope_profiler.plotting_scripts._build_call_stack_intervals`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from scope_profiler.h5reader import ProfilingH5Reader
|
|
22
|
+
from scope_profiler.plotting_scripts import (
|
|
23
|
+
_as_readers,
|
|
24
|
+
_build_call_stack_intervals,
|
|
25
|
+
_normalize_ranks,
|
|
26
|
+
_unique_labels,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SCHEMA_URL = "https://www.speedscope.app/file-format-schema.json"
|
|
30
|
+
|
|
31
|
+
# Timestamps are seconds throughout the reader API, so the profiles say so
|
|
32
|
+
# rather than converting back to the nanoseconds the HDF5 files store.
|
|
33
|
+
TIME_UNIT = "seconds"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _exporter_name() -> str:
|
|
37
|
+
"""Identify this package (and version) as the producer of the file."""
|
|
38
|
+
from scope_profiler import __version__
|
|
39
|
+
|
|
40
|
+
return f"scope-profiler@{__version__}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _nested_intervals(calls: list[dict]) -> list[tuple[float, float]]:
|
|
44
|
+
"""Return each call's interval, tightened to fit inside its parent's.
|
|
45
|
+
|
|
46
|
+
An evented profile is a stack machine: a frame can only close while it is
|
|
47
|
+
on top of the stack, so every call must lie entirely within its parent.
|
|
48
|
+
Reconstruction by containment does not guarantee that - a region that
|
|
49
|
+
starts inside another but ends after it is still recorded as its child -
|
|
50
|
+
so such an interval is clipped to the enclosing one. The clipping is
|
|
51
|
+
visible only where regions genuinely overlap, which no real call stack
|
|
52
|
+
does.
|
|
53
|
+
"""
|
|
54
|
+
intervals: list[tuple[float, float]] = []
|
|
55
|
+
for call in calls:
|
|
56
|
+
start, end = call["start"], call["end"]
|
|
57
|
+
parent = call["parent"]
|
|
58
|
+
if parent is not None:
|
|
59
|
+
# Parents precede their children here, so the bound is final.
|
|
60
|
+
low, high = intervals[parent]
|
|
61
|
+
start = min(max(start, low), high)
|
|
62
|
+
end = min(max(end, start), high)
|
|
63
|
+
intervals.append((start, end))
|
|
64
|
+
return intervals
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_speedscope_profile(
|
|
68
|
+
calls: list[dict],
|
|
69
|
+
name: str,
|
|
70
|
+
frame_indices: dict[str, int],
|
|
71
|
+
frames: list[dict],
|
|
72
|
+
origin: float = 0.0,
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Build one evented speedscope profile from reconstructed calls.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
calls : list[dict]
|
|
79
|
+
Calls as returned by
|
|
80
|
+
:func:`~scope_profiler.plotting_scripts._build_call_stack_intervals`:
|
|
81
|
+
each entry has ``name``, ``start`` and ``end`` in seconds, and
|
|
82
|
+
``parent`` (an index into this list, or ``None`` for a top-level call).
|
|
83
|
+
name : str
|
|
84
|
+
Name of the profile, shown in speedscope's profile selector.
|
|
85
|
+
frame_indices, frames : dict, list
|
|
86
|
+
The document-wide frame table, extended in place: speedscope shares one
|
|
87
|
+
table across all profiles in a file, so profiles of the same run refer
|
|
88
|
+
to the same frames.
|
|
89
|
+
origin : float, optional
|
|
90
|
+
Subtracted from every timestamp. Timestamps come from
|
|
91
|
+
``perf_counter_ns`` and are large in absolute terms; rebasing them on a
|
|
92
|
+
common origin keeps the values readable (and precise) without shifting
|
|
93
|
+
profiles relative to each other.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
dict
|
|
98
|
+
A profile object as described by the speedscope file format schema.
|
|
99
|
+
"""
|
|
100
|
+
intervals = _nested_intervals(calls)
|
|
101
|
+
|
|
102
|
+
children: dict[int | None, list[int]] = {}
|
|
103
|
+
for index, call in enumerate(calls):
|
|
104
|
+
children.setdefault(call["parent"], []).append(index)
|
|
105
|
+
|
|
106
|
+
events = []
|
|
107
|
+
# Depth-first over the reconstructed tree, siblings in start order (which
|
|
108
|
+
# is the order `calls` is already in). Emitting events this way makes them
|
|
109
|
+
# balanced by construction, instead of sorting timestamps and hoping.
|
|
110
|
+
stack: list[tuple[int, bool]] = [
|
|
111
|
+
(index, False) for index in reversed(children.get(None, []))
|
|
112
|
+
]
|
|
113
|
+
while stack:
|
|
114
|
+
index, closing = stack.pop()
|
|
115
|
+
start, end = intervals[index]
|
|
116
|
+
frame_name = calls[index]["name"]
|
|
117
|
+
if closing:
|
|
118
|
+
events.append({"type": "C", "frame": frame_indices[frame_name], "at": end})
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if frame_name not in frame_indices:
|
|
122
|
+
frame_indices[frame_name] = len(frames)
|
|
123
|
+
frames.append({"name": frame_name})
|
|
124
|
+
events.append({"type": "O", "frame": frame_indices[frame_name], "at": start})
|
|
125
|
+
stack.append((index, True))
|
|
126
|
+
stack.extend((child, False) for child in reversed(children.get(index, [])))
|
|
127
|
+
|
|
128
|
+
for event in events:
|
|
129
|
+
event["at"] -= origin
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"type": "evented",
|
|
133
|
+
"name": name,
|
|
134
|
+
"unit": TIME_UNIT,
|
|
135
|
+
"startValue": events[0]["at"] if events else 0.0,
|
|
136
|
+
"endValue": events[-1]["at"] if events else 0.0,
|
|
137
|
+
"events": events,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_speedscope_document(
|
|
142
|
+
named_calls: Sequence[tuple[str, list[dict]]],
|
|
143
|
+
name: str,
|
|
144
|
+
) -> dict:
|
|
145
|
+
"""Build a full speedscope document holding one profile per entry.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
named_calls : sequence of (str, list[dict])
|
|
150
|
+
Profile name and calls for each profile to include, e.g. one per rank.
|
|
151
|
+
name : str
|
|
152
|
+
Name of the document, shown in speedscope's title bar.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
dict
|
|
157
|
+
The document, ready to be serialized as JSON.
|
|
158
|
+
"""
|
|
159
|
+
starts = [
|
|
160
|
+
calls[0]["start"] for _, calls in named_calls if calls
|
|
161
|
+
] # calls are start-ordered
|
|
162
|
+
origin = min(starts) if starts else 0.0
|
|
163
|
+
|
|
164
|
+
frames: list[dict] = []
|
|
165
|
+
frame_indices: dict[str, int] = {}
|
|
166
|
+
profiles = [
|
|
167
|
+
build_speedscope_profile(
|
|
168
|
+
calls,
|
|
169
|
+
profile_name,
|
|
170
|
+
frame_indices=frame_indices,
|
|
171
|
+
frames=frames,
|
|
172
|
+
origin=origin,
|
|
173
|
+
)
|
|
174
|
+
for profile_name, calls in named_calls
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"$schema": SCHEMA_URL,
|
|
179
|
+
"exporter": _exporter_name(),
|
|
180
|
+
"name": name,
|
|
181
|
+
"activeProfileIndex": 0,
|
|
182
|
+
"shared": {"frames": frames},
|
|
183
|
+
"profiles": profiles,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_speedscope_file(filepath: str | Path, document: dict) -> Path:
|
|
188
|
+
"""Write a speedscope document to ``filepath`` as JSON."""
|
|
189
|
+
output_path = Path(filepath)
|
|
190
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
192
|
+
json.dump(document, f)
|
|
193
|
+
return output_path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def export_speedscope(
|
|
197
|
+
profiling_data: ProfilingH5Reader | Sequence[ProfilingH5Reader],
|
|
198
|
+
filepath: str | Path,
|
|
199
|
+
ranks: list[int] | int | None = None,
|
|
200
|
+
include: list[str] | str | None = None,
|
|
201
|
+
exclude: list[str] | str | None = None,
|
|
202
|
+
verbose: bool = True,
|
|
203
|
+
) -> list[Path]:
|
|
204
|
+
"""Write speedscope JSON files for the selected ranks.
|
|
205
|
+
|
|
206
|
+
One file is written per input HDF5 file, holding one profile per rank:
|
|
207
|
+
unlike ``.prof``, the format carries several profiles per file, and
|
|
208
|
+
speedscope switches between them from its profile selector.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
profiling_data : ProfilingH5Reader | Sequence[ProfilingH5Reader]
|
|
213
|
+
Reader(s) for the merged HDF5 file(s) to export.
|
|
214
|
+
filepath : str | Path
|
|
215
|
+
Base output path, e.g. ``figures/profile.speedscope.json``. The input
|
|
216
|
+
file's stem is appended when more than one file is exported.
|
|
217
|
+
ranks : list[int] | int, optional
|
|
218
|
+
Ranks to export (default: rank 0 only).
|
|
219
|
+
include, exclude : list[str] | str, optional
|
|
220
|
+
Region name filters, as for the plotting functions.
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
list[Path]
|
|
225
|
+
The files written, in the order they were written.
|
|
226
|
+
"""
|
|
227
|
+
readers = _as_readers(profiling_data)
|
|
228
|
+
if not readers:
|
|
229
|
+
raise ValueError("No profiling data provided.")
|
|
230
|
+
|
|
231
|
+
normalized_ranks = _normalize_ranks(ranks) if ranks is not None else [0]
|
|
232
|
+
|
|
233
|
+
labels = _unique_labels([reader.file_path.stem for reader in readers])
|
|
234
|
+
|
|
235
|
+
prepared = []
|
|
236
|
+
for label, reader in zip(labels, readers):
|
|
237
|
+
regions = reader.get_regions(include=include, exclude=exclude)
|
|
238
|
+
if not regions:
|
|
239
|
+
raise ValueError("No regions matched the selected filters.")
|
|
240
|
+
named_calls = []
|
|
241
|
+
for rank in normalized_ranks:
|
|
242
|
+
if rank < 0 or rank >= reader.num_ranks:
|
|
243
|
+
raise ValueError(f"Invalid rank requested: {rank}")
|
|
244
|
+
calls = _build_call_stack_intervals(regions, rank)
|
|
245
|
+
if calls:
|
|
246
|
+
named_calls.append((f"rank {rank}", calls))
|
|
247
|
+
if named_calls:
|
|
248
|
+
prepared.append((label, named_calls))
|
|
249
|
+
|
|
250
|
+
if not prepared:
|
|
251
|
+
raise ValueError("No calls recorded for the requested ranks.")
|
|
252
|
+
|
|
253
|
+
base_path = Path(filepath)
|
|
254
|
+
# ".speedscope.json" is the conventional extension, and Path.suffix only
|
|
255
|
+
# sees the ".json" half of it, so the whole tail is kept here.
|
|
256
|
+
stem, dot, extension = base_path.name.partition(".")
|
|
257
|
+
suffix = f".{extension}" if dot else ".speedscope.json"
|
|
258
|
+
multiple_files = len(readers) > 1
|
|
259
|
+
|
|
260
|
+
written = []
|
|
261
|
+
for label, named_calls in prepared:
|
|
262
|
+
parts = [stem]
|
|
263
|
+
if multiple_files:
|
|
264
|
+
parts.append(label)
|
|
265
|
+
out_path = base_path.with_name("_".join(parts) + suffix)
|
|
266
|
+
document = build_speedscope_document(named_calls, name=label)
|
|
267
|
+
written.append(write_speedscope_file(out_path, document))
|
|
268
|
+
if verbose:
|
|
269
|
+
print(f"Wrote {out_path} (view at https://www.speedscope.app)")
|
|
270
|
+
|
|
271
|
+
return written
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Assert that MPI is used if and only if the run was started by a launcher.
|
|
2
|
+
|
|
3
|
+
Run twice from CI with mpi4py installed, so that the two outcomes differ only
|
|
4
|
+
in how the process was started::
|
|
5
|
+
|
|
6
|
+
python3 check_mpi_launch.py serial
|
|
7
|
+
mpirun -n 2 python3 check_mpi_launch.py mpi 2
|
|
8
|
+
|
|
9
|
+
``serial`` asserts that no MPI call happens at all -- in particular that
|
|
10
|
+
``mpi4py.MPI`` is never imported, since importing it already calls
|
|
11
|
+
``MPI_Init``. ``mpi`` asserts that the communicator is picked up and that all
|
|
12
|
+
ranks end up in the merged output.
|
|
13
|
+
|
|
14
|
+
This lives outside the pytest suite because the launcher is the thing under
|
|
15
|
+
test; pytest itself is never started by mpirun.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import h5py
|
|
21
|
+
|
|
22
|
+
from scope_profiler import ProfileManager
|
|
23
|
+
|
|
24
|
+
OUTPUT_FILE = "mpi_launch_check.h5"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check(mode: str, expected_size: int) -> None:
|
|
28
|
+
"""Profile a trivial region and verify the MPI behaviour for ``mode``."""
|
|
29
|
+
ProfileManager.setup(time_trace=True, flush_to_disk=True, file_path=OUTPUT_FILE)
|
|
30
|
+
config = ProfileManager.get_config()
|
|
31
|
+
|
|
32
|
+
with ProfileManager.profile_region("check"):
|
|
33
|
+
sum(range(1000))
|
|
34
|
+
|
|
35
|
+
if mode == "serial":
|
|
36
|
+
assert "mpi4py.MPI" not in sys.modules, (
|
|
37
|
+
"mpi4py.MPI was imported in a run that was not started by an MPI "
|
|
38
|
+
"launcher; importing it calls MPI_Init."
|
|
39
|
+
)
|
|
40
|
+
assert config.comm is None, f"expected no communicator, got {config.comm!r}"
|
|
41
|
+
else:
|
|
42
|
+
assert (
|
|
43
|
+
config.comm is not None
|
|
44
|
+
), "no communicator, although the run was started by an MPI launcher"
|
|
45
|
+
|
|
46
|
+
assert (
|
|
47
|
+
config._size == expected_size
|
|
48
|
+
), f"expected {expected_size} rank(s), got {config._size}"
|
|
49
|
+
|
|
50
|
+
ProfileManager.finalize(verbose=False)
|
|
51
|
+
|
|
52
|
+
if config._rank == 0:
|
|
53
|
+
with h5py.File(OUTPUT_FILE, "r") as f:
|
|
54
|
+
ranks = sorted(key for key in f if key.startswith("rank"))
|
|
55
|
+
expected_ranks = [f"rank{r}" for r in range(expected_size)]
|
|
56
|
+
assert ranks == expected_ranks, f"expected {expected_ranks}, got {ranks}"
|
|
57
|
+
|
|
58
|
+
print(f"[{mode}] OK: rank {config._rank} of {config._size}, comm={config.comm}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
mode = sys.argv[1] if len(sys.argv) > 1 else "serial"
|
|
63
|
+
if mode not in ("serial", "mpi"):
|
|
64
|
+
sys.exit(f"usage: {sys.argv[0]} [serial|mpi] [expected_size]")
|
|
65
|
+
size = int(sys.argv[2]) if len(sys.argv) > 2 else 1
|
|
66
|
+
check(mode, size)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from scope_profiler.h5reader import ProfilingH5Reader
|
|
6
|
+
from scope_profiler.post_processing import main
|
|
7
|
+
from scope_profiler.speedscope_export import (
|
|
8
|
+
build_speedscope_document,
|
|
9
|
+
export_speedscope,
|
|
10
|
+
)
|
|
11
|
+
from scope_profiler.tests.test_post_processing import _write_sample_h5
|
|
12
|
+
|
|
13
|
+
MS = 1_000_000 # nanoseconds per millisecond, the unit stored in the HDF5 files
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _nested_file_data():
|
|
17
|
+
"""One rank whose regions nest: main > (setup, solve > assemble)."""
|
|
18
|
+
return {
|
|
19
|
+
0: {
|
|
20
|
+
"main": ([0], [100 * MS]),
|
|
21
|
+
"setup": ([0], [20 * MS]),
|
|
22
|
+
"solve": ([20 * MS], [90 * MS]),
|
|
23
|
+
"assemble": ([30 * MS], [60 * MS]),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _calls(*specs):
|
|
29
|
+
"""Build the call list the document builder expects, in seconds."""
|
|
30
|
+
return [
|
|
31
|
+
{"name": name, "start": start, "end": end, "parent": parent}
|
|
32
|
+
for name, start, end, parent in specs
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load(path):
|
|
37
|
+
with open(path, encoding="utf-8") as f:
|
|
38
|
+
return json.load(f)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _replay(profile, frames):
|
|
42
|
+
"""Replay an evented profile, returning (frame name, start, end) per call.
|
|
43
|
+
|
|
44
|
+
Raises if the events are not a balanced, correctly ordered stack machine —
|
|
45
|
+
which is what speedscope requires and refuses to import without.
|
|
46
|
+
"""
|
|
47
|
+
stack = []
|
|
48
|
+
closed = []
|
|
49
|
+
last_at = float("-inf")
|
|
50
|
+
for event in profile["events"]:
|
|
51
|
+
assert event["at"] >= last_at, "events must be ordered by timestamp"
|
|
52
|
+
last_at = event["at"]
|
|
53
|
+
if event["type"] == "O":
|
|
54
|
+
stack.append((frames[event["frame"]]["name"], event["at"]))
|
|
55
|
+
else:
|
|
56
|
+
assert stack, "close event with nothing open"
|
|
57
|
+
name, start = stack.pop()
|
|
58
|
+
assert (
|
|
59
|
+
frames[event["frame"]]["name"] == name
|
|
60
|
+
), "closed a frame that was not on top of the stack"
|
|
61
|
+
closed.append((name, start, event["at"]))
|
|
62
|
+
assert not stack, "profile ended with frames still open"
|
|
63
|
+
return closed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_document_events_replay_as_a_call_stack():
|
|
67
|
+
calls = _calls(
|
|
68
|
+
("main", 10.0, 11.0, None),
|
|
69
|
+
("setup", 10.0, 10.2, 0),
|
|
70
|
+
("solve", 10.2, 10.9, 0),
|
|
71
|
+
("assemble", 10.3, 10.6, 2),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
document = build_speedscope_document([("rank 0", calls)], name="run")
|
|
75
|
+
(profile,) = document["profiles"]
|
|
76
|
+
|
|
77
|
+
# Timestamps are rebased on the first call, so the profile starts at zero.
|
|
78
|
+
assert profile["startValue"] == pytest.approx(0.0)
|
|
79
|
+
assert profile["endValue"] == pytest.approx(1.0)
|
|
80
|
+
assert profile["unit"] == "seconds"
|
|
81
|
+
assert profile["type"] == "evented"
|
|
82
|
+
|
|
83
|
+
replayed = _replay(profile, document["shared"]["frames"])
|
|
84
|
+
assert replayed == [
|
|
85
|
+
("setup", pytest.approx(0.0), pytest.approx(0.2)),
|
|
86
|
+
("assemble", pytest.approx(0.3), pytest.approx(0.6)),
|
|
87
|
+
("solve", pytest.approx(0.2), pytest.approx(0.9)),
|
|
88
|
+
("main", pytest.approx(0.0), pytest.approx(1.0)),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_document_clips_partial_overlap():
|
|
93
|
+
# "long" is reconstructed as a child of "short" because it starts inside
|
|
94
|
+
# it, even though it runs past its end: an evented profile cannot express
|
|
95
|
+
# that, so the child is clipped to its parent.
|
|
96
|
+
calls = _calls(("short", 0.0, 0.5, None), ("long", 0.1, 2.0, 0))
|
|
97
|
+
|
|
98
|
+
document = build_speedscope_document([("rank 0", calls)], name="run")
|
|
99
|
+
replayed = _replay(document["profiles"][0], document["shared"]["frames"])
|
|
100
|
+
|
|
101
|
+
assert replayed == [
|
|
102
|
+
("long", pytest.approx(0.1), pytest.approx(0.5)),
|
|
103
|
+
("short", pytest.approx(0.0), pytest.approx(0.5)),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_document_handles_recursion():
|
|
108
|
+
calls = _calls(("recurse", 0.0, 1.0, None), ("recurse", 0.2, 0.6, 0))
|
|
109
|
+
|
|
110
|
+
document = build_speedscope_document([("rank 0", calls)], name="run")
|
|
111
|
+
|
|
112
|
+
# The frame table is keyed by name, so recursion reuses one frame.
|
|
113
|
+
assert document["shared"]["frames"] == [{"name": "recurse"}]
|
|
114
|
+
assert len(_replay(document["profiles"][0], document["shared"]["frames"])) == 2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_export_writes_one_profile_per_rank(tmp_path):
|
|
118
|
+
h5_file = tmp_path / "profiling_data.h5"
|
|
119
|
+
_write_sample_h5(h5_file, {rank: _nested_file_data()[0] for rank in (0, 1)})
|
|
120
|
+
|
|
121
|
+
written = export_speedscope(
|
|
122
|
+
ProfilingH5Reader(h5_file),
|
|
123
|
+
tmp_path / "profile.speedscope.json",
|
|
124
|
+
ranks=[0, 1],
|
|
125
|
+
verbose=False,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert written == [tmp_path / "profile.speedscope.json"]
|
|
129
|
+
|
|
130
|
+
document = _load(written[0])
|
|
131
|
+
assert document["$schema"] == "https://www.speedscope.app/file-format-schema.json"
|
|
132
|
+
assert document["exporter"].startswith("scope-profiler@")
|
|
133
|
+
assert document["activeProfileIndex"] == 0
|
|
134
|
+
assert [profile["name"] for profile in document["profiles"]] == ["rank 0", "rank 1"]
|
|
135
|
+
assert {frame["name"] for frame in document["shared"]["frames"]} == {
|
|
136
|
+
"main",
|
|
137
|
+
"setup",
|
|
138
|
+
"solve",
|
|
139
|
+
"assemble",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for profile in document["profiles"]:
|
|
143
|
+
replayed = _replay(profile, document["shared"]["frames"])
|
|
144
|
+
assert len(replayed) == 4
|
|
145
|
+
main_call = next(call for call in replayed if call[0] == "main")
|
|
146
|
+
assert main_call[2] - main_call[1] == pytest.approx(0.1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_export_defaults_to_rank_zero_and_splits_per_file(tmp_path):
|
|
150
|
+
file_one = tmp_path / "run_one.h5"
|
|
151
|
+
file_two = tmp_path / "run_two.h5"
|
|
152
|
+
_write_sample_h5(file_one, {rank: _nested_file_data()[0] for rank in (0, 1)})
|
|
153
|
+
_write_sample_h5(file_two, {rank: _nested_file_data()[0] for rank in (0, 1)})
|
|
154
|
+
|
|
155
|
+
readers = [ProfilingH5Reader(file_one), ProfilingH5Reader(file_two)]
|
|
156
|
+
written = export_speedscope(
|
|
157
|
+
readers, tmp_path / "profile.speedscope.json", verbose=False
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert [path.name for path in written] == [
|
|
161
|
+
"profile_run_one.speedscope.json",
|
|
162
|
+
"profile_run_two.speedscope.json",
|
|
163
|
+
]
|
|
164
|
+
for path in written:
|
|
165
|
+
document = _load(path)
|
|
166
|
+
assert [profile["name"] for profile in document["profiles"]] == ["rank 0"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_export_rejects_unknown_rank(tmp_path):
|
|
170
|
+
h5_file = tmp_path / "profiling_data.h5"
|
|
171
|
+
_write_sample_h5(h5_file, _nested_file_data())
|
|
172
|
+
|
|
173
|
+
with pytest.raises(ValueError, match="Invalid rank"):
|
|
174
|
+
export_speedscope(
|
|
175
|
+
ProfilingH5Reader(h5_file),
|
|
176
|
+
tmp_path / "profile.speedscope.json",
|
|
177
|
+
ranks=[3],
|
|
178
|
+
verbose=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_cli_export_speedscope_without_plots(tmp_path, capsys):
|
|
183
|
+
h5_file = tmp_path / "profiling_data.h5"
|
|
184
|
+
_write_sample_h5(h5_file, _nested_file_data())
|
|
185
|
+
out_dir = tmp_path / "figures"
|
|
186
|
+
|
|
187
|
+
main(
|
|
188
|
+
[
|
|
189
|
+
str(h5_file),
|
|
190
|
+
"-o",
|
|
191
|
+
str(out_dir),
|
|
192
|
+
"--export-speedscope",
|
|
193
|
+
"--skip-plot-images",
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
speedscope_file = out_dir / "profile.speedscope.json"
|
|
198
|
+
assert speedscope_file.exists()
|
|
199
|
+
assert not list(out_dir.glob("*.png"))
|
|
200
|
+
assert "speedscope.app" in capsys.readouterr().out
|
|
201
|
+
|
|
202
|
+
document = _load(speedscope_file)
|
|
203
|
+
assert {frame["name"] for frame in document["shared"]["frames"]} == {
|
|
204
|
+
"main",
|
|
205
|
+
"setup",
|
|
206
|
+
"solve",
|
|
207
|
+
"assemble",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_cli_export_speedscope_alongside_plots(tmp_path):
|
|
212
|
+
h5_file = tmp_path / "profiling_data.h5"
|
|
213
|
+
_write_sample_h5(h5_file, _nested_file_data())
|
|
214
|
+
out_dir = tmp_path / "figures"
|
|
215
|
+
|
|
216
|
+
main([str(h5_file), "-o", str(out_dir), "--export-speedscope", "--ranks", "0"])
|
|
217
|
+
|
|
218
|
+
assert (out_dir / "profile.speedscope.json").exists()
|
|
219
|
+
assert (out_dir / "flame_plot.png").exists()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cli_export_speedscope_requires_output(tmp_path):
|
|
223
|
+
h5_file = tmp_path / "profiling_data.h5"
|
|
224
|
+
_write_sample_h5(h5_file, _nested_file_data())
|
|
225
|
+
|
|
226
|
+
with pytest.raises(SystemExit):
|
|
227
|
+
main([str(h5_file), "--export-speedscope"])
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for MPI-launcher detection (``scope_profiler.mpi_launch``)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from scope_profiler.mpi_launch import (
|
|
8
|
+
_LAUNCHER_ENV_VARS,
|
|
9
|
+
_OVERRIDE_ENV_VAR,
|
|
10
|
+
get_comm,
|
|
11
|
+
launched_under_mpi,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def clean_env(monkeypatch):
|
|
17
|
+
"""An environment with no launcher variables and no override."""
|
|
18
|
+
for var in _LAUNCHER_ENV_VARS + (_OVERRIDE_ENV_VAR,):
|
|
19
|
+
monkeypatch.delenv(var, raising=False)
|
|
20
|
+
# A previous test (or the application) may have imported mpi4py; hide it
|
|
21
|
+
# so detection sees a genuinely serial process.
|
|
22
|
+
monkeypatch.delitem(sys.modules, "mpi4py.MPI", raising=False)
|
|
23
|
+
return monkeypatch
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_serial_run_is_not_mpi(clean_env):
|
|
27
|
+
assert launched_under_mpi() is False
|
|
28
|
+
assert get_comm() is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("var", _LAUNCHER_ENV_VARS)
|
|
32
|
+
def test_launcher_variables_are_detected(clean_env, var):
|
|
33
|
+
clean_env.setenv(var, "0")
|
|
34
|
+
assert launched_under_mpi() is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_override_forces_mpi_on(clean_env):
|
|
38
|
+
clean_env.setenv(_OVERRIDE_ENV_VAR, "1")
|
|
39
|
+
assert launched_under_mpi() is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_override_forces_mpi_off(clean_env):
|
|
43
|
+
clean_env.setenv("OMPI_COMM_WORLD_RANK", "0")
|
|
44
|
+
clean_env.setenv(_OVERRIDE_ENV_VAR, "false")
|
|
45
|
+
assert launched_under_mpi() is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_unrecognized_override_falls_back_to_detection(clean_env):
|
|
49
|
+
clean_env.setenv(_OVERRIDE_ENV_VAR, "maybe")
|
|
50
|
+
assert launched_under_mpi() is False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_already_initialized_mpi_is_used(clean_env):
|
|
54
|
+
class FakeMPI:
|
|
55
|
+
@staticmethod
|
|
56
|
+
def Is_initialized():
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
clean_env.setitem(sys.modules, "mpi4py.MPI", FakeMPI)
|
|
60
|
+
assert launched_under_mpi() is True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_imported_but_uninitialized_mpi_is_not_used(clean_env):
|
|
64
|
+
class FakeMPI:
|
|
65
|
+
@staticmethod
|
|
66
|
+
def Is_initialized():
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
clean_env.setitem(sys.modules, "mpi4py.MPI", FakeMPI)
|
|
70
|
+
assert launched_under_mpi() is False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_use_mpi_false_never_imports_mpi4py(clean_env):
|
|
74
|
+
clean_env.setenv("OMPI_COMM_WORLD_RANK", "0")
|
|
75
|
+
assert get_comm(use_mpi=False) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_serial_setup_does_not_import_mpi4py(clean_env, tmp_path):
|
|
79
|
+
"""A serial ProfileManager.setup() must leave mpi4py unimported."""
|
|
80
|
+
from scope_profiler import ProfileManager
|
|
81
|
+
|
|
82
|
+
clean_env.delitem(sys.modules, "mpi4py", raising=False)
|
|
83
|
+
|
|
84
|
+
ProfileManager.setup(file_path=str(tmp_path / "profiling_data.h5"))
|
|
85
|
+
config = ProfileManager.get_config()
|
|
86
|
+
|
|
87
|
+
assert config.comm is None
|
|
88
|
+
assert config._rank == 0
|
|
89
|
+
assert config._size == 1
|
|
90
|
+
assert "mpi4py.MPI" not in sys.modules
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scope-profiler
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Profile code regions in python, optionally with LIKWID markers.
|
|
5
5
|
Author: Max
|
|
6
6
|
Project-URL: Source, https://github.com/max-models/scope-profiler
|
|
@@ -445,3 +445,18 @@ Regions become "functions": `cumtime` is a region's total wall time and
|
|
|
445
445
|
`tottime` is that minus the time spent in its nested regions. One file is
|
|
446
446
|
written per exported rank, since `.prof` has no notion of ranks — see
|
|
447
447
|
[the CLI docs](docs/source/cli.md) for the caveats of the reconstruction.
|
|
448
|
+
|
|
449
|
+
### Viewing a run in speedscope
|
|
450
|
+
|
|
451
|
+
`--export-speedscope` writes the run as a
|
|
452
|
+
[speedscope](https://www.speedscope.app) JSON file. Unlike `.prof`, it keeps
|
|
453
|
+
every individual call, so the timeline shows the run as it happened:
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
scope-profiler pproc profiling_data.h5 -o figures --export-speedscope --skip-plot-images
|
|
457
|
+
npx speedscope figures/profile.speedscope.json # or drop the file on speedscope.app
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
One file is written per input, holding one profile per exported rank, all
|
|
461
|
+
sharing a time origin so ranks stay aligned. See
|
|
462
|
+
[the CLI docs](docs/source/cli.md) for details.
|
|
@@ -5,6 +5,7 @@ src/scope_profiler/__main__.py
|
|
|
5
5
|
src/scope_profiler/h5reader.py
|
|
6
6
|
src/scope_profiler/inspection.py
|
|
7
7
|
src/scope_profiler/metadata.py
|
|
8
|
+
src/scope_profiler/mpi_launch.py
|
|
8
9
|
src/scope_profiler/mpi_region.py
|
|
9
10
|
src/scope_profiler/plotting_scripts.py
|
|
10
11
|
src/scope_profiler/post_processing.py
|
|
@@ -13,6 +14,7 @@ src/scope_profiler/profile_config.py
|
|
|
13
14
|
src/scope_profiler/profile_manager.py
|
|
14
15
|
src/scope_profiler/region.py
|
|
15
16
|
src/scope_profiler/region_profiler.py
|
|
17
|
+
src/scope_profiler/speedscope_export.py
|
|
16
18
|
src/scope_profiler/summary.py
|
|
17
19
|
src/scope_profiler.egg-info/PKG-INFO
|
|
18
20
|
src/scope_profiler.egg-info/SOURCES.txt
|
|
@@ -21,6 +23,7 @@ src/scope_profiler.egg-info/entry_points.txt
|
|
|
21
23
|
src/scope_profiler.egg-info/requires.txt
|
|
22
24
|
src/scope_profiler.egg-info/top_level.txt
|
|
23
25
|
src/scope_profiler/tests/__init__.py
|
|
26
|
+
src/scope_profiler/tests/check_mpi_launch.py
|
|
24
27
|
src/scope_profiler/tests/examples.py
|
|
25
28
|
src/scope_profiler/tests/examples_pylikwid.py
|
|
26
29
|
src/scope_profiler/tests/pylikwid_readme.py
|
|
@@ -34,4 +37,7 @@ src/scope_profiler/tests/test_post_processing.py
|
|
|
34
37
|
src/scope_profiler/tests/test_prof_export.py
|
|
35
38
|
src/scope_profiler/tests/test_reader_api.py
|
|
36
39
|
src/scope_profiler/tests/test_readme.py
|
|
37
|
-
src/scope_profiler/tests/test_repeated_finalize.py
|
|
40
|
+
src/scope_profiler/tests/test_repeated_finalize.py
|
|
41
|
+
src/scope_profiler/tests/test_speedscope_export.py
|
|
42
|
+
src/scope_profiler/tests/unit/__init__.py
|
|
43
|
+
src/scope_profiler/tests/unit/test_mpi_launch.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_buffer_growth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_post_processing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_repeated_finalize.py
RENAMED
|
File without changes
|
{scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|