Skip to content

Commit 4977db5

Browse files
feedthejimclaude
andcommitted
chore: add dev boot profiling and benchmark scripts
- benchmark-boot-time.sh: Wall-clock benchmark with two metrics - benchmark-next-dev-boot.js: Multi-iteration benchmark with stats - profile-next-dev-boot.js: CPU profiling infrastructure - analyze-*.js: Profile and bundle analysis tools - trace-cli-startup.js: Module loading trace 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4fa7d80 commit 4977db5

File tree

6 files changed

+1061
-0
lines changed

6 files changed

+1061
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Dev Server Bundle Analyzer
4+
*
5+
* Generates a bundle analyzer report for the dev server bundle.
6+
*
7+
* Usage:
8+
* node scripts/analyze-bundle.js [options]
9+
*
10+
* Options:
11+
* --open Open the report in browser (default: false)
12+
* --verbose Show detailed module reasons
13+
* --json Also output stats.json file
14+
* --list-modules List all bundled modules to console
15+
* --list-externals List all externalized modules
16+
*/
17+
18+
const { execSync } = require('child_process')
19+
const path = require('path')
20+
const fs = require('fs')
21+
22+
// Parse arguments
23+
const args = process.argv.slice(2)
24+
const hasFlag = (name) => args.includes(`--${name}`)
25+
26+
const openBrowser = hasFlag('open')
27+
const verbose = hasFlag('verbose')
28+
const outputJson = hasFlag('json')
29+
const listModules = hasFlag('list-modules')
30+
const listExternals = hasFlag('list-externals')
31+
32+
const nextDir = path.join(__dirname, '..', 'packages', 'next')
33+
const bundlePath = path.join(
34+
nextDir,
35+
'dist/compiled/dev-server/start-server.js'
36+
)
37+
const reportPath = path.join(
38+
nextDir,
39+
'dist/compiled/dev-server/bundle-report.html'
40+
)
41+
42+
console.log('\x1b[34m=== Dev Server Bundle Analyzer ===\x1b[0m')
43+
console.log('')
44+
45+
// Build with analyzer
46+
console.log('Building bundle with analyzer...')
47+
const env = {
48+
...process.env,
49+
ANALYZE: '1',
50+
...(verbose ? { ANALYZE_REASONS: '1' } : {}),
51+
}
52+
53+
try {
54+
execSync('npx taskr next_bundle_dev_server', {
55+
cwd: nextDir,
56+
stdio: verbose ? 'inherit' : 'pipe',
57+
env,
58+
})
59+
} catch (err) {
60+
console.error('\x1b[31mBuild failed\x1b[0m')
61+
process.exit(1)
62+
}
63+
64+
// Get bundle stats
65+
const stats = fs.statSync(bundlePath)
66+
const sizeKB = Math.round(stats.size / 1024)
67+
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
68+
69+
console.log('')
70+
console.log('\x1b[32mBundle Stats:\x1b[0m')
71+
console.log(` Size: ${sizeKB} KB (${sizeMB} MB)`)
72+
console.log(` Path: ${bundlePath}`)
73+
console.log(` Report: ${reportPath}`)
74+
console.log('')
75+
76+
// List bundled modules
77+
if (listModules) {
78+
console.log('\x1b[33mBundled Modules:\x1b[0m')
79+
const content = fs.readFileSync(bundlePath, 'utf-8')
80+
const moduleMatches = content.match(/"\.\/dist\/[^"]+/g) || []
81+
const modules = [...new Set(moduleMatches)]
82+
.map((m) => m.replace(/^"/, ''))
83+
.filter((m) => !m.includes(' recursive'))
84+
.sort()
85+
86+
modules.forEach((m) => console.log(` ${m}`))
87+
console.log(`\n Total: ${modules.length} modules`)
88+
console.log('')
89+
}
90+
91+
// List externalized modules
92+
if (listExternals) {
93+
console.log('\x1b[33mExternalized Modules:\x1b[0m')
94+
const content = fs.readFileSync(bundlePath, 'utf-8')
95+
96+
// Find external requires
97+
const externalMatches =
98+
content.match(
99+
/require\("(next\/dist\/[^"]+|@next\/[^"]+|styled-jsx[^"]*)"\)/g
100+
) || []
101+
const externals = [...new Set(externalMatches)]
102+
.map((m) => m.match(/require\("([^"]+)"\)/)[1])
103+
.sort()
104+
105+
externals.forEach((m) => console.log(` ${m}`))
106+
console.log(`\n Total: ${externals.length} external requires`)
107+
console.log('')
108+
}
109+
110+
// Output JSON stats
111+
if (outputJson) {
112+
const statsJsonPath = path.join(
113+
nextDir,
114+
'dist/compiled/dev-server/stats.json'
115+
)
116+
console.log(`Stats JSON: ${statsJsonPath}`)
117+
console.log('(Run with ANALYZE_REASONS=1 for detailed stats)')
118+
}
119+
120+
// Open in browser
121+
if (openBrowser) {
122+
console.log('Opening report in browser...')
123+
const opener =
124+
process.platform === 'darwin'
125+
? 'open'
126+
: process.platform === 'win32'
127+
? 'start'
128+
: 'xdg-open'
129+
try {
130+
execSync(`${opener} "${reportPath}"`, { stdio: 'ignore' })
131+
} catch {
132+
console.log(`Could not open browser. Open manually: ${reportPath}`)
133+
}
134+
}
135+
136+
console.log('\x1b[32mDone!\x1b[0m')
137+
console.log('')
138+
console.log('Tips:')
139+
console.log(' - Open the HTML report to see interactive treemap')
140+
console.log(' - Use --list-modules to see all bundled modules')
141+
console.log(' - Use --list-externals to see external requires')
142+
console.log(' - Use --verbose for detailed build output')

scripts/analyze-profile.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Analyze a CPU profile to identify hot modules
4+
*/
5+
6+
const fs = require('fs')
7+
8+
const profilePath = process.argv[2]
9+
if (!profilePath) {
10+
console.error('Usage: node analyze-profile.js <profile.cpuprofile>')
11+
process.exit(1)
12+
}
13+
14+
const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'))
15+
16+
// Extract nodes with their hit counts
17+
const nodes = profile.nodes || []
18+
19+
// Group by file/module
20+
const moduleHits = {}
21+
nodes.forEach((node) => {
22+
const fn = node.callFrame
23+
if (fn && fn.url) {
24+
const url = fn.url
25+
// Extract module name from path
26+
let moduleName = url
27+
if (url.includes('next/dist/')) {
28+
moduleName = url.split('next/dist/')[1]
29+
} else if (url.includes('node_modules/')) {
30+
moduleName = 'node_modules/' + url.split('node_modules/').pop()
31+
}
32+
if (!moduleHits[moduleName]) {
33+
moduleHits[moduleName] = { hits: 0 }
34+
}
35+
moduleHits[moduleName].hits += node.hitCount || 0
36+
}
37+
})
38+
39+
// Sort by hits
40+
const sorted = Object.entries(moduleHits)
41+
.filter(([_, v]) => v.hits > 0)
42+
.sort((a, b) => b[1].hits - a[1].hits)
43+
.slice(0, 40)
44+
45+
console.log('Top 40 modules by CPU time:')
46+
console.log('='.repeat(70))
47+
sorted.forEach(([name, data], i) => {
48+
console.log(`${String(i + 1).padStart(2)}. ${name} (${data.hits} hits)`)
49+
})

scripts/benchmark-boot-time.sh

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/bin/bash
2+
#
3+
# Benchmark dev server boot time (wall-clock)
4+
#
5+
# Measures TWO metrics:
6+
# 1. listen_time: When server starts accepting TCP connections
7+
# 2. ready_time: When server responds to first HTTP request
8+
#
9+
# The delta between these shows how much initialization is deferred after "Ready".
10+
#
11+
# Usage:
12+
# ./scripts/benchmark-boot-time.sh [runs] [test-dir]
13+
#
14+
# Examples:
15+
# ./scripts/benchmark-boot-time.sh # 5 runs, uses /tmp/next-boot-test
16+
# ./scripts/benchmark-boot-time.sh 3 # 3 runs
17+
# ./scripts/benchmark-boot-time.sh 5 ./my-app # 5 runs on existing app
18+
19+
set -e
20+
21+
RUNS=${1:-5}
22+
TEST_DIR=${2:-/tmp/next-boot-test}
23+
NEXT_BIN="$(dirname "$0")/../packages/next/dist/bin/next"
24+
PORT=3456
25+
26+
echo "=== Dev Server Boot Time Benchmark ==="
27+
echo "Runs: $RUNS"
28+
echo "Test dir: $TEST_DIR"
29+
echo "Next.js: $NEXT_BIN"
30+
echo ""
31+
echo "Metrics:"
32+
echo " listen_time: TCP port accepting connections"
33+
echo " ready_time: First HTTP request succeeds"
34+
echo " delta: ready_time - listen_time (deferred init)"
35+
echo ""
36+
37+
# Create test app if it doesn't exist
38+
if [ ! -f "$TEST_DIR/package.json" ]; then
39+
echo "Creating test app..."
40+
mkdir -p "$TEST_DIR/app"
41+
cat > "$TEST_DIR/package.json" << 'EOF'
42+
{
43+
"name": "boot-test",
44+
"private": true,
45+
"dependencies": {
46+
"react": "19.0.0",
47+
"react-dom": "19.0.0"
48+
}
49+
}
50+
EOF
51+
cat > "$TEST_DIR/app/layout.tsx" << 'EOF'
52+
export default function RootLayout({ children }: { children: React.ReactNode }) {
53+
return <html><body>{children}</body></html>
54+
}
55+
EOF
56+
cat > "$TEST_DIR/app/page.tsx" << 'EOF'
57+
export default function Home() { return <h1>Hello</h1> }
58+
EOF
59+
(cd "$TEST_DIR" && npm install --silent)
60+
# Link local next
61+
(cd "$TEST_DIR" && npm link "$(dirname "$NEXT_BIN")/.." 2>/dev/null || true)
62+
fi
63+
64+
# Kill any existing next dev on our port
65+
pkill -f "next dev.*$PORT" 2>/dev/null || true
66+
sleep 0.5
67+
68+
# Returns: listen_time,ready_time (comma-separated)
69+
benchmark_run() {
70+
local label=$1
71+
local clean_next=$2
72+
73+
if [ "$clean_next" = "true" ]; then
74+
rm -rf "$TEST_DIR/.next"
75+
fi
76+
77+
# Measure wall-clock time from command start
78+
local start_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
79+
80+
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
81+
local pid=$!
82+
83+
local timeout=600 # 30s at 50ms intervals
84+
local listen_time=""
85+
local ready_time=""
86+
87+
# Phase 1: Wait for port to be listening (nc -z)
88+
for i in $(seq 1 $timeout); do
89+
if nc -z localhost $PORT 2>/dev/null; then
90+
listen_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
91+
break
92+
fi
93+
sleep 0.05
94+
done
95+
96+
# Phase 2: Wait for HTTP response (curl)
97+
if [ -n "$listen_time" ]; then
98+
for i in $(seq 1 $timeout); do
99+
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
100+
ready_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
101+
break
102+
fi
103+
sleep 0.05
104+
done
105+
fi
106+
107+
# Kill the server
108+
kill $pid 2>/dev/null || true
109+
wait $pid 2>/dev/null || true
110+
111+
if [ -n "$listen_time" ] && [ -n "$ready_time" ]; then
112+
local listen_delta=$((listen_time - start_time))
113+
local ready_delta=$((ready_time - start_time))
114+
echo "$listen_delta,$ready_delta"
115+
else
116+
echo "TIMEOUT,TIMEOUT"
117+
fi
118+
}
119+
120+
run_benchmark_series() {
121+
local series_name=$1
122+
local clean_next=$2
123+
124+
echo "--- $series_name ---"
125+
echo "Run | Listen | Ready | Delta"
126+
echo "----|--------|-------|------"
127+
128+
local listen_times=""
129+
local ready_times=""
130+
local deltas=""
131+
132+
for i in $(seq 1 $RUNS); do
133+
RESULT=$(benchmark_run "$series_name-$i" "$clean_next")
134+
LISTEN=$(echo "$RESULT" | cut -d',' -f1)
135+
READY=$(echo "$RESULT" | cut -d',' -f2)
136+
137+
if [ "$LISTEN" != "TIMEOUT" ] && [ "$READY" != "TIMEOUT" ]; then
138+
DELTA=$((READY - LISTEN))
139+
printf "%3d | %5dms | %5dms | %5dms\n" "$i" "$LISTEN" "$READY" "$DELTA"
140+
listen_times="$listen_times $LISTEN"
141+
ready_times="$ready_times $READY"
142+
deltas="$deltas $DELTA"
143+
else
144+
printf "%3d | TIMEOUT | TIMEOUT | -\n" "$i"
145+
fi
146+
done
147+
148+
# Calculate averages
149+
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"}')
150+
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"}')
151+
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"}')
152+
153+
echo ""
154+
echo "Average: listen=${listen_avg}ms, ready=${ready_avg}ms, delta=${delta_avg}ms"
155+
echo ""
156+
157+
# Export for summary
158+
export "${series_name}_LISTEN_AVG=$listen_avg"
159+
export "${series_name}_READY_AVG=$ready_avg"
160+
export "${series_name}_DELTA_AVG=$delta_avg"
161+
}
162+
163+
# Run cold start benchmarks
164+
run_benchmark_series "COLD" true
165+
166+
# Warmup for bytecode cache
167+
echo "--- Warming up bytecode cache (12s) ---"
168+
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
169+
WARMUP_PID=$!
170+
for i in $(seq 1 200); do
171+
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
172+
break
173+
fi
174+
sleep 0.05
175+
done
176+
sleep 12
177+
kill $WARMUP_PID 2>/dev/null || true
178+
wait $WARMUP_PID 2>/dev/null || true
179+
echo ""
180+
181+
# Run warm start benchmarks
182+
run_benchmark_series "WARM" false
183+
184+
# Summary
185+
echo "=============================================="
186+
echo " SUMMARY"
187+
echo "=============================================="
188+
echo ""
189+
echo "Cold Start ($RUNS runs):"
190+
echo " Port listening: ${COLD_LISTEN_AVG}ms"
191+
echo " First request: ${COLD_READY_AVG}ms"
192+
echo " Deferred init: ${COLD_DELTA_AVG}ms"
193+
echo ""
194+
echo "Warm Start ($RUNS runs):"
195+
echo " Port listening: ${WARM_LISTEN_AVG}ms"
196+
echo " First request: ${WARM_READY_AVG}ms"
197+
echo " Deferred init: ${WARM_DELTA_AVG}ms"
198+
echo ""
199+
200+
if [ "$COLD_READY_AVG" != "N/A" ] && [ "$WARM_READY_AVG" != "N/A" ]; then
201+
CACHE_BENEFIT=$((COLD_READY_AVG - WARM_READY_AVG))
202+
echo "Cache benefit: ${CACHE_BENEFIT}ms (cold - warm ready)"
203+
fi

0 commit comments

Comments
 (0)