Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type {
SetupInstrumentsResponse,
} from "./generated/openapi";
export { getV8Flags, tryIntrospect } from "./introspection";
export * from "./isolation";
export {
getUnsupportedNodeVersionWarning,
SUPPORTED_NODE_MAJORS,
Expand Down
219 changes: 219 additions & 0 deletions packages/core/src/isolation.ts
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";

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.

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.

/** 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;

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.

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.

/** 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 {

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 core commit message calls this Isolation.walltime(forks?); it is walltimeIsolation(forks?).

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" };
}
Comment thread
not-matthias marked this conversation as resolved.
Resolved
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[]> {

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.

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.

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));

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.

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.

}
46 changes: 46 additions & 0 deletions packages/core/src/walltime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,49 @@ export const msToNs = (ms: number) => ms * 1e6;
* @returns the seconds
*/
export const msToS = (ms: number) => ms / 1e3;

/** tinybench-style statistics of round samples, in milliseconds. */
export interface RoundsResult {
totalTime: number;
latency: {
min: number;
max: number;
mean: number;
sd: number;
samples: number[];
};
}

/**
* The result of nonempty round samples in milliseconds, e.g. the samples of
* several forks of one benchmark. Throws if no samples were recorded.
*/
export function samplesToResult(samples: number[]): RoundsResult {
const n = samples.length;
Comment thread
not-matthias marked this conversation as resolved.
Resolved
if (n === 0) {
throw new Error("[CodSpeed] Cannot compute statistics from empty samples");
}
let totalTime = 0;
let min = Infinity;
let max = -Infinity;
for (const ms of samples) {
totalTime += ms;
if (ms < min) min = ms;
if (ms > max) max = ms;
}
const mean = totalTime / n;
const variance =
n > 1
? samples.reduce((sum, ms) => sum + (ms - mean) ** 2, 0) / (n - 1)
: 0;
return {
totalTime,
latency: {
min,
max,
mean,
sd: Math.sqrt(variance),
samples,
},
};
}
36 changes: 36 additions & 0 deletions packages/core/tests/fixtures/isolation.cjs
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();
Loading
Loading