Log in
scripts/crosscheck_hyperfine.py 241 lines · 7.8 KB · python Blame
1
#!/usr/bin/env python3
2
"""Cross-check harness timings with optional external `hyperfine`.
3
4
The benchmark harness stays stdlib-only. `hyperfine` is optional: when absent,
5
callers get either a clear diagnostic or a structured returncode-77 skip row.
6
"""
7
8
from __future__ import annotations
9
10
import argparse
11
import json
12
import os
13
import shlex
14
import shutil
15
import subprocess
16
import sys
17
import tempfile
18
from pathlib import Path
19
from typing import Any, Callable, Optional, Sequence
20
21
from oakbench.environment import host_home_environment
22
23
SKIP_RETURNCODE = 77
24
MEASUREMENT_SOURCE = "external_hyperfine"
25
26
27
def hyperfine_path(executable: str = "hyperfine") -> Optional[str]:
28
    return shutil.which(executable)
29
30
31
def skip_row(
32
    *,
33
    command: Sequence[str],
34
    reason: str,
35
    bench_id: str = "manual",
36
    scenario: str = "external-timing",
37
    operation: str = "hyperfine.crosscheck",
38
    run: int = 1,
39
    subject: str = "unknown",
40
    subject_kind: str = "unknown",
41
) -> dict[str, Any]:
42
    return {
43
        "schema_version": 1,
44
        "bench_id": bench_id,
45
        "profile": "crosscheck",
46
        "scenario": scenario,
47
        "operation": operation,
48
        "run": run,
49
        "subject": subject,
50
        "subject_kind": subject_kind,
51
        "elapsed_ms": 0.0,
52
        "returncode": SKIP_RETURNCODE,
53
        "command": list(command),
54
        "skipped": True,
55
        "skip_reason": reason,
56
        "measurement_source": MEASUREMENT_SOURCE,
57
    }
58
59
60
def hyperfine_command(executable: str, command: Sequence[str], export_json: Path, *, runs: int, warmup: int) -> list[str]:
61
    return [
62
        executable,
63
        "--export-json",
64
        str(export_json),
65
        "--runs",
66
        str(runs),
67
        "--warmup",
68
        str(warmup),
69
        shlex.join(command),
70
    ]
71
72
73
def parse_hyperfine_json(payload: str) -> dict[str, Any]:
74
    data = json.loads(payload)
75
    results = data.get("results")
76
    if not isinstance(results, list) or not results:
77
        raise ValueError("hyperfine JSON did not contain any results")
78
    first = results[0]
79
    if not isinstance(first, dict):
80
        raise ValueError("hyperfine JSON result is not an object")
81
    return first
82
83
84
def measurement_row(
85
    *,
86
    command: Sequence[str],
87
    hyperfine_result: dict[str, Any],
88
    bench_id: str = "manual",
89
    scenario: str = "external-timing",
90
    operation: str = "hyperfine.crosscheck",
91
    run: int = 1,
92
    subject: str = "unknown",
93
    subject_kind: str = "unknown",
94
) -> dict[str, Any]:
95
    mean = _number_or_none(hyperfine_result.get("mean"))
96
    median = _number_or_none(hyperfine_result.get("median"))
97
    stddev = _number_or_none(hyperfine_result.get("stddev"))
98
    minimum = _number_or_none(hyperfine_result.get("min"))
99
    maximum = _number_or_none(hyperfine_result.get("max"))
100
    times = hyperfine_result.get("times")
101
    times_ms = None
102
    if isinstance(times, list):
103
        times_ms = [round(float(value) * 1000.0, 6) for value in times if _number_or_none(value) is not None]
104
    elapsed_seconds = median if median is not None else mean
105
    return {
106
        "schema_version": 1,
107
        "bench_id": bench_id,
108
        "profile": "crosscheck",
109
        "scenario": scenario,
110
        "operation": operation,
111
        "run": run,
112
        "subject": subject,
113
        "subject_kind": subject_kind,
114
        "elapsed_ms": round((elapsed_seconds or 0.0) * 1000.0, 6),
115
        "returncode": 0,
116
        "command": list(command),
117
        "measurement_source": MEASUREMENT_SOURCE,
118
        "hyperfine_command": hyperfine_result.get("command"),
119
        "hyperfine_mean_ms": _seconds_to_ms(mean),
120
        "hyperfine_median_ms": _seconds_to_ms(median),
121
        "hyperfine_stddev_ms": _seconds_to_ms(stddev),
122
        "hyperfine_min_ms": _seconds_to_ms(minimum),
123
        "hyperfine_max_ms": _seconds_to_ms(maximum),
124
        "hyperfine_times_ms": times_ms,
125
    }
