@@ -22,6 +22,13 @@ export const DEFAULT_WALLTIME_FORKS = 3;
2222export const ISOLATED_BENCHMARK_ENV = "CODSPEED_ISOLATED_BENCHMARK" ;
2323/** Set on each fork: the file where it writes its round samples. */
2424export 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 {
82106export 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. */
154209const 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}
0 commit comments