Skip to content

Commit 7f1e0cf

Browse files
test: gate memory-path benchmarks in baselines (parse + ZIP export) (#97)
* test: gate memory-path benchmarks in baselines (parse + ZIP export) * fix(ci): seed ubuntu baselines and fix ruff/mypy benchmark regressions * fix(test): harden benchmark gate and tracemalloc fixtures (PR review) * fix(ci): ruff format check_benchmark_regression.py * tests/benchmarks/conftest.py for i in range(count): write_jsonl(project / f"session_{i:04d}.jsonl", 20) # Unique first_timestamp per session so export filenames do not collide in ZIP benches. first_ts = f"2026-06-12T{i % 24:02d}:{i % 60:02d}:00Z" @timon0305 timon0305 1 minute ago Member first_ts = f"2026-06-12T{i % 24:02d}:{i % 60:02d}:00Z" is only unique for count ≤ 120 (collides at lcm(24,60)). Add assert count <= 120 (or a wider format) so a future corpus bump can't produce colliding ZIP entries. * fix(test): align baseline refresh slack and export timestamp rollover
1 parent 2c1fd9d commit 7f1e0cf

11 files changed

Lines changed: 362 additions & 67 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ coverage/
1616
coverage.xml
1717
benchmark-results.json
1818
benchmarks/_raw.json
19+
benchmarks/_ci/

Makefile

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
.PHONY: update-baselines check-benchmarks clean-benchmark-artifacts
1+
.PHONY: seed-baselines-local update-baselines check-benchmarks clean-benchmark-artifacts
22

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
3+
# WARNING: captures timings on THIS machine. Production baselines must match ubuntu-latest CI.
4+
# Prefer downloading benchmark-results.json from a CI artifact, then:
5+
# python scripts/reduce_baselines.py benchmark-results.json benchmarks/baselines.json --slack 1.5
6+
seed-baselines-local:
7+
@echo "WARNING: seed-baselines-local uses this host's timings; CI gates on ubuntu-latest." >&2
8+
PYTHONPATH=. pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
9+
PYTHONPATH=. python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json --slack 1.5
10+
11+
# Deprecated alias — kept for muscle memory; see seed-baselines-local warning above.
12+
update-baselines: seed-baselines-local
613

714
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
15+
PYTHONPATH=. pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts=
16+
PYTHONPATH=. python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
1017

1118
clean-benchmark-artifacts:
1219
rm -f benchmarks/_raw.json benchmark-results.json

benchmarks/README.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,38 +23,48 @@ The memory test also runs as part of the normal `pytest` suite (timing benchmark
2323

2424
| Group | What |
2525
|-------|------|
26-
| parse | `parse_session` on 10 / 500 / 5000+ line JSONL |
27-
| export | `run_bulk_export` over 10 / 50 / 100 sessions |
26+
| parse | `parse_session` on 10 / 500 / 5000+ line JSONL; large-file peak heap (`test_parse_large_peak_memory`) |
27+
| export | `run_bulk_export` latency over 10 / 50 / 100 sessions; ZIP export peak heap (`test_bulk_export_zip_peak_memory`) |
2828
| search | `GET /api/search` over a 50-session synthetic corpus |
2929
| cache | cold vs warm `get_cached_session` (informational; not gated) |
3030

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

3333
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.
3434

35-
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.
35+
The memory ceiling test (`test_large_parse_peak_memory_under_ceiling`) runs in the main `pytest` job. Tracked peak-memory benchmarks (`test_parse_large_peak_memory`, `test_bulk_export_zip_peak_memory`) run under `--benchmark-only` and store `extra_info.peak_bytes` for the regression gate.
3636

3737
## CI gate
3838

3939
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%**.
4040

41-
**Gated:** parse medium/large, export 10/50/100 sessions.
41+
**Gated:** parse medium/large + large peak memory; export 10/50/100 session latency + ZIP peak memory.
4242

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.
43+
**Not gated (informational only):** `test_parse_session_small`, `test_search_full_corpus` (sub-ms CI noise), and the `cache` group. These may appear in `baselines.json` for reference but are skipped by `check_benchmark_regression.py`. Benchmarks without a baseline entry print a warning and do not fail the gate.
44+
45+
Missing gated benchmarks (renamed or removed tests still listed in `baselines.json`) fail the gate.
4446

4547
## Refresh baselines
4648

47-
After intentional performance work, capture on **ubuntu-latest** (same OS as the gated CI job):
49+
After intentional performance work, capture on **ubuntu-latest** (same OS as the gated CI job). Download `benchmark-results.json` from a CI artifact when possible:
50+
51+
```bash
52+
python scripts/reduce_baselines.py benchmark-results.json benchmarks/baselines.json --slack 1.5
53+
```
54+
55+
For a quick local snapshot only (may not match CI timings):
4856

4957
```bash
50-
make update-baselines
58+
make seed-baselines-local
5159
```
5260

61+
`make update-baselines` is a deprecated alias for `seed-baselines-local` and prints a warning. Do not commit baselines from macOS/Windows unless you accept cross-OS gate skew.
62+
5363
Or manually:
5464

5565
```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
66+
PYTHONPATH=. pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
67+
PYTHONPATH=. python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json --slack 1.5
5868
```
5969

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.
70+
Baselines must be captured on **ubuntu-latest** to match the gated CI runner. Cross-OS variance causes spurious failures.

benchmarks/baselines.json

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
{
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",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json (PR #97, run 28126772276). Excluded from gate (recorded for reference): test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3+
"updated": "2026-06-24T20:15:37Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {
7-
"test_parse_session_medium": 0.002956,
8-
"test_parse_session_large": 0.029678
7+
"test_parse_session_small": 0.00010518068718225604,
8+
"test_parse_session_medium": 0.002991333112179635,
9+
"test_parse_session_large": 0.032311203818181436,
10+
"test_parse_large_peak_memory": 2032028.0
911
},
1012
"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
13+
"test_bulk_export_session_count[sessions-10]": 0.0042825538530803925,
14+
"test_bulk_export_session_count[sessions-50]": 0.021406330209302382,
15+
"test_bulk_export_session_count[sessions-100]": 0.04229194749999898,
16+
"test_bulk_export_zip_peak_memory[sessions-10]": 350628.0,
17+
"test_bulk_export_zip_peak_memory[sessions-50]": 506454.0,
18+
"test_bulk_export_zip_peak_memory[sessions-100]": 694088.0
1419
},
15-
"search": {}
20+
"search": {
21+
"test_search_full_corpus": 0.0011120838654706596
22+
}
1623
}
1724
}

