Skip to content

Commit 942bb2c

Browse files
davidfstrclaude
andcommitted
misc/perf_compare.py: Add options/behaviors to reduce measured variance
Specifically: * Median is reported, in addition to the existing mean+stdev, which is significantly more resistant to skew by outliers. * --metric {wall,cpu} (default wall): Enables profiling using CPU time rather than wall-clock time. CPU profiling has roughly half the coefficient of variation as wall-clock profiling equal run count. * --workers1: Forces MYPY_NUM_WORKERS=1 (rather than the default 4) to cut CPU scheduling variance. Strongly recommended when using --metric cpu. * --warmup-runs N (default 1): Configurable number of leading cold runs to discard. Previously was always 1. Higher run counts decrease outliers that skew the reported mean. * A new "Paired deltas vs <first commit>" section is added to the report, showing per-round paired differencing against the first commit to cancel round-level common-mode noise, reducing variance. Reported as median +/-95% CI. Also: * --cache-binaries (default false): Caches each commit's compiled clone to avoid ~5min recompile whenever comparing the same commit multiple times. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e15a6d5 commit 942bb2c

2 files changed

Lines changed: 205 additions & 27 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,6 @@ test_capi
6060
test_capi
6161
/mypyc/lib-rt/build/
6262
/mypyc/lib-rt/*.so
63+
64+
# perf_compare.py --cache-binaries cache
65+
/misc/perf_compare/

misc/perf_compare.py

Lines changed: 202 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,45 @@
2828
import subprocess
2929
import sys
3030
import time
31+
from collections.abc import Callable
3132
from concurrent.futures import ThreadPoolExecutor, as_completed
3233

3334

35+
def winsorized_paired_stats(
36+
diffs: list[float], *, trim_frac: float = 0.1, conf: float = 0.95
37+
) -> dict[str, float]:
38+
"""Robust summary of a list of per-round paired differences.
39+
40+
Point estimate: trimmed mean (drop ``trim_frac`` of values from each end), so a
41+
single outlier round cannot drag the estimate.
42+
43+
Error bar: the Tukey-McLaughlin standard error of the trimmed mean, built from the
44+
*Winsorized* variance. The tails are clamped to the boundary kept-value rather than
45+
deleted -- deleting them and taking the ordinary variance of the survivors would
46+
understate the error bar (it would measure only how calm the middle is, discarding
47+
the fact that the tails were wild). The ``(1 - 2*trim_frac)`` divisor rescales for
48+
the compression Winsorizing introduces.
49+
50+
Returns trimmed-mean estimate, median, the 95% CI half-width, and the kept count.
51+
A normal-approx critical value is used (fine for the n>=~30 runs this is used with).
52+
"""
53+
n = len(diffs)
54+
s = sorted(diffs)
55+
g = int(n * trim_frac) # number trimmed from each end
56+
median = statistics.median(s)
57+
if n < 2 or n - 2 * g < 2:
58+
est = statistics.mean(s)
59+
return {"est": est, "median": median, "ci": 0.0, "kept": float(n)}
60+
kept = s[g : n - g]
61+
est = statistics.mean(kept)
62+
# Winsorize: clamp the g smallest up to kept[0], the g largest down to kept[-1].
63+
wins = [kept[0]] * g + kept + [kept[-1]] * g
64+
wvar = statistics.variance(wins) # sample Winsorized variance (df = n-1)
65+
se = (wvar**0.5) / ((1 - 2 * trim_frac) * (n**0.5))
66+
z = statistics.NormalDist().inv_cdf(0.5 + conf / 2)
67+
return {"est": est, "median": median, "ci": z * se, "kept": float(len(kept))}
68+
69+
3470
def heading(s: str) -> None:
3571
print()
3672
print(f"=== {s} ===")
@@ -81,14 +117,23 @@ def edit_python_file(fnam: str) -> None:
81117

82118

83119
def run_benchmark(
84-
compiled_dir: str, check_dir: str, *, incremental: bool, code: str | None, foreign: bool | None
120+
compiled_dir: str,
121+
check_dir: str,
122+
*,
123+
incremental: bool,
124+
code: str | None,
125+
foreign: bool | None,
126+
metric: str = "wall",
127+
workers1: bool = False,
85128
) -> float:
86129
cache_dir = os.path.join(compiled_dir, ".mypy_cache")
87130
if os.path.isdir(cache_dir) and not incremental:
88131
shutil.rmtree(cache_dir)
89132
env = os.environ.copy()
90133
env["PYTHONPATH"] = os.path.abspath(compiled_dir)
91134
env["PYTHONHASHSEED"] = "1"
135+
if workers1:
136+
env["MYPY_NUM_WORKERS"] = "1"
92137
abschk = os.path.abspath(check_dir)
93138
cmd = [sys.executable, "-m", "mypy"]
94139
if code:
@@ -103,13 +148,42 @@ def run_benchmark(
103148
# Update a few files to force non-trivial incremental run
104149
edit_python_file(os.path.join(abschk, "mypy/__main__.py"))
105150
edit_python_file(os.path.join(abschk, "mypy/test/testcheck.py"))
106-
t0 = time.time()
107-
# Ignore errors, since some commits being measured may generate additional errors.
108-
if foreign:
109-
subprocess.run(cmd, cwd=check_dir, env=env)
151+
152+
def run() -> None:
153+
# Ignore errors, since some commits being measured may generate additional errors.
154+
if foreign:
155+
subprocess.run(cmd, cwd=check_dir, env=env)
156+
else:
157+
subprocess.run(cmd, cwd=compiled_dir, env=env)
158+
159+
if metric == "wall":
160+
stopwatch_func_w: Callable[[], float] = lambda: time.time()
161+
delta_func_w: Callable[[float, float], float] = lambda t0, t1: t1 - t0
162+
163+
v0_w = stopwatch_func_w() # capture
164+
run()
165+
v1_w = stopwatch_func_w() # capture
166+
return delta_func_w(v0_w, v1_w)
167+
elif metric == "cpu":
168+
if sys.platform == "win32":
169+
raise NotImplementedError("--metric cpu is not implemented on Windows")
170+
else:
171+
import resource
172+
from resource import struct_rusage as rusage
173+
174+
stopwatch_func_c: Callable[[], rusage] = lambda: resource.getrusage(
175+
resource.RUSAGE_CHILDREN
176+
)
177+
delta_func_c: Callable[[rusage, rusage], float] = lambda r0, r1: (
178+
r1.ru_utime - r0.ru_utime
179+
) + (r1.ru_stime - r0.ru_stime)
180+
181+
v0_c = stopwatch_func_c() # capture
182+
run()
183+
v1_c = stopwatch_func_c() # capture
184+
return delta_func_c(v0_c, v1_c)
110185
else:
111-
subprocess.run(cmd, cwd=compiled_dir, env=env)
112-
return time.time() - t0
186+
raise AssertionError(f"Unrecognized metric: {metric!r}")
113187

114188

115189
def main() -> None:
@@ -145,6 +219,41 @@ def main() -> None:
145219
type=int,
146220
help="set number of measurements to perform (default=15)",
147221
)
222+
parser.add_argument(
223+
"--warmup-runs",
224+
metavar="N",
225+
default=1,
226+
type=int,
227+
help="set number of leading warmup runs to discard (default=1)",
228+
)
229+
parser.add_argument(
230+
"--cache-binaries",
231+
default=False,
232+
action="store_true",
233+
help="cache each commit's compiled clone under "
234+
+ "<script_dir>/perf_compare/binaries/<commit> and restore from there on later runs, "
235+
+ "skipping the ~5-min clone+compile. Off by default so it doesn't silently consume "
236+
+ "disk. Caveat: the cache is keyed by the commit string you pass, so reuse stable SHAs "
237+
+ "(a moving ref like a branch name or HEAD can serve a stale build -- delete the cache "
238+
+ "dir if in doubt).",
239+
)
240+
parser.add_argument(
241+
"--metric",
242+
choices=["wall", "cpu"],
243+
default="wall",
244+
help="quantity to measure per run: 'wall' (wall-clock, default) or 'cpu' (user+sys "
245+
+ "CPU time of the type-check process). 'cpu' is much less sensitive to background "
246+
+ "interference and scheduling, so it tightens the per-run distribution.",
247+
)
248+
parser.add_argument(
249+
"--workers1",
250+
default=False,
251+
action="store_true",
252+
help="run selfcheck with a single mypy worker (MYPY_NUM_WORKERS=1) to "
253+
+ "decrease variance in measurements. "
254+
+ "Strongly recommended when --metric=cpu. "
255+
+ "When omitted, uses mypy's default worker count.",
256+
)
148257
parser.add_argument(
149258
"-j",
150259
metavar="N",
@@ -178,20 +287,39 @@ def main() -> None:
178287
dont_setup: bool = args.dont_setup
179288
multi_file: bool = args.multi_file
180289
commits = args.commit
181-
num_runs: int = args.num_runs + 1
290+
baseline_commit: str = commits[0]
291+
warmup_runs: int = args.warmup_runs
292+
measurement_runs: int = args.num_runs
293+
num_runs: int = measurement_runs + warmup_runs
182294
max_workers: int = args.j
183295
code: str | None = args.c
184296
foreign_repo: str | None = args.r
297+
metric: str = args.metric
298+
workers1: bool = args.workers1
299+
cache_binaries: bool = args.cache_binaries
185300

186301
if not (os.path.isdir(".git") and os.path.isdir("mypyc")):
187302
sys.exit("error: You must run this script from the mypy repo root")
188303

304+
archive_root = os.path.join(
305+
os.path.dirname(os.path.abspath(__file__)), "perf_compare", "binaries"
306+
)
307+
189308
target_dirs = []
309+
dirs_to_compile = []
190310
for i, commit in enumerate(commits):
191311
target_dir = f"mypy.{i}.tmpdir"
192312
target_dirs.append(target_dir)
193313
if not dont_setup:
194-
clone(target_dir, commit)
314+
archive = os.path.join(archive_root, commit)
315+
if cache_binaries and os.path.isdir(archive):
316+
print(f"restore: copying {archive} -> {target_dir} (skipping clone+compile)")
317+
if os.path.isdir(target_dir):
318+
shutil.rmtree(target_dir)
319+
shutil.copytree(archive, target_dir, symlinks=True)
320+
else:
321+
clone(target_dir, commit)
322+
dirs_to_compile.append(target_dir)
195323

196324
if foreign_repo:
197325
check_dir = "mypy.foreign.tmpdir"
@@ -202,27 +330,32 @@ def main() -> None:
202330
if not dont_setup:
203331
clone(check_dir, commits[0])
204332

205-
if not dont_setup:
333+
if not dont_setup and dirs_to_compile:
206334
heading("Compiling mypy")
207335
print("(This will take a while...)")
208336

209337
with ThreadPoolExecutor(max_workers=max_workers) as executor:
210338
futures = [
211-
executor.submit(build_mypy, target_dir, multi_file) for target_dir in target_dirs
339+
executor.submit(build_mypy, target_dir, multi_file)
340+
for target_dir in dirs_to_compile
212341
]
213342
for future in as_completed(futures):
214343
future.result()
215344

216-
print(f"Finished compiling mypy ({len(commits)} builds)")
345+
print(f"Finished compiling mypy ({len(dirs_to_compile)} builds)")
346+
elif not dont_setup:
347+
print("All targets restored from archive; skipping compile step.")
217348

218-
heading("Performing measurements")
349+
workers_desc = "workers: 1" if workers1 else "workers: default"
350+
key_options_desc = f"(metric: {metric}-time, {workers_desc})"
351+
heading(f"Performing measurements {key_options_desc}")
219352

220353
results: dict[str, list[float]] = {}
221354
for n in range(num_runs):
222-
if n == 0:
223-
print("Warmup...")
355+
if n < warmup_runs:
356+
print(f"Warmup {n + 1}/{warmup_runs}...")
224357
else:
225-
print(f"Run {n}/{num_runs - 1}...")
358+
print(f"Run {n - warmup_runs + 1}/{num_runs - warmup_runs}...")
226359
items = list(enumerate(commits))
227360
random.shuffle(items)
228361
for i, commit in items:
@@ -232,26 +365,56 @@ def main() -> None:
232365
incremental=incremental,
233366
code=code,
234367
foreign=bool(foreign_repo),
368+
metric=metric,
369+
workers1=workers1,
235370
)
236-
# Don't record the first warm-up run
237-
if n > 0:
371+
# Don't record the leading warm-up runs
372+
if n >= warmup_runs:
238373
print(f"{commit}: t={tt:.3f}s")
239374
results.setdefault(commit, []).append(tt)
240375

241376
print()
242-
heading("Results")
243-
first = -1.0
377+
heading(f"Results {key_options_desc}")
378+
first_mean = -1.0
379+
first_median = -1.0
244380
for commit in commits:
245-
tt = statistics.mean(results[commit])
381+
mean = statistics.mean(results[commit])
382+
median = statistics.median(results[commit])
246383
# pstdev (instead of stdev) is used here primarily to accommodate the case where num_runs=1
247384
s = statistics.pstdev(results[commit]) if len(results[commit]) > 1 else 0
248-
if first < 0:
249-
delta = "0.0%"
250-
first = tt
385+
if first_mean < 0:
386+
delta_mean = "0.0%"
387+
first_mean = mean
388+
delta_median = "0.0%"
389+
first_median = median
251390
else:
252-
d = (tt / first) - 1
253-
delta = f"{d:+.1%}"
254-
print(f"{commit:<25} {tt:.3f}s ({delta}) | stdev {s:.3f}s ")
391+
d1 = (mean / first_mean) - 1
392+
delta_mean = f"{d1:+.1%}"
393+
d2 = (median / first_median) - 1
394+
delta_median = f"{d2:+.1%}"
395+
print(
396+
f"{commit:<25} mean {mean:.3f}s ({delta_mean}) | stdev {s:.3f}s | "
397+
f"median {median:.3f}s ({delta_median})"
398+
)
399+
400+
# Paired per-round differences vs the baseline commit. Each round runs every commit
401+
# once, so results[commit][k] is round k for every commit -- the differences are
402+
# already matched. Differencing cancels round-level common-mode noise (a throttle or
403+
# background-process spike that round slows every commit together), which is the bulk
404+
# of the variance on a laptop. See winsorized_paired_stats for the robust estimator.
405+
base_runs = results[baseline_commit]
406+
base_center = statistics.median(base_runs)
407+
heading(f"Paired deltas vs {baseline_commit} (per-round diffs; median +/- 95% CI)")
408+
for commit in commits:
409+
if commit == baseline_commit:
410+
print(f"{commit:<25} baseline")
411+
continue
412+
diffs = [c - b for c, b in zip(results[commit], base_runs)]
413+
st = winsorized_paired_stats(diffs)
414+
ci_ms = st["ci"] * 1000
415+
median_ms = st["median"] * 1000
416+
pct = (st["median"] / base_center * 100) if base_center else 0.0
417+
print(f"{commit:<25} median {median_ms:+7.1f}ms +/-{ci_ms:4.1f} ({pct:+.2f}%)")
255418

256419
t = int(time.time() - whole_program_time_0)
257420
total_time_taken_formatted = ", ".join(
@@ -264,6 +427,18 @@ def main() -> None:
264427
total_time_taken_formatted,
265428
)
266429

430+
# Archive compiled clones before cleanup, keyed by commit, so later runs can
431+
# restore them instead of recompiling. Skip if destination already exists.
432+
if cache_binaries:
433+
os.makedirs(archive_root, exist_ok=True)
434+
for target_dir, commit in zip(target_dirs, commits):
435+
dest = os.path.join(archive_root, commit)
436+
if os.path.isdir(dest):
437+
print(f"archive: {dest} already exists, skipping")
438+
else:
439+
print(f"archive: copying {target_dir} -> {dest}")
440+
shutil.copytree(target_dir, dest, symlinks=True)
441+
267442
shutil.rmtree(check_dir)
268443
for target_dir in target_dirs:
269444
shutil.rmtree(target_dir)

0 commit comments

Comments
 (0)