Skip to content

Commit 64c435f

Browse files
committed
ci: add benchmark workflow
1 parent 846b6e6 commit 64c435f

13 files changed

Lines changed: 1055 additions & 32 deletions
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""Aggregate and merge benchmark JSON files.
3+
4+
The workflow runs the same benchmark suite on multiple independent runners.
5+
This script reads every JSON file produced by those attempts, normalizes the
6+
contained benchmark values, and writes a compact mapping JSON where each value is
7+
the median across attempts. It can also merge independent hyperfine JSON files
8+
from one runner into a single hyperfine-style JSON file.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import json
15+
import statistics
16+
from pathlib import Path
17+
from typing import Any
18+
19+
from compare_benchmarks import Benchmark, extract_benchmarks
20+
21+
22+
def collect_benchmarks(paths: list[Path]) -> dict[str, list[Benchmark]]:
23+
"""Collect benchmarks from multiple JSON files.
24+
25+
Args:
26+
paths (list[Path]): Paths to hyperfine, pytest-benchmark, or compact
27+
mapping JSON files.
28+
29+
Returns:
30+
dict[str, list[Benchmark]]: Benchmarks grouped by benchmark name.
31+
"""
32+
33+
collected: dict[str, list[Benchmark]] = {}
34+
for path in paths:
35+
for name, benchmark in extract_benchmarks(path).items():
36+
collected.setdefault(name, []).append(benchmark)
37+
return collected
38+
39+
40+
def aggregate(collected: dict[str, list[Benchmark]]) -> dict[str, dict[str, object]]:
41+
"""Aggregate grouped benchmarks using the median value.
42+
43+
Args:
44+
collected (dict[str, list[Benchmark]]): Benchmarks grouped by benchmark
45+
name.
46+
47+
Returns:
48+
dict[str, dict[str, object]]: Compact mapping JSON data. Each benchmark
49+
contains ``value``, ``unit``, ``metric``, ``attempts``, and
50+
``attempt_values``.
51+
"""
52+
53+
aggregated: dict[str, dict[str, object]] = {}
54+
for name, benchmarks in sorted(collected.items()):
55+
values = [benchmark.value for benchmark in benchmarks]
56+
unit = next((benchmark.unit for benchmark in benchmarks if benchmark.unit), "")
57+
metric = next((benchmark.metric for benchmark in benchmarks if benchmark.metric), "value")
58+
aggregated[name] = {
59+
"value": statistics.median(values),
60+
"unit": unit,
61+
"metric": f"median-of-attempt-{metric}",
62+
"attempts": len(values),
63+
"attempt_values": values,
64+
}
65+
return aggregated
66+
67+
68+
def merge_hyperfine_results(paths: list[Path]) -> dict[str, Any]:
69+
"""Merge hyperfine result files.
70+
71+
Args:
72+
paths (list[Path]): Hyperfine JSON files to merge.
73+
74+
Returns:
75+
dict[str, Any]: Hyperfine-style JSON object containing all result rows.
76+
77+
Raises:
78+
ValueError: If any file has no hyperfine ``results`` list.
79+
"""
80+
81+
merged: dict[str, Any] = {"results": []}
82+
for path in paths:
83+
data = json.loads(path.read_text(encoding="utf-8"))
84+
results = data.get("results", []) if isinstance(data, dict) else None
85+
if not isinstance(results, list):
86+
raise ValueError(f"{path} has no hyperfine results list")
87+
merged["results"].extend(results)
88+
return merged
89+
90+
91+
def main_from_paths(input_dir: Path, output: Path) -> int:
92+
"""Aggregate all JSON files in a directory and write the result.
93+
94+
Args:
95+
input_dir (Path): Directory containing benchmark JSON files.
96+
output (Path): Path where the aggregate JSON should be written.
97+
98+
Returns:
99+
int: Always ``0`` on success.
100+
101+
Raises:
102+
ValueError: If no JSON files are found in ``input_dir``.
103+
"""
104+
105+
paths = sorted(input_dir.rglob("*.json"))
106+
if not paths:
107+
raise ValueError(f"No benchmark JSON files found in {input_dir}")
108+
109+
output.parent.mkdir(parents=True, exist_ok=True)
110+
output.write_text(
111+
json.dumps(aggregate(collect_benchmarks(paths)), indent=2, sort_keys=True) + "\n",
112+
encoding="utf-8",
113+
)
114+
return 0
115+
116+
117+
def merge_from_paths(input_dir: Path, output: Path) -> int:
118+
"""Merge all hyperfine JSON files in a directory and write the result.
119+
120+
Args:
121+
input_dir (Path): Directory containing hyperfine JSON files.
122+
output (Path): Path where the merged JSON should be written.
123+
124+
Returns:
125+
int: Always ``0`` on success.
126+
127+
Raises:
128+
ValueError: If no JSON files are found in ``input_dir``.
129+
"""
130+
131+
paths = sorted(input_dir.glob("*.json"))
132+
if not paths:
133+
raise ValueError(f"No hyperfine JSON files found in {input_dir}")
134+
135+
output.parent.mkdir(parents=True, exist_ok=True)
136+
output.write_text(
137+
json.dumps(merge_hyperfine_results(paths), indent=2, sort_keys=True) + "\n",
138+
encoding="utf-8",
139+
)
140+
return 0
141+
142+
143+
def main() -> int:
144+
"""Run the benchmark aggregation command line interface.
145+
146+
Returns:
147+
int: Always ``0`` on success.
148+
"""
149+
150+
parser = argparse.ArgumentParser()
151+
parser.add_argument(
152+
"--mode",
153+
choices=("aggregate", "merge-hyperfine"),
154+
default="aggregate",
155+
help="Operation to perform.",
156+
)
157+
parser.add_argument("--input-dir", required=True, type=Path)
158+
parser.add_argument("--output", required=True, type=Path)
159+
args = parser.parse_args()
160+
if args.mode == "merge-hyperfine":
161+
return merge_from_paths(input_dir=args.input_dir, output=args.output)
162+
return main_from_paths(input_dir=args.input_dir, output=args.output)
163+
164+
165+
if __name__ == "__main__":
166+
raise SystemExit(main())

0 commit comments

Comments
 (0)