scripts/check_benchmark_regression.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,43 @@ class BenchmarkDataError(ValueError):
2222
"""Raised when benchmark JSON input is malformed or missing required fields."""
2323

2424

25-
def load_results(results_path: str | Path) -> dict[str, float]:
25+
def entry_uses_peak_bytes(entry: dict[str, object]) -> bool:
26+
"""True when the gated metric for *entry* is extra_info.peak_bytes."""
27+
extra = entry.get("extra_info")
28+
return isinstance(extra, dict) and "peak_bytes" in extra
29+
30+
31+
def metric_is_bytes(name: str, entry: dict[str, object] | None = None) -> bool:
32+
"""Shared heuristic for metric kind (bytes vs seconds) in gate and display."""
33+
if entry is not None and entry_uses_peak_bytes(entry):
34+
return True
35+
return "peak_memory" in name
36+
37+
38+
def benchmark_entry_mean(entry: dict[str, object]) -> float:
39+
"""Return gated metric: peak_bytes from extra_info when present, else stats.mean."""
40+
if entry_uses_peak_bytes(entry):
41+
extra = entry["extra_info"]
42+
if not isinstance(extra, dict):
43+
raise BenchmarkDataError(f"extra_info for {entry.get('name')!r} is not a dict")
44+
try:
45+
return float(extra["peak_bytes"])
46+
except (KeyError, TypeError, ValueError) as exc:
47+
raise BenchmarkDataError(
48+
f"benchmark {entry.get('name')!r} missing 'stats.mean' or extra_info.peak_bytes"
49+
) from exc
50+
try:
51+
stats = entry["stats"]
52+
return float(stats["mean"]) # type: ignore[index]
53+
except (KeyError, TypeError, ValueError) as exc:
54+
raise BenchmarkDataError(
55+
f"benchmark {entry.get('name')!r} missing 'stats.mean' or extra_info.peak_bytes"
56+
) from exc
57+
58+
59+
def load_results(
60+
results_path: str | Path,
61+
) -> tuple[dict[str, float], dict[str, dict[str, object]]]:
2662
path = Path(results_path)
2763
try:
2864
data = json.loads(path.read_text(encoding="utf-8"))
@@ -38,21 +74,25 @@ def load_results(results_path: str | Path) -> dict[str, float]:
3874
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
3975

