Skip to content

feat: run each analysis benchmark in its own process - #122

Open
not-matthias wants to merge 7 commits into
mainfrom
cod-4156-support-process-isolation-in-simulation
Open

not-matthias wants to merge 7 commits into
mainfrom
cod-4156-support-process-isolation-in-simulation

Conversation

@not-matthias

@not-matthias not-matthias commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

TLDR: Simulation and memory runs measured every benchmark in one shared process, so allocator, JIT and heap state from earlier benchmarks leaked into later ones. Each analysis benchmark now runs in its own fresh process, reusing the walltime fork machinery (tinybench and Vitest 5).

  • core: analysisIsolation() reads the same env as walltimeIsolation(), but always uses 1 fork. CODSPEED_WALLTIME_FORKS and forks options don't apply. CODSPEED_PROCESS_ISOLATION=false restores the shared process.
  • tinybench: the parent runs each task via runForks(uri, 1); the child measures only that task.
  • vitest (v5): workers queue analysis benchmarks; the main process runs one vitest bench per benchmark after the run. Vitest 3/4 and benchmark.js are unchanged.
  • Under valgrind --tool=callgrind --trace-children=yes (runner args), examples/with-typescript-simple-esm and examples/with-vitest-v5 produce one dump per benchmark, each from a distinct PID, none from the parent. switch 1/switch 2 measure 4,625/4,683 Ir (shared process: 4,625/3,152).

Review notes

  • Stacked on fix: keep one-time setup costs out of the first measured benchmark #123. Without it, every isolated benchmark pays the first-benchmark setup costs (about +230–300 µs per tinybench benchmark on CI).
  • Analysis forks write empty samples. They only tell the parent that the child measured its benchmark, so a URI mismatch fails loudly instead of silently measuring nothing.
  • Each benchmark now pays a full Node (or Vitest) startup under Valgrind: on CI, tinybench simulation went 5m47 → 9m41 and vitest simulation 6m22 → 14m35.

Closes COD-4156

@codspeed

codspeed Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 86.96%

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

⚠️ 34 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

⚠️ 12 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

ℹ️ 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

⚡ 6 improved benchmarks
❌ 61 regressed benchmarks
✅ 203 untouched benchmarks
⏩ 56 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Memory wait 1ms 656 B 34,392 B -98.09%
❌ Memory short body 656 B 33,864 B -98.06%
❌ Memory long body 656 B 33,864 B -98.06%
❌ Memory switch 2 656 B 33,864 B -98.06%
❌ Memory test_iterative_fibo_100 784 B 33,904 B -97.69%
❌ Memory test_iterative_fibo_10 784 B 33,904 B -97.69%
❌ Memory test_recursive_cached_fibo_20 784 B 33,904 B -97.69%
❌ Memory test sync baz 10 784 B 33,904 B -97.69%
❌ Memory test_recursive_cached_fibo_30 784 B 33,904 B -97.69%
❌ Memory test_recursive_fibo_20 784 B 33,904 B -97.69%
❌ Memory test_recursive_cached_fibo_10 784 B 33,904 B -97.69%
❌ Memory test sync baz 100 784 B 33,904 B -97.69%
❌ CPU Simulation switch 2 23.5 µs 661.5 µs -96.44%
❌ Memory test_recursive_cached_fibo_20 1.3 KB 33.6 KB -96.19%
❌ Memory switch 2 1.3 KB 33.6 KB -96.19%
❌ Memory switch 2 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_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-4156-support-process-isolation-in-simulation (7d16c57) with cod-4179-fix-nondeterministic-gc-and-async-bench-handling-in (16decfc)

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. ↩

