Skip to content

Commit 93cb445

Browse files
test: gate memory-path benchmarks in baselines (parse + ZIP export)
1 parent 2c1fd9d commit 93cb445

9 files changed

Lines changed: 213 additions & 27 deletions

benchmarks/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,22 @@ 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

4343
**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.
4444

benchmarks/baselines.json

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
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",
4-
"machine": "Linux",
2+
"_note": "Gated means from local reference run with 1.5x slack (Windows dev host). Excluded from gate: test_parse_session_small, test_search_full_corpus (CI noise). Memory benchmarks use peak_bytes; latency uses stats.mean. Refresh from ubuntu-latest CI artifact after first green job.",
3+
"updated": "2026-06-25T00:00:00Z",
4+
"machine": "Windows",
55
"groups": {
66
"parse": {
7-
"test_parse_session_medium": 0.002956,
8-
"test_parse_session_large": 0.029678
7+
"test_parse_session_medium": 0.0021930547314696017,
8+
"test_parse_session_large": 0.022708179059622507,
9+
"test_parse_large_peak_memory": 3175761.0
910
},
1011
"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
12+
"test_bulk_export_session_count[sessions-10]": 0.003750986296823886,
13+
"test_bulk_export_session_count[sessions-50]": 0.018420866638835933,
14+
"test_bulk_export_session_count[sessions-100]": 0.03871899230244498,
15+
"test_bulk_export_zip_peak_memory[sessions-10]": 525699.0,
16+
"test_bulk_export_zip_peak_memory[sessions-50]": 752907.0,
17+
"test_bulk_export_zip_peak_memory[sessions-100]": 1029018.0
1418
},
15-
"search": {}
19+
"search": {
20+
"test_search_full_corpus": 0.0033342776477665584
21+
}
1622
}
1723
}

scripts/check_benchmark_regression.py

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

2424

