首页
/ Next.js Render Pipeline Benchmark:如何用 e2e 与最小服务器双场景压测 App Router 完整渲染路径

Next.js Render Pipeline Benchmark:如何用 e2e 与最小服务器双场景压测 App Router 完整渲染路径

2026-09-04 21:42:48作者:俞予舒Fleming

bench/render-pipeline 是 Next.js 仓库内置的渲染管线基准测试工具集,它通过真实 HTTP 请求压测 App Router 的完整渲染路径(renderToHTMLOrFlight),覆盖 Node 流式渲染、按路由压测、服务端 CPU/堆剖析、Node trace 事件与 Next 内部 trace 采集。读完本文,你将掌握如何用它对 next build + next start 的生产栈和剥离了 router-server 层的最小 NextServer 做 A/B 对比,如何采集 CPU profile、Flight 字节构成与浏览器端主线程归因数据,并理解其闭环(closed-loop)测量模型对结果解读的约束。

定位:压测什么、为什么不直接上 k6

该工具的核心目标是定位「渲染管线本身」的开销,而不是泛泛的服务器吞吐。它默认使用 bench/basic-app 作为应用 fixture(--app-dir 可指定其他目录),以真实 HTTP 请求驱动 127.0.0.1 上运行的生产服务器。核心实现在 benchmark.ts,配套两个脚本:analyze-profiles.ts(CPU profile 热点分析)和 client-trace.ts(浏览器端 CDP 归因)。仓库根 package.json 中注册了三个入口:

"bench:render-pipeline": "tsx bench/render-pipeline/benchmark.ts"
"bench:render-pipeline:analyze": "tsx bench/render-pipeline/analyze-profiles.ts"
"bench:render-pipeline:client": "tsx bench/render-pipeline/client-trace.ts"

两个场景:e2e 与 minimal-server 的 A/B 设计

--scenario=e2e(默认)

执行 next build + next start,走完整生产栈调用链:startServer()router-server.initialize()NextNodeServer → app 渲染。适合获得贴近生产的吞吐数据:

pnpm bench:render-pipeline --scenario=e2e --stream-mode=node

--scenario=minimal-server

完全绕过 router-server 层,通过 bench/next-minimal-server 启动一个 minimalMode: true 的裸 NextServer。该最小服务器只做三件事:读取 .next/required-server-files.json 中的编译后配置、用 new NextServer({ conf, dir, distDir, minimalMode: true, customServer: false }) 实例化、再把 requestHandler 挂到原生 http.createServer 上。它用于把渲染管线与路由/中间件开销隔离开——当你修改了 app-render.tsx、流式逻辑或 Flight 序列化时,用这个场景能排除路由层的噪声:

pnpm bench:render-pipeline --scenario=minimal-server --stream-mode=node

对比两个场景的差值,就能看出 router-server 层在裸渲染路径之上增加了多少额外开销。源码中两个场景分别由 runE2EBenchmarksrunMinimalServerBenchmarks 驱动,e2e 会话启动命令即 node <next bin> start --port <port>,而 minimal-server 会话则是带 profiling flags 的 node <profiling-args> bench/next-minimal-server/bin/minimal-server.js

快速上手与常用参数

默认压测套件(12 条内置路由):

pnpm bench:render-pipeline --scenario=e2e --stream-mode=node

CPU profiling 默认关闭(避免抬高测量值),剖析运行时追加 --capture-cpu=true。已经构建过之后,可用 --build=false 跳过重建加快迭代:

pnpm bench:render-pipeline --scenario=e2e --stream-mode=node --build=false

输出 JSON 报告(包含 optionsfullResultsgeneratedAt、Node 版本):

pnpm bench:render-pipeline --scenario=e2e --stream-mode=node --json-out=/tmp/render-pipeline.json

benchmark.tsusage() 输出列出了全部可调参数及默认值:

