Skip to content

Commit 1ad4aa9

Browse files
committed
feat: store more benchmark metadata in results
Use json reprensation as benchmark identifier in valgrind instrumentation
1 parent a11f446 commit 1ad4aa9

12 files changed

+402
-74
lines changed

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ compat = [
4343
"pytest-xdist ~= 3.6.1",
4444
# "pytest-speed>=0.3.5",
4545
]
46-
test = ["pytest ~= 7.0", "pytest-cov ~= 4.0.0"]
46+
test = ["inline-snapshot>=0.18.2", "pytest ~= 7.0", "pytest-cov ~= 4.0.0"]
4747

4848
[project.entry-points]
4949
pytest11 = { codspeed = "pytest_codspeed.plugin" }

src/pytest_codspeed/benchmark.py

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from dataclasses import dataclass
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
9+
from pytest_codspeed.utils import get_git_relative_path
10+
11+
if TYPE_CHECKING:
12+
from typing import Any
13+
14+
15+
def has_args(item: pytest.Item) -> bool:
16+
return isinstance(item, pytest.Function) and "callspec" in item.__dict__
17+
18+
19+
@dataclass
20+
class Benchmark:
21+
file: str
22+
module: str
23+
groups: list[str]
24+
name: str
25+
args: list
26+
args_names: list[str]
27+
28+
@classmethod
29+
def from_item(cls, item: pytest.Item) -> Benchmark:
30+
file = str(get_git_relative_path(item.path))
31+
module = "::".join(
32+
[node.name for node in item.listchain() if isinstance(node, pytest.Class)]
33+
)
34+
name = item.originalname if isinstance(item, pytest.Function) else item.name
35+
args = list(item.callspec.params.values()) if has_args(item) else []
36+
args_names = list(item.callspec.params.keys()) if has_args(item) else []
37+
groups = []
38+
benchmark_marker = item.get_closest_marker("benchmark")
39+
if benchmark_marker is not None:
40+
benchmark_marker_kwargs = benchmark_marker.kwargs.get("group")
41+
if benchmark_marker_kwargs is not None:
42+
groups.append(benchmark_marker_kwargs)
43+
44+
return cls(
45+
file=file,
46+
module=module,
47+
groups=groups,
48+
name=name,
49+
args=args,
50+
args_names=args_names,
51+
)
52+
53+
@property
54+
def display_name(self) -> str:
55+
if len(self.args) == 0:
56+
args_str = ""
57+
else:
58+
arg_blocks = []
59+
for i, (arg_name, arg_value) in enumerate(zip(self.args_names, self.args)):
60+
arg_blocks.append(arg_to_str(arg_value, arg_name, i))
61+
args_str = f"[{'-'.join(arg_blocks)}]"
62+
63+
return f"{self.name}{args_str}"
64+
65+
def to_json_string(self) -> str:
66+
return json.dumps(
67+
self.__dict__, default=vars, separators=(",", ":"), sort_keys=True
68+
)
69+
70+
71+
def arg_to_str(arg: Any, arg_name: str, index: int) -> str:
72+
if type(arg) in [int, float, str]:
73+
return str(arg)
74+
if (
75+
arg is not None
76+
and type(arg) not in [list, dict, tuple]
77+
and hasattr(arg, "__str__")
78+
):
79+
return str(arg)
80+
return f"{arg_name}{index}"

src/pytest_codspeed/instruments/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111

12+
from pytest_codspeed.benchmark import Benchmark
1213
from pytest_codspeed.plugin import CodSpeedConfig
1314

