Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions bench/BENCHMARKING.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions bench/basic-app/app/streaming/_shared/client-boundary.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
105 changes: 105 additions & 0 deletions bench/basic-app/app/streaming/_shared/stress-page.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
21 changes: 21 additions & 0 deletions bench/basic-app/app/streaming/bulk/page.js
Original file line number Diff line number Diff line change
@@ -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>
)
}
17 changes: 17 additions & 0 deletions bench/basic-app/app/streaming/chunkstorm/page.js
Original file line number Diff line number Diff line change
@@ -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}
/>
)
}
17 changes: 17 additions & 0 deletions bench/basic-app/app/streaming/heavy/page.js
Original file line number Diff line number Diff line change
@@ -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}
/>
)
}
17 changes: 17 additions & 0 deletions bench/basic-app/app/streaming/light/page.js
Original file line number Diff line number Diff line change
@@ -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}
/>
)
}
Loading
Loading