首页
/ Next.js 仓库中的 sandbox-bench:在 Vercel Sandbox VM 上对 React/Next.js 变更做配对 A/B 性能基准

Next.js 仓库中的 sandbox-bench:在 Vercel Sandbox VM 上对 React/Next.js 变更做配对 A/B 性能基准

2026-09-05 16:47:41作者:宗隆裙

这篇指南讲解 Next.js 开源仓库内置的 sandbox-bench 技能:它把两个修订版本("arm")构建成完全相同的 Next.js 应用,在 Vercel Sandbox 虚拟机上端到端施压,并以"VM 启动(boot)"为统计复制单位做配对 A/B 分析。读完后你能掌握:如何配置并启动一次远程基准测试、如何先用正确性门禁(correctness gate)拦截坏臂、如何用 boot 级置信区间与 p 值解读结果,以及当本地编排进程崩溃时如何恢复正在远端执行的测量任务。

一、这套基准工具解决什么问题

sandbox-bench 技能文档 定义的目标是"衡量一次变更到底值多少"(measures what a change is actually worth, end to end)。其核心设计可以归纳为四条:

  1. 两个修订版本 = 两条臂(arms):把 base 与 candidate 两个 git ref 构建成两个"其他方面完全相同"的 Next.js 应用,由 bench/render-pipeline 压测框架驱动真实 HTTP 请求。
  2. 全部重活在沙箱 VM 上:本机(laptop)只负责编排(orchestration),构建、编译、施压都发生在 Vercel Sandbox VM 中,实现"无笔记本"(laptop-free)运行。
  3. 配对统计,boot 为复制单位:VM 的每次启动(boot)被视为一次统计复制,JIT 状态、代码布局、GC 节奏等随机效应在 boot 时固定;同一 boot 内的迭代不独立,因此推断(置信区间、p 值)全部在 boot 层面进行。
  4. 内容寻址缓存:首次使用一对新 ref 需要构建缓存(额外约 45–60 分钟,一次性),之后的运行直接进入测量阶段。

脚本位于 .agents/skills/sandbox-bench/scripts/,可从任意目录运行;臂(arms)是 git ref,在 react 与 next.js 的缓存克隆中解析;Next 侧默认使用 canary。

二、一次性配置

2.1 配置 team 与 project

node scripts/config.mjs show
# 若输出 NOT CONFIGURED:
node scripts/config.mjs set team=<slug> project=<name>

配置落在仓库之外的 ~/.config/sandbox-bench/config.json。从 config.mjs 的源码可以看到其字段定义:

字段 必填 说明
team 沙箱 VM 计费到哪个 Vercel team(无默认值,必须询问用户,禁止猜测或填默认)
project 沙箱 VM 挂载的 Vercel project
cacheDir 克隆/产物/snapshot-id 的存放位置,默认 ~/.cache/sandbox-bench
reactRepo / nextRepo 指向已有 checkout;缺省时首次使用会自动 git clone --filter=blob:none 到 cacheDir
reactRepoUrl / nextRepoUrl 克隆地址,默认分别为 react 与 next.js 的官方仓库
vercelBin vercel CLI 路径,默认 vercel

源码还支持 SANDBOX_BENCH_TEAMSANDBOX_BENCH_PROJECTSANDBOX_BENCH_CACHE 等环境变量覆盖,方便 CI 或一次性使用。所有 vercel sandbox 调用都会附加 --team <team> --project <project> 作用域(见 sandboxScope() 函数),且配置中的 team/project 名称永远不要提交进仓库

2.2 Vercel CLI 会话权限

CLI 会话必须能访问所配置的 team。遇到 403 时文档给出了明确的处置策略:

  • 不要反复重试穿透它。先验证访问是否已自行恢复:vercel whoami --scope <team-slug> 加一个带作用域的只读调用(如 vercel sandbox ls)——权限的失效与恢复都是自发的,瞬态 403 根本不需要重新登录。
  • 若验证仍失败,自行在后台跑 vercel login <team-slug>(token 属于 CLI 会话而非用户),出现确认 URL 时转达给用户,同时每 1–2 分钟重跑验证组合;一旦验证通过就杀掉挂起的登录流程继续执行。
  • 403 中断之后,预期在途的运行已经死了:运行 node scripts/bench-status.mjs 并按其恢复动作处理(长时间中断会使测量 VM 撞上约 5 小时的超时——那些单元需要重新启动而不是去收集)。

