Skip to content

feat: run each benchmark in its own process - #101

Open
not-matthias wants to merge 13 commits into
mainfrom
cod-3960-implement-process-isolation-in-codspeed-node
Open

not-matthias wants to merge 13 commits into
mainfrom
cod-3960-implement-process-isolation-in-codspeed-node

Conversation

@not-matthias

@not-matthias not-matthias commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

TLDR: Benchmarks in one process share allocator and JIT state, so one benchmark shifts the next; each instrumented benchmark now runs in a fresh process (one Vitest run restricted to the target file per benchmark for the Vitest plugin).

  • Env: CODSPEED_PROCESS_ISOLATION (unset/1/true on by default, 0/false off, other values error), CODSPEED_ISOLATED_BENCHMARK=<uri> set on each child
  • The parent runs benchmarks one after the other, each in a child; the child runs only its URI and prints no banner or summary
  • Re-exec uses process.execPath + process.execArgv + process.argv.slice(1), so the V8 flags from the runner survive
  • Only instrumented runs (simulation/memory, hooks active) are affected; walltime and non-CodSpeed runs are untouched
  • The child filter overrides user filters
  • Child failure exits the parent with the same code (128 + N for signal N)
  • Tests: core (env, exit codes, execArgv), benchmark.js, tinybench (async and sync), Vitest 5, each checking PID counts on/off and a failing benchmark

Review notes

  • Vitest records CODSPEED_ISOLATION_COMMAND from the main process; each worker re-runs the Vitest command per benchmark, but isolated children replace benchmark discovery globs with their target file before configuration resolution. Unrelated benchmark files are not imported; configuration and setup files still run. The child's Vitest reporter output is not silenced
  • In-process tests against a mocked core must set CODSPEED_PROCESS_ISOLATION=false
  • The legacy Vitest 3/4 runner was not exercised with isolation on locally
  • The parent still runs setupCore, so it writes a perf map and environment file for a PID that measures nothing

Mirrors the codspeed-rust implementation (CodSpeedHQ/codspeed-rust, process isolation)

Closes COD-3960

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.
@not-matthias
not-matthias force-pushed the cod-3960-implement-process-isolation-in-codspeed-node branch from a33e439 to 4bd0611 Compare October 21, 2026 10:14
@codspeed

codspeed Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 77.26%

3 warnings, 1 note

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚠️ 32 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

ℹ️ 28 benchmarks have no flamegraph

The flamegraph wasn't generated: most of the time was spent in runtime internals, which would leave nothing useful to analyze.

See affected benchmarks

⚡ 9 improved benchmarks
❌ 94 regressed benchmarks
✅ 161 untouched benchmarks
🆕 9 new benchmarks
⏩ 56 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Memory short body 656 B 34,392 B -98.09%
❌ Memory long body 656 B 34,392 B -98.09%
❌ Memory switch 2 656 B 34,392 B -98.09%
❌ Memory wait 1ms 656 B 34,392 B -98.09%
❌ Memory test_iterative_fibo_100 1.3 KB 33.6 KB -96.19%
❌ Memory test_iterative_fibo_10 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_20 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_fibo_20 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_20 1.3 KB 33.6 KB -96.19%
❌ Memory test sync baz 10 1.3 KB 33.6 KB -96.19%
❌ Memory switch 2 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_30 1.3 KB 33.6 KB -96.19%
❌ Memory switch 2 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_fibo_20 1.3 KB 33.6 KB -96.19%
❌ Memory test_iterative_fibo_100 1.3 KB 33.6 KB -96.19%
❌ Memory test_iterative_fibo_10 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_10 1.3 KB 33.6 KB -96.19%
❌ Memory test sync baz 100 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_10 1.3 KB 33.6 KB -96.19%
❌ Memory test_recursive_cached_fibo_30 1.3 KB 33.6 KB -96.19%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing cod-3960-implement-process-isolation-in-codspeed-node (860d65c) with main (727b808)

Open in CodSpeed

Footnotes

  1. 56 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Avoid transforming and importing unrelated benchmark files in each child. Replace discovery globs before Vitest resolves its configuration, preserving fresh processes and profiling flags.
Comment thread packages/vitest-plugin/src/index.ts Fixed
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.
@greptile-apps

greptile-apps Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5

[Medium impact] The change reaches instrumented benchmark runs across three Node.js integrations.

Fix the legacy Vitest resource conflict and missing chained Benchmark.js measurements before merging.

Fix All in Claude CodeFindings

  1. P1 Suite setup conflicts with children ▶
  2. P1 Chained benchmarks disappear ▶
