Skip to content

perf: serve warm modules to workers in one round-trip, enable the node compile cache - #10708

Merged
sheremet-va merged 9 commits into
mainfrom
perf/warm-modules
Jul 8, 2026
Merged

perf: serve warm modules to workers in one round-trip, enable the node compile cache#10708
sheremet-va merged 9 commits into
mainfrom
perf/warm-modules

Conversation

@sheremet-va

@sheremet-va sheremet-va commented Jul 4, 2026

Copy link
Copy Markdown
Member

Two runtime performance changes.

What changed

1. Warm-module snapshot (fetchWarmModules). With isolate: true, every fresh worker fetched each module of its test file's import graph through one strictly sequential RPC round-trip — on an app-shaped fixture that is ~122 round-trips per file and ~97 ms of pure serial waiting, even when the server had already transformed every module. Most of the per-call latency is queueing on the main process event loop, which serves all workers at once, so the cost compounds with worker count.

Workers now ask the server once per run request for every module of their files' import graphs that is already stored on disk (the forks pool tmp files, or experimental.fsModuleCache) and read the code directly; only misses go through the per-module fetch path. Externalize verdicts for resolved urls ride the same snapshot.

Correctness notes:

  • The snapshot is requested at the first dependency fetch — by then the entry file's transform has connected its import graph on the server, so the snapshot actually covers the file's transitive dependencies.
  • It is re-requested per run request, and the server builds it from the live module graph: a file edit invalidates the module server-side and it simply drops out of the next snapshot, keeping reused (isolate: false) workers in sync in watch mode.
  • The mock/builtin resolution ladder runs before the snapshot lookup, so vi.mock semantics are unchanged.
  • Only externalize verdicts for already-resolved urls are shared (an unresolved bare specifier resolves through the requesting environment's plugin container, so its verdict is environment/importer-specific), keyed by the Vite server instance so a restart drops them.

2. Node compile cache. The vitest bin now calls module.enableCompileCache() before importing the CLI and propagates NODE_COMPILE_CACHE to workers (child processes do not inherit it otherwise). The CLI graph and every worker's bundle skip V8 recompilation across processes and runs. Respects a pre-set NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE; no-op before Node 22.8. Workers get the cache disabled when coverage is enabled, because V8 serializes compile-cached scripts without the source positions precise coverage relies on.

3. experimental.fsModuleCache now remembers the on-disk location when saving a fresh transform (previously only when reading it back), so the snapshot is effective in the first session already, not only in the next one.

Benchmarks

App fixture = 176 TS source modules + 40 test files with heavily overlapping import graphs (layered utils/services/features, a 60-export barrel, a depth-15 chain, one externalized dep); tiny/huge bracket the startup-dominated and execution-dominated extremes. min of N interleaved runs, Apple Silicon 10 cores, Node 24. Measured while this PR was stacked on #10685 (the base column is that branch); the PR is now based on main directly and the numbers were not re-run — the changes themselves are unchanged.

benchmark base this PR this PR + fsModuleCache
tiny (1 test, 1 file, forks) 267 ms 205 ms (−23%) 152 ms (−43%)
huge (10k tests, 1 file, forks) 921 ms 864 ms (−6%) 574 ms (−38%)
app node, forks isolate:true (default) 1266 ms 1050 ms (−17%) 751 ms (−41%)
app node, forks isolate:false 629 ms 613 ms (−3%) 346 ms (−45%)
app node, threads isolate:true 967 ms 773 ms (−20%) 652 ms (−33%)
app node, threads isolate:false 481 ms 411 ms (−15%) 283 ms (−41%)
app jsdom, forks isolate:true 3809 ms 3393 ms (−11%) 3209 ms (−16%)
app jsdom, forks isolate:false 1184 ms 1095 ms (−8%) 880 ms (−26%)
app happy-dom, forks isolate:true 2134 ms 1969 ms (−8%) 1787 ms (−16%)

Why the shape of the numbers: the snapshot removes a cost that scales with modules × test files (biggest on isolated many-file runs and on warm fsModuleCache sessions where transform work is already gone), while the compile cache removes a fixed per-process boot cost (biggest on single-file runs and visible in every worker). The jsdom rows dilute in relative terms because jsdom setup (~550 ms per worker) dominates isolated DOM runs — that cost is untouched here and remains the main argument for the environment diagnostic in #10710.

Decomposition and cold/warm follow-up

The table above is base → PR → PR + fsModuleCache. These two blocks break that down: which of the three changes each delta comes from, and the cold-vs-warm behaviour of fsModuleCache. Reproduced on the original stack (base = #10685, this PR cherry-picked on top), same fixtures and harness, min of N interleaved runs, Apple Silicon 10-core / Node 24. Each block is its own session — compare per-row deltas, not absolute ms across blocks (the two blocks are self-consistent where they overlap: + compile cache below == fsModuleCache off further down, and + fsModuleCache (warm) == warm).

Per-lever contribution

Each row turns on one more lever on top of the previous. warm-modules is measured by running dist/cli.js directly (the bin is bypassed, so the compile cache is off for that row).

stacked on #10685 app forks isolate:true app threads isolate:true app forks isolate:false
base 1167 ms 880 ms 673 ms
+ warm-modules 1061 ms (−9%) 850 ms (−3%) 607 ms (−10%)
+ compile cache 1018 ms (−13%) 799 ms (−9%) 577 ms (−14%)
+ fsModuleCache (warm) 789 ms (−32%) 663 ms (−25%) 352 ms (−48%)
  • warm-modules carries the isolated-forks win on its own (−9…−10%): the forks pool writes tmp copies (cacheFs: true), so the snapshot already has on-disk modules to serve without fsModuleCache.
  • On threads warm-modules alone is ~0 (−3%, noise): threads don't write tmp copies, so without fsModuleCache the snapshot finds nothing on disk and falls back to per-module fetches — the threads delta in that row is the compile cache. fsModuleCache writes on-disk copies for every pool, which is what makes the snapshot pay off on threads as well.
  • compile cache is a flat ~−4…−6 pts everywhere, and the dominant lever on single-file runs (tiny above).
  • fsModuleCache is the largest lever (−15…−33 additional pts). It is default-on (separate PR), so the fsModuleCache column is the default steady state, not an opt-in.

fsModuleCache cold vs warm

The fsModuleCache gain is a warm-cache steady state; the run that populates the cache pays for it. App fixture, forks isolate:true, on this PR (compile cache + Vite optimizeDeps kept warm, so cache presence is the only variable; % relative to fsModuleCache off):

fsModuleCache wall (min) transform phase
off 1010 ms 2.06 s
cold — first run, writes 217 files / 0.9 MB 1080 ms (+7%) 2.20 s
warm — cache present 780 ms (−23%) 0.52 s

The cold run is slightly slower (full transforms plus the disk writes); the warm run collapses the transform phase (2.06 s → 0.52 s) because a fresh worker reads the transformed modules off disk instead of re-transforming them. Keys are content-hashed, so an edit invalidates only the changed module and leaves the rest warm. With fsModuleCache default-on, the warm row is what a repeat run — or a CI shard with a restored cache — actually gets.

@github-actions github-actions Bot added the maybe automated User is likely an AI agent, or the content was generated by an AI assistant without user control label Jul 4, 2026
@github-actions

This comment was marked as outdated.

@sheremet-va sheremet-va removed the maybe automated User is likely an AI agent, or the content was generated by an AI assistant without user control label Jul 4, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown
@vitest/browser

npm i https://pkg.pr.new/@vitest/browser@08daac3

@vitest/browser-playwright

npm i https://pkg.pr.new/@vitest/browser-playwright@08daac3

@vitest/browser-preview

npm i https://pkg.pr.new/@vitest/browser-preview@08daac3

@vitest/coverage-istanbul

npm i https://pkg.pr.new/@vitest/coverage-istanbul@08daac3

@vitest/coverage-v8

npm i https://pkg.pr.new/@vitest/coverage-v8@08daac3

@vitest/expect

npm i https://pkg.pr.new/@vitest/expect@08daac3

@vitest/mocker

npm i https://pkg.pr.new/@vitest/mocker@08daac3

@vitest/pretty-format

npm i https://pkg.pr.new/@vitest/pretty-format@08daac3

@vitest/snapshot

npm i https://pkg.pr.new/@vitest/snapshot@08daac3

@vitest/spy

npm i https://pkg.pr.new/@vitest/spy@08daac3

@vitest/ui

npm i https://pkg.pr.new/@vitest/ui@08daac3

@vitest/utils

npm i https://pkg.pr.new/@vitest/utils@08daac3

vitest

npm i https://pkg.pr.new/vitest@08daac3

@vitest/web-worker

npm i https://pkg.pr.new/@vitest/web-worker@08daac3

commit: 08daac3

AriPerkkio
AriPerkkio previously approved these changes Jul 6, 2026

@AriPerkkio AriPerkkio left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So when worker requests test file (or anything), main thread instructs where the dependencies of the requested file are on the cache, so that no new RPC calls need to be made? Sounds like good optimization.

...project.config.env,
}

// V8 serializes compile-cached scripts without the source positions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean in practice?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is basically called out in https://nodejs.org/api/module.html#limitations-of-the-compile-cache

Currently when using the compile cache with V8 JavaScript code coverage, the coverage being collected by V8 may be less precise in functions that are deserialized from the code cache. It's recommended to turn this off when running tests to generate precise coverage.

// pool (`cacheFs`) or by `experimental.fsModuleCache` — the worker can
// read the file itself instead of fetching each module separately.
// invalidated modules lose `transformResult` and drop out automatically
const tmp = transformResult.__vitestTmp ?? (transformResult as { _vitest_tmp?: string })._vitest_tmp

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that _vitest_tmp is used/leaking outside the fetchModule.ts, maybe it's time to add it to typings next to __vitestTmp.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened a separate PR to merge them, actually - #10733

// API is not available before Node 22.8.
try {
const result = module.enableCompileCache?.()
if (result?.directory && !process.env.NODE_COMPILE_CACHE) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious how much this speeds up test runs on its own 👀

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

around 8%

@sheremet-va
sheremet-va changed the base branch from perf/improve-loading-perf to main July 7, 2026 10:22
@sheremet-va
sheremet-va dismissed AriPerkkio’s stale review July 7, 2026 10:22

The base branch was changed.

@netlify

netlify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Name Link
🔨 Latest commit 62cbb17
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a4dfb91ee5f660008f33d21
😎 Deploy Preview https://deploy-preview-10708--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool!

...project.config.env,
}

// V8 serializes compile-cached scripts without the source positions

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is basically called out in https://nodejs.org/api/module.html#limitations-of-the-compile-cache

Currently when using the compile cache with V8 JavaScript code coverage, the coverage being collected by V8 may be less precise in functions that are deserialized from the code cache. It's recommended to turn this off when running tests to generate precise coverage.

Comment thread packages/vitest/src/node/pool.ts Outdated
Comment thread packages/vitest/src/node/pools/rpc.ts Outdated
@hi-ogawa

hi-ogawa commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Also another AI artifact to help me picture the idea https://artifacts.hiro18181.workers.dev/vitest-pr-10708-warm-modules#example

The `fetchWarmModules` fast path serves inline modules to fresh workers
without the `moduleType` tag the direct-fetch path attaches (#10709).
With `injectCjsGlobals: false` the evaluator injects the CommonJS scope
only into modules tagged `moduleType: 'cjs'`, so a CommonJS dependency
read back from the snapshot evaluated without `require`/`module`/
`__dirname` and threw "require is not defined" — the first file worked
(direct fetch) while every later file served from the snapshot failed.

Recompute the tag in `fetchWarmModules` with the same `detectModuleType`
the direct-fetch path uses, gated on `injectCjsGlobals === false` so the
default path pays nothing.
Disabling the compile cache under coverage was only needed for the v8
provider, which reads V8's built-in coverage and loses source-position
precision on scripts deserialized from the cache. istanbul instruments
the source at transform time, so the compile cache is harmless for it —
narrow the guard so istanbul runs keep the boot speedup.
`fetchWarmModules` walks each environment's own module graph for inline
modules but merged externalize verdicts from a server-wide map into every
environment's snapshot. A leading-slash url still resolves to its id
through the environment's plugin container, so a plugin that resolves
conditionally (e.g. on `this.environment`) can externalize a url in one
environment and inline it in another; sharing the verdict across
environments could serve the wrong one to a fresh worker.

Key `warmExternals` by DevEnvironment so the cache scope matches the
inline path. Environments are recreated with the server, so the verdicts
still drop on a restart.
The module type that decides CJS-globals injection (when `injectCjsGlobals`
is disabled) was re-detected on every fetch and, worse, recomputed in
`fetchWarmModules` for every module of every worker's run request. It is a
pure function of the module, so detect it once and reuse it:

- memoize the verdict on the transform result (`__vitestModuleType`), next
  to `__vitestTmp`, so repeat fetches and the warm snapshot read it back
  instead of re-detecting;
- persist it in the fsModuleCache entry alongside the other metadata, so a
  warm cache (or a restored CI cache) skips detection across sessions too;
  it revalidates with the rest of the entry when the lockfile changes.

`fetchWarmModules` now just reads the memoized value, which drops the
per-snapshot detection and its source-file reads from the hot path.
@sheremet-va

Copy link
Copy Markdown
Member Author

I also added the cache for the moduleType, otherwise we need to resolve it all the time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was thinking to point out in #10709 that if we fs cache moduleType then we need to bust config hash with injectCjsGlobal flag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also technically I don't think fs caching moduleType is too crucial for warm snapshot design because the single warm snapshot can still batch compute moduleType so rpc saving is effective.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also technically I don't think fs caching moduleType is too crucial for warm snapshot design because the single warm snapshot can still batch compute moduleType so rpc saving is effective.

It will recompute it for every worker every time; it doesn't affect performance much, but why do that when we can just cache it, like we do other things

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

@sheremet-va
sheremet-va merged commit b144ab5 into main Jul 8, 2026
27 of 28 checks passed
@sheremet-va
sheremet-va deleted the perf/warm-modules branch July 8, 2026 08:59
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants