Skip to content

Commit 53c22c6

Browse files
authored
Merge pull request #3 from mutating/develop
0.0.3
2 parents 5a877a8 + c6a6863 commit 53c22c6

24 files changed

Lines changed: 2360 additions & 110 deletions

README.md

Lines changed: 226 additions & 60 deletions
Large diffs are not rendered by default.

microbenchmark/__main__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import sys
5+
from pathlib import Path
6+
7+
from microbenchmark.scenario import Scenario
8+
from microbenchmark.scenario_group import ScenarioGroup
9+
10+
11+
def main(argv: list[str] | None = None) -> None:
12+
args = argv if argv is not None else sys.argv[1:]
13+
14+
if not args or ':' not in args[0]:
15+
sys.stderr.write(
16+
'microbenchmark: expected TARGET in the form module.path:attr\n'
17+
'Usage: microbenchmark module.path:attr [OPTIONS]\n',
18+
)
19+
sys.exit(3)
20+
21+
spec, *rest = args
22+
colon_pos = spec.index(':')
23+
module_path = spec[:colon_pos]
24+
attr_name = spec[colon_pos + 1:]
25+
26+
if not module_path or not attr_name:
27+
sys.stderr.write(
28+
f'microbenchmark: invalid target {spec!r} — '
29+
'expected non-empty module and attribute\n',
30+
)
31+
sys.exit(3)
32+
33+
# Ensure CWD is importable so local (non-installed) modules can be found.
34+
cwd = str(Path.cwd())
35+
if cwd not in sys.path and '' not in sys.path:
36+
sys.path.insert(0, cwd) # pragma: no cover
37+
38+
try:
39+
module = importlib.import_module(module_path)
40+
except ModuleNotFoundError as exc:
41+
sys.stderr.write(f'microbenchmark: cannot import module {module_path!r}: {exc}\n')
42+
sys.exit(3)
43+
44+
sentinel = object()
45+
target: object = getattr(module, attr_name, sentinel)
46+
if target is sentinel:
47+
sys.stderr.write(
48+
f'microbenchmark: module {module_path!r} has no attribute {attr_name!r}\n',
49+
)
50+
sys.exit(3)
51+
52+
if not isinstance(target, (Scenario, ScenarioGroup)):
53+
sys.stderr.write(
54+
f'microbenchmark: {module_path}:{attr_name} is a '
55+
f'{type(target).__name__!r}, expected Scenario or ScenarioGroup\n',
56+
)
57+
sys.exit(3)
58+
59+
target.cli(argv=rest)
60+
61+
62+
if __name__ == '__main__':
63+
main()

