Skip to content

Commit 12bf70c

Browse files
ci: populate baselines and gate benchmarks at +20%
Add check_benchmark_regression.py and reduce_baselines.py; populate benchmarks/baselines.json from post-cache run; wire regression gate into ubuntu benchmarks job; add Makefile update-baselines target and tests.
1 parent 047efe8 commit 12bf70c

8 files changed

Lines changed: 255 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ jobs:
207207
- run: npm run test:coverage
208208

209209
benchmarks:
210-
name: Performance benchmarks (informational)
210+
name: Performance benchmarks (gated)
211211
runs-on: ubuntu-latest
212212
permissions:
213213
contents: read
@@ -235,6 +235,9 @@ jobs:
235235
--benchmark-columns=min,max,mean,stddev,rounds
236236
-o addopts=
237237
238+
- name: Regression gate
239+
run: python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
240+
238241
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
239242
with:
240243
name: benchmark-results

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ node_modules/
1414
.coverage
1515
coverage/
1616
coverage.xml
17+
benchmark-results.json
18+
benchmarks/_raw.json

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.PHONY: update-baselines check-benchmarks
2+
3+
update-baselines:
4+
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
5+
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json
6+
7+
check-benchmarks:
8+
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts=
9+
python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json

benchmarks/README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Performance benchmarks
22

3-
Test files live under `tests/benchmarks/`; this directory holds only documentation and the informational `baselines.json` snapshot.
3+
Test files live under `tests/benchmarks/`; this directory holds documentation and `baselines.json` for the CI regression gate.
44

55
Repeatable local measurements for parse, bulk export, and search hot paths.
66

