Skip to content

Commit b903ed7

Browse files
rubennortefacebook-github-bot
authored andcommitted
Add support for JS sampling profiler (#52827)
Summary: Pull Request resolved: #52827 Changelog: [internal] This adds **support for creating Hermes/JS sampling profiler traces in Fantom**, which is especially useful when running benchmarks. Usage: ``` FANTOM_PROFILE_JS=1 yarn fantom Animated-benchmark ``` Output: {F1980642216} After this, the trace is fully symbolicated. Can be opened directly in Google Chrome: {F1980642229} Or in the built-in viewer in VSCode: {F1980642242} {F1980642240} {F1980642241} When collapsing frames in the Flame Chart viewer in VSCode, we can quickly identify opportunities for optimizations. This also supports multi-config environments. In that case, trace file names are created using a short representation of the configuration. User guide for benchmarks in Fantom, including how to use this, will be done in a future diff. NOTE: This still doesn't work in OSS because we don't support optimized mode there. In dev mode, there's a segmentation fault coming from this line: `hermesRuntime->sampledTraceToStreamInDevToolsFormat(fileStream)` Reviewed By: sammy-SC Differential Revision: D78905646 fbshipit-source-id: 382ddd5034db601309bd118cedde2fe0d57fde98
1 parent 064750d commit b903ed7

11 files changed

Lines changed: 247 additions & 11 deletions

File tree

packages/react-native/src/private/testing/fantom/specs/NativeFantom.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ interface Spec extends TurboModule {
117117
): () => ?number;
118118
saveJSMemoryHeapSnapshot: (filePath: string) => void;
119119
forceHighResTimeStamp: (timeStamp: ?number) => void;
120+
startJSSamplingProfiler: () => void;
121+
stopJSSamplingProfilerAndSaveToFile: (filePath: string) => void;
120122
}
121123

