Skip to content

benchmark-patches: Build a generated app, and apply a base patch to every variant - #2171

Open
vzaidman wants to merge 3 commits into
react:mainfrom
vzaidman:vzaidman/benchmark-patches-generated-app
Open

vzaidman wants to merge 3 commits into
react:mainfrom
vzaidman:vzaidman/benchmark-patches-generated-app

Conversation

@vzaidman

@vzaidman vzaidman commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

bundling_speed needs an app passed on the command line, so its results depend on which app is available and can't be reproduced by others without it. It also writes the build's output to a log file, so Metro's Terminal behaves as it does when its output is redirected to a file, where stream.bytesWritten is missing.

  • bundling_speed takes no targets. It cold-builds, in prod and dev, an app of 5,000 generated CommonJS modules (a binary tree of requires rooted at m0.js), so anyone gets the same target. The app is generated in <tmpdir>/metro-benchmark-patches-app, with a babel.config.js that re-exports Metro's Babel config (Metro's polyfills are Flow source) and an empty .watchmanconfig.
  • Benchmarks export setUp(), called once after dist/ is cleared and before the warmup runs.
  • patches/base.patch, if present, is applied to every variant, including unpatched, and is not a variant itself. Other patches are diffs on top of it. Like variant patches, it gets a dry run first, so one that doesn't apply cleanly fails without leaving the checkout partly patched. E.g. a base patch that makes Metro print in bursts measures a patch's effect under that output.
  • Builds write stdout and stderr to pipes, like in most CI setups, and the log is written after the build. A failing command's error includes its stderr. --build-command runs from the generated app's directory, so paths in it must be absolute.

This was used to measure #2170 (results below).

Changelog: [Internal]

Test plan

  • yarn jest scripts/benchmark-patches
  • yarn eslint and yarn prettier --check on scripts/benchmark-patches sources

Benchmark of #2170

node scripts/benchmark-patches/benchmark-patches.js bundling_speed --runs=10, with a base.patch that makes metro build log 3 lines per transformed module (30,000 lines per build, interleaved with progress updates), and the Terminal.js and TerminalReporter.js changes of #2170 as the variant patch. Measured on this PR's base, e7abf93, with #2170's files as of afd65a3:

Bundle Variant Wall (s) Wall Δ CPU (s) CPU Δ Size (bytes) Same as unpatched
app-prod unpatched 8.4 ± 1.1 69.0 ± 4.9 600,199 yes
app-prod terminal-external-write-bursts 5.6 ± 0.2 -33.41% (t -8.1) 67.0 ± 2.8 -2.89% (t -1.1) 600,199 yes
app-dev unpatched 8.2 ± 0.6 54.9 ± 4.1 1,447,772 yes
app-dev terminal-external-write-bursts 5.1 ± 0.4 -38.25% (t -14.1) 55.2 ± 3.2 +0.62% (t 0.2) 1,447,772 yes

Values are mean ± sample sd over 10 runs per variant. t is Welch's t vs unpatched.

The saving is a roughly flat 2-3s per build, not faster bundling: the process no longer waits for status timers before exiting (on main, the trailing lodash.throttle timer, up to 3.5s after the last status line in non-interactive output). The generated app builds in about 5s, so the percentages are large. Longer builds, like real apps, save about the same seconds, which is a much smaller share of their wall time. CPU time doesn't change significantly, and bundles are byte-identical.

