2828import subprocess
2929import sys
3030import time
31+ from collections .abc import Callable
3132from 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+
3470def heading (s : str ) -> None :
3571 print ()
3672 print (f"=== { s } ===" )
@@ -81,14 +117,23 @@ def edit_python_file(fnam: str) -> None:
81117
82118
83119def 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
115189def 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