Skip to content

Commit ad83f10

Browse files
ci: populate baselines and gate benchmarks at +20% (#91)
* 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. * fix: set benchmark baselines from ubuntu-latest CI means * fix: harden benchmark gate scripts with input validation Raise BenchmarkDataError for malformed JSON or missing keys; skip zero baselines safely; reject non-positive --slack values. * fix: harden benchmark gate per PR #91 review Upload benchmark-results.json even when the gate fails; add BenchmarkDataError handling to reduce_baselines; require explicit groups key; expose --threshold CLI flag; add reduce_baselines and boundary tests; clarify README. * fix: harden benchmark scripts against filesystem and type errors Wrap reduce_baselines I/O in BenchmarkDataError; guard machine_info type; move float(mean) into try block in load_results; assert argparse exit code 2. * fix: allow reduce_baselines to run as a direct script * fix: exclude noisy micro-benchmarks from regression gate Drop test_parse_session_small and test_search_full_corpus from gated baselines; clarify ubuntu CI provenance in _note; detect duplicate benchmark names; expand reduce_baselines tests. * fix: ruff format check_benchmark_regression.pyfix: ruff format check_benchmark_regression.py * fix: enforce EXCLUDED_FROM_GATE at check time and harden file reads * fix: enforce EXCLUDED_FROM_GATE at check time and harden file reads
1 parent 047efe8 commit ad83f10

9 files changed

Lines changed: 606 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 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,7 +235,11 @@ 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
242+
if: always()
239243
with:
240244
name: benchmark-results
241245
path: benchmark-results.json

.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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.PHONY: update-baselines check-benchmarks clean-benchmark-artifacts
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
10+
11+
clean-benchmark-artifacts:
12+
rm -f benchmarks/_raw.json benchmark-results.json

benchmarks/README.md

Lines changed: 22 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,35 @@ 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 (`--benchmark-json=benchmark-results.json`), then `scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json`. CI fails when any **gated** benchmark mean exceeds its baseline by more than **20%**.
40+
41+
**Gated:** parse medium/large, export 10/50/100 sessions.
42+
43+
**Not gated (informational only):** `test_parse_session_small`, `test_search_full_corpus` (sub-ms CI noise), and the `cache` group. Benchmarks without a baseline entry print a warning and do not fail the gate.
3944

4045
## Refresh baselines
4146

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.
47+
After intentional performance work, capture on **ubuntu-latest** (same OS as the gated CI job):
48+
49+
```bash
50+
make update-baselines
51+
```
52+
53+
Or manually:
54+
55+
```bash
56+
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
57+
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json
58+
```
59+
60+
Baselines must be captured on **ubuntu-latest** to match the gated CI runner. Cross-OS variance causes spurious failures. Download `benchmark-results.json` from a CI artifact to seed baselines if needed.

benchmarks/baselines.json

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
{
2-
"_note": "Informational snapshot only — CI does not gate on these values.",
3-
"updated": null,
4-
"machine": null,
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json (post-cache PR #90). Excluded from gate: test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Refresh via make update-baselines on ubuntu.",
3+
"updated": "2026-06-17T21:00:00Z",
4+
"machine": "Linux",
55
"groups": {
6-
"parse": {},
7-
"export": {},
6+
"parse": {
7+
"test_parse_session_medium": 0.002956,
8+
"test_parse_session_large": 0.029678
9+
},
10+
"export": {
11+
"test_bulk_export_session_count[sessions-10]": 0.004278,
12+
"test_bulk_export_session_count[sessions-50]": 0.021144,
13+
"test_bulk_export_session_count[sessions-100]": 0.042003
14+
},
815
"search": {}
916
}
1017
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Compare pytest-benchmark JSON output against stored baselines."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import sys
8+
from pathlib import Path
9+
10+
THRESHOLD = 1.20
11+
12+
# Sub-ms timings are too noisy for a fixed 20% gate on ubuntu CI.
13+
EXCLUDED_FROM_GATE = frozenset(
14+
{
15+
"test_parse_session_small",
16+
"test_search_full_corpus",
17+
}
18+
)
19+
20+
21+
class BenchmarkDataError(ValueError):
22+
"""Raised when benchmark JSON input is malformed or missing required fields."""
23+
24+
25+
def load_results(results_path: str | Path) -> dict[str, float]:
26+
path = Path(results_path)
27+
try:
28+
data = json.loads(path.read_text(encoding="utf-8"))
29+
except OSError as exc:
30+
raise BenchmarkDataError(f"cannot read {path}: {exc}") from exc
31+
except json.JSONDecodeError as exc:
32+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
33+
try:
34+
benchmarks = data["benchmarks"]
35+
except (KeyError, TypeError) as exc:
36+
raise BenchmarkDataError(f"{path} missing top-level 'benchmarks' array") from exc
37+
if not isinstance(benchmarks, list):
38+
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
39+
40+
results: dict[str, float] = {}
41+
for index, entry in enumerate(benchmarks):
42+
if not isinstance(entry, dict):
43+
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
44+
try:
45+
name = entry["name"]
46+
mean = float(entry["stats"]["mean"])
47+
except (KeyError, TypeError, ValueError) as exc:
48+
raise BenchmarkDataError(
49+
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
50+
) from exc
51+
name = str(name)
52+
if name in results:
53+
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r}")
54+
results[name] = mean
55+
return results
56+
57+
58+
def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
59+
path = Path(baselines_path)
60+
try:
61+
data = json.loads(path.read_text(encoding="utf-8"))
62+
except OSError as exc:
63+
raise BenchmarkDataError(f"cannot read {path}: {exc}") from exc
64+
except json.JSONDecodeError as exc:
65+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
66+
if not isinstance(data, dict):
67+
raise BenchmarkDataError(f"{path} root value must be an object")
68+
69+
if "groups" not in data:
70+
raise BenchmarkDataError(f"{path} missing required 'groups' key")
71+
groups = data["groups"]
72+
if not isinstance(groups, dict):
73+
raise BenchmarkDataError(f"{path} 'groups' must be an object")
74+
75+
means: dict[str, float] = {}
76+
for group_name, value in groups.items():
77+
if not isinstance(value, dict):
78+
continue
79+
for name, mean in value.items():
80+
name = str(name)
81+
if name in means:
82+
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r} across groups")
83+
try:
84+
means[name] = float(mean)
85+
except (TypeError, ValueError) as exc:
86+
raise BenchmarkDataError(
87+
f"{path} groups[{group_name!r}][{name!r}] is not a numeric mean"
88+
) from exc
89+
return means
90+
91+
92+
def check_regression(
93+
results_path: str | Path,
94+
baselines_path: str | Path,
95+
*,
96+
threshold: float = THRESHOLD,
97+
) -> int:
98+
"""Return 0 when within threshold; 1 when any gated benchmark regresses."""
99+
flat = load_results(results_path)
100+
baseline_means = load_baseline_means(baselines_path)
101+
102+
failures: list[str] = []
103+
for name, base in baseline_means.items():
104+
if name in EXCLUDED_FROM_GATE:
105+
continue
106+
cur = flat.get(name)
107+
if cur is None:
108+
print(f"WARN: no current result for baseline {name!r}; skipping")
109+
continue
110+
if base == 0:
111+
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
112+
continue
113+
ratio = cur / base
114+
tag = "FAIL" if ratio > threshold else "ok"
115+
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
116+
if ratio > threshold:
117+
failures.append(name)
118+
119+
for name in flat:
120+
if name in EXCLUDED_FROM_GATE:
121+
continue
122+
if name not in baseline_means:
123+
print(f"WARN: {name!r} has no baseline yet; not gated")
124+
125+
if failures:
126+
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {threshold:.0%}")
127+
return 1
128+
return 0
129+
130+
131+
def main(argv: list[str] | None = None) -> int:
132+
parser = argparse.ArgumentParser(description=__doc__)
133+
parser.add_argument("results_path", help="pytest-benchmark --benchmark-json output")
134+
parser.add_argument("baselines_path", help="path to benchmarks/baselines.json")
135+
parser.add_argument(
136+
"--threshold",
137+
type=float,
138+
default=THRESHOLD,
139+
help="fail when current mean exceeds baseline by more than this ratio (default: 1.20)",
140+
)
141+
args = parser.parse_args(argv)
142+
try:
143+
return check_regression(
144+
args.results_path,
145+
args.baselines_path,
146+
threshold=args.threshold,
147+
)
148+
except BenchmarkDataError as exc:
149+
print(f"ERROR: {exc}", file=sys.stderr)
150+
return 2
151+
152+
153+
if __name__ == "__main__":
154+
sys.exit(main())

scripts/reduce_baselines.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
try:
12+
from scripts.check_benchmark_regression import EXCLUDED_FROM_GATE, BenchmarkDataError
13+
except ModuleNotFoundError:
14+
from check_benchmark_regression import EXCLUDED_FROM_GATE, BenchmarkDataError
15+
16+
GATED_GROUPS = ("parse", "export", "search")
17+
18+
19+
def _positive_float(value: str) -> float:
20+
parsed = float(value)
21+
if parsed <= 0:
22+
raise argparse.ArgumentTypeError("slack must be greater than zero")
23+
return parsed
24+
25+
26+
def reduce_baselines(
27+
raw_path: str | Path,
28+
out_path: str | Path,
29+
*,
30+
slack: float = 1.0,
31+
) -> dict[str, object]:
32+
path = Path(raw_path)
33+
try:
34+
raw = json.loads(path.read_text(encoding="utf-8"))
35+
except json.JSONDecodeError as exc:
36+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
37+
except OSError as exc:
38+
raise BenchmarkDataError(f"cannot read {path}: {exc}") from exc
39+
40+
try:
41+
entries = raw["benchmarks"]
42+
except (KeyError, TypeError) as exc:
43+
raise BenchmarkDataError(f"{path} missing top-level 'benchmarks' array") from exc
44+
if not isinstance(entries, list):
45+
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
46+
47+
groups: dict[str, dict[str, float]] = {group: {} for group in GATED_GROUPS}
48+
for index, entry in enumerate(entries):
49+
if not isinstance(entry, dict):
50+
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
51+
try:
52+
name = entry["name"]
53+
mean = float(entry["stats"]["mean"])
54+
except (KeyError, TypeError, ValueError) as exc:
55+
raise BenchmarkDataError(
56+
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
57+
) from exc
58+
group = entry.get("group")
59+
if group not in GATED_GROUPS:
60+
continue
61+
if str(name) in EXCLUDED_FROM_GATE:
62+
continue
63+
groups[group][str(name)] = mean * slack
64+
65+
machine_info = raw.get("machine_info")
66+
machine = machine_info.get("system") if isinstance(machine_info, dict) else None
67+
output: dict[str, object] = {
68+
"_note": (
69+
"Gated means from ubuntu-latest CI (post-cache). "
70+
"Excluded from gate: test_parse_session_small, test_search_full_corpus (CI noise)."
71+
),
72+
"updated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
73+
"machine": machine,
74+
"groups": groups,
75+
}
76+
out = Path(out_path)
77+
try:
78+
out.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
79+
except OSError as exc:
80+
raise BenchmarkDataError(f"cannot write {out}: {exc}") from exc
81+
return output
82+
83+
84+
def main(argv: list[str] | None = None) -> int:
85+
parser = argparse.ArgumentParser(description=__doc__)
86+
parser.add_argument("raw_path", help="pytest-benchmark --benchmark-json output")
87+
parser.add_argument("out_path", help="destination baselines.json path")
88+
parser.add_argument(
89+
"--slack",
90+
type=_positive_float,
91+
default=1.0,
92+
help="multiply means by this factor (must be > 0)",
93+
)
94+
args = parser.parse_args(argv)
95+
try:
96+
reduce_baselines(args.raw_path, args.out_path, slack=args.slack)
97+
except BenchmarkDataError as exc:
98+
print(f"ERROR: {exc}", file=sys.stderr)
99+
return 2
100+
return 0
101+
102+
103+
if __name__ == "__main__":
104+
sys.exit(main())

0 commit comments

Comments
 (0)