bench: add warm cached build benchmark to nested-deps-app-router-many-pages (#100570)
### What?
Adds `bench-warm-build.mjs` (plus a `bench-warm-build` script entry) to
`bench/nested-deps-app-router-many-pages`. It benchmarks warm Turbopack
compiles of `next build` with the persistent filesystem cache, after
changing N source files.
```bash
cd bench/nested-deps-app-router-many-pages
pnpm prepare-bench
node bench-warm-build.mjs --files=1000
```
### Why?
To measure the incremental rebuild work of a cached build after many
changes on a large app, e.g. for turbo-tasks scheduler changes like
#100561. Until now there was no easy way to do that.
### How?
- **Cold compile:** an optional cold compile (deletes `.next`) fills the
cache. It is reported but not counted.
- **Warm compiles:** by default, 20 warm compiles run
(`--iterations=20`). The first 5 are warm-up (`--warmup=5`): they are
printed but excluded from the statistics.
- Each warm iteration changes N component files from `components/`,
picked deterministically with a seeded shuffle.
- The change is a rendered JSX attribute with a per-run/per-iteration
value (digits only), so it changes the emitted code (not just a comment)
and always differs from what is cached. A file that is still changed
from an earlier, killed run is changed again rather than failing.
- **Statistics:** wall time and the "Compiled successfully in" time are
printed per iteration. Over the non-warm-up builds, the script prints
count, median, mean, sample standard deviation, min and max.
- **Compile only:** builds use `--experimental-build-mode=compile`, so
prerendering the 3,000 routes doesn't add noise.
- **Cache safety:** the build cache is enabled by default, so the app
config is unchanged. The script sets `TURBO_ENGINE_IGNORE_DIRTY=1`,
because a native binary built locally from a dirty checkout otherwise
disables the cache. It also fails if the build reports a disabled cache.
- **Restoring files:** changed files are restored after success,
failure, and SIGINT/SIGTERM. On a signal, it waits for the running build
to exit before restoring.
- **Options:** strict validation of `--files`, `--iterations`,
`--warmup` (must be smaller than `--iterations`), `--cold` and `--seed`.
### Results
Example output of this script with the default options (20 warm builds,
the first 5 excluded) for the native binary of this PR's base commit
(canary). Times are in seconds. "Compile" is the "Compiled successfully
in" time and "wall" is the whole `next build` process.
| | cold compile | cold wall | warm compile median (mean ± std dev,
min–max) | warm wall median (mean ± std dev, min–max) |
|---|---|---|---|---|
| `--files=0` | 23.6 | 33.4 | 7.5 (7.44 ± 0.17, 7.1–7.7) | 12.9 (12.86 ±
0.60, 11.9–13.9) |
| `--files=1000` | 24.9 | 37.8 | 8.6 (8.54 ± 0.17, 8.2–8.7) | 14.6
(14.51 ± 0.63, 13.2–15.5) |
- **Warm builds without changes:** a warm build takes about a third of
the cold compile time, even with no files changed (7.5 s vs 23.6 s).
- **1,000 changed components** add about 1.1 s of compile time on top of
that.
- **First warm build:** it is noticeably slower than the rest (14.2 s
compile with 1,000 files, 10.9 s with 0), so it is a warm-up build.
- **Setup:**
- Machine: 32 logical CPUs (Intel Xeon @ 2.90 GHz), 64 GiB RAM, Linux,
Node.js 24.
- App: the generated app with 3,000 pages and 3,020 components (`pnpm
prepare-bench`).
- Native binary: a locally built release build.
- **Caveats:** one machine and one generated app; the numbers vary
between runs and machines, so compare binaries on the same machine.
### Verification
- Ran end to end with locally built release native bindings on a 32-core
machine:
- cold + warm compiles, including the default 20/5 runs;
- the changed marker appears in the emitted chunks;
- the generated components are byte-identical afterwards, also after a
SIGINT during a build, with no leftover build process.
- The cache-disabled guard fails as expected when the ignore-dirty
override is removed and the binary was built from a dirty tree.
- Invalid options are rejected before any build: unknown, `__proto__`,
unsafe or non-integer values, `--iterations=0`, bad booleans, and
`--warmup` that is negative or not smaller than `--iterations`.
- The change regex matches both an unchanged `<div>` and one that was
already changed.
- prettier and eslint pass.
<!-- NEXT_JS_LLM -->
<!-- fleet 519ddd31-d876-47cd-9933-de3e7be9444a -->
---------
Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com>
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>