Skip to content

Commit cfed9fe

Browse files
committed
perf(tinybench): batch fast walltime invocations into rounds
Calibrate approximately 1 ms rounds from warmup and share the iteration count across isolated forks. Normalize latency statistics per invocation while retaining raw total time. Keep per-invocation hooks and concurrent tasks unbatched, and cover shared, forked, synchronous, and asynchronous execution.
1 parent 3dca851 commit cfed9fe

11 files changed

Lines changed: 705 additions & 60 deletions

File tree

‎packages/core/src/isolation.ts‎

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export const DEFAULT_WALLTIME_FORKS = 3;
2222
export const ISOLATED_BENCHMARK_ENV = "CODSPEED_ISOLATED_BENCHMARK";
2323
/** Set on each fork: the file where it writes its round samples. */
2424
export const FORK_SAMPLES_FILE_ENV = "CODSPEED_FORK_SAMPLES_FILE";
25+
/**
26+
* Set on the forks after the first one by {@link runForks}: the number of
27+
* iterations that the first fork measured per round. Every fork of a benchmark
28+
* MUST batch the same way, because the samples of all forks are pooled.
29+
*/
30+
export const FORK_ITERATIONS_PER_ROUND_ENV =
31+
"CODSPEED_FORK_ITERATIONS_PER_ROUND";
2532
/**
2633
* JSON array `[executable, ...args]` of the command that runs the benchmarks
2734
* again. Integrations whose benchmarks run in a worker process (vitest) set it
@@ -45,8 +52,16 @@ export type IsolationMode =
4552
| { kind: "SharedProcess" }
4653
/** Every benchmark is measured in `forks` fresh processes, one after the other. */
4754
| { kind: "Forks"; forks: number }
48-
/** A fork: measures only `uri` and writes its samples to `samplesFile`. */
49-
| { kind: "Fork"; uri: string; samplesFile: string };
55+
/**
56+
* A fork: measures only `uri` and writes its samples to `samplesFile`. The
57+
* first fork picks `iterationsPerRound` itself; the ones after it inherit it.
58+
*/
59+
| {
60+
kind: "Fork";
61+
uri: string;
62+
samplesFile: string;
63+
iterationsPerRound?: number;
64+
};
5065

