scope-profiler 0.2.4__tar.gz → 0.2.6__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.4 → scope_profiler-0.2.6}/PKG-INFO +1 -1
  2. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/pyproject.toml +1 -1
  3. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/plotting_scripts.py +277 -65
  4. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/post_processing.py +107 -52
  5. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_post_processing.py +162 -9
  6. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler.egg-info/PKG-INFO +1 -1
  7. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/README.md +0 -0
  8. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/setup.cfg +0 -0
  9. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/__init__.py +0 -0
  10. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/__main__.py +0 -0
  11. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/h5reader.py +0 -0
  12. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/inspection.py +0 -0
  13. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/metadata.py +0 -0
  14. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/mpi_launch.py +0 -0
  15. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/mpi_region.py +0 -0
  16. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/prof_export.py +0 -0
  17. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/profile_config.py +0 -0
  18. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/profile_manager.py +0 -0
  19. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/region.py +0 -0
  20. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/region_profiler.py +0 -0
  21. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/speedscope_export.py +0 -0
  22. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/summary.py +0 -0
  23. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/__init__.py +0 -0
  24. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/check_mpi_launch.py +0 -0
  25. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/examples.py +0 -0
  26. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/examples_pylikwid.py +0 -0
  27. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/pylikwid_readme.py +0 -0
  28. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_app.py +0 -0
  29. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_buffer_growth.py +0 -0
  30. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_inspection.py +0 -0
  31. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_metadata.py +0 -0
  32. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_mpi.py +0 -0
  33. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_overhead.py +0 -0
  34. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_prof_export.py +0 -0
  35. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_reader_api.py +0 -0
  36. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_readme.py +0 -0
  37. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_repeated_finalize.py +0 -0
  38. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/test_speedscope_export.py +0 -0
  39. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/unit/__init__.py +0 -0
  40. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler/tests/unit/test_mpi_launch.py +0 -0
  41. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler.egg-info/SOURCES.txt +0 -0
  42. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler.egg-info/dependency_links.txt +0 -0
  43. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler.egg-info/entry_points.txt +0 -0
  44. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/src/scope_profiler.egg-info/requires.txt +0 -0
  45. {scope_profiler-0.2.4 → scope_profiler-0.2.6}/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.4
3
+ Version: 0.2.6
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
@@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ]
5
5
 
6
6
  [project]
7
7
  name = "scope-profiler"
8
- version = "0.2.4"
8
+ version = "0.2.6"
9
9
  description = "Profile code regions in python, optionally with LIKWID markers."
10
10
  readme = "README.md"
11
11
  keywords = [ "python" ]
@@ -99,32 +99,44 @@ def _get_cmap_colors(cmap: str, n_colors: int) -> list[str]:
99
99
  def _add_gantt_bars(
100
100
  canvas,
101
101
  row: int | None,
102
- tasks: Sequence[str],
103
- starts: Sequence[float],
104
- durations: Sequence[float],
105
- colors: Sequence[str],
102
+ lanes: Sequence[str],
103
+ bars: Sequence[tuple[int, float, float, str]],
106
104
  alpha: float = 0.7,
107
105
  ) -> None:
108
- """Draw ``Canvas.gantt`` bars, one call per color.
109
-
110
- ``Canvas.gantt`` takes a single color per call: maxplotlib's Plotly
111
- backend reads ``color`` as one RGB(A) value, so passing a per-bar list
112
- raises. One call is therefore issued per distinct color, with the bars
113
- belonging to the other colors passed as NaN. NaN bars draw nothing on
114
- either backend while still occupying their row, which keeps every call
115
- on the row ``Canvas.gantt`` assigns it (``y = arange(len(tasks))``).
106
+ """Draw ``Canvas.gantt`` bars, every call of a lane on that lane's row.
107
+
108
+ ``Canvas.gantt`` puts bar *i* of a call on row *i* (``y =
109
+ arange(len(tasks))``, on both backends) and takes a single color for the
110
+ whole call, since maxplotlib's Plotly backend reads ``color`` as one
111
+ RGB(A) value. Each drawing call therefore passes one entry per lane --- so
112
+ the entry index is the lane's row --- and only fills in the bars sharing
113
+ one color; the rest are NaN, which draws nothing on either backend.
114
+
115
+ Lanes hold several calls, so bars are grouped by their position within the
116
+ lane as well: the *k*-th call of every lane goes into the same group,
117
+ leaving at most one bar per lane per drawing call.
116
118
  """
117
119
  col = None if row is None else 0
118
- starts = np.asarray(starts, dtype=float)
119
- durations = np.asarray(durations, dtype=float)
120
- colors = np.asarray(colors, dtype=object)
121
-
122
- for color in dict.fromkeys(colors.tolist()):
123
- mask = colors == color
120
+ n_lanes = len(lanes)
121
+
122
+ # (color, index within the lane) -> the bars drawn together.
123
+ groups: dict[tuple[str, int], list[tuple[int, float, float]]] = defaultdict(list)
124
+ calls_per_lane: dict[int, int] = defaultdict(int)
125
+ for lane, start, duration, color in bars:
126
+ slot = calls_per_lane[lane]
127
+ calls_per_lane[lane] += 1
128
+ groups[(color, slot)].append((lane, start, duration))
129
+
130
+ for (color, _), group in groups.items():
131
+ starts = np.full(n_lanes, np.nan)
132
+ durations = np.full(n_lanes, np.nan)
133
+ for lane, start, duration in group:
134
+ starts[lane] = start
135
+ durations[lane] = duration
124
136
  canvas.gantt(
125
- list(tasks),
126
- np.where(mask, starts, np.nan),
127
- np.where(mask, durations, np.nan),
137
+ list(lanes),
138
+ starts,
139
+ durations,
128
140
  row=row,
129
141
  col=col,
130
142
  color=color,
@@ -169,19 +181,6 @@ def _render(
169
181
  _close_matplotlib_figure(canvas)
170
182
 
171
183
 
172
- def _block_ticks(tasks: Sequence[str]) -> tuple[list[float], list[str]]:
173
- """Return one tick per run of identical task names, at the run's centre."""
174
- ticks: list[float] = []
175
- labels: list[str] = []
176
- start = 0
177
- for index in range(1, len(tasks) + 1):
178
- if index == len(tasks) or tasks[index] != tasks[start]:
179
- ticks.append((start + index - 1) / 2)
180
- labels.append(tasks[start])
181
- start = index
182
- return ticks, labels
183
-
184
-
185
184
  def _panel_gridspec(
186
185
  fig_width: float, fig_height: float, label_chars: int, multi_panel: bool
187
186
  ) -> dict:
