Repository navigation
feat: run walltime benchmarks in forks - #101
not-matthias wants to merge 8 commits into
Conversation
a33e439 to
4bd0611
Compare
Merging this PR will regress 4 benchmarks3 warnings, 1 note
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
[Medium impact] The stack appears safe to merge, with this assessment depending on PR #111’s changes to walltime execution. SummaryAdds fresh-process walltime measurements for Tinybench and Vitest 5, with three forks by default. The parent pools their samples into one CodSpeed result.
|
860d65c to
8038822
Compare
8038822 to
86bdbbb
Compare
Benchmarks in one process share allocator and JIT state, and rounds within one process are correlated, so a single process gives a biased view of a benchmark. Add the building blocks for integrations to run each walltime benchmark in several fresh processes (forks) and pool their rounds: - `Isolation.walltime(forks?)` picks the mode from the env: parent, fork (`CODSPEED_ISOLATED_BENCHMARK`), or one shared process when `CODSPEED_PROCESS_ISOLATION=false` - the fork count is `CODSPEED_WALLTIME_FORKS`, else the integration's `forks` option, else 3 - `runForks`/`runForksSync` re-run the benchmark command (keeping the V8 flags) once per fork and collect the samples each fork writes with `writeForkSamples`; a failing fork's exit code is passed on - `samplesToResult` builds tinybench-style statistics over the pooled samples Refs COD-3960
`withCodSpeed(bench, { forks })` sets the fork count of walltime runs, and
`CODSPEED_WALLTIME_FORKS` overrides it. The parent runs each task in its
forks and reports their pooled rounds; a fork runs only its task, hands its
samples over and prints no banner or summary. Simulation runs are unchanged.
Refs COD-3960
With Vitest 5, each walltime benchmark runs in fresh `vitest bench`
processes restricted to its file. The fork count resolves as
`CODSPEED_WALLTIME_FORKS`, the benchmark's or run's `forks` option,
`codspeedPlugin({ forks })`, then 3. The test timeout is scaled by the
env or plugin fork count, since every fork of a test runs within it.
Vitest's own report shows zeroed statistics for forked benchmarks; the
CodSpeed results hold the pooled rounds. Vitest 3/4 don't fork.
Closes COD-3960
86bdbbb to
e9af7a1
Compare
adriencaccia
left a comment
There was a problem hiding this comment.
Main question is cost: with 3 forks by default, this repo's walltime benchmark step goes from 351 s on main to 1408 s on this PR. A run with forks should take about as long as one without; see the threads on the budget per fork, the per-fork overhead and the sample count of fast tinybench benchmarks. Vitest hooks must also keep working as they do today.
| /** Forks per walltime benchmark. Overrides the integration's `forks` option. */ | ||
| export const WALLTIME_FORKS_ENV = "CODSPEED_WALLTIME_FORKS"; | ||
| /** Forks per walltime benchmark when neither the env nor an option sets it. */ | ||
| export const DEFAULT_WALLTIME_FORKS = 3; |
There was a problem hiding this comment.
Each fork runs the full warmup and measurement budget, so 3 forks by default triple the measured time before any fork startup: this repo's walltime benchmark step goes from 351 s on main to 1408 s here, and test_iterative_fibo_10 measures 3.0 s instead of 1.0 s. A run with forks should take about as long as one without: let's split the budget across the forks.
| * Measures every benchmark in fresh processes and reports their pooled | ||
| * rounds. Vitest gets zeroed results, like in analysis mode. | ||
| */ | ||
| async function runForkParent( |
There was a problem hiding this comment.
Vitest hooks must keep working as they do today. Here the parent runs the file's beforeAll and beforeEach before spawning the forks and holds what they opened meanwhile, and each fork runs the whole file again: in CI, hooks.bench.ts ran 10 times for its 3 benchmarks. A beforeAll that starts a server on a fixed port would make the forks fail with EADDRINUSE, and costly setup runs 1 + 3 × (benchmarks in the file) times. Let's keep hooks running once around the benchmarks they wrap, and add a bench whose beforeAll holds an exclusive resource so this stays covered.
|
|
||
| /** Hands the round samples (milliseconds) of this fork to its parent. */ | ||
| export function writeForkSamples(samplesFile: string, samples: number[]) { | ||
| writeFileSync(samplesFile, JSON.stringify(samples)); |
There was a problem hiding this comment.
Why does test_iterative_fibo_10 record 7.2M rounds of 1 iteration for a 1 s budget? tinybench counts only the measured latencies against time, so the clock reads between 120 ns iterations go unbudgeted: on main this benchmark takes 21 s, and the 8 benchmarks under 250 ns of with-typescript-esm and with-typescript-cjs take 135 s of the 351 s walltime step. Forks multiply that (584 s here) and send every sample through JSON, which throws Invalid string length past ~23M samples (tinybench alone records 66.8M for this function in 1 s on my laptop). Let's batch iterations into rounds of at least 1 ms, like pytest-codspeed's min_round_time_ns.
| * while it runs: a Vitest worker must keep answering its main process, whose | ||
| * calls time out otherwise. | ||
| */ | ||
| export async function runForks(uri: string, forks: number): Promise<number[]> { |
There was a problem hiding this comment.
COD-3960 caps the fork overhead at 1.5 s. In CI a fork adds 0.8 to 1.9 s to a tinybench benchmark, 1.3 to 4 s to a Vitest one (parsePr.bench.ts short body: 5.6 s per fork against 1.6 s on main), and 5 to 9 s to the benchmarks with millions of samples. On Vitest it also counts against the test timeout, which is only multiplied by the forks: a group of 3 benchmarks that takes 50 s today needs 3 × (50 + 3 × 4) = 186 s with 3 forks, past the 180 s limit.
| import { join } from "path"; | ||
|
|
||
| /** Isolation is on unless this is `0` or `false`. */ | ||
| export const PROCESS_ISOLATION_ENV = "CODSPEED_PROCESS_ISOLATION"; |
There was a problem hiding this comment.
CODSPEED_PROCESS_ISOLATION is the variable codspeed-rust reads too, so it stays as the shared convention across integrations. Let's parse it the same way: codspeed-rust panics on anything but 0, false, 1 and true, while here CODSPEED_PROCESS_ISOLATION=off silently keeps the forks on.
| * The isolation of a walltime run, from the environment. `forks` is the | ||
| * integration's option, which {@link WALLTIME_FORKS_ENV} overrides. | ||
| */ | ||
| export function walltimeIsolation(forks?: number): IsolationMode { |
There was a problem hiding this comment.
The core commit message calls this Isolation.walltime(forks?); it is walltimeIsolation(forks?).
TLDR: Rounds within one process are correlated (JIT tier-up, heap sizing, allocator state), so walltime results depend on the process they ran in. Each walltime benchmark now runs in 3 fresh processes (forks) by default, and their rounds are pooled into one result (tinybench and Vitest 5).
Fork count, highest priority first:
CODSPEED_WALLTIME_FORKS=<n>bench(name, { forks }, fn)orbench.compare(..., { forks })withCodSpeed(bench, { forks }),codspeedPlugin({ forks })3CODSPEED_PROCESS_ISOLATION=falseruns everything in one process.How it works
execPath+execArgv+argv, so the runner's V8 flags survive) once per fork, withCODSPEED_ISOLATED_BENCHMARK=<uri>. The fork measures only that benchmark and writes its samples to a temp file. The parent pools them and writes the walltime result.Cost (
examples/with-typescript-esmtinybench, 9 benchmarks): 77 s with isolation off, 140 s with 1 fork, 412 s with 3 forks. Each fork pays process startup.Review notes
forksabove that count can still time out.BenchOptionstype doesn't includeforks, so the per-benchmark option needs a cast in TypeScript.Closes COD-3960