1415
T = TypeVar("T")
@@ -27,8 +28,7 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]: ...
2728
@abstractmethod
2829
def measure(
2930
self,
30-
name: str,
31-
uri: str,
31+
benchmark: Benchmark,
3232
fn: Callable[P, T],
3333
*args: P.args,
3434
**kwargs: P.kwargs,

src/pytest_codspeed/instruments/valgrind/__init__.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
from dataclasses import asdict
56
from typing import TYPE_CHECKING
67

78
from pytest_codspeed import __semver_version__
@@ -13,6 +14,7 @@
1314

1415
from pytest import Session
1516

17+
from pytest_codspeed.benchmark import Benchmark
1618
from pytest_codspeed.instruments import P, T
1719
from pytest_codspeed.instruments.valgrind._wrapper import LibType
1820
from pytest_codspeed.plugin import CodSpeedConfig
@@ -26,6 +28,7 @@ class ValgrindInstrument(Instrument):
2628

2729
def __init__(self, config: CodSpeedConfig) -> None:
2830
self.benchmark_count = 0
31+
self.benchmarks: list[Benchmark] = []
2932
self.should_measure = os.environ.get("CODSPEED_ENV") is not None
3033
if self.should_measure:
3134
self.lib = get_lib()
@@ -54,13 +57,13 @@ def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
5457

5558
def measure(
5659
self,
57-
name: str,
58-
uri: str,
60+
benchmark: Benchmark,
5961
fn: Callable[P, T],
6062
*args: P.args,
6163
**kwargs: P.kwargs,
6264
) -> T:
6365
self.benchmark_count += 1
66+
self.benchmarks.append(benchmark)
6467
if self.lib is None: # Thus should_measure is False
6568
return fn(*args, **kwargs)
6669

@@ -78,7 +81,7 @@ def __codspeed_root_frame__() -> T:
7881
finally:
7982
# Ensure instrumentation is stopped even if the test failed
8083
self.lib.stop_instrumentation()
81-
self.lib.dump_stats_at(uri.encode("ascii"))
84+
self.lib.dump_stats_at(benchmark.to_json_string().encode("ascii"))
8285

8386
def report(self, session: Session) -> None:
8487
reporter = session.config.pluginmanager.get_plugin("terminalreporter")
@@ -91,5 +94,5 @@ def report(self, session: Session) -> None:
9194
def get_result_dict(self) -> dict[str, Any]:
9295
return {
9396
"instrument": {"type": self.instrument},
94-
# bench results will be dumped by valgrind
97+
"benchmarks": [asdict(bench) for bench in self.benchmarks],
9598
}

src/pytest_codspeed/instruments/walltime.py

+25-21
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rich.table import Table
1212
from rich.text import Text
1313

14+
from pytest_codspeed.benchmark import Benchmark
1415
from pytest_codspeed.instruments import Instrument
1516

1617
if TYPE_CHECKING:
@@ -28,14 +29,14 @@
2829

2930

3031
@dataclass
31-
class BenchmarkConfig:
32+
class WalltimeBenchmarkConfig:
3233
warmup_time_ns: int
3334
min_round_time_ns: float
3435
max_time_ns: int
3536
max_rounds: int | None
3637

3738
@classmethod
38-
def from_codspeed_config(cls, config: CodSpeedConfig) -> BenchmarkConfig:
39+
def from_codspeed_config(cls, config: CodSpeedConfig) -> WalltimeBenchmarkConfig:
3940
return cls(
4041
warmup_time_ns=config.warmup_time_ns
4142
if config.warmup_time_ns is not None
@@ -49,7 +50,7 @@ def from_codspeed_config(cls, config: CodSpeedConfig) -> BenchmarkConfig:
4950

5051

5152
@dataclass
52-
class BenchmarkStats:
53+
class WalltimeBenchmarkStats:
5354
min_ns: float
5455
max_ns: float
5556
mean_ns: float
@@ -75,7 +76,7 @@ def from_list(
7576
iter_per_round: int,
7677
warmup_iters: int,
7778
total_time: float,
78-
) -> BenchmarkStats:
79+
) -> WalltimeBenchmarkStats:
7980
stdev_ns = stdev(times_ns) if len(times_ns) > 1 else 0
8081
mean_ns = mean(times_ns)
8182
if len(times_ns) > 1:
@@ -114,17 +115,18 @@ def from_list(
114115

115116

116117
@dataclass
117-
class Benchmark:
118-
name: str
119-
uri: str
120-
121-
config: BenchmarkConfig
122-
stats: BenchmarkStats
118+
class WalltimeBenchmark(Benchmark):
119+
config: WalltimeBenchmarkConfig
120+
stats: WalltimeBenchmarkStats
123121

124122

125123
def run_benchmark(
126-
name: str, uri: str, fn: Callable[P, T], args, kwargs, config: BenchmarkConfig
127-
) -> tuple[Benchmark, T]:
124+
benchmark: Benchmark,
125+
fn: Callable[P, T],
126+
args,
127+
kwargs,
128+
config: WalltimeBenchmarkConfig,
129+
) -> tuple[WalltimeBenchmark, T]:
128130
# Compute the actual result of the function
129131
out = fn(*args, **kwargs)
130132

@@ -171,42 +173,44 @@ def run_benchmark(
171173
benchmark_end = perf_counter_ns()
172174
total_time = (benchmark_end - run_start) / 1e9
173175

174-
stats = BenchmarkStats.from_list(
176+
stats = WalltimeBenchmarkStats.from_list(
175177
times_ns,
176178
rounds=rounds,
177179
total_time=total_time,
178180
iter_per_round=iter_per_round,
179181
warmup_iters=warmup_iters,
180182
)
181183

182-
return Benchmark(name=name, uri=uri, config=config, stats=stats), out
184+
return WalltimeBenchmark(
185+
**asdict(benchmark),
186+
config=config,
187+
stats=stats,
188+
), out
183189

184190

185191
class WallTimeInstrument(Instrument):
186192
instrument = "walltime"
187193

188194
def __init__(self, config: CodSpeedConfig) -> None:
189195
self.config = config
190-
self.benchmarks: list[Benchmark] = []
196+
self.benchmarks: list[WalltimeBenchmark] = []
191197

192198
def get_instrument_config_str_and_warns(self) -> tuple[str, list[str]]:
193199
return f"mode: walltime, timer_resolution: {TIMER_RESOLUTION_NS:.1f}ns", []
194200

195201
def measure(
196202
self,
197-
name: str,
198-
uri: str,
203+
benchmark: Benchmark,
199204
fn: Callable[P, T],
200205
*args: P.args,
201206
**kwargs: P.kwargs,
202207
) -> T:
203208
bench, out = run_benchmark(
204-
name=name,
205-
uri=uri,
209+
benchmark=benchmark,
206210
fn=fn,
207211
args=args,
208212
kwargs=kwargs,
209-
config=BenchmarkConfig.from_codspeed_config(self.config),
213+
config=WalltimeBenchmarkConfig.from_codspeed_config(self.config),
210214
)
211215
self.benchmarks.append(bench)
212216
return out
@@ -244,7 +248,7 @@ def _print_benchmark_table(self) -> None:
244248
if rsd > 0.1:
245249
rsd_text.stylize("red bold")
246250
table.add_row(
247-
escape(bench.name),
251+
escape(bench.display_name),
248252
f"{bench.stats.min_ns/bench.stats.iter_per_round:,.0f}ns",
249253
rsd_text,
250254
f"{bench.stats.total_time:,.2f}s",

src/pytest_codspeed/plugin.py

+12-14
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
import pytest
1414
from _pytest.fixtures import FixtureManager
1515

16+
from pytest_codspeed.benchmark import Benchmark
1617
from pytest_codspeed.instruments import (
1718
MeasurementMode,
1819
get_instrument_from_mode,
1920
)
2021
from pytest_codspeed.utils import (
2122
get_environment_metadata,
22-
get_git_relative_uri_and_name,
2323
)
2424

2525
from . import __version__
@@ -253,8 +253,7 @@ def pytest_collection_modifyitems(
253253

254254
def _measure(
255255
plugin: CodSpeedPlugin,
256-
nodeid: str,
257-
config: pytest.Config,
256+
item: pytest.Item,
258257
fn: Callable[P, T],
259258
*args: P.args,
260259
**kwargs: P.kwargs,
@@ -264,8 +263,8 @@ def _measure(
264263
gc.collect()
265264
gc.disable()
266265
try:
267-
uri, name = get_git_relative_uri_and_name(nodeid, config.rootpath)
268-
return plugin.instrument.measure(name, uri, fn, *args, **kwargs)
266+
benchmark = Benchmark.from_item(item)
267+
return plugin.instrument.measure(benchmark, fn, *args, **kwargs)
269268
finally:
270269
# Ensure GC is re-enabled even if the test failed
271270
if is_gc_enabled:
@@ -274,13 +273,13 @@ def _measure(
274273

275274
def wrap_runtest(
276275
plugin: CodSpeedPlugin,
277-
nodeid: str,
278-
config: pytest.Config,
279-
fn: Callable[P, T],
276+
item: pytest.Item,
280277
) -> Callable[P, T]:
278+
fn = item.runtest
279+
281280
@functools.wraps(fn)
282281
def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
283-
return _measure(plugin, nodeid, config, fn, *args, **kwargs)
282+
return _measure(plugin, item, fn, *args, **kwargs)
284283

285284
return wrapped
286285

@@ -297,7 +296,7 @@ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
297296
return None
298297

299298
# Wrap runtest and defer to default protocol
300-
item.runtest = wrap_runtest(plugin, item.nodeid, item.config, item.runtest)
299+
item.runtest = wrap_runtest(plugin, item)
301300
return None
302301

303302

@@ -340,10 +339,9 @@ def __init__(self, request: pytest.FixtureRequest):
340339
def __call__(self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
341340
config = self._request.config
342341
plugin = get_plugin(config)
343-
if plugin.is_codspeed_enabled:
344-
return _measure(
345-
plugin, self._request.node.nodeid, config, func, *args, **kwargs
346-
)
342+
item = self._request.node
343+
if plugin.is_codspeed_enabled and isinstance(item, pytest.Item):
344+
return _measure(plugin, item, func, *args, **kwargs)
347345
else:
348346
return func(*args, **kwargs)
349347

src/pytest_codspeed/utils.py

-20
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,6 @@ def get_git_relative_path(abs_path: Path) -> Path:
2727
return abs_path
2828

2929

30-
def get_git_relative_uri_and_name(nodeid: str, pytest_rootdir: Path) -> tuple[str, str]:
31-
"""Get the benchmark uri relative to the git root dir and the benchmark name.
32-
33-
Args:
34-
nodeid (str): the pytest nodeid, for example:
35-
testing/test_excinfo.py::TestFormattedExcinfo::test_repr_source
36-
pytest_rootdir (str): the pytest root dir, for example:
37-
/home/user/gitrepo/folder
38-
39-
Returns:
40-
str: the benchmark uri relative to the git root dir, for example:
41-
folder/testing/test_excinfo.py::TestFormattedExcinfo::test_repr_source
42-
43-
"""
44-
file_path, bench_name = nodeid.split("::", 1)
45-
absolute_file_path = pytest_rootdir / Path(file_path)
46-
relative_git_path = get_git_relative_path(absolute_file_path)
47-
return (f"{str(relative_git_path)}::{bench_name}", bench_name)
48-
49-
5030
def get_environment_metadata() -> dict[str, dict]:
5131
return {
5232
"creator": {

0 commit comments

Comments
 (0)