Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
96 changes: 96 additions & 0 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 @@ -286,6 +292,96 @@ 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'

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 the provider used by the README example

When a user copies this new direct-calibration example, import { hrtimeNowTimestampProvider } from 'tinybench' fails because the package entry point only exports hrtimeNow and not hrtimeNowTimestampProvider from src/index.ts. Either export the provider constant or change the example to use a public TimestampProvider so the documented API is actually importable.

Useful? React with 👍 / 👎.


const overhead = calibrateTimerOverhead(hrtimeNowTimestampProvider, {
estimator: 'p05',
samples: 1024,
warmupSamples: 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).
- On runtimes with a coarse timer (resolution >= 1 ms) 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 replaces the timer-measured sample directly,
bypassing both the timer and any overhead correction. 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
32 changes: 32 additions & 0 deletions src/bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { BenchEvent } from './event'
import { Task } from './task'
import {
assert,
calibrateTimerOverhead,
defaultConvertTaskResultForConsoleTable,
getTimestampProvider,
runtime,
Expand Down Expand Up @@ -95,6 +96,16 @@ 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'`; the constraint is enforced
* at construction and at the start of {@link Bench.run}.
* @default false
*/
readonly subtractTimerOverhead: boolean

/**
* A teardown function that runs after each task execution.
*/
Expand All @@ -120,6 +131,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 +215,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'),
'`subtractTimerOverhead` cannot be used with `concurrency: "task"` — set `concurrency` to `null` or `"bench"`, or disable `subtractTimerOverhead`'
)
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 +292,10 @@ export class Bench extends EventTarget implements BenchLike {
* @returns the tasks array
*/
async run (): Promise<Task[]> {
assert(
!(this.subtractTimerOverhead && this.concurrency === 'task'),
'`subtractTimerOverhead` cannot be used with `concurrency: "task"` — set `concurrency` to `null` or `"bench"`, or disable `subtractTimerOverhead`'
Comment on lines 292 to +297

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 Guard direct Task.run against task concurrency

This invariant is only checked in Bench.run(), so a bench created with subtractTimerOverhead: true, then changed via the documented mutable bench.concurrency = 'task' pattern, can still enter the unsupported mode by calling the public bench.getTask(name)?.run() API directly. That path uses #benchmark's concurrency === 'task' branch and then applies the sequentially calibrated overhead to concurrent samples, which is the combination this guard is trying to reject; enforce the same invariant before task-level runs too.

Useful? React with 👍 / 👎.

)
Comment on lines +295 to +298

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 Recheck concurrency after start listeners

Fresh evidence beyond the earlier direct Task.run() path: this guard runs before Bench.run() dispatches the public 'start' event, so a listener can still mutate the documented bench.concurrency value to 'task' after the check has passed. With new Bench({ subtractTimerOverhead: true, warmup: false }), such a listener makes the subsequent Task.#benchmark take its task-concurrency branch while still subtracting the sequentially calibrated overhead; recheck after event dispatches or enforce this invariant in Task.run()/#benchmark.

Useful? React with 👍 / 👎.

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
18 changes: 17 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,25 @@ 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,
formatNumber,
hrtimeNow,
medianAbsoluteDeviation,
mToNs,
performanceNow as now,
nToMs,
} from './utils'
Comment thread
jerome-benoit marked this conversation as resolved.
Loading
Loading