mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
co-authored by
Tim Neutkens
parent
326fb8131f
commit
663c9151cf
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Executable
+142
@@ -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 +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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
artifacts/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user