mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
chore: add dev boot profiling and benchmark scripts (#87940)
## Summary Development scripts for profiling and benchmarking Next.js dev server boot time. ### Benchmarking Scripts - `benchmark-boot-time.sh` - Wall-clock benchmarking with two metrics: - **Listen time**: When TCP port accepts connections - **Ready time**: When first HTTP request succeeds - `benchmark-next-dev-boot.js` - Multi-iteration benchmarking with statistics (median, p95, stddev) ### Profiling Scripts - `profile-next-dev-boot.js` - CPU profiling infrastructure using V8 inspector - `analyze-profile.js` - Analyze CPU profiles to identify hot modules by CPU time ### Analysis Scripts - `analyze-dev-server-bundle.js` - Bundle analyzer for dev server (generates treemap report) - `trace-cli-startup.js` - Module loading trace to identify slow imports ## Usage ```bash # Benchmark dev server boot time (5 runs by default) ./scripts/benchmark-boot-time.sh # Multi-iteration benchmark with stats node scripts/benchmark-next-dev-boot.js --iterations 10 # Generate CPU profile node scripts/profile-next-dev-boot.js # Analyze profile output node scripts/analyze-profile.js .next/cpu-profiles/*.cpuprofile # Analyze dev server bundle node scripts/analyze-dev-server-bundle.js --open ``` ## Test Plan - [x] Run `./scripts/benchmark-boot-time.sh` locally - [x] Verify scripts execute without errors
This commit is contained in:
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dev Server Bundle Analyzer
|
||||
*
|
||||
* Generates a bundle analyzer report for the dev server bundle.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/analyze-bundle.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --open Open the report in browser (default: false)
|
||||
* --verbose Show detailed module reasons
|
||||
* --json Also output stats.json file
|
||||
* --list-modules List all bundled modules to console
|
||||
* --list-externals List all externalized modules
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2)
|
||||
const hasFlag = (name) => args.includes(`--${name}`)
|
||||
|
||||
const openBrowser = hasFlag('open')
|
||||
const verbose = hasFlag('verbose')
|
||||
const outputJson = hasFlag('json')
|
||||
const listModules = hasFlag('list-modules')
|
||||
const listExternals = hasFlag('list-externals')
|
||||
|
||||
const nextDir = path.join(__dirname, '..', 'packages', 'next')
|
||||
const bundlePath = path.join(
|
||||
nextDir,
|
||||
'dist/compiled/dev-server/start-server.js'
|
||||
)
|
||||
const reportPath = path.join(
|
||||
nextDir,
|
||||
'dist/compiled/dev-server/bundle-report.html'
|
||||
)
|
||||
|
||||
console.log('\x1b[34m=== Dev Server Bundle Analyzer ===\x1b[0m')
|
||||
console.log('')
|
||||
|
||||
// Build with analyzer
|
||||
console.log('Building bundle with analyzer...')
|
||||
const env = {
|
||||
...process.env,
|
||||
ANALYZE: '1',
|
||||
...(verbose ? { ANALYZE_REASONS: '1' } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('npx taskr next_bundle_dev_server', {
|
||||
cwd: nextDir,
|
||||
stdio: verbose ? 'inherit' : 'pipe',
|
||||
env,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('\x1b[31mBuild failed\x1b[0m')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get bundle stats
|
||||
const stats = fs.statSync(bundlePath)
|
||||
const sizeKB = Math.round(stats.size / 1024)
|
||||
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
|
||||
|
||||
console.log('')
|
||||
console.log('\x1b[32mBundle Stats:\x1b[0m')
|
||||
console.log(` Size: ${sizeKB} KB (${sizeMB} MB)`)
|
||||
console.log(` Path: ${bundlePath}`)
|
||||
console.log(` Report: ${reportPath}`)
|
||||
console.log('')
|
||||
|
||||
// List bundled modules
|
||||
if (listModules) {
|
||||
console.log('\x1b[33mBundled Modules:\x1b[0m')
|
||||
const content = fs.readFileSync(bundlePath, 'utf-8')
|
||||
const moduleMatches = content.match(/"\.\/dist\/[^"]+/g) || []
|
||||
const modules = [...new Set(moduleMatches)]
|
||||
.map((m) => m.replace(/^"/, ''))
|
||||
.filter((m) => !m.includes(' recursive'))
|
||||
.sort()
|
||||
|
||||
modules.forEach((m) => console.log(` ${m}`))
|
||||
console.log(`\n Total: ${modules.length} modules`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// List externalized modules
|
||||
if (listExternals) {
|
||||
console.log('\x1b[33mExternalized Modules:\x1b[0m')
|
||||
const content = fs.readFileSync(bundlePath, 'utf-8')
|
||||
|
||||
// Find external requires
|
||||
const externalMatches =
|
||||
content.match(
|
||||
/require\("(next\/dist\/[^"]+|@next\/[^"]+|styled-jsx[^"]*)"\)/g
|
||||
) || []
|
||||
const externals = [...new Set(externalMatches)]
|
||||
.map((m) => m.match(/require\("([^"]+)"\)/)[1])
|
||||
.sort()
|
||||
|
||||
externals.forEach((m) => console.log(` ${m}`))
|
||||
console.log(`\n Total: ${externals.length} external requires`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// Output JSON stats
|
||||
if (outputJson) {
|
||||
const statsJsonPath = path.join(
|
||||
nextDir,
|
||||
'dist/compiled/dev-server/stats.json'
|
||||
)
|
||||
console.log(`Stats JSON: ${statsJsonPath}`)
|
||||
console.log('(Run with ANALYZE_REASONS=1 for detailed stats)')
|
||||
}
|
||||
|
||||
// Open in browser
|
||||
if (openBrowser) {
|
||||
console.log('Opening report in browser...')
|
||||
const opener =
|
||||
process.platform === 'darwin'
|
||||
? 'open'
|
||||
: process.platform === 'win32'
|
||||
? 'start'
|
||||
: 'xdg-open'
|
||||
try {
|
||||
execSync(`${opener} "${reportPath}"`, { stdio: 'ignore' })
|
||||
} catch {
|
||||
console.log(`Could not open browser. Open manually: ${reportPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\x1b[32mDone!\x1b[0m')
|
||||
console.log('')
|
||||
console.log('Tips:')
|
||||
console.log(' - Open the HTML report to see interactive treemap')
|
||||
console.log(' - Use --list-modules to see all bundled modules')
|
||||
console.log(' - Use --list-externals to see external requires')
|
||||
console.log(' - Use --verbose for detailed build output')
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Analyze a CPU profile to identify hot modules
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const profilePath = process.argv[2]
|
||||
if (!profilePath) {
|
||||
console.error('Usage: node analyze-profile.js <profile.cpuprofile>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'))
|
||||
|
||||
// Extract nodes with their hit counts
|
||||
const nodes = profile.nodes || []
|
||||
|
||||
// Group by file/module
|
||||
const moduleHits = {}
|
||||
nodes.forEach((node) => {
|
||||
const fn = node.callFrame
|
||||
if (fn && fn.url) {
|
||||
const url = fn.url
|
||||
// Extract module name from path
|
||||
let moduleName = url
|
||||
if (url.includes('next/dist/')) {
|
||||
moduleName = url.split('next/dist/')[1]
|
||||
} else if (url.includes('node_modules/')) {
|
||||
moduleName = 'node_modules/' + url.split('node_modules/').pop()
|
||||
}
|
||||
if (!moduleHits[moduleName]) {
|
||||
moduleHits[moduleName] = { hits: 0 }
|
||||
}
|
||||
moduleHits[moduleName].hits += node.hitCount || 0
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by hits
|
||||
const sorted = Object.entries(moduleHits)
|
||||
.filter(([_, v]) => v.hits > 0)
|
||||
.sort((a, b) => b[1].hits - a[1].hits)
|
||||
.slice(0, 40)
|
||||
|
||||
console.log('Top 40 modules by CPU time:')
|
||||
console.log('='.repeat(70))
|
||||
sorted.forEach(([name, data], i) => {
|
||||
console.log(`${String(i + 1).padStart(2)}. ${name} (${data.hits} hits)`)
|
||||
})
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Benchmark dev server boot time (wall-clock)
|
||||
#
|
||||
# Measures TWO metrics:
|
||||
# 1. listen_time: When server starts accepting TCP connections
|
||||
# 2. ready_time: When server responds to first HTTP request
|
||||
#
|
||||
# The delta between these shows how much initialization is deferred after "Ready".
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/benchmark-boot-time.sh [runs] [test-dir]
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/benchmark-boot-time.sh # 5 runs, uses /tmp/next-boot-test
|
||||
# ./scripts/benchmark-boot-time.sh 3 # 3 runs
|
||||
# ./scripts/benchmark-boot-time.sh 5 ./my-app # 5 runs on existing app
|
||||
|
||||
set -e
|
||||
|
||||
RUNS=${1:-5}
|
||||
TEST_DIR=${2:-/tmp/next-boot-test}
|
||||
NEXT_BIN="$(dirname "$0")/../packages/next/dist/bin/next"
|
||||
PORT=3456
|
||||
|
||||
echo "=== Dev Server Boot Time Benchmark ==="
|
||||
echo "Runs: $RUNS"
|
||||
echo "Test dir: $TEST_DIR"
|
||||
echo "Next.js: $NEXT_BIN"
|
||||
echo ""
|
||||
echo "Metrics:"
|
||||
echo " listen_time: TCP port accepting connections"
|
||||
echo " ready_time: First HTTP request succeeds"
|
||||
echo " delta: ready_time - listen_time (deferred init)"
|
||||
echo ""
|
||||
|
||||
# Create test app if it doesn't exist
|
||||
if [ ! -f "$TEST_DIR/package.json" ]; then
|
||||
echo "Creating test app..."
|
||||
mkdir -p "$TEST_DIR/app"
|
||||
cat > "$TEST_DIR/package.json" << 'EOF'
|
||||
{
|
||||
"name": "boot-test",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat > "$TEST_DIR/app/layout.tsx" << 'EOF'
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <html><body>{children}</body></html>
|
||||
}
|
||||
EOF
|
||||
cat > "$TEST_DIR/app/page.tsx" << 'EOF'
|
||||
export default function Home() { return <h1>Hello</h1> }
|
||||
EOF
|
||||
(cd "$TEST_DIR" && npm install --silent)
|
||||
# Link local next
|
||||
(cd "$TEST_DIR" && npm link "$(dirname "$NEXT_BIN")/.." 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Kill any existing next dev on our port
|
||||
pkill -f "next dev.*$PORT" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
||||
# Returns: listen_time,ready_time (comma-separated)
|
||||
benchmark_run() {
|
||||
local label=$1
|
||||
local clean_next=$2
|
||||
|
||||
if [ "$clean_next" = "true" ]; then
|
||||
rm -rf "$TEST_DIR/.next"
|
||||
fi
|
||||
|
||||
# Measure wall-clock time from command start
|
||||
local start_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
|
||||
|
||||
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
|
||||
local pid=$!
|
||||
|
||||
local timeout=600 # 30s at 50ms intervals
|
||||
local listen_time=""
|
||||
local ready_time=""
|
||||
|
||||
# Phase 1: Wait for port to be listening (nc -z)
|
||||
for i in $(seq 1 $timeout); do
|
||||
if nc -z localhost $PORT 2>/dev/null; then
|
||||
listen_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
|
||||
break
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
|
||||
# Phase 2: Wait for HTTP response (curl)
|
||||
if [ -n "$listen_time" ]; then
|
||||
for i in $(seq 1 $timeout); do
|
||||
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
|
||||
ready_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
|
||||
break
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
|
||||
# Kill the server
|
||||
kill $pid 2>/dev/null || true
|
||||
wait $pid 2>/dev/null || true
|
||||
|
||||
if [ -n "$listen_time" ] && [ -n "$ready_time" ]; then
|
||||
local listen_delta=$((listen_time - start_time))
|
||||
local ready_delta=$((ready_time - start_time))
|
||||
echo "$listen_delta,$ready_delta"
|
||||
else
|
||||
echo "TIMEOUT,TIMEOUT"
|
||||
fi
|
||||
}
|
||||
|
||||
run_benchmark_series() {
|
||||
local series_name=$1
|
||||
local clean_next=$2
|
||||
|
||||
echo "--- $series_name ---"
|
||||
echo "Run | Listen | Ready | Delta"
|
||||
echo "----|--------|-------|------"
|
||||
|
||||
local listen_times=""
|
||||
local ready_times=""
|
||||
local deltas=""
|
||||
|
||||
for i in $(seq 1 $RUNS); do
|
||||
RESULT=$(benchmark_run "$series_name-$i" "$clean_next")
|
||||
LISTEN=$(echo "$RESULT" | cut -d',' -f1)
|
||||
READY=$(echo "$RESULT" | cut -d',' -f2)
|
||||
|
||||
if [ "$LISTEN" != "TIMEOUT" ] && [ "$READY" != "TIMEOUT" ]; then
|
||||
DELTA=$((READY - LISTEN))
|
||||
printf "%3d | %5dms | %5dms | %5dms\n" "$i" "$LISTEN" "$READY" "$DELTA"
|
||||
listen_times="$listen_times $LISTEN"
|
||||
ready_times="$ready_times $READY"
|
||||
deltas="$deltas $DELTA"
|
||||
else
|
||||
printf "%3d | TIMEOUT | TIMEOUT | -\n" "$i"
|
||||
fi
|
||||
done
|
||||
|
||||
# Calculate averages
|
||||
local listen_avg=$(echo $listen_times | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
|
||||
local ready_avg=$(echo $ready_times | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
|
||||
local delta_avg=$(echo $deltas | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
|
||||
|
||||
echo ""
|
||||
echo "Average: listen=${listen_avg}ms, ready=${ready_avg}ms, delta=${delta_avg}ms"
|
||||
echo ""
|
||||
|
||||
# Export for summary
|
||||
export "${series_name}_LISTEN_AVG=$listen_avg"
|
||||
export "${series_name}_READY_AVG=$ready_avg"
|
||||
export "${series_name}_DELTA_AVG=$delta_avg"
|
||||
}
|
||||
|
||||
# Run cold start benchmarks
|
||||
run_benchmark_series "COLD" true
|
||||
|
||||
# Warmup for bytecode cache
|
||||
echo "--- Warming up bytecode cache (12s) ---"
|
||||
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
|
||||
WARMUP_PID=$!
|
||||
for i in $(seq 1 200); do
|
||||
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
sleep 12
|
||||
kill $WARMUP_PID 2>/dev/null || true
|
||||
wait $WARMUP_PID 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# Run warm start benchmarks
|
||||
run_benchmark_series "WARM" false
|
||||
|
||||
# Summary
|
||||
echo "=============================================="
|
||||
echo " SUMMARY"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "Cold Start ($RUNS runs):"
|
||||
echo " Port listening: ${COLD_LISTEN_AVG}ms"
|
||||
echo " First request: ${COLD_READY_AVG}ms"
|
||||
echo " Deferred init: ${COLD_DELTA_AVG}ms"
|
||||
echo ""
|
||||
echo "Warm Start ($RUNS runs):"
|
||||
echo " Port listening: ${WARM_LISTEN_AVG}ms"
|
||||
echo " First request: ${WARM_READY_AVG}ms"
|
||||
echo " Deferred init: ${WARM_DELTA_AVG}ms"
|
||||
echo ""
|
||||
|
||||
if [ "$COLD_READY_AVG" != "N/A" ] && [ "$WARM_READY_AVG" != "N/A" ]; then
|
||||
CACHE_BENEFIT=$((COLD_READY_AVG - WARM_READY_AVG))
|
||||
echo "Cache benefit: ${CACHE_BENEFIT}ms (cold - warm ready)"
|
||||
fi
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dev Server Boot Time Benchmark
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/benchmark-boot.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --iterations=N Number of iterations (default: 5)
|
||||
* --test-dir=PATH Test project directory (default: /private/tmp/next-boot-test)
|
||||
* --bundled Use bundled dev server (default)
|
||||
* --unbundled Use unbundled dev server
|
||||
* --compare Run both bundled and unbundled for comparison
|
||||
* --turbopack Use Turbopack (default)
|
||||
* --webpack Use Webpack
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2)
|
||||
const getArg = (name, defaultValue) => {
|
||||
const arg = args.find((a) => a.startsWith(`--${name}=`))
|
||||
return arg ? arg.split('=')[1] : defaultValue
|
||||
}
|
||||
const hasFlag = (name) => args.includes(`--${name}`)
|
||||
|
||||
const iterations = parseInt(getArg('iterations', '5'), 10)
|
||||
const testDir = getArg('test-dir', '/private/tmp/next-boot-test')
|
||||
const compare = hasFlag('compare')
|
||||
const useWebpack = hasFlag('webpack')
|
||||
const bundlerFlag = useWebpack ? '--webpack' : '--turbopack'
|
||||
|
||||
const nextDir = path.join(__dirname, '..', 'packages', 'next')
|
||||
const nextBin = path.join(nextDir, 'dist/bin/next')
|
||||
const cliSource = path.join(nextDir, 'src/cli/next-dev.ts')
|
||||
|
||||
console.log('\x1b[34m=== Next.js Dev Server Boot Benchmark ===\x1b[0m')
|
||||
console.log(`Iterations: ${iterations}`)
|
||||
console.log(`Test directory: ${testDir}`)
|
||||
console.log(`Bundler: ${useWebpack ? 'Webpack' : 'Turbopack'}`)
|
||||
console.log('')
|
||||
|
||||
// Verify test directory exists
|
||||
if (!fs.existsSync(testDir)) {
|
||||
console.error(
|
||||
`\x1b[31mError: Test directory does not exist: ${testDir}\x1b[0m`
|
||||
)
|
||||
console.log('Create a test project first:')
|
||||
console.log(` mkdir -p ${testDir} && cd ${testDir}`)
|
||||
console.log(' pnpm init && pnpm add next@canary react react-dom')
|
||||
console.log(
|
||||
' mkdir -p app && echo "export default function Page() { return <h1>Hello</h1> }" > app/page.tsx'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Kill existing next dev processes
|
||||
function killNextDev() {
|
||||
try {
|
||||
execSync('pkill -f "next dev"', { stdio: 'ignore' })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Run a single benchmark iteration
|
||||
function runIteration() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Clean .next directory
|
||||
const nextCache = path.join(testDir, '.next')
|
||||
if (fs.existsSync(nextCache)) {
|
||||
fs.rmSync(nextCache, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
let resolved = false
|
||||
|
||||
const child = spawn(nextBin, ['dev', bundlerFlag], {
|
||||
cwd: testDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, FORCE_COLOR: '0' },
|
||||
})
|
||||
|
||||
let output = ''
|
||||
|
||||
const onData = (data) => {
|
||||
output += data.toString()
|
||||
// Look for "Ready in Xms" pattern
|
||||
const match = output.match(/Ready in (\d+)ms/)
|
||||
if (match && !resolved) {
|
||||
resolved = true
|
||||
const reportedTime = parseInt(match[1], 10)
|
||||
const actualTime = Date.now() - startTime
|
||||
child.kill('SIGTERM')
|
||||
resolve({ reportedTime, actualTime })
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.stderr.on('data', onData)
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Timeout after 60 seconds
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('Timeout waiting for server to start'))
|
||||
}
|
||||
}, 60000)
|
||||
})
|
||||
}
|
||||
|
||||
// Run benchmark with multiple iterations
|
||||
async function runBenchmark(name) {
|
||||
console.log(`\x1b[33mRunning ${name}...\x1b[0m`)
|
||||
|
||||
const reportedTimes = []
|
||||
const actualTimes = []
|
||||
|
||||
for (let i = 1; i <= iterations; i++) {
|
||||
try {
|
||||
killNextDev()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const { reportedTime, actualTime } = await runIteration()
|
||||
reportedTimes.push(reportedTime)
|
||||
actualTimes.push(actualTime)
|
||||
console.log(
|
||||
` Run ${i}: ${reportedTime}ms (reported) / ${actualTime}ms (actual)`
|
||||
)
|
||||
} catch (err) {
|
||||
console.log(` Run ${i}: Failed - ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
killNextDev()
|
||||
|
||||
if (reportedTimes.length === 0) {
|
||||
console.log('\x1b[31mNo successful runs\x1b[0m')
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const calcStats = (times) => {
|
||||
const sum = times.reduce((a, b) => a + b, 0)
|
||||
const avg = Math.round(sum / times.length)
|
||||
const min = Math.min(...times)
|
||||
const max = Math.max(...times)
|
||||
const sorted = [...times].sort((a, b) => a - b)
|
||||
const median = sorted[Math.floor(sorted.length / 2)]
|
||||
return { avg, min, max, median, count: times.length }
|
||||
}
|
||||
|
||||
const reported = calcStats(reportedTimes)
|
||||
const actual = calcStats(actualTimes)
|
||||
|
||||
console.log(`\x1b[32mResults for ${name}:\x1b[0m`)
|
||||
console.log(` Reported time (Next.js internal):`)
|
||||
console.log(
|
||||
` Avg: ${reported.avg}ms | Min: ${reported.min}ms | Max: ${reported.max}ms | Median: ${reported.median}ms`
|
||||
)
|
||||
console.log(` Actual time (CLI to ready):`)
|
||||
console.log(
|
||||
` Avg: ${actual.avg}ms | Min: ${actual.min}ms | Max: ${actual.max}ms | Median: ${actual.median}ms`
|
||||
)
|
||||
console.log('')
|
||||
|
||||
return { reported, actual }
|
||||
}
|
||||
|
||||
// Switch between bundled/unbundled
|
||||
function setBundled(useBundled) {
|
||||
const content = fs.readFileSync(cliSource, 'utf-8')
|
||||
|
||||
const bundledPath = `require.resolve(
|
||||
'../compiled/dev-server/start-server'
|
||||
)`
|
||||
const unbundledPath = `require.resolve('../server/lib/start-server')`
|
||||
|
||||
let newContent
|
||||
if (useBundled) {
|
||||
newContent = content.replace(
|
||||
/const startServerPath = require\.resolve\(['"]\.\.\/server\/lib\/start-server['"]\)/,
|
||||
`const startServerPath = ${bundledPath}`
|
||||
)
|
||||
} else {
|
||||
newContent = content.replace(
|
||||
/const startServerPath = require\.resolve\(\s*['"]\.\.\/compiled\/dev-server\/start-server['"]\s*\)/,
|
||||
`const startServerPath = ${unbundledPath}`
|
||||
)
|
||||
}
|
||||
|
||||
if (newContent !== content) {
|
||||
fs.writeFileSync(cliSource, newContent)
|
||||
// Rebuild CLI
|
||||
console.log(`Rebuilding CLI (${useBundled ? 'bundled' : 'unbundled'})...`)
|
||||
execSync('npx taskr cli', { cwd: nextDir, stdio: 'ignore' })
|
||||
}
|
||||
}
|
||||
|
||||
// Main
|
||||
async function main() {
|
||||
killNextDev()
|
||||
|
||||
if (compare) {
|
||||
// Run both bundled and unbundled
|
||||
setBundled(true)
|
||||
const bundledResults = await runBenchmark('Bundled dev server')
|
||||
|
||||
setBundled(false)
|
||||
const unbundledResults = await runBenchmark('Unbundled dev server')
|
||||
|
||||
// Restore to bundled
|
||||
setBundled(true)
|
||||
|
||||
// Print comparison
|
||||
console.log('\x1b[34m=== Comparison ===\x1b[0m')
|
||||
if (bundledResults && unbundledResults) {
|
||||
const reportedDiff =
|
||||
bundledResults.reported.avg - unbundledResults.reported.avg
|
||||
const actualDiff = bundledResults.actual.avg - unbundledResults.actual.avg
|
||||
|
||||
console.log(
|
||||
`Reported time difference: ${reportedDiff > 0 ? '+' : ''}${reportedDiff}ms (${reportedDiff > 0 ? 'bundled slower' : 'bundled faster'})`
|
||||
)
|
||||
console.log(
|
||||
`Actual time difference: ${actualDiff > 0 ? '+' : ''}${actualDiff}ms (${actualDiff > 0 ? 'bundled slower' : 'bundled faster'})`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await runBenchmark('Dev server')
|
||||
}
|
||||
|
||||
console.log('\x1b[32mDone!\x1b[0m')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Next.js CPU Profile Script
|
||||
*
|
||||
* Generates CPU profiles for Next.js startup and dev server boot.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/profile-next-dev-boot.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --test-dir=PATH Test project directory (default: /private/tmp/next-boot-test)
|
||||
* --output-dir=PATH Output directory for profiles (default: ./profiles)
|
||||
* --turbopack Use Turbopack (default)
|
||||
* --webpack Use Webpack
|
||||
* --duration=MS How long to profile after ready (default: 1000)
|
||||
* --cli Profile just the CLI entry point (runs next --help)
|
||||
*
|
||||
* Output files:
|
||||
* - dev-turbopack-YYYY-MM-DDTHH-MM-SS.cpuprofile
|
||||
* - cli-turbopack-YYYY-MM-DDTHH-MM-SS.cpuprofile
|
||||
*
|
||||
* The profile can be loaded in:
|
||||
* - Chrome DevTools (Performance tab -> Load profile)
|
||||
* - VS Code (JavaScript Profile Visualizer extension)
|
||||
* - https://www.speedscope.app/
|
||||
*
|
||||
* Note: Currently profiles the parent process only. For child process profiling,
|
||||
* additional Next.js changes are needed (see future PRs).
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2)
|
||||
const getArg = (name, defaultValue) => {
|
||||
const arg = args.find((a) => a.startsWith(`--${name}=`))
|
||||
return arg ? arg.split('=')[1] : defaultValue
|
||||
}
|
||||
const hasFlag = (name) => args.includes(`--${name}`)
|
||||
|
||||
const testDir = getArg('test-dir', '/private/tmp/next-boot-test')
|
||||
const baseOutputDir =
|
||||
getArg('output-dir', null) || path.join(process.cwd(), 'profiles')
|
||||
const useWebpack = hasFlag('webpack')
|
||||
const duration = parseInt(getArg('duration', '1000'), 10)
|
||||
const profileCli = hasFlag('cli')
|
||||
const bundlerFlag = useWebpack ? '--webpack' : '--turbopack'
|
||||
|
||||
// Generate meaningful profile names
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const bundlerName = useWebpack ? 'webpack' : 'turbopack'
|
||||
const profileType = profileCli ? 'cli' : 'dev'
|
||||
const outputDir = baseOutputDir
|
||||
const profileName = `${profileType}-${bundlerName}-${timestamp}`
|
||||
|
||||
const nextDir = path.join(__dirname, '..', 'packages', 'next')
|
||||
const nextBin = path.join(nextDir, 'dist/bin/next')
|
||||
|
||||
if (profileCli) {
|
||||
console.log('\x1b[34m=== Next.js CLI Entry Point Profile ===\x1b[0m')
|
||||
} else {
|
||||
console.log('\x1b[34m=== Next.js Dev Server CPU Profile ===\x1b[0m')
|
||||
console.log(`Test directory: ${testDir}`)
|
||||
console.log(`Bundler: ${useWebpack ? 'Webpack' : 'Turbopack'}`)
|
||||
}
|
||||
console.log(`Output directory: ${outputDir}`)
|
||||
console.log('')
|
||||
|
||||
// Verify test directory (only for dev server profiling)
|
||||
if (!profileCli && !fs.existsSync(testDir)) {
|
||||
console.error(
|
||||
`\x1b[31mError: Test directory does not exist: ${testDir}\x1b[0m`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Kill existing processes
|
||||
function killNextDev() {
|
||||
try {
|
||||
execSync('pkill -f "next dev"', { stdio: 'ignore' })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function runProfile() {
|
||||
killNextDev()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
// Clean .next directory
|
||||
const nextCache = path.join(testDir, '.next')
|
||||
if (fs.existsSync(nextCache)) {
|
||||
fs.rmSync(nextCache, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('Starting dev server with CPU profiling...')
|
||||
console.log('(Profile will be saved after server is ready)')
|
||||
console.log('')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false
|
||||
|
||||
// Profile the parent process with --cpu-prof
|
||||
const spawnArgs = [
|
||||
process.execPath,
|
||||
[
|
||||
'--cpu-prof',
|
||||
`--cpu-prof-dir=${outputDir}`,
|
||||
`--cpu-prof-name=${profileName}`,
|
||||
nextBin,
|
||||
'dev',
|
||||
bundlerFlag,
|
||||
],
|
||||
]
|
||||
|
||||
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
||||
cwd: testDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, FORCE_COLOR: '0' },
|
||||
})
|
||||
|
||||
let output = ''
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString()
|
||||
output += text
|
||||
process.stdout.write(text)
|
||||
|
||||
// Wait for "Ready in Xms"
|
||||
if (output.includes('Ready in') && !resolved) {
|
||||
resolved = true
|
||||
console.log('')
|
||||
console.log(
|
||||
`\x1b[33mServer ready, profiling for ${duration}ms more...\x1b[0m`
|
||||
)
|
||||
|
||||
// Wait a bit then stop
|
||||
setTimeout(() => {
|
||||
console.log('Stopping server and saving profile...')
|
||||
child.kill('SIGINT')
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.stderr.on('data', onData)
|
||||
|
||||
child.on('close', (code) => {
|
||||
killNextDev()
|
||||
|
||||
// Wait a moment for profile files to be written
|
||||
setTimeout(() => {
|
||||
// Find and rename profiles matching our name pattern
|
||||
// --cpu-prof-name creates files without extension
|
||||
const files = fs.readdirSync(outputDir)
|
||||
const rawFiles = files.filter(
|
||||
(f) => f.startsWith(profileName) && !f.endsWith('.cpuprofile')
|
||||
)
|
||||
|
||||
// Rename raw files to have .cpuprofile extension
|
||||
rawFiles.forEach((f) => {
|
||||
const oldPath = path.join(outputDir, f)
|
||||
const newPath = path.join(outputDir, `${f}.cpuprofile`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
})
|
||||
|
||||
// Now find all .cpuprofile files
|
||||
const profileFiles = fs
|
||||
.readdirSync(outputDir)
|
||||
.filter((f) => f.startsWith(profileName) && f.endsWith('.cpuprofile'))
|
||||
const profiles = profileFiles
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(outputDir, f),
|
||||
size: fs.statSync(path.join(outputDir, f)).size,
|
||||
}))
|
||||
.filter((p) => p.size > 0)
|
||||
.sort((a, b) => b.size - a.size)
|
||||
|
||||
if (profiles.length > 0) {
|
||||
console.log('')
|
||||
console.log(`\x1b[32mProfile(s) saved:\x1b[0m`)
|
||||
profiles.forEach((p, i) => {
|
||||
const sizeKB = Math.round(p.size / 1024)
|
||||
console.log(` ${i + 1}. ${p.path} (${sizeKB} KB)`)
|
||||
})
|
||||
console.log('')
|
||||
console.log('To view the profile:')
|
||||
console.log(' 1. Open Chrome DevTools -> Performance tab')
|
||||
console.log(' 2. Click "Load profile" and select the file')
|
||||
console.log(' 3. Or use https://www.speedscope.app/')
|
||||
console.log('')
|
||||
console.log(
|
||||
'\x1b[33mTip:\x1b[0m The largest profile is usually the child process (server worker)'
|
||||
)
|
||||
resolve(profiles[0].path)
|
||||
} else {
|
||||
console.log('')
|
||||
console.log(
|
||||
'\x1b[33mNo profiles found. Trying alternative method...\x1b[0m'
|
||||
)
|
||||
console.log('')
|
||||
console.log(
|
||||
'To profile the child process, modify next-dev.ts to add profiling flags.'
|
||||
)
|
||||
console.log(
|
||||
'Or use: node --cpu-prof --cpu-prof-dir=./profiles ./dist/bin/next dev'
|
||||
)
|
||||
reject(new Error('Profile file not found'))
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('Timeout waiting for server'))
|
||||
}
|
||||
}, 120000)
|
||||
})
|
||||
}
|
||||
|
||||
async function runCliProfile() {
|
||||
console.log('Profiling CLI entry point (next --help)...')
|
||||
console.log('')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--cpu-prof',
|
||||
`--cpu-prof-dir=${outputDir}`,
|
||||
`--cpu-prof-name=${profileName}`,
|
||||
nextBin,
|
||||
'--help',
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, FORCE_COLOR: '0' },
|
||||
}
|
||||
)
|
||||
|
||||
child.stdout.on('data', () => {})
|
||||
child.stderr.on('data', () => {})
|
||||
|
||||
child.on('close', (code) => {
|
||||
// Wait for profile to be written
|
||||
setTimeout(() => {
|
||||
// --cpu-prof-name creates files without extension, rename to .cpuprofile
|
||||
const rawFile = path.join(outputDir, profileName)
|
||||
const finalFile = path.join(outputDir, `${profileName}.cpuprofile`)
|
||||
|
||||
if (fs.existsSync(rawFile)) {
|
||||
fs.renameSync(rawFile, finalFile)
|
||||
const size = fs.statSync(finalFile).size
|
||||
console.log(`\x1b[32mProfile saved:\x1b[0m`)
|
||||
console.log(` ${finalFile} (${Math.round(size / 1024)} KB)`)
|
||||
console.log('')
|
||||
console.log('To view the profile:')
|
||||
console.log(' 1. Open Chrome DevTools -> Performance tab')
|
||||
console.log(' 2. Click "Load profile" and select the file')
|
||||
console.log(' 3. Or use https://www.speedscope.app/')
|
||||
console.log('')
|
||||
console.log(
|
||||
'\x1b[33mTip:\x1b[0m Look for heavy modules loaded at startup'
|
||||
)
|
||||
resolve(finalFile)
|
||||
} else if (fs.existsSync(finalFile)) {
|
||||
const size = fs.statSync(finalFile).size
|
||||
console.log(`\x1b[32mProfile saved:\x1b[0m`)
|
||||
console.log(` ${finalFile} (${Math.round(size / 1024)} KB)`)
|
||||
resolve(finalFile)
|
||||
} else {
|
||||
reject(new Error('Profile file not found'))
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const main = profileCli ? runCliProfile : runProfile
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\x1b[31mError:\x1b[0m', err.message)
|
||||
if (!profileCli) killNextDev()
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI Startup Tracer
|
||||
*
|
||||
* Uses the V8 Inspector API to trace module loading at CLI startup.
|
||||
* This helps identify which modules are being loaded eagerly.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/trace-cli-startup.js [--command=dev|build|--help]
|
||||
*/
|
||||
|
||||
const inspector = require('inspector')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const getArg = (name, defaultValue) => {
|
||||
const arg = args.find((a) => a.startsWith(`--${name}=`))
|
||||
return arg ? arg.split('=')[1] : defaultValue
|
||||
}
|
||||
|
||||
const command = getArg('command', '--help')
|
||||
const outputDir = path.join(process.cwd(), 'profiles')
|
||||
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
console.log('\x1b[34m=== Next.js CLI Startup Trace ===\x1b[0m')
|
||||
console.log(`Command: next ${command}`)
|
||||
console.log(`Output directory: ${outputDir}`)
|
||||
console.log('')
|
||||
|
||||
// Start CPU profiling
|
||||
const session = new inspector.Session()
|
||||
session.connect()
|
||||
|
||||
// Track module loading via require hook
|
||||
const Module = require('module')
|
||||
const originalRequire = Module.prototype.require
|
||||
const loadedModules = []
|
||||
const moduleLoadTimes = []
|
||||
|
||||
Module.prototype.require = function (id) {
|
||||
const start = process.hrtime.bigint()
|
||||
const result = originalRequire.apply(this, arguments)
|
||||
const end = process.hrtime.bigint()
|
||||
const durationMs = Number(end - start) / 1e6
|
||||
|
||||
// Get the resolved path
|
||||
let resolvedPath = id
|
||||
try {
|
||||
resolvedPath = require.resolve(id, { paths: [this.path || process.cwd()] })
|
||||
} catch {}
|
||||
|
||||
// Filter to show only Next.js-related modules
|
||||
if (resolvedPath.includes('next/dist/') || resolvedPath.includes('@next/')) {
|
||||
const shortPath = resolvedPath.includes('next/dist/')
|
||||
? resolvedPath.split('next/dist/')[1]
|
||||
: resolvedPath
|
||||
|
||||
if (!loadedModules.includes(shortPath)) {
|
||||
loadedModules.push(shortPath)
|
||||
moduleLoadTimes.push({ module: shortPath, time: durationMs })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Save original process.exit and intercept to prevent CLI from exiting mid-profile
|
||||
const originalExit = process.exit
|
||||
process.exit = () => {
|
||||
// Don't actually exit during profiling - we want to capture the full profile
|
||||
}
|
||||
|
||||
// Start profiling
|
||||
session.post('Profiler.enable', () => {
|
||||
session.post('Profiler.start', () => {
|
||||
console.log('Starting CLI with profiling...')
|
||||
console.log('')
|
||||
|
||||
const startTime = process.hrtime.bigint()
|
||||
|
||||
// Load the CLI
|
||||
try {
|
||||
process.argv = [process.argv[0], 'next', command]
|
||||
require('../packages/next/dist/bin/next')
|
||||
} catch (e) {
|
||||
// Expected - CLI might throw
|
||||
}
|
||||
|
||||
const endTime = process.hrtime.bigint()
|
||||
const totalMs = Number(endTime - startTime) / 1e6
|
||||
|
||||
// Stop profiling and save
|
||||
session.post('Profiler.stop', (err, { profile }) => {
|
||||
if (err) {
|
||||
console.error('Error stopping profiler:', err)
|
||||
} else {
|
||||
const profilePath = path.join(
|
||||
outputDir,
|
||||
`cli-startup-${Date.now()}.cpuprofile`
|
||||
)
|
||||
fs.writeFileSync(profilePath, JSON.stringify(profile))
|
||||
console.log(`\x1b[32mProfile saved:\x1b[0m ${profilePath}`)
|
||||
}
|
||||
|
||||
// Print results
|
||||
console.log('')
|
||||
console.log(`\x1b[32mTotal startup time:\x1b[0m ${totalMs.toFixed(2)}ms`)
|
||||
console.log('')
|
||||
console.log(`\x1b[33mModules loaded (${loadedModules.length}):\x1b[0m`)
|
||||
console.log('='.repeat(70))
|
||||
|
||||
// Sort by load time
|
||||
moduleLoadTimes.sort((a, b) => b.time - a.time)
|
||||
moduleLoadTimes.slice(0, 30).forEach((m, i) => {
|
||||
const timeStr =
|
||||
m.time > 1
|
||||
? `${m.time.toFixed(1)}ms`
|
||||
: `${(m.time * 1000).toFixed(0)}μs`
|
||||
console.log(`${String(i + 1).padStart(2)}. ${m.module} (${timeStr})`)
|
||||
})
|
||||
|
||||
console.log('')
|
||||
console.log(`\x1b[33mAll loaded modules:\x1b[0m`)
|
||||
console.log(loadedModules.join('\n'))
|
||||
|
||||
// Restore original require
|
||||
Module.prototype.require = originalRequire
|
||||
|
||||
session.disconnect()
|
||||
|
||||
// Exit cleanly now that profiling is complete
|
||||
originalExit(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user