Benchmark files¶
A benchmark file is an ordinary Python module whose name matches the
prob_pattern ini option (default bench_*.py). Inside it, every
module-level function whose name starts with bench_ is a benchmark
— the same convention pytest applies to test_* functions in
test_*.py files. Point pytest at a directory or at the file itself.
The module contract¶
bench_*(**params)functionsEach is an ordinary test body: it takes its case’s values as arguments, does the work, and
asserts the outcome. Cases come from plain@pytest.mark.parametrizeon the function — every parameter combination is one case. A function with no parametrize marks is a single case named after the function. See Parametrization and selection.setup()/teardown()(optional)Called once per file:
setup()before the file’s first item runs,teardown()after its last — shared by allbench_*functions in the file. Ifsetup()raises, the file’s items error out individually with that exception.
A file matching the pattern with no bench_* functions is silently
skipped — zero items, not an error. A file that raises on import is
reported as a pytest collection error, with the traceback.
Note
Benchmark files are not test modules: pytest fixtures and
conftest.py fixtures do not apply (see Internals for why). If
you need per-run resources, create them inside the bench function;
per-file resources belong in setup()/teardown().
Writing a benchmark¶
If you can write a pytest test, you already know the syntax:
import pytest
from pytest_probability import record_cost
@pytest.mark.parametrize("text,expected", [
pytest.param("my ssn is 078-05-1120", "pii", id="identify_pii",
marks=pytest.mark.fast),
pytest.param("is this a question?", "question", id="is_question"),
])
def bench_classify(text, expected):
answer = my_classifier(text)
record_cost(0.0002) # optional, see Cost
assert answer == expected, f"got '{answer}'"
Values are function arguments — named, hintable, refactorable.
pytest.param(id=...)names the case; without it, pytest’s id rules apply (value text for scalars,argnameNfor objects,ids=list/callable supported).pytest.param(marks=...)tags a single case for-mselection or skips/xfails it; marks on the function apply to every case.Stacked decorators cross-product with pytest’s exact id layout —
refund-terse,refund-chain_of_thought, bottom decorator leftmost.No parametrize at all → the function runs as one case named after itself (
bench_smoke→ casesmoke), which makes--prob-runsa repeat-runner for plain functions.
Each case gets its own summary row, namespaced by the function’s short name so identical ids in different benchmarks never collide:
classify::identify_pii 7/10 FLAKY
classify::is_question 10/10
Dataset-driven suites are a comprehension away — a ragged payload is just a dict-valued parameter:
@pytest.mark.parametrize("row", [
pytest.param(row, id=row["id"]) for row in load_dataset()
])
def bench_extract(row):
assert extract(row["input"]) == row["expected"]
Run outcomes¶
A run’s outcome is decided by how the function body ends — the three classes fall straight out of Python:
The body… |
Outcome |
Meaning |
|---|---|---|
returns normally |
pass |
correct answer |
raises |
fail |
your code answered wrong |
raises anything else |
error |
your harness broke (API down, timeout, bug) |
Two consequences worth internalizing:
Assert the outcome, let infrastructure raise. A wrong classification is an
assert; aConnectionErrorfrom the client library propagates by itself and lands in the error class. You get the fail/error separation for free by writing idiomatic code.pytest.skip()/xfailwork exactly as in pytest — and a skipped run is not a sample: it never enters any fraction.
The plugin measures each run’s wall-clock time itself (elapsed in the
JSON report) — no stopwatch code in your benches.
Failures are ordinary assertion failures with pytest’s full assertion
rewriting: bench modules pass through the same rewriter as
test_*.py files, so a bare assert reports its operands and diffs —
> assert answer == expected
E AssertionError: assert 'other' == 'pii'
bench_classify.py:41: AssertionError
The first line of that message (your explicit assert message, or the
rewritten assert 'other' == 'pii') also lands in the JSON report as
the run’s message, so wrong answers are analyzable in bulk.
Rewriting honors pytest’s own escape hatches: --assert=plain
disables it session-wide, and a module docstring containing
PYTEST_DONT_REWRITE opts that file out.
Want several checks against one case? Either assert them in sequence
(the first failure ends the run — exactly like a test), or split them
into separate bench_* functions sharing a helper so each check gets
its own fraction row.
Multiple benchmarks per file¶
A file holds as many bench_* functions as you like; they collect in
definition order:
@pytest.mark.parametrize("row", CASES)
def bench_classify(row):
assert classify(row["input"]) == row["expected"]
@pytest.mark.parametrize("row", CASES)
def bench_latency(row):
t0 = time.perf_counter()
classify(row["input"])
assert time.perf_counter() - t0 < 2.0
Summary rows are namespaced by the function’s short name
(bench_classify → classify::), so identical case ids in different
benchmarks aggregate separately, and -k bench_latency selects one
benchmark’s items.