Add API to run benchmarks in Fantom (#48452)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/48452

Changelog: [internal]

Implements a basic API to run benchmarks with Fantom (using `tinybench` under the hood):

```
import {benchmark} from 'react-native/fantom';

benchmark
  .suite('Suite name', {
    // options
  })
  .add(
    'Test name',
    () => {
      // code to benchmark
    },
    {
      beforeAll: () => {},
      beforeEach: () => {},
      afterEach: () => {},
      afterAll: () => {},
    },
  )
  .verify(results => {
    // check results and throw an error if the expectations fail
  });
```

Features:
* Print benchmark results in the console as a table.
* It opts into optimized builds automatically
* Verifies that optimized build is used (unless manually opting out of the check via `disableOptimizedBuildCheck`).
* Supports verification of results (making expectations and making the test fail if the benchmark doesn't meet some expectations).

Reviewed By: rshest

Differential Revision: D66926183

fbshipit-source-id: 61cfa7689ea7684eb870fbbc815b8d236a1871e6
This commit is contained in:
Rubén Norte
2025-01-06 07:10:28 -08:00
committed by Facebook GitHub Bot
parent 96205dd78e
commit 3795f0fb66
3 changed files with 176 additions and 2 deletions
+9 -1
View File
@@ -46,6 +46,8 @@ const DEFAULT_MODE: FantomTestConfigMode =
const FANTOM_FLAG_FORMAT = /^(\w+):(\w+)$/;
const FANTOM_BENCHMARK_SUITE_RE = /\nbenchmark(\s*)\.suite\(/g;
/**
* Extracts the Fantom configuration from the test file, specified as part of
* the docblock comment. E.g.:
@@ -69,7 +71,9 @@ const FANTOM_FLAG_FORMAT = /^(\w+):(\w+)$/;
export default function getFantomTestConfig(
testPath: string,
): FantomTestConfig {
const docblock = extract(fs.readFileSync(testPath, 'utf8'));
const testContents = fs.readFileSync(testPath, 'utf8');
const docblock = extract(testContents);
const pragmas = parse(docblock) as DocblockPragmas;
const config: FantomTestConfig = {
@@ -102,6 +106,10 @@ export default function getFantomTestConfig(
default:
throw new Error(`Invalid Fantom mode: ${mode}`);
}
} else {
if (FANTOM_BENCHMARK_SUITE_RE.test(testContents)) {
config.mode = FantomTestConfigMode.Optimized;
}
}
const maybeRawFlagConfig = pragmas.fantom_flags;
+99
View File
@@ -0,0 +1,99 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
import nullthrows from 'nullthrows';
import NativeCPUTime from 'react-native/src/private/specs/modules/NativeCPUTime';
import {
Bench,
type BenchOptions,
type FnOptions,
type TaskResult,
} from 'tinybench';
type SuiteOptions = $ReadOnly<{
...Pick<
BenchOptions,
'iterations' | 'time' | 'warmup' | 'warmupIterations' | 'warmupTime',
>,
disableOptimizedBuildCheck?: boolean,
}>;
type SuiteResults = Array<$ReadOnly<TaskResult>>;
interface SuiteAPI {
add(name: string, fn: () => void, options?: FnOptions): SuiteAPI;
verify(fn: (results: SuiteResults) => void): SuiteAPI;
}
export function suite(
suiteName: string,
suiteOptions?: ?SuiteOptions,
): SuiteAPI {
const {disableOptimizedBuildCheck, ...benchOptions} = suiteOptions ?? {};
const bench = new Bench({
...benchOptions,
name: suiteName,
throws: true,
now: () => NativeCPUTime.getCPUTimeNanos() / 1000000,
});
const verifyFns = [];
global.it(suiteName, () => {
if (bench.tasks.length === 0) {
throw new Error('No benchmark tests defined');
}
bench.runSync();
printBenchmarkResults(bench);
for (const verify of verifyFns) {
verify(bench.results);
}
if (!NativeCPUTime.hasAccurateCPUTimeNanosForBenchmarks()) {
throw new Error(
'`NativeCPUTime` module does not provide accurate CPU time information in this environment. Please run the benchmarks in an environment where it does.',
);
}
if (__DEV__ && disableOptimizedBuildCheck !== true) {
throw new Error('Benchmarks should not be run in development mode');
}
});
const suiteAPI = {
add(name: string, fn: () => void, options?: FnOptions): SuiteAPI {
bench.add(name, fn, options);
return suiteAPI;
},
verify(fn: (results: SuiteResults) => void): SuiteAPI {
verifyFns.push(fn);
return suiteAPI;
},
};
return suiteAPI;
}
function printBenchmarkResults(bench: Bench) {
const longestTaskNameLength = bench.tasks.reduce(
(maxLength, task) => Math.max(maxLength, task.name.length),
0,
);
const separatorWidth = 121 + longestTaskNameLength - 'Task name'.length;
console.log('-'.repeat(separatorWidth));
console.log(bench.name);
console.table(nullthrows(bench.table()));
console.log('-'.repeat(separatorWidth) + '\n');
}
+68 -1
View File
@@ -14,6 +14,7 @@ import type {
} from './getFantomRenderedOutput';
import type {MixedElement} from 'react';
import * as Benchmark from './Benchmark';
import getFantomRenderedOutput from './getFantomRenderedOutput';
import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric';
import NativeFantom from 'react-native/src/private/specs/modules/NativeFantom';
@@ -63,7 +64,7 @@ class Root {
this.#hasRendered = true;
}
ReactFabric.render(element, this.#surfaceId, () => {}, true);
ReactFabric.render(element, this.#surfaceId, null, true);
}
getMountingLogs(): Array<string> {
@@ -137,3 +138,69 @@ export function runWorkLoop(): void {
export function createRoot(rootConfig?: RootConfig): Root {
return new Root(rootConfig);
}
export const benchmark = Benchmark;
/**
* Quick and dirty polyfills required by tinybench.
*/
if (typeof global.Event === 'undefined') {
global.Event = class Event {
constructor() {}
};
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}
if (typeof global.EventTarget === 'undefined') {
global.EventTarget = class EventTarget {
listeners: $FlowFixMe;
constructor() {
this.listeners = {};
}
addEventListener(type: string, cb: () => void) {
if (!(type in this.listeners)) {
this.listeners[type] = [];
}
this.listeners[type].push(cb);
}
removeEventListener(type: string, cb: () => void): void {
if (!(type in this.listeners)) {
return;
}
let handlers = this.listeners[type];
for (let i in handlers) {
if (cb === handlers[i]) {
handlers.splice(i, 1);
return;
}
}
}
dispatchEvent(type: string, event: Event) {
if (!(type in this.listeners)) {
return;
}
let handlers = this.listeners[type];
for (let i in handlers) {
handlers[i].call(this, event);
}
}
clearEventListeners() {
for (let i in this.listeners) {
delete this.listeners[i];
}
}
};
} else {
console.warn(
'The global Event class is already defined. If this API is already defined by React Native, you might want to remove this logic.',
);
}