cloudflare/vinext
Publicmirrored from https://github.com/cloudflare/vinextAvailable
benchmarks/run.mjs
616lines · modecode
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Benchmark harness: compares Next.js 16 (Turbopack) vs vinext (Vite 7/Rollup) vs vinext (Vite 8/Rolldown) |
| 4 | * |
| 5 | * Metrics: |
| 6 | * 1. Production build time (hyperfine) |
| 7 | * 2. Production bundle size (total JS+CSS, gzipped) |
| 8 | * 3. Dev server cold start (time to first successful HTTP 200) |
| 9 | * 4. SSR throughput & TTFB (autocannon against production server) |
| 10 | * 5. Memory usage (peak RSS during build and dev) |
| 11 | * |
| 12 | * Prerequisites: hyperfine, autocannon (npm i -g autocannon) |
| 13 | * Usage: node benchmarks/run.mjs [--runs N] [--dev-runs N] [--skip-build] [--skip-dev] [--skip-ssr] [--skip-rolldown] |
| 14 | */ |
| 15 | |
| 16 | import { execSync, spawn } from "node:child_process"; |
| 17 | import { existsSync, readdirSync, statSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; |
| 18 | import { join, dirname } from "node:path"; |
| 19 | import { fileURLToPath } from "node:url"; |
| 20 | import { gzipSync } from "node:zlib"; |
| 21 | |
| 22 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 23 | const RESULTS_DIR = join(__dirname, "results"); |
| 24 | mkdirSync(RESULTS_DIR, { recursive: true }); |
| 25 | |
| 26 | // Disable Next.js telemetry to avoid non-deterministic network latency in benchmarks. |
| 27 | process.env.NEXT_TELEMETRY_DISABLED = "1"; |
| 28 | |
| 29 | // ─── CLI args ────────────────────────────────────────────────────────────────── |
| 30 | const args = process.argv.slice(2); |
| 31 | const RUNS = parseInt(args.find((a) => a.startsWith("--runs="))?.split("=")[1] ?? "5", 10); |
| 32 | const DEV_RUNS = parseInt(args.find((a) => a.startsWith("--dev-runs="))?.split("=")[1] ?? "10", 10); |
| 33 | const SKIP_BUILD = args.includes("--skip-build"); |
| 34 | const SKIP_DEV = args.includes("--skip-dev"); |
| 35 | const SKIP_SSR = args.includes("--skip-ssr"); |
| 36 | const SKIP_ROLLDOWN = args.includes("--skip-rolldown"); |
| 37 | |
| 38 | // ─── Helpers ─────────────────────────────────────────────────────────────────── |
| 39 | function exec(cmd, opts = {}) { |
| 40 | console.log(` $ ${cmd}`); |
| 41 | return execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts }); |
| 42 | } |
| 43 | |
| 44 | function getGitHash() { |
| 45 | try { |
| 46 | return execSync("git rev-parse --short HEAD", { encoding: "utf-8", cwd: join(__dirname, "..") }).trim(); |
| 47 | } catch { |
| 48 | return "unknown"; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Calculate total size of JS and CSS files in a directory (recursively). |
| 54 | * Returns { raw: bytes, gzip: bytes, files: number } |
| 55 | */ |
| 56 | function bundleSize(dir) { |
| 57 | let raw = 0; |
| 58 | let gzip = 0; |
| 59 | let files = 0; |
| 60 | |
| 61 | function walk(d) { |
| 62 | if (!existsSync(d)) return; |
| 63 | for (const entry of readdirSync(d)) { |
| 64 | const full = join(d, entry); |
| 65 | const stat = statSync(full); |
| 66 | if (stat.isDirectory()) { |
| 67 | walk(full); |
| 68 | } else if (/\.(js|css|mjs)$/.test(entry)) { |
| 69 | const content = readFileSync(full); |
| 70 | raw += content.length; |
| 71 | gzip += gzipSync(content).length; |
| 72 | files++; |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | walk(dir); |
| 78 | return { raw, gzip, files }; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Wait for a URL to return HTTP 200. Returns time in ms. |
| 83 | */ |
| 84 | async function waitForServer(url, timeoutMs = 60000) { |
| 85 | const start = performance.now(); |
| 86 | const deadline = start + timeoutMs; |
| 87 | |
| 88 | while (performance.now() < deadline) { |
| 89 | try { |
| 90 | const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); |
| 91 | if (res.ok) return performance.now() - start; |
| 92 | } catch { |
| 93 | // not ready yet |
| 94 | } |
| 95 | await new Promise((r) => setTimeout(r, 200)); |
| 96 | } |
| 97 | throw new Error(`Server at ${url} did not respond within ${timeoutMs}ms`); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Sum RSS (in KB) for an entire process group. |
| 102 | * The process was spawned with detached:true so proc.pid is the PGID. |
| 103 | * On Linux, `pgrep -g` enumerates PIDs in the process group reliably. |
| 104 | * On macOS, `pgrep -g` matches by GID (not PGID), so instead we list all |
| 105 | * processes with `ps -axo pid=,pgid=,rss=` and sum RSS for rows whose PGID |
| 106 | * matches the leader PID. |
| 107 | */ |
| 108 | function getGroupRssKb(pid) { |
| 109 | try { |
| 110 | let out; |
| 111 | if (process.platform === "linux") { |
| 112 | const pidsRaw = execSync(`pgrep -g ${pid}`, { encoding: "utf-8" }).trim(); |
| 113 | if (!pidsRaw) return 0; |
| 114 | const pidList = pidsRaw.split("\n").join(","); |
| 115 | out = execSync(`ps -o rss= -p ${pidList}`, { encoding: "utf-8" }); |
| 116 | return out |
| 117 | .trim() |
| 118 | .split("\n") |
| 119 | .reduce((sum, line) => sum + (parseInt(line.trim(), 10) || 0), 0); |
| 120 | } else { |
| 121 | // macOS: enumerate all processes and sum RSS for those in our PGID |
| 122 | out = execSync(`ps -axo pid=,pgid=,rss=`, { encoding: "utf-8" }); |
| 123 | const pgid = String(pid); |
| 124 | return out |
| 125 | .trim() |
| 126 | .split("\n") |
| 127 | .reduce((sum, line) => { |
| 128 | const parts = line.trim().split(/\s+/); |
| 129 | if (parts.length >= 3 && parts[1] === pgid) { |
| 130 | return sum + (parseInt(parts[2], 10) || 0); |
| 131 | } |
| 132 | return sum; |
| 133 | }, 0); |
| 134 | } |
| 135 | } catch { |
| 136 | return 0; |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Start a process and measure cold start time + peak RSS. |
| 142 | * Returns { coldStartMs, peakRssKb, process } |
| 143 | */ |
| 144 | async function startAndMeasure(cmd, args, cwd, url) { |
| 145 | let peakRssKb = 0; |
| 146 | |
| 147 | const proc = spawn(cmd, args, { |
| 148 | cwd, |
| 149 | stdio: ["pipe", "pipe", "pipe"], |
| 150 | detached: true, // Own process group so we can kill the tree safely |
| 151 | env: { ...process.env, PORT: new URL(url).port }, |
| 152 | }); |
| 153 | |
| 154 | // Collect stderr/stdout for debugging |
| 155 | let output = ""; |
| 156 | proc.stdout?.on("data", (d) => (output += d.toString())); |
| 157 | proc.stderr?.on("data", (d) => (output += d.toString())); |
| 158 | |
| 159 | // Poll RSS every 200ms (sum across process group on Linux) |
| 160 | const rssInterval = setInterval(() => { |
| 161 | const rss = getGroupRssKb(proc.pid); |
| 162 | if (rss > peakRssKb) peakRssKb = rss; |
| 163 | }, 200); |
| 164 | |
| 165 | try { |
| 166 | const coldStartMs = await waitForServer(url, 60000); |
| 167 | clearInterval(rssInterval); |
| 168 | |
| 169 | // Final RSS |
| 170 | const rss = getGroupRssKb(proc.pid); |
| 171 | if (rss > peakRssKb) peakRssKb = rss; |
| 172 | |
| 173 | return { coldStartMs, peakRssKb, process: proc, output }; |
| 174 | } catch (err) { |
| 175 | clearInterval(rssInterval); |
| 176 | kill(proc); |
| 177 | console.error("Server output:", output); |
| 178 | throw err; |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | function kill(proc) { |
| 183 | if (!proc || proc.killed) return; |
| 184 | // The process was spawned with detached:true so it has its own process group. |
| 185 | // Kill the entire group with -PID (negative) which is safe because it won't |
| 186 | // include our own script. |
| 187 | try { |
| 188 | process.kill(-proc.pid, "SIGKILL"); |
| 189 | } catch {} |
| 190 | } |
| 191 | |
| 192 | // Fisher-Yates shuffle — used to randomize runner order and eliminate positional bias. |
| 193 | function shuffle(arr) { |
| 194 | const a = [...arr]; |
| 195 | for (let i = a.length - 1; i > 0; i--) { |
| 196 | const j = Math.floor(Math.random() * (i + 1)); |
| 197 | [a[i], a[j]] = [a[j], a[i]]; |
| 198 | } |
| 199 | return a; |
| 200 | } |
| 201 | |
| 202 | function formatBytes(b) { |
| 203 | if (b < 1024) return `${b} B`; |
| 204 | if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; |
| 205 | return `${(b / (1024 * 1024)).toFixed(2)} MB`; |
| 206 | } |
| 207 | |
| 208 | function formatMs(ms) { |
| 209 | if (ms < 1000) return `${Math.round(ms)} ms`; |
| 210 | return `${(ms / 1000).toFixed(2)} s`; |
| 211 | } |
| 212 | |
| 213 | // ─── Main ────────────────────────────────────────────────────────────────────── |
| 214 | async function main() { |
| 215 | const results = { |
| 216 | timestamp: new Date().toISOString(), |
| 217 | gitHash: getGitHash(), |
| 218 | buildRuns: RUNS, |
| 219 | devRuns: DEV_RUNS, |
| 220 | system: { |
| 221 | platform: process.platform, |
| 222 | arch: process.arch, |
| 223 | nodeVersion: process.version, |
| 224 | cpus: (await import("node:os")).cpus().length, |
| 225 | }, |
| 226 | nextjs: {}, |
| 227 | vinext: {}, |
| 228 | vinextRolldown: {}, |
| 229 | }; |
| 230 | |
| 231 | const nextjsDir = join(__dirname, "nextjs"); |
| 232 | const vinextDir = join(__dirname, "vinext"); |
| 233 | const vinextRolldownDir = join(__dirname, "vinext-rolldown"); |
| 234 | const hasRolldown = !SKIP_ROLLDOWN && existsSync(join(vinextRolldownDir, "package.json")); |
| 235 | |
| 236 | // Detect actual installed versions for reproducibility |
| 237 | try { |
| 238 | const njsPkg = JSON.parse(readFileSync(join(nextjsDir, "node_modules", "next", "package.json"), "utf-8")); |
| 239 | results.system.nextjsVersion = njsPkg.version; |
| 240 | } catch { /* deps not installed yet */ } |
| 241 | try { |
| 242 | const vitePkg = JSON.parse(readFileSync(join(vinextDir, "node_modules", "vite", "package.json"), "utf-8")); |
| 243 | results.system.viteVersion = vitePkg.version; |
| 244 | } catch { /* deps not installed yet */ } |
| 245 | if (hasRolldown) { |
| 246 | try { |
| 247 | const rdPkg = JSON.parse(readFileSync(join(vinextRolldownDir, "node_modules", "vite", "package.json"), "utf-8")); |
| 248 | results.system.viteRolldownVersion = rdPkg.version; |
| 249 | } catch { /* deps not installed yet */ } |
| 250 | } |
| 251 | |
| 252 | // ─── 1. Production Build Time ────────────────────────────────────────────── |
| 253 | if (!SKIP_BUILD) { |
| 254 | console.log("\n=== Production Build Time ===\n"); |
| 255 | |
| 256 | // Clean previous builds |
| 257 | exec("rm -rf .next", { cwd: nextjsDir }); |
| 258 | exec("rm -rf dist", { cwd: vinextDir }); |
| 259 | if (hasRolldown) exec("rm -rf dist", { cwd: vinextRolldownDir }); |
| 260 | |
| 261 | // Ensure plugin is built |
| 262 | console.log(" Building vinext plugin..."); |
| 263 | exec("./node_modules/.bin/tsc -p packages/vinext/tsconfig.json", { cwd: join(__dirname, "..") }); |
| 264 | |
| 265 | // Warmup run for all (not measured) |
| 266 | console.log(" Warmup: Next.js build..."); |
| 267 | exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 }); |
| 268 | exec("rm -rf .next", { cwd: nextjsDir }); |
| 269 | |
| 270 | console.log(" Warmup: vinext (Rollup) build..."); |
| 271 | exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 }); |
| 272 | exec("rm -rf dist", { cwd: vinextDir }); |
| 273 | |
| 274 | if (hasRolldown) { |
| 275 | console.log(" Warmup: vinext (Rolldown) build..."); |
| 276 | exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 }); |
| 277 | exec("rm -rf dist", { cwd: vinextRolldownDir }); |
| 278 | } |
| 279 | |
| 280 | // Measured runs with hyperfine (single invocation with --shuffle for fair ordering) |
| 281 | console.log(`\n Running ${RUNS} build iterations with hyperfine (randomized order)...\n`); |
| 282 | |
| 283 | function parseHyperfineResult(r) { |
| 284 | return { |
| 285 | mean: r.mean * 1000, |
| 286 | stddev: r.stddev * 1000, |
| 287 | min: r.min * 1000, |
| 288 | max: r.max * 1000, |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | try { |
| 293 | // Build a single hyperfine command with all projects and --shuffle so |
| 294 | // runs are interleaved randomly, eliminating positional bias from |
| 295 | // warmed filesystem caches, CPU thermal state, or residual process state. |
| 296 | const cmds = [ |
| 297 | `--command-name nextjs 'rm -rf ${nextjsDir}/.next && ./node_modules/.bin/next build --turbopack'`, |
| 298 | `--command-name vinext 'rm -rf ${vinextDir}/dist && ./node_modules/.bin/vite build --root ${vinextDir}'`, |
| 299 | ]; |
| 300 | if (hasRolldown) { |
| 301 | cmds.push( |
| 302 | `--command-name rolldown 'rm -rf ${vinextRolldownDir}/dist && ./node_modules/.bin/vite build --root ${vinextRolldownDir}'` |
| 303 | ); |
| 304 | } |
| 305 | |
| 306 | console.log(" Timing all builds (shuffled)..."); |
| 307 | const hfJson = exec( |
| 308 | `hyperfine --runs ${RUNS} --shuffle ${cmds.join(" ")} --export-json /dev/stdout 2>/dev/null`, |
| 309 | { cwd: __dirname, timeout: 600000 } |
| 310 | ); |
| 311 | const hf = JSON.parse(hfJson); |
| 312 | |
| 313 | // Map results by command name |
| 314 | for (const r of hf.results) { |
| 315 | if (r.command.includes("next build")) { |
| 316 | results.nextjs.buildTime = parseHyperfineResult(r); |
| 317 | } else if (r.command.includes(vinextDir)) { |
| 318 | results.vinext.buildTime = parseHyperfineResult(r); |
| 319 | } else if (hasRolldown && r.command.includes(vinextRolldownDir)) { |
| 320 | results.vinextRolldown.buildTime = parseHyperfineResult(r); |
| 321 | } |
| 322 | } |
| 323 | results.buildMethodology = "hyperfine --shuffle (randomized)"; |
| 324 | } catch { |
| 325 | // Fallback: manual timing with randomized runner order to eliminate positional bias |
| 326 | console.log(" hyperfine failed, falling back to manual timing..."); |
| 327 | const buildTimes = { nextjs: [], vinext: [], rolldown: [] }; |
| 328 | |
| 329 | const buildRunners = [ |
| 330 | { |
| 331 | key: "nextjs", |
| 332 | run: () => { |
| 333 | exec("rm -rf .next", { cwd: nextjsDir }); |
| 334 | const start = performance.now(); |
| 335 | exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 }); |
| 336 | buildTimes.nextjs.push(performance.now() - start); |
| 337 | }, |
| 338 | }, |
| 339 | { |
| 340 | key: "vinext", |
| 341 | run: () => { |
| 342 | exec("rm -rf dist", { cwd: vinextDir }); |
| 343 | const start = performance.now(); |
| 344 | exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 }); |
| 345 | buildTimes.vinext.push(performance.now() - start); |
| 346 | }, |
| 347 | }, |
| 348 | ...(hasRolldown |
| 349 | ? [{ |
| 350 | key: "rolldown", |
| 351 | run: () => { |
| 352 | exec("rm -rf dist", { cwd: vinextRolldownDir }); |
| 353 | const start = performance.now(); |
| 354 | exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 }); |
| 355 | buildTimes.rolldown.push(performance.now() - start); |
| 356 | }, |
| 357 | }] |
| 358 | : []), |
| 359 | ]; |
| 360 | |
| 361 | const buildRunOrders = []; |
| 362 | for (let i = 0; i < RUNS; i++) { |
| 363 | const order = shuffle(buildRunners); |
| 364 | buildRunOrders.push(order.map((r) => r.key)); |
| 365 | console.log(` Run ${i + 1}/${RUNS} (order: ${order.map((r) => r.key).join(" → ")})...`); |
| 366 | for (const runner of order) runner.run(); |
| 367 | } |
| 368 | |
| 369 | const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length; |
| 370 | const stddev = (arr) => { |
| 371 | if (arr.length < 2) return 0; |
| 372 | const m = avg(arr); |
| 373 | return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1)); |
| 374 | }; |
| 375 | |
| 376 | results.nextjs.buildTime = { |
| 377 | mean: avg(buildTimes.nextjs), |
| 378 | stddev: stddev(buildTimes.nextjs), |
| 379 | min: Math.min(...buildTimes.nextjs), |
| 380 | max: Math.max(...buildTimes.nextjs), |
| 381 | }; |
| 382 | results.vinext.buildTime = { |
| 383 | mean: avg(buildTimes.vinext), |
| 384 | stddev: stddev(buildTimes.vinext), |
| 385 | min: Math.min(...buildTimes.vinext), |
| 386 | max: Math.max(...buildTimes.vinext), |
| 387 | }; |
| 388 | if (hasRolldown && buildTimes.rolldown.length) { |
| 389 | results.vinextRolldown.buildTime = { |
| 390 | mean: avg(buildTimes.rolldown), |
| 391 | stddev: stddev(buildTimes.rolldown), |
| 392 | min: Math.min(...buildTimes.rolldown), |
| 393 | max: Math.max(...buildTimes.rolldown), |
| 394 | }; |
| 395 | } |
| 396 | results.buildMethodology = "manual timing (randomized)"; |
| 397 | results.buildRunOrders = buildRunOrders; |
| 398 | } |
| 399 | |
| 400 | // ─── 2. Bundle Size ──────────────────────────────────────────────────────── |
| 401 | // Measures client-side JS/CSS/MJS files only. Both directories (.next/static |
| 402 | // and dist/client) contain only browser assets, not server bundles. |
| 403 | // TODO: If the benchmark app ever uses CSS Modules or global CSS imports, |
| 404 | // verify that both frameworks extract CSS to the measured directories. |
| 405 | // Currently the app uses inline styles so CSS extraction is not a factor. |
| 406 | console.log("\n=== Production Bundle Size ===\n"); |
| 407 | |
| 408 | // Rebuild both to get fresh output |
| 409 | exec("rm -rf .next", { cwd: nextjsDir }); |
| 410 | exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 }); |
| 411 | |
| 412 | exec("rm -rf dist", { cwd: vinextDir }); |
| 413 | exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 }); |
| 414 | |
| 415 | // Next.js: client bundles are in .next/static |
| 416 | const njsSize = bundleSize(join(nextjsDir, ".next", "static")); |
| 417 | results.nextjs.bundleSize = njsSize; |
| 418 | console.log(` Next.js: ${njsSize.files} files, ${formatBytes(njsSize.raw)} raw, ${formatBytes(njsSize.gzip)} gzip`); |
| 419 | |
| 420 | // vinext (Rollup): client bundles are in dist/client |
| 421 | const ncSize = bundleSize(join(vinextDir, "dist", "client")); |
| 422 | results.vinext.bundleSize = ncSize; |
| 423 | console.log(` vinext (Rollup): ${ncSize.files} files, ${formatBytes(ncSize.raw)} raw, ${formatBytes(ncSize.gzip)} gzip`); |
| 424 | |
| 425 | // vinext (Rolldown): client bundles are in dist/client |
| 426 | if (hasRolldown) { |
| 427 | exec("rm -rf dist", { cwd: vinextRolldownDir }); |
| 428 | exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 }); |
| 429 | const rdSize = bundleSize(join(vinextRolldownDir, "dist", "client")); |
| 430 | results.vinextRolldown.bundleSize = rdSize; |
| 431 | console.log(` vinext (Rolldown): ${rdSize.files} files, ${formatBytes(rdSize.raw)} raw, ${formatBytes(rdSize.gzip)} gzip`); |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | // ─── 3. Dev Server Cold Start ────────────────────────────────────────────── |
| 436 | if (!SKIP_DEV) { |
| 437 | console.log("\n=== Dev Server Cold Start ===\n"); |
| 438 | |
| 439 | const devResults = { nextjs: [], vinext: [], rolldown: [] }; |
| 440 | |
| 441 | // Define the runners; order is randomized each iteration to eliminate |
| 442 | // positional bias (first-after-kill penalties, OS cache warming, etc.) |
| 443 | const runners = [ |
| 444 | { |
| 445 | key: "nextjs", |
| 446 | label: "Next.js", |
| 447 | run: async () => { |
| 448 | exec("rm -rf .next", { cwd: nextjsDir }); |
| 449 | return startAndMeasure("./node_modules/.bin/next", ["dev", "--turbopack", "-p", "4100"], nextjsDir, "http://localhost:4100"); |
| 450 | }, |
| 451 | }, |
| 452 | { |
| 453 | key: "vinext", |
| 454 | label: "vinext (Rollup)", |
| 455 | run: async () => { |
| 456 | exec("rm -rf node_modules/.vite", { cwd: vinextDir }); |
| 457 | return startAndMeasure("./node_modules/.bin/vite", ["--port", "4101"], vinextDir, "http://localhost:4101"); |
| 458 | }, |
| 459 | }, |
| 460 | ...(hasRolldown |
| 461 | ? [ |
| 462 | { |
| 463 | key: "rolldown", |
| 464 | label: "vinext (Rolldown)", |
| 465 | run: async () => { |
| 466 | exec("rm -rf node_modules/.vite", { cwd: vinextRolldownDir }); |
| 467 | return startAndMeasure("./node_modules/.bin/vite", ["--port", "4102"], vinextRolldownDir, "http://localhost:4102"); |
| 468 | }, |
| 469 | }, |
| 470 | ] |
| 471 | : []), |
| 472 | ]; |
| 473 | |
| 474 | const runOrders = []; |
| 475 | |
| 476 | for (let i = 0; i < DEV_RUNS; i++) { |
| 477 | const order = shuffle(runners); |
| 478 | const orderLabels = order.map((r) => r.label); |
| 479 | runOrders.push(orderLabels); |
| 480 | console.log(` Run ${i + 1}/${DEV_RUNS} (order: ${orderLabels.join(" → ")})...`); |
| 481 | |
| 482 | for (const runner of order) { |
| 483 | console.log(` Starting ${runner.label} dev server...`); |
| 484 | const result = await runner.run(); |
| 485 | devResults[runner.key].push({ coldStartMs: result.coldStartMs, peakRssKb: result.peakRssKb }); |
| 486 | console.log(` ${runner.label}: ${formatMs(result.coldStartMs)}, ${Math.round(result.peakRssKb / 1024)} MB RSS`); |
| 487 | kill(result.process); |
| 488 | await new Promise((r) => setTimeout(r, 2000)); // cooldown |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length; |
| 493 | |
| 494 | results.nextjs.devColdStart = { |
| 495 | meanMs: avg(devResults.nextjs.map((r) => r.coldStartMs)), |
| 496 | meanRssKb: avg(devResults.nextjs.map((r) => r.peakRssKb)), |
| 497 | runs: devResults.nextjs, |
| 498 | }; |
| 499 | results.vinext.devColdStart = { |
| 500 | meanMs: avg(devResults.vinext.map((r) => r.coldStartMs)), |
| 501 | meanRssKb: avg(devResults.vinext.map((r) => r.peakRssKb)), |
| 502 | runs: devResults.vinext, |
| 503 | }; |
| 504 | if (hasRolldown && devResults.rolldown.length) { |
| 505 | results.vinextRolldown.devColdStart = { |
| 506 | meanMs: avg(devResults.rolldown.map((r) => r.coldStartMs)), |
| 507 | meanRssKb: avg(devResults.rolldown.map((r) => r.peakRssKb)), |
| 508 | runs: devResults.rolldown, |
| 509 | }; |
| 510 | } |
| 511 | results.devRunOrders = runOrders; |
| 512 | } |
| 513 | |
| 514 | // ─── 4. SSR Throughput & TTFB ────────────────────────────────────────────── |
| 515 | // TODO: Requires wiring up vinext production server (startProdServer) |
| 516 | // to serve dynamic SSR pages, not just static preview. Skipped for now. |
| 517 | if (!SKIP_SSR) { |
| 518 | console.log("\n=== SSR Throughput & TTFB ===\n"); |
| 519 | console.log(" Skipped — vinext production server not yet wired for benchmark app."); |
| 520 | console.log(" Next.js `next start` does SSR; vinext `vite preview` only serves static."); |
| 521 | console.log(" This will be added once the prod server integration is complete.\n"); |
| 522 | } |
| 523 | |
| 524 | // ─── Output Results ──────────────────────────────────────────────────────── |
| 525 | console.log("\n=== Results ===\n"); |
| 526 | |
| 527 | // Save JSON |
| 528 | const jsonFile = join(RESULTS_DIR, `bench-${results.gitHash}-${Date.now()}.json`); |
| 529 | writeFileSync(jsonFile, JSON.stringify(results, null, 2)); |
| 530 | console.log(` JSON: ${jsonFile}\n`); |
| 531 | |
| 532 | // Generate markdown summary |
| 533 | let md = `# Benchmark Results\n\n`; |
| 534 | md += `- **Date**: ${results.timestamp}\n`; |
| 535 | md += `- **Git**: ${results.gitHash}\n`; |
| 536 | md += `- **Node**: ${results.system.nodeVersion}\n`; |
| 537 | md += `- **CPUs**: ${results.system.cpus}\n`; |
| 538 | md += `- **Build runs**: ${results.buildRuns}\n`; |
| 539 | md += `- **Dev cold start runs**: ${results.devRuns}\n`; |
| 540 | if (results.system.nextjsVersion) md += `- **Next.js**: ${results.system.nextjsVersion}\n`; |
| 541 | if (results.system.viteVersion) md += `- **Vite (Rollup)**: ${results.system.viteVersion}\n`; |
| 542 | if (results.system.viteRolldownVersion) md += `- **Vite (Rolldown)**: ${results.system.viteRolldownVersion}\n`; |
| 543 | md += "\n"; |
| 544 | md += `> **Note:** TypeScript type checking is disabled for the Next.js build (\`typescript.ignoreBuildErrors: true\`) so that build timings measure bundler/compilation speed only. Vite does not type-check during build.\n\n`; |
| 545 | md += `> **Methodology:** Build and dev cold start runs are executed in randomized order to eliminate positional bias from filesystem caches, CPU thermal state, and residual process state.\n\n`; |
| 546 | |
| 547 | const hasRolldownResults = results.vinextRolldown && Object.keys(results.vinextRolldown).length > 0; |
| 548 | |
| 549 | // Format a speed ratio comparison: "2.1x faster" or "1.3x slower" |
| 550 | function fmtSpeedup(baseline, value) { |
| 551 | if (!baseline || !value) return "N/A"; |
| 552 | const ratio = baseline / value; |
| 553 | if (ratio > 1) return `**${ratio.toFixed(1)}x faster**`; |
| 554 | if (ratio < 1) return `**${(1 / ratio).toFixed(1)}x slower**`; |
| 555 | return "same"; |
| 556 | } |
| 557 | // Format a size reduction: "56% smaller" or "12% larger" |
| 558 | function fmtSizeReduction(baseline, value) { |
| 559 | if (!baseline || !value) return "N/A"; |
| 560 | const pct = Math.round((1 - value / baseline) * 100); |
| 561 | if (pct > 0) return `**${pct}% smaller**`; |
| 562 | if (pct < 0) return `**${Math.abs(pct)}% larger**`; |
| 563 | return "same"; |
| 564 | } |
| 565 | |
| 566 | if (results.nextjs.buildTime && results.vinext.buildTime) { |
| 567 | md += `## Production Build Time\n\n`; |
| 568 | md += `| Framework | Mean | StdDev | Min | Max | vs Next.js |\n`; |
| 569 | md += `|-----------|------|--------|-----|-----|------------|\n`; |
| 570 | md += `| Next.js 16 (Turbopack) | ${formatMs(results.nextjs.buildTime.mean)} | ±${formatMs(results.nextjs.buildTime.stddev)} | ${formatMs(results.nextjs.buildTime.min)} | ${formatMs(results.nextjs.buildTime.max)} | baseline |\n`; |
| 571 | md += `| vinext (Vite 7 / Rollup) | ${formatMs(results.vinext.buildTime.mean)} | ±${formatMs(results.vinext.buildTime.stddev)} | ${formatMs(results.vinext.buildTime.min)} | ${formatMs(results.vinext.buildTime.max)} | ${fmtSpeedup(results.nextjs.buildTime.mean, results.vinext.buildTime.mean)} |\n`; |
| 572 | |
| 573 | if (hasRolldownResults && results.vinextRolldown.buildTime) { |
| 574 | md += `| vinext (Vite 8 / Rolldown) | ${formatMs(results.vinextRolldown.buildTime.mean)} | ±${formatMs(results.vinextRolldown.buildTime.stddev)} | ${formatMs(results.vinextRolldown.buildTime.min)} | ${formatMs(results.vinextRolldown.buildTime.max)} | ${fmtSpeedup(results.nextjs.buildTime.mean, results.vinextRolldown.buildTime.mean)} |\n`; |
| 575 | } |
| 576 | md += "\n"; |
| 577 | } |
| 578 | |
| 579 | if (results.nextjs.bundleSize && results.vinext.bundleSize) { |
| 580 | md += `## Production Bundle Size (Client)\n\n`; |
| 581 | md += `| Framework | Files | Raw | Gzipped | vs Next.js (gzip) |\n`; |
| 582 | md += `|-----------|-------|-----|----------|--------------------|\n`; |
| 583 | md += `| Next.js 16 | ${results.nextjs.bundleSize.files} | ${formatBytes(results.nextjs.bundleSize.raw)} | ${formatBytes(results.nextjs.bundleSize.gzip)} | baseline |\n`; |
| 584 | md += `| vinext (Rollup) | ${results.vinext.bundleSize.files} | ${formatBytes(results.vinext.bundleSize.raw)} | ${formatBytes(results.vinext.bundleSize.gzip)} | ${fmtSizeReduction(results.nextjs.bundleSize.gzip, results.vinext.bundleSize.gzip)} |\n`; |
| 585 | |
| 586 | if (hasRolldownResults && results.vinextRolldown.bundleSize) { |
| 587 | md += `| vinext (Rolldown) | ${results.vinextRolldown.bundleSize.files} | ${formatBytes(results.vinextRolldown.bundleSize.raw)} | ${formatBytes(results.vinextRolldown.bundleSize.gzip)} | ${fmtSizeReduction(results.nextjs.bundleSize.gzip, results.vinextRolldown.bundleSize.gzip)} |\n`; |
| 588 | } |
| 589 | md += "\n"; |
| 590 | } |
| 591 | |
| 592 | if (results.nextjs.devColdStart && results.vinext.devColdStart) { |
| 593 | md += `## Dev Server Cold Start\n\n`; |
| 594 | md += `| Framework | Mean Cold Start | Mean Peak RSS | vs Next.js |\n`; |
| 595 | md += `|-----------|----------------|----------------|------------|\n`; |
| 596 | md += `| Next.js 16 (Turbopack) | ${formatMs(results.nextjs.devColdStart.meanMs)} | ${Math.round(results.nextjs.devColdStart.meanRssKb / 1024)} MB | baseline |\n`; |
| 597 | md += `| vinext (Vite 7 / Rollup) | ${formatMs(results.vinext.devColdStart.meanMs)} | ${Math.round(results.vinext.devColdStart.meanRssKb / 1024)} MB | ${fmtSpeedup(results.nextjs.devColdStart.meanMs, results.vinext.devColdStart.meanMs)} |\n`; |
| 598 | |
| 599 | if (hasRolldownResults && results.vinextRolldown.devColdStart) { |
| 600 | md += `| vinext (Vite 8 / Rolldown) | ${formatMs(results.vinextRolldown.devColdStart.meanMs)} | ${Math.round(results.vinextRolldown.devColdStart.meanRssKb / 1024)} MB | ${fmtSpeedup(results.nextjs.devColdStart.meanMs, results.vinextRolldown.devColdStart.meanMs)} |\n`; |
| 601 | } |
| 602 | md += "\n"; |
| 603 | } |
| 604 | |
| 605 | // SSR throughput section will be added once prod server is wired up |
| 606 | |
| 607 | const mdFile = join(RESULTS_DIR, `bench-${results.gitHash}-${Date.now()}.md`); |
| 608 | writeFileSync(mdFile, md); |
| 609 | console.log(` Markdown: ${mdFile}\n`); |
| 610 | console.log(md); |
| 611 | } |
| 612 | |
| 613 | main().catch((err) => { |
| 614 | console.error("Benchmark failed:", err); |
| 615 | process.exit(1); |
| 616 | }); |
| 617 | |