Skip to content

Commit 964d53f

Browse files
Евгений БлиновЕвгений Блинов
authored andcommitted
5 new features:
- Feature 1 — arguments.match() + signature validation in Scenario.__init__ - Feature 2 — microbenchmark CLI entry point (__main__.py), argv parameter on .cli() - Feature 3 — BenchmarkResult.total_duration and functions_duration fields, shown as total: / fn total: - Feature 4 — Unicode box borders ╭─╮│╰─╯ around .cli() output, nested borders for ScenarioGroup - Feature 5 — --histogram flag with draw_histogram() producing ASCII █ bar charts (8 rows fixed height)
1 parent 88aa3fa commit 964d53f

24 files changed

Lines changed: 2031 additions & 104 deletions

README.md

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

microbenchmark/__main__.py

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

microbenchmark/_render.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
import shutil
4+
from collections.abc import Sequence
5+
6+
7+
def terminal_width() -> int:
8+
"""Return the current terminal width, clamped to a minimum of 20."""
9+
cols = shutil.get_terminal_size((80, 24)).columns
10+
return max(cols, 20)
11+
12+
13+
def draw_box(lines: list[str], width: int) -> list[str]:
14+
"""Draw a Unicode border box around *lines*.
15+
16+
Each output line is exactly *width* characters wide. Content lines are
17+
padded with spaces or truncated to ``width - 4`` characters (2 chars for
18+
the border + space on each side).
19+
20+
Box drawing uses ASCII + box-drawing characters (``╭╮╰╯│─``). Width is
21+
measured by ``len()``, which counts every character as 1 column. Emoji or
22+
CJK characters may cause visual misalignment.
23+
24+
Args:
25+
lines: Content lines to render inside the box.
26+
width: Total width of each output line, including borders.
27+
"""
28+
inner_width = width - 4 # 2 for │ and 1 space on each side
29+
top = '╭' + '─' * (width - 2) + '╮'
30+
bottom = '╰' + '─' * (width - 2) + '╯'
31+
result = [top]
32+
for line in lines:
33+
if len(line) > inner_width:
34+
content = line[:inner_width]
35+
else:
36+
content = line.ljust(inner_width)
37+
result.append('│ ' + content + ' │')
38+
result.append(bottom)
39+
return result
40+
41+
42+
def draw_nested(
43+
inner_blocks: list[list[str]],
44+
extras: list[str],
45+
width: int,
46+
) -> list[str]:
47+
"""Draw an outer border box wrapping pre-rendered *inner_blocks*.
48+
49+
Each inner block is already a list of strings (e.g. from :func:`draw_box`).
50+
They are placed inside the outer border with 1-space padding on each side.
51+
*extras* are additional plain-text lines rendered after the inner blocks,
52+
also padded.
53+
54+
Args:
55+
inner_blocks: List of already-rendered blocks (each a list of strings).
56+
extras: Extra plain-text lines appended after the inner blocks.
57+
width: Total width of the outer box.
58+
"""
59+
inner_width = width - 4 # space for │ + space on each side
60+
61+
def pad_inner_line(line: str) -> str:
62+
if len(line) > inner_width:
63+
content = line[:inner_width]
64+
else:
65+
content = line.ljust(inner_width)
66+
return '│ ' + content + ' │'
67+
68+
top = '╭' + '─' * (width - 2) + '╮'
69+
bottom = '╰' + '─' * (width - 2) + '╯'
70+
result = [top]
71+
for block in inner_blocks:
72+
for line in block:
73+
result.append(pad_inner_line(line))
74+
for line in extras:
75+
result.append(pad_inner_line(line))
76+
result.append(bottom)
77+
return result
78+
79+
80+
def draw_histogram(durations: Sequence[float], width: int, height: int) -> list[str]:
81+
"""Render an ASCII bar chart of *durations* as a ``height`` x ``width`` grid.
82+
83+
Each column is one bucket; each row is one unit of height. Filled cells
84+
use ``'█'``; empty cells use ``' '``. Returns an empty list when any
85+
dimension is zero/negative or when *durations* is empty.
86+
87+
When all values are identical (``hi == lo``), the middle column is drawn
88+
full-height and all other columns are left empty.
89+
90+
Args:
91+
durations: Sequence of per-call timings in seconds.
92+
width: Number of columns (buckets) in the chart.
93+
height: Number of rows in the chart.
94+
"""
95+
if width < 1 or height < 1 or len(durations) == 0:
96+
return []
97+
98+
lo = min(durations)
99+
hi = max(durations)
100+
101+
counts = [0] * width
102+
103+
if hi == lo:
104+
counts[width // 2] = 1
105+
else:
106+
span = hi - lo
107+
for d in durations:
108+
idx = min(width - 1, int((d - lo) / span * width))
109+
counts[idx] += 1
110+
111+
max_count = max(counts)
112+
bar_heights = [
113+
(max(1, round(c / max_count * height)) if c > 0 else 0)
114+
for c in counts
115+
]
116+
117+
rows: list[str] = []
118+
for row in range(height - 1, -1, -1):
119+
line = ''.join('█' if bar_heights[col] > row else ' ' for col in range(width))
120+
rows.append(line)
121+
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
)

microbenchmark/scenario.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
from printo import describe_data_object
99
from printo.reprs import superrepr
10+
from sigmatch import SignatureMismatchError
1011

12+
from microbenchmark._render import draw_box, draw_histogram, terminal_width
1113
from microbenchmark.arguments import arguments as Arguments # noqa: N812
1214
from microbenchmark.benchmark_result import BenchmarkResult
1315