react 与 next.js 的克隆在首次使用时落入缓存(或在配置中用 reactRepo/nextRepo 指向现有 checkout)。

2.3 dry-run 预检

node scripts/sandbox-e2e.mjs --pr <url> --label <slug> --dry-run

在任何新配置下的首次真实运行前,用 --dry-run 预检计划(打印将要做什么,不触碰任何东西)。

三、工作流五步法

第 1 步:解析"比的是什么"

场景 参数 基线如何确定
React PR --pr <url|number> 自动计算:PR head 与 react main 的 merge-base
React refs --arms base=<ref>,cand=<ref> base 必须在前;多提交分支的 base 是与 main 的 merge-base,不是 cand^
Next.js PR --next-pr <url|number> React 侧默认取各 Next ref 所 vendored 的版本(即实际会发布的组合);仅在想把两条臂钉死到同一个 React 构建时才传 --react-ref
Next refs --next-arms base=<ref>,cand=<ref> 同上

隔离原则:恰好一侧变化,另一侧在两条臂中完全相同。这正是数字可归因(attributable)的原因——永远不要两侧同时变化。sandbox-e2e.mjs 的参数解析对这一点做了硬约束:--pr--arms--next-pr--next-arms 四者必须恰好出现一个,否则直接抛错。

第 2 步:先做正确性门禁,再烧基准算力

"一个自己测试都挂掉的臂,其基准数字毫无意义。"对任何尚未在上游 CI 变绿的臂(手工拼装的分支、解决了冲突的 cherry-pick、本地提交):

node scripts/sandbox-gate.mjs --arms cand=<ref>

基准程序本身强制执行主门禁:每个 react 臂的 commit 必须在 react 仓库上有绿的 CI,在任何构建或 VM 消耗发生之前自动检查。PR 与 main 历史上的提交通常天然满足。对于本地或未推送的 ref(不存在 CI):先用 sandbox-gate 在 VM 上跑门禁(prod 模式跑完整测试套件——即被基准测试的同一渠道),PASS 的判据是输出里能看到真实的测试数量;然后在基准上追加 --allow-ungated。每条臂在各自 lockfile 的环境里做门禁;门禁若失败,报告失败并停止——不要基准测试一个坏掉的臂。基准测试时要用门禁打印的精确 sha(分支 ref 可能在门禁与基准之间移动)。

第 3 步:以后台非阻塞方式启动基准

bash -c 'node scripts/sandbox-e2e.mjs --pr <url> --label <slug> \
  2>&1 | grep --line-buffered -v "^live "; exit ${PIPESTATUS[0]}'

对 React PR,需要同时启动两套套件(两个独立后台任务,共享臂构建与缓存):

bash -c 'node scripts/sandbox-ssr.mjs --pr <url> --label <slug>-ssr \
  2>&1 | grep --line-buffered -v "^live "; exit ${PIPESTATUS[0]}'

两套套件分工明确:

  • e2e 套件:通过真实的 Next.js 应用测量 Node 路径;
  • ssr 套件:测量 react 仓库的 flight-ssr-bench 夹具(fixture)——8 种变体(Fizz 与 Flight+Fizz × Node 与 Edge web streams × 同步与异步),每种变体依次以 Flight script 注入、在 HTTP 服务器后以 c=1 / c=10 压测。Edge 单元格是 ssr 套件的头条(e2e 套件看不到该路径);其 Node 与 Fizz-only 单元格可以把效应归因到 Flight 层、Fizz 层或 stream 管线。夹具(工作负载)对两条臂钉死在同一个 ref(默认 react main),因此只有 React 构建不同;如果 PR 本身修改了夹具,启动器会说明该次运行不测量那些修改。Next PR 只跑 e2e 套件。

