Skip to content

Commit 0d2e227

Browse files
committed
chg: add benchmark tool
1 parent 4f05298 commit 0d2e227

5 files changed

Lines changed: 394 additions & 1 deletion

File tree

.github/workflows/benchmark.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Benchmark
2+
3+
on:
4+
pull_request:
5+
6+
jobs:
7+
benchmark:
8+
runs-on: ubuntu-latest
9+
permissions:
10+
pull-requests: write
11+
steps:
12+
- name: Checkout PR branch
13+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
14+
with:
15+
fetch-depth: 0
16+
persist-credentials: false
17+
18+
- name: Install uv
19+
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3
20+
21+
- name: Install dependencies (PR branch)
22+
run: uv sync --frozen
23+
24+
- name: Benchmark current (PR branch)
25+
run: >
26+
uv run pytest
27+
src/gps_logger_parser/tests/test_benchmark.py
28+
--benchmark-json=bench_current.json
29+
--benchmark-min-rounds=5
30+
-q
31+
32+
- name: Save current benchmark
33+
run: cp bench_current.json /tmp/bench_current.json
34+
35+
- name: Checkout base branch
36+
run: git checkout ${{ github.event.pull_request.base.sha }}
37+
38+
- name: Install dependencies (base branch)
39+
run: uv sync
40+
41+
- name: Benchmark baseline (base branch)
42+
id: baseline
43+
run: >
44+
uv run pytest
45+
src/gps_logger_parser/tests/test_benchmark.py
46+
--benchmark-json=bench_baseline.json
47+
--benchmark-min-rounds=5
48+
-q
49+
continue-on-error: true
50+
51+
- name: Checkout PR branch again
52+
run: git checkout ${{ github.event.pull_request.head.sha }}
53+
54+
- name: Restore current benchmark
55+
run: cp /tmp/bench_current.json bench_current.json
56+
57+
- name: Generate comparison report
58+
run: |
59+
if [ -f bench_baseline.json ]; then
60+
uv run python scripts/benchmark_compare.py bench_baseline.json bench_current.json > benchmark_report.md
61+
else
62+
echo "No baseline benchmark available (benchmark test may not exist on base branch)." > benchmark_report.md
63+
fi
64+
65+
- name: Post benchmark comment
66+
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
67+
with:
68+
issue-number: ${{ github.event.pull_request.number }}
69+
body-path: benchmark_report.md
70+
comment-tag: benchmark-report

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ dev = [
1212
"mkdocs-material>=9.7.1",
1313
"mkdocs-typer2>=0.1.6",
1414
"ty>=0.0.3",
15-
"coverage>=7.13.5"
15+
"coverage>=7.13.5",
16+
"pytest-benchmark>=5.2.3",
17+
"pytest-memray>=1.8.0"
1618
]
1719

1820
[project]

