Repository navigation
feat(vitest-plugin): add walltime forks and GC between rounds - #111
not-matthias wants to merge 21 commits into
Conversation
Benchmarks of one process share allocator, JIT and thread state, so what one benchmark leaves behind (raised mmap threshold, fragmented heap, optimized code, retained caches) changes the measurement of every benchmark after it. Adding or changing one benchmark can then shift unrelated results. Add `Isolation` for the integrations. It is on by default and turned off with `CODSPEED_PROCESS_ISOLATION=false`. `Isolation.run` re-executes the current command with `CODSPEED_ISOLATED_BENCHMARK` set to one benchmark URI, so each benchmark gets a fresh process. The child only runs that benchmark, which overrides user filters. - `CODSPEED_PROCESS_ISOLATION`: unset, `1` or `true` is on, `0` or `false` is off, anything else is an error. - `CODSPEED_ISOLATED_BENCHMARK`: set by the parent on each child. - `CODSPEED_ISOLATION_COMMAND`: JSON command to re-run, for runners that measure in worker processes whose argv is not the benchmark command. The child is started with `process.execPath` and `process.execArgv`, so the V8 flags set by the runner survive. A failing child ends the parent with the same exit code (128 + N when signal N killed it). Children run sequentially with `spawnSync`, which keeps the output in order. Re-exec is used instead of fork because fork only copies the calling thread.
Route every instrumented benchmark through `Isolation.run`. A child process only runs its own benchmark and skips the banner, the summary line and the completion listeners that the parent already handles. A run call that owns no benchmark of the child does not touch the instruments.
The analysis runner measures every task through `Isolation.run` (async and sync). Walltime runs and runs without active instrument hooks are not isolated. A child only measures its own task and stays quiet about the banner and the summary line.
Both the Vitest 5 provider and the legacy analysis runner measure through `Isolation.run`. An isolated child still reports every benchmark to Vitest, but only measures its own. Vitest measures in forked workers, and the argv of a worker is not the command that starts the benchmarks. The plugin records the command of the main Vitest process in `CODSPEED_ISOLATION_COMMAND` from its `config()` hook, before the workers are forked, and each worker re-runs that command once per benchmark. This means one full Vitest run per benchmark.
Run fixtures in simulation mode with the instrument hooks stubbed (they only exist under the CodSpeed runner) and check, for core, benchmark.js, tinybench and Vitest 5, that: - every benchmark runs in a fresh process by default; - `CODSPEED_PROCESS_ISOLATION=false` runs them all in one process; - a failing benchmark fails the run; - core forwards the child exit code (128 + N for signals) and the execArgv. Tests that drive a plugin in-process against a mocked core now set `CODSPEED_PROCESS_ISOLATION=false`, because isolation would start the test runner again for every benchmark.
Document the environment contract, the parent and child roles, and the Vitest and test caveats.
Avoid transforming and importing unrelated benchmark files in each child. Replace discovery globs before Vitest resolves its configuration, preserving fresh processes and profiling flags.
Keep environment alias validation in core instead of repeating it through every plugin. Retain real process, opt-out, failure, signal, and flag propagation checks while dropping getter-only assertions and redundant documentation.
[Medium impact] The changes affect Vitest walltime benchmark runs and their reported samples. The PR appears safe to merge, with non-blocking fixes recommended for failed-fork cleanup and promise-like hooks.
|
| let samples: number[] = []; | ||
| for (let fork = 0; fork < forks; fork++) { | ||
| const file = join(dir, `${fork}.json`); | ||
| runInChildProcess(uri, { [FORK_SAMPLES_FILE_ENV]: file }); |
There was a problem hiding this comment.
Failed forks leave files behind
runForks relies on finally to remove its temporary directory, but runInChildProcess calls process.exit() when a child fails. That skips cleanup and leaves any earlier fork's sample files behind. Repeated failures can build up files on long-lived workers. Return or throw the failure so cleanup finishes before exiting.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/isolation.ts
Line: 170
Comment:
**Failed forks leave files behind**
`runForks` relies on `finally` to remove its temporary directory, but `runInChildProcess` calls `process.exit()` when a child fails. That skips cleanup and leaves any earlier fork's sample files behind. Repeated failures can build up files on long-lived workers. Return or throw the failure so cleanup finishes before exiting.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if (result instanceof Promise) { | ||
| return result.then(collectGarbage); | ||
| } | ||
| collectGarbage(); |
There was a problem hiding this comment.
If a hook returns a promise from another JavaScript context or a promise-like object, withGcAfter fails the instanceof Promise check. It runs GC immediately and discards the returned value, so the benchmark can start before setup finishes. Recognize promise-like results and wait for them before collecting garbage, while keeping synchronous hooks synchronous.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/vitest-plugin/src/instrument.ts
Line: 48-51
Comment:
**Async setup can finish late**
If a hook returns a promise from another JavaScript context or a promise-like object, `withGcAfter` fails the `instanceof Promise` check. It runs GC immediately and discards the returned value, so the benchmark can start before setup finishes. Recognize promise-like results and wait for them before collecting garbage, while keeping synchronous hooks synchronous.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.The legacy analysis runner ran beforeAll/afterAll in the process that only starts children, so a child setting up the same resources (e.g. a fixed port) failed while the parent still held them.
A child skipped the complete listeners of runs that don't own its benchmark, so a benchmark started from such a listener was never measured.
4bde326 to
a415741
Compare
Merging this PR will degrade performance by 0.53%3 warnings, 1 note
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Isolation.run waited for the child with spawnSync. In a Vitest worker that blocked the worker's RPC with its main process for the whole child run, and under the simulation instrument the calls timed out (Timeout calling "onTaskUpdate"), failing the run.
a415741 to
aec7410
Compare
Separators are already normalized to slashes; escaping every glob metacharacter, backslash included, makes the escaping complete.
…e file path The walltime runner only registered URI prefixes for nested suites, so a bench declared outside a `describe` block reported `::<name>` to the runner while its results used `<file>::<name>`. The profile and results URIs then no longer matched and the benchmark had no flamegraph. Register the root file suite too, and add a top-level bench to the Vitest 3 and 4 examples. Refs COD-3984
Walltime rounds vary when a major GC lands in some of them, and a single process can carry JIT and allocator state that a fresh one would not. Two opt-in settings, modeled on JMH, target this: - CODSPEED_WALLTIME_GC_BETWEEN_ROUNDS=true adds --expose-gc and runs a full GC in tinybench's beforeEach hook, outside the timed call (Vitest 3, 4 and 5). - CODSPEED_WALLTIME_FORKS=<N> runs each benchmark in N fresh processes and reports all their rounds as one result (Vitest 3 and 4). Forks re-run the Vitest command restricted to one URI, like process isolation, and hand their samples to the parent through a file. The legacy runner now patches tinybench's Task prototype once, so new runner instances no longer stack root-frame wrappers.
With several forks, every fork paid the full warmup and measurement budget on top of its process startup, so F forks cost F full runs. Each fork now runs half the time and ceil(half) the rounds of both phases. A single fork keeps the full budget.
A failing fork exited the parent through process.exit(), which skipped the cleanup of the samples directory.
A beforeEach hook returning a promise from another realm or a thenable failed the instanceof check, so the GC ran before the hook finished.
runForks waited for each fork with spawnSync, blocking a Vitest worker's RPC with its main process for the whole run of all forks.
aec7410 to
4353e5d
Compare
860d65c to
8038822
Compare
8038822 to
86bdbbb
Compare
86bdbbb to
e9af7a1
Compare
TLDR: Two opt-in walltime knobs for the Vitest 3/4 plugin: run each benchmark in several fresh processes and merge their rounds, and run a full GC before every round. With several forks, each fork runs half the warmup and measurement budget.
CODSPEED_WALLTIME_FORKS=<N>: each benchmark runs inNfresh processes, one after the other. Each child re-runs the Vitest command restricted to its benchmark (CODSPEED_ISOLATED_BENCHMARK), writes its round samples toCODSPEED_FORK_SAMPLES_FILE, and the parent reports all rounds as one result. Unset: unchanged behavior.N > 1, each fork runs half the time andceil(half)the rounds of both warmup and measurement (FORK_BUDGET_FRACTION). Every fork pays the process startup anyway; without this,Nforks costNfull runs. A single fork keeps the full budget.CODSPEED_WALLTIME_GC_BETWEEN_ROUNDS=true: adds--expose-gcand runs a full GC in tinybench'sbeforeEach, outside the timed call (Vitest 3/4/5). It re-baselines a benchmark's results.Review notes
CODSPEED_WALLTIME_FORKSis set.Refs COD-3984