@not-matthias
not-matthias force-pushed the cod-4156-support-process-isolation-in-simulation branch from 8728bb6 to fdf9f67 Compare November 6, 2026 18:44
@not-matthias
not-matthias changed the base branch from main to warm-measurement-path-before-measuring November 6, 2026 18:44
--predictable and --predictable-gc-schedule fix the heap growing schedule but V8 still posts incremental marking steps as tasks on the event loop. Async work before a benchmark (Vitest's ESM loading, an awaited readFile loop) runs a varying number of those tasks, so when marking finishes, how many GCs ran and where objects end up differ between identical runs. Under Valgrind this shows as steps in both instruction counts (lazy sweeping inside the measured call) and cache misses.

--no-incremental-marking-task makes marking progress only on allocation, which is deterministic. The flag exists on every supported Node major (22, 24, 26).
In analysis mode, runSync passed an async lambda to optimizeFunctionSync. Each warmup call returned at its first await, so its afterEach hook ran after the measured call instead of right after the warmup call, and the measured call ran with every warmup's state still live.

The lambda is now synchronous, like the function runSync accepts.
The Vitest 5 provider only awaited functions declared async. A function that returns a promise without being async was run on the sync path, so its asynchronous work ran after the measured window closed and after afterEach.

The provider now calls a non-async function once between its hooks, like tinybench does, and takes the async path when it returns a thenable. beforeAll is awaited up front for both paths.
tinybench calls a function that isn't declared async once in bench.add to tell whether it returns a promise. The walltime root-frame wrapper is sync, so every async benchmark was probed this way, and with forks every process re-registered and probed every benchmark in the file. Benchmarks with side effects ran in forks that never measure them.

A process now registers an async no-op for benchmarks it doesn't run. For the benchmarks it runs, an async function is registered with { async: true } so tinybench 6 skips the probe; the wrapper stays sync because an async wrapper adds an await to every iteration.
@not-matthias
not-matthias force-pushed the cod-4156-support-process-isolation-in-simulation branch from fdf9f67 to 675ecac Compare November 9, 2026 14:17
@not-matthias
not-matthias changed the base branch from warm-measurement-path-before-measuring to main November 9, 2026 14:17
@not-matthias
not-matthias changed the base branch from main to cod-4179-fix-nondeterministic-gc-and-async-bench-handling-in November 9, 2026 16:08
@not-matthias
not-matthias force-pushed the cod-4156-support-process-isolation-in-simulation branch from 675ecac to 9bd3d5a Compare November 9, 2026 16:08
@not-matthias
not-matthias marked this pull request as ready for review November 9, 2026 18:20
@greptile-apps

greptile-apps Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The changes appear safe to merge; no blocking issue was found.

Summary

Analysis benchmarks now run in separate processes in tinybench and Vitest 5.

  • Tinybench analysis benchmarks run in separate fresh processes.
  • Vitest 5 analysis benchmarks run in separate fresh processes.

No actionable issues were found in the changes since the previous review or the full-PR rule check. No previous review threads were supplied. This review used source checks; no runtime tests were run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Benchmark registration] --> B{Isolation setting}
  B -->|Disabled| C[Measure in shared process]
  B -->|Enabled| D[Parent selects benchmark URI]
  D --> E[Start fresh child process]
  E --> F[Measure only matching benchmark]
  F --> G[Instruments keep analysis measurement]
  F --> H[Write completion file]
  H --> I[Parent checks child completed]
Loading

Reviews (2) · Last reviewed commit: "feat(vitest): run analysis benchmarks in..." · Reviewed by Greptile

@not-matthias
not-matthias added this pull request to stack #125 November 9, 2026 18:25
@not-matthias
not-matthias force-pushed the cod-4156-support-process-isolation-in-simulation branch from 9bd3d5a to 6cc3868 Compare November 9, 2026 18:39
Benchmarks measured in one process share allocator, JIT, and heap state, so
a benchmark's simulation or memory result depends on the ones that ran
before it. Walltime runs already run every benchmark in fresh processes.

Add `analysisIsolation()`, which reads the same environment as
`walltimeIsolation()` but always uses one fork per benchmark: analysis
measures a benchmark once, and `CODSPEED_WALLTIME_FORKS` does not apply.
`CODSPEED_PROCESS_ISOLATION=false` keeps every benchmark in one process.

Analysis forks reuse `runForks`: the instruments hold the measurement, so a
fork writes empty samples, which tell the parent it measured its benchmark.

Refs COD-4156
In simulation and memory mode, the parent now runs each task in one fresh
process, which measures only that task. Children keep quiet about the
banner and summary, like walltime forks.

The in-process integration test mocks the core, which forks cannot see, so
it turns isolation off.

