Skip to content

Commit 57148a1

Browse files
committed
feat: support pytest-benchmark's pedantic API
1 parent 2bcac3a commit 57148a1

7 files changed

Lines changed: 400 additions & 93 deletions

File tree

src/pytest_codspeed/config.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
4-
from typing import TYPE_CHECKING
3+
import dataclasses
4+
from dataclasses import dataclass, field
5+
from typing import TYPE_CHECKING, Generic, ParamSpec, TypeVar
6+
7+
import pytest
8+
9+
T = TypeVar("T")
10+
P = ParamSpec("P")
511

612
if TYPE_CHECKING:
13+
from typing import Any, Callable
14+
715
import pytest
816

917

@@ -64,17 +72,51 @@ def from_pytest_item(cls, item: pytest.Item) -> BenchmarkMarkerOptions:
6472
raise ValueError(
6573
"Positional arguments are not allowed in the benchmark marker"
6674
)
75+
kwargs = marker.kwargs
6776

68-
options = cls(
69-
group=marker.kwargs.pop("group", None),
70-
min_time=marker.kwargs.pop("min_time", None),
71-
max_time=marker.kwargs.pop("max_time", None),
72-
max_rounds=marker.kwargs.pop("max_rounds", None),
73-
)
74-
75-
if len(marker.kwargs) > 0:
77+
unknown_kwargs = set(kwargs.keys()) - {
78+
field.name for field in dataclasses.fields(cls)
79+
}
80+
if unknown_kwargs:
7681
raise ValueError(
7782
"Unknown kwargs passed to benchmark marker: "
78-
+ ", ".join(marker.kwargs.keys())
83+
+ ", ".join(sorted(unknown_kwargs))
84+
)
85+
86+
return cls(**kwargs)
87+
88+
89+
@dataclass(frozen=True)
90+
class PedanticOptions(Generic[P, T]):
91+
"""Parameters for running a benchmark using the pedantic fixture API."""
92+
93+
target: Callable[P, T]
94+
setup: Callable[[], Any | None] | None
95+
teardown: Callable[P, Any | None] | None
96+
rounds: int
97+
warmup_rounds: int
98+
iterations: int
99+
args: tuple[Any, ...] = field(default_factory=tuple)
100+
kwargs: dict[str, Any] = field(default_factory=dict)
101+
102+
def __post_init__(self) -> None:
103+
if self.rounds < 0:
104+
raise ValueError("rounds must be positive")
105+
if self.warmup_rounds < 0:
106+
raise ValueError("warmup_rounds must be non-negative")
107+
if self.iterations <= 0:
108+
raise ValueError("iterations must be positive")
109+
if self.iterations > 1 and self.setup is not None:
110+
raise ValueError(
111+
"setup cannot be used with multiple iterations, use multiple rounds"
79112
)
80-
return options
113+
114+
def setup_and_get_args_kwargs(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
115+
if self.setup is None:
116+
return self.args, self.kwargs
117+
maybe_result = self.setup(*self.args, **self.kwargs)
118+
if maybe_result is not None:
119+
if len(self.args) > 0 or len(self.kwargs) > 0:
120+
raise ValueError("setup cannot return a value when args are provided")
121+
return maybe_result
122+
return self.args, self.kwargs

src/pytest_codspeed/instruments/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from pytest_codspeed.config import BenchmarkMarkerOptions
12+
from pytest_codspeed.config import BenchmarkMarkerOptions, PedanticOptions
1313
from pytest_codspeed.plugin import CodSpeedConfig
1414

1515
T = TypeVar("T")
@@ -36,6 +36,15 @@ def measure(
3636
**kwargs: P.kwargs,
3737
) -> T: ...
3838

39+
@abstractmethod
40+
def measure_pedantic(
41+
self,
42+
marker_options: BenchmarkMarkerOptions,
43+
pedantic_options: PedanticOptions[P, T],
44+
name: str,
45+
uri: str,
46+
) -> T: ...
47+
3948
@abstractmethod
4049
def report(self, session: pytest.Session) -> None: ...
4150

src/pytest_codspeed/instruments/valgrind/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
import warnings
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.config import PedanticOptions
1618
from pytest_codspeed.instruments import P, T
1719
from pytest_codspeed.instruments.valgrind._wrapper import LibType
1820
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
@@ -81,8 +83,55 @@ def __codspeed_root_frame__() -> T:
8183
self.lib.stop_instrumentation()
8284
self.lib.dump_stats_at(uri.encode("ascii"))
8385

86+
def measure_pedantic(
87+
self,
88+
marker_options: BenchmarkMarkerOptions,
89+
pedantic_options: PedanticOptions[P, T],
90+
name: str,
91+
uri: str,
92+
) -> T:
93+
if pedantic_options.rounds != 1 or pedantic_options.iterations != 1:
94+
warnings.warn(
95+
"Valgrind instrument ignores rounds and iterations settings "
96+
"in pedantic mode"
97+
)
98+
if self.lib is None: # Thus should_measure is False
99+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
100+
out = pedantic_options.target(*args, **kwargs)
101+
if pedantic_options.teardown is not None:
102+
pedantic_options.teardown(*args, **kwargs)
103+
return out
104+
105+
def __codspeed_root_frame__(*args, **kwargs) -> T:
106+
return pedantic_options.target(*args, **kwargs)
107+
108+
# Warmup
109+
warmup_rounds = max(
110+
pedantic_options.warmup_rounds, 1 if SUPPORTS_PERF_TRAMPOLINE else 0
111+
)
112+
for _ in range(warmup_rounds):
113+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
114+
__codspeed_root_frame__(*args, **kwargs)
115+
if pedantic_options.teardown is not None:
116+
pedantic_options.teardown(*args, **kwargs)
117+
118+
# Compute the actual result of the function
119+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
120+
self.lib.zero_stats()
121+
self.lib.start_instrumentation()
122+
try:
123+
out = __codspeed_root_frame__(*args, **kwargs)
124+
finally:
125+
self.lib.stop_instrumentation()
126+
self.lib.dump_stats_at(uri.encode("ascii"))
127+
if pedantic_options.teardown is not None:
128+
pedantic_options.teardown(*args, **kwargs)
129+
130+
return out
131+
84132
def report(self, session: Session) -> None:
85133
reporter = session.config.pluginmanager.get_plugin("terminalreporter")
134+
assert reporter is not None, "terminalreporter not found"
86135
count_suffix = "benchmarked" if self.should_measure else "benchmark tested"
87136
reporter.write_sep(
88137
"=",

src/pytest_codspeed/instruments/walltime.py

Lines changed: 116 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from pytest import Session
2020

21+
from pytest_codspeed.config import PedanticOptions
2122
from pytest_codspeed.instruments import P, T
2223
from pytest_codspeed.plugin import BenchmarkMarkerOptions, CodSpeedConfig
2324

@@ -149,66 +150,6 @@ class Benchmark:
149150
stats: BenchmarkStats
150151

151152

152-
def run_benchmark(
153-
name: str, uri: str, fn: Callable[P, T], args, kwargs, config: BenchmarkConfig
154-
) -> tuple[Benchmark, T]:
155-
# Compute the actual result of the function
156-
out = fn(*args, **kwargs)
157-
158-
# Warmup
159-
times_per_round_ns: list[float] = []
160-
warmup_start = start = perf_counter_ns()
161-
while True:
162-
start = perf_counter_ns()
163-
fn(*args, **kwargs)
164-
end = perf_counter_ns()
165-
times_per_round_ns.append(end - start)
166-
if end - warmup_start > config.warmup_time_ns:
167-
break
168-
169-
# Round sizing
170-
warmup_mean_ns = mean(times_per_round_ns)
171-
warmup_iters = len(times_per_round_ns)
172-
times_per_round_ns.clear()
173-
iter_per_round = (
174-
int(ceil(config.min_round_time_ns / warmup_mean_ns))
175-
if warmup_mean_ns <= config.min_round_time_ns
176-
else 1
177-
)
178-
if config.max_rounds is None:
179-
round_time_ns = warmup_mean_ns * iter_per_round
180-
rounds = int(config.max_time_ns / round_time_ns)
181-
else:
182-
rounds = config.max_rounds
183-
rounds = max(1, rounds)
184-
185-
# Benchmark
186-
iter_range = range(iter_per_round)
187-
run_start = perf_counter_ns()
188-
for _ in range(rounds):
189-
start = perf_counter_ns()
190-
for _ in iter_range:
191-
fn(*args, **kwargs)
192-
end = perf_counter_ns()
193-
times_per_round_ns.append(end - start)
194-
195-
if end - run_start > config.max_time_ns:
196-
# TODO: log something
197-
break
198-
benchmark_end = perf_counter_ns()
199-
total_time = (benchmark_end - run_start) / 1e9
200-
201-
stats = BenchmarkStats.from_list(
202-
times_per_round_ns,
203-
rounds=rounds,
204-
total_time=total_time,
205-
iter_per_round=iter_per_round,
206-
warmup_iters=warmup_iters,
207-
)
208-
209-
return Benchmark(name=name, uri=uri, config=config, stats=stats), out
210-
211-
212153
class WallTimeInstrument(Instrument):
213154
instrument = "walltime"
214155

@@ -228,21 +169,126 @@ def measure(
228169
*args: P.args,
229170
**kwargs: P.kwargs,
230171
) -> T:
231-
bench, out = run_benchmark(
232-
name=name,
233-
uri=uri,
234-
fn=fn,
235-
args=args,
236-
kwargs=kwargs,
237-
config=BenchmarkConfig.from_codspeed_config_and_marker_data(
238-
self.config, marker_options
239-
),
172+
benchmark_config = BenchmarkConfig.from_codspeed_config_and_marker_data(
173+
self.config, marker_options
174+
)
175+
176+
# Compute the actual result of the function
177+
out = fn(*args, **kwargs)
178+
# Warmup
179+
times_per_round_ns: list[float] = []
180+
warmup_start = start = perf_counter_ns()
181+
while True:
182+
start = perf_counter_ns()
183+
fn(*args, **kwargs)
184+
end = perf_counter_ns()
185+
times_per_round_ns.append(end - start)
186+
if end - warmup_start > benchmark_config.warmup_time_ns:
187+
break
188+
189+
# Round sizing
190+
warmup_mean_ns = mean(times_per_round_ns)
191+
warmup_iters = len(times_per_round_ns)
192+
times_per_round_ns.clear()
193+
iter_per_round = (
194+
int(ceil(benchmark_config.min_round_time_ns / warmup_mean_ns))
195+
if warmup_mean_ns <= benchmark_config.min_round_time_ns
196+
else 1
197+
)
198+
if benchmark_config.max_rounds is None:
199+
round_time_ns = warmup_mean_ns * iter_per_round
200+
rounds = int(benchmark_config.max_time_ns / round_time_ns)
201+
else:
202+
rounds = benchmark_config.max_rounds
203+
rounds = max(1, rounds)
204+
205+
# Benchmark
206+
iter_range = range(iter_per_round)
207+
run_start = perf_counter_ns()
208+
for _ in range(rounds):
209+
start = perf_counter_ns()
210+
for _ in iter_range:
211+
fn(*args, **kwargs)
212+
end = perf_counter_ns()
213+
times_per_round_ns.append(end - start)
214+
215+
if end - run_start > benchmark_config.max_time_ns:
216+
# TODO: log something
217+
break
218+
benchmark_end = perf_counter_ns()
219+
total_time = (benchmark_end - run_start) / 1e9
220+
221+
stats = BenchmarkStats.from_list(
222+
times_per_round_ns,
223+
rounds=rounds,
224+
total_time=total_time,
225+
iter_per_round=iter_per_round,
226+
warmup_iters=warmup_iters,
227+
)
228+
229+
self.benchmarks.append(
230+
Benchmark(name=name, uri=uri, config=benchmark_config, stats=stats)
231+
)
232+
return out
233+
234+
def measure_pedantic(
235+
self,
236+
marker_options: BenchmarkMarkerOptions,
237+
pedantic_options: PedanticOptions[P, T],
238+
name: str,
239+
uri: str,
240+
) -> T:
241+
benchmark_config = BenchmarkConfig.from_codspeed_config_and_marker_data(
242+
self.config, marker_options
243+
)
244+
245+
iter_range = range(pedantic_options.iterations)
246+
247+
# Warmup
248+
for _ in range(pedantic_options.warmup_rounds):
249+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
250+
for _ in iter_range:
251+
pedantic_options.target(*args, **kwargs)
252+
if pedantic_options.teardown is not None:
253+
pedantic_options.teardown(*args, **kwargs)
254+
255+
# Benchmark
256+
times_per_round_ns: list[float] = []
257+
benchmark_start = perf_counter_ns()
258+
for _ in range(pedantic_options.rounds):
259+
start = perf_counter_ns()
260+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
261+
for _ in iter_range:
262+
pedantic_options.target(*args, **kwargs)
263+
end = perf_counter_ns()
264+
times_per_round_ns.append(end - start)
265+
if pedantic_options.teardown is not None:
266+
pedantic_options.teardown(*args, **kwargs)
267+
268+
benchmark_end = perf_counter_ns()
269+
total_time = (benchmark_end - benchmark_start) / 1e9
270+
stats = BenchmarkStats.from_list(
271+
times_per_round_ns,
272+
rounds=pedantic_options.rounds,
273+
total_time=total_time,
274+
iter_per_round=pedantic_options.iterations,
275+
warmup_iters=pedantic_options.warmup_rounds,
276+
)
277+
278+
# Compute the actual result of the function
279+
args, kwargs = pedantic_options.setup_and_get_args_kwargs()
280+
out = pedantic_options.target(*args, **kwargs)
281+
if pedantic_options.teardown is not None:
282+
pedantic_options.teardown(*args, **kwargs)
283+
284+
self.benchmarks.append(
285+
Benchmark(name=name, uri=uri, config=benchmark_config, stats=stats)
240286
)
241-
self.benchmarks.append(bench)
242287
return out
243288

244289
def report(self, session: Session) -> None:
245290
reporter = session.config.pluginmanager.get_plugin("terminalreporter")
291+
assert reporter is not None, "terminalreporter not found"
246292

247293
if len(self.benchmarks) == 0:
248294
reporter.write_sep(

0 commit comments

Comments
 (0)