5166
/**
5267
* The isolation of a walltime run, from the environment. `forks` is the
@@ -61,7 +76,16 @@ export function walltimeIsolation(forks?: number): IsolationMode {
6176
`[CodSpeed] ${ISOLATED_BENCHMARK_ENV} is set without ${FORK_SAMPLES_FILE_ENV}`,
6277
);
6378
}
64-
return { kind: "Fork", uri, samplesFile };
79+
const iterationsPerRound = process.env[FORK_ITERATIONS_PER_ROUND_ENV];
80+
return {
81+
kind: "Fork",
82+
uri,
83+
samplesFile,
84+
iterationsPerRound:
85+
iterationsPerRound === undefined || iterationsPerRound === ""
86+
? undefined
87+
: validPositiveInt(iterationsPerRound, FORK_ITERATIONS_PER_ROUND_ENV),
88+
};
6589
}
6690
const isolation = process.env[PROCESS_ISOLATION_ENV];
6791
if (isolation === "0" || isolation === "false") {
@@ -82,22 +106,22 @@ export function walltimeIsolation(forks?: number): IsolationMode {
82106
export function resolveWalltimeForks(option?: number): number {
83107
const env = process.env[WALLTIME_FORKS_ENV];
84108
if (env !== undefined && env !== "") {
85-
return validForks(env, WALLTIME_FORKS_ENV);
109+
return validPositiveInt(env, WALLTIME_FORKS_ENV);
86110
}
87111
if (option !== undefined) {
88-
return validForks(option, "forks");
112+
return validPositiveInt(option, "forks");
89113
}
90114
return DEFAULT_WALLTIME_FORKS;
91115
}
92116

93-
function validForks(raw: string | number, source: string): number {
94-
const forks = Number(raw);
95-
if (!Number.isInteger(forks) || forks < 1) {
117+
function validPositiveInt(raw: string | number, source: string): number {
118+
const value = Number(raw);
119+
if (!Number.isInteger(value) || value < 1) {
96120
throw new Error(
97121
`[CodSpeed] ${source} must be a positive integer, got \`${raw}\``,
98122
);
99123
}
100-
return forks;
124+
return value;
101125
}
102126

103127
/**
@@ -122,44 +146,78 @@ function isolationCommand(): string[] {
122146
return JSON.parse(recorded) as string[];
123147
}
124148

149+
/** The pooled samples of the forks of one benchmark. */
150+
export interface ForkSamples {
151+
/** Round samples (milliseconds), one per measured round. */
152+
samples: number[];
153+
/** Benchmark iterations one round measured; every fork MUST agree. */
154+
iterationsPerRound: number;
155+
}
156+
125157
/**
126158
* Runs the benchmark `uri` in `forks` fresh processes, one after the other,
127159
* and returns the round samples of all of them, in milliseconds. Throws when
128160
* a fork fails, without running the next one.
129161
*
162+
* The first fork picks how many iterations one round batches; the ones after
163+
* it inherit that count, so the pooled samples mean the same thing.
164+
*
130165
* Each fork re-runs the benchmark command, not fork(): Node can't fork, and a
131166
* fork would only copy the calling thread. The fork's event loop stays free
132167
* while it runs: a Vitest worker must keep answering its main process, whose
133168
* calls time out otherwise.
134169
*/
135-
export async function runForks(uri: string, forks: number): Promise<number[]> {
136-
let samples: number[] = [];
170+
export async function runForks(
171+
uri: string,
172+
forks: number,
173+
): Promise<ForkSamples> {
174+
let result: ForkSamples | undefined;
137175
for (let fork = 0; fork < forks; fork++) {
138-
// Not push(...): spreading a time-based run's samples overflows the stack.
139-
samples = samples.concat(await runFork(uri));
176+
const next = await runFork(uri, result?.iterationsPerRound);
177+
result = poolForkSamples(uri, result, next);
140178
}
141-
return samples;
179+
return result ?? { samples: [], iterationsPerRound: 1 };
142180
}
143181

144182
/** {@link runForks} for synchronous runs, which block anyway. */
145-
export function runForksSync(uri: string, forks: number): number[] {
146-
let samples: number[] = [];
183+
export function runForksSync(uri: string, forks: number): ForkSamples {
184+
let result: ForkSamples | undefined;
147185
for (let fork = 0; fork < forks; fork++) {
148-
samples = samples.concat(runForkSync(uri));
186+
const next = runForkSync(uri, result?.iterationsPerRound);
187+
result = poolForkSamples(uri, result, next);
149188
}
150-
return samples;
189+
return result ?? { samples: [], iterationsPerRound: 1 };
190+
}
191+
192+
function poolForkSamples(
193+
uri: string,
194+
pooled: ForkSamples | undefined,
195+
next: ForkSamples,
196+
): ForkSamples {
197+
if (!pooled) return next;
198+
if (pooled.iterationsPerRound !== next.iterationsPerRound) {
199+
throw new Error(
200+
`[CodSpeed] the forks of ${uri} measured a different number of iterations per round (${pooled.iterationsPerRound} and ${next.iterationsPerRound})`,
201+
);
202+
}
203+
// Not push(...): spreading a time-based run's samples overflows the stack.
204+
for (const sample of next.samples) pooled.samples.push(sample);
205+
return pooled;
151206
}
152207

153208
/** Forks run one after the other, so one file per parent process suffices. */
154209
const SAMPLES_FILE = join(tmpdir(), `codspeed-samples-${process.pid}.v8.gz`);
155210

156211
/** Runs one fork of `uri` and returns the samples it wrote. */
157-
async function runFork(uri: string): Promise<number[]> {
212+
async function runFork(
213+
uri: string,
214+
iterationsPerRound?: number,
215+
): Promise<ForkSamples> {
158216
rmSync(SAMPLES_FILE, { force: true }); // a leftover file must not pass as this fork's
159217
const [executable, ...args] = isolationCommand();
160218
const child = spawn(executable, args, {
161219
stdio: "inherit",
162-
env: forkEnv(uri),
220+
env: forkEnv(uri, iterationsPerRound),
163221
});
164222
const { promise, resolve, reject } =
165223
Promise.withResolvers<[number | null, NodeJS.Signals | null]>();
@@ -169,24 +227,25 @@ async function runFork(uri: string): Promise<number[]> {
169227
return forkSamples(uri, status, signal);
170228
}
171229

172-
function runForkSync(uri: string): number[] {
230+
function runForkSync(uri: string, iterationsPerRound?: number): ForkSamples {
173231
rmSync(SAMPLES_FILE, { force: true });
174232
const [executable, ...args] = isolationCommand();
175233
const result = spawnSync(executable, args, {
176234
stdio: "inherit",
177-
env: forkEnv(uri),
235+
env: forkEnv(uri, iterationsPerRound),
178236
});
179237
if (result.error !== undefined) {
180238
throw startError(uri, result.error);
181239
}
182240
return forkSamples(uri, result.status, result.signal);
183241
}
184242

185-
function forkEnv(uri: string): NodeJS.ProcessEnv {
243+
function forkEnv(uri: string, iterationsPerRound?: number): NodeJS.ProcessEnv {
186244
return {
187245
...process.env,
188246
[ISOLATED_BENCHMARK_ENV]: uri,
189247
[FORK_SAMPLES_FILE_ENV]: SAMPLES_FILE,
248+
[FORK_ITERATIONS_PER_ROUND_ENV]: iterationsPerRound?.toString(),
190249
};
191250
}
192251

@@ -201,15 +260,15 @@ function forkSamples(
201260
uri: string,
202261
status: number | null,
203262
signal: NodeJS.Signals | null,
204-
): number[] {
263+
): ForkSamples {
205264
try {
206265
if (status !== 0) {
207266
throw new Error(
208267
`[CodSpeed] the process for ${uri} failed with ${signal ?? `exit code ${status}`}`,
209268
);
210269
}
211270
try {
212-
return deserialize(gunzipSync(readFileSync(SAMPLES_FILE))) as number[];
271+
return deserialize(gunzipSync(readFileSync(SAMPLES_FILE))) as ForkSamples;
213272
} catch (error) {
214273
throw new Error(
215274
`[CodSpeed] the process for ${uri} did not report its samples: ${(error as Error).message}`,
@@ -220,10 +279,19 @@ function forkSamples(
220279
}
221280
}
222281

223-
/** Hands round samples (milliseconds) to the parent as gzip-compressed V8 data. */
224-
export function writeForkSamples(samplesFile: string, samples: number[]) {
282+
/**
283+
* Hands round samples (milliseconds) to the parent as gzip-compressed V8 data,
284+
* along with the number of benchmark iterations one round measured.
285+
*/
286+
export function writeForkSamples(
287+
samplesFile: string,
288+
samples: number[],
289+
iterationsPerRound = 1,
290+
) {
225291
writeFileSync(
226292
samplesFile,
227-
gzipSync(serialize(samples), { level: constants.Z_BEST_SPEED }),
293+
gzipSync(serialize({ samples, iterationsPerRound }), {
294+
level: constants.Z_BEST_SPEED,
295+
}),
228296
);
229297
}

‎packages/core/tests/fixtures/isolation.cjs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const {
77
} = require("../../dist/index.cjs.js");
88

99
const mode = walltimeIsolation();
10+
/** The count a first fork picks; the forks after it inherit it from the parent. */
11+
const CALIBRATED = Number(process.env.ISOLATION_FIXTURE_ITERATIONS ?? 1);
1012

1113
function measure() {
1214
console.log(`ran ${process.pid} ${JSON.stringify(process.execArgv)}`);
@@ -16,7 +18,21 @@ function measure() {
1618
case "no-samples":
1719
process.exit(0);
1820
}
19-
writeForkSamples(mode.samplesFile, [Number(process.pid), 1]);
21+
const inherited = mode.iterationsPerRound;
22+
// Tells the test whether this fork picked the count or reused the first one.
23+
console.log(
24+
`${inherited === undefined ? "calibrated" : "reused"} ${inherited ?? CALIBRATED}`,
25+
);
26+
const iterationsPerRound =
27+
inherited !== undefined &&
28+
process.env.ISOLATION_FIXTURE_ACTION === "mismatch"
29+
? inherited + 1 // batches differently than the first fork
30+
: (inherited ?? CALIBRATED);
31+
writeForkSamples(
32+
mode.samplesFile,
33+
[Number(process.pid), 1],
34+
iterationsPerRound,
35+
);
2036
}
2137

2238
async function main() {
@@ -25,11 +41,12 @@ async function main() {
2541
return;
2642
}
2743
const forks = Number(process.env.ISOLATION_FIXTURE_FORKS);
28-
const samples =
44+
const result =
2945
process.env.ISOLATION_FIXTURE_SYNC !== undefined
3046
? runForksSync("a", forks)
3147
: await runForks("a", forks);
32-
console.log(`samples ${samples.length}`);
48+
console.log(`samples ${result.samples.length}`);
49+
console.log(`iterations ${result.iterationsPerRound}`);
3350
}
3451

3552
// A rejection ends the process with exit code 1.

0 commit comments

Comments
 (0)