Skip to content

Commit 5ab0d5f

Browse files
author
Anders Brams
committed
feat: performance benchmark in cicd
1 parent 394c603 commit 5ab0d5f

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

.github/workflows/qa.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,50 @@ jobs:
3737

3838
- name: Contract tests
3939
run: uv run pytest -n auto
40+
41+
benchmark:
42+
name: Generation benchmark
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 15
45+
46+
steps:
47+
- name: Check out repository
48+
uses: actions/checkout@v4
49+
50+
- name: Check out base repository
51+
if: github.event_name == 'pull_request'
52+
uses: actions/checkout@v4
53+
with:
54+
ref: ${{ github.event.pull_request.base.sha }}
55+
path: .benchmark/base
56+
57+
- name: Install uv
58+
uses: astral-sh/setup-uv@v5
59+
60+
- name: Set up Python
61+
uses: actions/setup-python@v5
62+
with:
63+
python-version-file: .python-version
64+
65+
- name: Sync packages
66+
run: uv sync --locked --all-packages
67+
68+
- name: Benchmark base generation
69+
if: github.event_name == 'pull_request'
70+
run: >
71+
uv run python scripts/benchmark_generate.py run --package-path
72+
.benchmark/base --spec tests/performance/nautobot.json.gz --repeat 5
73+
--warmup 1 --output .benchmark/base.json
74+
75+
- name: Benchmark current generation
76+
run: >
77+
uv run python scripts/benchmark_generate.py run --package-path .
78+
--spec tests/performance/nautobot.json.gz --repeat 5 --warmup 1
79+
--output .benchmark/current.json
80+
81+
- name: Check generation regression
82+
if: github.event_name == 'pull_request'
83+
run: >
84+
uv run python scripts/benchmark_generate.py compare --baseline
85+
.benchmark/base.json --candidate .benchmark/current.json
86+
--max-regression 0.02

scripts/benchmark_generate.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import gzip
5+
import json
6+
import shutil
7+
import statistics
8+
import sys
9+
import tempfile
10+
import time
11+
from pathlib import Path
12+
from typing import Any
13+
14+
15+
def _load_spec(path: Path) -> str:
16+
if path.suffix == ".gz":
17+
return gzip.decompress(path.read_bytes()).decode("utf-8")
18+
return path.read_text(encoding="utf-8")
19+
20+
21+
def _load_generator(package_path: Path) -> tuple[Any, Any]:
22+
sys.path.insert(0, str(package_path.resolve()))
23+
24+
from openapi_python.generator import GenerationRequest, generate_client
25+
26+
return GenerationRequest, generate_client
27+
28+
29+
def _run_once(
30+
*,
31+
generate_client: Any,
32+
generation_request: Any,
33+
spec_json: str,
34+
package_name: str,
35+
) -> tuple[float, Any]:
36+
output_dir = Path(tempfile.mkdtemp(prefix="openapi-python-benchmark-"))
37+
try:
38+
start = time.perf_counter()
39+
result = generate_client(
40+
generation_request(
41+
output_dir=output_dir,
42+
spec_json=spec_json,
43+
package_name=package_name,
44+
overwrite=True,
45+
)
46+
)
47+
elapsed = time.perf_counter() - start
48+
return elapsed, result
49+
finally:
50+
shutil.rmtree(output_dir, ignore_errors=True)
51+
52+
53+
def run_benchmark(args: argparse.Namespace) -> int:
54+
spec_json = _load_spec(args.spec)
55+
generation_request, generate_client = _load_generator(args.package_path)
56+
57+
result = None
58+
for _ in range(args.warmup):
59+
_, result = _run_once(
60+
generate_client=generate_client,
61+
generation_request=generation_request,
62+
spec_json=spec_json,
63+
package_name=args.package,
64+
)
65+
66+
samples = []
67+
for _ in range(args.repeat):
68+
elapsed, result = _run_once(
69+
generate_client=generate_client,
70+
generation_request=generation_request,
71+
spec_json=spec_json,
72+
package_name=args.package,
73+
)
74+
samples.append(elapsed)
75+
76+
if result is None:
77+
raise RuntimeError("benchmark did not run")
78+
79+
payload = {
80+
"best_seconds": min(samples),
81+
"median_seconds": statistics.median(samples),
82+
"samples_seconds": samples,
83+
"operations": result.operations,
84+
"type_definitions": result.type_definitions,
85+
"repeat": args.repeat,
86+
"warmup": args.warmup,
87+
}
88+
89+
encoded = json.dumps(payload, indent=2, sort_keys=True)
90+
if args.output:
91+
args.output.write_text(encoded + "\n", encoding="utf-8")
92+
print(encoded)
93+
return 0
94+
95+
96+
def compare_benchmarks(args: argparse.Namespace) -> int:
97+
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
98+
candidate = json.loads(args.candidate.read_text(encoding="utf-8"))
99+
100+
baseline_seconds = float(baseline["best_seconds"])
101+
candidate_seconds = float(candidate["best_seconds"])
102+
allowed_seconds = baseline_seconds * (1 + args.max_regression)
103+
change = (candidate_seconds - baseline_seconds) / baseline_seconds
104+
105+
print(f"baseline best: {baseline_seconds:.6f}s")
106+
print(f"candidate best: {candidate_seconds:.6f}s")
107+
print(f"change: {change:+.2%}")
108+
print(f"limit: +{args.max_regression:.2%}")
109+
110+
if candidate_seconds > allowed_seconds:
111+
print(
112+
"generation benchmark regressed beyond the configured limit",
113+
file=sys.stderr,
114+
)
115+
return 1
116+
return 0
117+
118+
119+
def _build_parser() -> argparse.ArgumentParser:
120+
parser = argparse.ArgumentParser(
121+
prog="benchmark_generate.py",
122+
description="Benchmark OpenAPI client generation for a large spec.",
123+
)
124+
subcommands = parser.add_subparsers(dest="command", required=True)
125+
126+
run = subcommands.add_parser("run", help="Run the generation benchmark")
127+
run.add_argument("--spec", type=Path, required=True)
128+
run.add_argument("--package-path", type=Path, default=Path.cwd())
129+
run.add_argument("--package", default="my_client")
130+
run.add_argument("--repeat", type=int, default=5)
131+
run.add_argument("--warmup", type=int, default=1)
132+
run.add_argument("--output", type=Path)
133+
run.set_defaults(func=run_benchmark)
134+
135+
compare = subcommands.add_parser("compare", help="Compare two benchmark results")
136+
compare.add_argument("--baseline", type=Path, required=True)
137+
compare.add_argument("--candidate", type=Path, required=True)
138+
compare.add_argument("--max-regression", type=float, default=0.02)
139+
compare.set_defaults(func=compare_benchmarks)
140+
141+
return parser
142+
143+
144+
def main(argv: list[str] | None = None) -> int:
145+
parser = _build_parser()
146+
args = parser.parse_args(argv)
147+
return args.func(args)
148+
149+
150+
if __name__ == "__main__":
151+
raise SystemExit(main())

tests/performance/nautobot.json.gz

639 KB
Binary file not shown.

0 commit comments

Comments
 (0)