microbenchmark/_render.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
import shutil
4+
from collections.abc import Sequence
5+
6+
_BLOCKS = ' ▁▂▃▄▅▆▇█'
7+
8+
9+
def _format_duration(t: float) -> str:
10+
"""Format *t* seconds into a compact human-readable string."""
11+
if t >= 1.0:
12+
return f'{t:.3f}s'
13+
if t >= 1e-3:
14+
return f'{t * 1e3:.2f}ms'
15+
if t >= 1e-6:
16+
return f'{t * 1e6:.2f}\u03bcs'
17+
return f'{t * 1e9:.2f}ns'
18+
19+
20+
def terminal_width() -> int:
21+
"""Return the current terminal width, clamped to a minimum of 20."""
22+
cols = shutil.get_terminal_size((80, 24)).columns
23+
return max(cols, 20)
24+
25+
26+
def draw_box(lines: list[str], width: int) -> list[str]:
27+
"""Draw a Unicode border box around *lines*.
28+
29+
Each output line is exactly *width* characters wide. Content lines are
30+
padded with spaces or truncated to ``width - 4`` characters (2 chars for
31+
the border + space on each side).
32+
33+
Box drawing uses ASCII + box-drawing characters (``╭╮╰╯│─``). Width is
34+
measured by ``len()``, which counts every character as 1 column. Emoji or
35+
CJK characters may cause visual misalignment.
36+
37+
Args:
38+
lines: Content lines to render inside the box.
39+
width: Total width of each output line, including borders.
40+
"""
41+
inner_width = width - 4 # 2 for │ and 1 space on each side
42+
top = '╭' + '─' * (width - 2) + '╮'
43+
bottom = '╰' + '─' * (width - 2) + '╯'
44+
result = [top]
45+
for line in lines:
46+
if len(line) > inner_width:
47+
content = line[:inner_width]
48+
else:
49+
content = line.ljust(inner_width)
50+
result.append('│ ' + content + ' │')
51+
result.append(bottom)
52+
return result
53+
54+
55+
def draw_nested(
56+
inner_blocks: list[list[str]],
57+
extras: list[str],
58+
width: int,
59+
) -> list[str]:
60+
"""Draw an outer border box wrapping pre-rendered *inner_blocks*.
61+
62+
Each inner block is already a list of strings (e.g. from :func:`draw_box`).
63+
They are placed inside the outer border with 1-space padding on each side.
64+
*extras* are additional plain-text lines rendered after the inner blocks,
65+
also padded.
66+
67+
Args:
68+
inner_blocks: List of already-rendered blocks (each a list of strings).
69+
extras: Extra plain-text lines appended after the inner blocks.
70+
width: Total width of the outer box.
71+
"""
72+
inner_width = width - 4 # space for │ + space on each side
73+
74+
def pad_inner_line(line: str) -> str:
75+
if len(line) > inner_width:
76+
content = line[:inner_width]
77+
else:
78+
content = line.ljust(inner_width)
79+
return '│ ' + content + ' │'
80+
81+
top = '╭' + '─' * (width - 2) + '╮'
82+
bottom = '╰' + '─' * (width - 2) + '╯'
83+
result = [top]
84+
for block in inner_blocks:
85+
for line in block:
86+
result.append(pad_inner_line(line))
87+
for line in extras:
88+
result.append(pad_inner_line(line))
89+
result.append(bottom)
90+
return result
91+
92+
93+
def histogram_bounds(durations: Sequence[float]) -> tuple[float, float]:
94+
"""Return ``(lo, hi)`` bounds for a histogram: minimum and p99 value.
95+
96+
The p99 clip prevents extreme outliers from compressing the bulk of the
97+
distribution into the leftmost column.
98+
99+
Args:
100+
durations: Sequence of per-call timings in seconds.
101+
102+
Raises:
103+
ValueError: If *durations* is empty.
104+
"""
105+
if not durations:
106+
raise ValueError('durations must not be empty')
107+
sorted_durs = sorted(durations)
108+
lo = sorted_durs[0]
109+
p99_idx = min(len(sorted_durs) - 1, int(len(sorted_durs) * 0.99))
110+
return lo, sorted_durs[p99_idx]
111+
112+
113+
def draw_histogram_axis(lo: float, hi: float, width: int) -> str:
114+
"""Return a single-line x-axis label for a histogram with bounds *lo*/*hi*.
115+
116+
The minimum value is left-aligned; the p99 clip value is right-aligned.
117+
Both are formatted with auto-selected time units (ns / μs / ms / s).
118+
119+
Args:
120+
lo: Minimum value displayed on the x-axis.
121+
hi: Maximum value (p99 clip point) displayed on the x-axis.
122+
width: Total width of the label in characters.
123+
"""
124+
if width < 1:
125+
return ''
126+
left = _format_duration(lo)
127+
right = _format_duration(hi) + ' (p99)'
128+
if len(left) + 1 + len(right) > width:
129+
return left[:width]
130+
spaces = width - len(left) - len(right)
131+
return left + ' ' * spaces + right
132+
133+
134+
def draw_histogram(durations: Sequence[float], width: int, height: int) -> list[str]:
135+
"""Render an ASCII bar chart of *durations* as a ``height`` x ``width`` grid.
136+
137+
The output is ``width`` characters wide and ``height`` rows tall. Each
138+
cell uses one of the Unicode block characters ``' ▁▂▃▄▅▆▇█'`` so bar
139+
heights are resolved at 1/8-row precision, giving smooth transitions
140+
between adjacent buckets. Returns an empty list when any dimension is
141+
zero/negative or when *durations* is empty.
142+
143+
Internally the data is bucketed into at most 20 bins regardless of
144+
*width*. Each bin is then rendered as ``width // n_buckets`` characters
145+
wide. This prevents timer-quantisation artefacts from producing a single
146+
spike with stray isolated pixels.
147+
148+
The x-axis upper bound is clipped to the p99 value via
149+
:func:`histogram_bounds`. Values above the clip point are omitted.
150+
151+
When all values are identical (``hi == lo``), the middle bin is drawn
152+
full-height and all others are left empty.
153+
154+
Args:
155+
durations: Sequence of per-call timings in seconds.
156+
width: Total number of output characters per row.
157+
height: Number of rows in the chart.
158+
"""
159+
if width < 1 or height < 1 or len(durations) == 0:
160+
return []
161+
162+
lo, hi = histogram_bounds(durations)
163+
164+
# Cap the number of data buckets at 20 so that adjacent quantised timer
165+
# values are merged into the same wider bar.
166+
n_buckets = min(width, 20)
167+
counts = [0] * n_buckets
168+
169+
if hi == lo:
170+
counts[n_buckets // 2] = 1
171+
else:
172+
span = hi - lo
173+
for d in durations:
174+
if d > hi:
175+
continue
176+
idx = min(n_buckets - 1, int((d - lo) / span * n_buckets))
177+
counts[idx] += 1
178+
179+
max_count = max(counts)
180+
bar_heights_float = [
181+
c / max_count * height if c > 0 else 0.0
182+
for c in counts
183+
]
184+
185+
rows: list[str] = []
186+
for row in range(height - 1, -1, -1):
187+
line_chars: list[str] = []
188+
for col in range(width):
189+
bh = bar_heights_float[col * n_buckets // width]
190+
if bh >= row + 1:
191+
line_chars.append('█')
192+
elif bh > row:
193+
frac = bh - row
194+
line_chars.append(_BLOCKS[max(1, min(8, round(frac * 8)))])
195+
else:
196+
line_chars.append(' ')
197+
rows.append(''.join(line_chars))
198+
return rows

microbenchmark/arguments.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
from printo import describe_data_object
4+
from sigmatch import PossibleCallMatcher
5+
from sigmatch.errors import SignatureNotFoundError, UnsupportedSignatureError
46

57

68
class arguments: # noqa: N801
@@ -28,3 +30,25 @@ def __eq__(self, other: object) -> bool:
2830

2931
def __hash__(self) -> int:
3032
return hash((self.args, tuple(sorted(self.kwargs.items()))))
33+
34+
def match(self, function: object) -> bool:
35+
"""Check whether *function* can be called with these arguments.
36+
37+
Returns ``True`` if the call is compatible with the function's
38+
signature, ``False`` if not.
39+
40+
**Limitation:** if Python cannot introspect the signature of
41+
*function* (e.g. built-in / C-extension functions such as ``len``),
42+
the check is silently skipped and ``True`` is returned. The function
43+
will be validated at runtime when the benchmark actually runs.
44+
"""
45+
from sigmatch import SignatureMismatchError # noqa: PLC0415
46+
shape = ('.',) * len(self.args) + tuple(self.kwargs)
47+
matcher: PossibleCallMatcher = PossibleCallMatcher(*shape)
48+
try:
49+
matcher.match(function, raise_exception=True) # type: ignore[arg-type]
50+
return True
51+
except SignatureMismatchError:
52+
return False
53+
except (SignatureNotFoundError, UnsupportedSignatureError):
54+
return True

microbenchmark/benchmark_result.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class _ScenarioMeta(TypedDict):
1919

2020
class _ResultJson(TypedDict):
2121
durations: list[float]
22+
total_duration: float
2223
is_primary: bool
2324
scenario: _ScenarioMeta | None
2425

@@ -27,16 +28,19 @@ class _ResultJson(TypedDict):
2728
class BenchmarkResult:
2829
scenario: Scenario | None
2930
durations: tuple[float, ...]
31+
total_duration: float
3032
is_primary: bool = True
3133

3234
mean: float = field(init=False)
3335
best: float = field(init=False)
3436
worst: float = field(init=False)
37+
functions_duration: float = field(init=False)
3538

3639
def __post_init__(self) -> None:
3740
self.mean = math.fsum(self.durations) / len(self.durations)
3841
self.best = min(self.durations)
3942
self.worst = max(self.durations)
43+
self.functions_duration = math.fsum(self.durations)
4044

4145
def percentile(self, p: float) -> BenchmarkResult:
4246
if not (0 < p <= 100):
@@ -46,6 +50,7 @@ def percentile(self, p: float) -> BenchmarkResult:
4650
return BenchmarkResult(
4751
scenario=self.scenario,
4852
durations=trimmed,
53+
total_duration=0.0,
4954
is_primary=False,
5055
)
5156

@@ -73,6 +78,7 @@ def to_json(self) -> str:
7378
scenario_meta = None
7479
data: _ResultJson = _ResultJson(
7580
durations=list(self.durations),
81+
total_duration=self.total_duration,
7682
is_primary=self.is_primary,
7783
scenario=scenario_meta,
7884
)
@@ -91,8 +97,14 @@ def from_json(cls, data: str) -> BenchmarkResult:
9197
raise ValueError('durations must be a list')
9298
if not isinstance(raw_is_primary, bool):
9399
raise ValueError('is_primary must be a bool')
100+
raw_total: object = raw.get('total_duration')
101+
if raw_total is None:
102+
total_duration = math.fsum(float(d) for d in raw_durations)
103+
else:
104+
total_duration = float(raw_total) # type: ignore[arg-type]
94105
return cls(
95106
scenario=None,
96107
durations=tuple(float(d) for d in raw_durations),
108+
total_duration=total_duration,
97109
is_primary=raw_is_primary,
98110
)

0 commit comments

Comments
 (0)