运行守则(来自技能文档,逐条都有统计或工程动机):

  • 以后台任务方式运行,等完成通知再处理;永远不要前台死等,也不要轮询循环——这条规则约束的是控制流而不是状态转达:读输出尾部回答"进展如何"总是允许的。
  • 框架内部保证实验不变量:两条臂在同一 VM 内、ABBA 交错、按 (vm, run) 配对;远端执行是 detached 的(传输中断不会杀死运行);每行结果都记录构建指纹。
  • live ... 行只是供进度展示用的流式估计。不要因为 live p 值看起来好就提前停止运行,也永远不要报告 live 数字——序贯偷看(sequential peeking)会制造假阳性,只有最终分析才算数。
  • 默认配置(16 VM × 2 次配对运行)就是方法论本身;不要为了省时间减少 VM 数——boot 是推断单位,boot 更少意味着区间更宽,而不是答案更快。
  • 沙箱算力是内部容量而非预算:启动、重启、确认运行,不需要就成本反复征询或缩水。
  • Next 侧默认 canary 是最新已发布的 canary(启动器会打印其版本与 sha),因此重复的基准测试会复用已构建的快照,直到发布新的 canary。
  • 常用参数:--bench-env KEY=VALUE(仅运行时注入基准进程的环境变量,不影响快照中的应用构建)、--isolate-routes(尾部延迟调查,路由之间重启服务器)、--no-profile(跳过默认在计时运行之后执行的 CPU 采集)、--prepare(只构建缓存——当两个单元格将共享同一条臂时使用,避免重复构建竞速)。
  • CPU profile 默认开启:每条臂一个 profile 通道,严格在计时运行之后执行(不会触碰数字),额外花费约 45–60 分钟 VM 墙钟时间,产物落在 <runDir>/prof-vm<N>/,是标准 V8 .cpuprofile 文件。跨 VM 的 profile 差异高度稳定(文档记录在真实 mover 上观察到 16/16 符号一致),因此一个 profiled 单元格足以对热点路径排序。分析注意事项:按 (functionName, line, column) 聚合——裸的 minified 名会在 bundle 内冲突——且永远不要按 minified 名跨臂 diff(minifier 会在构建之间改名),应改用位置或代码片段匹配。
  • 基准测试走的是 Next 的 node-streams 路径(__NEXT_USE_NODE_STREAMS 在 node 运行时构建期被内联为 true)。只改动 EDGE stream 配置的 React 变更不会被端到端执行,会(正确地)测出"无检测到差异"。

第 4 步:像怀疑论数据科学家一样读结果

目标是关于这次变更的真相,而不是让作者感觉良好。最终分析对每个 route/phase/metric 打印 boot 级均值、±95% CI 和跨 boot 的 p。methodology 文档 给出了判定策略:

  • 声称(claim)的门槛:boot 级 p < 0.01、附带 CI、且 team/config 经过 A/A 验证(见下文)。
  • PR 是假设,不是解释。结论只能来自分析输出本身。当数字与 PR 的叙事吻合时,检查所捕获的数据是否真能把该机制与替代解释区分开——归因于"payload 变小"的延迟收益应当伴随 document-bytes 差异;如果字节数没动,叙事就不成立,报告里要明说。
  • 用尽每一个被捕获的指标,并把一切对不上账的地方大声说出来:某指标族与其他指标反向移动、无字节级或 RSS 痕迹的效应、吞吐动了而延迟没动、boot 之间符号翻转。无法解释的不一致属于报告内容,而不是塞进抽屉。
  • 括号里显示的 within-run p 只是诊断量,永远不构成 claim。
  • 先看指纹头:两条臂指纹不同 = 有效的 A/B;"inconsistent fingerprints" = 无效,不报告数字。指纹哈希的是两个 bundler 编译出的服务器文件——只改客户端文件的臂可以合法地呈现"指纹相同但版本串不同"。
  • 逐 boot 数值会被打印出来;如果 boot 之间符号不一致,就直说。
  • 任何将驱动决策的结论,在作为事实陈述前都要有一个独立的确认运行