4076
results: dict[str, float] = {}
77+
entries_by_name: dict[str, dict[str, object]] = {}
4178
for index, entry in enumerate(benchmarks):
4279
if not isinstance(entry, dict):
4380
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
4481
try:
4582
name = entry["name"]
46-
mean = float(entry["stats"]["mean"])
83+
mean = benchmark_entry_mean(entry)
84+
except BenchmarkDataError:
85+
raise
4786
except (KeyError, TypeError, ValueError) as exc:
4887
raise BenchmarkDataError(
49-
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
88+
f"{path} benchmarks[{index}] missing 'name' or measurable value"
5089
) from exc
5190
name = str(name)
5291
if name in results:
5392
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r}")
5493
results[name] = mean
55-
return results
94+
entries_by_name[name] = entry
95+
return results, entries_by_name
5696

5797

5898
def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
@@ -96,23 +136,29 @@ def check_regression(
96136
threshold: float = THRESHOLD,
97137
) -> int:
98138
"""Return 0 when within threshold; 1 when any gated benchmark regresses."""
99-
flat = load_results(results_path)
139+
flat, entries_by_name = load_results(results_path)
100140
baseline_means = load_baseline_means(baselines_path)
101141

102142
failures: list[str] = []
143+
missing: list[str] = []
103144
for name, base in baseline_means.items():
104145
if name in EXCLUDED_FROM_GATE:
105146
continue
106147
cur = flat.get(name)
107148
if cur is None:
108-
print(f"WARN: no current result for baseline {name!r}; skipping")
149+
print(f"FAIL: no current result for gated baseline {name!r}")
150+
missing.append(name)
109151
continue
110152
if base == 0:
111153
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
112154
continue
113155
ratio = cur / base
114156
tag = "FAIL" if ratio > threshold else "ok"
115-
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
157+
entry = entries_by_name.get(name)
158+
if metric_is_bytes(name, entry):
159+
print(f"[{tag}] {name}: {cur:.0f} bytes vs {base:.0f} bytes ({ratio:.2f}x)")
160+
else:
161+
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
116162
if ratio > threshold:
117163
failures.append(name)
118164