Refs COD-4156
With Vitest 5, simulation and memory runs now reuse the walltime fork flow:
workers queue each benchmark instead of measuring it, and the main process
runs one fresh `vitest bench` per benchmark once the run is over. The fork
is restricted to the benchmark's file and measures only that benchmark.

`CODSPEED_WALLTIME_FORKS` and the `forks` options do not apply: analysis
measures a benchmark once. Vitest 3/4 still don't fork.

Closes COD-4156
@not-matthias
not-matthias force-pushed the cod-4156-support-process-isolation-in-simulation branch from 6cc3868 to 7d16c57 Compare November 9, 2026 18:40

@adriencaccia adriencaccia 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.

Main question: does this need #123 to ship? Without it, tinybench results here move ×2 between two runs of the same commit. Isolation on by default is fine, with a follow-up to lower the startup cost.

const rootCallingFile = getCallingFile(1);
const instrumentMode = getInstrumentMode();
const isolationMode = getIsolationMode(instrumentMode, options);
const isolationMode = processIsolation(options.forks);

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.

The description says this is stacked on #123, but the base is #124's branch and #123 isn't in it. Do we need to merge #123 to release this as well? Without it, every tinybench benchmark here pays the first-benchmark setup #123 removes (Memory 656 B → 34 KB, switch 2 23.5 µs → 661.5 µs), and that cost isn't stable on CI: two runs of 9bd3d5a give test_recursive_fibo_10 339 µs then 679 µs, two runs of 675ecac move 6 tinybench benchmarks by ×2. If so, can you restack on #123 and show two runs of the same commit agree?

https://app.codspeed.io/CodSpeedHQ/codspeed-node/runs/compare/6af1f0830bbd4d5a590423c6..6af1f0950bbd4d5a590423cd
https://app.codspeed.io/CodSpeedHQ/codspeed-node/runs/compare/6af1d65b42a1644c0fdb1080..6af1d66842a1644c0fdb1083

Comment on lines +73 to +74
const analysis = getInstrumentMode() === "analysis";
return { kind: "Forks", forks: analysis ? 1 : resolveWalltimeForks(forks) };

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.

One process per analysis benchmark costs a full Node or Vitest startup under Valgrind. On CI, from #124 to this PR: tinybench simulation 4m26 → 8m20 (+6.7 s per benchmark), memory 55 s → 1m52; Vitest simulation 6m20 → 11m43 (+14 s per benchmark), memory 1m07 → 2m48. A 100-benchmark Vitest suite would run ~30 min longer. We can ship it like this and follow up to lower the overhead. For the final release, it would be great to have numbers showing the variance improvement, to understand the tradeoff.

https://github.com/CodSpeedHQ/codspeed-node/actions/runs/42632341179
https://github.com/CodSpeedHQ/codspeed-node/actions/runs/42676098172

// tinybench calls a function that isn't `async` once when it's added, to
// tell whether it returns a promise. A process that doesn't run the task
// registers a stub instead, so the user's function isn't called there.
if (!runsTaskFn(isolationMode, uri)) {

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.

runsTaskFn now decides for analysis too: move it from walltime.ts to shared.ts.

@GuillaumeLagrange GuillaumeLagrange left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think unless we have actual data about how it improves variance, we don't want to pay the startup under valgrind cost, and if we do we should definitely try and take mitigation actions to not pay the full price. We can discuss this deeper if you will

Comment on lines +2 to +7
* Process isolation: every benchmark runs in fresh copies of the benchmark
* process (forks), so the allocator state, JIT state, and handles that other
* benchmarks or setup code leave behind can't change its measurement. Walltime
* rounds within one process are correlated (JIT tier-up, heap sizing), so the
* rounds of several forks are pooled into one result. Analysis runs measure
* each benchmark once, in one fork.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's format this a bit better to be more readable.

Also the comment's first sentence hints at this process isolation being on no matter what.

);
}
return { kind: "Forks", forks: resolveWalltimeForks(forks) };
const analysis = getInstrumentMode() === "analysis";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: isAnalysis or soemthing that hints at it being a boolean

Base automatically changed from cod-4179-fix-nondeterministic-gc-and-async-bench-handling-in to main November 12, 2026 15:00
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.

3 participants