mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
59cc6420a3
This adds `bench/dev-validation/`, wired as `pnpm bench:dev-validation`, which measures how much dev-mode Cache Components validation contends for the dev server's event loop during rapid navigation, and how much running it on a worker thread relieves that. It toggles `experimental.devValidationWorker` (added in the previous commit) to A/B the two configurations on the same build. Until the worker implementation lands the flag is inert and the A/B shows no delta. The fixture generates one route per family (`client`, `server`, `sprite`), each nested several layout segments deep under a `(routes)` route group. Validation renders a combined payload at every URL depth, so a deeper route means more validation work per navigation, which mirrors a realistically deep app rather than a single flat segment. The runner clicks a family's `<Link>` repeatedly, since navigating to the current route re-renders and re-validates it on every click. The routes carry no `instant` config because dev validation applies to page segments by default at the warning level. The three families isolate the client prerender, the Flight re-encode plus owner-stack work, and the Flight payload size, respectively. The signal is browser-observed TTFB taken from Playwright's own network timing, because it includes the time a request waits for the event loop while validation monopolizes it. We deliberately do not use the CLI's logged request durations: the dev server starts that clock inside the request handler, after the loop has already yielded to the request, so the queue wait is invisible to it. The runner prints each configuration's absolute TTFB (p50/p95/max) side by side rather than a ratio. The time the worker frees is the validation render's CPU, which is bounded, route-dependent, and does no IO, so a ratio would overstate a win that does not scale with total request time. Because the clicks are back-to-back the numbers are a worst case — navigations that land inside the validation window — and the `max` tail is the honest headline: it is the main-thread stall the worker removes.
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
// Dev-validation benchmark. Must be run with tsx (via `pnpm
|
|
// bench:dev-validation`).
|
|
//
|
|
// Measures how much dev-mode Cache Components validation contends for the dev
|
|
// server's event loop during rapid navigation, and how much running it on a
|
|
// worker thread (`experimental.devValidationWorker`) relieves that.
|
|
//
|
|
// Workload: for each route family (client / server / sprite) Playwright clicks
|
|
// the family's `<Link>` repeatedly. Navigating to the current route re-renders
|
|
// and re-validates it, so every click triggers a fresh dev validation (the
|
|
// reloop "click Overview repeatedly" case). The routes carry no `instant`
|
|
// config; dev validation applies to page segments by default.
|
|
//
|
|
// Signal: the browser-observed request latency of those navigations, taken from
|
|
// Playwright's own network timing (`request.timing()`). We use TTFB
|
|
// (`responseStart - requestStart`) because that is the time the browser waits
|
|
// for the server, which includes the event-loop queue wait while validation
|
|
// monopolizes the loop. The CLI's logged request durations are NOT usable for
|
|
// this: the dev server starts that clock inside the request handler (after the
|
|
// event loop has already yielded to the request), so the queue wait is
|
|
// invisible to it.
|
|
//
|
|
// By default it runs the A/B on the same build: validation on a worker thread
|
|
// (the default) vs in-process (`experimental.devValidationWorker: false`), and
|
|
// prints each configuration's absolute TTFB side by side (not a ratio). Pass
|
|
// `--worker=true|false` to run a single configuration.
|
|
//
|
|
// The clicks are back-to-back, so this is a worst case: navigations that land
|
|
// inside the validation window, where the in-process path makes them wait for
|
|
// the current validation render to finish. The time the worker frees is that
|
|
// render's CPU, which is bounded, route-dependent, and does no IO, so it does
|
|
// not scale with total request time and largely disappears at human click
|
|
// speed. Read these numbers (especially the `max` tail) as an upper bound on a
|
|
// dev-only responsiveness gain, not a speedup that generalizes to real apps.
|
|
|
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { resolve } from 'node:path'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { chromium, type Browser, type Page } from 'playwright'
|
|
import { FAMILIES, familyHref } from './app/families'
|
|
|
|
const APP_DIR = fileURLToPath(new URL('.', import.meta.url))
|
|
const REPO_ROOT = resolve(APP_DIR, '..', '..')
|
|
const NEXT_BIN = resolve(REPO_ROOT, 'packages/next/dist/bin/next')
|
|
const GENERATE = resolve(APP_DIR, 'scripts', 'generate.mjs')
|
|
|
|
type Stats = {
|
|
n: number
|
|
p50: number
|
|
p95: number
|
|
max: number
|
|
mean: number
|
|
}
|
|
|
|
type FamilyResult = {
|
|
family: string
|
|
ttfb: Stats
|
|
total: Stats
|
|
}
|
|
|
|
type RunResult = {
|
|
worker: boolean
|
|
families: FamilyResult[]
|
|
}
|
|
|
|
type CliOptions = {
|
|
compare: boolean
|
|
worker: boolean
|
|
bundler: 'turbopack' | 'webpack'
|
|
clicks: number
|
|
port: number
|
|
headless: boolean
|
|
settleMs: number
|
|
jsonOut?: string
|
|
}
|
|
|
|
function parseArgs(argv: string[]): CliOptions {
|
|
const opts: CliOptions = {
|
|
compare: true,
|
|
worker: true,
|
|
bundler: 'turbopack',
|
|
clicks: 48,
|
|
port: 3210,
|
|
headless: true,
|
|
settleMs: 3000,
|
|
}
|
|
for (const arg of argv) {
|
|
const [rawKey, rawValue] = arg.replace(/^--/, '').split('=')
|
|
const value = rawValue ?? 'true'
|
|
switch (rawKey) {
|
|
case 'compare':
|
|
opts.compare = value !== 'false'
|
|
break
|
|
case 'worker':
|
|
// A single explicit --worker run turns off the A/B comparison.
|
|
opts.worker = value !== 'false'
|
|
opts.compare = false
|
|
break
|
|
case 'bundler':
|
|
opts.bundler = value === 'webpack' ? 'webpack' : 'turbopack'
|
|
break
|
|
case 'clicks':
|
|
opts.clicks = Number(value)
|
|
break
|
|
case 'port':
|
|
opts.port = Number(value)
|
|
break
|
|
case 'headless':
|
|
opts.headless = value !== 'false'
|
|
break
|
|
case 'settle-ms':
|
|
opts.settleMs = Number(value)
|
|
break
|
|
case 'json-out':
|
|
opts.jsonOut = value
|
|
break
|
|
default:
|
|
throw new Error(`Unknown option: --${rawKey}`)
|
|
}
|
|
}
|
|
return opts
|
|
}
|
|
|
|
function stats(samples: number[]): Stats {
|
|
if (samples.length === 0) {
|
|
return { n: 0, p50: NaN, p95: NaN, max: NaN, mean: NaN }
|
|
}
|
|
const sorted = [...samples].sort((a, b) => a - b)
|
|
const at = (p: number) =>
|
|
sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]
|
|
const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length
|
|
return {
|
|
n: sorted.length,
|
|
p50: Math.round(at(50)),
|
|
p95: Math.round(at(95)),
|
|
max: Math.round(sorted[sorted.length - 1]),
|
|
mean: Math.round(mean),
|
|
}
|
|
}
|
|
|
|
function run(command: string, args: string[]): Promise<void> {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(command, args, { stdio: 'inherit' })
|
|
child.on('error', reject)
|
|
child.on('exit', (code) =>
|
|
code === 0
|
|
? resolvePromise()
|
|
: reject(new Error(`${command} exited with ${code}`))
|
|
)
|
|
})
|
|
}
|
|
|
|
async function waitForReady(port: number, timeoutMs: number): Promise<void> {
|
|
const deadline = performance.now() + timeoutMs
|
|
while (performance.now() < deadline) {
|
|
try {
|
|
const res = await fetch(`http://localhost:${port}/`)
|
|
await res.text()
|
|
if (res.status < 500) {
|
|
return
|
|
}
|
|
} catch {
|
|
// Server not up yet.
|
|
}
|
|
await sleep(500)
|
|
}
|
|
throw new Error(`Dev server did not become ready within ${timeoutMs}ms`)
|
|
}
|
|
|
|
async function stopServer(server: ChildProcess): Promise<void> {
|
|
if (server.exitCode !== null) {
|
|
return
|
|
}
|
|
const exited = new Promise<void>((resolvePromise) =>
|
|
server.once('exit', () => resolvePromise())
|
|
)
|
|
server.kill('SIGTERM')
|
|
const killed = await Promise.race([
|
|
exited.then(() => true),
|
|
sleep(5000).then(() => false),
|
|
])
|
|
if (!killed) {
|
|
server.kill('SIGKILL')
|
|
await exited
|
|
}
|
|
}
|
|
|
|
async function measureFamily(
|
|
page: Page,
|
|
family: string,
|
|
opts: CliOptions
|
|
): Promise<FamilyResult> {
|
|
const routePath = familyHref(family)
|
|
const linkSel = `nav a[data-nav="${family}"]`
|
|
|
|
// Fire the family's link and resolve once its RSC navigation request has come
|
|
// back, so clicks stay one-per-navigation and measurable.
|
|
const clickOnce = async () => {
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(r) => r.url().includes(routePath) && r.request().method() === 'GET',
|
|
{ timeout: 60_000 }
|
|
),
|
|
page.click(linkSel),
|
|
])
|
|
}
|
|
|
|
// Collect Playwright's own network timing for the family's navigation
|
|
// requests while measuring. TTFB (responseStart - requestStart) is the
|
|
// browser-observed server wait; total (responseEnd - requestStart) adds the
|
|
// response transfer.
|
|
const ttfb: number[] = []
|
|
const total: number[] = []
|
|
let collecting = false
|
|
const onRequestFinished = (request: import('playwright').Request) => {
|
|
if (!collecting) {
|
|
return
|
|
}
|
|
if (request.method() !== 'GET' || !request.url().includes(routePath)) {
|
|
return
|
|
}
|
|
const timing = request.timing()
|
|
if (timing.requestStart >= 0 && timing.responseStart >= 0) {
|
|
ttfb.push(timing.responseStart - timing.requestStart)
|
|
total.push(timing.responseEnd - timing.requestStart)
|
|
}
|
|
}
|
|
page.on('requestfinished', onRequestFinished)
|
|
|
|
collecting = true
|
|
for (let i = 0; i < opts.clicks; i++) {
|
|
await clickOnce()
|
|
}
|
|
collecting = false
|
|
page.off('requestfinished', onRequestFinished)
|
|
|
|
return {
|
|
family,
|
|
ttfb: stats(ttfb),
|
|
total: stats(total),
|
|
}
|
|
}
|
|
|
|
async function runOnce(worker: boolean, opts: CliOptions): Promise<RunResult> {
|
|
const label = worker ? 'worker' : 'in-process'
|
|
process.stdout.write(`\n=== booting dev server: ${label} ===\n`)
|
|
|
|
const server = spawn(
|
|
'node',
|
|
[
|
|
NEXT_BIN,
|
|
'dev',
|
|
opts.bundler === 'webpack' ? '--webpack' : '--turbopack',
|
|
'--port',
|
|
String(opts.port),
|
|
],
|
|
{
|
|
cwd: APP_DIR,
|
|
env: {
|
|
...process.env,
|
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
NO_COLOR: '1',
|
|
...(worker ? {} : { BENCH_DEV_VALIDATION_WORKER: 'false' }),
|
|
},
|
|
stdio: ['ignore', 'ignore', 'inherit'],
|
|
}
|
|
)
|
|
|
|
let browser: Browser | undefined
|
|
try {
|
|
await waitForReady(opts.port, 180_000)
|
|
const base = `http://localhost:${opts.port}`
|
|
|
|
browser = await chromium.launch({ headless: opts.headless })
|
|
const page = await browser.newPage()
|
|
await page.goto(base, { waitUntil: 'load' })
|
|
await page.waitForSelector(`nav a[data-nav="${FAMILIES[0]}"]`)
|
|
|
|
// Warm every route once (compile) and let the warmup-triggered validation
|
|
// settle, so the measured clicks reflect steady-state contention, not
|
|
// first-compile cost.
|
|
process.stdout.write('warming up routes...\n')
|
|
for (const family of FAMILIES) {
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(r) =>
|
|
r.url().includes(familyHref(family)) &&
|
|
r.request().method() === 'GET',
|
|
{ timeout: 180_000 }
|
|
),
|
|
page.click(`nav a[data-nav="${family}"]`),
|
|
])
|
|
}
|
|
await sleep(opts.settleMs)
|
|
|
|
const families: FamilyResult[] = []
|
|
for (const family of FAMILIES) {
|
|
process.stdout.write(`measuring ${family} (${opts.clicks} clicks)...\n`)
|
|
families.push(await measureFamily(page, family, opts))
|
|
}
|
|
|
|
return { worker, families }
|
|
} finally {
|
|
if (browser) {
|
|
await browser.close().catch(() => {})
|
|
}
|
|
await stopServer(server)
|
|
// Give the OS a moment to release the port before the next configuration.
|
|
await sleep(1000)
|
|
}
|
|
}
|
|
|
|
function formatStats(stat: Stats): string {
|
|
return `p50=${stat.p50}ms p95=${stat.p95}ms max=${stat.max}ms (n=${stat.n})`
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv.slice(2))
|
|
|
|
process.stdout.write('generating fixture...\n')
|
|
await run('node', [GENERATE])
|
|
|
|
const configs = opts.compare ? [true, false] : [opts.worker]
|
|
const results: RunResult[] = []
|
|
for (const worker of configs) {
|
|
results.push(await runOnce(worker, opts))
|
|
}
|
|
|
|
process.stdout.write(
|
|
'\n=== RESULTS (browser-observed TTFB, lower is better) ===\n'
|
|
)
|
|
for (const result of results) {
|
|
const label = result.worker ? 'worker' : 'in-process'
|
|
process.stdout.write(`${label}:\n`)
|
|
for (const family of result.families) {
|
|
process.stdout.write(
|
|
` ${family.family.padEnd(6)} ttfb ${formatStats(family.ttfb)}\n`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (opts.compare) {
|
|
const off = results.find((r) => r.worker)
|
|
const inProc = results.find((r) => !r.worker)
|
|
if (off && inProc) {
|
|
// Report absolute TTFB side by side rather than a ratio. A ratio
|
|
// overstates the win: the time the worker frees is the validation
|
|
// render's CPU, which is bounded and route-dependent, and it only shows
|
|
// up when a navigation lands in the validation window. The tail (`max`)
|
|
// is the honest headline: it is the main-thread stall the worker removes.
|
|
process.stdout.write(
|
|
'\n=== in-process vs worker, browser-observed TTFB (ms, lower is better) ===\n'
|
|
)
|
|
for (const family of FAMILIES) {
|
|
const o = off.families.find((f) => f.family === family)
|
|
const p = inProc.families.find((f) => f.family === family)
|
|
if (o && p) {
|
|
process.stdout.write(
|
|
` ${family.padEnd(6)} in-process ${formatStats(p.ttfb)}\n` +
|
|
` ${' '.repeat(6)} worker ${formatStats(o.ttfb)}\n`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (opts.jsonOut) {
|
|
await mkdir(resolve(opts.jsonOut, '..'), { recursive: true })
|
|
await writeFile(
|
|
opts.jsonOut,
|
|
JSON.stringify({ options: opts, results }, null, 2)
|
|
)
|
|
process.stdout.write(`\nwrote ${opts.jsonOut}\n`)
|
|
}
|
|
}
|
|
|
|
main().then(
|
|
() => process.exit(0),
|
|
(error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
}
|
|
)
|