| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * Performance profiling script for Rust vs JS React Compiler. |
| 10 | * |
| 11 | * Runs both compilers on all fixtures without debug logging, |
| 12 | * collects fine-grained timing data at every stage, and reports |
| 13 | * aggregate performance breakdowns. |
| 14 | * |
| 15 | * Usage: npx tsx compiler/scripts/profile-rust-port.ts [flags] |
| 16 | * |
| 17 | * Flags: |
| 18 | * --release Build and use release-mode Rust binary |
| 19 | * --json Output JSON instead of formatted tables |
| 20 | * --limit N Max fixtures to profile (default: all) |
| 21 | */ |
| 22 | |
| 23 | import * as babel from '@babel/core'; |
| 24 | import {execSync} from 'child_process'; |
| 25 | import fs from 'fs'; |
| 26 | import path from 'path'; |
| 27 | |
| 28 | import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils'; |
| 29 | |
| 30 | const REPO_ROOT = path.resolve(__dirname, '../..'); |
| 31 | |
| 32 | // --- Parse flags --- |
| 33 | const rawArgs = process.argv.slice(2); |
| 34 | const releaseMode = rawArgs.includes('--release'); |
| 35 | const jsonMode = rawArgs.includes('--json'); |
| 36 | const limitIdx = rawArgs.indexOf('--limit'); |
| 37 | const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 0; |
| 38 | |
| 39 | // --- ANSI colors --- |
| 40 | const useColor = !jsonMode; |
| 41 | const BOLD = useColor ? '\x1b[1m' : ''; |
| 42 | const DIM = useColor ? '\x1b[2m' : ''; |
| 43 | const RED = useColor ? '\x1b[0;31m' : ''; |
| 44 | const GREEN = useColor ? '\x1b[0;32m' : ''; |
| 45 | const YELLOW = useColor ? '\x1b[0;33m' : ''; |
| 46 | const CYAN = useColor ? '\x1b[0;36m' : ''; |
| 47 | const RESET = useColor ? '\x1b[0m' : ''; |
| 48 | |
| 49 | // --- Build native module --- |
| 50 | const NATIVE_DIR = path.join( |
| 51 | REPO_ROOT, |
| 52 | 'compiler/packages/babel-plugin-react-compiler-rust/native', |
| 53 | ); |
| 54 | const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node'); |
| 55 | |
| 56 | if (!jsonMode) { |
| 57 | console.log( |
| 58 | `Building Rust native module (${releaseMode ? 'release' : 'debug'})...`, |
| 59 | ); |
| 60 | } |
| 61 | |
| 62 | // Profiling needs symbol names; override the release profile's strip=true. |
| 63 | const cargoBuildArgs = releaseMode |
| 64 | ? "--release --config 'profile.release.strip=false' -p react_compiler_napi" |
| 65 | : '-p react_compiler_napi'; |
| 66 | |
| 67 | try { |
| 68 | execSync(`~/.cargo/bin/cargo build ${cargoBuildArgs}`, { |
| 69 | cwd: path.join(REPO_ROOT, 'compiler/crates'), |
| 70 | stdio: jsonMode ? ['inherit', 'pipe', 'inherit'] : 'inherit', |
| 71 | shell: true, |
| 72 | }); |
| 73 | } catch { |
| 74 | console.error('ERROR: Failed to build Rust native module.'); |
| 75 | process.exit(1); |
| 76 | } |
| 77 | |
| 78 | // Copy the built dylib as index.node |
| 79 | const TARGET_DIR = path.join( |
| 80 | REPO_ROOT, |
| 81 | releaseMode ? 'compiler/target/release' : 'compiler/target/debug', |
| 82 | ); |
| 83 | const dylib = fs.existsSync( |
| 84 | path.join(TARGET_DIR, 'libreact_compiler_napi.dylib'), |
| 85 | ) |
| 86 | ? path.join(TARGET_DIR, 'libreact_compiler_napi.dylib') |
| 87 | : path.join(TARGET_DIR, 'libreact_compiler_napi.so'); |
| 88 | |
| 89 | if (!fs.existsSync(dylib)) { |
| 90 | console.error(`ERROR: Could not find built native module in ${TARGET_DIR}`); |
| 91 | process.exit(1); |
| 92 | } |
| 93 | fs.copyFileSync(dylib, NATIVE_NODE_PATH); |
| 94 | |
| 95 | // --- Load plugins --- |
| 96 | const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default; |
| 97 | const {extractScopeInfo} = |
| 98 | require('../packages/babel-plugin-react-compiler-rust/src/scope') as typeof import('../packages/babel-plugin-react-compiler-rust/src/scope'); |
| 99 | const {resolveOptions} = |
| 100 | require('../packages/babel-plugin-react-compiler-rust/src/options') as typeof import('../packages/babel-plugin-react-compiler-rust/src/options'); |
| 101 | const {compileWithRustProfiled} = |
| 102 | require('../packages/babel-plugin-react-compiler-rust/src/bridge') as typeof import('../packages/babel-plugin-react-compiler-rust/src/bridge'); |
| 103 | |
| 104 | // --- Types --- |
| 105 | interface TimingEntry { |
| 106 | name: string; |
| 107 | duration_us: number; |
| 108 | } |
| 109 | |
| 110 | interface BridgeTiming { |
| 111 | jsStringifyAst_us: number; |
| 112 | jsStringifyScope_us: number; |
| 113 | jsStringifyOptions_us: number; |
| 114 | napiCall_us: number; |
| 115 | jsParseResult_us: number; |
| 116 | } |
| 117 | |
| 118 | interface FixtureProfile { |
| 119 | fixture: string; |
| 120 | sizeBytes: number; |
| 121 | tsTotal_us: number; |
| 122 | rustTotal_us: number; |
| 123 | rustScopeExtraction_us: number; |
| 124 | rustBridge: BridgeTiming; |
| 125 | rustPasses: TimingEntry[]; |
| 126 | } |
| 127 | |
| 128 | // --- Discover fixtures --- |
| 129 | function discoverFixtures(rootPath: string): string[] { |
| 130 | const results: string[] = []; |
| 131 | function walk(dir: string): void { |
| 132 | for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { |
| 133 | const fullPath = path.join(dir, entry.name); |
| 134 | if (entry.isDirectory()) { |
| 135 | walk(fullPath); |
| 136 | } else if ( |
| 137 | /\.(js|jsx|ts|tsx)$/.test(entry.name) && |
| 138 | !entry.name.endsWith('.expect.md') |
| 139 | ) { |
| 140 | results.push(fullPath); |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | walk(rootPath); |
| 145 | results.sort(); |
| 146 | return results; |
| 147 | } |
| 148 | |
| 149 | // --- Compile fixture with TS compiler (no debug logging) --- |
| 150 | function compileWithTS(fixturePath: string): number { |
| 151 | const source = fs.readFileSync(fixturePath, 'utf8'); |
| 152 | const firstLine = source.substring(0, source.indexOf('\n')); |
| 153 | const pragmaOpts = parseConfigPragmaForTests(firstLine, { |
| 154 | compilationMode: 'all', |
| 155 | }); |
| 156 | |
| 157 | const isFlow = firstLine.includes('@flow'); |
| 158 | const isScript = firstLine.includes('@script'); |
| 159 | const parserPlugins: string[] = isFlow |
| 160 | ? ['flow', 'jsx'] |
| 161 | : ['typescript', 'jsx']; |
| 162 | |
| 163 | const start = performance.now(); |
| 164 | try { |
| 165 | babel.transformSync(source, { |
| 166 | filename: fixturePath, |
| 167 | sourceType: isScript ? 'script' : 'module', |
| 168 | parserOpts: {plugins: parserPlugins}, |
| 169 | plugins: [ |
| 170 | [ |
| 171 | tsPlugin, |
| 172 | { |
| 173 | ...pragmaOpts, |
| 174 | compilationMode: 'all' as const, |
| 175 | panicThreshold: 'all_errors' as const, |
| 176 | }, |
| 177 | ], |
| 178 | ], |
| 179 | configFile: false, |
| 180 | babelrc: false, |
| 181 | }); |
| 182 | } catch { |
| 183 | // Ignore errors - we still measure timing |
| 184 | } |
| 185 | const end = performance.now(); |
| 186 | return Math.round((end - start) * 1000); // microseconds |
| 187 | } |
| 188 | |
| 189 | // --- Compile fixture with Rust compiler (profiled) --- |
| 190 | function compileWithRustProfile(fixturePath: string): { |
| 191 | total_us: number; |
| 192 | scopeExtraction_us: number; |
| 193 | bridge: BridgeTiming; |
| 194 | passes: TimingEntry[]; |
| 195 | } { |
| 196 | const source = fs.readFileSync(fixturePath, 'utf8'); |
| 197 | const firstLine = source.substring(0, source.indexOf('\n')); |
| 198 | const pragmaOpts = parseConfigPragmaForTests(firstLine, { |
| 199 | compilationMode: 'all', |
| 200 | }); |
| 201 | |
| 202 | const isFlow = firstLine.includes('@flow'); |
| 203 | const isScript = firstLine.includes('@script'); |
| 204 | const parserPlugins: string[] = isFlow |
| 205 | ? ['flow', 'jsx'] |
| 206 | : ['typescript', 'jsx']; |
| 207 | |
| 208 | // Parse the AST via Babel (same as the real plugin) |
| 209 | const parseResult = babel.transformSync(source, { |
| 210 | filename: fixturePath, |
| 211 | sourceType: isScript ? 'script' : 'module', |
| 212 | parserOpts: {plugins: parserPlugins}, |
| 213 | plugins: [ |
| 214 | // Use a minimal plugin that captures the AST and scope info |
| 215 | function capturePlugin(_api: typeof babel): babel.PluginObj { |
| 216 | return { |
| 217 | name: 'capture', |
| 218 | visitor: { |
| 219 | Program: { |
| 220 | enter(prog, pass): void { |
| 221 | // Resolve options |
| 222 | const opts = resolveOptions( |
| 223 | { |
| 224 | ...pragmaOpts, |
| 225 | compilationMode: 'all', |
| 226 | panicThreshold: 'all_errors', |
| 227 | }, |
| 228 | pass.file, |
| 229 | fixturePath, |
| 230 | pass.file.ast, |
| 231 | ); |
| 232 | |
| 233 | // Extract scope info (timed) |
| 234 | const scopeStart = performance.now(); |
| 235 | let scopeInfo; |
| 236 | try { |
| 237 | scopeInfo = extractScopeInfo(prog); |
| 238 | } catch { |
| 239 | // Store failed result |
| 240 | (pass as any).__profileResult = { |
| 241 | total_us: Math.round( |
| 242 | (performance.now() - scopeStart) * 1000, |
| 243 | ), |
| 244 | scopeExtraction_us: Math.round( |
| 245 | (performance.now() - scopeStart) * 1000, |
| 246 | ), |
| 247 | bridge: { |
| 248 | jsStringifyAst_us: 0, |
| 249 | jsStringifyScope_us: 0, |
| 250 | jsStringifyOptions_us: 0, |
| 251 | napiCall_us: 0, |
| 252 | jsParseResult_us: 0, |
| 253 | }, |
| 254 | passes: [], |
| 255 | }; |
| 256 | return; |
| 257 | } |
| 258 | const scopeEnd = performance.now(); |
| 259 | |
| 260 | const totalStart = performance.now(); |
| 261 | try { |
| 262 | const profiled = compileWithRustProfiled( |
| 263 | pass.file.ast, |
| 264 | scopeInfo, |
| 265 | opts, |
| 266 | pass.file.code ?? null, |
| 267 | ); |
| 268 | const totalEnd = performance.now(); |
| 269 | |
| 270 | (pass as any).__profileResult = { |
| 271 | total_us: Math.round((totalEnd - totalStart) * 1000), |
| 272 | scopeExtraction_us: Math.round( |
| 273 | (scopeEnd - scopeStart) * 1000, |
| 274 | ), |
| 275 | bridge: profiled.bridgeTiming, |
| 276 | passes: profiled.rustTiming, |
| 277 | }; |
| 278 | } catch { |
| 279 | const totalEnd = performance.now(); |
| 280 | (pass as any).__profileResult = { |
| 281 | total_us: Math.round((totalEnd - totalStart) * 1000), |
| 282 | scopeExtraction_us: Math.round( |
| 283 | (scopeEnd - scopeStart) * 1000, |
| 284 | ), |
| 285 | bridge: { |
| 286 | jsStringifyAst_us: 0, |
| 287 | jsStringifyScope_us: 0, |
| 288 | jsStringifyOptions_us: 0, |
| 289 | napiCall_us: 0, |
| 290 | jsParseResult_us: 0, |
| 291 | }, |
| 292 | passes: [], |
| 293 | }; |
| 294 | } |
| 295 | prog.skip(); |
| 296 | }, |
| 297 | }, |
| 298 | }, |
| 299 | }; |
| 300 | }, |
| 301 | ], |
| 302 | configFile: false, |
| 303 | babelrc: false, |
| 304 | }); |
| 305 | |
| 306 | // Extract the profile result stored by the plugin |
| 307 | const result = (parseResult as any)?.metadata?.__profileResult ?? |
| 308 | (parseResult as any)?.__profileResult ?? { |
| 309 | total_us: 0, |
| 310 | scopeExtraction_us: 0, |
| 311 | bridge: { |
| 312 | jsStringifyAst_us: 0, |
| 313 | jsStringifyScope_us: 0, |
| 314 | jsStringifyOptions_us: 0, |
| 315 | napiCall_us: 0, |
| 316 | jsParseResult_us: 0, |
| 317 | }, |
| 318 | passes: [], |
| 319 | }; |
| 320 | |
| 321 | return result; |
| 322 | } |
| 323 | |
| 324 | // --- Compile fixture with Rust compiler (simpler approach using direct API) --- |
| 325 | function compileWithRustDirect(fixturePath: string): { |
| 326 | total_us: number; |
| 327 | scopeExtraction_us: number; |
| 328 | bridge: BridgeTiming; |
| 329 | passes: TimingEntry[]; |
| 330 | } { |
| 331 | const source = fs.readFileSync(fixturePath, 'utf8'); |
| 332 | const firstLine = source.substring(0, source.indexOf('\n')); |
| 333 | const pragmaOpts = parseConfigPragmaForTests(firstLine, { |
| 334 | compilationMode: 'all', |
| 335 | }); |
| 336 | |
| 337 | const isFlow = firstLine.includes('@flow'); |
| 338 | const isScript = firstLine.includes('@script'); |
| 339 | const parserPlugins: string[] = isFlow |
| 340 | ? ['flow', 'jsx'] |
| 341 | : ['typescript', 'jsx']; |
| 342 | |
| 343 | // Parse the AST via Babel |
| 344 | let ast: babel.types.File | null = null; |
| 345 | let scopeInfo: any = null; |
| 346 | let opts: any = null; |
| 347 | let scopeExtraction_us = 0; |
| 348 | |
| 349 | try { |
| 350 | babel.transformSync(source, { |
| 351 | filename: fixturePath, |
| 352 | sourceType: isScript ? 'script' : 'module', |
| 353 | parserOpts: {plugins: parserPlugins}, |
| 354 | plugins: [ |
| 355 | function capturePlugin(_api: typeof babel): babel.PluginObj { |
| 356 | return { |
| 357 | name: 'capture-for-profile', |
| 358 | visitor: { |
| 359 | Program: { |
| 360 | enter(prog, pass): void { |
| 361 | ast = pass.file.ast; |
| 362 | opts = resolveOptions( |
| 363 | { |
| 364 | ...pragmaOpts, |
| 365 | compilationMode: 'all', |
| 366 | panicThreshold: 'all_errors', |
| 367 | }, |
| 368 | pass.file, |
| 369 | fixturePath, |
| 370 | pass.file.ast, |
| 371 | ); |
| 372 | |
| 373 | const scopeStart = performance.now(); |
| 374 | try { |
| 375 | scopeInfo = extractScopeInfo(prog); |
| 376 | } catch { |
| 377 | scopeInfo = null; |
| 378 | } |
| 379 | scopeExtraction_us = Math.round( |
| 380 | (performance.now() - scopeStart) * 1000, |
| 381 | ); |
| 382 | prog.skip(); |
| 383 | }, |
| 384 | }, |
| 385 | }, |
| 386 | }; |
| 387 | }, |
| 388 | ], |
| 389 | configFile: false, |
| 390 | babelrc: false, |
| 391 | }); |
| 392 | } catch { |
| 393 | // Parse error or other babel failure - skip this fixture |
| 394 | } |
| 395 | |
| 396 | if (ast == null || scopeInfo == null || opts == null) { |
| 397 | return { |
| 398 | total_us: 0, |
| 399 | scopeExtraction_us, |
| 400 | bridge: { |
| 401 | jsStringifyAst_us: 0, |
| 402 | jsStringifyScope_us: 0, |
| 403 | jsStringifyOptions_us: 0, |
| 404 | napiCall_us: 0, |
| 405 | jsParseResult_us: 0, |
| 406 | }, |
| 407 | passes: [], |
| 408 | }; |
| 409 | } |
| 410 | |
| 411 | const totalStart = performance.now(); |
| 412 | try { |
| 413 | const profiled = compileWithRustProfiled(ast, scopeInfo, opts, source); |
| 414 | const totalEnd = performance.now(); |
| 415 | |
| 416 | return { |
| 417 | total_us: Math.round((totalEnd - totalStart) * 1000), |
| 418 | scopeExtraction_us, |
| 419 | bridge: profiled.bridgeTiming, |
| 420 | passes: profiled.rustTiming, |
| 421 | }; |
| 422 | } catch { |
| 423 | const totalEnd = performance.now(); |
| 424 | return { |
| 425 | total_us: Math.round((totalEnd - totalStart) * 1000), |
| 426 | scopeExtraction_us, |
| 427 | bridge: { |
| 428 | jsStringifyAst_us: 0, |
| 429 | jsStringifyScope_us: 0, |
| 430 | jsStringifyOptions_us: 0, |
| 431 | napiCall_us: 0, |
| 432 | jsParseResult_us: 0, |
| 433 | }, |
| 434 | passes: [], |
| 435 | }; |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | // --- Main --- |
| 440 | const DEFAULT_FIXTURES_DIR = path.join( |
| 441 | REPO_ROOT, |
| 442 | 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler', |
| 443 | ); |
| 444 | |
| 445 | let fixtures = discoverFixtures(DEFAULT_FIXTURES_DIR); |
| 446 | if (limitArg > 0) { |
| 447 | fixtures = fixtures.slice(0, limitArg); |
| 448 | } |
| 449 | |
| 450 | if (fixtures.length === 0) { |
| 451 | console.error('No fixtures found.'); |
| 452 | process.exit(1); |
| 453 | } |
| 454 | |
| 455 | if (!jsonMode) { |
| 456 | console.log(`\nProfiling ${BOLD}${fixtures.length}${RESET} fixtures...`); |
| 457 | } |
| 458 | |
| 459 | // --- Warmup pass --- |
| 460 | if (!jsonMode) { |
| 461 | console.log(`${DIM}Warmup pass (results discarded)...${RESET}`); |
| 462 | } |
| 463 | for (const fixturePath of fixtures) { |
| 464 | compileWithTS(fixturePath); |
| 465 | compileWithRustDirect(fixturePath); |
| 466 | } |
| 467 | |
| 468 | // --- Profile pass --- |
| 469 | if (!jsonMode) { |
| 470 | console.log(`Profiling...`); |
| 471 | } |
| 472 | |
| 473 | const profiles: FixtureProfile[] = []; |
| 474 | |
| 475 | for (const fixturePath of fixtures) { |
| 476 | const relPath = path.relative(REPO_ROOT, fixturePath); |
| 477 | const sizeBytes = fs.statSync(fixturePath).size; |
| 478 | |
| 479 | const tsTotal_us = compileWithTS(fixturePath); |
| 480 | const rustResult = compileWithRustDirect(fixturePath); |
| 481 | |
| 482 | profiles.push({ |
| 483 | fixture: relPath, |
| 484 | sizeBytes, |
| 485 | tsTotal_us, |
| 486 | rustTotal_us: rustResult.scopeExtraction_us + rustResult.total_us, |
| 487 | rustScopeExtraction_us: rustResult.scopeExtraction_us, |
| 488 | rustBridge: rustResult.bridge, |
| 489 | rustPasses: rustResult.passes, |
| 490 | }); |
| 491 | } |
| 492 | |
| 493 | // --- Aggregation --- |
| 494 | const totalTS = profiles.reduce((sum, p) => sum + p.tsTotal_us, 0); |
| 495 | const totalRust = profiles.reduce((sum, p) => sum + p.rustTotal_us, 0); |
| 496 | const ratio = totalRust / totalTS; |
| 497 | |
| 498 | // Aggregate pass timing |
| 499 | const passAggregates = new Map<string, {total_us: number; values: number[]}>(); |
| 500 | |
| 501 | function addPassTiming(name: string, duration_us: number): void { |
| 502 | let agg = passAggregates.get(name); |
| 503 | if (!agg) { |
| 504 | agg = {total_us: 0, values: []}; |
| 505 | passAggregates.set(name, agg); |
| 506 | } |
| 507 | agg.total_us += duration_us; |
| 508 | agg.values.push(duration_us); |
| 509 | } |
| 510 | |
| 511 | for (const profile of profiles) { |
| 512 | // Bridge phases |
| 513 | addPassTiming('JS: extractScopeInfo', profile.rustScopeExtraction_us); |
| 514 | addPassTiming('JS: JSON.stringify AST', profile.rustBridge.jsStringifyAst_us); |
| 515 | addPassTiming( |
| 516 | 'JS: JSON.stringify scope', |
| 517 | profile.rustBridge.jsStringifyScope_us, |
| 518 | ); |
| 519 | addPassTiming( |
| 520 | 'JS: JSON.stringify options', |
| 521 | profile.rustBridge.jsStringifyOptions_us, |
| 522 | ); |
| 523 | addPassTiming('JS: JSON.parse result', profile.rustBridge.jsParseResult_us); |
| 524 | |
| 525 | // Rust passes |
| 526 | for (const pass of profile.rustPasses) { |
| 527 | addPassTiming(`Rust: ${pass.name}`, pass.duration_us); |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | function percentile(values: number[], p: number): number { |
| 532 | const sorted = [...values].sort((a, b) => a - b); |
| 533 | const idx = Math.ceil((p / 100) * sorted.length) - 1; |
| 534 | return sorted[Math.max(0, idx)]; |
| 535 | } |
| 536 | |
| 537 | // --- Output --- |
| 538 | if (jsonMode) { |
| 539 | const output = { |
| 540 | build: releaseMode ? 'release' : 'debug', |
| 541 | fixtureCount: fixtures.length, |
| 542 | totalTS_us: totalTS, |
| 543 | totalRust_us: totalRust, |
| 544 | ratio: Math.round(ratio * 100) / 100, |
| 545 | passAggregates: Object.fromEntries( |
| 546 | [...passAggregates.entries()].map(([name, agg]) => [ |
| 547 | name, |
| 548 | { |
| 549 | total_us: agg.total_us, |
| 550 | avg_us: Math.round(agg.total_us / agg.values.length), |
| 551 | p95_us: percentile(agg.values, 95), |
| 552 | count: agg.values.length, |
| 553 | }, |
| 554 | ]), |
| 555 | ), |
| 556 | fixtures: profiles, |
| 557 | }; |
| 558 | console.log(JSON.stringify(output, null, 2)); |
| 559 | } else { |
| 560 | console.log(''); |
| 561 | console.log(`${BOLD}=== Summary ===${RESET}`); |
| 562 | console.log( |
| 563 | `Build: ${CYAN}${releaseMode ? 'release' : 'debug'}${RESET} | Fixtures: ${BOLD}${fixtures.length}${RESET} | Warmup: done`, |
| 564 | ); |
| 565 | console.log(''); |
| 566 | |
| 567 | const tsMs = (totalTS / 1000).toFixed(1); |
| 568 | const rustMs = (totalRust / 1000).toFixed(1); |
| 569 | const ratioStr = ratio.toFixed(2); |
| 570 | const ratioColor = ratio <= 1.0 ? GREEN : ratio <= 1.5 ? YELLOW : RED; |
| 571 | console.log( |
| 572 | `Total: TS ${BOLD}${tsMs}ms${RESET} | Rust ${BOLD}${rustMs}ms${RESET} | Ratio ${ratioColor}${ratioStr}x${RESET}`, |
| 573 | ); |
| 574 | console.log(''); |
| 575 | |
| 576 | // --- Pass breakdown table --- |
| 577 | console.log(`${BOLD}=== Rust Time Breakdown (aggregate) ===${RESET}`); |
| 578 | |
| 579 | // Sort by total time descending |
| 580 | const sortedPasses = [...passAggregates.entries()].sort( |
| 581 | (a, b) => b[1].total_us - a[1].total_us, |
| 582 | ); |
| 583 | |
| 584 | const header = `${'Phase'.padEnd(50)} ${'Total(ms)'.padStart(10)} ${'%'.padStart(6)} ${'Avg(us)'.padStart(9)} ${'P95(us)'.padStart(9)}`; |
| 585 | console.log(`${DIM}${header}${RESET}`); |
| 586 | |
| 587 | for (const [name, agg] of sortedPasses) { |
| 588 | const totalMs = (agg.total_us / 1000).toFixed(1); |
| 589 | const pct = ((agg.total_us / totalRust) * 100).toFixed(1); |
| 590 | const avg = Math.round(agg.total_us / agg.values.length); |
| 591 | const p95 = percentile(agg.values, 95); |
| 592 | |
| 593 | console.log( |
| 594 | `${name.padEnd(50)} ${totalMs.padStart(10)} ${(pct + '%').padStart(6)} ${String(avg).padStart(9)} ${String(p95).padStart(9)}`, |
| 595 | ); |
| 596 | } |
| 597 | console.log(''); |
| 598 | |
| 599 | // --- Top 20 slowest fixtures --- |
| 600 | console.log(`${BOLD}=== Top 20 Slowest Fixtures (Rust) ===${RESET}`); |
| 601 | const sortedFixtures = [...profiles].sort( |
| 602 | (a, b) => b.rustTotal_us - a.rustTotal_us, |
| 603 | ); |
| 604 | const topN = sortedFixtures.slice(0, 20); |
| 605 | |
| 606 | const fHeader = `${'Fixture'.padEnd(55)} ${'Size'.padStart(6)} ${'TS(ms)'.padStart(8)} ${'Rust(ms)'.padStart(9)} ${'Ratio'.padStart(7)} ${'Bottleneck'.padStart(20)}`; |
| 607 | console.log(`${DIM}${fHeader}${RESET}`); |
| 608 | |
| 609 | for (const p of topN) { |
| 610 | const shortName = |
| 611 | p.fixture.length > 54 ? '...' + p.fixture.slice(-51) : p.fixture; |
| 612 | const sizeStr = |
| 613 | p.sizeBytes > 1024 |
| 614 | ? (p.sizeBytes / 1024).toFixed(0) + 'K' |
| 615 | : String(p.sizeBytes); |
| 616 | const tsMsStr = (p.tsTotal_us / 1000).toFixed(2); |
| 617 | const rustMsStr = (p.rustTotal_us / 1000).toFixed(2); |
| 618 | const fixtureRatio = p.tsTotal_us > 0 ? p.rustTotal_us / p.tsTotal_us : 0; |
| 619 | const ratioStr = fixtureRatio.toFixed(1) + 'x'; |
| 620 | const ratioColor = |
| 621 | fixtureRatio <= 1.0 ? GREEN : fixtureRatio <= 1.5 ? YELLOW : RED; |
| 622 | |
| 623 | // Find bottleneck pass |
| 624 | let bottleneck = ''; |
| 625 | if (p.rustPasses.length > 0) { |
| 626 | const sorted = [...p.rustPasses].sort( |
| 627 | (a, b) => b.duration_us - a.duration_us, |
| 628 | ); |
| 629 | const top = sorted[0]; |
| 630 | const pct = ((top.duration_us / p.rustTotal_us) * 100).toFixed(0); |
| 631 | bottleneck = `${top.name} (${pct}%)`; |
| 632 | } |
| 633 | const bottleneckStr = |
| 634 | bottleneck.length > 19 ? bottleneck.slice(0, 19) + '…' : bottleneck; |
| 635 | |
| 636 | console.log( |
| 637 | `${shortName.padEnd(55)} ${sizeStr.padStart(6)} ${tsMsStr.padStart(8)} ${rustMsStr.padStart(9)} ${ratioColor}${ratioStr.padStart(7)}${RESET} ${bottleneckStr.padStart(20)}`, |
| 638 | ); |
| 639 | } |
| 640 | } |