main
ts 640 lines 19.5 KB
Raw
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 }