Hardhat 回归基准测试运行次数评估指南:从历史方差数据推导最小 run count

原创2026-09-15 20:35:281,165 阅读
文章标签:开发工具区块链CLI

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。脚本输出分两部分:

  1. 一张 Markdown 表格:覆盖全部基准,按场景(scenario)与命令类型排序;
  2. 一个 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 判定构建失败所依据的降速百分比。

两条基本统计结论

  1. 均值的标准误:n 个独立测量(每个相对离散度为 CV)的平均值,其相对离散度为 CV / √n。
  2. 比值的误差传播:两个相互独立、相对离散度均较小的值,其比值的相对离散度等于两者平方和的平方根。

一次回归检查比较的是两个独立的均值(新 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,而是:

  1. 按提交计算 CV:每个提交、每个基准,CV = stdev(times) / mean(times)(少于 2 次运行的提交跳过);
  2. 剔除极端离群点:按基准使用 MAD(中位数绝对偏差)修正 z-score,阈值 > 3.5 的样本被丢弃(OUTLIER_Z = 3.5,analyze.py),用于清除"跑坏了"的异常运行;
  3. 取 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%)的过程中,用数据决定哪些基准需要加跑、哪些可以保持在下限。

登录后查看全文
hardhat