scope-profiler 0.1__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.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: scope-profiler
3
+ Version: 0.1
4
+ Summary: Profile code regions in python, optionally with LIKWID markers.
5
+ Author: Max
6
+ Project-URL: Source, https://github.com/max-models/scope_profiler
7
+ Keywords: python
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: h5py
19
+ Requires-Dist: numpy
20
+ Requires-Dist: matplotlib
21
+ Provides-Extra: dev
22
+ Requires-Dist: black[jupyter]; extra == "dev"
23
+ Requires-Dist: isort; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: scope-profiler[docs,test]; extra == "dev"
26
+ Provides-Extra: docs
27
+ Requires-Dist: ipykernel; extra == "docs"
28
+ Requires-Dist: myst-parser; extra == "docs"
29
+ Requires-Dist: nbconvert; extra == "docs"
30
+ Requires-Dist: nbsphinx; extra == "docs"
31
+ Requires-Dist: jupyterlab; extra == "docs"
32
+ Requires-Dist: pre-commit; extra == "docs"
33
+ Requires-Dist: pyproject-fmt; extra == "docs"
34
+ Requires-Dist: sphinx; extra == "docs"
35
+ Requires-Dist: sphinx-book-theme; extra == "docs"
36
+ Provides-Extra: test
37
+ Requires-Dist: coverage; extra == "test"
38
+ Requires-Dist: pytest; extra == "test"
39
+
40
+ # scope-profiler - Python Profiling System with Optional LIKWID Integration
41
+
42
+ This module provides a unified profiling system for Python applications, with optional integration of [LIKWID](https://github.com/RRZE-HPC/likwid) markers using the [pylikwid](https://github.com/RRZE-HPC/pylikwid) marker API for hardware performance counters. It allows you to:
43
+ - Configure profiling globally via a singleton ProfilingConfig.
44
+ - Collect timing data via context-managed profiling regions.
45
+ - Use a clean decorator syntax to profile functions.
46
+ - Optionally record time traces in HDF5 files.
47
+ - Automatically initialize and close LIKWID markers only when needed.
48
+ - Print aggregated summaries of all profiling regions.
49
+
50
+ Documentation: https://max-models.github.io/scope-profiler/
51
+
52
+ ## Install
53
+
54
+ ```
55
+ pip install scope-profiler
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```python
61
+ from scope_profiler.profiling import (
62
+ ProfilingConfig,
63
+ ProfileManager,
64
+ )
65
+
66
+ config = ProfilingConfig(
67
+ use_likwid=False,
68
+ time_trace=True,
69
+ flush_to_disk=True,
70
+ )
71
+
72
+ @ProfileManager.profile("main")
73
+ def main():
74
+ x = 0
75
+ for i in range(10):
76
+ with ProfileManager.profile_region(region_name="iteration"):
77
+ x += 1
78
+
79
+ main()
80
+
81
+ ProfileManager.print_summary()
82
+
83
+ ProfileManager.finalize()
84
+ ```
85
+
86
+ Execution:
87
+
88
+ ```bash
89
+ ❯ python test.py
90
+ Profiling Summary:
91
+ ========================================
92
+ Region: main
93
+ Number of Calls: 1
94
+ Total Duration: 0.000315 seconds
95
+ Average Duration: 0.000315 seconds
96
+ Min Duration: 0.000315 seconds
97
+ Max Duration: 0.000315 seconds
98
+ Std Deviation: 0.000000 seconds
99
+ ----------------------------------------
100
+ Region: iteration
101
+ Number of Calls: 10
102
+ Total Duration: 0.000007 seconds
103
+ Average Duration: 0.000001 seconds
104
+ Min Duration: 0.000000 seconds
105
+ Max Duration: 0.000003 seconds
106
+ Std Deviation: 0.000001 seconds
107
+ ----------------------------------------
108
+ ```
@@ -0,0 +1,69 @@
1
+ # scope-profiler - Python Profiling System with Optional LIKWID Integration
2
+
3
+ This module provides a unified profiling system for Python applications, with optional integration of [LIKWID](https://github.com/RRZE-HPC/likwid) markers using the [pylikwid](https://github.com/RRZE-HPC/pylikwid) marker API for hardware performance counters. It allows you to:
4
+ - Configure profiling globally via a singleton ProfilingConfig.
5
+ - Collect timing data via context-managed profiling regions.
6
+ - Use a clean decorator syntax to profile functions.
7
+ - Optionally record time traces in HDF5 files.
8
+ - Automatically initialize and close LIKWID markers only when needed.
9
+ - Print aggregated summaries of all profiling regions.
10
+
11
+ Documentation: https://max-models.github.io/scope-profiler/
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install scope-profiler
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from scope_profiler.profiling import (
23
+ ProfilingConfig,
24
+ ProfileManager,
25
+ )
26
+
27
+ config = ProfilingConfig(
28
+ use_likwid=False,
29
+ time_trace=True,
30
+ flush_to_disk=True,
31
+ )
32
+
33
+ @ProfileManager.profile("main")
34
+ def main():
35
+ x = 0
36
+ for i in range(10):
37
+ with ProfileManager.profile_region(region_name="iteration"):
38
+ x += 1
39
+
40
+ main()
41
+
42
+ ProfileManager.print_summary()
43
+
44
+ ProfileManager.finalize()
45
+ ```
46
+
47
+ Execution:
48
+
49
+ ```bash
50
+ ❯ python test.py
51
+ Profiling Summary:
52
+ ========================================
53
+ Region: main
54
+ Number of Calls: 1
55
+ Total Duration: 0.000315 seconds
56
+ Average Duration: 0.000315 seconds
57
+ Min Duration: 0.000315 seconds
58
+ Max Duration: 0.000315 seconds
59
+ Std Deviation: 0.000000 seconds
60
+ ----------------------------------------
61
+ Region: iteration
62
+ Number of Calls: 10
63
+ Total Duration: 0.000007 seconds
64
+ Average Duration: 0.000001 seconds
65
+ Min Duration: 0.000000 seconds
66
+ Max Duration: 0.000003 seconds
67
+ Std Deviation: 0.000001 seconds
68
+ ----------------------------------------
69
+ ```
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+
4
+ requires = [ "setuptools", "wheel" ]
5
+
6
+ [project]
7
+ name = "scope-profiler"
8
+ version = "0.1"
9
+ description = "Profile code regions in python, optionally with LIKWID markers."
10
+ readme = "README.md"
11
+ keywords = [ "python" ]
12
+ license = { file = "LICENSE.txt" }
13
+ authors = [ { name = "Max" } ]
14
+ requires-python = ">=3.8"
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.8",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "h5py",
27
+ "numpy",
28
+ "matplotlib",
29
+ ]
30
+
31
+ optional-dependencies.dev = [
32
+ "black[jupyter]",
33
+ "isort",
34
+ "ruff",
35
+ "scope-profiler[test,docs]",
36
+ ]
37
+ # https://medium.com/@pratikdomadiya123/build-project-documentation-quickly-with-the-sphinx-python-2a9732b66594
38
+ optional-dependencies.docs = [
39
+ "ipykernel",
40
+ "myst-parser",
41
+ "nbconvert",
42
+ "nbsphinx",
43
+ "jupyterlab",
44
+ "pre-commit",
45
+ "pyproject-fmt",
46
+ "sphinx",
47
+ "sphinx-book-theme",
48
+ ]
49
+ optional-dependencies.test = [
50
+ "coverage",
51
+ "pytest"
52
+ ]
53
+ urls."Source" = "https://github.com/max-models/scope_profiler"
54
+
55
+ [project.scripts]
56
+ scope-profiler-pproc = "scope_profiler.post_processing:main"
57
+
58
+ [tool.setuptools.packages.find]
59
+ where = [ "src" ]
60
+
61
+ [tool.isort]
62
+ profile = "black"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,220 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List
3
+
4
+ import h5py
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+
9
+ class Region:
10
+ def __init__(self, start_times: np.ndarray, end_times: np.ndarray) -> None:
11
+ self._start_times = start_times
12
+ self._end_times = end_times
13
+ self._durations = end_times - start_times
14
+
15
+ def get_summary(self) -> Dict[str, Any]:
16
+ """Return a summary of the region’s statistics as a dictionary."""
17
+ return {
18
+ "num_calls": self.num_calls,
19
+ "total_duration": self.total_duration,
20
+ "average_duration": self.average_duration,
21
+ "min_duration": self.min_duration,
22
+ "max_duration": self.max_duration,
23
+ "std_duration": self.std_duration,
24
+ }
25
+
26
+ def __repr__(self) -> str:
27
+ """Print summaries for all regions in the file."""
28
+ # print(f"\nProfiling data summary for: {self.file_path}")
29
+ _out = "-" * 60 + "\n"
30
+ stats = self.get_summary()
31
+ for key, value in stats.items():
32
+ _out += f" {key:>18}: {value}\n"
33
+ _out += "-" * 60 + "\n\n"
34
+ return _out
35
+
36
+ @property
37
+ def start_times(self) -> np.ndarray:
38
+ return self._start_times
39
+
40
+ @property
41
+ def end_times(self) -> np.ndarray:
42
+ return self._end_times
43
+
44
+ @property
45
+ def durations(self) -> np.ndarray:
46
+ return self._durations
47
+
48
+ @property
49
+ def num_calls(self) -> int:
50
+ """Number of recorded calls."""
51
+ return len(self._durations)
52
+
53
+ @property
54
+ def total_duration(self) -> float:
55
+ """Total time spent in this region (sum of all durations)."""
56
+ return float(np.sum(self._durations)) if self.num_calls else 0.0
57
+
58
+ @property
59
+ def average_duration(self) -> float:
60
+ """Average duration per call."""
61
+ return float(np.mean(self._durations)) if self.num_calls else 0.0
62
+
63
+ @property
64
+ def min_duration(self) -> float:
65
+ """Minimum duration among all calls."""
66
+ return float(np.min(self._durations)) if self.num_calls else 0.0
67
+
68
+ @property
69
+ def max_duration(self) -> float:
70
+ """Maximum duration among all calls."""
71
+ return float(np.max(self._durations)) if self.num_calls else 0.0
72
+
73
+ @property
74
+ def std_duration(self) -> float:
75
+ """Standard deviation of durations."""
76
+ return float(np.std(self._durations)) if self.num_calls else 0.0
77
+
78
+
79
+ class ProfilingH5Reader:
80
+ """
81
+ Reads profiling data stored by ProfileRegion in an HDF5 file.
82
+ """
83
+
84
+ def __init__(self, file_path: str | Path):
85
+ self._file_path = Path(file_path)
86
+ if not self.file_path.exists():
87
+ raise FileNotFoundError(f"HDF5 file not found: {self.file_path}")
88
+
89
+ # Verify it's an HDF5 file
90
+ try:
91
+ with h5py.File(self.file_path, "r") as f:
92
+ if "regions" not in f:
93
+ raise ValueError("Invalid profiling file: missing 'regions' group.")
94
+ except OSError as e:
95
+ raise ValueError(f"Cannot open {self.file_path} as an HDF5 file") from e
96
+
97
+ # Read the file
98
+ self._region_dict = {}
99
+ with h5py.File(self.file_path, "r") as f:
100
+ # region_names = list(f["regions"].keys())
101
+
102
+ for region_name, region in f["regions"].items():
103
+ # grp = f[f"regions/{region_name}"]
104
+ self._region_dict[region_name] = Region(
105
+ region["start_times"][()],
106
+ region["end_times"][()],
107
+ )
108
+
109
+ def get_region(self, region_name: str) -> Region:
110
+ return self._region_dict[region_name]
111
+
112
+ def plot_gantt(
113
+ self,
114
+ regions: List[str] | str | None = None,
115
+ filepath: str | None = None,
116
+ show: bool = False,
117
+ ) -> None:
118
+ """
119
+ Plot a Gantt chart of all (or selected) regions.
120
+
121
+ Parameters
122
+ ----------
123
+ regions : list[str] | None
124
+ List of region names to plot. If None, plot all.
125
+ """
126
+ if regions is None:
127
+ regions = list(self._region_dict.keys())
128
+ elif isinstance(regions, str):
129
+ regions = [regions]
130
+
131
+ fig, ax = plt.subplots(figsize=(10, 0.7 * len(regions)))
132
+ colors = plt.cm.tab20(np.linspace(0, 1, len(regions)))
133
+
134
+ for i, region_name in enumerate(regions):
135
+ region = self._region_dict[region_name]
136
+ for start, end in zip(region.start_times, region.end_times):
137
+ ax.barh(
138
+ y=i,
139
+ width=end - start,
140
+ left=start,
141
+ height=0.4,
142
+ color=colors[i],
143
+ edgecolor="black",
144
+ alpha=0.7,
145
+ )
146
+
147
+ ax.set_xlabel("Time (seconds)")
148
+ ax.set_yticks(range(len(regions)))
149
+ ax.set_yticklabels(regions)
150
+ ax.set_title("Profiling Gantt Chart")
151
+ ax.grid(True, axis="x", linestyle="--", alpha=0.5)
152
+ fig.tight_layout()
153
+ if filepath:
154
+ plt.savefig(filepath, dpi=300)
155
+ if show:
156
+ plt.show()
157
+
158
+ def plot_durations(
159
+ self,
160
+ regions: List[str] | str | None = None,
161
+ filepath: str | None = None,
162
+ show: bool = False,
163
+ bins: int = 30,
164
+ ) -> None:
165
+ """
166
+ Plot duration histograms for each region.
167
+
168
+ Parameters
169
+ ----------
170
+ regions : list[str] | None
171
+ List of region names to plot. If None, plot all.
172
+ bins : int
173
+ Number of histogram bins.
174
+ """
175
+ if regions is None:
176
+ regions = list(self._region_dict.keys())
177
+ elif isinstance(regions, str):
178
+ regions = [regions]
179
+
180
+ n = len(regions)
181
+ fig, axes = plt.subplots(nrows=n, ncols=1, figsize=(8, 3 * n))
182
+ if n == 1:
183
+ axes = [axes]
184
+
185
+ for ax, region_name in zip(axes, regions):
186
+ region = self._region_dict[region_name]
187
+ durations = region.durations
188
+ if len(durations) == 0:
189
+ ax.text(0.5, 0.5, "No data", ha="center", va="center")
190
+ continue
191
+
192
+ ax.hist(
193
+ durations, bins=bins, color="steelblue", alpha=0.7, edgecolor="black"
194
+ )
195
+ ax.set_title(f"Region: {region_name}")
196
+ ax.set_xlabel("Duration (s)")
197
+ ax.set_ylabel("Frequency")
198
+ ax.grid(True, alpha=0.4)
199
+
200
+ fig.suptitle("Region Duration Distributions", fontsize=14)
201
+ fig.tight_layout()
202
+ if filepath:
203
+ plt.savefig(filepath, dpi=300)
204
+ if show:
205
+ plt.show()
206
+
207
+ def __repr__(self) -> str:
208
+ _out = ""
209
+ for region_name, region in self._region_dict.items():
210
+ _out += f"Region: {region_name}\n"
211
+ _out += str(region)
212
+ return _out
213
+
214
+ @property
215
+ def file_path(self) -> Path:
216
+ return self._file_path
217
+
218
+ @property
219
+ def regions(self) -> List[Region]:
220
+ return self._regions
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import os
3
+
4
+ from scope_profiler.h5reader import ProfilingH5Reader
5
+
6
+
7
+ def main():
8
+ """Main function for reading and summarizing profiling HDF5 data."""
9
+ parser = argparse.ArgumentParser(
10
+ description="Read and summarize profiling HDF5 data."
11
+ )
12
+ parser.add_argument("file", type=str, help="Path to the profiling_data.h5 file")
13
+ parser.add_argument("--region", type=str, help="Region name to inspect (optional)")
14
+ parser.add_argument(
15
+ "--show",
16
+ action="store_true",
17
+ help="Show plots interactively (default: do not show plots)",
18
+ )
19
+ parser.add_argument(
20
+ "-o",
21
+ "--output",
22
+ type=str,
23
+ help="Directory or file prefix to save plots instead of displaying them",
24
+ )
25
+ args = parser.parse_args()
26
+
27
+ reader = ProfilingH5Reader(args.file)
28
+
29
+ # Handle optional region selection
30
+ if args.region:
31
+ regions = [args.region]
32
+ print(f"\nRegion: {args.region}")
33
+ print(reader.get_region(args.region))
34
+ else:
35
+ regions = None
36
+ print(reader)
37
+
38
+ # Prepare output filepaths if requested
39
+ gantt_path = durations_path = None
40
+ if args.output:
41
+ os.makedirs(args.output, exist_ok=True)
42
+ gantt_path = os.path.join(args.output, "gantt_plot.png")
43
+ durations_path = os.path.join(args.output, "durations_plot.png")
44
+
45
+ # Call the plotting functions with the appropriate arguments
46
+ reader.plot_gantt(regions, filepath=gantt_path, show=args.show)
47
+ reader.plot_durations(regions, filepath=durations_path, show=args.show)
48
+
49
+ # If saving only (no show), print confirmation
50
+ if args.output and not args.show:
51
+ print(f"Plots saved to:\n {gantt_path}\n {durations_path}")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,422 @@
1
+ """
2
+ profiling.py
3
+
4
+ This module provides a centralized profiling configuration and management system
5
+ using LIKWID markers. It includes:
6
+ - A singleton class for managing profiling configuration.
7
+ - A context manager for profiling specific code regions.
8
+ - Initialization and cleanup functions for LIKWID markers.
9
+ - Convenience functions for setting and getting the profiling configuration.
10
+
11
+ LIKWID is imported only when profiling is enabled to avoid unnecessary overhead.
12
+ """
13
+
14
+ import functools
15
+ import inspect
16
+ import time
17
+
18
+ # Import the profiling configuration class and context manager
19
+ from functools import lru_cache
20
+ from typing import Callable, Dict
21
+
22
+ import h5py
23
+ import numpy as np
24
+
25
+
26
+ @lru_cache(maxsize=None) # Cache the import result to avoid repeated imports
27
+ def _import_pylikwid():
28
+ import pylikwid
29
+
30
+ return pylikwid
31
+
32
+
33
+ class ProfilingConfig:
34
+ """Singleton class for managing global profiling configuration."""
35
+
36
+ _instance = None
37
+ _initialized = False
38
+
39
+ def __new__(cls, *args, **kwargs):
40
+ if cls._instance is None:
41
+ cls._instance = super().__new__(cls)
42
+ # Default values
43
+ cls._instance.profiling_activated = True
44
+ cls._instance.use_likwid = False
45
+ cls._instance.time_trace = False
46
+ cls._instance.flush_to_disk = False
47
+ return cls._instance
48
+
49
+ def __init__(
50
+ self,
51
+ profiling_activated: bool = True,
52
+ use_likwid: bool = False,
53
+ time_trace: bool = True,
54
+ flush_to_disk: bool = False,
55
+ ):
56
+
57
+ if self._initialized:
58
+ return
59
+
60
+ # Only update if value provided
61
+ self.profiling_activated = profiling_activated
62
+ self.use_likwid = use_likwid
63
+ self.time_trace = time_trace
64
+ self.flush_to_disk = flush_to_disk
65
+
66
+ self._pylikwid = None
67
+ if self.use_likwid:
68
+ try:
69
+ import pylikwid
70
+
71
+ self._pylikwid = pylikwid
72
+ except ImportError as e:
73
+ raise ImportError(
74
+ "LIKWID profiling requested but pylikwid module not installed"
75
+ ) from e
76
+ self._initialized = True
77
+
78
+ @classmethod
79
+ def reset(cls):
80
+ """Reset the singleton so it can be reinitialized."""
81
+ cls._instance = None
82
+ cls._initialized = False
83
+
84
+ def pylikwid_markerinit(self):
85
+ """Initialize LIKWID profiling markers."""
86
+ if self.use_likwid and self._pylikwid:
87
+ self._pylikwid.markerinit()
88
+
89
+ def pylikwid_markerclose(self):
90
+ """Close LIKWID profiling markers."""
91
+ if self.use_likwid and self._pylikwid:
92
+ self._pylikwid.markerclose()
93
+
94
+ @property
95
+ def profiling_activated(self) -> bool:
96
+ return self._profiling_activated
97
+
98
+ @profiling_activated.setter
99
+ def profiling_activated(self, value: bool) -> None:
100
+ assert isinstance(value, bool)
101
+ self._profiling_activated = value
102
+
103
+ @property
104
+ def use_likwid(self) -> bool:
105
+ return self._likwid
106
+
107
+ @use_likwid.setter
108
+ def use_likwid(self, value: bool) -> None:
109
+ assert isinstance(value, bool)
110
+ self._likwid = value
111
+
112
+ @property
113
+ def time_trace(self) -> bool:
114
+ return self._time_trace
115
+
116
+ @property
117
+ def flush_to_disk(self) -> bool:
118
+ return self._flush_to_disk
119
+
120
+ @flush_to_disk.setter
121
+ def flush_to_disk(self, value) -> None:
122
+ if not isinstance(value, bool):
123
+ raise TypeError("flush_to_disk must be a bool")
124
+ self._flush_to_disk = value
125
+
126
+ @time_trace.setter
127
+ def time_trace(self, value: bool) -> None:
128
+ assert isinstance(value, bool)
129
+ self._time_trace = value
130
+
131
+
132
+ class ProfileRegion:
133
+ """Context manager for profiling specific code regions using LIKWID markers."""
134
+
135
+ def __init__(
136
+ self,
137
+ region_name: str,
138
+ time_trace: bool = False,
139
+ buffer_limit: int = 100000,
140
+ file_path: str | None = None,
141
+ flush_to_disk: bool = False,
142
+ profiling_activated: bool = True,
143
+ ):
144
+ if hasattr(self, "_initialized") and self._initialized:
145
+ return
146
+ self._config = ProfilingConfig()
147
+ self._region_name = region_name
148
+ self._time_trace = time_trace
149
+ self._buffer_limit = buffer_limit
150
+ self._file_path = file_path or "profiling_data.h5"
151
+ self._flush_to_disk = flush_to_disk
152
+ self._profiling_activated = profiling_activated
153
+
154
+ # Timer data
155
+ self._ncalls = 0
156
+ self._start_times = []
157
+ self._end_times = []
158
+ self._duration = 0.0
159
+ self._started = False
160
+
161
+ # Create file and datasets if not existing
162
+ if self.flush_to_disk and self._time_trace:
163
+ with h5py.File(self._file_path, "a") as f:
164
+ grp = f.require_group(f"regions/{self._region_name}")
165
+ for name in ["start_times", "end_times", "durations"]:
166
+ if name not in grp:
167
+ grp.create_dataset(
168
+ name,
169
+ shape=(0,),
170
+ maxshape=(None,),
171
+ dtype="f8",
172
+ chunks=True,
173
+ compression="gzip",
174
+ )
175
+
176
+ def __enter__(self):
177
+ if not self.profiling_activated:
178
+ return self
179
+ if self.config.use_likwid:
180
+ self._pylikwid().markerstartregion(self.region_name)
181
+
182
+ if self._time_trace:
183
+
184
+ self._start_time = time.perf_counter()
185
+ self._start_times.append(self._start_time)
186
+ self._started = True
187
+
188
+ self._ncalls += 1
189
+
190
+ return self
191
+
192
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
193
+ if not self.profiling_activated:
194
+ return
195
+ if self.config.use_likwid:
196
+ self._pylikwid().markerstopregion(self.region_name)
197
+ if self._time_trace and self.started:
198
+ end_time = time.perf_counter()
199
+ self._end_times.append(end_time)
200
+ self._started = False
201
+
202
+ if self.flush_to_disk and len(self._start_times) >= self._buffer_limit:
203
+ self.flush()
204
+
205
+ def flush(self) -> None:
206
+ """Append buffered profiling data to the HDF5 file and clear memory."""
207
+ if not self._start_times:
208
+ return
209
+
210
+ starts = self.start_times # np.array(self._start_times, dtype=np.float64)
211
+ ends = self.end_times # np.array(self._end_times, dtype=np.float64)
212
+ durations = self.durations
213
+
214
+ with h5py.File(self._file_path, "a") as f:
215
+ grp = f.require_group(f"regions/{self._region_name}")
216
+ for name, data in [
217
+ ("start_times", starts),
218
+ ("end_times", ends),
219
+ ("durations", durations),
220
+ ]:
221
+ ds = grp[name]
222
+ old_size = ds.shape[0]
223
+ new_size = old_size + len(data)
224
+ ds.resize((new_size,))
225
+ ds[old_size:new_size] = data
226
+
227
+ self._start_times.clear()
228
+ self._end_times.clear()
229
+
230
+ def _pylikwid(self):
231
+ return _import_pylikwid()
232
+
233
+ @property
234
+ def profiling_activated(self) -> bool:
235
+ return self._profiling_activated
236
+
237
+ @property
238
+ def config(self) -> ProfilingConfig:
239
+ return self._config
240
+
241
+ @property
242
+ def durations(self) -> np.ndarray:
243
+ return self.end_times - self.start_times
244
+
245
+ @property
246
+ def end_times(self) -> np.ndarray:
247
+ return np.array(self._end_times)
248
+
249
+ @property
250
+ def flush_to_disk(self) -> bool:
251
+ return self._flush_to_disk
252
+
253
+ @property
254
+ def num_calls(self) -> int:
255
+ return self._ncalls
256
+
257
+ @property
258
+ def region_name(self) -> str:
259
+ return self._region_name
260
+
261
+ @property
262
+ def start_times(self) -> np.ndarray:
263
+ return np.array(self._start_times)
264
+
265
+ @property
266
+ def started(self) -> bool:
267
+ return self._started
268
+
269
+
270
+ class ProfileManager:
271
+ """
272
+ Singleton class to manage and track all ProfileRegion instances.
273
+ """
274
+
275
+ _regions = {}
276
+
277
+ @classmethod
278
+ def reset(cls) -> None:
279
+ cls._regions = {}
280
+
281
+ @classmethod
282
+ def profile_region(cls, region_name) -> ProfileRegion:
283
+ """
284
+ Get an existing ProfileRegion by name, or create a new one if it doesn't exist.
285
+
286
+ Parameters
287
+ ----------
288
+ region_name: str
289
+ The name of the profiling region.
290
+
291
+ Returns
292
+ -------
293
+ ProfileRegion: The ProfileRegion instance.
294
+ """
295
+ if region_name in cls._regions:
296
+ # print(f"Using existing region '{region_name}'...")
297
+ return cls._regions[region_name]
298
+ else:
299
+ # print(f"Creating new region '{region_name}'...")
300
+ # Create and register a new ProfileRegion
301
+ cls._regions[region_name] = ProfileRegion(
302
+ region_name,
303
+ time_trace=ProfilingConfig().time_trace,
304
+ flush_to_disk=ProfilingConfig().flush_to_disk,
305
+ profiling_activated=ProfilingConfig().profiling_activated,
306
+ )
307
+ return cls._regions[region_name]
308
+
309
+ @classmethod
310
+ def profile(cls, region_name: str | None = None) -> Callable:
311
+ """
312
+ Decorator factory for profiling a function.
313
+
314
+ Usage:
315
+ @ProfileManager.profile # region name defaults to function.__name__
316
+ def foo(...): ...
317
+
318
+ @ProfileManager.profile("myregion")
319
+ def bar(...): ...
320
+ """
321
+
322
+ def decorator(func: Callable) -> Callable:
323
+ # Default to function.__name__ if region_name is None
324
+ name = region_name or func.__name__
325
+ region = cls.profile_region(name)
326
+
327
+ if inspect.iscoroutinefunction(func):
328
+ # async function wrapper
329
+ @functools.wraps(func)
330
+ async def async_wrapper(*args, **kwargs):
331
+ with region:
332
+ return await func(*args, **kwargs)
333
+
334
+ return async_wrapper
335
+ else:
336
+
337
+ @functools.wraps(func)
338
+ def sync_wrapper(*args, **kwargs):
339
+ with region:
340
+ return func(*args, **kwargs)
341
+
342
+ return sync_wrapper
343
+
344
+ # If decorator used without parentheses: @ProfileManager.profile
345
+ # Python will pass the function directly to the decorator factory call,
346
+ # but because this is a factory we should allow that too:
347
+ if callable(region_name):
348
+ # invoked as @ProfileManager.profile with no args
349
+ func = region_name
350
+ region_name = None
351
+ return decorator(func)
352
+
353
+ return decorator
354
+
355
+ @classmethod
356
+ def finalize(cls) -> None:
357
+ if ProfilingConfig().flush_to_disk:
358
+ for name, region in cls.get_all_regions().items():
359
+ region.flush()
360
+
361
+ @classmethod
362
+ def get_region(cls, region_name) -> ProfileRegion:
363
+ """
364
+ Get a registered ProfileRegion by name.
365
+
366
+ Parameters
367
+ ----------
368
+ region_name: str
369
+ The name of the profiling region.
370
+
371
+ Returns
372
+ -------
373
+ ProfileRegion or None: The registered ProfileRegion instance or None if not found.
374
+ """
375
+ return cls._regions.get(region_name)
376
+
377
+ @classmethod
378
+ def get_all_regions(cls) -> Dict[str, "ProfileRegion"]:
379
+ """
380
+ Get all registered ProfileRegion instances.
381
+
382
+ Returns
383
+ -------
384
+ dict: Dictionary of all registered ProfileRegion instances.
385
+ """
386
+ return cls._regions
387
+
388
+ @classmethod
389
+ def print_summary(cls) -> None:
390
+ """
391
+ Print a summary of the profiling data for all regions.
392
+ """
393
+
394
+ _config = ProfilingConfig()
395
+ if not _config.time_trace:
396
+ print(
397
+ "time_trace is not set to True --> Time traces are not measured --> Skip printing summary...",
398
+ )
399
+ return
400
+
401
+ print("Profiling Summary:")
402
+ print("=" * 40)
403
+ for name, region in cls._regions.items():
404
+ if region.num_calls > 0:
405
+ total_duration = sum(region.durations)
406
+ average_duration = total_duration / region.num_calls
407
+ min_duration = min(region.durations)
408
+ max_duration = max(region.durations)
409
+ std_duration = np.std(region.durations)
410
+ else:
411
+ total_duration = average_duration = min_duration = max_duration = (
412
+ std_duration
413
+ ) = 0
414
+
415
+ print(f"Region: {name}")
416
+ print(f" Number of Calls: {region.num_calls}")
417
+ print(f" Total Duration: {total_duration:.6f} seconds")
418
+ print(f" Average Duration: {average_duration:.6f} seconds")
419
+ print(f" Min Duration: {min_duration:.6f} seconds")
420
+ print(f" Max Duration: {max_duration:.6f} seconds")
421
+ print(f" Std Deviation: {std_duration:.6f} seconds")
422
+ print("-" * 40)
@@ -0,0 +1,40 @@
1
+ from scope_profiler.profiling import ProfileManager
2
+
3
+
4
+ def loop(
5
+ label,
6
+ num_loops: int = 100,
7
+ ):
8
+ s = 0
9
+ for i in range(num_loops):
10
+ with ProfileManager.profile_region(region_name=label):
11
+ s += 1
12
+
13
+
14
+ if __name__ == "__main__":
15
+ from scope_profiler.profiling import (
16
+ ProfilingConfig,
17
+ )
18
+
19
+ config = ProfilingConfig(
20
+ use_likwid=False,
21
+ time_trace=True,
22
+ flush_to_disk=True,
23
+ )
24
+
25
+ ProfileManager.reset()
26
+ num_loops = 10
27
+
28
+ loop(
29
+ label="loop1",
30
+ num_loops=num_loops,
31
+ )
32
+
33
+ loop(
34
+ label="loop2",
35
+ num_loops=num_loops * 2,
36
+ )
37
+
38
+ ProfileManager.print_summary()
39
+
40
+ ProfileManager.finalize()
@@ -0,0 +1,73 @@
1
+ import pytest
2
+
3
+ import scope_profiler.tests.examples as examples
4
+ from scope_profiler.profiling import (
5
+ ProfileManager,
6
+ ProfilingConfig,
7
+ )
8
+
9
+
10
+ @pytest.mark.parametrize("time_trace", [True, False])
11
+ @pytest.mark.parametrize("use_likwid", [False])
12
+ @pytest.mark.parametrize("num_loops", [10, 50, 100])
13
+ @pytest.mark.parametrize("profiling_activated", [True, False])
14
+ def test_profile_manager(
15
+ time_trace: bool,
16
+ use_likwid: bool,
17
+ num_loops: int,
18
+ profiling_activated: bool,
19
+ ):
20
+
21
+ ProfilingConfig().reset()
22
+ config = ProfilingConfig(
23
+ use_likwid=use_likwid,
24
+ time_trace=time_trace,
25
+ profiling_activated=profiling_activated,
26
+ )
27
+ ProfileManager.reset()
28
+
29
+ examples.loop(
30
+ label="loop1",
31
+ num_loops=num_loops,
32
+ )
33
+
34
+ examples.loop(
35
+ label="loop2",
36
+ num_loops=num_loops * 2,
37
+ )
38
+
39
+ @ProfileManager.profile
40
+ def test_decorator():
41
+ return
42
+
43
+ for i in range(num_loops):
44
+ test_decorator()
45
+
46
+ with ProfileManager.profile_region("main"):
47
+ pass
48
+
49
+ if config.time_trace:
50
+ ProfileManager.print_summary()
51
+
52
+ ProfileManager.finalize()
53
+
54
+ regions = ProfileManager.get_all_regions()
55
+
56
+ if profiling_activated:
57
+ assert regions["loop1"].num_calls == num_loops
58
+ assert regions["loop2"].num_calls == num_loops * 2
59
+ assert regions["test_decorator"].num_calls == num_loops
60
+ assert regions["main"].num_calls == 1
61
+ else:
62
+ assert regions["loop1"].num_calls == 0
63
+ assert regions["loop2"].num_calls == 0
64
+ assert regions["test_decorator"].num_calls == 0
65
+ assert regions["main"].num_calls == 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ test_profile_manager(
70
+ time_trace=True,
71
+ use_likwid=False,
72
+ num_loops=100,
73
+ )
@@ -0,0 +1,75 @@
1
+ import math
2
+ import random
3
+ import time
4
+
5
+ import numpy as np
6
+
7
+ import scope_profiler.tests.examples as examples
8
+ from scope_profiler.profiling import (
9
+ ProfileManager,
10
+ ProfilingConfig,
11
+ )
12
+
13
+
14
+ def random_math(N=100_000):
15
+ s = 0.0
16
+ for _ in range(N):
17
+ x = random.random()
18
+ s += math.sin(x) * math.sqrt(x + 1.2345)
19
+ return s
20
+
21
+
22
+ def test_overhead():
23
+
24
+ config = ProfilingConfig(
25
+ use_likwid=False,
26
+ time_trace=True,
27
+ flush_to_disk=True,
28
+ )
29
+
30
+ ProfileManager.reset()
31
+ num_computations = 1
32
+ num_tests = 10
33
+ N = 1_000_000
34
+
35
+ # Without ProfileManager
36
+ elapsed_no_manager = []
37
+ for _ in range(num_tests):
38
+ t0 = time.perf_counter()
39
+ for _ in range(num_computations):
40
+ # examples.axpy(N = 50)
41
+ random_math(N)
42
+ t1 = time.perf_counter()
43
+ elapsed_no_manager.append(t1 - t0)
44
+
45
+ # With ProfileManager
46
+ elapsed_with_manager = []
47
+ for _ in range(num_tests):
48
+ t0 = time.perf_counter()
49
+ for _ in range(num_computations):
50
+ with ProfileManager.profile_region("main"):
51
+ # examples.axpy(N = 50)
52
+ random_math(N)
53
+ t1 = time.perf_counter()
54
+ elapsed_with_manager.append(t1 - t0)
55
+
56
+ elapsed_no_manager = np.array(elapsed_no_manager)
57
+ elapsed_with_manager = np.array(elapsed_with_manager)
58
+
59
+ time_no_manager = np.min(elapsed_no_manager)
60
+ time_manager = np.min(elapsed_with_manager)
61
+ ratio = time_manager / time_no_manager
62
+ print(f"{time_no_manager = }")
63
+ print(f"{time_manager = }")
64
+ print(f"Overhead ratio = {ratio}")
65
+
66
+ # Very low bar, make sure the overhead is <3%
67
+ assert ratio < 1.03
68
+
69
+ ProfileManager.finalize()
70
+
71
+ # ProfileManager.print_summary()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ test_overhead()
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: scope-profiler
3
+ Version: 0.1
4
+ Summary: Profile code regions in python, optionally with LIKWID markers.
5
+ Author: Max
6
+ Project-URL: Source, https://github.com/max-models/scope_profiler
7
+ Keywords: python
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: h5py
19
+ Requires-Dist: numpy
20
+ Requires-Dist: matplotlib
21
+ Provides-Extra: dev
22
+ Requires-Dist: black[jupyter]; extra == "dev"
23
+ Requires-Dist: isort; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: scope-profiler[docs,test]; extra == "dev"
26
+ Provides-Extra: docs
27
+ Requires-Dist: ipykernel; extra == "docs"
28
+ Requires-Dist: myst-parser; extra == "docs"
29
+ Requires-Dist: nbconvert; extra == "docs"
30
+ Requires-Dist: nbsphinx; extra == "docs"
31
+ Requires-Dist: jupyterlab; extra == "docs"
32
+ Requires-Dist: pre-commit; extra == "docs"
33
+ Requires-Dist: pyproject-fmt; extra == "docs"
34
+ Requires-Dist: sphinx; extra == "docs"
35
+ Requires-Dist: sphinx-book-theme; extra == "docs"
36
+ Provides-Extra: test
37
+ Requires-Dist: coverage; extra == "test"
38
+ Requires-Dist: pytest; extra == "test"
39
+
40
+ # scope-profiler - Python Profiling System with Optional LIKWID Integration
41
+
42
+ This module provides a unified profiling system for Python applications, with optional integration of [LIKWID](https://github.com/RRZE-HPC/likwid) markers using the [pylikwid](https://github.com/RRZE-HPC/pylikwid) marker API for hardware performance counters. It allows you to:
43
+ - Configure profiling globally via a singleton ProfilingConfig.
44
+ - Collect timing data via context-managed profiling regions.
45
+ - Use a clean decorator syntax to profile functions.
46
+ - Optionally record time traces in HDF5 files.
47
+ - Automatically initialize and close LIKWID markers only when needed.
48
+ - Print aggregated summaries of all profiling regions.
49
+
50
+ Documentation: https://max-models.github.io/scope-profiler/
51
+
52
+ ## Install
53
+
54
+ ```
55
+ pip install scope-profiler
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```python
61
+ from scope_profiler.profiling import (
62
+ ProfilingConfig,
63
+ ProfileManager,
64
+ )
65
+
66
+ config = ProfilingConfig(
67
+ use_likwid=False,
68
+ time_trace=True,
69
+ flush_to_disk=True,
70
+ )
71
+
72
+ @ProfileManager.profile("main")
73
+ def main():
74
+ x = 0
75
+ for i in range(10):
76
+ with ProfileManager.profile_region(region_name="iteration"):
77
+ x += 1
78
+
79
+ main()
80
+
81
+ ProfileManager.print_summary()
82
+
83
+ ProfileManager.finalize()
84
+ ```
85
+
86
+ Execution:
87
+
88
+ ```bash
89
+ ❯ python test.py
90
+ Profiling Summary:
91
+ ========================================
92
+ Region: main
93
+ Number of Calls: 1
94
+ Total Duration: 0.000315 seconds
95
+ Average Duration: 0.000315 seconds
96
+ Min Duration: 0.000315 seconds
97
+ Max Duration: 0.000315 seconds
98
+ Std Deviation: 0.000000 seconds
99
+ ----------------------------------------
100
+ Region: iteration
101
+ Number of Calls: 10
102
+ Total Duration: 0.000007 seconds
103
+ Average Duration: 0.000001 seconds
104
+ Min Duration: 0.000000 seconds
105
+ Max Duration: 0.000003 seconds
106
+ Std Deviation: 0.000001 seconds
107
+ ----------------------------------------
108
+ ```
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/scope_profiler/h5reader.py
4
+ src/scope_profiler/post_processing.py
5
+ src/scope_profiler/profiling.py
6
+ src/scope_profiler.egg-info/PKG-INFO
7
+ src/scope_profiler.egg-info/SOURCES.txt
8
+ src/scope_profiler.egg-info/dependency_links.txt
9
+ src/scope_profiler.egg-info/entry_points.txt
10
+ src/scope_profiler.egg-info/requires.txt
11
+ src/scope_profiler.egg-info/top_level.txt
12
+ src/scope_profiler/tests/__init__.py
13
+ src/scope_profiler/tests/examples.py
14
+ src/scope_profiler/tests/test_app.py
15
+ src/scope_profiler/tests/test_overhead.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scope-profiler-pproc = scope_profiler.post_processing:main
@@ -0,0 +1,24 @@
1
+ h5py
2
+ numpy
3
+ matplotlib
4
+
5
+ [dev]
6
+ black[jupyter]
7
+ isort
8
+ ruff
9
+ scope-profiler[docs,test]
10
+
11
+ [docs]
12
+ ipykernel
13
+ myst-parser
14
+ nbconvert
15
+ nbsphinx
16
+ jupyterlab
17
+ pre-commit
18
+ pyproject-fmt
19
+ sphinx
20
+ sphinx-book-theme
21
+
22
+ [test]
23
+ coverage
24
+ pytest
@@ -0,0 +1 @@
1
+ scope_profiler