Hardhat 回归基准测试运行次数评估指南:从历史方差数据推导最小 run count
Hardhat 回归基准测试运行次数评估指南:从历史方差数据推导最小 run count
Hardhat 的回归基准测试在 CI 中为每个基准命令运行多次取平均值,当平均值相对上一次超过设定的告警阈值(alert limit)时判定为回归。运行次数越多,均值越稳定,但 CI 耗时也越长。本文介绍一种可复现的"运行次数评估"(benchmark run sizing)方法:基于历史基准数据(hardhat-benchmark-results 仓库中的 data.js),计算每个基准在历次提交上的变异系数(CV),剔除离群点后取 95 分位 CV,再按目标噪声水平(σ = 3% / 1.5% / 1%,对应告警阈值 10% / 5% / 3%)推导每个基准的最小运行次数,并输出一份可直接使用的 Markdown 报告。
阅读完本文,你将掌握:如何获取并解析 Hardhat 基准历史数据、理解 CV 与 run count 之间的数学关系、运行现成的分析脚本(.claude/skills/benchmark-run-sizing/scripts/analyze.py)、以及如何从脚本的 Findings 撰写报告的 Conclusion 部分。整个过程面向"决定是否调整某个基准的运行次数""评估基准方差/噪声"以及"修改回归告警阈值后是否需要增减运行次数"等真实场景。
背景:Hardhat 的回归基准测试如何工作
在展开运行次数评估之前,先理解数据从何而来。Hardhat 仓库自身维护了两套基准工具:
- 单场景基准入口:初始化一个端到端场景并用 hyperfine 对单个命令计时,
--runs默认 10 次,支持--warmup、--prepare、--export-json等参数; - 多场景回归基准入口:遍历 end-to-end 目录下所有启用基准的场景,按 scenario.json 中
benchmark.commands声明的顺序运行每个命令(或步进序列),最后输出一个扁平 JSON 数组,格式与github-action-benchmark的customSmallerIsBetter兼容。
回归基准的输出中,每个计时条目除了墙钟时间外,还携带 "(cpu)"(用户 + 系统 CPU 时间)与 "(peak RSS)"(峰值内存)两类伴随条目;每次运行的原始耗时样本存放在 extra.times 字段中(见 regression.ts)。这些样本正是后续方差分析的输入:CI 把每次运行的耗时写入基准结果仓库的 data.js,而运行次数评估技能读取的就是这份历史记录。
package.json 中对应的两条脚本为 pnpm bench(node ./scripts/benchmark/main.ts)与 pnpm bench:regression(node ./scripts/benchmark/regression.ts)。
第一步:获取基准历史数据
运行次数评估的第一步是拿到历史基准数据。原技能文档推荐的做法是:
dir=$(mktemp -d)
git clone --depth 1 <hardhat-benchmark-results 仓库地址> "$dir"
该仓库由 nomic-foundation-automation 组织维护,其中的 hardhat3/data.js 是分析的目标文件——它由 github-action-benchmark 的 action 生成,形如 window.BENCHMARK_DATA = {...},内部按提交(commit)组织 benches 数组,每个基准条目通过 extra 字段携带当次提交的全部运行耗时(times)。
如果用户直接给出了本地路径或一段粘贴的 benches 数组,则跳过 clone,直接把脚本指向该文件即可(analyze.py 的加载逻辑兼容这两种输入,见下节)。
第二步:运行分析脚本
在取得数据文件后,执行:
python3 ${CLAUDE_SKILL_DIR}/scripts/analyze.py "$dir/hardhat3/data.js"
其中 ${CLAUDE_SKILL_DIR} 指向技能目录(即 .claude/skills/benchmark-run-sizing/),脚本默认参数为 hardhat3/data.js。脚本输出分两部分:
- 一张 Markdown 表格:覆盖全部基准,按场景(scenario)与命令类型排序;
- 一个 Findings 小节:数据驱动的事实汇总。
历史提交数少于 5 个的基准会被标记为 (provisional)——其 CV 是小样本估计,随历史累积会逐渐趋于稳定。这一阈值对应 analyze.py 中的 PROVISIONAL_BELOW = 5。
脚本的输入兼容性
analyze.py 的 collect 函数按以下顺序识别数据结构:
- 若数据对象含
entries字段(完整的历史数据,多提交),则把所有提交的条目摊平; - 若含
benches字段(粘贴的单次提交),则当作单条记录处理; - 否则视为空。
load 函数会先截取首个 { 之后的内容、剥掉尾部的 ;,再用正则容忍尾随逗号(JSON5 风格),最后交给标准 JSON 解析。每个条目的 extra 字段会被 json.loads 解析并读取 times 数组;缺少 times、均值为 0 或少于 2 次运行的提交会被跳过。
核心方法:从 CV 到 run count
本节是运行次数评估的数学内核,原技能文档以"可以直接采用的标准结论"方式给出了两条公式,并据此推导出 run count。这里结合 analyze.py 的实现逐项展开。
记法约定
- run:一次基准命令执行;
- mean:一个基准若干次 run 的平均耗时(也是 CI 拿来比较的数值);
- CV(变异系数):run 与 run 之间的标准差 ÷ 均值,以 % 表示。它刻画基准自身的"固有噪声",与运行次数无关;
- σ(sigma):新提交均值与上一次提交均值之差的(相对)标准差,以 % 表示。这是告警机制实际"看到"的噪声;
- p95:95 分位,即只有 1/20 的提交会超过的值;
- alert limit(L):CI 判定构建失败所依据的降速百分比。
两条基本统计结论
- 均值的标准误:n 个独立测量(每个相对离散度为 CV)的平均值,其相对离散度为 CV / √n。
- 比值的误差传播:两个相互独立、相对离散度均较小的值,其比值的相对离散度等于两者平方和的平方根。
一次回归检查比较的是两个独立的均值(新 vs 旧),各自离散度为 CV/√n,由结论 (1)(2) 得:
σ = √2 · CV / √n
目标 σ 与告警阈值
要求 σ ≈ L/3,即一个真实的 L% 变化会落在噪声之上约 3 个标准差处(远离误报区):
| alert limit L | target σ |
|---|---|
| 10% | 3% |
| 5% | 1.5% |
| 3% | 1% |
这三组目标正是 analyze.py 中 TARGETS 常量:("sigma=3%", 3.0, 10)、("sigma=1.5%", 1.5, 5)、("sigma=1%", 1.0, 3)。
解出运行次数
对 σ 的表达式求 n,得到:
runs = ceil( 2 · (CV / target σ)² ), 最低 2
对应 analyze.py 的 runs_for 函数:max(MIN_RUNS, math.ceil(2 * (cv_pct / target_pct) ** 2)),其中 MIN_RUNS = 2 是下限(一个基准至少要跑 2 次才能算出 CV)。
95 分位 CV 与离群点剔除
脚本并非直接用某个提交的 CV,而是:
- 按提交计算 CV:每个提交、每个基准,CV = stdev(times) / mean(times)(少于 2 次运行的提交跳过);
- 剔除极端离群点:按基准使用 MAD(中位数绝对偏差)修正 z-score,阈值 > 3.5 的样本被丢弃(
OUTLIER_Z = 3.5,analyze.py),用于清除"跑坏了"的异常运行; - 取 95 分位 CV(
PERCENTILE = 95)作为定容输入——这是保守选择:保证除最嘈杂的约 1/20 提交外,噪声目标对所有提交都成立;而那些最嘈杂的提交恰恰是真正引发误报的提交。
percentile 采用线性插值实现(analyze.py),与常见的 numpy.percentile 线性模式一致。
关于 ~runtime
表格中的 ~runtime 列是最近一次提交(chronological 顺序下最后一条记录)的均值运行时间,由 collect 在遍历时不断覆盖 last_rt 得到(analyze.py),用于估算总成本。
调整目标与阈值参数
脚本顶部的常量区(analyze.py)集中了全部可调参数:
TARGETS = [("sigma=3%", 3.0, 10), ("sigma=1.5%", 1.5, 5), ("sigma=1%", 1.0, 3)]
OUTLIER_Z = 3.5 # MAD modified z-score cutoff
PERCENTILE = 95 # CV percentile used for sizing
MIN_RUNS = 2 # floor
PROVISIONAL_BELOW = 5 # commits below this => provisional estimate
如需更改目标噪声水平、离群点规则、所用分位数或运行次数下限,直接修改这些常量即可。
Findings 小节的客观口径
main 中生成的 Findings 逐目标(σ = 3% / 1.5% / 1%)报告:
- 处于运行次数下限(
MIN_RUNS = 2)的基准占多少比例; - 超出下限的基准分别需要多少次运行(按需降序排列);
- 若全部按建议次数运行,估算总耗时(分钟),并与全部按下限运行的基准耗时代对比。
Findings 的估算基于"最近提交均值 × 建议次数",对 NaN 运行时长为 0 处理(analyze.py)。这些数字全部由数据驱动,报告中的 Conclusion 应当从 Findings 出发撰写,不要手工重算,也不要断言数据不支持的内容。
第三步:组装报告
技能文档附带了一份可直接套用的报告模板(原样输出),其中表格替换为脚本输出的表,Conclusion 依据脚本的 Findings 小节撰写。模板要点:
- Purpose:说明每个回归基准运行多次取平均、CI 在均值超过告警阈值时报警,以及"更多运行 → 更稳均值但更耗 CI 时间"的权衡;
- Notation:定义 run / mean / CV / σ / p95 / alert limit 六个概念;
- Method and formulas:推导 σ = √2·CV/√n 与 runs = ceil(2·(CV/target σ)²)(最低 2);
- Benchmark variance and recommended runs:插入脚本表格;
- Conclusion:根据脚本 Findings 撰写。
Conclusion 的写作角度
如果数据支持,可以从以下几个角度切入(保持几行 bullet 即可):
- 哪些命令类型稳定到足以停留在运行次数下限;
- 额外运行次数集中在哪些场景、哪些命令上;
- 目标收紧(σ 从 3% → 1.5% → 1%)时总成本如何增长;
- 因为 runs ∝ CV²,降低嘈杂基准的 CV(例如固定/重放 RPC、减少 fuzz 迭代、改为上报 run 的中位数或最小值)往往比单纯增加运行次数更划算——平方关系意味着噪声减半可将运行次数降为原来的 1/4。
与基准基础设施的源码印证
运行次数评估的输入输出,与仓库内的基准基础设施是闭环的,以下源码位置可供继续深入:
- scenario.json 示例:
benchmark.commands声明了runs、prepare、command(单命令)与runs、steps(步进序列)两种形态;步进序列中的measure: false表示只运行不计时(如reset files & cache),dependsOn声明状态依赖。回归基准实际执行时,"作为纯前置条件"的条目只跑一次而非配置的runs(见 regression.ts 与 plan.ts)。 - 统计计算:
computeStats采用样本标准差(n-1),与 hyperfine 一致;这也解释了为什么analyze.py中直接使用statistics.stdev(同样为样本标准差)。 - GNU time 封装:回归基准依赖 GNU time(非 BSD time)抓取 CPU 时间与峰值 RSS,
readTimeOutput按正则匹配user system peakRSS_KB一行,并向上取整到 MB。 - 输出格式:每个基准生成
"<scenarioId> / <name>"计时条目(单位 s,range为 ±stddev,extra携带全部times与 min/max/median/mean)以及"(cpu)"、"(peak RSS)"伴随条目。运行次数评估读取的data.js正是这类条目的历史沉淀。 - 单场景基准:
--runs默认值为 10,buildHyperfineCommand会把--runs、--warmup、--prepare等直接转发给 hyperfine。
小结
运行次数评估把"该跑几次"这一工程直觉变成了可复现的数据分析:按提交计算 CV、剔除离群点、取 95 分位、再按 σ ≈ L/3 反解最小运行次数。核心结论是 runs = ceil(2·(CV/target σ)²)(下限 2),而 runs ∝ CV² 意味着在源头降低 CV(固定环境、减少模糊迭代、采用中位数)通常比盲目加跑更经济。配合仓库中的 analyze.py、回归基准入口 与 场景配置,你可以随时重新评估任意基准的运行次数,并在 CI 告警阈值从 10% 逐步收紧到 5%(乃至 3%)的过程中,用数据决定哪些基准需要加跑、哪些可以保持在下限。