初始化上传
Some checks failed
Run mypy_primer on push / Run mypy_primer on push (push) Has been cancelled
Validation / Typecheck (push) Has been cancelled
Validation / Style (push) Has been cancelled
Validation / Test macos-latest (push) Has been cancelled
Validation / Test ubuntu-latest (push) Has been cancelled
Validation / Test windows-latest (push) Has been cancelled
Validation / Build (push) Has been cancelled
Validation / Required (push) Has been cancelled
Some checks failed
Run mypy_primer on push / Run mypy_primer on push (push) Has been cancelled
Validation / Typecheck (push) Has been cancelled
Validation / Style (push) Has been cancelled
Validation / Test macos-latest (push) Has been cancelled
Validation / Test ubuntu-latest (push) Has been cancelled
Validation / Test windows-latest (push) Has been cancelled
Validation / Build (push) Has been cancelled
Validation / Required (push) Has been cancelled
This commit is contained in:
255
build/perfCompare.py
Normal file
255
build/perfCompare.py
Normal file
@@ -0,0 +1,255 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
# perfCompare.py
|
||||
# Copyright (c) Microsoft Corporation.
|
||||
# Licensed under the MIT license.
|
||||
#
|
||||
# Benchmarks pyright's type-checking performance across two or more git
|
||||
# commits/branches by building the production CLI bundle at each commit and
|
||||
# timing repeated runs over a fixed corpus. See the module docstring for usage
|
||||
# and the measurement methodology.
|
||||
|
||||
"""Compare pyright type-checking performance across two or more commits/branches.
|
||||
|
||||
Adapted from mypy's misc/perf_compare.py for pyright. Where mypy self-checks its
|
||||
own (mypyc-compiled) source, pyright is a bundled JS CLI, so this script:
|
||||
|
||||
* For each target commit: checks it out *in place*, builds the production CLI
|
||||
bundle (``packages/pyright/dist``), and stashes that dist dir under
|
||||
``build/perfCompare/binaries/<sha>/`` so later runs can skip the rebuild.
|
||||
* Restores your original branch/commit when the build phase is done.
|
||||
* Runs each commit's bundle N times over a fixed corpus, in randomized
|
||||
interleaved order, parsing pyright's own ``Completed in X.XXXsec`` line as the
|
||||
per-run metric (in-process analysis time -- excludes node startup jitter).
|
||||
* Reports per-commit mean/median plus robust paired deltas vs the baseline.
|
||||
|
||||
Usage:
|
||||
|
||||
python build/perfCompare.py --corpus <path-to-python-project> main HEAD
|
||||
|
||||
Requirements / caveats:
|
||||
|
||||
* Run from the pyright repo root with a CLEAN working tree (it checks out
|
||||
commits in place, reusing the already-installed node_modules -- worktrees
|
||||
would lack them).
|
||||
* The corpus path is whatever you want type-checked; it stays fixed across all
|
||||
commits so only the checker changes. Errors in the corpus are ignored.
|
||||
* Builds are keyed by the resolved commit SHA, so passing a moving ref like
|
||||
HEAD/branch name caches under its current SHA (fine within one run).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import resource
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
PYRIGHT_PKG = os.path.join(REPO_ROOT, "packages", "pyright")
|
||||
DIST_DIR = os.path.join(PYRIGHT_PKG, "dist")
|
||||
# Cached production bundles, keyed by commit SHA, kept beside this script (gitignored).
|
||||
BUILD_CACHE = os.path.join(SCRIPT_DIR, "perfCompare", "binaries")
|
||||
|
||||
COMPLETED_RE = re.compile(r"Completed in ([\d.]+)sec")
|
||||
CHECK_RE = re.compile(r"Check:\s+([\d.]+)sec")
|
||||
|
||||
|
||||
def heading(s: str) -> None:
|
||||
print()
|
||||
print(f"=== {s} ===")
|
||||
print()
|
||||
|
||||
|
||||
def git(*args: str, capture: bool = False) -> str:
|
||||
res = subprocess.run(
|
||||
["git", *args], cwd=REPO_ROOT, check=True, text=True,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
)
|
||||
return (res.stdout or "").strip()
|
||||
|
||||
|
||||
def resolve_sha(commit: str) -> str:
|
||||
return git("rev-parse", commit, capture=True)
|
||||
|
||||
|
||||
def current_ref() -> str:
|
||||
"""The symbolic branch name if on one, else the detached SHA -- for restore."""
|
||||
branch = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
||||
return branch if branch != "HEAD" else resolve_sha("HEAD")
|
||||
|
||||
|
||||
def working_tree_clean() -> bool:
|
||||
return git("status", "--porcelain", capture=True) == ""
|
||||
|
||||
|
||||
def build_commit(commit: str, sha: str) -> str:
|
||||
"""Check out `commit`, build the prod bundle, cache dist under the SHA. Returns cache dir."""
|
||||
cache_dir = os.path.join(BUILD_CACHE, sha)
|
||||
if os.path.isdir(cache_dir) and os.path.isfile(os.path.join(cache_dir, "pyright.js")):
|
||||
print(f"build cache hit for {commit} ({sha[:10]}): {cache_dir}")
|
||||
return cache_dir
|
||||
|
||||
heading(f"Building {commit} ({sha[:10]})")
|
||||
git("checkout", "-q", sha)
|
||||
subprocess.run(["npm", "run", "build"], cwd=PYRIGHT_PKG, check=True)
|
||||
|
||||
if os.path.isdir(cache_dir):
|
||||
shutil.rmtree(cache_dir)
|
||||
os.makedirs(BUILD_CACHE, exist_ok=True)
|
||||
shutil.copytree(DIST_DIR, cache_dir, symlinks=True)
|
||||
print(f"cached build -> {cache_dir}")
|
||||
return cache_dir
|
||||
|
||||
|
||||
def run_once(bundle_dir: str, corpus: str) -> tuple[float, float | None, float]:
|
||||
"""Run pyright on corpus once; return (wall_completed_sec, check_sec | None, cpu_sec).
|
||||
|
||||
`wall` is pyright's own ``Completed in`` (in-process analysis wall time). `cpu` is the
|
||||
user+sys CPU time of the node child, via the RUSAGE_CHILDREN delta around the run --
|
||||
much less sensitive to scheduling/background noise. This is accurate because pyright runs
|
||||
single-process by default (no ``--threads``), so there are no un-reaped worker processes
|
||||
whose CPU would escape node's accounting.
|
||||
"""
|
||||
cmd = ["node", os.path.join(bundle_dir, "pyright.js"), "--stats", os.path.abspath(corpus)]
|
||||
r0 = resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||
# Ignore exit code: corpora routinely produce type errors.
|
||||
res = subprocess.run(cmd, cwd=REPO_ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
r1 = resource.getrusage(resource.RUSAGE_CHILDREN)
|
||||
cpu = (r1.ru_utime - r0.ru_utime) + (r1.ru_stime - r0.ru_stime)
|
||||
m = COMPLETED_RE.search(res.stdout)
|
||||
if not m:
|
||||
sys.stderr.write(res.stdout[-2000:] + "\n")
|
||||
raise RuntimeError("Could not parse 'Completed in ...sec' from pyright output")
|
||||
c = CHECK_RE.search(res.stdout)
|
||||
return float(m.group(1)), (float(c.group(1)) if c else None), cpu
|
||||
|
||||
|
||||
def winsorized_paired_stats(diffs: list[float], *, trim_frac: float = 0.1, conf: float = 0.95) -> dict[str, float]:
|
||||
"""Robust paired-difference summary (trimmed mean + Tukey-McLaughlin SE). See mypy's perf_compare."""
|
||||
n = len(diffs)
|
||||
s = sorted(diffs)
|
||||
g = int(n * trim_frac)
|
||||
median = statistics.median(s)
|
||||
if n < 2 or n - 2 * g < 2:
|
||||
return {"est": statistics.mean(s), "median": median, "ci": 0.0, "kept": float(n)}
|
||||
kept = s[g: n - g]
|
||||
est = statistics.mean(kept)
|
||||
wins = [kept[0]] * g + kept + [kept[-1]] * g
|
||||
wvar = statistics.variance(wins)
|
||||
se = (wvar ** 0.5) / ((1 - 2 * trim_frac) * (n ** 0.5))
|
||||
z = statistics.NormalDist().inv_cdf(0.5 + conf / 2)
|
||||
return {"est": est, "median": median, "ci": z * se, "kept": float(len(kept))}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
t0 = time.time()
|
||||
p = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__
|
||||
)
|
||||
src = p.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--corpus", help="path to a Python project *directory* to type-check (fixed across commits)")
|
||||
src.add_argument("--corpus-file", help="path to a single Python *file* to type-check (e.g. one hot file). "
|
||||
"Lower per-run cost and variance than a whole project.")
|
||||
p.add_argument("--num-runs", type=int, default=10, help="measured runs per commit (default 10)")
|
||||
p.add_argument("--warmup-runs", type=int, default=2, help="leading warmup runs to discard (default 2)")
|
||||
p.add_argument("--metric", choices=["wall", "cpu"], default="wall",
|
||||
help="quantity to compare: 'wall' (pyright's reported analysis time, default) or "
|
||||
"'cpu' (user+sys CPU of the node child). 'cpu' is far less sensitive to background "
|
||||
"interference/scheduling, tightening the distribution -- recommended for high run counts.")
|
||||
p.add_argument("--workers1", action="store_true",
|
||||
help="parity flag with mypy's perf_compare: assert single-process analysis. Pyright already "
|
||||
"runs single-process by default (multi-process needs --threads, which this script never "
|
||||
"passes), so this is the default; the flag is accepted to document intent.")
|
||||
p.add_argument("--no-build", action="store_true", help="skip building; reuse cached dist bundles under build/perfCompare/binaries/")
|
||||
p.add_argument("commit", nargs="+", help="git revisions to compare; the first is the baseline (e.g. main HEAD)")
|
||||
args = p.parse_args()
|
||||
|
||||
if not os.path.isdir(os.path.join(REPO_ROOT, ".git")):
|
||||
sys.exit("error: run from the pyright repo root")
|
||||
corpus = args.corpus if args.corpus is not None else args.corpus_file
|
||||
if args.corpus is not None and not os.path.isdir(args.corpus):
|
||||
sys.exit(f"error: --corpus directory not found: {args.corpus}")
|
||||
if args.corpus_file is not None and not os.path.isfile(args.corpus_file):
|
||||
sys.exit(f"error: --corpus-file not found: {args.corpus_file}")
|
||||
|
||||
commits: list[str] = args.commit
|
||||
shas = {c: resolve_sha(c) for c in commits}
|
||||
|
||||
bundles: dict[str, str] = {}
|
||||
if args.no_build:
|
||||
for c in commits:
|
||||
cache_dir = os.path.join(BUILD_CACHE, shas[c])
|
||||
if not os.path.isfile(os.path.join(cache_dir, "pyright.js")):
|
||||
sys.exit(f"error: --no-build but no cached bundle for {c} ({shas[c][:10]})")
|
||||
bundles[c] = cache_dir
|
||||
else:
|
||||
if not working_tree_clean():
|
||||
sys.exit("error: working tree not clean -- commit/stash first (this script checks out commits in place)")
|
||||
original = current_ref()
|
||||
print(f"original ref: {original} (will restore after building)")
|
||||
try:
|
||||
for c in commits:
|
||||
bundles[c] = build_commit(c, shas[c])
|
||||
finally:
|
||||
heading(f"Restoring {original}")
|
||||
git("checkout", "-q", original)
|
||||
|
||||
num_runs = args.num_runs + args.warmup_runs
|
||||
metric_label = "CPU time (user+sys)" if args.metric == "cpu" else "pyright-reported wall time"
|
||||
heading(f"Measuring (corpus: {corpus}, {args.num_runs} runs + {args.warmup_runs} warmup, "
|
||||
f"metric: {args.metric}, single-process)")
|
||||
# `results` holds the chosen comparison metric; the other values are still printed per run.
|
||||
results: dict[str, list[float]] = {c: [] for c in commits}
|
||||
for n in range(num_runs):
|
||||
warm = n < args.warmup_runs
|
||||
print(f"{'Warmup' if warm else 'Run'} {n + 1 - (0 if warm else args.warmup_runs)}/"
|
||||
f"{args.warmup_runs if warm else args.num_runs}...")
|
||||
order = commits[:]
|
||||
random.shuffle(order)
|
||||
for c in order:
|
||||
wall, check, cpu = run_once(bundles[c], corpus)
|
||||
metric_val = cpu if args.metric == "cpu" else wall
|
||||
if not warm:
|
||||
results[c].append(metric_val)
|
||||
check_str = f" check={check:.2f}s" if check is not None else ""
|
||||
print(f" {c}: cpu={cpu:.3f}s wall={wall:.3f}s{check_str}")
|
||||
|
||||
baseline = commits[0]
|
||||
heading(f"Results ({metric_label})")
|
||||
first_mean = first_median = -1.0
|
||||
for c in commits:
|
||||
mean = statistics.mean(results[c])
|
||||
median = statistics.median(results[c])
|
||||
sd = statistics.pstdev(results[c]) if len(results[c]) > 1 else 0.0
|
||||
if first_mean < 0:
|
||||
first_mean, first_median = mean, median
|
||||
dm = dmed = "0.0%"
|
||||
else:
|
||||
dm = f"{mean / first_mean - 1:+.1%}"
|
||||
dmed = f"{median / first_median - 1:+.1%}"
|
||||
print(f"{c:<22} mean {mean:.3f}s ({dm}) | stdev {sd:.3f}s | median {median:.3f}s ({dmed})")
|
||||
|
||||
base_runs = results[baseline]
|
||||
base_center = statistics.median(base_runs)
|
||||
heading(f"Paired deltas vs {baseline} (per-round diffs; median +/- 95% CI)")
|
||||
for c in commits:
|
||||
if c == baseline:
|
||||
print(f"{c:<22} baseline")
|
||||
continue
|
||||
diffs = [a - b for a, b in zip(results[c], base_runs)]
|
||||
st = winsorized_paired_stats(diffs)
|
||||
pct = (st["median"] / base_center * 100) if base_center else 0.0
|
||||
print(f"{c:<22} median {st['median'] * 1000:+7.1f}ms +/-{st['ci'] * 1000:4.1f} ({pct:+.2f}%)")
|
||||
|
||||
t = int(time.time() - t0)
|
||||
print(f"\nTotal wall time: {t // 60}m {t % 60}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user