base.patch
diff --git a/packages/metro/src/commands/build.js b/packages/metro/src/commands/build.js
index af858666c..ee2f45492 100644
--- a/packages/metro/src/commands/build.js
+++ b/packages/metro/src/commands/build.js
@@ -133,6 +133,11 @@ export default (): CommandModule => ({
         transformedFileCount: number,
         totalFileCount: number,
       ): void => {
+        for (let i = 0; i < 3; i++) {
+          console.log(
+            `transformed ${transformedFileCount}/${totalFileCount} (${i})`,
+          );
+        }
         updateReporter.update({
           buildID: '$',
           type: 'bundle_transform_progressed',

Benchmark on an internal app

bundling_speed as on main, which takes an app as a target (5 runs per variant), on an internal app, built cold with Metro from source, with DEBUG=Metro:*, a patch that logs 3 lines per transformed module in the main process, and output piped. Its Metro didn't include #2162 yet, so main-terminal adds #2162, and terminal-external-write-bursts adds #2162 and #2170: #2170's effect is the difference between the two.

Bundle Variant Wall (s) Wall Δ CPU (s) CPU Δ Same as unpatched
internal-app-prod unpatched 107.6 ± 1.9 1376.7 ± 6.2 yes
internal-app-prod main-terminal 108.2 ± 2.9 +0.58% (t 0.4) 1385.1 ± 13.4 +0.61% (t 1.3) yes
internal-app-prod terminal-external-write-bursts 107.7 ± 1.1 +0.14% (t 0.2) 1385.5 ± 9.8 +0.64% (t 1.7) yes
internal-app-dev unpatched 93.5 ± 2.4 1089.8 ± 14.4 yes
internal-app-dev main-terminal 93.5 ± 1.3 -0.03% (t -0.0) 1087.3 ± 10.3 -0.23% (t -0.3) yes
internal-app-dev terminal-external-write-bursts 94.1 ± 2.3 +0.63% (t 0.4) 1090.6 ± 9.1 +0.08% (t 0.1) yes

Values are mean ± sample sd. t is Welch's t vs unpatched; at 5 runs, |t| < 2.3 is not significant (p > 0.05).

No wall time or CPU change is significant, and bundles are byte-identical. This app's build command already flushes its terminal when the build finishes, so there's no exit wait to save; the burst handling adds no measurable cost on a large app under heavy logging.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 4, 2026
@vzaidman
vzaidman marked this pull request as ready for review November 4, 2026 20:22
@vzaidman
vzaidman requested review from huntie and robhogan November 4, 2026 20:22
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Metro review: Looks good

Risk: low · Commit: 7bb4411 · 2 commands run

This PR changes bundling_speed so it no longer takes a target app. It now cold-builds a generated app of 5,000 CommonJS modules with piped stdio, and applies an optional patches/base.patch to every variant, including unpatched. The head is the same commit I reviewed last time. All three earlier findings are fixed in the code, and the scope is internal benchmark tooling only, so I have no findings.

No findings.

Verified
  • ✅ yarn jest --no-watchman scripts/benchmark-patches — 2 suites, 5 tests
  • ✅ yarn eslint scripts/benchmark-patches

Not run: I didn't do a full benchmark-patches.js bundling_speed run. It needs GNU time at /usr/bin/time and many minutes of cold builds, and the code under review is benchmark tooling, not Metro runtime code, so no benchmark of Metro itself applies. The #2170 numbers in the description weren't reproduced, because they belong to that PR. Windows wasn't checked: the script needs /usr/bin/time and is POSIX-only by design.

Automated review by Claude. Advisory only: it never approves or blocks, and a maintainer makes the call.

…very variant

Summary:
`bundling_speed` needed an app passed on the command line, so its results depended on which app was available and couldn't be reproduced by others without it. It also wrote the build's output to a log file, so Metro's `Terminal` behaved as it does when its output is redirected to a file, where `stream.bytesWritten` is missing.

- `bundling_speed` takes no targets. It cold-builds, in prod and dev, an app of 5,000 generated CommonJS modules (a binary tree of `require`s rooted at `m0.js`), so anyone gets the same target. The app is generated in `<tmpdir>/metro-benchmark-patches-app`, with a `babel.config.js` that re-exports Metro's Babel config (Metro's polyfills are Flow source), an empty `.watchmanconfig`, and a `metro.config.js` that resolves `asyncRequire` with `metro:runtime/...`.
- Benchmarks export `setUp()`, called once after `dist/` is cleared and before the warmup runs.
- `patches/base.patch`, if present, is applied to every variant, including unpatched, and is not a variant itself. Other patches are diffs on top of it. E.g. a base patch that makes Metro print in bursts measures a patch's effect under that output.
- Builds write stdout and stderr to pipes, like in most CI setups, and the log is written after the build. A failing command's error includes its stderr.

Changelog: [Internal]

Test Plan:
- `yarn jest scripts/benchmark-patches`
- `yarn eslint` and `yarn prettier --check` on `scripts/benchmark-patches` sources
- `node scripts/benchmark-patches/benchmark-patches.js bundling_speed --runs=10` with a `base.patch` that logs 3 lines per transformed module in `metro build`, and a patch with the Terminal changes for bursts of external writes.
…t --build-command's directory

- `base.patch` gets a dry run before it's applied, like variant patches, so one that doesn't apply cleanly fails without leaving the checkout partly patched.
- The help states that `--build-command` runs from the generated app's directory, so paths in it must be absolute.
@vzaidman
vzaidman force-pushed the vzaidman/benchmark-patches-generated-app branch from f90299d to 23823a0 Compare November 6, 2026 12:52
`asyncRequireModulePath` defaults to `metro:runtime/modules/asyncRequire`, so the generated app needs no Metro config.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant