Skip to content

Commit c4ddb46

Browse files
authored
feat: add runSync method to Bench to force benchmarks to be synchronous (#210)
1 parent c935110 commit c4ddb46

File tree

4 files changed

+911
-97
lines changed

4 files changed

+911
-97
lines changed

src/bench.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from './constants'
2121
import { createBenchEvent } from './event'
2222
import { Task } from './task'
23-
import { type JSRuntime, mToNs, now, runtime, runtimeVersion } from './utils'
23+
import { invariant, type JSRuntime, mToNs, now, runtime, runtimeVersion } from './utils'
2424

2525
/**
2626
* The Bench class keeps track of the benchmark tasks and controls them.
@@ -207,6 +207,20 @@ export class Bench extends EventTarget {
207207
return values
208208
}
209209

210+
runSync (): Task[] {
211+
invariant(this.concurrency === null, 'Cannot use `concurrency` option when using `runSync`')
212+
if (this.opts.warmup) {
213+
this.warmupTasksSync()
214+
}
215+
const values: Task[] = []
216+
this.dispatchEvent(createBenchEvent('start'))
217+
for (const task of this._tasks.values()) {
218+
values.push(task.runSync())
219+
}
220+
this.dispatchEvent(createBenchEvent('complete'))
221+
return values
222+
}
223+
210224
/**
211225
* table of the tasks results
212226
* @param convert - an optional callback to convert the task result to a table record
@@ -258,4 +272,14 @@ export class Bench extends EventTarget {
258272
}
259273
}
260274
}
275+
276+
/**
277+
* warmup the benchmark tasks (sync version)
278+
*/
279+
private warmupTasksSync (): void {
280+
this.dispatchEvent(createBenchEvent('warmup'))
281+
for (const task of this._tasks.values()) {
282+
task.warmupSync()
283+
}
284+
}
261285
}

src/task.ts

Lines changed: 191 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
} from './types'
1313

1414
import { createBenchEvent, createErrorEvent } from './event'
15-
import { getStatisticsSorted, isFnAsyncResource } from './utils'
15+
import { getStatisticsSorted, invariant, isFnAsyncResource, isPromiseLike } from './utils'
1616

1717
/**
1818
* A class that represents each benchmark task in Tinybench. It keeps track of the
@@ -109,66 +109,38 @@ export class Task extends EventTarget {
109109
)) as { error?: Error; samples?: number[] }
110110
await this.bench.opts.teardown?.(this, 'run')
111111

112-
if (latencySamples) {
113-
this.runs = latencySamples.length
114-
const totalTime = latencySamples.reduce((a, b) => a + b, 0)
112+
this.processRunResult({ error, latencySamples })
115113

116-
// Latency statistics
117-
const latencyStatistics = getStatisticsSorted(
118-
latencySamples.sort((a, b) => a - b)
119-
)
114+
return this
115+
}
120116

121-
// Throughput statistics
122-
const throughputSamples = latencySamples
123-
.map(sample =>
124-
sample !== 0 ? 1000 / sample : 1000 / latencyStatistics.mean
125-
) // Use latency average as imputed sample
126-
.sort((a, b) => a - b)
127-
const throughputStatistics = getStatisticsSorted(throughputSamples)
117+
/**
118+
* run the current task and write the results in `Task.result` object property
119+
* @returns the current task
120+
* @internal
121+
*/
122+
runSync (): this {
123+
if (this.result?.error) {
124+
return this
125+
}
128126

129-
if (this.bench.opts.signal?.aborted) {
130-
return this
131-
}
127+
invariant(this.bench.concurrency === null, 'Cannot use `concurrency` option when using `runSync`')
128+
this.dispatchEvent(createBenchEvent('start', this))
132129

133-
this.mergeTaskResult({
134-
critical: latencyStatistics.critical,
135-
df: latencyStatistics.df,
136-
hz: throughputStatistics.mean,
137-
latency: latencyStatistics,
138-
max: latencyStatistics.max,
139-
mean: latencyStatistics.mean,
140-
min: latencyStatistics.min,
141-
moe: latencyStatistics.moe,
142-
p75: latencyStatistics.p75,
143-
p99: latencyStatistics.p99,
144-
p995: latencyStatistics.p995,
145-
p999: latencyStatistics.p999,
146-
period: totalTime / this.runs,
147-
rme: latencyStatistics.rme,
148-
runtime: this.bench.runtime,
149-
runtimeVersion: this.bench.runtimeVersion,
150-
samples: latencyStatistics.samples,
151-
sd: latencyStatistics.sd,
152-
sem: latencyStatistics.sem,
153-
throughput: throughputStatistics,
154-
totalTime,
155-
variance: latencyStatistics.variance,
156-
})
157-
}
130+
const setupResult = this.bench.opts.setup?.(this, 'run')
131+
invariant(!isPromiseLike(setupResult), '`setup` function must be sync when using `runSync()`')
158132

159-
if (error) {
160-
this.mergeTaskResult({ error })
161-
this.dispatchEvent(createErrorEvent(this, error))
162-
this.bench.dispatchEvent(createErrorEvent(this, error))
163-
if (this.bench.opts.throws) {
164-
throw error
165-
}
166-
}
133+
const { error, samples: latencySamples } = (this.benchmarkSync(
134+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
135+
this.bench.opts.time!,
136+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
137+
this.bench.opts.iterations!
138+
)) as { error?: Error; samples?: number[] }
167139

168-
this.dispatchEvent(createBenchEvent('cycle', this))
169-
this.bench.dispatchEvent(createBenchEvent('cycle', this))
170-
// cycle and complete are equal in Task
171-
this.dispatchEvent(createBenchEvent('complete', this))
140+
const teardownResult = this.bench.opts.teardown?.(this, 'run')
141+
invariant(!isPromiseLike(teardownResult), '`teardown` function must be sync when using `runSync()`')
142+
143+
this.processRunResult({ error, latencySamples })
172144

173145
return this
174146
}
@@ -191,14 +163,34 @@ export class Task extends EventTarget {
191163
)) as { error?: Error }
192164
await this.bench.opts.teardown?.(this, 'warmup')
193165

194-
if (error) {
195-
this.mergeTaskResult({ error })
196-
this.dispatchEvent(createErrorEvent(this, error))
197-
this.bench.dispatchEvent(createErrorEvent(this, error))
198-
if (this.bench.opts.throws) {
199-
throw error
200-
}
166+
this.postWarmup(error)
167+
}
168+
169+
/**
170+
* warmup the current task (sync version)
171+
* @internal
172+
*/
173+
warmupSync (): void {
174+
if (this.result?.error) {
175+
return
201176
}
177+
178+
this.dispatchEvent(createBenchEvent('warmup', this))
179+
180+
const setupResult = this.bench.opts.setup?.(this, 'warmup')
181+
invariant(!isPromiseLike(setupResult), '`setup` function must be sync when using `runSync()`')
182+
183+
const { error } = (this.benchmarkSync(
184+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
185+
this.bench.opts.warmupTime!,
186+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
187+
this.bench.opts.warmupIterations!
188+
)) as { error?: Error }
189+
190+
const teardownResult = this.bench.opts.teardown?.(this, 'warmup')
191+
invariant(!isPromiseLike(teardownResult), '`teardown` function must be sync when using `runSync()`')
192+
193+
this.postWarmup(error)
202194
}
203195