122124
export default TurboModuleRegistry.getEnforcing<Spec>(

private/react-native-fantom/runner/EnvironmentOptions.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const VALID_ENVIRONMENT_VARIABLES = [
1616
'FANTOM_FORCE_TEST_MODE',
1717
'FANTOM_LOG_COMMANDS',
1818
'FANTOM_PRINT_OUTPUT',
19+
'FANTOM_PROFILE_JS',
1920
];
2021

2122
/**
@@ -60,6 +61,8 @@ export const forceTestModeForBenchmarks: boolean = Boolean(
6061
process.env.FANTOM_FORCE_TEST_MODE,
6162
);
6263

64+
export const profileJS: boolean = Boolean(process.env.FANTOM_PROFILE_JS);
65+
6366
/**
6467
* Throws an error if there is an environment variable defined with the FANTOM_
6568
* prefix that is not recognized.

private/react-native-fantom/runner/entrypoint-template.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,21 @@ module.exports = function entrypointTemplate({
2121
featureFlagsModulePath,
2222
testConfig,
2323
snapshotConfig,
24+
jsTraceOutputPath,
2425
}: {
2526
testPath: string,
2627
setupModulePath: string,
2728
featureFlagsModulePath: string,
2829
testConfig: FantomTestConfig,
2930
snapshotConfig: SnapshotConfig,
31+
jsTraceOutputPath: ?string,
3032
}): string {
3133
const constants: FantomRuntimeConstants = {
3234
isOSS: EnvironmentOptions.isOSS,
3335
isRunningFromCI: EnvironmentOptions.isCI,
3436
forceTestModeForBenchmarks: EnvironmentOptions.forceTestModeForBenchmarks,
3537
fantomConfigSummary: formatFantomConfig(testConfig),
38+
jsTraceOutputPath,
3639
};
3740

3841
return `/**

private/react-native-fantom/runner/formatFantomConfig.js

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,38 @@ function formatModes(overrides: PartialFantomTestConfig) {
4848
return parts;
4949
}
5050

51+
function formatModesShort(overrides: PartialFantomTestConfig) {
52+
const parts = [];
53+
54+
if (
55+
overrides.isNativeOptimized === false &&
56+
overrides.isJsOptimized === false &&
57+
overrides.isJsBytecode === false
58+
) {
59+
return ['dev'];
60+
} else if (
61+
overrides.isNativeOptimized === true &&
62+
overrides.isJsOptimized === true &&
63+
overrides.isJsBytecode === true
64+
) {
65+
return ['opt'];
66+
}
67+
68+
if (overrides.isNativeOptimized != null) {
69+
parts.push(overrides.isNativeOptimized ? 'native-opt' : 'native-dev');
70+
}
71+
72+
if (overrides.isJsOptimized != null) {
73+
parts.push(overrides.isJsOptimized ? 'js-opt' : 'js-dev');
74+
}
75+
76+
if (overrides.isJsBytecode != null && overrides.isJsBytecode) {
77+
parts.push('bytecode');
78+
}
79+
80+
return parts;
81+
}
82+
5183
function formatFantomHermesVariant(hermesVariant: HermesVariant): string {
5284
switch (hermesVariant) {
5385
case FantomTestConfigHermesVariant.Hermes:
@@ -70,21 +102,20 @@ function formatFantomFeatureFlag(
70102
return `🔐 ${flagName} = ${flagValue}`;
71103
}
72104

73-
export default function formatFantomConfig(config: FantomTestConfig): string {
74-
const overrides = getOverrides(config);
105+
function formatFantomConfigPretty(config: PartialFantomTestConfig): string {
75106
const parts = [];
76107

77-
parts.push(...formatModes(overrides));
108+
parts.push(...formatModes(config));
78109

79-
if (overrides.hermesVariant) {
80-
parts.push(formatFantomHermesVariant(overrides.hermesVariant));
110+
if (config.hermesVariant) {
111+
parts.push(formatFantomHermesVariant(config.hermesVariant));
81112
}
82113

83-
if (overrides.flags) {
114+
if (config.flags) {
84115
for (const flagType of ['common', 'jsOnly', 'reactInternal'] as const) {
85-
if (overrides.flags[flagType]) {
116+
if (config.flags[flagType]) {
86117
for (const [flagName, flagValue] of Object.entries(
87-
overrides.flags[flagType],
118+
config.flags[flagType],
88119
)) {
89120
parts.push(formatFantomFeatureFlag(flagName, flagValue));
90121
}
@@ -94,3 +125,40 @@ export default function formatFantomConfig(config: FantomTestConfig): string {
94125

95126
return parts.join(', ');
96127
}
128+
129+
function formatFantomConfigShort(config: PartialFantomTestConfig): string {
130+
const parts = [];
131+
132+
parts.push(...formatModesShort(config));
133+
134+
if (config.hermesVariant) {
135+
parts.push((config.hermesVariant as string).toLocaleLowerCase());
136+
}
137+
138+
if (config.flags) {
139+
for (const flagType of ['common', 'jsOnly', 'reactInternal'] as const) {
140+
if (config.flags[flagType]) {
141+
for (const [flagName, flagValue] of Object.entries(
142+
config.flags[flagType],
143+
)) {
144+
parts.push(`${flagName}[${String(flagValue)}]`);
145+
}
146+
}
147+
}
148+
}
149+
150+
return parts.join('-');
151+
}
152+
153+
export default function formatFantomConfig(
154+
config: FantomTestConfig,
155+
options?: ?{style?: 'pretty' | 'short'},
156+
): string {
157+
const overrides = getOverrides(config);
158+
159+
if (options?.style === 'short') {
160+
return formatFantomConfigShort(overrides);
161+
}
162+
163+
return formatFantomConfigPretty(overrides);
164+
}

private/react-native-fantom/runner/paths.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export const NATIVE_BUILD_OUTPUT_PATH: string = path.join(
1616
OUTPUT_PATH,
1717
'native-builds',
1818
);
19+
export const JS_TRACES_OUTPUT_PATH: string = path.join(
20+
OUTPUT_PATH,
21+
'js-traces',
22+
);
1923

2024
export function getTestBuildOutputPath(): string {
2125
const fantomRunID = process.env.__FANTOM_RUN_ID__;

private/react-native-fantom/runner/runner.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
TestSuiteResult,
1515
} from '../runtime/setup';
1616
import type {TestSnapshotResults} from '../runtime/snapshotContext';
17+
import type {FantomTestConfig} from './getFantomTestConfigs';
1718
import type {
1819
AsyncCommandResult,
1920
ConsoleLogMessage,
@@ -27,7 +28,7 @@ import {run as runHermesCompiler} from './executables/hermesc';
2728
import {run as runFantomTester} from './executables/tester';
2829
import formatFantomConfig from './formatFantomConfig';
2930
import getFantomTestConfigs from './getFantomTestConfigs';
30-
import {getTestBuildOutputPath} from './paths';
31+
import {JS_TRACES_OUTPUT_PATH, getTestBuildOutputPath} from './paths';
3132
import {
3233
getInitialSnapshotData,
3334
updateSnapshotsAndGetJestSnapshotResult,
@@ -37,6 +38,7 @@ import {
3738
getDebugInfoFromCommandResult,
3839
printConsoleLog,
3940
runCommand,
41+
symbolicateJSTrace,
4042
symbolicateStackTrace,
4143
} from './utils';
4244
import fs from 'fs';
@@ -50,6 +52,10 @@ import readline from 'readline';
5052
const TEST_BUILD_OUTPUT_PATH = getTestBuildOutputPath();
5153
fs.mkdirSync(TEST_BUILD_OUTPUT_PATH, {recursive: true});
5254

55+
if (EnvironmentOptions.profileJS) {
56+
fs.mkdirSync(JS_TRACES_OUTPUT_PATH, {recursive: true});
57+
}
58+
5359
function buildError(
5460
failureDetail: FailureDetail,
5561
sourceMapPath: string,
@@ -267,6 +273,10 @@ module.exports = async function runTest(
267273
continue;
268274
}
269275

276+
const jsTraceOutputPath = EnvironmentOptions.profileJS
277+
? buildJSTracesOutputPath(testPath, testConfig, testConfigs.length > 1)
278+
: null;
279+
270280
const entrypointContents = entrypointTemplate({
271281
testPath: `${path.relative(TEST_BUILD_OUTPUT_PATH, testPath)}`,
272282
setupModulePath: `${path.relative(TEST_BUILD_OUTPUT_PATH, setupModulePath)}`,
@@ -276,6 +286,7 @@ module.exports = async function runTest(
276286
updateSnapshot: snapshotState._updateSnapshot,
277287
data: getInitialSnapshotData(snapshotState),
278288
},
289+
jsTraceOutputPath,
279290
});
280291

281292
const entrypointPath = path.join(
@@ -340,7 +351,7 @@ module.exports = async function runTest(
340351
rnTesterCommandResult,
341352
);
342353

343-
if (containsError(processedResult)) {
354+
if (containsError(processedResult) || EnvironmentOptions.profileJS) {
344355
await createSourceMap({
345356
...bundleOptions,
346357
out: sourceMapPath,
@@ -349,6 +360,15 @@ module.exports = async function runTest(
349360
});
350361
}
351362

363+
if (EnvironmentOptions.profileJS && jsTraceOutputPath != null) {
364+
symbolicateJSTrace(jsTraceOutputPath, sourceMapPath);
365+
console.info(
366+
'🔥 JS sampling profiler trace saved to',
367+
jsTraceOutputPath,
368+
'\n',
369+
);
370+
}
371+
352372
const testResultError = processedResult.error;
353373
if (testResultError) {
354374
const error = buildError(testResultError, sourceMapPath);
@@ -451,3 +471,20 @@ function containsError(testResult: TestSuiteResult): boolean {
451471
)
452472
);
453473
}
474+
475+
function buildJSTracesOutputPath(
476+
testPath: string,
477+
testConfig: FantomTestConfig,
478+
isMultiConfig: boolean,
479+
): string {
480+
let fileName;
481+
482+
if (isMultiConfig) {
483+
const configSummary = formatFantomConfig(testConfig, {style: 'short'});
484+
fileName = `${path.basename(testPath)}-${configSummary}-${Date.now()}.cpuprofile`;
485+
} else {
486+
fileName = `${path.basename(testPath)}-${Date.now()}.cpuprofile`;
487+
}
488+
489+
return path.join(JS_TRACES_OUTPUT_PATH, fileName);
490+
}

private/react-native-fantom/runner/utils.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,68 @@ export function symbolicateStackTrace(
279279
.join('\n');
280280
}
281281

282+
type ChromeDevToolsTraceNode = {
283+
id: number,
284+
callFrame: {
285+
functionName: string,
286+
scriptId: string,
287+
url: string,
288+
lineNumber: number,
289+
columnNumber: number,
290+
...
291+
},
292+
children: Array<number>,
293+
...
294+
};
295+
296+
type ChromeDevToolsTrace = {
297+
samples: Array<number>,
298+
timeDeltas: Array<number>,
299+
nodes: Array<ChromeDevToolsTraceNode>,
300+
};
301+
302+
export function symbolicateJSTrace(
303+
jsTraceOutputPath: string,
304+
sourceMapPath: string,
305+
) {
306+
const traceContents: ChromeDevToolsTrace = JSON.parse(
307+
fs.readFileSync(jsTraceOutputPath, 'utf8'),
308+
);
309+
const sourceMapData = JSON.parse(fs.readFileSync(sourceMapPath, 'utf8'));
310+
const consumer = new SourceMapConsumer(sourceMapData);
311+
312+
for (const node of traceContents.nodes) {
313+
const {lineNumber, columnNumber} = node.callFrame;
314+
315+
if (lineNumber === 0 || columnNumber === 0) {
316+
continue;
317+
}
318+
319+
const originalPosition = consumer.originalPositionFor({
320+
line: lineNumber,
321+
column: columnNumber,
322+
});
323+
324+
if (originalPosition.name) {
325+
node.callFrame.functionName = originalPosition.name;
326+
}
327+
328+
if (originalPosition.source) {
329+
node.callFrame.url = `file://${originalPosition.source}`;
330+
}
331+
332+
if (originalPosition.line && originalPosition.line > 0) {
333+
node.callFrame.lineNumber = originalPosition.line - 1;
334+
}
335+
336+
if (originalPosition.column && originalPosition.column > 0) {
337+
node.callFrame.columnNumber = originalPosition.column;
338+
}
339+
}
340+
341+
fs.writeFileSync(jsTraceOutputPath, JSON.stringify(traceContents), 'utf8');
342+
}
343+
282344
export type ConsoleLogMessage = {
283345
type: 'console-log',
284346
level: 'info' | 'warn' | 'error',

private/react-native-fantom/runtime/setup.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import type {SnapshotConfig, TestSnapshotResults} from './snapshotContext';
1212

13+
import {getConstants} from '../src/Constants';
1314
import expect from './expect';
1415
import {createMockFunction} from './mocks';
1516
import patchWeakRef from './patchWeakRef';
@@ -418,6 +419,33 @@ function serializeError(error: Error): FailureDetail {
418419
return result;
419420
}
420421

422+
function runTest(): Array<TestCaseResult> {
423+
const {jsTraceOutputPath} = getConstants();
424+
if (jsTraceOutputPath == null) {
425+
return runSuite(currentContext);
426+
}
427+
428+
// Force the import of the native module to be lazy
429+
const NativeFantom =
430+
require('react-native/src/private/testing/fantom/specs/NativeFantom').default;
431+
432+
try {
433+
NativeFantom.startJSSamplingProfiler();
434+
} catch (e) {
435+
console.error('Could not start JS sampling profiler', e);
436+
}
437+
438+
try {
439+
return runSuite(currentContext);
440+
} finally {
441+
try {
442+
NativeFantom.stopJSSamplingProfilerAndSaveToFile(jsTraceOutputPath);
443+
} catch (e) {
444+
console.error('Could not stop JS sampling profiler', e);
445+
}
446+
}
447+
}
448+
421449
global.$$RunTests$$ = () => {
422450
if (testSetupError != null) {
423451
reportTestSuiteResult({
@@ -428,7 +456,7 @@ global.$$RunTests$$ = () => {
428456
});
429457
} else {
430458
reportTestSuiteResult({
431-
testResults: runSuite(currentContext),
459+
testResults: runTest(),
432460
});
433461
}
434462
};

0 commit comments

Comments
 (0)