Repository navigation
feat: run walltime benchmarks in forks #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
not-matthias
wants to merge
8
commits into
main
Choose a base branch
from
cod-3960-implement-process-isolation-in-codspeed-node
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,208
−35
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
7067a24
feat(core): run walltime benchmarks in forks
not-matthias 4fa7936
feat(tinybench): run walltime benchmarks in forks
not-matthias e9af7a1
feat(vitest): run walltime benchmarks in forks
not-matthias 7b7de07
fixup! feat(core): run walltime benchmarks in forks
not-matthias 103afb8
fixup! feat(tinybench): run walltime benchmarks in forks
not-matthias 977f82d
fixup! feat(vitest): run walltime benchmarks in forks
not-matthias ad0b566
fixup! feat(core): run walltime benchmarks in forks
not-matthias c376bfd
fixup! feat(core): run walltime benchmarks in forks
not-matthias File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| /** | ||
| * Process isolation for walltime runs: 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. Rounds within one process are correlated (JIT tier-up, heap | ||
| * sizing), so the rounds of several forks are pooled into one result. | ||
| */ | ||
| import { spawn, spawnSync } from "child_process"; | ||
| import { readFileSync, rmSync, writeFileSync } from "fs"; | ||
| import { tmpdir } from "os"; | ||
| import { join } from "path"; | ||
|
|
||
| /** Isolation is on unless this is `0` or `false`. */ | ||
| export const PROCESS_ISOLATION_ENV = "CODSPEED_PROCESS_ISOLATION"; | ||
| /** 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; | ||
| /** Set on each fork: the URI of the only benchmark it runs. */ | ||
| export const ISOLATED_BENCHMARK_ENV = "CODSPEED_ISOLATED_BENCHMARK"; | ||
| /** Set on each fork: the file where it writes its round samples. */ | ||
| export const FORK_SAMPLES_FILE_ENV = "CODSPEED_FORK_SAMPLES_FILE"; | ||
| /** | ||
| * JSON array `[executable, ...args]` of the command that runs the benchmarks | ||
| * again. Integrations whose benchmarks run in a worker process (vitest) set it | ||
| * from the main process, because re-executing the worker alone can't work. | ||
| */ | ||
| export const ISOLATION_COMMAND_ENV = "CODSPEED_ISOLATION_COMMAND"; | ||
|
|
||
| /** Walltime settings that every integration exposes next to its own options. */ | ||
| export interface WalltimeOptions { | ||
| /** | ||
| * Fresh processes that each walltime benchmark runs in; their rounds are | ||
| * reported together. {@link WALLTIME_FORKS_ENV} overrides it; defaults to | ||
| * {@link DEFAULT_WALLTIME_FORKS}. `CODSPEED_PROCESS_ISOLATION=false` runs | ||
| * everything in the benchmark process instead. | ||
| */ | ||
| forks?: number; | ||
| } | ||
|
|
||
| export type IsolationMode = | ||
| /** All benchmarks are measured in this process. */ | ||
| | { kind: "SharedProcess" } | ||
| /** Every benchmark is measured in `forks` fresh processes, one after the other. */ | ||
| | { kind: "Forks"; forks: number } | ||
| /** A fork: measures only `uri` and writes its samples to `samplesFile`. */ | ||
| | { kind: "Fork"; uri: string; samplesFile: string }; | ||
|
|
||
| /** | ||
| * 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 { | ||
| const uri = process.env[ISOLATED_BENCHMARK_ENV]; | ||
| if (uri !== undefined) { | ||
| const samplesFile = process.env[FORK_SAMPLES_FILE_ENV]; | ||
| if (samplesFile === undefined) { | ||
| throw new Error( | ||
| `[CodSpeed] ${ISOLATED_BENCHMARK_ENV} is set without ${FORK_SAMPLES_FILE_ENV}`, | ||
| ); | ||
| } | ||
| return { kind: "Fork", uri, samplesFile }; | ||
| } | ||
| const isolation = process.env[PROCESS_ISOLATION_ENV]; | ||
| if (isolation === "0" || isolation === "false") { | ||
| return { kind: "SharedProcess" }; | ||
| } | ||
| return { kind: "Forks", forks: resolveWalltimeForks(forks) }; | ||
| } | ||
|
|
||
| /** | ||
| * The number of forks per walltime benchmark: {@link WALLTIME_FORKS_ENV}, | ||
| * else `option`, else {@link DEFAULT_WALLTIME_FORKS}. | ||
| */ | ||
| export function resolveWalltimeForks(option?: number): number { | ||
| const env = process.env[WALLTIME_FORKS_ENV]; | ||
| if (env !== undefined && env !== "") { | ||
| return validForks(env, WALLTIME_FORKS_ENV); | ||
| } | ||
| if (option !== undefined) { | ||
| return validForks(option, "forks"); | ||
| } | ||
| return DEFAULT_WALLTIME_FORKS; | ||
| } | ||
|
|
||
| function validForks(raw: string | number, source: string): number { | ||
| const forks = Number(raw); | ||
| if (!Number.isInteger(forks) || forks < 1) { | ||
| throw new Error( | ||
| `[CodSpeed] ${source} must be a positive integer, got \`${raw}\``, | ||
| ); | ||
| } | ||
| return forks; | ||
| } | ||
|
|
||
| /** | ||
| * Records the command of this process as the one that forks run. Call it | ||
| * from the main process of integrations that measure in worker processes. It | ||
| * overwrites a command inherited from an outer process, which is not ours. | ||
| */ | ||
| export function recordIsolationCommand(): void { | ||
| process.env[ISOLATION_COMMAND_ENV] = JSON.stringify(currentCommand()); | ||
| } | ||
|
|
||
| function currentCommand(): string[] { | ||
| // execArgv keeps the V8 flags (e.g. --perf-prof) that the runner set. | ||
| return [process.execPath, ...process.execArgv, ...process.argv.slice(1)]; | ||
| } | ||
|
|
||
| function isolationCommand(): string[] { | ||
| const recorded = process.env[ISOLATION_COMMAND_ENV]; | ||
| if (recorded === undefined) { | ||
| return currentCommand(); | ||
| } | ||
| return JSON.parse(recorded) as string[]; | ||
| } | ||
|
|
||
| /** | ||
| * Runs the benchmark `uri` in `forks` fresh processes, one after the other, | ||
| * and returns the round samples of all of them, in milliseconds. Throws when | ||
| * a fork fails, without running the next one. | ||
| * | ||
| * Each fork re-runs the benchmark command, not fork(): Node can't fork, and a | ||
| * fork would only copy the calling thread. The fork's event loop stays free | ||
| * 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[]> { | ||
| let samples: number[] = []; | ||
| for (let fork = 0; fork < forks; fork++) { | ||
| // Not push(...): spreading a time-based run's samples overflows the stack. | ||
| samples = samples.concat(await runFork(uri)); | ||
| } | ||
| return samples; | ||
| } | ||
|
|
||
| /** {@link runForks} for synchronous runs, which block anyway. */ | ||
| export function runForksSync(uri: string, forks: number): number[] { | ||
| let samples: number[] = []; | ||
| for (let fork = 0; fork < forks; fork++) { | ||
| samples = samples.concat(runForkSync(uri)); | ||
| } | ||
| return samples; | ||
| } | ||
|
|
||
| /** Forks run one after the other, so one file per parent process suffices. */ | ||
| const SAMPLES_FILE = join(tmpdir(), `codspeed-samples-${process.pid}.json`); | ||
|
|
||
| /** Runs one fork of `uri` and returns the samples it wrote. */ | ||
| async function runFork(uri: string): Promise<number[]> { | ||
| rmSync(SAMPLES_FILE, { force: true }); // a leftover file must not pass as this fork's | ||
| const [executable, ...args] = isolationCommand(); | ||
| const child = spawn(executable, args, { | ||
| stdio: "inherit", | ||
| env: forkEnv(uri), | ||
| }); | ||
| const { promise, resolve, reject } = | ||
| Promise.withResolvers<[number | null, NodeJS.Signals | null]>(); | ||
| child.once("error", (error) => reject(startError(uri, error))); | ||
| child.once("close", (status, signal) => resolve([status, signal])); | ||
| const [status, signal] = await promise; | ||
| return forkSamples(uri, status, signal); | ||
| } | ||
|
|
||
| function runForkSync(uri: string): number[] { | ||
| rmSync(SAMPLES_FILE, { force: true }); | ||
| const [executable, ...args] = isolationCommand(); | ||
| const result = spawnSync(executable, args, { | ||
| stdio: "inherit", | ||
| env: forkEnv(uri), | ||
| }); | ||
| if (result.error !== undefined) { | ||
| throw startError(uri, result.error); | ||
| } | ||
| return forkSamples(uri, result.status, result.signal); | ||
| } | ||
|
|
||
| function forkEnv(uri: string): NodeJS.ProcessEnv { | ||
| return { | ||
| ...process.env, | ||
| [ISOLATED_BENCHMARK_ENV]: uri, | ||
| [FORK_SAMPLES_FILE_ENV]: SAMPLES_FILE, | ||
| }; | ||
| } | ||
|
|
||
| function startError(uri: string, error: Error): Error { | ||
| return new Error( | ||
| `[CodSpeed] failed to start the process for ${uri}: ${error.message}`, | ||
| ); | ||
| } | ||
|
|
||
| /** The samples a finished fork wrote, deleting the file; throws if it failed. */ | ||
| function forkSamples( | ||
| uri: string, | ||
| status: number | null, | ||
| signal: NodeJS.Signals | null, | ||
| ): number[] { | ||
| try { | ||
| if (status !== 0) { | ||
| throw new Error( | ||
| `[CodSpeed] the process for ${uri} failed with ${signal ?? `exit code ${status}`}`, | ||
| ); | ||
| } | ||
| try { | ||
| return JSON.parse(readFileSync(SAMPLES_FILE, "utf-8")) as number[]; | ||
| } catch (error) { | ||
| throw new Error( | ||
| `[CodSpeed] the process for ${uri} did not report its samples: ${(error as Error).message}`, | ||
| ); | ||
| } | ||
| } finally { | ||
| rmSync(SAMPLES_FILE, { force: true }); | ||
| } | ||
| } | ||
|
|
||
| /** Hands the round samples (milliseconds) of this fork to its parent. */ | ||
| export function writeForkSamples(samplesFile: string, samples: number[]) { | ||
| writeFileSync(samplesFile, JSON.stringify(samples)); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // Run by `isolation.integ.test.ts`. Measures benchmark "a" in forks. | ||
| const { | ||
| runForks, | ||
| runForksSync, | ||
| walltimeIsolation, | ||
| writeForkSamples, | ||
| } = require("../../dist/index.cjs.js"); | ||
|
|
||
| const mode = walltimeIsolation(); | ||
|
|
||
| function measure() { | ||
| console.log(`ran ${process.pid} ${JSON.stringify(process.execArgv)}`); | ||
| switch (process.env.ISOLATION_FIXTURE_ACTION) { | ||
| case "exit": | ||
| process.exit(3); | ||
| case "no-samples": | ||
| process.exit(0); | ||
| } | ||
| writeForkSamples(mode.samplesFile, [Number(process.pid), 1]); | ||
| } | ||
|
|
||
| async function main() { | ||
| if (mode.kind === "Fork") { | ||
| measure(); | ||
| return; | ||
| } | ||
| const forks = Number(process.env.ISOLATION_FIXTURE_FORKS); | ||
| const samples = | ||
| process.env.ISOLATION_FIXTURE_SYNC !== undefined | ||
| ? runForksSync("a", forks) | ||
| : await runForks("a", forks); | ||
| console.log(`samples ${samples.length}`); | ||
| } | ||
|
|
||
| // A rejection ends the process with exit code 1. | ||
| main(); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.