@@ -575,30 +574,43 @@ def plot_gantt(
575
574
  else:
576
575
  print(f"Plotting combined Gantt chart for files: {', '.join(labels)}")
577
576
 
578
- # Canvas.gantt gives every bar its own row (y = arange(len(tasks))), so a
579
- # panel is as tall as the number of calls it draws, and the rows of one
580
- # region form a contiguous block labelled at its centre.
577
+ # One lane per (region, rank): every call of a region lands on the same
578
+ # row, so a panel is as tall as the number of lanes it draws.
581
579
  single_panel = len(prepared) == 1
582
- panel_bars = [
583
- [
584
- (
585
- f"{region.name} (rank {rank})",
586
- float(start - first_start_time),
587
- float(end - start),
588
- _to_hex(region.color),
589
- )
590
- for region in regions
591
- for rank in selected_ranks
592
- for start, end in zip(region[rank].start_times, region[rank].end_times)
593
- ]
594
- for _, regions, selected_ranks, first_start_time in prepared
595
- ]
580
+ panel_lanes: list[list[str]] = []
581
+ panel_bars: list[list[tuple[int, float, float, str]]] = []
582
+ for _, regions, selected_ranks, first_start_time in prepared:
583
+ lanes: list[str] = []
584
+ bars: list[tuple[int, float, float, str]] = []
585
+ for region in regions:
586
+ for rank in selected_ranks:
587
+ # A region need not have been entered on every rank.
588
+ if rank not in region:
589
+ continue
590
+ region_data = region[rank]
591
+ if not len(region_data.start_times):
592
+ continue
593
+ lane = len(lanes)
594
+ lanes.append(f"{region.name} (rank {rank})")
595
+ color = _to_hex(region.color)
596
+ for start, end in zip(region_data.start_times, region_data.end_times):
597
+ bars.append(
598
+ (
599
+ lane,
600
+ float(start - first_start_time),
601
+ float(end - start),
602
+ color,
603
+ )
604
+ )
605
+ panel_lanes.append(lanes)
606
+ panel_bars.append(bars)
607
+
596
608
  if not any(panel_bars):
597
609
  raise ValueError("No calls recorded for the requested ranks.")
598
610
 
599
- panel_heights = [max(2.5, 0.22 * len(bars)) for bars in panel_bars]
611
+ panel_heights = [max(2.5, 0.35 * len(lanes)) for lanes in panel_lanes]
600
612
  fig_width, fig_height = 12.0, 1.0 + sum(panel_heights)
601
- lane_label_chars = max(len(bar[0]) for bars in panel_bars for bar in bars)
613
+ lane_label_chars = max(len(lane) for lanes in panel_lanes for lane in lanes)
602
614
  canvas = Canvas(
603
615
  nrows=len(prepared),
604
616
  ncols=1,
@@ -608,27 +620,20 @@ def plot_gantt(
608
620
  ),
609
621
  )
610
622
 
611
- for idx, (label, bars) in enumerate(zip(labels, panel_bars)):
623
+ for idx, (label, lanes, bars) in enumerate(zip(labels, panel_lanes, panel_bars)):
612
624
  row = None if single_panel else idx
613
625
  col = None if single_panel else 0
614
626
 
615
- tasks = [bar[0] for bar in bars]
616
- starts = [bar[1] for bar in bars]
617
- durations = [bar[2] for bar in bars]
618
- colors = [bar[3] for bar in bars]
619
- _add_gantt_bars(canvas, row, tasks, starts, durations, colors)
627
+ _add_gantt_bars(canvas, row, lanes, bars)
620
628
 
621
- # Label each region's block of rows once, at its centre, instead of
622
- # repeating the region name on every call's row.
623
- yticks, yticklabels = _block_ticks(tasks)
624
- canvas.set_yticks(yticks, labels=yticklabels, row=row, col=col)
629
+ canvas.set_yticks(list(range(len(lanes))), labels=lanes, row=row, col=col)
625
630
  canvas.set_xlim(
626
631
  0,
627
- max(start + duration for start, duration in zip(starts, durations)),
632
+ max(start + duration for _, start, duration, _ in bars),
628
633
  row=row,
629
634
  col=col,
630
635
  )
631
- canvas.set_ylim(-0.6, len(tasks) - 0.4, row=row, col=col)
636
+ canvas.set_ylim(-0.6, len(lanes) - 0.4, row=row, col=col)
632
637
  canvas.set_xlabel("Time (seconds)", row=row, col=col)
633
638
  canvas.set_title(
634
639
  "Profiling Gantt Chart" if single_panel else label, row=row, col=col
@@ -1005,6 +1010,213 @@ def plot_durations(
1005
1010
  return saved_paths
1006
1011
 
1007
1012
 
1013
+ def _duration_timeseries(
1014
+ region,
1015
+ ranks: list[int] | None,
1016
+ first_start_time: float,
1017
+ ) -> dict[str, np.ndarray] | None:
1018
+ """Aggregate one region's calls over ranks into a duration-versus-time series.
1019
+
1020
+ Calls are matched across ranks by call index, so point ``i`` describes the
1021
+ i-th call of the region on every rank that got that far. Ranks that stopped
1022
+ calling the region earlier simply drop out of the later points, which keeps
1023
+ ragged call counts (e.g. a rank-dependent number of iterations) usable.
1024
+
1025
+ Returns ``None`` if no selected rank recorded any call.
1026
+ """
1027
+ if ranks is None:
1028
+ selected_ranks = sorted(region.regions)
1029
+ else:
1030
+ selected_ranks = [rank for rank in ranks if rank in region.regions]
1031
+
1032
+ per_rank = [
1033
+ (rank, region.regions[rank])
1034
+ for rank in selected_ranks
1035
+ if region.regions[rank].durations.size
1036
+ ]
1037
+ if not per_rank:
1038
+ return None
1039
+
1040
+ max_calls = max(len(region_data.durations) for _, region_data in per_rank)
1041
+ times, means, minima, maxima, counts = [], [], [], [], []
1042
+ for index in range(max_calls):
1043
+ starts = [
1044
+ float(region_data.start_times[index]) - first_start_time
1045
+ for _, region_data in per_rank
1046
+ if len(region_data.durations) > index
1047
+ ]
1048
+ durations = [
1049
+ float(region_data.durations[index])
1050
+ for _, region_data in per_rank
1051
+ if len(region_data.durations) > index
1052
+ ]
1053
+ times.append(float(np.mean(starts)))
1054
+ means.append(float(np.mean(durations)))
1055
+ minima.append(float(np.min(durations)))
1056
+ maxima.append(float(np.max(durations)))
1057
+ counts.append(len(durations))
1058
+
1059
+ return {
1060
+ "time": np.asarray(times, dtype=float),
1061
+ "mean": np.asarray(means, dtype=float),
1062
+ "min": np.asarray(minima, dtype=float),
1063
+ "max": np.asarray(maxima, dtype=float),
1064
+ "num_ranks": np.asarray(counts, dtype=int),
1065
+ "ranks": np.asarray([rank for rank, _ in per_rank], dtype=int),
1066
+ }
1067
+
1068
+
1069
+ def plot_duration_timeseries(
1070
+ profiling_data: ProfilingH5Reader | Sequence[ProfilingH5Reader],
1071
+ ranks: list[int] | int | None = None,
1072
+ include: list[str] | str | None = None,
1073
+ exclude: list[str] | str | None = None,
1074
+ filepath: str | None = None,
1075
+ show: bool = False,
1076
+ verbose: bool = True,
1077
+ cmap: str = DEFAULT_CMAP,
1078
+ data_filepath: str | Path | None = None,
1079
+ data_format: str = "csv",
1080
+ backend: str = "matplotlib",
1081
+ ) -> None:
1082
+ """Plot each region's call duration over wall-clock time, with a min-max band.
1083
+
1084
+ One line per region tracks the mean duration over the ranks that recorded
1085
+ each call, shaded between the minimum and maximum duration seen across
1086
+ those ranks, so rank imbalance shows up as a widening band.
1087
+
1088
+ Parameters
1089
+ ----------
1090
+ backend : str
1091
+ Backend to use for rendering: "matplotlib" (default) or "plotly".
1092
+ """
1093
+ Canvas = _get_canvas()
1094
+ readers = _as_readers(profiling_data)
1095
+ if not readers:
1096
+ raise ValueError("No profiling data provided.")
1097
+
1098
+ normalized_ranks = _normalize_ranks(ranks)
1099
+
1100
+ reader_regions = []
1101
+ all_region_names: set[str] = set()
1102
+ for reader in readers:
1103
+ regions = reader.get_regions(include=include, exclude=exclude)
1104
+ if not regions:
1105
+ raise ValueError("No regions matched the selected filters.")
1106
+ all_region_names.update(region.name for region in regions)
1107
+ reader_regions.append((reader, regions))
1108
+
1109
+ color_map = _region_color_map(all_region_names, cmap=cmap)
1110
+
1111
+ prepared = []
1112
+ for reader, regions in reader_regions:
1113
+ first_start_time = reader.minimum_start_time
1114
+ series = [
1115
+ (
1116
+ region.name,
1117
+ _duration_timeseries(region, normalized_ranks, first_start_time),
1118
+ )
1119
+ for region in regions
1120
+ ]
1121
+ series = [(name, values) for name, values in series if values is not None]
1122
+ if series:
1123
+ prepared.append((reader, series))
1124
+
1125
+ if not prepared:
1126
+ raise ValueError("No calls recorded for the requested ranks.")
1127
+
1128
+ labels = _unique_labels([reader.file_path.stem for reader, _ in prepared])
1129
+
1130
+ if data_filepath:
1131
+ records = []
1132
+ for label, (_, series) in zip(labels, prepared):
1133
+ for region_name, values in series:
1134
+ for index in range(values["time"].size):
1135
+ records.append(
1136
+ [
1137
+ label,
1138
+ region_name,
1139
+ index,
1140
+ float(values["time"][index]),
1141
+ float(values["mean"][index]),
1142
+ float(values["min"][index]),
1143
+ float(values["max"][index]),
1144
+ int(values["num_ranks"][index]),
1145
+ ]
1146
+ )
1147
+ header = [
1148
+ "file",
1149
+ "region",
1150
+ "call_index",
1151
+ "time_seconds",
1152
+ "mean_duration_seconds",
1153
+ "min_duration_seconds",
1154
+ "max_duration_seconds",
1155
+ "num_ranks",
1156
+ ]
1157
+ if data_format == "json":
1158
+ points = [dict(zip(header, record)) for record in records]
1159
+ colors_map = {
1160
+ name: _to_hex(color) for name, color in sorted(color_map.items())
1161
+ }
1162
+ _write_json(data_filepath, {"points": points, "colors": colors_map})
1163
+ else:
1164
+ _write_csv(data_filepath, header, records)
1165
+
1166
+ if verbose:
1167
+ print("Plotting duration over time for files: " + ", ".join(labels))
1168
+
1169
+ single_panel = len(prepared) == 1
1170
+ fig_width, fig_height = 12.0, 1.0 + 4.0 * len(prepared)
1171
+ canvas = Canvas(
1172
+ nrows=len(prepared),
1173
+ ncols=1,
1174
+ figsize=(fig_width, fig_height),
1175
+ # Duration tick labels plus the "Duration (seconds)" axis label.
1176
+ gridspec_kw=_panel_gridspec(fig_width, fig_height, 12, not single_panel),
1177
+ )
1178
+
1179
+ for idx, (reader, series) in enumerate(prepared):
1180
+ row = None if single_panel else idx
1181
+ col = None if single_panel else 0
1182
+
1183
+ for region_name, values in series:
1184
+ color = _to_hex(color_map[region_name])
1185
+ canvas.fill_between(
1186
+ values["time"],
1187
+ values["min"],
1188
+ values["max"],
1189
+ row=row,
1190
+ col=col,
1191
+ color=color,
1192
+ alpha=0.25,
1193
+ )
1194
+ canvas.add_line(
1195
+ values["time"],
1196
+ values["mean"],
1197
+ row=row,
1198
+ col=col,
1199
+ linewidth=1.8,
1200
+ color=color,
1201
+ label=region_name,
1202
+ )
1203
+
1204
+ canvas.set_xlabel("Time (seconds)", row=row, col=col)
1205
+ canvas.set_ylabel("Duration per call (seconds)", row=row, col=col)
1206
+ canvas.set_title(
1207
+ "Region duration over time" if single_panel else reader.file_path.stem,
1208
+ row=row,
1209
+ col=col,
1210
+ )
1211
+ canvas.set_grid(True, row=row, col=col)
1212
+ canvas.set_legend(row=row, col=col)
1213
+
1214
+ if not single_panel:
1215
+ canvas.suptitle("Region duration over time")
1216
+
1217
+ _render(canvas, filepath, show, backend)
1218
+
1219
+
1008
1220
  def plot_speedup(
1009
1221
  profiling_data: ProfilingH5Reader | Sequence[ProfilingH5Reader],
1010
1222
  x_field: str = "num_ranks",
@@ -7,6 +7,7 @@ import os
7
7
  from scope_profiler.h5reader import ProfilingH5Reader
8
8
  from scope_profiler.plotting_scripts import (
9
9
  DEFAULT_CMAP,
10
+ plot_duration_timeseries,
10
11
  plot_durations,
11
12
  plot_flame,
12
13
  plot_gantt,
@@ -60,7 +61,8 @@ def build_parser() -> argparse.ArgumentParser:
60
61
  help=(
61
62
  "Directory where outputs are saved "
62
63
  "(gantt_plot.png, flame_plot.png, durations_plot.png, "
63
- "optional speedup_plot.png, and region_statistics.json)"
64
+ "duration_timeseries_plot.png, optional speedup_plot.png, "
65
+ "and region_statistics.json)"
64
66
  ),
65
67
  )
66
68
  parser.add_argument(
@@ -90,16 +92,30 @@ def build_parser() -> argparse.ArgumentParser:
90
92
  "Supports comma-separated values and ranges (e.g., 1-3,5)."
91
93
  ),
92
94
  )
95
+ parser.add_argument(
96
+ "--plots",
97
+ "-p",
98
+ nargs="*",
99
+ type=str,
100
+ choices=["gantt", "flame", "durations", "timeseries", "speedup"],
101
+ default=None,
102
+ help=(
103
+ "Which plots to generate (default: all). "
104
+ "Choices: gantt, flame, durations, timeseries, speedup. "
105
+ "Example: --plots gantt durations"
106
+ ),
107
+ )
93
108
  parser.add_argument(
94
109
  "--metrics",
95
110
  "-m",
96
111
  nargs="*",
97
112
  type=str,
98
113
  choices=["avg", "min", "max", "total"],
99
- default=None,
114
+ default=["total"],
100
115
  help=(
101
116
  "Which duration statistics to include in the durations bar plot "
102
- "(default: all of avg, min, max, total)."
117
+ "(default: total). "
118
+ "Example: --metrics avg min max total"
103
119
  ),
104
120
  )
105
121
  parser.add_argument(
@@ -140,7 +156,8 @@ def build_parser() -> argparse.ArgumentParser:
140
156
  action="store_true",
141
157
  help=(
142
158
  "Also write the exact data behind each plot as a data file "
143
- "(gantt_data, flame_data, durations_data, optional speedup_data; "
159
+ "(gantt_data, flame_data, durations_data, "
160
+ "duration_timeseries_data, optional speedup_data; "
144
161
  "see --export-data-format for the file extension/content), so "
145
162
  "charts can be reconstructed later without the original HDF5 "
146
163
  "files. Requires -o/--output."
@@ -243,6 +260,9 @@ def main(argv: list[str] | None = None):
243
260
  "--export-speedscope."
244
261
  )
245
262
 
263
+ _ALL_PLOTS = {"gantt", "flame", "durations", "timeseries", "speedup"}
264
+ selected_plots: set[str] = set(args.plots) if args.plots is not None else _ALL_PLOTS
265
+
246
266
  if args.ranks:
247
267
  ranks = []
248
268
  for spec in args.ranks:
@@ -274,11 +294,13 @@ def main(argv: list[str] | None = None):
274
294
  gantt_path = None
275
295
  flame_path = None
276
296
  durations_path = None
297
+ timeseries_path = None
277
298
  speedup_path = None
278
299
  statistics_path = None
279
300
  gantt_data_path = None
280
301
  flame_data_path = None
281
302
  durations_data_path = None
303
+ timeseries_data_path = None
282
304
  speedup_data_path = None
283
305
  prof_path = None
284
306
  prof_paths: list = []
@@ -291,20 +313,34 @@ def main(argv: list[str] | None = None):
291
313
  # Plotly's native output is a self-contained interactive page;
292
314
  # writing .png from it would additionally require kaleido.
293
315
  ext = "html" if args.backend == "plotly" else "png"
294
- gantt_path = os.path.join(args.output, f"gantt_plot.{ext}")
295
- flame_path = os.path.join(args.output, f"flame_plot.{ext}")
296
- durations_path = os.path.join(args.output, f"durations_plot.{ext}")
297
- if len(readers) > 1:
316
+ if "gantt" in selected_plots:
317
+ gantt_path = os.path.join(args.output, f"gantt_plot.{ext}")
318
+ if "flame" in selected_plots:
319
+ flame_path = os.path.join(args.output, f"flame_plot.{ext}")
320
+ if "durations" in selected_plots:
321
+ durations_path = os.path.join(args.output, f"durations_plot.{ext}")
322
+ if "timeseries" in selected_plots:
323
+ timeseries_path = os.path.join(
324
+ args.output, f"duration_timeseries_plot.{ext}"
325
+ )
326
+ if len(readers) > 1 and "speedup" in selected_plots:
298
327
  speedup_path = os.path.join(args.output, f"speedup_plot.{ext}")
299
328
  statistics_path = os.path.join(args.output, "region_statistics.json")
300
329
  if args.export_data:
301
330
  data_ext = args.export_data_format
302
- gantt_data_path = os.path.join(args.output, f"gantt_data.{data_ext}")
303
- flame_data_path = os.path.join(args.output, f"flame_data.{data_ext}")
304
- durations_data_path = os.path.join(
305
- args.output, f"durations_data.{data_ext}"
306
- )
307
- if len(readers) > 1:
331
+ if "gantt" in selected_plots:
332
+ gantt_data_path = os.path.join(args.output, f"gantt_data.{data_ext}")
333
+ if "flame" in selected_plots:
334
+ flame_data_path = os.path.join(args.output, f"flame_data.{data_ext}")
335
+ if "durations" in selected_plots:
336
+ durations_data_path = os.path.join(
337
+ args.output, f"durations_data.{data_ext}"
338
+ )
339
+ if "timeseries" in selected_plots:
340
+ timeseries_data_path = os.path.join(
341
+ args.output, f"duration_timeseries_data.{data_ext}"
342
+ )
343
+ if len(readers) > 1 and "speedup" in selected_plots:
308
344
  speedup_data_path = os.path.join(
309
345
  args.output, f"speedup_data.{data_ext}"
310
346
  )
@@ -339,47 +375,64 @@ def main(argv: list[str] | None = None):
339
375
  )
340
376
 
341
377
  if render_plots:
342
- plot_gantt(
343
- profiling_data=readers,
344
- filepath=gantt_path,
345
- show=args.show,
346
- include=args.include,
347
- exclude=args.exclude,
348
- ranks=args.ranks,
349
- cmap=args.cmap,
350
- data_filepath=gantt_data_path,
351
- data_format=args.export_data_format,
352
- backend=args.backend,
353
- )
378
+ if "gantt" in selected_plots:
379
+ plot_gantt(
380
+ profiling_data=readers,
381
+ filepath=gantt_path,
382
+ show=args.show,
383
+ include=args.include,
384
+ exclude=args.exclude,
385
+ ranks=args.ranks,
386
+ cmap=args.cmap,
387
+ data_filepath=gantt_data_path,
388
+ data_format=args.export_data_format,
389
+ backend=args.backend,
390
+ )
354
391
 
355
- plot_flame(
356
- profiling_data=readers,
357
- filepath=flame_path,
358
- show=args.show,
359
- include=args.include,
360
- exclude=args.exclude,
361
- ranks=args.ranks,
362
- cmap=args.cmap,
363
- data_filepath=flame_data_path,
364
- data_format=args.export_data_format,
365
- backend=args.backend,
366
- )
392
+ if "flame" in selected_plots:
393
+ plot_flame(
394
+ profiling_data=readers,
395
+ filepath=flame_path,
396
+ show=args.show,
397
+ include=args.include,
398
+ exclude=args.exclude,
399
+ ranks=args.ranks,
400
+ cmap=args.cmap,
401
+ data_filepath=flame_data_path,
402
+ data_format=args.export_data_format,
403
+ backend=args.backend,
404
+ )
367
405
 
368
- durations_paths = plot_durations(
369
- profiling_data=readers,
370
- filepath=durations_path,
371
- show=args.show,
372
- include=args.include,
373
- exclude=args.exclude,
374
- ranks=args.ranks,
375
- metrics=args.metrics,
376
- cmap=args.cmap,
377
- data_filepath=durations_data_path,
378
- data_format=args.export_data_format,
379
- backend=args.backend,
380
- )
406
+ if "durations" in selected_plots:
407
+ durations_paths = plot_durations(
408
+ profiling_data=readers,
409
+ filepath=durations_path,
410
+ show=args.show,
411
+ include=args.include,
412
+ exclude=args.exclude,
413
+ ranks=args.ranks,
414
+ metrics=args.metrics,
415
+ cmap=args.cmap,
416
+ data_filepath=durations_data_path,
417
+ data_format=args.export_data_format,
418
+ backend=args.backend,
419
+ )
420
+
421
+ if "timeseries" in selected_plots:
422
+ plot_duration_timeseries(
423
+ profiling_data=readers,
424
+ filepath=timeseries_path,
425
+ show=args.show,
426
+ include=args.include,
427
+ exclude=args.exclude,
428
+ ranks=args.ranks,
429
+ cmap=args.cmap,
430
+ data_filepath=timeseries_data_path,
431
+ data_format=args.export_data_format,
432
+ backend=args.backend,
433
+ )
381
434
 
382
- if len(readers) > 1:
435
+ if len(readers) > 1 and "speedup" in selected_plots:
383
436
  plot_speedup(
384
437
  profiling_data=readers,
385
438
  x_field=args.x_field,
@@ -410,11 +463,13 @@ def main(argv: list[str] | None = None):
410
463
  gantt_path,
411
464
  flame_path,
412
465
  *durations_paths,
466
+ timeseries_path,
413
467
  speedup_path,
414
468
  statistics_path,
415
469
  gantt_data_path,
416
470
  flame_data_path,
417
471
  durations_data_path,
472
+ timeseries_data_path,
418
473
  speedup_data_path,
419
474
  *prof_paths,
420
475
  *speedscope_paths,
@@ -7,6 +7,8 @@ import pytest
7
7
  from scope_profiler.h5reader import ProfilingH5Reader
8
8
  from scope_profiler.plotting_scripts import (
9
9
  _build_call_stack_intervals,
10
+ _duration_timeseries,
11
+ plot_duration_timeseries,
10
12
  plot_durations,
11
13
  plot_flame,
12
14
  plot_gantt,
@@ -36,6 +38,11 @@ def _write_sample_h5(path, rank_regions, metadata=None):
36
38
  )
37
39
 
38
40
 
41
+ def _seconds(nanoseconds):
42
+ """Timestamps are written in nanoseconds; the reader reports seconds."""
43
+ return [value / 1e9 for value in nanoseconds]
44
+
45
+
39
46
  def _sample_file_data(rank_count, setup_duration, solve_duration):
40
47
  return {
41
48
  rank: {
@@ -56,7 +63,13 @@ def test_plot_durations_comparison(tmp_path):
56
63
 
57
64
  readers = [ProfilingH5Reader(file_one), ProfilingH5Reader(file_two)]
58
65
 
59
- saved_paths = plot_durations(readers, filepath=out_file, show=False, verbose=False)
66
+ saved_paths = plot_durations(
67
+ readers,
68
+ filepath=out_file,
69
+ show=False,
70
+ verbose=False,
71
+ metrics=["avg", "min", "max", "total"],
72
+ )
60
73
 
61
74
  assert len(saved_paths) == 4
62
75
  for metric in ("avg", "min", "max", "total"):
@@ -65,6 +78,91 @@ def test_plot_durations_comparison(tmp_path):
65
78
  assert metric_file.stat().st_size > 0
66
79
 
67
80
 
81
+ def test_duration_timeseries_bands_span_ranks(tmp_path):
82
+ file_path = tmp_path / "run.h5"
83
+ # Two calls of "solve" per rank, with rank 1 slower on the second call, so
84
+ # the band has to widen between the two points.
85
+ _write_sample_h5(
86
+ file_path,
87
+ {
88
+ 0: {"solve": ([0, 100], [10, 110])},
89
+ 1: {"solve": ([0, 100], [20, 140])},
90
+ },
91
+ )
92
+ reader = ProfilingH5Reader(file_path)
93
+
94
+ series = _duration_timeseries(reader.get_region("solve"), None, 0.0)
95
+
96
+ assert list(series["num_ranks"]) == [2, 2]
97
+ assert series["min"] == pytest.approx(_seconds([10, 10]))
98
+ assert series["max"] == pytest.approx(_seconds([20, 40]))
99
+ assert series["mean"] == pytest.approx(_seconds([15, 25]))
100
+ assert series["time"] == pytest.approx(_seconds([0, 100]))
101
+
102
+
103
+ def test_duration_timeseries_handles_ragged_call_counts(tmp_path):
104
+ file_path = tmp_path / "run.h5"
105
+ _write_sample_h5(
106
+ file_path,
107
+ {
108
+ 0: {"solve": ([0, 100], [10, 130])},
109
+ 1: {"solve": ([0], [20])},
110
+ },
111
+ )
112
+ reader = ProfilingH5Reader(file_path)
113
+
114
+ series = _duration_timeseries(reader.get_region("solve"), None, 0.0)
115
+
116
+ # The second call exists on rank 0 only, so its band collapses to a point.
117
+ assert list(series["num_ranks"]) == [2, 1]
118
+ assert series["min"] == pytest.approx(_seconds([10, 30]))
119
+ assert series["max"] == pytest.approx(_seconds([20, 30]))
120
+
121
+
122
+ def test_duration_timeseries_respects_rank_selection(tmp_path):
123
+ file_path = tmp_path / "run.h5"
124
+ _write_sample_h5(
125
+ file_path,
126
+ {
127
+ 0: {"solve": ([0], [10])},
128
+ 1: {"solve": ([0], [50])},
129
+ },
130
+ )
131
+ reader = ProfilingH5Reader(file_path)
132
+
133
+ series = _duration_timeseries(reader.get_region("solve"), [0], 0.0)
134
+
135
+ assert list(series["num_ranks"]) == [1]
136
+ assert series["min"] == pytest.approx(series["max"])
137
+ assert series["max"] == pytest.approx(_seconds([10]))
138
+
139
+
140
+ def test_plot_duration_timeseries_export_data_json(tmp_path):
141
+ file_path = tmp_path / "run.h5"
142
+ data_file = tmp_path / "duration_timeseries_data.json"
143
+
144
+ _write_sample_h5(file_path, _sample_file_data(2, 10, 20))
145
+ reader = ProfilingH5Reader(file_path)
146
+
147
+ plot_duration_timeseries(
148
+ reader,
149
+ filepath=tmp_path / "duration_timeseries_plot.png",
150
+ show=False,
151
+ verbose=False,
152
+ data_filepath=data_file,
153
+ data_format="json",
154
+ )
155
+
156
+ assert (tmp_path / "duration_timeseries_plot.png").stat().st_size > 0
157
+ payload = json.loads(data_file.read_text(encoding="utf-8"))
158
+ assert {point["region"] for point in payload["points"]} == {"setup", "solve"}
159
+ for point in payload["points"]:
160
+ assert point["min_duration_seconds"] <= point["mean_duration_seconds"]
161
+ assert point["mean_duration_seconds"] <= point["max_duration_seconds"]
162
+ assert point["num_ranks"] == 2
163
+ assert set(payload["colors"]) == {"setup", "solve"}
164
+
165
+
68
166
  def test_plot_gantt_combined(tmp_path):
69
167
  file_one = tmp_path / "run_one.h5"
70
168
  file_two = tmp_path / "run_two.h5"
@@ -81,6 +179,59 @@ def test_plot_gantt_combined(tmp_path):
81
179
  assert out_file.stat().st_size > 0
82
180
 
83
181
 
182
+ def test_plot_gantt_puts_every_call_of_a_region_on_one_lane(tmp_path, monkeypatch):
183
+ """Repeated calls of a region share one row per rank, not a row each."""
184
+ h5_path = tmp_path / "run.h5"
185
+ _write_sample_h5(
186
+ h5_path,
187
+ {
188
+ rank: {
189
+ "setup": ([0], [10]),
190
+ "solve": ([20, 40, 60], [30, 50, 70]),
191
+ }
192
+ for rank in range(2)
193
+ },
194
+ )
195
+
196
+ bars_by_lane = {}
197
+
198
+ class _RecordingCanvas:
199
+ """Collect what plot_gantt draws instead of rendering it."""
200
+
201
+ def __init__(self, *args, **kwargs):
202
+ self.lanes = None
203
+
204
+ def gantt(self, tasks, start_times, durations, **kwargs):
205
+ self.lanes = tasks
206
+ for lane, start in enumerate(start_times):
207
+ if not np.isnan(start):
208
+ bars_by_lane.setdefault(lane, []).append(float(start))
209
+
210
+ def __getattr__(self, name):
211
+ # set_yticks, set_xlim, set_title, ... are all no-ops here.
212
+ return lambda *args, **kwargs: None
213
+
214
+ import scope_profiler.plotting_scripts as plotting_scripts
215
+
216
+ canvas = _RecordingCanvas()
217
+ monkeypatch.setattr(
218
+ plotting_scripts, "_get_canvas", lambda: lambda *a, **kw: canvas
219
+ )
220
+ monkeypatch.setattr(plotting_scripts, "_render", lambda *a, **kw: None)
221
+
222
+ plot_gantt(ProfilingH5Reader(h5_path), show=False, verbose=False)
223
+
224
+ # One lane per (region, rank), and solve's three calls share their lane.
225
+ assert canvas.lanes == [
226
+ "setup (rank 0)",
227
+ "setup (rank 1)",
228
+ "solve (rank 0)",
229
+ "solve (rank 1)",
230
+ ]
231
+ assert sorted(bars_by_lane) == [0, 1, 2, 3]
232
+ assert [len(bars_by_lane[lane]) for lane in sorted(bars_by_lane)] == [1, 1, 3, 3]
233
+
234
+
84
235
  def test_build_call_stack_intervals_reconstructs_nesting(tmp_path):
85
236
  # "outer" [0, 100) encloses two sequential "inner" calls, [10, 40) and
86
237
  # [50, 90), which in turn each enclose a "leaf" call.
@@ -294,6 +445,7 @@ def test_post_processing_cli_supports_multiple_files(tmp_path):
294
445
 
295
446
  gantt_plot = output_dir / "gantt_plot.png"
296
447
  flame_plot = output_dir / "flame_plot.png"
448
+ timeseries_plot = output_dir / "duration_timeseries_plot.png"
297
449
  speedup_plot = output_dir / "speedup_plot.png"
298
450
  stats_json = output_dir / "region_statistics.json"
299
451
 
@@ -301,10 +453,11 @@ def test_post_processing_cli_supports_multiple_files(tmp_path):
301
453
  assert gantt_plot.stat().st_size > 0
302
454
  assert flame_plot.exists()
303
455
  assert flame_plot.stat().st_size > 0
304
- for metric in ("avg", "min", "max", "total"):
305
- metric_file = output_dir / f"durations_plot_{metric}.png"
306
- assert metric_file.exists()
307
- assert metric_file.stat().st_size > 0
456
+ durations_plot = output_dir / "durations_plot.png"
457
+ assert durations_plot.exists()
458
+ assert durations_plot.stat().st_size > 0
459
+ assert timeseries_plot.exists()
460
+ assert timeseries_plot.stat().st_size > 0
308
461
  assert speedup_plot.exists()
309
462
  assert speedup_plot.stat().st_size > 0
310
463
  assert stats_json.exists()
@@ -338,10 +491,9 @@ def test_post_processing_cli_supports_wildcard_file_patterns(tmp_path):
338
491
  assert gantt_plot.stat().st_size > 0
339
492
  assert flame_plot.exists()
340
493
  assert flame_plot.stat().st_size > 0
341
- for metric in ("avg", "min", "max", "total"):
342
- metric_file = output_dir / f"durations_plot_{metric}.png"
343
- assert metric_file.exists()
344
- assert metric_file.stat().st_size > 0
494
+ durations_plot = output_dir / "durations_plot.png"
495
+ assert durations_plot.exists()
496
+ assert durations_plot.stat().st_size > 0
345
497
  assert speedup_plot.exists()
346
498
  assert speedup_plot.stat().st_size > 0
347
499
  assert stats_json.exists()
@@ -410,6 +562,7 @@ def test_plot_durations_export_data_json(tmp_path):
410
562
  verbose=False,
411
563
  data_filepath=data_file,
412
564
  data_format="json",
565
+ metrics=["avg", "min", "max", "total"],
413
566
  )
414
567
 
415
568
  payload = json.loads(data_file.read_text(encoding="utf-8"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scope-profiler
3
- Version: 0.2.4
3
+ Version: 0.2.6
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
File without changes
File without changes