任何历史运行都可以不重跑地重新分析:

node scripts/bench-analyze.mjs <runDir>

统计实现值得展开。bench-stats.mjs 的注释直接点明设计依据:boot 内的迭代共享 JIT 状态、堆布局、宿主机与阶段顺序,不是独立样本(引用了 Kalibera & Jones 的严谨基准方法论与 JMH 的 forks 模型)。具体做法:

  • 每个 boot 对每个指标贡献一个 delta(该 boot 内配对差值的均值),t 推断跨 boot 进行;
  • 由于 boot 数少(典型 16 个),p 值用数值积分计算 Student-t 双侧尾部,df=1 时用 Cauchy 闭式解避免数值截断;
  • 进度展示用的置信区间是 anytime-valid 置信序列(Waudby-Smith & Ramdas 的渐近 CS),保证"在每次偷看时都同时有效",因此中途展示不可能通过反复观察制造显著性;样本数少于 6 时干脆不显示;
  • 指纹一致性校验是硬性的:任一臂内指纹不唯一,分析器直接打印 "RESULTS INVALID" 并置非零退出码,拒绝输出统计量。

methodology 文档还解释了为什么加 boot 而不是加 run:JIT 状态、代码布局、GC 节奏、宿主机硬件、阶段顺序都是 boot 时固定的随机效应,boot 内迭代共享它们,把它们当独立样本会产出"自信的错误"(一次运行里 p<1e-4 的效应,下次运行就消失)。因此固定预算下"更多 boot × 更少 run"优于相反;boot 可以并行跑,加 boot 花的是钱而不是墙钟时间。此外两条臂永远在同一 VM 内 ABBA 交错、按 (vm, run) 配对——宿主机之间差异可达约 20%,跨 VM 比较没有意义;VM 的存在是为了复制,不是为了比较

关于百分位数:小加载阶段的单次 p95/p99 是"接近最大值的次序统计量",不是分布估计;百分位只能在 boot 层比较,p99 需要每 boot 约 1000+ 样本才有意义。尾部效应也是阶段串扰(phase carryover)所在——用 --isolate-routes(路由间重启服务器)调查尾部。

A/A 验证:在新的 team/config/套件上声称任何东西之前,先跑一个 A/A 单元格(两条臂用同一个 ref):

node scripts/sandbox-e2e.mjs --arms base=<ref>,cand=<ref> --label aa
node scripts/sandbox-ssr.mjs --arms base=<ref>,cand=<ref> --label ssr-aa

A/A 验证的是推断本身。臂相同时,期望按 chance 约有(指标单元格数 × 0.01)个单元格落到 p < 0.01——e2e 套件约 24 个单元格,预期少于 1 个;ssr 套件约 88 个,预期约 1 个。因此判定标准是两次运行:套件 A/A 失败当且仅当某单元格在两次运行中都以相同符号达到 p < 0.01(与臂标签相关的偏置会复发,chance 不会),或任一单元格越过 familywise 门槛(p < 0.01 / 独立变体×阶段单元格数)。A/A 的 CI 也告诉你该配置能分辨多大的效应。平台变化(VM 硬件代际、node 版本、基准应用或夹具变更)时要重跑 A/A。文档记录当前验证状态:e2e 两次 0/24 全过;ssr 两次运行中第一次的 1 个 chance 单元格(p=0.0024)在第二次溶解为 p=0.86,无复发。

第 5 步:报告

用链接命名所测量的对象:PR 标题(打印在分析头、存于 meta.json)链接到 PR;ref 臂则用 commit 标题。以显著单元格表开头,每行带效应(含单位)、CI 和 p:

## <PR title> — e2e, Vercel Sandbox (x86 Xeon), <n> boots

Significant (boot-level p < 0.01, A/A-validated):
| cell | effect | 95% CI | p |
|---|---|---|---|
| /dashboard under load | +14.4% throughput (req/s) | ±3.2% | <0.0001 |
| /dashboard serial | −10.7% median latency (ms) | ±0.6% | <0.0001 |

