rickub/ci-benchpublic Fork 0
09e7984e3695d820dceab05c61327b2e486c5019
Commits
Clone
git clone https://git.rickub.com/rickub/ci-bench.git
git clone ssh://git@rickub.com/rickub/ci-bench.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

compare.py · 197 lines · 6.9 KBPython Blame HistoryRaw
ci-bench: cross-platform CI race scaffold (rickub CI vs GitHub Actions) 0baf736 Olivier Girardot yesterday1#!/usr/bin/env python3
2"""compare.py — median/p95 table per step per platform, from collected runs.
3
4Reads results/<platform>/<run-id>.json files (produced by scripts/collect.sh).
5python3 stdlib only.
6
7Usage:
8 scripts/compare.py [--push-times FILE]
9
10Output:
11 - per step x platform: n, median, p95 of duration_ms (status ok only;
12 "fail"/"skipped" counted separately and shown)
13 - for metric-bearing steps (value field: probe-*), median of the value
14 - with --push-times: push -> job-start latency per platform, where push
15 timestamps come from FILE: {"<platform>/<run-id>": "<iso8601>", ...}
16 (see README "push->job-start" for where each timestamp comes from)
17
18Percentiles: linear interpolation between order statistics (the method numpy
19percentile uses), reported at p50 (median) and p95.
20"""
21
22import argparse
23import json
24import sys
25from pathlib import Path
26
27RESULTS_DIR = Path(__file__).resolve().parent.parent / "results"
28JOB_START_STEP = "job-start"
29DURATION_UNITS = {"duration_ms": "ms"}
30
31
32def percentile(sorted_vals, q):
33 """Linear-interpolation percentile. sorted_vals non-empty, q in [0,100]."""
34 if len(sorted_vals) == 1:
35 return sorted_vals[0]
36 pos = (len(sorted_vals) - 1) * q / 100.0
37 lo = int(pos)
38 hi = min(lo + 1, len(sorted_vals) - 1)
39 frac = pos - lo
40 return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * frac
41
42
43def fmt_ms(v):
44 return f"{v / 1000.0:8.2f}s" if v >= 1000 else f"{v:8.0f}ms"
45
46
47def fmt_v(v):
48 if v is None:
49 return " null"
50 return f"{v:8.1f}"
51
52
53def load_platforms():
54 platforms = {}
55 if not RESULTS_DIR.is_dir():
56 return platforms
57 for pdir in sorted(RESULTS_DIR.iterdir()):
58 if not pdir.is_dir():
59 continue
60 runs = []
61 for rfile in sorted(pdir.glob("*.json")):
62 try:
63 with open(rfile, encoding="utf-8") as fh:
64 runs.append(json.load(fh))
65 except (json.JSONDecodeError, OSError) as exc:
66 print(f"compare: skipping unreadable {rfile}: {exc}", file=sys.stderr)
67 if runs:
68 platforms[pdir.name] = runs
69 return platforms
70
71
72def parse_iso(ts):
73 from datetime import datetime, timezone
74
75 ts = ts.strip()
76 if ts.endswith("Z"):
77 ts = ts[:-1] + "+00:00"
78 dt = datetime.fromisoformat(ts)
79 if dt.tzinfo is None:
80 dt = dt.replace(tzinfo=timezone.utc)
81 return dt
82
83
84def main():
85 ap = argparse.ArgumentParser()
86 ap.add_argument("--push-times", metavar="FILE",
87 help="JSON map of '<platform>/<run-id>' -> push ISO timestamp")
88 args = ap.parse_args()
89
90 platforms = load_platforms()
91 if not platforms:
92 print("compare: no collected results under results/ — run scripts/collect.sh first")
93 return 1
94
95 # step -> platform -> list of (record); plus counters
96 steps = {}
97 for platform, runs in platforms.items():
98 for run in runs:
99 for rec in run:
100 step = rec.get("step", "?")
101 steps.setdefault(step, {}).setdefault(platform, []).append(rec)
102
103 step_names = sorted(steps)
104 plat_names = sorted(platforms)
105
106 header = f"{'step':<22}" + "".join(f"{p:>26}" for p in plat_names)
107 sub = f"{'':<22}" + "".join(f"{'n / median / p95':>26}" for p in plat_names)
108 print(header)
109 print(sub)
110 print("-" * len(header))
111
112 for step in step_names:
113 row = f"{step:<22}"
114 for plat in plat_names:
115 recs = steps[step].get(plat, [])
116 durs = sorted(r["duration_ms"] for r in recs
117 if r.get("status") == "ok" and isinstance(r.get("duration_ms"), (int, float)))
118 n_skipped = sum(1 for r in recs if r.get("status") == "skipped")
119 n_fail = sum(1 for r in recs if r.get("status") == "fail")
120 cell = f"{len(durs):>3} /"
121 if durs:
122 cell += f" {fmt_ms(percentile(durs, 50))} / {fmt_ms(percentile(durs, 95))}"
123 else:
124 cell += " - / -"
125 if n_skipped:
126 cell += f" (+{n_skipped}skip)"
127 if n_fail:
128 cell += f" (+{n_fail}FAIL)"
129 row += f"{cell:>26}"
130 print(row)
131
132 # Metric-bearing steps: median of value.
133 metric_steps = [s for s in step_names
134 if any("value" in r for recs in steps[s].values() for r in recs)]
135 if metric_steps:
136 print()
137 print(f"{'metric (median value)':<22}" + "".join(f"{p:>26}" for p in plat_names))
138 print("-" * len(header))
139 for step in metric_steps:
140 row = f"{step:<22}"
141 unit = ""
142 for plat in plat_names:
143 recs = [r for r in steps[step].get(plat, []) if "value" in r]
144 nums = sorted(r["value"] for r in recs
145 if isinstance(r.get("value"), (int, float)))
146 strs = [r["value"] for r in recs if isinstance(r.get("value"), str)]
147 if nums:
148 unit = next((r.get("unit", "") for r in recs if r.get("unit")), "")
149 cell = f"{percentile(nums, 50):.1f} {unit}"
150 elif strs:
151 # categorical metric (e.g. fs type): show the most common value
152 cell = max(set(strs), key=strs.count)[:16]
153 else:
154 cell = "null"
155 row += f"{cell:>26}"
156 print(row)
157
158 # push -> job-start latency.
159 if args.push_times:
160 print()
161 try:
162 with open(args.push_times, encoding="utf-8") as fh:
163 push_times = json.load(fh)
164 except (json.JSONDecodeError, OSError) as exc:
165 print(f"compare: cannot read --push-times: {exc}", file=sys.stderr)
166 return 1
167 row = f"{'push->job-start':<22}"
168 for plat in plat_names:
169 lat = []
170 for run in platforms[plat]:
171 run_id = next((r.get("run_id") for r in run if r.get("run_id")), None)
172 key = f"{plat}/{run_id}"
173 if key not in push_times:
174 continue
175 mark = next((r for r in run if r.get("step") == JOB_START_STEP), None)
176 if not mark:
177 continue
178 try:
179 delta = (parse_iso(mark["start"]) - parse_iso(push_times[key])).total_seconds() * 1000
180 except (ValueError, KeyError):
181 continue
182 if delta >= 0:
183 lat.append(delta)
184 cell = f"{len(lat):>3} /"
185 cell += (f" {fmt_ms(percentile(sorted(lat), 50))} / {fmt_ms(percentile(sorted(lat), 95))}"
186 if lat else " - / -")
187 row += f"{cell:>26}"
188 print(row + " (cross-clock; see README caveats)")
189
190 print()
191 print("durations = emitted end-start per step; percentiles linear-interpolated;")
192 print("report n>=10 per platform before trusting any comparison (README: variance).")
193 return 0
194
195
196if __name__ == "__main__":
197 sys.exit(main())