@@ -19,6 +21,7 @@ class _CliArgs:
1921
def __init__(self) -> None:
2022
self.number: int | None = None
2123
self.max_mean: float | None = None
24+
self.histogram: bool = False
2225

2326

2427
class Scenario:
@@ -34,6 +37,13 @@ def __init__( # noqa: PLR0913
3437
) -> None:
3538
if number < 1:
3639
raise ValueError(f'number must be at least 1, got {number}')
40+
checker: Arguments = arguments if isinstance(arguments, Arguments) else Arguments()
41+
if not checker.match(function):
42+
fn_name: object = getattr(function, '__name__', repr(function))
43+
raise SignatureMismatchError(
44+
f'Scenario arguments {checker!r} are incompatible with the '
45+
f'signature of {fn_name}',
46+
)
3747
self.function: object = function
3848
self._arguments: Arguments | None = arguments
3949
if name is None and hasattr(function, '__name__'):
@@ -56,24 +66,29 @@ def run(self, warmup: int = 0) -> BenchmarkResult:
5666
self._call_once()
5767
timer()
5868
durations: list[float] = []
69+
loop_start = timer()
5970
for _ in range(self.number):
6071
start = timer()
6172
self._call_once()
6273
end = timer()
6374
durations.append(end - start)
75+
loop_end = timer()
6476
return BenchmarkResult(
6577
scenario=self,
6678
durations=tuple(durations),
79+
total_duration=loop_end - loop_start,
6780
is_primary=True,
6881
)
6982

70-
def cli(self) -> None:
83+
def cli(self, argv: list[str] | None = None) -> None:
7184
parser = argparse.ArgumentParser(description=self.doc or f'Benchmark: {self.name}')
7285
parser.add_argument('--number', type=int, default=None, help='Number of iterations')
7386
parser.add_argument('--max-mean', type=float, default=None, dest='max_mean',
7487
help='Fail if mean time (seconds) exceeds this threshold')
88+
parser.add_argument('--histogram', action='store_true', default=False,
89+
help='Append an ASCII histogram of per-call timings')
7590
cli_args = _CliArgs()
76-
parser.parse_args(namespace=cli_args)
91+
parser.parse_args(argv, namespace=cli_args)
7792

7893
scenario = self
7994
if cli_args.number is not None:
@@ -87,7 +102,13 @@ def cli(self) -> None:
87102
)
88103

89104
result = scenario.run()
90-
_print_result(result)
105+
width = terminal_width()
106+
lines = _render_result(result)
107+
if cli_args.histogram:
108+
lines.append('')
109+
lines.extend(draw_histogram(list(result.durations), width - 4, 8))
110+
box = draw_box(lines, width)
111+
sys.stdout.write('\n'.join(box) + '\n')
91112

92113
if cli_args.max_mean is not None and result.mean > cli_args.max_mean:
93114
sys.stdout.write(
@@ -128,19 +149,23 @@ def _fn_call_str(function: object, arguments: Arguments | None) -> str:
128149
return describe_data_object(fn_name, args, kwargs)
129150

130151

131-
def _print_result(result: BenchmarkResult) -> None:
152+
def _render_result(result: BenchmarkResult) -> list[str]:
132153
scenario = result.scenario
133154
assert scenario is not None
134155
call_str = _fn_call_str(scenario.function, scenario._arguments)
135156
label_width = len('p95 mean:')
136-
sys.stdout.write(f'benchmark: {scenario.name}\n')
137-
sys.stdout.write(f'{"call:".ljust(label_width)} {call_str}\n')
157+
lines: list[str] = []
158+
lines.append(f'benchmark: {scenario.name}')
159+
lines.append(f'{"call:".ljust(label_width)} {call_str}')
138160
if scenario.doc:
139-
sys.stdout.write(f'{"doc:".ljust(label_width)} {scenario.doc}\n')
140-
sys.stdout.write(f'{"runs:".ljust(label_width)} {scenario.number}\n')
141-
sys.stdout.write(f'{"mean:".ljust(label_width)} {result.mean:.6f}s\n')
142-
sys.stdout.write(f'{"median:".ljust(label_width)} {result.median:.6f}s\n')
143-
sys.stdout.write(f'{"best:".ljust(label_width)} {result.best:.6f}s\n')
144-
sys.stdout.write(f'{"worst:".ljust(label_width)} {result.worst:.6f}s\n')
145-
sys.stdout.write(f'{"p95 mean:".ljust(label_width)} {result.p95.mean:.6f}s\n')
146-
sys.stdout.write(f'{"p99 mean:".ljust(label_width)} {result.p99.mean:.6f}s\n')
161+
lines.append(f'{"doc:".ljust(label_width)} {scenario.doc}')
162+
lines.append(f'{"runs:".ljust(label_width)} {scenario.number}')
163+
lines.append(f'{"mean:".ljust(label_width)} {result.mean:.6f}s')
164+
lines.append(f'{"median:".ljust(label_width)} {result.median:.6f}s')
165+
lines.append(f'{"best:".ljust(label_width)} {result.best:.6f}s')
166+
lines.append(f'{"worst:".ljust(label_width)} {result.worst:.6f}s')
167+
lines.append(f'{"p95 mean:".ljust(label_width)} {result.p95.mean:.6f}s')
168+
lines.append(f'{"p99 mean:".ljust(label_width)} {result.p99.mean:.6f}s')
169+
lines.append(f'{"total:".ljust(label_width)} {result.total_duration:.6f}s')
170+
lines.append(f'{"fn total:".ljust(label_width)} {result.functions_duration:.6f}s')
171+
return lines

0 commit comments

Comments
 (0)