Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6a71cb2
feat: add timer-overhead correction, saturation warning, and resoluti…
jerome-benoit May 30, 2026
5477644
fix: align overridden samples and harden subtractTimerOverhead
jerome-benoit May 30, 2026
49e619e
refactor(utils): expose saturation classifier and tighten timer typing
jerome-benoit May 30, 2026
2b1e3fe
feat(event): carry timer saturation reason on warning events
jerome-benoit May 30, 2026
e9f9fe4
fix(task): align resolution and saturation diagnostics with measured-…
jerome-benoit May 30, 2026
dc5d765
fix(bench): enforce subtractTimerOverhead invariant at run() and tigh…
jerome-benoit May 30, 2026
a24e811
fix(types): make BenchLike.timerOverhead optional and readonly
jerome-benoit May 30, 2026
869e64e
docs(types): document subtractTimerOverhead clamp consequences honestly
jerome-benoit May 30, 2026
b93d693
test: cover alignment, p05 estimator, run() invariant, and saturation…
jerome-benoit May 30, 2026
556d884
docs(readme): document timer overhead correction, per-sample override…
jerome-benoit May 30, 2026
411c1d7
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit May 30, 2026
eac7e03
fix(utils): use backticked refs for non-exported symbols in JSDoc
jerome-benoit May 30, 2026
2a5a701
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit May 30, 2026
b7e7e42
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit May 31, 2026
ce3c2c4
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 3, 2026
2f8232a
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 5, 2026
a3b9a37
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 7, 2026
f9a0a6a
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 9, 2026
1376205
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 14, 2026
c53307d
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 17, 2026
32e0ee5
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 17, 2026
718cff9
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 25, 2026
42fd0be
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 25, 2026
0a221a3
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jun 28, 2026
eadeb79
Merge branch 'main' into feat/timer-diagnostics-and-overhead-correction
jerome-benoit Jul 22, 2026
8600893
fix(index): export hrtimeNow and performanceNow timestamp providers
jerome-benoit Jul 22, 2026
ec6c5ae
perf(task): derive detectedResolution from sorted samples
jerome-benoit Jul 22, 2026
80e4ac5
test(utils): lock timer-saturation classifier thresholds
jerome-benoit Jul 22, 2026
48ba856
docs(readme): clarify the coarse-timer no-op condition
jerome-benoit Jul 22, 2026
fdb108c
refactor(bench): hoist duplicated subtractTimerOverhead/concurrency a…
jerome-benoit Jul 22, 2026
264c751
refactor(task): use hasAnyOverridden instead of reference-identity check
jerome-benoit Jul 22, 2026
12226b7
refactor(utils): rename calibrate options to pairs/warmupPairs
jerome-benoit Jul 22, 2026
35fce79
docs(readme): configure concurrency and threshold via constructor opt…
jerome-benoit Jul 22, 2026
94c0d8c
docs(bench): clarify the run() re-assert guards JS-side mutation
jerome-benoit Jul 22, 2026
244af5a
refactor(task): unify isOverridden guard and correct its param JSDoc
jerome-benoit Jul 22, 2026
d5822c0
refactor(utils): guard pairs===0 explicitly in calibrateTimerOverhead
jerome-benoit Jul 22, 2026
19358ab
fix(utils): guard non-positive pairs in calibrateTimerOverhead
jerome-benoit Jul 22, 2026
eca70bf
docs(readme): clarify overriddenDuration does not bypass the timer
jerome-benoit Jul 22, 2026
f806350
fix(utils): reject non-finite/non-integer pair counts in calibrateTim…
jerome-benoit Jul 22, 2026
15f30d9
perf(task): track overridden samples by index Set, not a parallel boo…
jerome-benoit Jul 22, 2026
344fa4b
refactor(task): extract BenchmarkResult union for #benchmark/#benchma…
jerome-benoit Jul 22, 2026
26d8507
docs(task): finish isOverridden -> overriddenIndices rename in #proce…
jerome-benoit Jul 22, 2026
739fbfa
test(task): rename stale isOverridden test title to overridden samples
jerome-benoit Jul 22, 2026
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
105 changes: 103 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ Both the `Task` and `Bench` classes extend the `EventTarget` object. So you can
bench.addEventListener('cycle', (evt) => {
const task = evt.task!;
});

// runs when timer saturation is detected for a task's measured samples
bench.addEventListener('warning', (evt) => {
const task = evt.task!;
const reason = evt.reason; // 'zero-dominated' | 'low-distinct' | 'zero-mad'
});
```

#### [`TaskEvents`](https://tinylibs.github.io/tinybench/types/TaskEvents.html)
Expand Down Expand Up @@ -147,8 +153,10 @@ await bench.run()
- When `mode` is set to 'bench', different tasks within the bench run concurrently. Concurrent cycles.

```ts
bench.threshold = 10 // The maximum number of concurrent tasks to run. Defaults to Number.POSITIVE_INFINITY.
bench.concurrency = 'task' // The concurrency mode to determine how tasks are run.
const bench = new Bench({
concurrency: 'task', // The concurrency mode to determine how tasks are run.
threshold: 10, // The maximum number of concurrent tasks to run. Defaults to Number.POSITIVE_INFINITY.
})
await bench.run()
```

Expand Down Expand Up @@ -286,6 +294,99 @@ const bench = new Bench({
})
```

## Timer Overhead Correction

Each timer call (`performance.now()`, `process.hrtime.bigint()`, …) has a
non-zero call cost `C`. For a task whose true duration `X` is comparable
to `C`, the raw measured sample `X + C` is dominated by the timer rather
than the task.

When `subtractTimerOverhead: true` is set, an estimate `Ĉ` is computed
once at construction time via [`calibrateTimerOverhead`](https://tinylibs.github.io/tinybench/functions/calibrateTimerOverhead.html),
and `Math.max(0, raw_sample - Ĉ)` is used as each non-overridden sample
before statistics are computed.

```ts
const bench = new Bench({ subtractTimerOverhead: true })
console.log(bench.timerOverhead) // calibrated Ĉ in ms (or undefined)
```

The calibration helper is also exported for direct use, with a
configurable estimator strategy (`'median'` default, or `'min'` / `'p05'`):

```ts
import { calibrateTimerOverhead, hrtimeNowTimestampProvider } from 'tinybench'
Comment thread
jerome-benoit marked this conversation as resolved.

const overhead = calibrateTimerOverhead(hrtimeNowTimestampProvider, {
estimator: 'p05',
pairs: 1024,
warmupPairs: 64,
})
```

**Caveats.**

- Incompatible with `concurrency: 'task'` — overhead is calibrated
sequentially and does not reflect concurrent execution cost.
Construction (and `run()`) throws if both are set.
- For sub-overhead measurements (`X ≈ Ĉ`) the `max(0, …)` clamp
truncates the lower tail and biases statistics; prefer
`overriddenDuration` (see below).
- When the timer is too coarse to resolve the call cost — fewer than half
of the calibration pairs produce a positive delta (call cost `C < R / 2`,
e.g. a `Date.now`-class timer with `>= 1 ms` resolution) — the calibration
returns `0` and the option becomes a no-op.

## Per-Sample Override (`overriddenDuration`)

A task function may return an object containing `overriddenDuration`
(in ms). That value is recorded in place of the timer-measured sample:
the timer still brackets the task function, but its measurement is
discarded and overhead correction is not applied to the substituted
value. Useful for externally-timed work or sub-overhead measurements
that the timer cannot resolve.

```ts
bench.add('externally-timed', () => {
const start = process.hrtime.bigint()
doWork()
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6
return { overriddenDuration: elapsedMs }
})
```

Overridden samples are excluded from `Task.detectedResolution` and
from timer-saturation detection.

## Timer Diagnostics

After `bench.run()` (or `runSync()`), each task exposes
`detectedResolution` — the smallest reproducibly observed positive
sample (in ms) among the timer-measured samples, or `undefined` when no
positive timer measurement was seen (e.g. every sample was overridden).

```ts
const task = bench.getTask('foo')
console.log(task?.detectedResolution) // e.g. 0.000041 (≈ 41 ns)
```

When the timer's resolution dominates a task's measured distribution
(more than half zero samples, fewer than `max(3, min(10, ⌊n / 1000⌋))`
distinct values, or zero MAD with `n > 100`), tinybench dispatches a
`'warning'` event on both the task and the bench, carrying the matching
[`TimerSaturationReason`](https://tinylibs.github.io/tinybench/types/TimerSaturationReason.html):

```ts
bench.addEventListener('warning', evt => {
console.warn(`timer-saturated: ${evt.task?.name} — ${evt.reason}`)
})
```

The same heuristic and estimator are exposed as standalone helpers for
custom analysis: [`detectTimerSaturation`](https://tinylibs.github.io/tinybench/functions/detectTimerSaturation.html),
[`classifyTimerSaturation`](https://tinylibs.github.io/tinybench/functions/classifyTimerSaturation.html),
and [`estimateResolution`](https://tinylibs.github.io/tinybench/functions/estimateResolution.html).

## Aborting Benchmarks

Tinybench supports aborting benchmarks using `AbortSignal` at both the bench and task levels:
Expand Down
37 changes: 37 additions & 0 deletions src/bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ import { BenchEvent } from './event'
import { Task } from './task'
import {
assert,
calibrateTimerOverhead,
defaultConvertTaskResultForConsoleTable,
getTimestampProvider,
runtime,
runtimeVersion,
} from './utils'

const subtractTimerOverheadConcurrencyError =
'`subtractTimerOverhead` cannot be used with `concurrency: "task"` — set `concurrency` to `null` or `"bench"`, or disable `subtractTimerOverhead`'

/**
* The Bench class keeps track of the benchmark tasks and controls them.
*/
Expand Down Expand Up @@ -95,6 +99,18 @@ export class Bench extends EventTarget implements BenchLike {
*/
readonly signal?: AbortSignal

/**
* Whether to subtract an estimated timestamp provider call overhead from
* each raw latency sample.
*
* Incompatible with `concurrency: 'task'`. Enforced at construction and
* re-checked at the start of {@link Bench.run} to guard against untyped
* (JS-side) mutation of the `readonly` `concurrency` field after
* construction.
* @default false
*/
readonly subtractTimerOverhead: boolean

/**
* A teardown function that runs after each task execution.
*/
Expand All @@ -120,6 +136,15 @@ export class Bench extends EventTarget implements BenchLike {
*/
readonly time: number

/**
* The estimated cost of one timestamp provider call in milliseconds.
*
* `undefined` when {@link subtractTimerOverhead} is `false`.
* Otherwise calibrated once at construction time via
* {@link calibrateTimerOverhead}.
*/
readonly timerOverhead: number | undefined

/**
* A timestamp provider and its related functions.
*/
Expand Down Expand Up @@ -195,6 +220,14 @@ export class Bench extends EventTarget implements BenchLike {
this.throws = restOptions.throws ?? false
this.signal = restOptions.signal
this.retainSamples = restOptions.retainSamples === true
this.subtractTimerOverhead = restOptions.subtractTimerOverhead === true
assert(
!(this.subtractTimerOverhead && this.concurrency === 'task'),
subtractTimerOverheadConcurrencyError
)
Comment thread
jerome-benoit marked this conversation as resolved.
this.timerOverhead = this.subtractTimerOverhead
? calibrateTimerOverhead(this.timestampProvider)
: undefined

if (this.signal) {
this.signal.addEventListener(
Expand Down Expand Up @@ -264,6 +297,10 @@ export class Bench extends EventTarget implements BenchLike {
* @returns the tasks array
*/
async run (): Promise<Task[]> {
assert(
!(this.subtractTimerOverhead && this.concurrency === 'task'),
subtractTimerOverheadConcurrencyError
)
Comment thread
jerome-benoit marked this conversation as resolved.
if (this.warmup) {
await this.#warmupTasks()
}
Expand Down
29 changes: 27 additions & 2 deletions src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
BenchEventsOptionalTask,
BenchEventsWithError,
BenchEventsWithTask,
TimerSaturationReason,
} from './types'

/**
Expand All @@ -24,6 +25,20 @@ class BenchEvent<
return this.#error as K extends BenchEventsWithError ? Error : undefined
}

/**
* The reason a `'warning'` event was dispatched.
* @returns The {@link TimerSaturationReason} for `'warning'` events;
* `undefined` for every other event type and for `'warning'` events
* dispatched without a reason
*/
get reason (): K extends 'warning'
? TimerSaturationReason | undefined
: undefined {
return this.#reason as K extends 'warning'
? TimerSaturationReason | undefined
: undefined
}

/**
* The task associated with the event.
* @returns The task if the event type is one that includes a task; otherwise, undefined
Expand All @@ -41,15 +56,25 @@ class BenchEvent<
}

#error?: Error
#reason?: TimerSaturationReason
#task?: Task

constructor (type: 'warning', task: Task, reason?: TimerSaturationReason)
constructor (type: BenchEventsWithError, task: Task, error: Error)
constructor (type: BenchEventsWithTask, task: Task)
constructor (type: BenchEventsOptionalTask, task?: Task)
constructor (type: BenchEvents, task?: Task, error?: Error) {
constructor (
type: BenchEvents,
task?: Task,
errorOrReason?: Error | TimerSaturationReason
) {
super(type)
this.#task = task
this.#error = error
if (typeof errorOrReason === 'string') {
this.#reason = errorOrReason
} else {
this.#error = errorOrReason
}
}
}

Expand Down
20 changes: 19 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,27 @@ export type {
TaskResultStarted,
TaskResultTimestampProviderInfo,
TaskResultWithStatistics,
TimerSaturationReason,
TimestampFn,
TimestampFns,
TimestampProvider,
TimestampValue,
} from './types'
export { formatNumber, hrtimeNow, mToNs, performanceNow as now, nToMs } from './utils'
export type {
CalibrateTimerOverheadOptions,
TimerOverheadEstimatorKind,
} from './utils'
export {
calibrateTimerOverhead,
classifyTimerSaturation,
detectTimerSaturation,
estimateResolution,
Comment on lines +47 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Export a way to construct sorted samples

For TypeScript consumers performing the advertised custom analysis on their own data, these helpers require the branded SortedSamples type, so even a sorted number[] is rejected by the compiler. The only function that establishes this brand is sortSamples, but it is not exported from the package root; consequently classifyTimerSaturation, detectTimerSaturation, and estimateResolution require an unsafe cast unless samples came directly from a retained task result. Export sortSamples or change the public helpers to accept and validate ordinary arrays.

Useful? React with 👍 / 👎.

formatNumber,
hrtimeNow,
hrtimeNowTimestampProvider,
medianAbsoluteDeviation,
mToNs,
performanceNow as now,
nToMs,
performanceNowTimestampProvider,
} from './utils'
Comment thread
jerome-benoit marked this conversation as resolved.
Loading
Loading