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.
Files changed (45) hide show
  1. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/PKG-INFO +16 -1
  2. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/README.md +15 -0
  3. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/pyproject.toml +1 -1
  4. scope_profiler-0.2.4/src/scope_profiler/mpi_launch.py +119 -0
  5. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/post_processing.py +48 -7
  6. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/profile_config.py +11 -12
  7. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/profile_manager.py +8 -0
  8. scope_profiler-0.2.4/src/scope_profiler/speedscope_export.py +271 -0
  9. scope_profiler-0.2.4/src/scope_profiler/tests/check_mpi_launch.py +66 -0
  10. scope_profiler-0.2.4/src/scope_profiler/tests/test_speedscope_export.py +227 -0
  11. scope_profiler-0.2.4/src/scope_profiler/tests/unit/__init__.py +0 -0
  12. scope_profiler-0.2.4/src/scope_profiler/tests/unit/test_mpi_launch.py +90 -0
  13. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/PKG-INFO +16 -1
  14. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/SOURCES.txt +7 -1
  15. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/setup.cfg +0 -0
  16. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/__init__.py +0 -0
  17. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/__main__.py +0 -0
  18. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/h5reader.py +0 -0
  19. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/inspection.py +0 -0
  20. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/metadata.py +0 -0
  21. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/mpi_region.py +0 -0
  22. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/plotting_scripts.py +0 -0
  23. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/prof_export.py +0 -0
  24. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/region.py +0 -0
  25. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/region_profiler.py +0 -0
  26. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/summary.py +0 -0
  27. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/__init__.py +0 -0
  28. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/examples.py +0 -0
  29. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/examples_pylikwid.py +0 -0
  30. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/pylikwid_readme.py +0 -0
  31. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_app.py +0 -0
  32. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_buffer_growth.py +0 -0
  33. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_inspection.py +0 -0
  34. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_metadata.py +0 -0
  35. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_mpi.py +0 -0
  36. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_overhead.py +0 -0
  37. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_post_processing.py +0 -0
  38. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_prof_export.py +0 -0
  39. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_reader_api.py +0 -0
  40. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_readme.py +0 -0
  41. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler/tests/test_repeated_finalize.py +0 -0
  42. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/dependency_links.txt +0 -0
  43. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/entry_points.txt +0 -0
  44. {scope_profiler-0.2.3 → scope_profiler-0.2.4}/src/scope_profiler.egg-info/requires.txt +0 -0
  45. {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
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.
@@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ]
5
5
 
6
6
  [project]
7
7
  name = "scope-profiler"
8
- version = "0.2.3"
8
+ version = "0.2.4"
9
9
  description = "Profile code regions in python, optionally with LIKWID markers."
10
10
  readme = "README.md"
11
11
  keywords = [ "python" ]
@@ -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 charts are rendered "
177
- "entirely client-side (e.g. with Plotly) from the exported data. "
178
- "Requires --export-data or --export-prof."
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.skip_plot_images and not (args.export_data or args.export_prof):
223
- parser.error("--skip-plot-images requires --export-data or --export-prof.")
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 a prof-only run should not touch the plotting
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
- if _MPI_AVAILABLE:
105
- self._comm = MPI.COMM_WORLD
106
- else:
107
- self._comm = None
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"])
@@ -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
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