1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78/**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 reports13 * 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 binary19 * --json Output JSON instead of formatted tables20 * --limit N Max fixtures to profile (default: all)21 */2223import * as babel from '@babel/core';24import {execSync} from 'child_process';25import fs from 'fs';26import path from 'path';2728import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils';2930const REPO_ROOT = path.resolve(__dirname, '../..');3132// --- Parse flags ---33const rawArgs = process.argv.slice(2);34const releaseMode = rawArgs.includes('--release');35const jsonMode = rawArgs.includes('--json');36const limitIdx = rawArgs.indexOf('--limit');37const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 0;3839// --- ANSI colors ---40const useColor = !jsonMode;41const BOLD = useColor ? '\x1b[1m' : '';42const DIM = useColor ? '\x1b[2m' : '';43const RED = useColor ? '\x1b[0;31m' : '';44const GREEN = useColor ? '\x1b[0;32m' : '';45const YELLOW = useColor ? '\x1b[0;33m' : '';46const CYAN = useColor ? '\x1b[0;36m' : '';47const RESET = useColor ? '\x1b[0m' : '';4849// --- Build native module ---50const NATIVE_DIR = path.join(51 REPO_ROOT,52 'compiler/packages/babel-plugin-react-compiler-rust/native',53);54const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');5556if (!jsonMode) {57 console.log(58 `Building Rust native module (${releaseMode ? 'release' : 'debug'})...`,59 );60}6162// Profiling needs symbol names; override the release profile's strip=true.63const cargoBuildArgs = releaseMode64 ? "--release --config 'profile.release.strip=false' -p react_compiler_napi"65 : '-p react_compiler_napi';6667try {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}7778// Copy the built dylib as index.node79const TARGET_DIR = path.join(80 REPO_ROOT,81 releaseMode ? 'compiler/target/release' : 'compiler/target/debug',82);83const 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');8889if (!fs.existsSync(dylib)) {90 console.error(`ERROR: Could not find built native module in ${TARGET_DIR}`);91 process.exit(1);92}93fs.copyFileSync(dylib, NATIVE_NODE_PATH);9495// --- Load plugins ---96const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;97const {extractScopeInfo} =98 require('../packages/babel-plugin-react-compiler-rust/src/scope') as typeof import('../packages/babel-plugin-react-compiler-rust/src/scope');99const {resolveOptions} =100 require('../packages/babel-plugin-react-compiler-rust/src/options') as typeof import('../packages/babel-plugin-react-compiler-rust/src/options');101const {compileWithRustProfiled} =102 require('../packages/babel-plugin-react-compiler-rust/src/bridge') as typeof import('../packages/babel-plugin-react-compiler-rust/src/bridge');103104// --- Types ---105interface TimingEntry {106 name: string;107 duration_us: number;108}109110interface BridgeTiming {111 jsStringifyAst_us: number;112 jsStringifyScope_us: number;113 jsStringifyOptions_us: number;114 napiCall_us: number;115 jsParseResult_us: number;116}117118interface 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}127128// --- Discover fixtures ---129function 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}148149// --- Compile fixture with TS compiler (no debug logging) ---150function 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 });156157 const isFlow = firstLine.includes('@flow');158 const isScript = firstLine.includes('@script');159 const parserPlugins: string[] = isFlow160 ? ['flow', 'jsx']161 : ['typescript', 'jsx'];162163 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 timing184 }185 const end = performance.now();186 return Math.round((end - start) * 1000); // microseconds187}188189// --- Compile fixture with Rust compiler (profiled) ---190function 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 });201202 const isFlow = firstLine.includes('@flow');203 const isScript = firstLine.includes('@script');204 const parserPlugins: string[] = isFlow205 ? ['flow', 'jsx']206 : ['typescript', 'jsx'];207208 // 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 info215 function capturePlugin(_api: typeof babel): babel.PluginObj {216 return {217 name: 'capture',218 visitor: {219 Program: {220 enter(prog, pass): void {221 // Resolve options222 const opts = resolveOptions(223 {224 ...pragmaOpts,225 compilationMode: 'all',226 panicThreshold: 'all_errors',227 },228 pass.file,229 fixturePath,230 pass.file.ast,231 );232233 // Extract scope info (timed)234 const scopeStart = performance.now();235 let scopeInfo;236 try {237 scopeInfo = extractScopeInfo(prog);238 } catch {239 // Store failed result240 (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();259260 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();269270 (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 });305306 // Extract the profile result stored by the plugin307 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 };320321 return result;322}323324// --- Compile fixture with Rust compiler (simpler approach using direct API) ---325function 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 });336337 const isFlow = firstLine.includes('@flow');338 const isScript = firstLine.includes('@script');339 const parserPlugins: string[] = isFlow340 ? ['flow', 'jsx']341 : ['typescript', 'jsx'];342343 // Parse the AST via Babel344 let ast: babel.types.File | null = null;345 let scopeInfo: any = null;346 let opts: any = null;347 let scopeExtraction_us = 0;348349 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 );372373 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 fixture394 }395396 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 }410411 const totalStart = performance.now();412 try {413 const profiled = compileWithRustProfiled(ast, scopeInfo, opts, source);414 const totalEnd = performance.now();415416 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}438439// --- Main ---440const DEFAULT_FIXTURES_DIR = path.join(441 REPO_ROOT,442 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler',443);444445let fixtures = discoverFixtures(DEFAULT_FIXTURES_DIR);446if (limitArg > 0) {447 fixtures = fixtures.slice(0, limitArg);448}449450if (fixtures.length === 0) {451 console.error('No fixtures found.');452 process.exit(1);453}454455if (!jsonMode) {456 console.log(`\nProfiling ${BOLD}${fixtures.length}${RESET} fixtures...`);457}458459// --- Warmup pass ---460if (!jsonMode) {461 console.log(`${DIM}Warmup pass (results discarded)...${RESET}`);462}463for (const fixturePath of fixtures) {464 compileWithTS(fixturePath);465 compileWithRustDirect(fixturePath);466}467468// --- Profile pass ---469if (!jsonMode) {470 console.log(`Profiling...`);471}472473const profiles: FixtureProfile[] = [];474475for (const fixturePath of fixtures) {476 const relPath = path.relative(REPO_ROOT, fixturePath);477 const sizeBytes = fs.statSync(fixturePath).size;478479 const tsTotal_us = compileWithTS(fixturePath);480 const rustResult = compileWithRustDirect(fixturePath);481482 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}492493// --- Aggregation ---494const totalTS = profiles.reduce((sum, p) => sum + p.tsTotal_us, 0);495const totalRust = profiles.reduce((sum, p) => sum + p.rustTotal_us, 0);496const ratio = totalRust / totalTS;497498// Aggregate pass timing499const passAggregates = new Map<string, {total_us: number; values: number[]}>();500501function 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}510511for (const profile of profiles) {512 // Bridge phases513 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);524525 // Rust passes526 for (const pass of profile.rustPasses) {527 addPassTiming(`Rust: ${pass.name}`, pass.duration_us);528 }529}530531function 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}536537// --- Output ---538if (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('');566567 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('');575576 // --- Pass breakdown table ---577 console.log(`${BOLD}=== Rust Time Breakdown (aggregate) ===${RESET}`);578579 // Sort by total time descending580 const sortedPasses = [...passAggregates.entries()].sort(581 (a, b) => b[1].total_us - a[1].total_us,582 );583584 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}`);586587 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);592593 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('');598599 // --- 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);605606 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}`);608609 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 > 1024614 ? (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;622623 // Find bottleneck pass624 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;635636 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}
Findings
✓ No findings reported for this file.