126
127
128
def run_hyperfine(
129
    command: Sequence[str],
130
    *,
131
    cwd: Path,
132
    runs: int,
133
    warmup: int,
134
    executable: str = "hyperfine",
135
    env: Optional[dict[str, str]] = None,
136
    runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
137
) -> dict[str, Any]:
138
    resolved = hyperfine_path(executable)
139
    if resolved is None:
140
        raise HyperfineUnavailable(
141
            f"hyperfine not found on PATH; install hyperfine or emit a returncode-{SKIP_RETURNCODE} skip row"
142
        )
143
    with tempfile.TemporaryDirectory(prefix="oakbench-hyperfine-") as tmp:
144
        export_json = Path(tmp) / "hyperfine.json"
145
        proc = runner(
146
            hyperfine_command(resolved, command, export_json, runs=runs, warmup=warmup),
147
            cwd=cwd,
148
            env=host_home_environment(env),
149
            stdout=subprocess.PIPE,
150
            stderr=subprocess.PIPE,
151
            text=True,
152
        )
153
        if proc.returncode != 0:
154
            raise HyperfineFailed(proc.returncode, proc.stderr.strip() or proc.stdout.strip())
155
        return parse_hyperfine_json(export_json.read_text())
156
157
158
class HyperfineUnavailable(RuntimeError):
159
    pass
160
161
162
class HyperfineFailed(RuntimeError):
163
    def __init__(self, returncode: int, message: str) -> None:
164
        super().__init__(message)
165
        self.returncode = returncode
166
167
168
def _number_or_none(value: Any) -> Optional[float]:
169
    if value is None:
170
        return None
171
    try:
172
        return float(value)
173
    except (TypeError, ValueError):
174
        return None
175
176
177
def _seconds_to_ms(value: Optional[float]) -> Optional[float]:
178
    return None if value is None else round(value * 1000.0, 6)
179
180
181
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
182
    parser = argparse.ArgumentParser(description=__doc__)
183
    parser.add_argument("--cwd", type=Path, default=Path.cwd())
184
    parser.add_argument("--runs", type=int, default=10)
185
    parser.add_argument("--warmup", type=int, default=1)
186
    parser.add_argument("--hyperfine", default="hyperfine")
187
    parser.add_argument("--jsonl", action="store_true", help="print one JSON row, including skip rows")
188
    parser.add_argument("--bench-id", default="manual")
189
    parser.add_argument("--scenario", default="external-timing")
190
    parser.add_argument("--operation", default="hyperfine.crosscheck")
191
    parser.add_argument("--run", type=int, default=1)
192
    parser.add_argument("--subject", default="unknown")
193
    parser.add_argument("--subject-kind", default="unknown")
194
    parser.add_argument("command", nargs=argparse.REMAINDER, help="command to measure, usually after --")
195
    args = parser.parse_args(argv)
196
    if args.command and args.command[0] == "--":
197
        args.command = args.command[1:]
198
    if not args.command:
199
        parser.error("command is required; pass it after --")
200
    if args.runs <= 0:
201
        parser.error("--runs must be positive")
202
    if args.warmup < 0:
203
        parser.error("--warmup must be non-negative")
204
    return args
205
206
207
def main(argv: Optional[Sequence[str]] = None) -> int:
208
    args = parse_args(argv)
209
    row_kwargs = {
210
        "bench_id": args.bench_id,
211
        "scenario": args.scenario,
212
        "operation": args.operation,
213
        "run": args.run,
214
        "subject": args.subject,
215
        "subject_kind": args.subject_kind,
216
    }
217
    try:
218
        result = run_hyperfine(
219
            args.command,
220
            cwd=args.cwd,
221
            runs=args.runs,
222
            warmup=args.warmup,
223
            executable=args.hyperfine,
224
            env=host_home_environment(),
225
        )
226
    except HyperfineUnavailable as exc:
227
        if args.jsonl:
228
            print(json.dumps(skip_row(command=args.command, reason=str(exc), **row_kwargs), sort_keys=True))
229
        else:
230
            print(str(exc), file=sys.stderr)
231
        return SKIP_RETURNCODE
232
    except HyperfineFailed as exc:
233
        print(f"hyperfine failed with exit code {exc.returncode}: {exc}", file=sys.stderr)
234
        return exc.returncode or 1
235
236
    print(json.dumps(measurement_row(command=args.command, hyperfine_result=result, **row_kwargs), sort_keys=True))
237
    return 0
238
239
240
if __name__ == "__main__":
241
    raise SystemExit(main())