bench: render-pipeline benchmarks and stress routes (2/8) (#89863)

## Summary

Benchmark infrastructure for measuring render pipeline performance.

- **render-pipeline benchmark** (`bench/render-pipeline/`):
`benchmark.ts` for profiling render paths, `analyze-profiles.ts` for CPU
profile analysis
- **Stress routes** (`bench/basic-app/app/streaming/`): light, medium,
heavy, bulk, wide, chunkstorm variants for different streaming load
profiles
- **Basic app harness**: `benchmark.sh` runner script, `next.config.js`
- **Minimal server**: `bench/next-minimal-server/bin/minimal-server.js`
- **Docs**: `bench/BENCHMARKING.md` guide
- **Config**: eslint exclusion for bench paths, package.json bench
scripts

## Test plan

- [ ] No runtime behavior changes
- [ ] Benchmark scripts are standalone tooling

---------

Co-authored-by: Tim Neutkens <[email protected]>
This commit is contained in:
Jimmy Lai
2026-02-17 09:40:26 +01:00
committed by GitHub
co-authored by Tim Neutkens
parent 326fb8131f
commit 663c9151cf
18 changed files with 2235 additions and 15 deletions
+192
View File
@@ -0,0 +1,192 @@
# Benchmarking Playbook (Render Pipeline / Node Streams)
This is the practical workflow for benchmarking and profiling render pipeline changes in this repo.
Primary tools:
- `pnpm bench:render-pipeline`
- `pnpm bench:render-pipeline:analyze`
## 1. Build-first baseline
Always rebuild `next` before benchmark runs when framework source changed.
```bash
pnpm --filter=next build
```
## 2. End-to-end benchmark (full app render path)
This measures the full request path (`renderToHTMLOrFlight`) through `bench/next-minimal-server`.
In `scenario=full` and `scenario=all`, `--capture-cpu` defaults to `true`.
Node streams only:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Web vs Node comparison:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=both \
--build-full=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
## 3. Route-focused stress runs
Use this when targeting streaming-heavy behavior only.
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--routes=/streaming/heavy,/streaming/chunkstorm,/streaming/wide \
--warmup-requests=10 \
--serial-requests=40 \
--load-requests=400 \
--load-concurrency=40 \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Default stress routes currently include:
- `/`
- `/streaming/light`
- `/streaming/medium`
- `/streaming/heavy`
- `/streaming/chunkstorm`
- `/streaming/wide`
- `/streaming/bulk`
## 4. Isolate helper-level costs (micro scenario)
Use this to quickly test helper-level changes before full runs.
```bash
pnpm bench:render-pipeline \
--scenario=micro \
--iterations=300 \
--warmup=30
```
Micro benchmark output includes cases for:
- `teeNodeReadable`
- `createBufferedTransformNode`
- `createInlinedDataNodeStream`
- `continueStaticPrerender` / `continueDynamicPrerender` / `continueDynamicHTMLResume`
Flight payload mode toggles:
```bash
# Binary-heavy flight chunks
pnpm bench:render-pipeline --scenario=micro --binary-flight=true
# UTF-8-heavy flight chunks
pnpm bench:render-pipeline --scenario=micro --binary-flight=false
```
Stress payload shape:
```bash
pnpm bench:render-pipeline \
--scenario=micro \
--iterations=300 \
--warmup=30 \
--flight-chunks=128 \
--flight-chunk-bytes=8192 \
--html-chunks=128 \
--html-chunk-bytes=32768
```
## 5. Capture CPU profiles and traces
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--capture-trace=true \
--capture-next-trace=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Artifacts are written under:
- `bench/render-pipeline/artifacts/<run>/node/node.cpuprofile`
- `bench/render-pipeline/artifacts/<run>/node/node-trace-*.json`
- `bench/render-pipeline/artifacts/<run>/node/next-runtime-trace.log`
- `bench/render-pipeline/artifacts/<run>/results.json`
## 6. Analyze hotspots
```bash
pnpm bench:render-pipeline:analyze \
--artifact-dir=bench/render-pipeline/artifacts/<run> \
--top=20
```
Filter only the Node-stream-relevant hotspots:
```bash
pnpm bench:render-pipeline:analyze --artifact-dir=bench/render-pipeline/artifacts/<run> --top=20 > /tmp/analyze.txt
rg "use-flight-response|encodeFlightDataChunkNode|node-stream-tee|flushPending|node-stream-helpers|htmlEscapeJsonString" /tmp/analyze.txt
```
## 7. Compare two runs quickly
```bash
node - <<'NODE'
const fs = require('fs')
const [baseRun, candRun] = process.argv.slice(2)
const load = (name) =>
JSON.parse(
fs.readFileSync(`bench/render-pipeline/artifacts/${name}/results.json`, 'utf8')
).fullResults[0].routeResults
const base = load(baseRun)
const cand = load(candRun)
for (const b of base) {
const c = cand.find((x) => x.route === b.route && x.phase === b.phase)
if (!c) continue
const throughputDelta =
((c.throughputRps - b.throughputRps) / b.throughputRps) * 100
const p95Delta = ((b.latency.p95 - c.latency.p95) / b.latency.p95) * 100
console.log(
`${b.route} ${b.phase} throughput ${throughputDelta >= 0 ? '+' : ''}${throughputDelta.toFixed(2)}% p95 ${p95Delta >= 0 ? '+' : ''}${p95Delta.toFixed(2)}%`
)
}
NODE investigation-10-boundary-data investigation-17-profile-current
```
## 8. Noise control rules
Use these rules to keep measurements trustworthy:
- Build first (`pnpm --filter=next build`) after framework source changes.
- Compare runs with identical route sets and request knobs.
- Repeat suspicious runs at least once (especially if one route regresses while others improve).
- Use dedicated artifact directories per run.
- Prefer relative deltas across multiple runs over one-off absolute numbers.
## 9. Suggested iteration loop
1. Change one thing.
2. Build.
3. Run `scenario=micro` for quick signal.
4. Run focused full stress (`heavy/chunkstorm/wide`) with CPU profile.
5. Analyze hotspots and compare deltas.
6. Keep only changes that hold up across repeat runs.
@@ -0,0 +1,20 @@
'use client'
import React from 'react'
export function StreamingClientBoundary({
chunkId,
payload,
fragments,
checksum,
}) {
return (
<section data-client-boundary={chunkId}>
<h3>client-{chunkId}</h3>
<p>checksum:{checksum}</p>
<p>payload-bytes:{payload.length}</p>
<p>fragment-count:{fragments.length}</p>
<p>{fragments[0] ?? ''}</p>
</section>
)
}
@@ -0,0 +1,105 @@
import React, { Suspense } from 'react'
import { StreamingClientBoundary } from './client-boundary'
function sleep(ms) {
if (ms <= 0) return Promise.resolve()
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createPayload(title, payloadBytes) {
const prefix = `${title}:`
if (prefix.length >= payloadBytes) return prefix
return `${prefix}${'x'.repeat(payloadBytes - prefix.length)}`
}
function createClientPayload({ title, id, payloadBytes, fragmentCount }) {
const payload = createPayload(`${title}-client-${id}`, payloadBytes)
const safeFragmentCount = Math.max(1, fragmentCount)
const fragmentSize = Math.max(
16,
Math.floor(payload.length / safeFragmentCount)
)
const fragments = Array.from({ length: safeFragmentCount }, (_, index) => {
const start = Math.min(index * fragmentSize, payload.length)
const end = Math.min(start + fragmentSize, payload.length)
return payload.slice(start, end)
})
return {
chunkId: id,
payload,
fragments,
checksum: payload.length + id * 31 + safeFragmentCount,
}
}
async function StreamedChunk({
title,
id,
delayMs,
payload,
clientPayloadBytes,
clientPayloadFragments,
}) {
await sleep(delayMs)
const clientPayload = createClientPayload({
title,
id,
payloadBytes: clientPayloadBytes,
fragmentCount: clientPayloadFragments,
})
return (
<article data-chunk-id={id}>
<h2>chunk-{id}</h2>
<p>{payload}</p>
<StreamingClientBoundary {...clientPayload} />
</article>
)
}
export function StreamingStressPage({
title,
boundaryCount,
payloadBytes,
clientPayloadBytes = Math.max(128, Math.floor(payloadBytes / 2)),
clientPayloadFragments = 4,
maxDelayMs,
}) {
const payload = createPayload(title, payloadBytes)
const boundaries = Array.from({ length: boundaryCount }, (_, index) => index)
return (
<main>
<h1>{title}</h1>
<p>
boundaries={boundaryCount} payloadBytes={payloadBytes}{' '}
clientPayloadBytes=
{clientPayloadBytes} clientPayloadFragments={clientPayloadFragments}{' '}
maxDelayMs={maxDelayMs}
</p>
{boundaries.map((id) => {
const delayMs = maxDelayMs === 0 ? 0 : id % (maxDelayMs + 1)
return (
<Suspense
key={id}
fallback={<div data-fallback-id={id}>loading-{id}</div>}
>
<StreamedChunk
title={title}
id={id}
delayMs={delayMs}
payload={payload}
clientPayloadBytes={clientPayloadBytes}
clientPayloadFragments={clientPayloadFragments}
/>
</Suspense>
)
})}
</main>
)
}
@@ -0,0 +1,21 @@
import React from 'react'
export const dynamic = 'force-dynamic'
const ROWS = 2500
const PAYLOAD = 'x'.repeat(384)
const DATA = Array.from(
{ length: ROWS },
(_, index) => `row-${index}-${PAYLOAD}`
)
export default function Page() {
return (
<main>
<h1>stream-bulk</h1>
{DATA.map((line, index) => (
<p key={index}>{line}</p>
))}
</main>
)
}
@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-chunkstorm"
boundaryCount={960}
payloadBytes={192}
clientPayloadBytes={1024}
clientPayloadFragments={4}
maxDelayMs={3}
/>
)
}
@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-heavy"
boundaryCount={240}
payloadBytes={1536}
clientPayloadBytes={3072}
clientPayloadFragments={6}
maxDelayMs={8}
/>
)
}
@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-light"
boundaryCount={24}
payloadBytes={512}
clientPayloadBytes={384}
clientPayloadFragments={2}
maxDelayMs={2}
/>
)
}
@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-medium"
boundaryCount={96}
payloadBytes={1024}
clientPayloadBytes={1024}
clientPayloadFragments={4}
maxDelayMs={4}
/>
)
}
@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-wide"
boundaryCount={120}
payloadBytes={8192}
clientPayloadBytes={16384}
clientPayloadFragments={8}
maxDelayMs={6}
/>
)
}
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
# Benchmark script for comparing web streams vs node streams performance.
# Uses the minimal server (bench/next-minimal-server) for lowest overhead.
# Warms up with 50 requests, then runs two phases:
# Phase 1: 10s at concurrency=1 (single-client latency)
# Phase 2: 10s at concurrency=100 (throughput under load)
# Reports throughput and latency percentiles for each phase.
#
# Usage:
# ./benchmark.sh [duration] [warmup_requests]
#
# Defaults: 10s duration per phase, 50 warmup requests
set -euo pipefail
DURATION=${1:-10}
WARMUP_REQS=${2:-50}
PORT=3199
NEXT_BIN="../../packages/next/dist/bin/next"
MINIMAL_SERVER="../next-minimal-server/bin/minimal-server.js"
if ! command -v npx &>/dev/null; then
echo "npx is required (for autocannon)"
exit 1
fi
cleanup() {
lsof -ti :"$PORT" 2>/dev/null | xargs kill -9 2>/dev/null || true
}
trap cleanup EXIT
start_server() {
cleanup
sleep 0.5
PORT=$PORT node "$MINIMAL_SERVER" &>/dev/null &
SERVER_PID=$!
# Wait for server to be ready
local retries=0
while ! curl -sf "http://localhost:$PORT" >/dev/null 2>&1; do
retries=$((retries + 1))
if [ "$retries" -gt 30 ]; then
echo "ERROR: Server failed to start after 15s"
exit 1
fi
sleep 0.5
done
}
stop_server() {
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
cleanup
sleep 1
}
warmup() {
echo " Warming up ($WARMUP_REQS requests)..."
for i in $(seq 1 "$WARMUP_REQS"); do
curl -sf "http://localhost:$PORT" >/dev/null 2>&1 || true
done
sleep 0.5
}
run_phase() {
local label="$1"
local connections="$2"
echo ""
echo " --- $label (${DURATION}s, c=$connections) ---"
local result
result=$(npx autocannon -d "$DURATION" -c "$connections" -j "http://localhost:$PORT" 2>/dev/null)
node -e "
const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const r = d.requests;
const l = d.latency;
console.log(' Throughput:');
console.log(' avg: ' + r.average + ' req/s');
console.log(' mean: ' + r.mean + ' req/s');
console.log(' total: ' + r.total + ' requests in ${DURATION}s');
console.log(' Latency:');
console.log(' avg: ' + l.average.toFixed(2) + ' ms');
console.log(' p50: ' + l.p50.toFixed(2) + ' ms');
console.log(' p90: ' + l.p90.toFixed(2) + ' ms');
console.log(' p99: ' + l.p99.toFixed(2) + ' ms');
console.log(' max: ' + l.max.toFixed(2) + ' ms');
" <<< "$result"
}
run_benchmark() {
local mode="$1"
echo ""
echo "============================================"
echo " $mode"
echo "============================================"
start_server
warmup
run_phase "Single client" 1
run_phase "Under load" 100
stop_server
}
echo "Benchmark: web streams vs node streams"
echo "======================================="
echo "Duration: ${DURATION}s per phase | Warmup: ${WARMUP_REQS} reqs"
echo "Server: minimal-server (minimalMode: true)"
# --- Web Streams (default) ---
cat > next.config.js <<'CONF'
module.exports = {}
CONF
echo ""
echo "Building (web streams)..."
node "$NEXT_BIN" build &>/dev/null
run_benchmark "Web Streams (default)"
# --- Node Streams ---
cat > next.config.js <<'CONF'
module.exports = {
experimental: {
useNodeStreams: true,
},
}
CONF
echo ""
echo "Building (node streams)..."
node "$NEXT_BIN" build &>/dev/null
run_benchmark "Node Streams (useNodeStreams: true)"
# Restore config
cat > next.config.js <<'CONF'
module.exports = {}
CONF
echo ""
echo "Done."
+1 -5
View File
@@ -1,5 +1 @@
module.exports = {
experimental: {
serverMinification: true,
},
}
module.exports = {}
@@ -26,13 +26,32 @@ const nextServer = new NextServer({
const requestHandler = nextServer.getRequestHandler()
require('http')
.createServer((req, res) => {
console.time('next-request')
return requestHandler(req, res).finally(() => {
console.timeEnd('next-request')
})
})
.listen(3000, () => {
console.timeEnd('next-cold-start')
const port = parseInt(process.env.PORT, 10) || 3000
const server = require('http').createServer((req, res) => {
return requestHandler(req, res)
})
server.listen(port, () => {
console.timeEnd('next-cold-start')
console.log('Listening on port ' + port)
})
let shuttingDown = false
function shutdown() {
if (shuttingDown) return
shuttingDown = true
// Allow Node to exit cleanly so --cpu-prof/--heap-prof outputs are flushed.
server.close(() => {
process.exit(0)
})
// Fallback in case active keep-alive connections prevent close callback.
setTimeout(() => {
process.exit(1)
}, 5000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
+1
View File
@@ -0,0 +1 @@
artifacts/
+105
View File
@@ -0,0 +1,105 @@
# Render Pipeline Benchmark
This benchmark targets the full App Router render path (`renderToHTMLOrFlight`) via real HTTP requests through `bench/next-minimal-server`.
It supports:
- `web` vs `node` streams mode comparison
- route-based stress suites for streaming SSR
- CPU/heap profiling for the server process
- Node trace events and Next internal trace artifact capture
## Quick start
Run end-to-end benchmark (default stress routes):
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=both
```
For `scenario=full` and `scenario=all`, CPU profiles are captured by default.
Disable with `--capture-cpu=false` if you want lower-overhead runs.
Skip rebuild for faster iteration (after you already built once):
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=node --build-full=false
```
When `--stream-mode=both`, the runner forces `--build-full=true` so web/node
comparisons do not accidentally reuse stale build output.
Output JSON report:
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=both --json-out=/tmp/render-pipeline.json
```
## Profiling and traces
Capture CPU profiles + Node trace events + Next trace logs:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=both \
--capture-trace=true \
--capture-next-trace=true
```
Artifacts are written to:
```text
bench/render-pipeline/artifacts/<timestamp>/
```
Per mode (`web` and `node`) this includes:
- `<mode>.cpuprofile` (if `--capture-cpu=true`)
- `<mode>.heapprofile` (if `--capture-heap=true`)
- `<mode>-trace-*.json` (if `--capture-trace=true`)
- `next-trace-build.log` and `next-runtime-trace.log` (if `--capture-next-trace=true`)
Open `.cpuprofile` files in Chrome DevTools Performance panel.
Analyze results and CPU hotspots from artifacts:
```bash
pnpm bench:render-pipeline:analyze --artifact-dir=bench/render-pipeline/artifacts/<timestamp>
```
Omit `--artifact-dir` to analyze the latest run automatically.
## Stress routes
Default routes:
- `/`
- `/streaming/light`
- `/streaming/medium`
- `/streaming/heavy`
- `/streaming/chunkstorm`
- `/streaming/wide`
- `/streaming/bulk`
The `streaming/*` pages now include a client boundary per Suspense chunk, so benchmark runs also stress Server-to-Client payload serialization in Flight data.
Override with:
```bash
pnpm bench:render-pipeline --scenario=full --routes=/,/streaming/heavy
```
## Common tuning flags
- `--warmup-requests=30`
- `--serial-requests=120`
- `--load-requests=1200`
- `--load-concurrency=80`
- `--timeout-ms=30000`
- `--port=3199`
## Optional micro benchmarks
The runner also supports helper-only micro benchmarks:
```bash
pnpm bench:render-pipeline --scenario=micro
```
+399
View File
@@ -0,0 +1,399 @@
// This script must be run with tsx
import { constants } from 'node:fs'
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { SourceMap } from 'node:module'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const DEFAULT_ARTIFACTS_ROOT = resolve(
REPO_ROOT,
'bench/render-pipeline/artifacts'
)
type FullRoutePhaseResult = {
mode: 'web' | 'node'
route: string
phase: 'single-client' | 'under-load'
requests: number
concurrency: number
throughputRps: number
latency: {
min: number
median: number
mean: number
p95: number
max: number
}
}
type BenchmarkJson = {
fullResults?: Array<{
mode: 'web' | 'node'
routeResults: FullRoutePhaseResult[]
}>
}
type ProfileAnalysis = {
totalUs: number
runtimeUs: number
runtimeFile: string | null
topModules: Array<{ name: string; us: number }>
topRuntimeSources: Array<{ name: string; us: number }>
topRuntimeSymbols: Array<{ name: string; us: number }>
}
function usage() {
console.log(`Usage: pnpm bench:render-pipeline:analyze [options]
Options:
--artifact-dir=<path> Artifact run directory, or parent artifacts directory.
Default: latest run under bench/render-pipeline/artifacts
--top=<number> Number of top hotspots to show per section (default: 15)
`)
}
function parseArgs() {
const rawArgs = process.argv.slice(2)
if (rawArgs.includes('--help')) {
usage()
process.exit(0)
}
const args = new Map<string, string>()
for (const rawArg of rawArgs) {
if (!rawArg.startsWith('--')) continue
const [rawKey, rawValue] = rawArg.slice(2).split('=')
args.set(rawKey, rawValue ?? 'true')
}
const topRaw = args.get('top')
const top = topRaw ? Number(topRaw) : 15
if (!Number.isFinite(top) || top < 1) {
throw new Error(`Invalid --top value: ${topRaw}`)
}
return {
artifactDirArg: args.get('artifact-dir'),
top: Math.floor(top),
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK)
return true
} catch {
return false
}
}
async function resolveArtifactRunDir(artifactDirArg?: string): Promise<string> {
const requested = resolve(REPO_ROOT, artifactDirArg ?? DEFAULT_ARTIFACTS_ROOT)
const requestedResults = resolve(requested, 'results.json')
if (await exists(requestedResults)) {
return requested
}
const entries = await readdir(requested, { withFileTypes: true })
const dirs = entries.filter((entry) => entry.isDirectory())
const runs: Array<{ dir: string; mtimeMs: number }> = []
for (const dirent of dirs) {
const dir = resolve(requested, dirent.name)
const resultsPath = resolve(dir, 'results.json')
if (!(await exists(resultsPath))) continue
const stats = await stat(resultsPath)
runs.push({ dir, mtimeMs: stats.mtimeMs })
}
if (runs.length === 0) {
throw new Error(
`No artifact run found in ${requested}. Expected a results.json file.`
)
}
runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
return runs[0].dir
}
function toPercent(part: number, total: number): string {
if (total <= 0) return '0.00%'
return `${((part / total) * 100).toFixed(2)}%`
}
function toMs(us: number): string {
return `${(us / 1000).toFixed(1)}ms`
}
function sortTop(
entries: Iterable<[string, number]>,
limit: number
): Array<{ name: string; us: number }> {
return [...entries]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([name, us]) => ({ name, us }))
}
function mapModuleFromUrl(url: string): string {
if (!url || url === '(no-url)') return '(no-url)'
if (url.startsWith('node:')) return url
const appPageMatch = url.match(/app-page-turbo[\w-]*\.runtime\.prod\.js/)
if (appPageMatch) return appPageMatch[0]
if (url.includes('/.next/server/chunks/')) return '.next/server/chunks/*'
if (url.includes('/next/dist/')) return 'next/dist/*'
if (url.includes('/node_modules/')) return 'node_modules/*'
return url
}
function detectRuntimeFile(
urlsByUs: Array<{ url: string; us: number }>
): string | null {
for (const entry of urlsByUs) {
const match = entry.url.match(/app-page-turbo[\w-]*\.runtime\.prod\.js/)
if (match) return match[0]
}
return null
}
async function analyzeProfile(
profilePath: string,
top: number
): Promise<ProfileAnalysis | null> {
if (!(await exists(profilePath))) return null
const rawProfile = await readFile(profilePath, 'utf8')
const profile = JSON.parse(rawProfile) as {
nodes: Array<{
id: number
callFrame: {
functionName: string
url: string
lineNumber: number
columnNumber: number
}
}>
samples: number[]
timeDeltas: number[]
}
const idToNode = new Map(profile.nodes.map((node) => [node.id, node]))
const urlTotals = new Map<string, number>()
const moduleTotals = new Map<string, number>()
let totalUs = 0
for (let i = 0; i < profile.samples.length; i++) {
const sampleId = profile.samples[i]
const deltaUs = profile.timeDeltas[i] ?? 0
totalUs += deltaUs
const node = idToNode.get(sampleId)
if (!node) continue
const url = node.callFrame.url || '(no-url)'
urlTotals.set(url, (urlTotals.get(url) ?? 0) + deltaUs)
const moduleName = mapModuleFromUrl(url)
moduleTotals.set(moduleName, (moduleTotals.get(moduleName) ?? 0) + deltaUs)
}
const topUrls = sortTop(urlTotals.entries(), 30).map((entry) => ({
url: entry.name,
us: entry.us,
}))
const runtimeFile = detectRuntimeFile(topUrls)
let runtimeUs = 0
const runtimeSources = new Map<string, number>()
const runtimeSymbols = new Map<string, number>()
let sourceMap: SourceMap | null = null
if (runtimeFile) {
const mapPath = resolve(
REPO_ROOT,
`packages/next/dist/compiled/next-server/${runtimeFile}.map`
)
if (await exists(mapPath)) {
sourceMap = new SourceMap(JSON.parse(await readFile(mapPath, 'utf8')))
}
}
if (runtimeFile) {
for (let i = 0; i < profile.samples.length; i++) {
const sampleId = profile.samples[i]
const deltaUs = profile.timeDeltas[i] ?? 0
const node = idToNode.get(sampleId)
if (!node) continue
const { callFrame } = node
if (!callFrame.url.includes(runtimeFile)) continue
runtimeUs += deltaUs
const generatedLine = callFrame.lineNumber ?? 0
const generatedColumn = callFrame.columnNumber ?? 0
let sourceName = callFrame.url
let symbolName = callFrame.functionName || '(anonymous)'
let sourceLine = generatedLine
let sourceColumn = generatedColumn
if (sourceMap) {
const entry = sourceMap.findEntry(generatedLine, generatedColumn) as {
originalSource?: string
originalLine?: number
originalColumn?: number
name?: string
}
if (entry.originalSource) sourceName = entry.originalSource
if (entry.name) symbolName = entry.name
if (entry.originalLine !== undefined) sourceLine = entry.originalLine
if (entry.originalColumn !== undefined)
sourceColumn = entry.originalColumn
}
runtimeSources.set(
sourceName,
(runtimeSources.get(sourceName) ?? 0) + deltaUs
)
const symbolKey = `${symbolName} @ ${sourceName}:${sourceLine}:${sourceColumn}`
runtimeSymbols.set(
symbolKey,
(runtimeSymbols.get(symbolKey) ?? 0) + deltaUs
)
}
}
return {
totalUs,
runtimeUs,
runtimeFile,
topModules: sortTop(moduleTotals.entries(), top),
topRuntimeSources: sortTop(runtimeSources.entries(), top),
topRuntimeSymbols: sortTop(runtimeSymbols.entries(), top),
}
}
function printProfileAnalysis(
mode: 'web' | 'node',
analysis: ProfileAnalysis,
top: number
) {
console.log(`\n[${mode}]`)
console.log(` sampled: ${toMs(analysis.totalUs)}`)
if (analysis.runtimeFile) {
console.log(
` runtime: ${analysis.runtimeFile} (${toMs(analysis.runtimeUs)}, ${toPercent(analysis.runtimeUs, analysis.totalUs)})`
)
} else {
console.log(' runtime: not detected')
}
console.log(` top ${top} modules:`)
for (const entry of analysis.topModules) {
console.log(
` ${toPercent(entry.us, analysis.totalUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
if (analysis.topRuntimeSources.length > 0) {
console.log(` top ${top} runtime sources:`)
for (const entry of analysis.topRuntimeSources) {
console.log(
` ${toPercent(entry.us, analysis.runtimeUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
}
if (analysis.topRuntimeSymbols.length > 0) {
console.log(` top ${top} runtime symbols:`)
for (const entry of analysis.topRuntimeSymbols) {
console.log(
` ${toPercent(entry.us, analysis.runtimeUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
}
}
function printComparison(results: BenchmarkJson) {
const fullResults = results.fullResults
if (!fullResults || fullResults.length < 2) return
const web = fullResults.find((entry) => entry.mode === 'web')
const node = fullResults.find((entry) => entry.mode === 'node')
if (!web || !node) return
const webByKey = new Map(
web.routeResults.map((item) => [`${item.route}|${item.phase}`, item])
)
console.log('\n[comparison node vs web]')
console.log(
' route'.padEnd(20) +
'phase'.padEnd(16) +
'RPS delta'.padEnd(14) +
'P95 delta'
)
for (const nodeEntry of node.routeResults) {
const key = `${nodeEntry.route}|${nodeEntry.phase}`
const webEntry = webByKey.get(key)
if (!webEntry) continue
const rpsDelta =
((nodeEntry.throughputRps - webEntry.throughputRps) /
webEntry.throughputRps) *
100
const p95Delta =
((webEntry.latency.p95 - nodeEntry.latency.p95) / webEntry.latency.p95) *
100
const line =
` ${nodeEntry.route}`.padEnd(20) +
`${nodeEntry.phase}`.padEnd(16) +
`${rpsDelta >= 0 ? '+' : ''}${rpsDelta.toFixed(2)}%`.padEnd(14) +
`${p95Delta >= 0 ? '+' : ''}${p95Delta.toFixed(2)}%`
console.log(line)
}
}
async function main() {
const { artifactDirArg, top } = parseArgs()
const runDir = await resolveArtifactRunDir(artifactDirArg)
console.log(`Analyzing render pipeline artifacts:`)
console.log(` ${runDir}`)
const resultsPath = resolve(runDir, 'results.json')
const resultsRaw = await readFile(resultsPath, 'utf8')
const resultsJson = JSON.parse(resultsRaw) as BenchmarkJson
printComparison(resultsJson)
const webProfile = resolve(runDir, 'web/web.cpuprofile')
const nodeProfile = resolve(runDir, 'node/node.cpuprofile')
const [webAnalysis, nodeAnalysis] = await Promise.all([
analyzeProfile(webProfile, top),
analyzeProfile(nodeProfile, top),
])
if (!webAnalysis && !nodeAnalysis) {
console.log('\nNo CPU profiles found in this artifact run.')
console.log(
'This analyzer reads only <mode>/<mode>.cpuprofile artifacts (not trace-event JSON or next-runtime-trace.log).'
)
console.log(
'Run benchmark with --capture-cpu=true, e.g. pnpm bench:render-pipeline --scenario=full --stream-mode=node --capture-cpu=true'
)
return
}
if (webAnalysis) printProfileAnalysis('web', webAnalysis, top)
if (nodeAnalysis) printProfileAnalysis('node', nodeAnalysis, top)
console.log('\nDone.')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,7 +10,13 @@ export default defineConfig([
// This override adds type-checked rules.
// Linting with type-checked rules is very slow and needs a lot of memory,
// so we exclude non-essential files.
ignores: ['examples/**/*', 'test/**/*', '**/*.d.ts', 'turbopack/**/*'],
ignores: [
'bench/**/*',
'examples/**/*',
'test/**/*',
'**/*.d.ts',
'turbopack/**/*',
],
languageOptions: {
parserOptions: {
project: true,
+2
View File
@@ -12,6 +12,8 @@
"build": "turbo run build --remote-cache-timeout 60 --summarize true",
"lerna": "lerna",
"dev": "turbo run dev --parallel --filter=\"!@next/bundle-analyzer-ui\"",
"bench:render-pipeline": "tsx bench/render-pipeline/benchmark.ts",
"bench:render-pipeline:analyze": "tsx bench/render-pipeline/analyze-profiles.ts",
"pack-next": "tsx scripts/pack-next.ts",
"test-types": "tsc",
"test-unit": "jest test/unit/ packages/next/ packages/font",