@@ -124,6 +170,9 @@ def check_regression(
124170

125171
if failures:
126172
print(f"\nREGRESSION: {len(failures)} benchmark(s) exceeded {threshold:.0%}")
173+
if missing:
174+
print(f"\nMISSING: {len(missing)} gated benchmark(s) absent from current results")
175+
if failures or missing:
127176
return 1
128177
return 0
129178

scripts/reduce_baselines.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
from datetime import UTC, datetime
99
from pathlib import Path
1010

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
11+
from scripts.check_benchmark_regression import (
12+
BenchmarkDataError,
13+
benchmark_entry_mean,
14+
)
1515

1616
GATED_GROUPS = ("parse", "export", "search")
1717

@@ -50,24 +50,30 @@ def reduce_baselines(
5050
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
5151
try:
5252
name = entry["name"]
53-
mean = float(entry["stats"]["mean"])
53+
mean = benchmark_entry_mean(entry)
54+
except BenchmarkDataError:
55+
raise
5456
except (KeyError, TypeError, ValueError) as exc:
5557
raise BenchmarkDataError(
56-
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
58+
f"{path} benchmarks[{index}] missing 'name' or measurable value"
5759
) from exc
60+
bench_name = str(name)
5861
group = entry.get("group")
5962
if group not in GATED_GROUPS:
6063
continue
61-
if str(name) in EXCLUDED_FROM_GATE:
62-
continue
63-
groups[group][str(name)] = mean * slack
64+
groups[group][bench_name] = mean * slack
6465

66+
slack_note = f" Values multiplied by {slack}× slack at generation time." if slack != 1.0 else ""
6567
machine_info = raw.get("machine_info")
6668
machine = machine_info.get("system") if isinstance(machine_info, dict) else None
6769
output: dict[str, object] = {
6870
"_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+
"Gated means from ubuntu-latest CI benchmark-results.json."
72+
f"{slack_note} "
73+
"Excluded from gate (recorded for reference): test_parse_session_small, "
74+
"test_search_full_corpus (sub-ms CI noise). "
75+
"Memory benchmarks use extra_info.peak_bytes (bytes); "
76+
"latency uses stats.mean (seconds)."
7177
),
7278
"updated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
7379
"machine": machine,

tests/benchmarks/conftest.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
from __future__ import annotations
44

55
import json
6+
import tracemalloc
7+
from collections.abc import Callable
68
from copy import deepcopy
9+
from datetime import UTC, datetime, timedelta
710
from pathlib import Path
11+
from typing import Any, TypeVar
812

913
import pytest
1014

@@ -13,14 +17,47 @@
1317
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
1418
TEMPLATE_LINE = (FIXTURES / "session_with_tools.jsonl").read_text(encoding="utf-8").splitlines()[0]
1519

20+
T = TypeVar("T")
1621

17-
def write_jsonl(path: Path, line_count: int) -> Path:
22+
_EXPORT_SESSION_BASE = datetime(2026, 6, 12, 0, 0, tzinfo=UTC)
23+
24+
25+
def export_session_first_timestamp(index: int) -> str:
26+
"""Return a unique, valid ISO timestamp for export-corpus session *index*."""
27+
return (_EXPORT_SESSION_BASE + timedelta(minutes=index)).strftime("%Y-%m-%dT%H:%M:%SZ")
28+
29+
30+
class TracemallocPeak:
31+
"""Measure peak Python heap bytes for one callable invocation."""
32+
33+
def measure(self, func: Callable[..., T], /, *args: Any, **kwargs: Any) -> tuple[T, int]:
34+
was_tracing = tracemalloc.is_tracing()
35+
tracemalloc.start()
36+
tracemalloc.clear_traces()
37+
try:
38+
result = func(*args, **kwargs)
39+
_, peak = tracemalloc.get_traced_memory()
40+
return result, peak
41+
finally:
42+
if not was_tracing:
43+
tracemalloc.stop()
44+
45+
46+
@pytest.fixture
47+
def tracemalloc_peak() -> TracemallocPeak:
48+
return TracemallocPeak()
49+
50+
51+
def write_jsonl(path: Path, line_count: int, *, first_timestamp: str | None = None) -> Path:
1852
"""Write a JSONL session file with *line_count* rows derived from the template fixture."""
1953
template = json.loads(TEMPLATE_LINE)
2054
with path.open("w", encoding="utf-8") as f:
2155
for i in range(line_count):
2256
entry = deepcopy(template)
23-
entry["timestamp"] = f"2026-06-12T10:{i % 60:02d}:00Z"
57+
if i == 0 and first_timestamp is not None:
58+
entry["timestamp"] = first_timestamp
59+
else:
60+
entry["timestamp"] = f"2026-06-12T10:{i % 60:02d}:00Z"
2461
if i % 3 == 1:
2562
msg = entry.setdefault("message", {})
2663
if isinstance(msg, dict) and "content" in msg:
@@ -72,7 +109,9 @@ def export_corpus(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
72109
project = tmp_path / "bench-project"
73110
project.mkdir()
74111
for i in range(count):
75-
write_jsonl(project / f"session_{i:04d}.jsonl", 20)
112+
# Unique first_timestamp per session so export filenames do not collide in ZIP benches.
113+
first_ts = export_session_first_timestamp(i)
114+
write_jsonl(project / f"session_{i:04d}.jsonl", 20, first_timestamp=first_ts)
76115
return project
77116

78117

0 commit comments

Comments
 (0)