Skip to content

Commit 941bc83

Browse files
authored
perf: make the Node compile cache opt-in, persist worker caches on teardown (#10742)
1 parent 96fa6d7 commit 941bc83

8 files changed

Lines changed: 139 additions & 18 deletions

File tree

‎docs/guide/improving-performance.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ Duration 8.75s (transform 4.02s, setup 629ms, import 5.52s, tests 2.52s, enviro
9191
Duration 5.90s (transform 842ms, setup 543ms, import 2.35s, tests 2.94s, environment 0ms, prepare 3ms)
9292
```
9393

94+
## Node Compile Cache
95+
96+
Vitest supports Node's [on-disk compile cache](https://nodejs.org/api/cli.html#node_compile_cachedir): when the `NODE_COMPILE_CACHE` environment variable points at a directory, the V8 bytecode of Vitest's own modules and of your externalized dependencies is written to disk and reused by later runs instead of being recompiled. Vitest propagates the variable to every worker, and workers persist the modules they compiled when they shut down.
97+
98+
```shell
99+
NODE_COMPILE_CACHE=node_modules/.cache/node-compile-cache vitest
100+
```
101+
102+
The first run with an empty directory pays for serializing the compiled modules, so this is only worth enabling when the directory survives between runs: local runs, or CI pipelines that cache the directory. `NODE_DISABLE_COMPILE_CACHE=1` disables the cache entirely, taking precedence over `NODE_COMPILE_CACHE`.
103+
104+
Note that Vitest automatically disables the compile cache in workers when the `v8` coverage provider is enabled — V8 serializes cached scripts without the source positions that precise coverage relies on.
105+
94106
## Pool
95107

96108
By default Vitest runs tests in `pool: 'forks'`. While `'forks'` pool is better for compatibility issues ([hanging process](/guide/common-errors.html#failed-to-terminate-worker) and [segfaults](/guide/common-errors.html#segfaults-and-native-code-errors)), it may be slightly slower than `pool: 'threads'` in larger projects.

‎packages/vitest/src/runtime/workers/init.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { WorkerRequest, WorkerResponse } from '../../node/pools/types'
22
import type { MetaEnv, WorkerSetupContext } from '../../types/worker'
33
import type { FileSpecification } from '../runner/types'
44
import type { VitestWorker } from './types'
5+
// default import: `flushCompileCache` only exists since Node 22.10, a named
6+
// import would fail to link on older versions
7+
import Module from 'node:module'
58
import { serializeError } from '@vitest/utils/error'
69
import { disableDefaultColors } from 'tinyrainbow'
710
import { Traces } from '../../utils/traces'
@@ -241,6 +244,20 @@ export function init(worker: Options): void {
241244
case 'stop': {
242245
await runPromise
243246

247+
// Persist this worker's compile cache before the parent tears the
248+
// worker down — forks are SIGTERM'd and never reach Node's exit-time
249+
// flush, so without this the cache stays write-only for them. Runs
250+
// even when teardown throws (the compiled modules are still worth
251+
// persisting). A no-op when the cache is disabled or was fully loaded
252+
// from disk, and cheap (~tens of ms) otherwise, so every worker can
253+
// afford it.
254+
const persistCompileCache = () => {
255+
try {
256+
Module.flushCompileCache?.()
257+
}
258+
catch {}
259+
}
260+
244261
try {
245262
const context = traces.getContextFromCarrier(message.otelCarrier)
246263

@@ -256,9 +273,13 @@ export function init(worker: Options): void {
256273

257274
await traces.finish()
258275

276+
persistCompileCache()
277+
259278
send({ type: 'stopped', error, __vitest_worker_response__ })
260279
}
261280
catch (error) {
281+
persistCompileCache()
282+
262283
send({ type: 'stopped', error: serializeError(error), __vitest_worker_response__ })
263284
}
264285

‎packages/vitest/vitest.mjs‎

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,2 @@
11
#!/usr/bin/env node
2-
import * as module from 'node:module'
3-
4-
// Enable Node's on-disk compile cache before importing the CLI so both the CLI
5-
// graph and (via the inherited env variable) every spawned worker skip V8
6-
// recompilation of unchanged modules. `enableCompileCache()` only affects the
7-
// current process — child processes pick the cache up from NODE_COMPILE_CACHE.
8-
// Respects an explicit NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE; the
9-
// API is not available before Node 22.8.
10-
try {
11-
const result = module.enableCompileCache?.()
12-
if (result?.directory && !process.env.NODE_COMPILE_CACHE) {
13-
process.env.NODE_COMPILE_CACHE = result.directory
14-
}
15-
}
16-
catch {}
17-
18-
// eslint-disable-next-line antfu/no-top-level-await -- the import must not be hoisted above `enableCompileCache`
19-
await import('./dist/cli.js')
2+
import './dist/cli.js'

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { expect, test } from 'vitest'
2+
3+
test('worker sees the expected compile cache environment', () => {
4+
// set when the spec expects the v8 coverage provider to strip the cache
5+
if (process.env.EXPECT_COVERAGE_STRIPPED) {
6+
expect(process.env.NODE_COMPILE_CACHE).toBeUndefined()
7+
expect(process.env.NODE_DISABLE_COMPILE_CACHE).toBe('1')
8+
}
9+
// gate on the discriminator between the spec's runs, not on
10+
// EXPECTED_COMPILE_CACHE_DIR itself — if the spec's env stops propagating,
11+
// this fails loudly (toBe(undefined)) instead of passing vacuously
12+
else if (!process.env.NODE_DISABLE_COMPILE_CACHE) {
13+
expect(process.env.NODE_COMPILE_CACHE).toBe(process.env.EXPECTED_COMPILE_CACHE_DIR)
14+
}
15+
expect(document).toBeDefined()
16+
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
watch: false,
6+
environment: 'jsdom',
7+
pool: 'forks',
8+
},
9+
})

‎test/e2e/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@vitejs/test-dep-virtual": "file:./deps/test-dep-virtual",
2525
"@vitest/browser-playwright": "workspace:*",
2626
"@vitest/browser-preview": "workspace:*",
27+
"@vitest/coverage-v8": "workspace:*",
2728
"@vitest/mocker": "workspace:*",
2829
"@vitest/test-dep-optimizer-external": "file:./deps/optimizer/external",
2930
"@vitest/test-dep-optimizer-optimized": "file:./deps/optimizer/optimized",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
2+
import { join, resolve } from 'node:path'
3+
import { expect, test } from 'vitest'
4+
import { runVitestCli } from '../../test-utils'
5+
6+
const fixture = resolve(import.meta.dirname, '../fixtures/compile-cache')
7+
const cacheDir = resolve(fixture, 'node_modules/.cache/compile-cache-test')
8+
9+
function cacheEntries(): string[] {
10+
return readdirSync(cacheDir, { recursive: true, encoding: 'utf-8' })
11+
.filter(file => statSync(join(cacheDir, file)).isFile())
12+
}
13+
14+
test('NODE_COMPILE_CACHE redirects the compile cache and the worker persists its graph', async () => {
15+
rmSync(cacheDir, { recursive: true, force: true })
16+
17+
// the fixture test asserts that the worker sees this exact NODE_COMPILE_CACHE
18+
const { exitCode } = await runVitestCli(
19+
{ nodeOptions: { env: {
20+
NODE_COMPILE_CACHE: cacheDir,
21+
EXPECTED_COMPILE_CACHE_DIR: cacheDir,
22+
} } },
23+
'run',
24+
'--root',
25+
fixture,
26+
)
27+
28+
expect(exitCode).toBe(0)
29+
30+
// jsdom is only loaded inside the worker, so its modules can reach the
31+
// cache only through the worker flush — the CLI graph alone is ~150 entries,
32+
// the worker graph pushes it past 1000
33+
expect(cacheEntries().length).toBeGreaterThan(300)
34+
})
35+
36+
test('NODE_DISABLE_COMPILE_CACHE wins over NODE_COMPILE_CACHE', async () => {
37+
rmSync(cacheDir, { recursive: true, force: true })
38+
39+
const { exitCode } = await runVitestCli(
40+
{ nodeOptions: { env: {
41+
NODE_COMPILE_CACHE: cacheDir,
42+
NODE_DISABLE_COMPILE_CACHE: '1',
43+
} } },
44+
'run',
45+
'--root',
46+
fixture,
47+
)
48+
49+
expect(exitCode).toBe(0)
50+
expect(existsSync(cacheDir)).toBe(false)
51+
})
52+
53+
test('the cache is stripped from workers when v8 coverage is enabled', async () => {
54+
rmSync(cacheDir, { recursive: true, force: true })
55+
56+
const { exitCode } = await runVitestCli(
57+
{ nodeOptions: { env: {
58+
NODE_COMPILE_CACHE: cacheDir,
59+
EXPECT_COVERAGE_STRIPPED: '1',
60+
} } },
61+
'run',
62+
'--root',
63+
fixture,
64+
'--coverage.enabled',
65+
'--coverage.provider=v8',
66+
'--coverage.reporter=text',
67+
)
68+
69+
expect(exitCode).toBe(0)
70+
71+
// the CLI process itself still persists its own graph (Node enabled its
72+
// cache from the env var at startup, before coverage is known), but the
73+
// worker's jsdom graph must not reach the cache
74+
const entries = existsSync(cacheDir) ? cacheEntries() : []
75+
expect(entries.length).toBeLessThan(300)
76+
})

0 commit comments

Comments
 (0)