Files
Viper_VSCode/build/perfCompare.py
TermiNexus e9e4693333
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
初始化上传
2026-07-24 17:08:39 +08:00

256 lines
11 KiB
Python

#! /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()