Skip to content
Merged
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
101 changes: 101 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,107 @@ bench.concurrency = 'task' // The concurrency mode to determine how tasks are ru
await bench.run()
```

## Aborting Benchmarks

Tinybench supports aborting benchmarks using `AbortSignal` at both the bench and task levels:

### Bench-level Abort

Abort all tasks in a benchmark by passing a signal to the `Bench` constructor:

```ts
const controller = new AbortController()

const bench = new Bench({ signal: controller.signal })

bench
.add('task1', () => {
// This will be aborted
})
.add('task2', () => {
// This will also be aborted
})

// Abort all tasks
controller.abort()

await bench.run()
// Both tasks will be aborted
```

### Task-level Abort

Abort individual tasks without affecting other tasks by passing a signal to the task options:

```ts
const controller = new AbortController()

const bench = new Bench()

bench
.add('abortable task', () => {
// This task can be aborted independently
}, { signal: controller.signal })
.add('normal task', () => {
// This task will continue normally
})

// Abort only the first task
controller.abort()

await bench.run()
// Only 'abortable task' will be aborted, 'normal task' continues
```

### Abort During Execution

You can abort benchmarks while they're running:

```ts
const controller = new AbortController()

const bench = new Bench({ time: 10000 }) // Long-running benchmark

bench.add('long task', async () => {
await new Promise(resolve => setTimeout(resolve, 100))
}, { signal: controller.signal })

// Abort after 1 second
setTimeout(() => controller.abort(), 1000)

await bench.run()
// Task will stop after ~1 second instead of running for 10 seconds
```

### Abort Events

Both `Bench` and `Task` emit `abort` events when aborted:

```ts
const controller = new AbortController()
const bench = new Bench()

bench.add('task', () => {
// Task function
}, { signal: controller.signal })

const task = bench.getTask('task')

// Listen for abort events
task.addEventListener('abort', () => {
console.log('Task aborted!')
})

bench.addEventListener('abort', () => {
console.log('Bench received abort event!')
})

controller.abort()
await bench.run()
```

**Note:** When a task is aborted, `task.result.aborted` will be `true`, and the task will have completed any iterations that were running when the abort signal was received.

## Prior art

- [Benchmark.js](https://github.com/bestiejs/benchmark.js)
Expand Down
44 changes: 37 additions & 7 deletions src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,30 @@ export class Task extends EventTarget {
*/
private readonly fnOpts: Readonly<FnOptions>

/**
* The task-level abort signal
*/
private readonly signal: AbortSignal | undefined

constructor (bench: Bench, name: string, fn: Fn, fnOpts: FnOptions = {}) {
super()
this.bench = bench
this.name = name
this.fn = fn
this.fnOpts = fnOpts
this.async = isFnAsyncResource(fn)
// TODO: support signal in Tasks
this.signal = fnOpts.signal

if (this.signal) {
this.signal.addEventListener(
'abort',
() => {
this.dispatchEvent(createBenchEvent('abort', this))
this.bench.dispatchEvent(createBenchEvent('abort', this))
},
{ once: true }
)
}
}

addEventListener<K extends TaskEvents>(
Expand Down Expand Up @@ -225,7 +241,7 @@ export class Task extends EventTarget {
let totalTime = 0 // ms
const samples: number[] = []
const benchmarkTask = async () => {
if (this.bench.opts.signal?.aborted) {
if (this.isAborted()) {
return
}
try {
Expand Down Expand Up @@ -259,7 +275,7 @@ export class Task extends EventTarget {
// eslint-disable-next-line no-unmodified-loop-condition
(totalTime < time ||
samples.length + (limit?.activeCount ?? 0) + (limit?.pendingCount ?? 0) < iterations) &&
!this.bench.opts.signal?.aborted
!this.isAborted()
) {
if (this.bench.concurrency === 'task') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -268,7 +284,7 @@ export class Task extends EventTarget {
await benchmarkTask()
}
}
if (!this.bench.opts.signal?.aborted && promises.length > 0) {
if (!this.isAborted() && promises.length > 0) {
await Promise.all(promises)
} else if (promises.length > 0) {
// Abort path
Expand Down Expand Up @@ -309,7 +325,7 @@ export class Task extends EventTarget {
let totalTime = 0
const samples: number[] = []
const benchmarkTask = () => {
if (this.bench.opts.signal?.aborted) {
if (this.isAborted()) {
return
}
try {
Expand Down Expand Up @@ -341,7 +357,7 @@ export class Task extends EventTarget {
// eslint-disable-next-line no-unmodified-loop-condition
(totalTime < time ||
samples.length < iterations) &&
!this.bench.opts.signal?.aborted
!this.isAborted()
) {
benchmarkTask()
}
Expand All @@ -363,6 +379,14 @@ export class Task extends EventTarget {
return { samples }
}

/**
* Check if either our signal or the bench-level signal is aborted
* @returns `true` if either signal is aborted
*/
private isAborted (): boolean {
return this.signal?.aborted === true || this.bench.opts.signal?.aborted === true
}

private async measureOnce (): Promise<{ fnResult: ReturnType<Fn>; taskTime: number }> {
const taskStart = this.bench.opts.now()
// eslint-disable-next-line no-useless-call
Expand Down Expand Up @@ -420,6 +444,9 @@ export class Task extends EventTarget {
error?: Error
latencySamples?: number[]
}): void {
// Always set aborted status, even if no samples were collected
const isAborted = this.isAborted()

if (latencySamples && latencySamples.length > 0) {
this.runs = latencySamples.length
const totalTime = latencySamples.reduce((a, b) => a + b, 0)
Expand All @@ -442,7 +469,7 @@ export class Task extends EventTarget {
const throughputStatistics = getStatisticsSorted(throughputSamples)

this.mergeTaskResult({
aborted: this.bench.opts.signal?.aborted ?? false,
aborted: isAborted,
critical: latencyStatistics.critical,
df: latencyStatistics.df,
hz: throughputStatistics.mean,
Expand All @@ -466,6 +493,9 @@ export class Task extends EventTarget {
totalTime,
variance: latencyStatistics.variance,
})
} else if (isAborted) {
// If aborted with no samples, still set the aborted flag
this.mergeTaskResult({ aborted: true })
}

if (error) {
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
*/
export interface BenchOptions {
/**
* number of times that a task should run if even the time option is finished @default 64

Check warning on line 50 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 50 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 50 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
iterations?: number

Expand Down Expand Up @@ -77,27 +77,27 @@
teardown?: Hook

/**
* Throws if a task fails @default false

Check warning on line 80 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 80 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 80 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
throws?: boolean

/**
* time needed for running a benchmark task (milliseconds) @default 1000

Check warning on line 85 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 85 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 85 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
time?: number

/**
* warmup benchmark @default true

Check warning on line 90 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 90 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 90 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
warmup?: boolean

/**
* warmup iterations @default 16

Check warning on line 95 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 95 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 95 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
warmupIterations?: number

/**
* warmup time (milliseconds) @default 250

Check warning on line 100 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on ubuntu-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 100 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on macos-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?

Check warning on line 100 in src/types.ts

View workflow job for this annotation

GitHub Actions / Node.js 22 QA on windows-latest

Unexpected inline JSDoc tag. Did you mean to use {@default}, \@default, or `@default`?
*/
warmupTime?: number
}
Expand Down Expand Up @@ -156,6 +156,13 @@
* An optional function that is run before each iteration of this task
*/
beforeEach?: FnHook

/**
* An AbortSignal for aborting this specific task
*
* If not provided, falls back to {@link BenchOptions.signal}
*/
signal?: AbortSignal
}

/**
Expand Down
Loading
Loading