参数 默认值 说明
--scenario e2e e2e(生产栈)或 minimal-server(最小 NextServer)
--app-dir bench/basic-app 被压测的应用 fixture 目录
--routes 内置 12 条压测路由 逗号分隔,每条必须以 / 开头
--stream-mode node 当前仅支持 Node 流式模式
--build true 是否先执行 next build
--warmup-requests 50 每个预热批次的请求数
--warmup-until-stable true 反复预热直至平均延迟稳定(相邻批次差值 < 5%),最多 10 个批次
--serial-requests 120 单客户端(并发 1)阶段请求数
--load-requests 1200 压载阶段总请求数
--load-concurrency 80 压载阶段并发 worker 数
--port 3199 服务器端口(启动前会先探测端口是否被占用)
--timeout-ms 30000 单请求与就绪等待超时
--isolate-routes false 每条路由之间重启服务器,避免跨路由的 GC/内存污染
--capture-cpu / --capture-heap / --capture-trace false 采集 CPU profile / 堆 profile / Node trace 事件
--capture-next-trace true 拷贝 .next/trace.next/trace-build 为运行时/构建 trace 日志
--trace-categories node,node.async_hooks,v8 Node trace 事件类别
--artifact-dir bench/render-pipeline/artifacts/<timestamp> 产物输出目录

每个路由的测量流程在源码 runRoutePhases 中固定为三段:先 runWarmup(串行预热并打印 warmup stabilized after N requests (batch X, delta=Y%)),再单客户端串行阶段(single-client),最后闭环并发压载阶段(under-load),两阶段各自输出吞吐(req/s)、延迟统计(median/p95/stddev)与 TTFB 统计。

压测路由套件与生成的客户端图

默认压测 12 条路由://attributes(属性与内联样式序列化)、/tailwind(工具类密集的仪表盘)、/dashboard(客户端引用 import、流式面板、混排标记与客户端原子的表格)、/docs(导航元数据树作为数据、服务端高亮代码)、/blog(服务端卡片 + 富文本帖子数据作为客户端 props)、以及 /streaming/light/streaming/medium/streaming/heavy/streaming/chunkstorm/streaming/wide/streaming/bulkstreaming/* 页面在每个 Suspense chunk 内包含客户端边界,因此运行同时也压测 Flight 数据中的 Server-to-Client 载荷序列化。可用 --routes=/,/streaming/heavy 覆盖。

一个关键细节是「生成的客户端图」。bench/basic-app/scripts/generate-client-graph.mjs 在构建前生成批量客户端模块图(app/ui/vendor/,120 个模块 × 每模块 24 个函数)和 40 条合成路由段(app/g/r0..r39,各以 force-dynamic 渲染,导入图中不同的 60 模块滑窗,目录均被 gitignore)。原因在脚本头注释中写得很清楚:Flight 的 client-reference import 行携带每个模块的传递 chunk 闭包,而生产 chunker 按路由 chunk 组划分模块——小应用的均匀路由会被合并进少数几个 chunk,使 Flight 载荷中的 import 行远小于真实生产应用;生成的合成段以中等规模复现「数百个异构 segment、每行重复数十个 chunk」的形态。benchmark 会自动运行该生成器(ensureGeneratedClientGraph);若你在 harness 之外手动构建 bench/basic-app,需先执行一次:

node bench/basic-app/scripts/generate-client-graph.mjs

生成器以 app/ui/vendor/.generated 中的版本标记(当前为 v5-120x24-40routes-w60-c120)做幂等短路,标记匹配时直接退出。

逐路由文档指标:TTFB 与 Flight 字节构成

每个路由除延迟外还报告文档指标,实现在 inspectRouteDocument 中(每次运行只发一次检查请求,不污染计时):

  • ttfb——从流读取的首个 body 字节耗时。它能捕捉到流式路由上总延迟掩盖的 shell-flush 回归。
  • document bytes——解压后的 body 大小,加上内联 self.__next_f.push(...) Flight 脚本的字节占比与内联脚本数。由于 fixture 数据是播种的,字节总量对每次构建是确定性的:A/B 对比中任何字节差值都是真实的载荷变化,无需重复运行,同时可反证两侧渲染了相同输出。注意脚本数量不稳定——Fizz 把每次 flush 时挂起的 Flight 行包进一个脚本,数量随写入时机变化;应比较字节而非计数。