No detected difference: <every cell not in the table, by name>.
Flags: <cells at 0.01 ≤ p < 0.05, sign disagreements across boots,
fingerprint caveats, anything that does not add up>

报告细则:

  • 每个单元格一行:rps 与 median 互为转述,报吞吐数即可(只有尾部移动方式不同于 median 时才加 p95 行)。Document 指标(raw/gzip/Flight KB)在数值不同时各占一行——它们是机制证据。若 Next 侧版本早于 document-metrics 框架(vercel/next.js#95828),这些单元格会缺席;要明说,而不是悄悄少报。
  • 数字旁边标注平台。量级是平台相关的(GC 占请求时间的比例随 CPU 速度变化);方向与机制可迁移,百分比不可迁移
  • 永远不要把与噪声兼容的差值呈现为小幅赢或小幅输——它就是"no detected difference"。

四、底层压测框架:bench/render-pipeline

sandbox-bench 的 e2e 侧驱动的是 bench/render-pipeline,它针对完整的 App Router 渲染路径(renderToHTMLOrFlight),通过 bench/next-minimal-server 用真实 HTTP 请求压测。关键特性:

  • 场景--scenario=e2e(默认,next build + next start,走完整生产栈:startServer()router-server.initialize()NextNodeServer → app render)与 --scenario=minimal-server(绕过 router-server,用 minimalMode: true 的裸 NextServer 隔离渲染管线本身);
  • 压力路由/dashboard(应用形态工作负载:client-reference 导入、流式面板、markup 与 client atoms 混合的表格)、/docs/blog 及一组 streaming/* 路由(每个 Suspense chunk 内含 client 边界,因此同时压测 Flight 的 Server-to-Client 序列化);
  • 测量模型:闭环(closed-loop)负载发生器——每个并发 worker 在当前请求完成前不发下一个请求。吞吐数对相对比较可靠;但负载下的延迟百分位偏乐观(慢请求降低背压而不是排队,掩盖尾部延迟),不能与 k6/wrk2 等开环工具做绝对值对比;
  • 逐路由 document 指标:ttfb(首个 body 字节时间,抓住总延迟在流式路由上会掩盖的 shell-flush 回归)与 document 字节数(解压后体积、内联 self.__next_f.push(...) Flight 脚本字节占比、内联脚本数)。字节总量按构建确定(夹具数据有种子),A/B 中任何字节差值都是真实的 payload 变化,且兼作"两侧渲染了相同输出"的检查——注意比较字节,不要比较脚本(Fizz 按 flush 时机把 pending 的 Flight 行包进一个脚本,数不稳定)。

五、结果数据库:统计量是 DB 的纯函数

每次收集到的运行都落入唯一的 SQLite 文件 ~/.cache/sandbox-bench/results.db——只有原始测量与产物(CPU profile、日志),且只由 importer 写入,永不手工修改。启动器在收集时自动导入并校验;bench-analyze.mjs 只读 DB 且别无他读,因此每个统计量都是 DB 的纯函数。报告里的数字必须原样取自分析输出——不手抄、不复算、不自行聚合。

bench-db.mjs 的 schema 体现了这一约束:runs / samples(按 run_id+boot+arm+block+run+route+phase 唯一)/ measurements(每个 sample 每个 metric 一行)/ artifacts,启用 WAL 与外键。常用命令:

node scripts/bench-db.mjs ls              # 所有运行及其样本/产物计数
node scripts/bench-db.mjs verify [runId] # 完整性:sqlite 级、引用完整性、每臂一个指纹、配对样本数、产物 sha256
node scripts/bench-db.mjs export out.db <runId...>  # 切出自包含 DB(含 profile)供他人用任意 SQLite 工具打开
node scripts/bench-analyze.mjs <runId>   # 重新分析 db 中任何运行;传 run-dir 参数会先导入

引用旧数据前先跑 verify

六、进程生命周期与故障恢复

6.1 保持用户知情

启动器在 stdout 上自述:先是启动事实(运行目录、臂、CI 判定),之后每约 2 分钟一行进度(已收集的行列数 + 带置信的逐路由中期效应)。转达给用户:启动后立即报运行目录与预期时长;用户问起时转达值得注意的中期变化;完成通知到达时给出完整最终判定。分析会点名"本次运行未捕获的指标"——当它限制了数据所能说明的范围时(document 指标缺席意味着 payload 机制"未被验证",而非"验证为相同"),要把这一点复述进结论。

运行活跃期间,任何回复都以每运行一行的状态开头:读启动器输出尾部并引用最新进度行。若会话支持定时唤醒,就在每次预期转换(臂构建 → 实验快照 → 测量,之后每约 15 分钟一次测量)安排检查并贴进度行;否则说明下次更新何时到达,让静默永远不产生歧义。进度行里的中期效应是流式估计——只作为进度分享,绝不作为 claim。

若启动器进程死了(会话拆除、崩溃),远端 VM 会继续执行测量循环——数据不会丢。node scripts/bench-collect.mjs <runDir> 会重连、等循环结束、下载结果、清理并分析;务必在 VM 撞上约 5 小时超时之前运行。

6.2 恢复动作清单

  • 永远的第一步:node scripts/bench-status.mjs。会话重启会静默杀死后台启动器,而它们的 detached VM 仍在测量;死掉启动器的日志末尾还可能是一行看起来健康的进度——永远不要从日志尾部或任务输出文件推断存活bench-status.mjs 检查每个运行记录的 launcher pid 是否存活,并打印逐运行的恢复动作(running / collect now / relaunch);它在源码层面用 process.kill(pid, 0) 探活,并按"是否有已收集行、距上次更新多久、VM 是否可能已过 5h 超时"分类给出动作。任何预期有在途工作的会话开头、任何崩溃之后、告诉用户什么在跑什么不在跑之前,都要先跑它。启动器崩溃也会记录在运行的 status.json 中(phase: "failed" 加错误信息)。
  • 本地进程被中断:远端 VM 以 detached 状态继续运行。用 vercel sandbox list(带配置的 team/project)找到它们;轮询每个 VM 的 /vercel/sandbox/loop.done,完成后 cp 下其 results.jsonl,然后删 VM 并用 bench-analyze.mjs 分析。
  • 崩溃后泄漏的 VMnode scripts/sandbox-sweep.mjs 列出本技能的 VM(按 sbench-* 名称 purpose=sandbox-bench 标签匹配,且仅当年龄超过 --min-age-hours,默认 3 小时,因此健康的在途运行绝不会被触碰);--yes 按列出的精确名称删除。
  • 不稳定的上传/传输:框架对产物做尺寸校验,截断即中止。失败的单元格可以安全地重新启动;缓存让重试很便宜。不要在同一时刻重启两个需要同一条未缓存臂的单元格——它们会竞速构建;先用 --prepare

七、成本预期

与用户在大运行之前对齐这些数字:

  • 每单元格在默认配置下约 18 台 VM(8 台测量 + 构建/快照 VM),冷启动约 1–2 小时墙钟,热启动约 30–60 分钟;
  • A/A 校准与确认运行是额外的单元格;
  • VM 计费到所配置的 team——超过"单次 PR 检查"范畴的事,先确认范围。

八、小结

sandbox-bench 把"这个 PR 更快吗 / 它回归 RSC 了吗"这类问题变成一条可审计的流水线:解析比较对象 → CI/VM 正确性门禁 → 后台启动(boot 为复制单位、同 VM ABBA 配对、detach 远端执行)→ 结果自动导入 SQLite 并做 boot 级统计 → 按"仅 boot 级 p<0.01 + A/A 验证 + 指纹一致 + 独立确认运行"的严格策略出具报告。配合 methodology.md 的统计依据与 bench/render-pipeline 的压测框架,它既保证"测的是真的",也保证"报的数是真的"。

登录后查看全文
热门项目推荐
相关项目推荐