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 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)