Log in
scripts/tperf_runner.py 319 lines · 11.0 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""External cross-check against git's own ``t/perf`` suite.
3
4
License boundary (GPLv2): git's ``t/perf`` scripts are GPLv2 and are NEVER
5
vendored, copied, or translated into this repository. This module drives the
6
upstream suite at arm's length only β€” a subprocess invocation of
7
``<git-src>/t/perf/run`` inside the user's own git source tree β€” and then
8
aggregates the raw numeric output files (``t/test-results/*.result``) that the
9
suite leaves behind. Reading numbers out of result files is data aggregation,
10
not derivation of GPL code; no shell logic crosses the boundary.
11
12
What this gives the harness: git's own perf numbers for the same corpus
13
fixture (``GIT_PERF_REPO``), labeled ``measurement_source:
14
"git_tperf_external"``. t/perf reports min-of-N while our lanes report
15
medians, so the comparison is a sanity band (see ``sanity_band``), never an
16
equality check.
17
18
Result-file format (observed, liberal): each ``<script>.<n>.result`` file
19
holds one line per repetition of whitespace-separated floats β€” GNU-time-style
20
``real user sys`` seconds. We take the first float as real seconds and any
21
second/third as user/sys; malformed lines are skipped, never fatal.
22
``<script>.<n>.descr`` (when present) holds the human description;
23
``<script>.subtests`` lists test numbers and is not needed for parsing.
24
25
Modes:
26
27
- run mode (default): requires ``--git-src`` pointing at a git source tree
28
  containing ``t/perf/run``. Missing or not such a tree -> exit 3 with skip
29
  JSON ``{"status": "skipped", "reason": "git_src_missing:<path>"}`` on
30
  stdout (skip honesty: no git source tree is a coverage gap, not an error).
31
  A nonzero exit from ``./run`` is recorded in a run-record row, never a hard
32
  failure β€” partial result files are still parsed.
33
- ``--parse-only DIR``: skip running; parse an existing ``t/test-results``
34
  directory. This is the testable path (unittest runs without a git tree).
35
36
Output: JSONL under ``--results`` (a directory): one row per repetition plus
37
one ``min_of_n`` summary row per test, all stamped with runner identity
38
(ADR-0007) via ``oakbench.runner``.
39
40
Exit codes: 0 on parsed rows; 3 skip (git source tree or results missing);
41
2 usage errors.
42
"""
43
44
from __future__ import annotations
45
46
import argparse
47
import json
48
import os
49
import subprocess
50
import sys
51
from pathlib import Path
52
from typing import Any, Optional
53
54
sys.path.insert(0, str(Path(__file__).resolve().parent))
55
56
from oakbench.runner import runner_fields, stamp_row
57
58
SCHEMA_VERSION = 1
59
MEASUREMENT_SOURCE = "git_tperf_external"
60
RESULTS_FILENAME = "tperf_external.jsonl"
61
DEFAULT_REPEAT_COUNT = 10
62
63
EXIT_OK = 0
64
EXIT_USAGE = 2
65
EXIT_SKIP = 3
66
67
68
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
69
    parser = argparse.ArgumentParser(
70
        description="Run git's t/perf suite at arm's length and aggregate its raw .result files."
71
    )
72
    parser.add_argument(
73
        "--git-src",
74
        type=Path,
75
        default=None,
76
        help="Git source tree containing t/perf (missing/not-a-git-tree -> exit 3 skip JSON)",
77
    )
78
    parser.add_argument(
79
        "--perf-repo",
80
        type=Path,
81
        default=None,
82
        help="Corpus fixture repository exported as GIT_PERF_REPO",
83
    )
84
    parser.add_argument(
85
        "--repeat-count",
86
        type=int,
87
        default=DEFAULT_REPEAT_COUNT,
88
        help=f"Exported as GIT_PERF_REPEAT_COUNT (default {DEFAULT_REPEAT_COUNT})",
89
    )
90
    parser.add_argument(
91
        "--tests",
92
        type=str,
93
        default=None,
94
        help="Optional CSV subset of perf scripts, e.g. p0001-rev-list.sh",
95
    )
96
    parser.add_argument(
97
        "--results",
98
        type=Path,
99
        required=True,
100
        help=f"Directory for the output JSONL ({RESULTS_FILENAME})",
101
    )
102
    parser.add_argument(
103
        "--parse-only",
104
        type=Path,
105
        default=None,
106
        metavar="DIR",
107
        help="Skip running; parse an existing t/test-results directory",
108
    )
109
    return parser.parse_args(argv)
110
111
112
def _floats(tokens: list[str]) -> Optional[list[float]]:
113
    try:
114
        return [float(token) for token in tokens]
115
    except ValueError:
116
        return None
117
118
119
def _split_result_stem(stem: str) -> tuple[str, Optional[str]]:
120
    """``p0001-rev-list.1`` -> (``p0001-rev-list``, ``1``)."""
121
    if "." in stem:
122
        script, number = stem.rsplit(".", 1)
123
        return script, number
124
    return stem, None
125
126
127
def _description_for(results_dir: Path, script: str, number: Optional[str]) -> Optional[str]:
128
    if number is None:
129
        return None
130
    descr_path = results_dir / f"{script}.{number}.descr"
131
    try:
132
        text = descr_path.read_text(errors="replace").strip()
133
    except OSError:
134
        return None
135
    return text or None
136
137
138
def _ms(seconds: Optional[float]) -> Optional[float]:
139
    if seconds is None:
140
        return None
141
    return round(seconds * 1000.0, 6)
142
143
144
def parse_tperf_results(results_dir: Path) -> list[dict[str, Any]]:
145
    """Parse every ``*.result`` file in a t/test-results directory into rows.
146
147
    One row per repetition line plus one min-of-N summary row per test.
148
    Liberal: each line is N whitespace-separated floats; the first is real
149
    seconds, optional second/third are user/sys. Malformed lines are skipped.
150
    """
151
    rows: list[dict[str, Any]] = []
152
    for result_path in sorted(results_dir.glob("*.result")):
153
        script, number = _split_result_stem(result_path.stem)
154
        tperf_test = f"{script}.{number}" if number is not None else script
155
        description = _description_for(results_dir, script, number)
156
        try:
157
            text = result_path.read_text(errors="replace")
158
        except OSError:
159
            continue
160
161
        reals_ms: list[float] = []
162
        repetition = 0
163
        for line in text.splitlines():
164
            tokens = line.split()
165
            if not tokens:
166
                continue
167
            values = _floats(tokens)
168
            if not values:
169
                continue  # malformed line: skipped, never fatal
170
            repetition += 1
171
            real_ms = _ms(values[0])
172
            reals_ms.append(real_ms)
173
            rows.append(
174
                {
175
                    "schema_version": SCHEMA_VERSION,
176
                    "measurement_source": MEASUREMENT_SOURCE,
177
                    "tperf_script": script,
178
                    "tperf_test": tperf_test,
179
                    "tperf_description": description,
180
                    "repetition": repetition,
181
                    "elapsed_ms": real_ms,
182
                    "user_ms": _ms(values[1]) if len(values) > 1 else None,
183
                    "sys_ms": _ms(values[2]) if len(values) > 2 else None,
184
                    "subject": "git",
185
                    "subject_kind": "git",
186
                }
187
            )
188
        if reals_ms:
189
            rows.append(
190
                {
191
                    "schema_version": SCHEMA_VERSION,
192
                    "measurement_source": MEASUREMENT_SOURCE,
193
                    "tperf_script": script,
194
                    "tperf_test": tperf_test,
195
                    "tperf_description": description,
196
                    "tperf_statistic": "min_of_n",
197
                    "elapsed_ms": min(reals_ms),
198
                    "n": len(reals_ms),
199
                    "subject": "git",
200
                    "subject_kind": "git",
201
                }
202
            )
203
    return rows
204
205
206
def sanity_band(
207
    tperf_min_ms: Optional[float],
208
    our_median_ms: Optional[float],
209
    *,
210
    tolerance_pct: float = 50,
211
) -> dict[str, Any]:
212
    """Sanity band between t/perf's min-of-N and our median for one operation.
213
214
    ``ratio`` is ``our_median_ms / tperf_min_ms``; ``within_band`` is true when
215
    the ratio sits inside ``1 +/- tolerance_pct/100``. Guards (documented):
216
    when either input is None or ``tperf_min_ms <= 0`` (zero-division guard),
217
    ``ratio`` is None and ``within_band`` is False β€” unmeasured never passes.
218
    """
219
    result: dict[str, Any] = {
220
        "tperf_min_ms": tperf_min_ms,
221
        "our_median_ms": our_median_ms,
222
        "tolerance_pct": tolerance_pct,
223
        "ratio": None,
224
        "within_band": False,
225
    }
226
    if tperf_min_ms is None or our_median_ms is None:
227
        return result
228
    if tperf_min_ms <= 0:
229
        return result
230
    ratio = float(our_median_ms) / float(tperf_min_ms)
231
    result["ratio"] = ratio
232
    result["within_band"] = abs(ratio - 1.0) * 100.0 <= tolerance_pct
233
    return result
234
235
236
def _skip(reason: str) -> int:
237
    print(json.dumps({"status": "skipped", "reason": reason}))
238
    return EXIT_SKIP
239
240
241
def run_tperf(git_src: Path, args: argparse.Namespace) -> dict[str, Any]:
242
    """Invoke ``./run`` inside <git-src>/t/perf at arm's length; record only."""
243
    perf_dir = git_src / "t" / "perf"
244
    command = ["./run"]
245
    if args.tests:
246
        command.extend(name.strip() for name in args.tests.split(",") if name.strip())
247
    env = dict(os.environ)
248
    if args.perf_repo is not None:
249
        env["GIT_PERF_REPO"] = str(args.perf_repo.resolve())
250
    env["GIT_PERF_REPEAT_COUNT"] = str(args.repeat_count)
251
    try:
252
        proc = subprocess.run(
253
            command,
254
            cwd=str(perf_dir),
255
            env=env,
256
            capture_output=True,
257
            text=True,
258
        )
259
        returncode: Optional[int] = proc.returncode
260
        error: Optional[str] = None
261
    except OSError as exc:
262
        returncode = None
263
        error = str(exc)
264
    # Nonzero/failed runs are recorded, never fatal: partial .result files
265
    # are still worth aggregating.
266
    return {
267
        "schema_version": SCHEMA_VERSION,
268
        "measurement_source": MEASUREMENT_SOURCE,
269
        "record_kind": "tperf_run",
270
        "command": command,
271
        "cwd": str(perf_dir),
272
        "git_perf_repo": str(args.perf_repo) if args.perf_repo is not None else None,
273
        "git_perf_repeat_count": args.repeat_count,
274
        "returncode": returncode,
275
        "error": error,
276
        "subject": "git",
277
        "subject_kind": "git",
278
    }
279
280
281
def main(argv: Optional[list[str]] = None) -> int:
282
    args = parse_args(argv)
283
284
    rows: list[dict[str, Any]] = []
285
286
    if args.parse_only is not None:
287
        results_dir = args.parse_only
288
        if not results_dir.is_dir():
289
            return _skip(f"results_dir_missing:{results_dir}")
290
    else:
291
        git_src = args.git_src
292
        if git_src is None:
293
            return _skip("git_src_missing:")
294
        run_script = git_src / "t" / "perf" / "run"
295
        if not git_src.is_dir() or not run_script.is_file():
296
            return _skip(f"git_src_missing:{git_src}")
297
        rows.append(run_tperf(git_src, args))
298
        results_dir = git_src / "t" / "test-results"
299
        if not results_dir.is_dir():
300
            return _skip(f"results_dir_missing:{results_dir}")
301
302
    rows.extend(parse_tperf_results(results_dir))
303
304
    fields = runner_fields()
305
    stamped = [stamp_row(row, fields) for row in rows]
306
307
    args.results.mkdir(parents=True, exist_ok=True)
308
    out_path = args.results / RESULTS_FILENAME
309
    with out_path.open("w", encoding="utf-8") as fh:
310
        for row in stamped:
311
            fh.write(json.dumps(row, sort_keys=True) + "\n")
312
    print(f"[result] {out_path} ({len(stamped)} rows)", file=sys.stderr)
313
314
    measured = [row for row in stamped if "elapsed_ms" in row]
315
    return EXIT_OK if measured else EXIT_SKIP
316
317
318
if __name__ == "__main__":
319
    raise SystemExit(main())