scripts/benchmark_compare.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Compare two pytest-benchmark JSON result files and output a Markdown report.
2+
3+
Usage:
4+
python scripts/benchmark_compare.py <baseline.json> <current.json>
5+
6+
The report is printed to stdout and is suitable for posting as a GitHub PR
7+
comment. The script always exits 0 — it is informational only.
8+
"""
9+
10+
import json
11+
import pathlib
12+
import sys
13+
14+
REGRESSION_THRESHOLD_PCT = 20 # warn marker above this percentage
15+
16+
17+
def load_benchmarks(path: str) -> dict[str, dict]:
18+
"""Return a dict mapping test name -> stats from a pytest-benchmark JSON."""
19+
with pathlib.Path(path).open() as file:
20+
data = json.load(file)
21+
return {bench["name"]: bench["stats"] for bench in data["benchmarks"]}
22+
23+
24+
def short_name(full_name: str) -> str:
25+
"""Extract the parametrize ID from a full test name."""
26+
start = full_name.find("[")
27+
end = full_name.rfind("]")
28+
if start != -1 and end != -1:
29+
return full_name[start + 1 : end]
30+
return full_name
31+
32+
33+
def format_table(
34+
rows: list[tuple[str, float, float, float]],
35+
) -> list[str]:
36+
"""Format comparison rows into a Markdown table.
37+
38+
Each row is (name, baseline_ms, current_ms, change_pct).
39+
"""
40+
lines = [
41+
"| Test | Baseline (ms) | Current (ms) | Change |",
42+
"|---|--:|--:|--:|",
43+
]
44+
for name, baseline_ms, current_ms, change_pct in rows:
45+
warn = " :warning:" if change_pct > REGRESSION_THRESHOLD_PCT else ""
46+
lines.append(
47+
f"| {name} | {baseline_ms:.2f} | {current_ms:.2f} "
48+
f"| {change_pct:+.1f}%{warn} |"
49+
)
50+
return lines
51+
52+
53+
def compare(
54+
baseline: dict[str, dict],
55+
current: dict[str, dict],
56+
name_filter: str,
57+
) -> tuple[list[tuple[str, float, float, float]], float]:
58+
"""Compare benchmarks whose names contain *name_filter*.
59+
60+
Returns (rows, average_change_pct).
61+
"""
62+
rows: list[tuple[str, float, float, float]] = []
63+
for name in sorted(baseline):
64+
if name_filter not in name:
65+
continue
66+
if name not in current:
67+
continue
68+
baseline_ms = baseline[name]["mean"] * 1000
69+
current_ms = current[name]["mean"] * 1000
70+
change_pct = ((current_ms - baseline_ms) / baseline_ms) * 100
71+
rows.append((short_name(name), baseline_ms, current_ms, change_pct))
72+
73+
average = sum(r[3] for r in rows) / len(rows) if rows else 0.0
74+
return rows, average
75+
76+
77+
def main() -> None:
78+
if len(sys.argv) != 3:
79+
print(f"Usage: {sys.argv[0]} <baseline.json> <current.json>", file=sys.stderr)
80+
sys.exit(1)
81+
82+
baseline = load_benchmarks(sys.argv[1])
83+
current = load_benchmarks(sys.argv[2])
84+
85+
output: list[str] = ["## Benchmark Comparison", ""]
86+
87+
# --- Detection ---
88+
detect_rows, detect_avg = compare(baseline, current, "test_bench_detect[")
89+
# Exclude harmonize tests that also contain "detect"
90+
detect_rows = [r for r in detect_rows if "harmonize" not in r[0]]
91+
if detect_rows:
92+
# Recalculate average after filtering
93+
detect_avg = (
94+
sum(r[3] for r in detect_rows) / len(detect_rows) if detect_rows else 0.0
95+
)
96+
output.append("### Detection (`detect_file`)")
97+
output.append("")
98+
output.extend(format_table(detect_rows))
99+
output.append("")
100+
output.append(f"**Average: {detect_avg:+.1f}%**")
101+
output.append("")
102+
103+
# --- Full pipeline ---
104+
harmonize_rows, harmonize_avg = compare(
105+
baseline, current, "test_bench_detect_and_harmonize["
106+
)
107+
if harmonize_rows:
108+
output.append("### Full Pipeline (`detect_file` + `as_table`)")
109+
output.append("")
110+
output.extend(format_table(harmonize_rows))
111+
output.append("")
112+
output.append(f"**Average: {harmonize_avg:+.1f}%**")
113+
output.append("")
114+
115+
# --- Summary ---
116+
if not detect_rows and not harmonize_rows:
117+
output.append(
118+
"No matching benchmarks found in both baseline and current results."
119+
)
120+
else:
121+
regressions = [
122+
r for r in (detect_rows + harmonize_rows) if r[3] > REGRESSION_THRESHOLD_PCT
123+
]
124+
if regressions:
125+
output.append(
126+
f":warning: **{len(regressions)} test(s) regressed "
127+
f"by more than {REGRESSION_THRESHOLD_PCT}%**"
128+
)
129+
else:
130+
output.append("No significant regressions detected.")
131+
132+
print("\n".join(output))
133+
134+
135+
if __name__ == "__main__":
136+
main()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import pathlib
2+
3+
import pytest
4+
import yaml
5+
6+
from ..parser import detect_file
7+
8+
TESTS_DATA_PATH = pathlib.Path("tests")
9+
TEST_CONFIG_PATH = TESTS_DATA_PATH / "config.yaml"
10+
11+
CONFIG = yaml.safe_load(TEST_CONFIG_PATH.open("r"))
12+
13+
test_files = [
14+
pytest.param(
15+
TESTS_DATA_PATH / "files" / filename,
16+
conf,
17+
id=filename,
18+
)
19+
for filename, conf in CONFIG.get("files", {}).items()
20+
if conf.get("skip", False) is not True
21+
and (TESTS_DATA_PATH / "files" / filename).exists()
22+
]
23+
24+
25+
@pytest.mark.parametrize("path,config", test_files)
26+
def test_bench_detect(benchmark, path, config):
27+
"""Benchmark detect_file() — the parser detection loop."""
28+
result = benchmark(detect_file, path)
29+
assert result.DATATYPE == config["type"]
30+
31+
32+
@pytest.mark.parametrize("path,config", test_files)
33+
def test_bench_detect_and_harmonize(benchmark, path, config):
34+
"""Benchmark detect_file() + as_table() — full pipeline."""
35+
36+
def detect_and_harmonize():
37+
parser_instance = detect_file(path)
38+
return parser_instance.as_table()
39+
40+
table = benchmark(detect_and_harmonize)
41+
assert table
42+
assert "_original_data" in table.column_names

0 commit comments

Comments
 (0)