@@ -26,17 +26,31 @@ The memory test also runs as part of the normal `pytest` suite (timing benchmark
2626
| parse | `parse_session` on 10 / 500 / 5000+ line JSONL |
2727
| export | `run_bulk_export` over 10 / 50 / 100 sessions |
2828
| search | `GET /api/search` over a 50-session synthetic corpus |
29+
| cache | cold vs warm `get_cached_session` (informational; not gated) |
2930

3031
Large JSONL files (5000+ lines) are generated at test session scope under pytest's temp directory — not committed to git.
3132

3233
Corpora repeat one row from `tests/fixtures/session_with_tools.jsonl`, so parse/export numbers measure steady-state throughput on a narrow schema slice — not full parser branch coverage. Treat as v1 baselines, not exhaustive perf proof.
3334

3435
The memory test (`test_parse_memory.py`) is intentionally **not** skipped by `--benchmark-skip`; it runs in the main `pytest` job and builds the session-scoped 5000-line fixture once per session.
3536

36-
## CI
37+
## CI gate
3738

38-
The `benchmarks` workflow job uploads `benchmark-results.json` as a downloadable artifact. There is no regression gate yet.
39+
The `benchmarks` job on **ubuntu-latest** runs pytest-benchmark, then `scripts/check_benchmark_regression.py`. CI fails when any gated benchmark mean exceeds its baseline by more than **20%**. Benchmarks without a baseline entry (e.g. new `cache` group) print a warning and do not fail the gate.
3940

4041
## Refresh baselines
4142

42-
After intentional performance work, copy key means from a local run into `baselines.json` with a date and machine note. This file is informational only; CI does not compare against it.
43+
After intentional performance work on ubuntu (same OS as CI):
44+
45+
```bash
46+
make update-baselines
47+
```
48+
49+
Or manually:
50+
51+
```bash
52+
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
53+
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json
54+
```
55+
56+
Use `--slack 1.25` on `reduce_baselines.py` when capturing on a faster host than CI to absorb cross-machine variance.

benchmarks/baselines.json

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
{
2-
"_note": "Informational snapshot only — CI does not gate on these values.",
3-
"updated": null,
4-
"machine": null,
2+
"_note": "CI gates the ubuntu benchmarks job when mean exceeds baseline by >20%.",
3+
"updated": "2026-06-17T20:33:56Z",
4+
"machine": "Windows",
55
"groups": {
6-
"parse": {},
7-
"export": {},
8-
"search": {}
6+
"parse": {
7+
"test_parse_session_small": 8.711556942772768e-05,
8+
"test_parse_session_medium": 0.0019041118800302193,
9+
"test_parse_session_large": 0.018555900882518687
10+
},
11+
"export": {
12+
"test_bulk_export_session_count[sessions-10]": 0.0034216649980517108,
13+
"test_bulk_export_session_count[sessions-50]": 0.017290590000629893,
14+
"test_bulk_export_session_count[sessions-100]": 0.03397804391996553
15+
},
16+
"search": {
17+
"test_search_full_corpus": 0.002600985144447307
18+
}
919
}
1020
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Compare pytest-benchmark JSON output against stored baselines."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
from pathlib import Path
8+
9+
THRESHOLD = 1.20
10+
11+
12+
def load_results(results_path: str | Path) -> dict[str, float]:
13+
data = json.loads(Path(results_path).read_text(encoding="utf-8"))
14+
return {entry["name"]: float(entry["stats"]["mean"]) for entry in data["benchmarks"]}
15+
16+
17+
def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
18+
data = json.loads(Path(baselines_path).read_text(encoding="utf-8"))
19+
groups = data.get("groups", data)
20+
means: dict[str, float] = {}
21+
for key, value in groups.items():
22+
if not isinstance(value, dict):
23+
continue
24+
for name, mean in value.items():
25+
means[name] = float(mean)
26+
return means
27+
28+
29+
def check_regression(
30+
results_path: str | Path,
31+
baselines_path: str | Path,
32+
*,
33+
threshold: float = THRESHOLD,
34+
) -> int:
35+
"""Return 0 when within threshold; 1 when any gated benchmark regresses."""
36+
flat = load_results(results_path)
37+
baseline_means = load_baseline_means(baselines_path)
38+
39+
failures: list[str] = []
40+
for name, base in baseline_means.items():
41+
cur = flat.get(name)
42+
if cur is None:
43+
print(f"WARN: no current result for baseline {name!r}; skipping")
44+
continue
45+
ratio = cur / base
46+
tag = "FAIL" if ratio > threshold else "ok"
47+
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
48+
if ratio > threshold:
49+
failures.append(name)
50+
51+
for name in flat:
52+
if name not in baseline_means:
53+
print(f"WARN: {name!r} has no baseline yet; not gated")
54+
55+
if failures:
56+
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {threshold:.0%}")
57+
return 1
58+
return 0
59+
60+
61+
def main(argv: list[str] | None = None) -> int:
62+
argv = sys.argv[1:] if argv is None else argv
63+
if len(argv) != 2:
64+
print(
65+
"usage: check_benchmark_regression.py <results.json> <baselines.json>",
66+
file=sys.stderr,
67+
)
68+
return 2
69+
return check_regression(argv[0], argv[1])
70+
71+
72+
if __name__ == "__main__":
73+
sys.exit(main())

scripts/reduce_baselines.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Reduce pytest-benchmark JSON into benchmarks/baselines.json."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import sys
8+
from datetime import UTC, datetime
9+
from pathlib import Path
10+
11+
GATED_GROUPS = ("parse", "export", "search")
12+
13+
14+
def reduce_baselines(
15+
raw_path: str | Path,
16+
out_path: str | Path,
17+
*,
18+
slack: float = 1.0,
19+
) -> dict[str, object]:
20+
raw = json.loads(Path(raw_path).read_text(encoding="utf-8"))
21+
groups: dict[str, dict[str, float]] = {group: {} for group in GATED_GROUPS}
22+
for entry in raw["benchmarks"]:
23+
group = entry.get("group")
24+
if group not in GATED_GROUPS:
25+
continue
26+
groups[group][entry["name"]] = float(entry["stats"]["mean"]) * slack
27+
28+
machine_info = raw.get("machine_info", {})
29+
output: dict[str, object] = {
30+
"_note": "CI gates the ubuntu benchmarks job when mean exceeds baseline by >20%.",
31+
"updated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
32+
"machine": machine_info.get("system"),
33+
"groups": groups,
34+
}
35+
path = Path(out_path)
36+
path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
37+
return output
38+
39+
40+
def main(argv: list[str] | None = None) -> int:
41+
parser = argparse.ArgumentParser(description=__doc__)
42+
parser.add_argument("raw_path", help="pytest-benchmark --benchmark-json output")
43+
parser.add_argument("out_path", help="destination baselines.json path")
44+
parser.add_argument(
45+
"--slack",
46+
type=float,
47+
default=1.0,
48+
help="multiply means by this factor (e.g. 1.25 when capturing on a faster host)",
49+
)
50+
args = parser.parse_args(argv)
51+
reduce_baselines(args.raw_path, args.out_path, slack=args.slack)
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
sys.exit(main())
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for scripts/check_benchmark_regression.py."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
9+
from scripts.check_benchmark_regression import check_regression
10+
11+
12+
def _write_results(path, benchmarks: list[dict]) -> None:
13+
path.write_text(
14+
json.dumps({"benchmarks": benchmarks}, indent=2),
15+
encoding="utf-8",
16+
)
17+
18+
19+
def _write_baselines(path, groups: dict[str, dict[str, float]]) -> None:
20+
path.write_text(
21+
json.dumps({"groups": groups}, indent=2),
22+
encoding="utf-8",
23+
)
24+
25+
26+
def test_missing_baseline_warns_without_failing(
27+
tmp_path, capsys: pytest.CaptureFixture[str]
28+
) -> None:
29+
results = tmp_path / "results.json"
30+
baselines = tmp_path / "baselines.json"
31+
_write_results(
32+
results,
33+
[
34+
{"name": "test_new_bench", "stats": {"mean": 0.01}},
35+
{"name": "test_parse_session_small", "stats": {"mean": 0.0001}},
36+
],
37+
)
38+
_write_baselines(
39+
baselines,
40+
{"parse": {"test_parse_session_small": 0.0001}},
41+
)
42+
43+
assert check_regression(results, baselines) == 0
44+
out = capsys.readouterr().out
45+
assert "WARN: 'test_new_bench' has no baseline yet" in out
46+
47+
48+
def test_regression_over_threshold_fails(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
49+
results = tmp_path / "results.json"
50+
baselines = tmp_path / "baselines.json"
51+
_write_results(
52+
results,
53+
[{"name": "test_parse_session_small", "stats": {"mean": 0.0002}}],
54+
)
55+
_write_baselines(
56+
baselines,
57+
{"parse": {"test_parse_session_small": 0.0001}},
58+
)
59+
60+
assert check_regression(results, baselines) == 1
61+
out = capsys.readouterr().out
62+
assert "REGRESSION" in out
63+
64+
65+
def test_within_threshold_passes(tmp_path) -> None:
66+
results = tmp_path / "results.json"
67+
baselines = tmp_path / "baselines.json"
68+
_write_results(
69+
results,
70+
[{"name": "test_parse_session_small", "stats": {"mean": 0.00011}}],
71+
)
72+
_write_baselines(
73+
baselines,
74+
{"parse": {"test_parse_session_small": 0.0001}},
75+
)
76+
77+
assert check_regression(results, baselines) == 0

0 commit comments

Comments
 (0)