perf: serve warm modules to workers in one round-trip, enable the node compile cache - #10708
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
@vitest/browser
@vitest/browser-playwright
@vitest/browser-preview
@vitest/coverage-istanbul
@vitest/coverage-v8
@vitest/expect
@vitest/mocker
@vitest/pretty-format
@vitest/snapshot
@vitest/spy
@vitest/ui
@vitest/utils
vitest
@vitest/web-worker
commit: |
e2513ed to
053dce5
Compare
4acf60a to
4caa27d
Compare
AriPerkkio
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
What does this mean in practice?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Now that _vitest_tmp is used/leaking outside the fetchModule.ts, maybe it's time to add it to typings next to __vitestTmp.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Curious how much this speeds up test runs on its own 👀
4caa27d to
98bc513
Compare
✅ Deploy Preview for vitest-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
| ...project.config.env, | ||
| } | ||
|
|
||
| // V8 serializes compile-cached scripts without the source positions |
There was a problem hiding this comment.
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.
|
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.
|
I also added the cache for the moduleType, otherwise we need to resolve it all the time |
There was a problem hiding this comment.
Was thinking to point out in #10709 that if we fs cache moduleType then we need to bust config hash with injectCjsGlobal flag.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Two runtime performance changes.
What changed
1. Warm-module snapshot (
fetchWarmModules). Withisolate: 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-modulefetchpath. Externalize verdicts for resolved urls ride the same snapshot.Correctness notes:
isolate: false) workers in sync in watch mode.vi.mocksemantics are unchanged.2. Node compile cache. The
vitestbin now callsmodule.enableCompileCache()before importing the CLI and propagatesNODE_COMPILE_CACHEto 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-setNODE_COMPILE_CACHEandNODE_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.fsModuleCachenow 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/hugebracket 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 onmaindirectly and the numbers were not re-run — the changes themselves are unchanged.fsModuleCacheWhy 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
fsModuleCachesessions 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 offsModuleCache. Reproduced on the original stack (base = #10685, this PR cherry-picked on top), same fixtures and harness,minof 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 cachebelow ==fsModuleCache offfurther 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.jsdirectly (the bin is bypassed, so the compile cache is off for that row).fsModuleCache(warm)cacheFs: true), so the snapshot already has on-disk modules to serve withoutfsModuleCache.fsModuleCachethe snapshot finds nothing on disk and falls back to per-module fetches — the threads delta in that row is the compile cache.fsModuleCachewrites on-disk copies for every pool, which is what makes the snapshot pay off on threads as well.tinyabove).fsModuleCacheis the largest lever (−15…−33 additional pts). It is default-on (separate PR), so thefsModuleCachecolumn is the default steady state, not an opt-in.fsModuleCachecold vs warmThe
fsModuleCachegain 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 tofsModuleCacheoff):fsModuleCacheThe 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
fsModuleCachedefault-on, the warm row is what a repeat run — or a CI shard with a restored cache — actually gets.