Fix with agent prompt
### Issue 1
packages/vitest-plugin/src/legacy/analysis.ts:50
The legacy runner starts each child while the parent's suite resources are still open. `runAnalysisBenchmarkSuite` calls `beforeAll` before this new `isolation.run` call, and `afterAll` runs only after the child finishes. If `beforeAll` starts a server on a fixed port, the child repeats that setup while the parent still owns the port and fails with `EADDRINUSE`.

Skip suite hooks in the process that only schedules children; run them in the child that measures the benchmark.

### Issue 2
packages/benchmark.js-plugin/src/index.ts:195-197
Skipping `complete` listeners in children breaks benchmarks started by those listeners. If the first suite's `complete` listener calls `second.run()`, the parent reaches the second suite, but its child restarts the script at the first suite. That first suite has no matching URI and returns without calling the listener, so `second.run()` is never reached. The child exits successfully, and the parent reports completion without measuring the second suite.

Preserve the callbacks needed to reach the selected benchmark, including when an earlier suite has no matching benchmark.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds default-on process isolation for instrumented Benchmark.js, Tinybench, and Vitest benchmarks.

  • Instrumented benchmarks can run in a fresh process for each benchmark.
  • Instrumented benchmark.js runs measure each benchmark in its own process.
  • Instrumented tinybench runs measure each task in its own process.
  • Instrumented Vitest benchmarks run in fresh processes and target only their own file.

Diagram

sequenceDiagram
  participant Parent as Benchmark parent
  participant Child as Fresh child
  participant Hooks as Measurement hooks
  Parent->>Parent: Discover benchmark URIs
  loop Each selected URI
    Parent->>Child: Restart command with isolated URI
    Child->>Child: Load definitions and select URI
    Child->>Hooks: Set up and measure benchmark
    Hooks-->>Child: Record child PID and URI
    Child-->>Parent: Exit status
  end
  Parent->>Parent: Report completion
Loading

Reviews (1) · Last reviewed commit: "test: trim redundant process isolation c..." · Reviewed by Greptile

await optimizeFunction(async () => {
if (!isolation.shouldRun(uri)) return;

await isolation.run(uri, async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Suite setup conflicts with children

The legacy runner starts each child while the parent's suite resources are still open. runAnalysisBenchmarkSuite calls beforeAll before this new isolation.run call, and afterAll runs only after the child finishes. If beforeAll starts a server on a fixed port, the child repeats that setup while the parent still owns the port and fails with EADDRINUSE.

Skip suite hooks in the process that only schedules children; run them in the child that measures the benchmark.

Knowledge Base Used: Vitest integration

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/vitest-plugin/src/legacy/analysis.ts
Line: 50

Comment:
**Suite setup conflicts with children**

The legacy runner starts each child while the parent's suite resources are still open. `runAnalysisBenchmarkSuite` calls `beforeAll` before this new `isolation.run` call, and `afterAll` runs only after the child finishes. If `beforeAll` starts a server on a fixed port, the child repeats that setup while the parent still owns the port and fails with `EADDRINUSE`.

Skip suite hooks in the process that only schedules children; run them in the child that measures the benchmark.

**Knowledge Base Used:** [Vitest integration](https://app.greptile.com/codspeed/-/custom-context/knowledge-base/codspeedhq/codspeed-node/-/docs/vitest-integration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +195 to +197
// The parent reports for every benchmark, the child ran only one of them.
if (!isChild) {
benchmarkCompletedListeners.forEach((listener) => listener());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Chained benchmarks disappear

Skipping complete listeners in children breaks benchmarks started by those listeners. If the first suite's complete listener calls second.run(), the parent reaches the second suite, but its child restarts the script at the first suite. That first suite has no matching URI and returns without calling the listener, so second.run() is never reached. The child exits successfully, and the parent reports completion without measuring the second suite.

Preserve the callbacks needed to reach the selected benchmark, including when an earlier suite has no matching benchmark.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/benchmark.js-plugin/src/index.ts
Line: 195-197

Comment:
**Chained benchmarks disappear**

Skipping `complete` listeners in children breaks benchmarks started by those listeners. If the first suite's `complete` listener calls `second.run()`, the parent reaches the second suite, but its child restarts the script at the first suite. That first suite has no matching URI and returns without calling the listener, so `second.run()` is never reached. The child exits successfully, and the parent reports completion without measuring the second suite.

Preserve the callbacks needed to reach the selected benchmark, including when an earlier suite has no matching benchmark.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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.
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.
Separators are already normalized to slashes; escaping every glob metacharacter, backslash included, makes the escaping complete.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants