Skip to content

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

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

vzaidman wants to merge 2 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), 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. 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.

…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.
@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: 85e07f2 · 7 commands run

This makes bundling_speed build a generated 5,000-module app with piped output, and applies an optional base.patch to every variant. Both earlier findings are fixed at this head: a stale base.patch now fails at the dry run and leaves the checkout clean, and the help documents that --build-command needs absolute paths. Base-plus-variant stacking, reverting after a failed build or Ctrl-C, and the bundle sizes in the description all held up when run, and the change only touches internal dev tooling.

No findings.

Verified
  • ✅ yarn jest --no-watchman scripts/benchmark-patches — 2 suites, 5 tests.
  • ✅ yarn eslint scripts/benchmark-patches
  • ✅ yarn prettier --check scripts/benchmark-patches/benchmark-patches.js scripts/benchmark-patches/run.js scripts/benchmark-patches/benchmarks/bundling_speed.js
  • ✅ yarn jest --no-watchman scripts/benchmark-patches/__tests__/review-repro-test.js (stale two-file base.patch) — Exits 1 at the --dry-run patch call. git status is clean, build.js is unchanged, and there is no .rej file.
  • ✅ yarn jest --no-watchman scripts/benchmark-patches/__tests__/review-repro-test.js (valid base.patch plus variant, --runs=2) — Used a wall-clock stand-in for GNU time. Unpatched logs have only the base line, variant logs have both. Sizes are 600,199 and 1,447,772 bytes, matching the description, and bundles are identical. Both patches reverted.
  • ✅ yarn jest --no-watchman scripts/benchmark-patches/__tests__/review-repro-test.js (SIGINT to the process group during a build) — The checkout was patched during the run. After SIGINT the process exited with code 1 within 39ms and git status was clean. Used the stand-in for GNU time, which ignores SIGINT as GNU time does.
  • ✅ yarn jest --no-watchman scripts/benchmark-patches/__tests__/review-repro-test.js (failed build, stdout and stderr captured separately) — A missing module's error goes to stderr, so it appears in the thrown error even though no log is written for a failed build. An earlier run with a broken base.patch also reverted cleanly and showed the build's stderr.

Not run: The #2170 table was not reproduced: this Mac has no GNU time at /usr/bin/time, so runs used a wall-clock stand-in and CPU time was not measured. Head and base were not benchmarked, because the PR changes only benchmark tooling, not Metro's runtime code. yarn typecheck was skipped because the scripts have no @flow annotation. Nothing was run on Windows; the tool already requires GNU time and patch. Some shell commands were denied by the permission check, so repros ran as a Jest test.

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

…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.

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