25+
def benchmark_entry_mean(entry: dict[str, object]) -> float:
26+
"""Return gated metric: peak_bytes from extra_info when present, else stats.mean."""
27+
extra = entry.get("extra_info")
28+
if isinstance(extra, dict) and "peak_bytes" in extra:
29+
return float(extra["peak_bytes"])
30+
try:
31+
stats = entry["stats"]
32+
return float(stats["mean"]) # type: ignore[index]
33+
except (KeyError, TypeError, ValueError) as exc:
34+
raise BenchmarkDataError(
35+
f"benchmark {entry.get('name')!r} missing 'stats.mean' or extra_info.peak_bytes"
36+
) from exc
37+
38+
39+
def is_memory_metric(name: str) -> bool:
40+
return "peak_memory" in name
41+
42+
2543
def load_results(results_path: str | Path) -> dict[str, float]:
2644
path = Path(results_path)
2745
try:
@@ -43,10 +61,10 @@ def load_results(results_path: str | Path) -> dict[str, float]:
4361
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
4462
try:
4563
name = entry["name"]
46-
mean = float(entry["stats"]["mean"])
64+
mean = benchmark_entry_mean(entry)
4765
except (KeyError, TypeError, ValueError) as exc:
4866
raise BenchmarkDataError(
49-
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
67+
f"{path} benchmarks[{index}] missing 'name' or measurable value"
5068
) from exc
5169
name = str(name)
5270
if name in results:
@@ -112,7 +130,10 @@ def check_regression(
112130
continue
113131
ratio = cur / base
114132
tag = "FAIL" if ratio > threshold else "ok"
115-
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
133+
if is_memory_metric(name):
134+
print(f"[{tag}] {name}: {cur:.0f} bytes vs {base:.0f} bytes ({ratio:.2f}x)")
135+
else:
136+
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
116137
if ratio > threshold:
117138
failures.append(name)
118139

scripts/reduce_baselines.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@
99
from pathlib import Path
1010

1111
try:
12-
from scripts.check_benchmark_regression import EXCLUDED_FROM_GATE, BenchmarkDataError
12+
from scripts.check_benchmark_regression import (
13+
EXCLUDED_FROM_GATE,
14+
BenchmarkDataError,
15+
benchmark_entry_mean,
16+
)
1317
except ModuleNotFoundError:
14-
from check_benchmark_regression import EXCLUDED_FROM_GATE, BenchmarkDataError
18+
from check_benchmark_regression import (
19+
EXCLUDED_FROM_GATE,
20+
BenchmarkDataError,
21+
benchmark_entry_mean,
22+
)
1523

1624
GATED_GROUPS = ("parse", "export", "search")
1725

@@ -50,24 +58,23 @@ def reduce_baselines(
5058
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
5159
try:
5260
name = entry["name"]
53-
mean = float(entry["stats"]["mean"])
61+
mean = benchmark_entry_mean(entry)
5462
except (KeyError, TypeError, ValueError) as exc:
5563
raise BenchmarkDataError(
56-
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
64+
f"{path} benchmarks[{index}] missing 'name' or measurable value"
5765
) from exc
5866
group = entry.get("group")
5967
if group not in GATED_GROUPS:
6068
continue
61-
if str(name) in EXCLUDED_FROM_GATE:
62-
continue
6369
groups[group][str(name)] = mean * slack
6470

6571
machine_info = raw.get("machine_info")
6672
machine = machine_info.get("system") if isinstance(machine_info, dict) else None
6773
output: dict[str, object] = {
6874
"_note": (
6975
"Gated means from ubuntu-latest CI (post-cache). "
70-
"Excluded from gate: test_parse_session_small, test_search_full_corpus (CI noise)."
76+
"Excluded from gate: test_parse_session_small, test_search_full_corpus (CI noise). "
77+
"Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds)."
7178
),
7279
"updated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
7380
"machine": machine,

tests/benchmarks/conftest.py

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

55
import json
6+
import tracemalloc
7+
from collections.abc import Callable
68
from copy import deepcopy
79
from pathlib import Path
10+
from typing import Any, TypeVar
811

912
import pytest
1013

@@ -13,6 +16,27 @@
1316
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures"
1417
TEMPLATE_LINE = (FIXTURES / "session_with_tools.jsonl").read_text(encoding="utf-8").splitlines()[0]
1518

19+
T = TypeVar("T")
20+
21+
22+
class TracemallocPeak:
23+
"""Measure peak Python heap bytes for one callable invocation."""
24+
25+
def measure(self, func: Callable[..., T], /, *args: Any, **kwargs: Any) -> tuple[T, int]:
26+
tracemalloc.start()
27+
tracemalloc.clear_traces()
28+
try:
29+
result = func(*args, **kwargs)
30+
_, peak = tracemalloc.get_traced_memory()
31+
return result, peak
32+
finally:
33+
tracemalloc.stop()
34+
35+
36+
@pytest.fixture
37+
def tracemalloc_peak() -> TracemallocPeak:
38+
return TracemallocPeak()
39+
1640

1741
def write_jsonl(path: Path, line_count: int) -> Path:
1842
"""Write a JSONL session file with *line_count* rows derived from the template fixture."""

tests/benchmarks/test_export_bench.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
from __future__ import annotations
44

5+
import io
6+
import zipfile
57
from pathlib import Path
68

79
import pytest
810

9-
from utils.export_engine import NoopSink, run_bulk_export
11+
from tests.benchmarks.conftest import TracemallocPeak
12+
from utils.export_engine import NoopSink, ZipSink, run_bulk_export
13+
14+
15+
def _bench_projects(export_corpus: Path) -> list[dict[str, str]]:
16+
return [{"name": "bench-project", "path": str(export_corpus), "display_name": "Bench"}]
1017

1118

1219
@pytest.mark.benchmark(group="export")
@@ -20,7 +27,7 @@ def test_bulk_export_session_count(
2027
benchmark,
2128
export_corpus: Path,
2229
) -> None:
23-
projects = [{"name": "bench-project", "path": str(export_corpus), "display_name": "Bench"}]
30+
projects = _bench_projects(export_corpus)
2431

2532
def _run() -> object:
2633
# NoopSink + since="all" + empty last_export_sessions: no disk/state writes per round.
@@ -37,3 +44,43 @@ def _run() -> object:
3744

3845
result = benchmark(_run)
3946
assert result.exported_session_count > 0
47+
48+
49+
@pytest.mark.benchmark(group="export")
50+
@pytest.mark.parametrize(
51+
"export_corpus",
52+
[10, 50, 100],
53+
indirect=True,
54+
ids=["sessions-10", "sessions-50", "sessions-100"],
55+
)
56+
def test_bulk_export_zip_peak_memory(
57+
benchmark,
58+
export_corpus: Path,
59+
tracemalloc_peak: TracemallocPeak,
60+
) -> None:
61+
projects = _bench_projects(export_corpus)
62+
peaks: list[int] = []
63+
64+
def _run() -> None:
65+
def _export() -> object:
66+
buf = io.BytesIO()
67+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
68+
sink = ZipSink(zf)
69+
return run_bulk_export(
70+
projects=projects,
71+
since="all",
72+
rules=[],
73+
last_export_sessions={},
74+
sink=sink,
75+
fmt="md",
76+
path_layout="api",
77+
manifest_style="api",
78+
)
79+
80+
result, peak = tracemalloc_peak.measure(_export)
81+
assert result.exported_session_count > 0
82+
peaks.append(peak)
83+
84+
benchmark(_run)
85+
assert peaks, "benchmark produced no peak memory samples"
86+
benchmark.extra_info["peak_bytes"] = int(sum(peaks) / len(peaks))

tests/benchmarks/test_parse_memory.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
"""Peak memory ceiling for large-file parse_session (regular pytest, not benchmark-only)."""
1+
"""Peak memory for large-file parse_session: ceiling test + tracked benchmark."""
22

33
from __future__ import annotations
44

55
import tracemalloc
66
from pathlib import Path
77

8+
import pytest
9+
10+
from tests.benchmarks.conftest import TracemallocPeak
811
from utils.jsonl_parser import parse_session
912

1013

@@ -26,3 +29,21 @@ def test_large_parse_peak_memory_under_ceiling(parse_large_file: Path) -> None:
2629
tracemalloc.stop()
2730

2831
assert peak < ceiling, f"peak {peak} bytes exceeds 10x file size {file_bytes}"
32+
33+
34+
@pytest.mark.benchmark(group="parse")
35+
def test_parse_large_peak_memory(
36+
benchmark,
37+
parse_large_file: Path,
38+
tracemalloc_peak: TracemallocPeak,
39+
) -> None:
40+
path = str(parse_large_file)
41+
peaks: list[int] = []
42+
43+
def _run() -> None:
44+
_, peak = tracemalloc_peak.measure(parse_session, path)
45+
peaks.append(peak)
46+
47+
benchmark(_run)
48+
assert peaks, "benchmark produced no peak memory samples"
49+
benchmark.extra_info["peak_bytes"] = int(sum(peaks) / len(peaks))

tests/test_check_benchmark_regression.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,46 @@ def test_main_reports_benchmark_data_error(tmp_path, capsys: pytest.CaptureFixtu
179179
assert "ERROR:" in capsys.readouterr().err
180180

181181

182+
def test_load_results_prefers_peak_bytes_extra_info(tmp_path) -> None:
183+
path = tmp_path / "results.json"
184+
_write_results(
185+
path,
186+
[
187+
{
188+
"name": "test_parse_large_peak_memory",
189+
"stats": {"mean": 0.05},
190+
"extra_info": {"peak_bytes": 12_345_678},
191+
}
192+
],
193+
)
194+
195+
assert load_results(path)["test_parse_large_peak_memory"] == 12_345_678.0
196+
197+
198+
def test_memory_metric_regression_uses_bytes(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
199+
results = tmp_path / "results.json"
200+
baselines = tmp_path / "baselines.json"
201+
_write_results(
202+
results,
203+
[
204+
{
205+
"name": "test_parse_large_peak_memory",
206+
"stats": {"mean": 0.05},
207+
"extra_info": {"peak_bytes": 15_000_000},
208+
}
209+
],
210+
)
211+
_write_baselines(
212+
baselines,
213+
{"parse": {"test_parse_large_peak_memory": 10_000_000}},
214+
)
215+
216+
assert check_regression(results, baselines) == 1
217+
out = capsys.readouterr().out
218+
assert "bytes" in out
219+
assert "REGRESSION" in out
220+
221+
182222
def test_duplicate_baseline_name_raises(tmp_path) -> None:
183223
baselines = tmp_path / "baselines.json"
184224
_write_baselines(

tests/test_reduce_baselines.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_reduce_baselines_writes_gated_groups_only(tmp_path) -> None:
3939

4040
assert output["machine"] == "Linux"
4141
assert "test_parse_session_medium" in output["groups"]["parse"]
42-
assert "test_parse_session_small" not in output["groups"]["parse"]
42+
assert "test_parse_session_small" in output["groups"]["parse"]
4343
assert "cache" not in output["groups"]
4444

4545

@@ -79,6 +79,26 @@ def test_reduce_baselines_cli_rejects_non_positive_slack(tmp_path) -> None:
7979
assert exc_info.value.code == 2
8080

8181

82+
def test_reduce_baselines_uses_peak_bytes_extra_info(tmp_path) -> None:
83+
raw = tmp_path / "raw.json"
84+
out = tmp_path / "baselines.json"
85+
_write_raw(
86+
raw,
87+
[
88+
{
89+
"group": "parse",
90+
"name": "test_parse_large_peak_memory",
91+
"stats": {"mean": 0.05},
92+
"extra_info": {"peak_bytes": 10_000_000},
93+
}
94+
],
95+
)
96+
97+
output = reduce_baselines(raw, out)
98+
99+
assert output["groups"]["parse"]["test_parse_large_peak_memory"] == 10_000_000.0
100+
101+
82102
def test_reduce_baselines_machine_info_non_dict(tmp_path) -> None:
83103
raw = tmp_path / "raw.json"
84104
raw.write_text(

0 commit comments

Comments
 (0)