dsabench 0.1.0__py3-none-any.whl
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.
- bench/__init__.py +86 -0
- bench/auto.py +309 -0
- bench/benchmark.py +423 -0
- bench/compare.py +118 -0
- bench/complexity.py +125 -0
- bench/config.py +153 -0
- bench/cpu.py +50 -0
- bench/decorator.py +138 -0
- bench/exceptions.py +30 -0
- bench/export.py +224 -0
- bench/graph.py +147 -0
- bench/memory.py +113 -0
- bench/profiler.py +155 -0
- bench/py.typed +0 -0
- bench/report.py +239 -0
- bench/stats.py +116 -0
- bench/timer.py +51 -0
- bench/types.py +401 -0
- bench/utils.py +172 -0
- dsabench-0.1.0.dist-info/METADATA +337 -0
- dsabench-0.1.0.dist-info/RECORD +23 -0
- dsabench-0.1.0.dist-info/WHEEL +4 -0
- dsabench-0.1.0.dist-info/licenses/LICENSE +21 -0
bench/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""DsaBench — beautiful one-line benchmarking for DSA and beyond.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from bench import bench
|
|
6
|
+
|
|
7
|
+
answer = bench(solve, arg1, arg2) # runs, reports, returns the value
|
|
8
|
+
|
|
9
|
+
See :func:`benchmark` (decorator), :func:`auto` (automatic mode),
|
|
10
|
+
:func:`compare`, :func:`estimate_complexity`, and :func:`configure`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .auto import AutoBench, AutoReport, FunctionRecord, auto, stop_auto
|
|
14
|
+
from .benchmark import bench, bench_async, run
|
|
15
|
+
from .compare import compare
|
|
16
|
+
from .complexity import estimate_complexity
|
|
17
|
+
from .config import Config, configure, get_config, reset_config
|
|
18
|
+
from .decorator import benchmark
|
|
19
|
+
from .exceptions import BenchError, ConfigurationError, ExportError, GraphError
|
|
20
|
+
from .export import auto_export, export_result, to_csv, to_json, to_markdown
|
|
21
|
+
from .graph import plot_comparison, plot_memory, plot_runtime
|
|
22
|
+
from .types import (
|
|
23
|
+
MODE_DEFAULTS,
|
|
24
|
+
BenchmarkResult,
|
|
25
|
+
CallStats,
|
|
26
|
+
ComparisonEntry,
|
|
27
|
+
ComparisonResult,
|
|
28
|
+
ComplexityFit,
|
|
29
|
+
ComplexityResult,
|
|
30
|
+
CPUStats,
|
|
31
|
+
ExceptionInfo,
|
|
32
|
+
MemoryStats,
|
|
33
|
+
Mode,
|
|
34
|
+
TimingStats,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# core API
|
|
41
|
+
"bench",
|
|
42
|
+
"bench_async",
|
|
43
|
+
"run",
|
|
44
|
+
"benchmark",
|
|
45
|
+
"auto",
|
|
46
|
+
"stop_auto",
|
|
47
|
+
"compare",
|
|
48
|
+
"estimate_complexity",
|
|
49
|
+
# configuration
|
|
50
|
+
"configure",
|
|
51
|
+
"get_config",
|
|
52
|
+
"reset_config",
|
|
53
|
+
"Config",
|
|
54
|
+
# export & graphs
|
|
55
|
+
"export_result",
|
|
56
|
+
"auto_export",
|
|
57
|
+
"to_json",
|
|
58
|
+
"to_csv",
|
|
59
|
+
"to_markdown",
|
|
60
|
+
"plot_runtime",
|
|
61
|
+
"plot_memory",
|
|
62
|
+
"plot_comparison",
|
|
63
|
+
# types
|
|
64
|
+
"Mode",
|
|
65
|
+
"MODE_DEFAULTS",
|
|
66
|
+
"TimingStats",
|
|
67
|
+
"MemoryStats",
|
|
68
|
+
"CPUStats",
|
|
69
|
+
"CallStats",
|
|
70
|
+
"ExceptionInfo",
|
|
71
|
+
"BenchmarkResult",
|
|
72
|
+
"ComparisonEntry",
|
|
73
|
+
"ComparisonResult",
|
|
74
|
+
"ComplexityFit",
|
|
75
|
+
"ComplexityResult",
|
|
76
|
+
"AutoBench",
|
|
77
|
+
"AutoReport",
|
|
78
|
+
"FunctionRecord",
|
|
79
|
+
# exceptions
|
|
80
|
+
"BenchError",
|
|
81
|
+
"ConfigurationError",
|
|
82
|
+
"ExportError",
|
|
83
|
+
"GraphError",
|
|
84
|
+
# metadata
|
|
85
|
+
"__version__",
|
|
86
|
+
]
|
bench/auto.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Automatic benchmarking of every user-defined function call.
|
|
2
|
+
|
|
3
|
+
Calling :func:`auto` installs a lightweight :func:`sys.setprofile` hook (and
|
|
4
|
+
:func:`threading.setprofile` for new threads). From that moment on, every
|
|
5
|
+
call of a *user-defined* function — code living outside the standard
|
|
6
|
+
library, site-packages, and bench itself — is timed and aggregated.
|
|
7
|
+
|
|
8
|
+
Per top-level call a compact live line is printed; :func:`stop_auto` (or
|
|
9
|
+
interpreter exit) prints a ranked summary table.
|
|
10
|
+
|
|
11
|
+
Known limitations (by design of the profile-hook approach):
|
|
12
|
+
|
|
13
|
+
* Generator bodies are measured per resume slice, not per full iteration.
|
|
14
|
+
* While :func:`bench.bench` runs its instrumented pass, its own
|
|
15
|
+
:class:`~bench.profiler.CallTracker` temporarily supersedes the auto hook.
|
|
16
|
+
* Only functions, not C builtins, are captured.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import atexit
|
|
22
|
+
import fnmatch
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from types import FrameType, TracebackType
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from rich import box
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
|
|
34
|
+
from .config import get_config
|
|
35
|
+
from .timer import cpu_ns, wall_ns
|
|
36
|
+
from .utils import describe_location, format_time_ns, is_user_file
|
|
37
|
+
|
|
38
|
+
__all__ = ["auto", "stop_auto", "AutoBench", "AutoReport", "FunctionRecord"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class FunctionRecord:
|
|
43
|
+
"""Aggregated statistics for one user function under auto mode."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
location: str
|
|
47
|
+
calls: int = 0
|
|
48
|
+
total_wall_ns: int = 0
|
|
49
|
+
total_cpu_ns: int = 0
|
|
50
|
+
min_wall_ns: int = field(default=2**63 - 1)
|
|
51
|
+
max_wall_ns: int = 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def average_wall_ns(self) -> float:
|
|
55
|
+
"""Average wall time per call in nanoseconds."""
|
|
56
|
+
return self.total_wall_ns / self.calls if self.calls else 0.0
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
"""Return a JSON-serialisable representation."""
|
|
60
|
+
return {
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"location": self.location,
|
|
63
|
+
"calls": self.calls,
|
|
64
|
+
"total_wall_ns": self.total_wall_ns,
|
|
65
|
+
"total_cpu_ns": self.total_cpu_ns,
|
|
66
|
+
"average_wall_ns": self.average_wall_ns,
|
|
67
|
+
"min_wall_ns": self.min_wall_ns if self.calls else 0,
|
|
68
|
+
"max_wall_ns": self.max_wall_ns,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class AutoReport:
|
|
74
|
+
"""Summary returned by :meth:`AutoBench.stop`."""
|
|
75
|
+
|
|
76
|
+
records: list[FunctionRecord]
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
"""Return a JSON-serialisable representation."""
|
|
80
|
+
return {"records": [record.to_dict() for record in self.records]}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_ACTIVE: AutoBench | None = None
|
|
84
|
+
_state_lock = threading.RLock()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AutoBench:
|
|
88
|
+
"""Automatic benchmarking session (see module docstring).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
live: Print a one-line timing for every top-level user call.
|
|
92
|
+
include: Optional function names or fnmatch patterns (e.g.
|
|
93
|
+
``["solve*"]``) to capture exclusively.
|
|
94
|
+
exclude: Optional function names or fnmatch patterns to ignore.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
live: bool = True,
|
|
100
|
+
include: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
|
|
101
|
+
exclude: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.live = live
|
|
104
|
+
self.include = frozenset(include) if include else None
|
|
105
|
+
self.exclude = frozenset(exclude) if exclude else frozenset()
|
|
106
|
+
self.records: dict[Any, FunctionRecord] = {}
|
|
107
|
+
self.last_report: AutoReport | None = None
|
|
108
|
+
self._local = threading.local()
|
|
109
|
+
self._prev_profile: Any = None
|
|
110
|
+
self._prev_thread_profile: Any = None
|
|
111
|
+
self._running = False
|
|
112
|
+
self._console = Console(no_color=not get_config().color, highlight=False)
|
|
113
|
+
|
|
114
|
+
# -- qualification ------------------------------------------------------
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _matches(patterns: frozenset[str], name: str, qualname: str) -> bool:
|
|
117
|
+
"""True when *name* or *qualname* matches any fnmatch pattern."""
|
|
118
|
+
return any(
|
|
119
|
+
fnmatch.fnmatchcase(name, pat) or fnmatch.fnmatchcase(qualname, pat) for pat in patterns
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _qualifies(self, code: Any) -> bool:
|
|
123
|
+
name = code.co_name
|
|
124
|
+
if name.startswith("<"): # <lambda>, <listcomp>, <module>, ...
|
|
125
|
+
return False
|
|
126
|
+
if not is_user_file(code.co_filename):
|
|
127
|
+
return False
|
|
128
|
+
qualname = getattr(code, "co_qualname", name)
|
|
129
|
+
if self.include is not None:
|
|
130
|
+
return self._matches(self.include, name, qualname)
|
|
131
|
+
return not self._matches(self.exclude, name, qualname)
|
|
132
|
+
|
|
133
|
+
# -- profile hook ----------------------------------------------------------
|
|
134
|
+
def _hook(self, frame: FrameType, event: str, arg: Any) -> None:
|
|
135
|
+
if event == "call":
|
|
136
|
+
code = frame.f_code
|
|
137
|
+
if not self._qualifies(code):
|
|
138
|
+
return
|
|
139
|
+
stack = getattr(self._local, "stack", None)
|
|
140
|
+
if stack is None:
|
|
141
|
+
stack = self._local.stack = []
|
|
142
|
+
is_top = not stack
|
|
143
|
+
stack.append((frame, wall_ns(), cpu_ns(), is_top))
|
|
144
|
+
elif event == "return":
|
|
145
|
+
stack = getattr(self._local, "stack", None)
|
|
146
|
+
if not stack:
|
|
147
|
+
return
|
|
148
|
+
top_frame, w0, c0, is_top = stack[-1]
|
|
149
|
+
if top_frame is not frame:
|
|
150
|
+
return # return of a non-tracked frame interleaved
|
|
151
|
+
stack.pop()
|
|
152
|
+
wall = wall_ns() - w0
|
|
153
|
+
cpu = cpu_ns() - c0
|
|
154
|
+
code = frame.f_code
|
|
155
|
+
record = self.records.get(code)
|
|
156
|
+
if record is None:
|
|
157
|
+
record = self.records[code] = FunctionRecord(
|
|
158
|
+
name=getattr(code, "co_qualname", code.co_name),
|
|
159
|
+
location=describe_location(code.co_filename, code.co_firstlineno),
|
|
160
|
+
)
|
|
161
|
+
record.calls += 1
|
|
162
|
+
record.total_wall_ns += wall
|
|
163
|
+
record.total_cpu_ns += cpu
|
|
164
|
+
if wall < record.min_wall_ns:
|
|
165
|
+
record.min_wall_ns = wall
|
|
166
|
+
if wall > record.max_wall_ns:
|
|
167
|
+
record.max_wall_ns = wall
|
|
168
|
+
if self.live and is_top:
|
|
169
|
+
# Safe: CPython suppresses profile events raised by the hook itself.
|
|
170
|
+
self._console.print(
|
|
171
|
+
Text("⚡ ", style="cyan")
|
|
172
|
+
+ Text(record.name, style="bold magenta")
|
|
173
|
+
+ Text(f" {format_time_ns(wall)}", style="green")
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# -- lifecycle ----------------------------------------------------------------
|
|
177
|
+
@property
|
|
178
|
+
def active(self) -> bool:
|
|
179
|
+
"""``True`` while this session's hooks are installed."""
|
|
180
|
+
return self._running
|
|
181
|
+
|
|
182
|
+
def start(self) -> AutoBench:
|
|
183
|
+
"""Install the hooks and register this session as the active one."""
|
|
184
|
+
global _ACTIVE
|
|
185
|
+
with _state_lock:
|
|
186
|
+
if _ACTIVE is not None and _ACTIVE is not self:
|
|
187
|
+
_ACTIVE.stop(print_summary=False)
|
|
188
|
+
self._prev_profile = sys.getprofile()
|
|
189
|
+
self._prev_thread_profile = threading.getprofile()
|
|
190
|
+
sys.setprofile(self._hook)
|
|
191
|
+
threading.setprofile(self._hook)
|
|
192
|
+
self._running = True
|
|
193
|
+
_ACTIVE = self
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def stop(self, print_summary: bool = True) -> AutoReport:
|
|
197
|
+
"""Remove the hooks and return (optionally print) the summary.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
print_summary: Print the ranked table when records exist.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
An :class:`AutoReport` with per-function aggregates ranked by
|
|
204
|
+
total wall time.
|
|
205
|
+
"""
|
|
206
|
+
global _ACTIVE
|
|
207
|
+
with _state_lock:
|
|
208
|
+
if self._running:
|
|
209
|
+
sys.setprofile(self._prev_profile)
|
|
210
|
+
threading.setprofile(self._prev_thread_profile)
|
|
211
|
+
self._running = False
|
|
212
|
+
if _ACTIVE is self:
|
|
213
|
+
_ACTIVE = None
|
|
214
|
+
ranked = sorted(self.records.values(), key=lambda r: r.total_wall_ns, reverse=True)
|
|
215
|
+
report = AutoReport(records=ranked)
|
|
216
|
+
self.last_report = report
|
|
217
|
+
if print_summary:
|
|
218
|
+
self._print_summary(report)
|
|
219
|
+
return report
|
|
220
|
+
|
|
221
|
+
def __enter__(self) -> AutoBench:
|
|
222
|
+
return self.start()
|
|
223
|
+
|
|
224
|
+
def __exit__(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: type[BaseException] | None,
|
|
227
|
+
exc: BaseException | None,
|
|
228
|
+
tb: TracebackType | None,
|
|
229
|
+
) -> None:
|
|
230
|
+
self.stop(print_summary=True)
|
|
231
|
+
|
|
232
|
+
# -- rendering ------------------------------------------------------------------
|
|
233
|
+
def _print_summary(self, report: AutoReport) -> None:
|
|
234
|
+
if not report.records:
|
|
235
|
+
self._console.print(Text("bench.auto: no user functions captured", style="yellow"))
|
|
236
|
+
return
|
|
237
|
+
table = Table(
|
|
238
|
+
box=box.ROUNDED,
|
|
239
|
+
border_style="cyan",
|
|
240
|
+
title="⚡ Auto Benchmark Summary",
|
|
241
|
+
title_justify="left",
|
|
242
|
+
)
|
|
243
|
+
table.add_column("Function", style="bold")
|
|
244
|
+
table.add_column("Location")
|
|
245
|
+
table.add_column("Calls", justify="right")
|
|
246
|
+
table.add_column("Total", justify="right")
|
|
247
|
+
table.add_column("Average", justify="right")
|
|
248
|
+
table.add_column("Min", justify="right")
|
|
249
|
+
table.add_column("Max", justify="right")
|
|
250
|
+
for record in report.records:
|
|
251
|
+
table.add_row(
|
|
252
|
+
record.name,
|
|
253
|
+
record.location,
|
|
254
|
+
f"{record.calls:,}",
|
|
255
|
+
format_time_ns(record.total_wall_ns),
|
|
256
|
+
format_time_ns(record.average_wall_ns),
|
|
257
|
+
format_time_ns(record.min_wall_ns if record.calls else 0),
|
|
258
|
+
format_time_ns(record.max_wall_ns),
|
|
259
|
+
)
|
|
260
|
+
self._console.print(table)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def auto(
|
|
264
|
+
live: bool = True,
|
|
265
|
+
include: set[str] | list[str] | tuple[str, ...] | None = None,
|
|
266
|
+
exclude: set[str] | list[str] | tuple[str, ...] | None = None,
|
|
267
|
+
) -> AutoBench:
|
|
268
|
+
"""Start automatic benchmarking of every user-defined function call.
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
>>> from bench import auto
|
|
272
|
+
>>> auto() # doctest: +SKIP
|
|
273
|
+
>>> def work(n):
|
|
274
|
+
... return sum(range(n))
|
|
275
|
+
>>> work(10_000) # printed and recorded automatically
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
live: Print a one-line timing for each top-level user call.
|
|
279
|
+
include: Capture only these function names or fnmatch patterns.
|
|
280
|
+
exclude: Ignore these function names or fnmatch patterns.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The started :class:`AutoBench` session (also usable as a context
|
|
284
|
+
manager and stoppable via :func:`stop_auto`).
|
|
285
|
+
"""
|
|
286
|
+
return AutoBench(live=live, include=include, exclude=exclude).start()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def stop_auto(print_summary: bool = True) -> AutoReport | None:
|
|
290
|
+
"""Stop the active :func:`auto` session, if any.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
print_summary: Print the ranked summary table.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
The session's :class:`AutoReport`, or ``None`` when auto mode was
|
|
297
|
+
not running.
|
|
298
|
+
"""
|
|
299
|
+
session = _ACTIVE
|
|
300
|
+
if session is None:
|
|
301
|
+
return None
|
|
302
|
+
return session.stop(print_summary=print_summary)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@atexit.register
|
|
306
|
+
def _print_summary_at_exit() -> None: # pragma: no cover - interpreter teardown
|
|
307
|
+
session = _ACTIVE
|
|
308
|
+
if session is not None and session.active:
|
|
309
|
+
session.stop(print_summary=True)
|