204196
private async benchmark (
@@ -278,6 +270,69 @@ export class Task extends EventTarget {
278270
return { samples }
279271
}
280272

273+
private benchmarkSync (
274+
time: number,
275+
iterations: number
276+
): { error?: unknown; samples?: number[] } {
277+
if (this.fnOpts.beforeAll != null) {
278+
try {
279+
const beforeAllResult = this.fnOpts.beforeAll.call(this)
280+
invariant(!isPromiseLike(beforeAllResult), '`beforeAll` function must be sync when using `runSync()`')
281+
} catch (error) {
282+
return { error }
283+
}
284+
}
285+
286+
// TODO: factor out
287+
let totalTime = 0 // ms
288+
const samples: number[] = []
289+
const benchmarkTask = () => {
290+
if (this.fnOpts.beforeEach != null) {
291+
const beforeEachResult = this.fnOpts.beforeEach.call(this)
292+
invariant(!isPromiseLike(beforeEachResult), '`beforeEach` function must be sync when using `runSync()`')
293+
}
294+
295+
let taskTime = 0 // ms;
296+
297+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
298+
const taskStart = this.bench.opts.now!()
299+
// eslint-disable-next-line no-useless-call
300+
const result = this.fn.call(this)
301+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
302+
taskTime = this.bench.opts.now!() - taskStart
303+
304+
invariant(!isPromiseLike(result), 'task function must be sync when using `runSync()`')
305+
306+
samples.push(taskTime)
307+
totalTime += taskTime
308+
309+
if (this.fnOpts.afterEach != null) {
310+
const afterEachResult = this.fnOpts.afterEach.call(this)
311+
invariant(!isPromiseLike(afterEachResult), '`afterEach` function must be sync when using `runSync()`')
312+
}
313+
}
314+
315+
try {
316+
while (
317+
// eslint-disable-next-line no-unmodified-loop-condition
318+
(totalTime < time || samples.length < iterations)) {
319+
benchmarkTask()
320+
}
321+
} catch (error) {
322+
return { error }
323+
}
324+
325+
if (this.fnOpts.afterAll != null) {
326+
try {
327+
const afterAllResult = this.fnOpts.afterAll.call(this)
328+
invariant(!isPromiseLike(afterAllResult), '`afterAll` function must be sync when using `runSync()`')
329+
} catch (error) {
330+
return { error }
331+
}
332+
}
333+
return { samples }
334+
}
335+
281336
/**
282337
* merge into the result object values
283338
* @param result - the task result object to merge with the current result object values
@@ -288,4 +343,78 @@ export class Task extends EventTarget {
288343
...result,
289344
}) as Readonly<TaskResult>
290345
}
346+
347+
private postWarmup (error: Error | undefined): void {
348+
if (error) {
349+
this.mergeTaskResult({ error })
350+
this.dispatchEvent(createErrorEvent(this, error))
351+
this.bench.dispatchEvent(createErrorEvent(this, error))
352+
if (this.bench.opts.throws) {
353+
throw error
354+
}
355+
}
356+
}
357+
358+
private processRunResult ({ error, latencySamples }: { error?: Error, latencySamples?: number[] }): void {
359+
if (latencySamples) {
360+
this.runs = latencySamples.length
361+
const totalTime = latencySamples.reduce((a, b) => a + b, 0)
362+
363+
// Latency statistics
364+
const latencyStatistics = getStatisticsSorted(
365+
latencySamples.sort((a, b) => a - b)
366+
)
367+
368+
// Throughput statistics
369+
const throughputSamples = latencySamples
370+
.map(sample =>
371+
sample !== 0 ? 1000 / sample : 1000 / latencyStatistics.mean
372+
) // Use latency average as imputed sample
373+
.sort((a, b) => a - b)
374+
const throughputStatistics = getStatisticsSorted(throughputSamples)
375+
376+
if (this.bench.opts.signal?.aborted) {
377+
return
378+
}
379+
380+
this.mergeTaskResult({
381+
critical: latencyStatistics.critical,
382+
df: latencyStatistics.df,
383+
hz: throughputStatistics.mean,
384+
latency: latencyStatistics,
385+
max: latencyStatistics.max,
386+
mean: latencyStatistics.mean,
387+
min: latencyStatistics.min,
388+
moe: latencyStatistics.moe,
389+
p75: latencyStatistics.p75,
390+
p99: latencyStatistics.p99,
391+
p995: latencyStatistics.p995,
392+
p999: latencyStatistics.p999,
393+
period: totalTime / this.runs,
394+
rme: latencyStatistics.rme,
395+
runtime: this.bench.runtime,
396+
runtimeVersion: this.bench.runtimeVersion,
397+
samples: latencyStatistics.samples,
398+
sd: latencyStatistics.sd,
399+
sem: latencyStatistics.sem,
400+
throughput: throughputStatistics,
401+
totalTime,
402+
variance: latencyStatistics.variance,
403+
})
404+
}
405+
406+
if (error) {
407+
this.mergeTaskResult({ error })
408+
this.dispatchEvent(createErrorEvent(this, error))
409+
this.bench.dispatchEvent(createErrorEvent(this, error))
410+
if (this.bench.opts.throws) {
411+
throw error
412+
}
413+
}
414+
415+
this.dispatchEvent(createBenchEvent('cycle', this))
416+
this.bench.dispatchEvent(createBenchEvent('cycle', this))
417+
// cycle and complete are equal in Task
418+
this.dispatchEvent(createBenchEvent('complete', this))
419+
}
291420
}

src/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export const now = performanceNow
158158
* @param maybePromiseLike - the value to check
159159
* @returns true if the value is a promise-like object
160160
*/
161-
const isPromiseLike = <T>(
161+
export const isPromiseLike = <T>(
162162
maybePromiseLike: unknown
163163
): maybePromiseLike is PromiseLike<T> =>
164164
maybePromiseLike !== null &&
@@ -335,3 +335,9 @@ export const getStatisticsSorted = (samples: number[]): Statistics => {
335335
variance: vr,
336336
}
337337
}
338+
339+
export const invariant = (condition: boolean, message: string): void => {
340+
if (!condition) {
341+
throw new Error(message)
342+
}
343+
}

0 commit comments

Comments
 (0)