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
4 changes: 4 additions & 0 deletions packages/core/src/introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const getV8Flags = () => {
"--predictable-gc-schedule",
"--expose-gc",
"--no-concurrent-sweeping",
// --predictable keeps posting incremental marking steps as tasks on
// the event loop, so async work before the benchmark (e.g. module
// loading) shifts the GC count and heap layout it measures in.
"--no-incremental-marking-task",
"--max-old-space-size=4096",
],
);
Expand Down
28 changes: 18 additions & 10 deletions packages/core/src/isolation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* 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.
* 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.
Comment on lines +2 to +7

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.

*/
import { spawn, spawnSync } from "child_process";
import { randomUUID } from "crypto";
Expand All @@ -12,6 +13,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { deserialize, serialize } from "v8";
import { constants, gunzipSync, gzipSync } from "zlib";
import { getInstrumentMode } from "./runnerMode";

/** Accepts `0`, `false`, `1`, or `true`; defaults to enabled. */
export const PROCESS_ISOLATION_ENV = "CODSPEED_PROCESS_ISOLATION";
Expand Down Expand Up @@ -44,10 +46,11 @@ export type IsolationMode =
| { 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.
* The isolation of this run, from the environment. A walltime benchmark runs
* in `forks` forks, the integration's option, which {@link WALLTIME_FORKS_ENV}
* overrides; an analysis benchmark is measured once, so it runs in one.
*/
export function walltimeIsolation(forks?: number): IsolationMode {
export function processIsolation(forks?: number): IsolationMode {
const uri = process.env[ISOLATED_BENCHMARK_ENV];
if (uri !== undefined) {
const samplesFile = process.env[FORK_SAMPLES_FILE_ENV];
Expand All @@ -67,7 +70,8 @@ export function walltimeIsolation(forks?: number): IsolationMode {
`[CodSpeed] ${PROCESS_ISOLATION_ENV} must be 0, false, 1, or true, got \`${isolation}\``,
);
}
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

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

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

}

/**
Expand Down Expand Up @@ -221,7 +225,11 @@ function forkSamples(
}
}

/** Hands round samples (milliseconds) to the parent as gzip-compressed V8 data. */
/**
* Hands round samples (milliseconds) to the parent as gzip-compressed V8 data.
* Analysis forks hand none: the instruments hold their measurement, and the
* file only tells the parent that they measured their benchmark.
*/
export function writeForkSamples(
samplesFile: string,
samples: ArrayLike<number>,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/tests/fixtures/isolation.cjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Run by `isolation.integ.test.ts`. Measures benchmark "a" in forks.
const {
processIsolation,
runForks,
runForksSync,
walltimeIsolation,
writeForkSamples,
} = require("../../dist/index.cjs.js");

const mode = walltimeIsolation();
const mode = processIsolation();

function measure() {
console.log(`ran ${process.pid} ${JSON.stringify(process.execArgv)}`);
Expand Down
21 changes: 16 additions & 5 deletions packages/core/tests/isolation.integ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
FORK_SAMPLES_FILE_ENV,
ISOLATED_BENCHMARK_ENV,
PROCESS_ISOLATION_ENV,
processIsolation,
resolveWalltimeForks,
WALLTIME_FORKS_ENV,
walltimeIsolation,
} from "../src/isolation";

const FIXTURE = join(__dirname, "fixtures", "isolation.cjs");
Expand All @@ -21,6 +21,8 @@ const NODE_FLAG = "--no-warnings";
const SAMPLES_PER_FORK = 2;

const ENV_KEYS = [
"CODSPEED_ENV",
"CODSPEED_RUNNER_MODE",
PROCESS_ISOLATION_ENV,
ISOLATED_BENCHMARK_ENV,
FORK_SAMPLES_FILE_ENV,
Expand Down Expand Up @@ -75,7 +77,7 @@ describe("resolveWalltimeForks", () => {
});
});

describe("walltimeIsolation", () => {
describe("processIsolation", () => {
it.each([
[undefined, "Forks"],
["1", "Forks"],
Expand All @@ -84,12 +86,12 @@ describe("walltimeIsolation", () => {
["false", "SharedProcess"],
])("selects %s as %s", (value, kind) => {
setEnv({ [PROCESS_ISOLATION_ENV]: value });
expect(walltimeIsolation().kind).toBe(kind);
expect(processIsolation().kind).toBe(kind);
});

it("runs everything in this process when isolation is off, whatever the fork count", () => {
setEnv({ [PROCESS_ISOLATION_ENV]: "false", [WALLTIME_FORKS_ENV]: "4" });
expect(walltimeIsolation(2).kind).toBe("SharedProcess");
expect(processIsolation(2).kind).toBe("SharedProcess");
});

it("restricts a fork to its benchmark, whatever the isolation setting", () => {
Expand All @@ -98,12 +100,21 @@ describe("walltimeIsolation", () => {
[ISOLATED_BENCHMARK_ENV]: "file::bench",
[FORK_SAMPLES_FILE_ENV]: "samples.json",
});
expect(walltimeIsolation()).toEqual({
expect(processIsolation()).toEqual({
kind: "Fork",
uri: "file::bench",
samplesFile: "samples.json",
});
});

it("runs an analysis benchmark in one fork, whatever the walltime fork count", () => {
setEnv({
CODSPEED_ENV: "true",
CODSPEED_RUNNER_MODE: "simulation",
[WALLTIME_FORKS_ENV]: "4",
});
expect(processIsolation(2)).toEqual({ kind: "Forks", forks: 1 });
});
});

interface FixtureOptions {
Expand Down
23 changes: 18 additions & 5 deletions packages/tinybench-plugin/src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import {
optimizeFunctionSync,
wrapWithRootFrame,
wrapWithRootFrameSync,
writeForkSamples,
type IsolationMode,
} from "@codspeed/core";
import { Bench, Task } from "tinybench";
import { BaseBenchRunner } from "./shared";

export function setupCodspeedAnalysisBench(
bench: Bench,
rootCallingFile: string,
isolationMode: IsolationMode,
): void {
const runner = new AnalysisBenchRunner(bench, rootCallingFile);
const runner = new AnalysisBenchRunner(bench, rootCallingFile, isolationMode);
runner.setupBenchMethods();
}

Expand All @@ -28,6 +31,16 @@ class AnalysisBenchRunner extends BaseBenchRunner {
return InstrumentHooks.isInstrumented() ? "Measured" : "Checked";
}

private completeTask(uri: string): void {
this.logTaskCompletion(uri, this.taskCompletionMessage());
if (this.isolationMode.kind === "Fork") {
writeForkSamples(this.isolationMode.samplesFile, []);
}
}

/** The instruments hold the measurement of the forks. */
protected collectForks(): void {}

protected async runTaskAsync(task: Task, uri: string): Promise<void> {
const { fnOpts, fn } = this.getTaskData(task);

Expand All @@ -54,17 +67,17 @@ class AnalysisBenchRunner extends BaseBenchRunner {
await fnOpts?.afterEach?.call(task, "run");
await fnOpts?.afterAll?.call(task, "run");

this.logTaskCompletion(uri, this.taskCompletionMessage());
this.completeTask(uri);
}

protected runTaskSync(task: Task, uri: string): void {
const { fnOpts, fn } = this.getTaskData(task);

fnOpts?.beforeAll?.call(task, "run");

optimizeFunctionSync(async () => {
optimizeFunctionSync(() => {
fnOpts?.beforeEach?.call(task, "run");
await fn();
fn();
fnOpts?.afterEach?.call(task, "run");
});

Expand All @@ -83,7 +96,7 @@ class AnalysisBenchRunner extends BaseBenchRunner {
fnOpts?.afterEach?.call(task, "run");
fnOpts?.afterAll?.call(task, "run");

this.logTaskCompletion(uri, this.taskCompletionMessage());
this.completeTask(uri);
}

protected finalizeAsyncRun(): Task[] {
Expand Down
43 changes: 30 additions & 13 deletions packages/tinybench-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import {
getInstrumentMode,
InstrumentHooks,
mongoMeasurement,
processIsolation,
SetupInstrumentsRequestBody,
SetupInstrumentsResponse,
tryIntrospect,
wrapWithRootFrameSync,
type WalltimeOptions,
} from "@codspeed/core";
import { Bench } from "tinybench";
import { Bench, type FnOptions } from "tinybench";
import { setupCodspeedAnalysisBench } from "./analysis";
import { getOrCreateTaskDataMap } from "./taskData";
import { getOrCreateUriMap } from "./uri";
import { setupCodspeedWalltimeBench } from "./walltime";
import { runsTaskFn, setupCodspeedWalltimeBench } from "./walltime";

tryIntrospect();

Expand All @@ -29,6 +30,7 @@ export function withCodSpeed(

const rootCallingFile = getCallingFile(1);
const instrumentMode = getInstrumentMode();
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


// Compute and register URI for bench
const uriMap = getOrCreateUriMap(bench);
Expand All @@ -43,25 +45,40 @@ export function withCodSpeed(
uri += `::${name}`;
uriMap.set(name, uri);
taskDataMap.set(name, { fn, fnOpts: opts });
// In walltime mode the task is driven by tinybench's own measured loop, so
// the root frame must be baked into the function tinybench stores rather
// than injected later (the function is an inaccessible private field on the
// task from tinybench v6 onwards). The sync wrapper is transparent to the
// function's return value, so it preserves tinybench's sync/async handling.
const registeredFn =
instrumentMode === "walltime" ? wrapWithRootFrameSync(fn) : fn;
return rawAdd.bind(bench)(name, registeredFn, opts);
const add = rawAdd.bind(bench);
// 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.

return add(name, notRunHere, opts);
}
// The analysis runner calls the captured function itself.
if (instrumentMode === "analysis") {
return add(name, fn, opts);
}
// The task is driven by tinybench's own measured loop, so the root frame
// must be baked into the function tinybench stores rather than injected
// later (the function is an inaccessible private field on the task from
// tinybench v6 onwards). An async wrapper would add an await to every
// iteration, so the wrapper stays sync and tinybench is told that an async
// function is async instead (honored from tinybench v6 onwards).
const fnOpts: (FnOptions & { async?: boolean }) | undefined =
fn.constructor.name === "AsyncFunction" ? { ...opts, async: true } : opts;
return add(name, wrapWithRootFrameSync(fn), fnOpts);
};

if (instrumentMode === "analysis") {
setupCodspeedAnalysisBench(bench, rootCallingFile);
} else if (instrumentMode === "walltime") {
setupCodspeedWalltimeBench(bench, rootCallingFile, options);
setupCodspeedAnalysisBench(bench, rootCallingFile, isolationMode);
} else {
setupCodspeedWalltimeBench(bench, rootCallingFile, isolationMode);
}

return bench;
}

/** Registered for tasks this process never runs; `async` so tinybench doesn't call it. */
async function notRunHere(): Promise<void> {}

/**
* Dynamically setup the CodSpeed instruments.
*/
Expand Down
26 changes: 21 additions & 5 deletions packages/tinybench-plugin/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { setupCore, teardownCore, type IsolationMode } from "@codspeed/core";
import {
runForks,
runForksSync,
setupCore,
teardownCore,
type IsolationMode,
} from "@codspeed/core";
import { Bench, Task } from "tinybench";
import { CapturedTaskData, getTaskData } from "./taskData";
import { getTaskUri } from "./uri";
Expand All @@ -12,8 +18,7 @@ export abstract class BaseBenchRunner {
constructor(
bench: Bench,
rootCallingFile: string,
/** Only walltime runs are isolated. */
protected readonly isolationMode: IsolationMode = { kind: "SharedProcess" },
protected readonly isolationMode: IsolationMode,
) {
this.bench = bench;
this.rootCallingFile = rootCallingFile;
Expand Down Expand Up @@ -72,6 +77,7 @@ export abstract class BaseBenchRunner {
protected abstract runTaskSync(task: Task, uri: string): void;
protected abstract finalizeAsyncRun(): Task[];
protected abstract finalizeSyncRun(): Task[];
protected abstract collectForks(task: Task, samples: Float64Array): void;

public setupBenchMethods(): void {
this.bench.run = async () => {
Expand All @@ -85,7 +91,12 @@ export abstract class BaseBenchRunner {

for (const task of tasks) {
const uri = this.getTaskUri(task);
await this.runTaskAsync(task, uri);
if (this.isolationMode.kind === "Forks") {
const samples = await runForks(uri, this.isolationMode.forks);
this.collectForks(task, samples);
} else {
await this.runTaskAsync(task, uri);
}
}

return this.finalizeAsyncRun();
Expand All @@ -100,7 +111,12 @@ export abstract class BaseBenchRunner {

for (const task of tasks) {
const uri = this.getTaskUri(task);
this.runTaskSync(task, uri);
if (this.isolationMode.kind === "Forks") {
const samples = runForksSync(uri, this.isolationMode.forks);
this.collectForks(task, samples);
} else {
this.runTaskSync(task, uri);
}
}

return this.finalizeSyncRun();
Expand Down
Loading
Loading