源码用正则 /<script[^>]*>(self\.__next_f\.push\(.*?)<\/script>/gs 提取 Flight 脚本(容忍脚本标签携带 CSP nonce 等属性),并对两类异常硬失败:文档不含 __next_f(非 App Router 文档,该基准只测 App Router 路由);文档含 __next_f 但正则零匹配(标记形态变了,需要更新提取正则)。

Profiling、Trace 与产物目录

采集 CPU profile + Node trace 事件 + Next trace 日志:

pnpm bench:render-pipeline \
  --scenario=e2e \
  --stream-mode=node \
  --capture-cpu=true \
  --capture-trace=true \
  --capture-next-trace=true

产物写入 bench/render-pipeline/artifacts/<timestamp>/<mode>/,每次运行包含:

  • <mode>.cpuprofile--capture-cpu=true 时,底层是 Node 的 --cpu-prof flags)
  • <mode>.heapprofile--capture-heap=true 时)
  • <mode>-trace-*.json--capture-trace=true 时,--trace-events-enabled + 指定类别)
  • next-trace-build.lognext-runtime-trace.log--capture-next-trace=true 时,分别拷贝自 .next/trace-build.next/trace

.cpuprofile 可直接在 Chrome DevTools 的 Performance 面板打开。更推荐用仓库自带的分析器,它会把采样按模块(.next/server/chunks/*next/dist/*node_modules/* 等)聚合,并借助 packages/next/dist/compiled/next-server/<runtime>.map 中的 source map 把 App 页面运行时(app-page-turbo*.runtime.prod.js)内的热点还原为源文件与符号级耗时:

pnpm bench:render-pipeline:analyze --artifact-dir=bench/render-pipeline/artifacts/<timestamp>

省略 --artifact-dir 时自动分析 bench/render-pipeline/artifacts 下最新的运行(按 results.json 的 mtime 选取)。

客户端 trace 通道:主线程时间归因

pnpm bench:render-pipeline:client --build=true

该通道用 Playwright 驱动 Chromium 访问生产服务器,启用 CDP tracing 与 CPU 节流(默认 4 倍),把主线程时间分解为按路由的归因桶。client-trace.ts 的关键设计:每个样本使用全新浏览器上下文(无 HTTP 缓存与编译缓存复用,每次都是冷访问),通过 navigationStart 的 user-timing 事件定位主线程 pid/tid 再过滤事件,保证 worker/合成器/浏览器进程事件不污染求和。报告字段包括:

  • 每 chunk 脚本 eval 与 compile 时间(含文件数);
  • 内联脚本 eval 时间(Flight __next_f.push 脚本 + Fizz 的 $RS/$RC 边界揭示脚本,Flight 占时长大头);
  • 非主线程流式解析 CPU(v8.parseOnBackgroundParsing)——大型外部 chunk 的大部分解析成本落在这里而非主线程 compile 桶;
  • bench:hydrated 标记的耗时(shell hydration 提交);
  • hydration 前的长任务数 / 总阻塞时间(每个任务只截取 hydration 之前的部分;未观察到标记时置 null,因为没有标记就不存在明确的窗口);
  • GC 时间(仅顶层 MinorGC/MajorGC 暂停;GC 可能发生在 eval 内部,各桶相互重叠,不求和等于墙钟时间);
  • JS 传输字节 vs 解析字节(来自 PerformanceResourceTiming)。

bench:hydrated 标记来自 fixture 根布局里一个形似 analytics provider 的小型客户端组件 app/ui/hydration-mark.js,它在每个路由的被测载荷中,因此此前构建的字节总量不可与其直接比较。FCP/LCP/DOMContentLoaded/load 从同一 trace 中提取为次要行——只是合理性锚点,不是对比指标。归因桶在低样本数下也稳定(默认每路由 3 样本、每次冷访问),所以该通道只多花几分钟而非几十分钟。注意:tracing 会扰动计时,绝不要用该通道获取延迟/吞吐数据,也不要与 HTTP 基准并发运行。 原始 trace 落在产物目录(client-trace-<route>.json,第一个样本落盘),可用 DevTools 或 Perfetto UI 加载。

常用选项(usage() 输出):--samples=3--cpu-throttle=4(CDP 拒绝低于 1 的倍率)、--build=false(默认)、--start-server=false --port=<port>(复用别处启动的服务器)、--settle-ms=750(hydration 后等待,让 prefetch 处理落入 trace)、--timeout-ms=30000--artifact-dir(默认 bench/render-pipeline/artifacts/<timestamp>-client)、--json-out。报告固定写入产物目录的 client-trace.json。Chromium 来自仓库的 playwright 依赖,若启动失败先执行 pnpm exec playwright install chromium

测量模型:为什么吞吐量可信而 p95 偏乐观

该基准使用闭环负载发生器:每个并发 worker 只在当前请求完成后才发出下一个(见 benchmark.tsrunConcurrentRequests,一组 worker 共享一个原子自增索引轮流取请求)。这意味着:

  • 吞吐数字适合相对对比(代码变更前后)。两侧经历相同测量模型,差值有效。
  • 负载下的延迟百分位(p95、max)偏乐观。慢请求降低了背压而非排队,掩盖了尾延迟。不要把这个基准的绝对延迟值与 k6、wrk2 等开环工具对比。

CPU profiling(--capture-cpu)默认关闭以避免抬高测量值;需要 .cpuprofile 产物时,单独跑一轮 --capture-cpu=true 的剖析 pass。

运行细节与工程保障

除了 README 的主线,源码中还有几个保证结果可信的工程细节,值得在复现时留意:

  • 端口与就绪探测assertPortFree 先探测端口是否已有服务在响应;waitForServerReady 轮询首个路由的同时检查子进程是否已退出——否则启动即死(如 EADDRINUSE)的服务器会与慢启动难以区分,甚至被别的进程返回的 200 蒙混过关。
  • 请求测量measureRequest 以流式读取 body,首个 chunk 到达即记录 TTFB,字节统计针对解压后内容;单请求失败只损失一个样本(errors 计数上报)而非毁掉整轮。
  • 优雅关闭gracefulKill 依次 SIGINT(3 秒)→ SIGTERM(3 秒)→ SIGKILL,配合 minimal-server.js 中对 SIGTERM/SIGINT 的处理,确保 keep-alive 连接不阻塞 profile 输出 flush。
  • 微基准:README 末尾提到 runner 还支持 helper-only 微基准(--scenario=micro)。需要说明的是,从当前源码结构看,benchmark.ts 的参数解析仅接受 e2eminimal-server 两个 scenario 取值,传入其他值会直接报错;该微基准入口可推断为文档领先于代码或已被暂时收敛,实际使用时以 --help 输出为准。

总结

bench/render-pipeline 提供了一条从「生产栈吞吐 → 裸渲染管线隔离 → 服务端 CPU/trace 剖析 → 浏览器端主线程归因」的完整性能度量链路,其设计重点在于:用双场景 A/B 剥离 router-server 开销、用确定性字节指标捕捉 Flight 载荷变化、用生成客户端图逼近生产 chunk 形态、用闭环模型明确吞吐与延迟各自的适用边界。修改 App Router 渲染、流式或序列化代码时,按「先 --scenario=e2e 看生产影响,再 --scenario=minimal-server 定位是否来自渲染路径,最后 --capture-cpu + analyze 找热点、client trace 看浏览器侧成本」的顺序使用,是当前仓库内验证渲染管线变更最直接的工具链。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384