Skip to content

Commit fbae262

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

12 files changed

+317
-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

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from dataclasses import dataclass
5+
6+
import pytest
7+
8+
from pytest_codspeed.utils import get_git_relative_path
9+
10+
11+
def has_args(item: pytest.Item) -> bool:
12+
return isinstance(item, pytest.Function) and "callspec" in item.__dict__
13+
14+
15+
@dataclass
16+
class Benchmark:
17+
file: str
18+
module: str
19+
groups: list[str]
20+
name: str
21+
args: list
22+
args_names: list[str]
23+
24+
@classmethod
25+
def from_item(cls, item: pytest.Item) -> Benchmark:
26+
file = str(get_git_relative_path(item.path))
27+
module = "::".join(
28+
[node.name for node in item.listchain() if isinstance(node, pytest.Class)]
29+
)
30+
name = item.originalname if isinstance(item, pytest.Function) else item.name
31+
args = list(item.callspec.params.values()) if has_args(item) else []
32+
args_names = list(item.callspec.params.keys()) if has_args(item) else []
33+
groups = []
34+
benchmark_marker = item.get_closest_marker("benchmark")
35+
if benchmark_marker is not None:
36+
benchmark_marker_kwargs = benchmark_marker.kwargs.get("group")
37+
if benchmark_marker_kwargs is not None:
38+
groups.append(benchmark_marker_kwargs)
39+
40+
return cls(
41+
file=file,
42+
module=module,
43+
groups=groups,
44+
name=name,
45+
args=args,
46+
args_names=args_names,
47+
)
48+
49+
@property
50+
def display_name(self) -> str:
51+
args_str = f"[{'-'.join(map(str, self.args))}]" if len(self.args) > 0 else ""
52+
return f"{self.name}{args_str}"
53+
54+
def to_json_string(self) -> str:
55+
return json.dumps(self.__dict__, separators=(",", ":"), sort_keys=True)

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": {

tests/conftest.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ def run_pytest_codspeed_with_mode(
8181
if mode == MeasurementMode.WallTime:
8282
# Run only 1 round to speed up the test times
8383
csargs.extend(["--codspeed-warmup-time=0", "--codspeed-max-rounds=2"])
84-
return pytester.runpytest(
84+
# create empty `.git` folder in the rootdir to simulate a git repository
85+
if not pytester.path.joinpath(".git").exists():
86+
pytester.mkdir(".git")
87+
return pytester.runpytest_subprocess(
8588
*csargs,
8689
*args,
8790
**kwargs,

0 commit comments

Comments
 (0)