Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 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,13 @@ export class Bench extends EventTarget implements BenchLike {
*/
readonly signal?: AbortSignal

/**
* Whether to subtract an estimated timestamp provider call overhead from
* each raw latency sample.
* @default false
*/
readonly subtractTimerOverhead: boolean

/**
* A teardown function that runs after each task execution.
*/
Expand All @@ -120,6 +128,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 +212,10 @@ 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
this.timerOverhead = this.subtractTimerOverhead
? calibrateTimerOverhead(this.timestampProvider)
: undefined

if (this.signal) {
this.signal.addEventListener(
Expand Down
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,15 @@ export type {
TimestampProvider,
TimestampValue,
} from './types'
export { formatNumber, hrtimeNow, performanceNow as now, nToMs } from './utils'
export type {
CalibrateTimerOverheadOptions,
TimerOverheadEstimator,
} from './utils'
export {
calibrateTimerOverhead,
estimateResolution,
formatNumber,
hrtimeNow,
performanceNow as now,
nToMs,
} from './utils'
Comment thread
jerome-benoit marked this conversation as resolved.
137 changes: 110 additions & 27 deletions src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { BenchEvent } from './event'
import {
assert,
computeStatistics,
detectTimerSaturation,
estimateResolution,
isFnAsyncResource,
isPromiseLike,
isValidSamples,
Expand Down Expand Up @@ -70,6 +72,17 @@ export class Task extends EventTarget {
options?: RemoveEventListenerOptionsArgument
) => void

/**
* The estimated effective timer resolution observed during the last run,
* computed as the smallest strictly positive latency sample that appears
* at least twice in the sample set.
* @returns The resolution in milliseconds, or `undefined` when no run has
* produced a strictly positive sample
*/
get detectedResolution (): number | undefined {
return this.#detectedResolution
}

/**
* The name of the task.
* @returns The task name as a string
Expand Down Expand Up @@ -116,6 +129,11 @@ export class Task extends EventTarget {
*/
readonly #bench: BenchLike

/**
* The estimated effective timer resolution from the last run.
*/
#detectedResolution: number | undefined = undefined

/**
* The task function
*/
Expand Down Expand Up @@ -217,6 +235,7 @@ export class Task extends EventTarget {
*/
reset (emit = true): void {
this.#runs = 0
this.#detectedResolution = undefined
this.#result = this.#aborted ? abortedTaskResult : notStartedTaskResult

if (emit) this.dispatchEvent(new BenchEvent('reset', this))
Expand All @@ -233,14 +252,14 @@ export class Task extends EventTarget {
this.#result = { state: 'started' }
this.dispatchEvent(new BenchEvent('start', this))
await this.#bench.setup(this, 'run')
const { error, samples: latencySamples } = await this.#benchmark(
'run',
this.#bench.time,
this.#bench.iterations
)
const {
error,
isOverridden,
samples: latencySamples,
} = await this.#benchmark('run', this.#bench.time, this.#bench.iterations)
await this.#bench.teardown(this, 'run')

this.#processRunResult({ error, latencySamples })
this.#processRunResult({ error, isOverridden, latencySamples })

return this
}
Expand All @@ -267,19 +286,19 @@ export class Task extends EventTarget {
'`setup` function must be sync when using `runSync()`'
)

const { error, samples: latencySamples } = this.#benchmarkSync(
'run',
this.#bench.time,
this.#bench.iterations
)
const {
error,
isOverridden,
samples: latencySamples,
} = this.#benchmarkSync('run', this.#bench.time, this.#bench.iterations)

const teardownResult = this.#bench.teardown(this, 'run')
assert(
!isPromiseLike(teardownResult),
'`teardown` function must be sync when using `runSync()`'
)

this.#processRunResult({ error, latencySamples })
this.#processRunResult({ error, isOverridden, latencySamples })

return this
}
Expand Down Expand Up @@ -339,7 +358,8 @@ export class Task extends EventTarget {
time: number,
iterations: number
): Promise<
{ error: Error; samples?: never } | { error?: never; samples?: Samples }
| { error: Error; isOverridden?: never; samples?: never }
| { error?: never; isOverridden?: boolean[]; samples?: Samples }
> {
try {
if (this.#fnOpts.beforeAll) {
Expand All @@ -348,6 +368,8 @@ export class Task extends EventTarget {

let totalTime = 0 // ms
const samples: number[] = []
const isOverridden: boolean[] | undefined =
this.#bench.timerOverhead !== undefined ? [] : undefined

const benchmarkTask = async () => {
if (this.#aborted) {
Expand All @@ -358,11 +380,12 @@ export class Task extends EventTarget {
await this.#fnOpts.beforeEach.call(this, mode)
}

const taskTime = this.#async
const { overridden, taskTime } = this.#async
? await this.#measure()
: this.#measureSync()

samples.push(taskTime)
isOverridden?.push(overridden)
totalTime += taskTime
} finally {
if (this.#fnOpts.afterEach != null) {
Expand Down Expand Up @@ -395,7 +418,7 @@ export class Task extends EventTarget {
await this.#fnOpts.afterAll.call(this, mode)
}

return isValidSamples(samples) ? { samples } : {}
return isValidSamples(samples) ? { isOverridden, samples } : {}
} catch (error) {
return { error: toError(error) }
}
Expand All @@ -411,7 +434,9 @@ export class Task extends EventTarget {
mode: 'run' | 'warmup',
time: number,
iterations: number
): { error: Error; samples?: never } | { error?: never; samples?: Samples } {
):
| { error: Error; isOverridden?: never; samples?: never }
| { error?: never; isOverridden?: boolean[]; samples?: Samples } {
try {
if (this.#fnOpts.beforeAll) {
const beforeAllResult = this.#fnOpts.beforeAll.call(this, mode)
Expand All @@ -423,6 +448,8 @@ export class Task extends EventTarget {

let totalTime = 0
const samples: number[] = []
const isOverridden: boolean[] | undefined =
this.#bench.timerOverhead !== undefined ? [] : undefined

const benchmarkTask = () => {
if (this.#aborted) {
Expand All @@ -437,9 +464,10 @@ export class Task extends EventTarget {
)
}

const taskTime = this.#measureSync()
const { overridden, taskTime } = this.#measureSync()

samples.push(taskTime)
isOverridden?.push(overridden)
totalTime += taskTime
} finally {
if (this.#fnOpts.afterEach) {
Expand Down Expand Up @@ -467,17 +495,18 @@ export class Task extends EventTarget {
'`afterAll` function must be sync when using `runSync()`'
)
}
return isValidSamples(samples) ? { samples } : {}
return isValidSamples(samples) ? { isOverridden, samples } : {}
} catch (error) {
return { error: toError(error) }
}
}

/**
* Measures a single execution of the task function asynchronously.
* @returns The measured execution time
* @returns The measured execution time and whether it was supplied by the
* task function via `overriddenDuration`
*/
async #measure (): Promise<number> {
async #measure (): Promise<{ overridden: boolean; taskTime: number }> {
const taskStart = this.#timestampFn() as unknown as number
// eslint-disable-next-line no-useless-call
const fnResult = await this.#fn.call(this)
Expand All @@ -487,16 +516,17 @@ export class Task extends EventTarget {

const overriddenDuration = getOverriddenDurationFromFnResult(fnResult)
if (overriddenDuration !== undefined) {
return overriddenDuration
return { overridden: true, taskTime: overriddenDuration }
}
return taskTime
return { overridden: false, taskTime }
}

/**
* Measures a single execution of the task function synchronously.
* @returns The measured execution time
* @returns The measured execution time and whether it was supplied by the
* task function via `overriddenDuration`
*/
#measureSync (): number {
#measureSync (): { overridden: boolean; taskTime: number } {
const taskStart = this.#timestampFn() as unknown as number
// eslint-disable-next-line no-useless-call
const fnResult = this.#fn.call(this)
Expand All @@ -510,9 +540,9 @@ export class Task extends EventTarget {
)
const overriddenDuration = getOverriddenDurationFromFnResult(fnResult)
if (overriddenDuration !== undefined) {
return overriddenDuration
return { overridden: true, taskTime: overriddenDuration }
}
return taskTime
return { overridden: false, taskTime }
}

/**
Expand Down Expand Up @@ -555,26 +585,73 @@ export class Task extends EventTarget {
/**
* Processes the result of a benchmark run and updates the task result.
* Calculates statistics from the collected samples and dispatches appropriate events.
* @param options - An object containing the error and latency samples from the run
*
* Ordering:
* 1. Sort raw samples in place.
* 2. Compute the raw timer-resolution diagnostic ({@link estimateResolution}).
* 3. If overhead correction is enabled: compute raw statistics for an
* accurate `mad`, evaluate timer-saturation against the **raw** sample
* set, then subtract the calibrated overhead from each sample whose
* duration was measured by the timer (skipping samples supplied via
* `overriddenDuration`). Re-sort only when overridden samples were
* skipped, since correction otherwise preserves the ascending order.
* 4. Compute the final (possibly corrected) statistics.
* 5. When no correction was applied, evaluate timer-saturation against the
* final samples (raw == final in this case).
* 6. Dispatch `'cycle'` and `'complete'` events; dispatch `'warning'` if
* timer saturation was detected.
* @param options - An object containing the run results
* @param options.error - The error that occurred during the run, if any
* @param options.isOverridden - Parallel boolean array indicating which
* samples were supplied by the task function via `overriddenDuration`,
* or `undefined` when overhead correction is disabled
* @param options.latencySamples - The array of latency samples collected during the run
*/
#processRunResult ({
error,
isOverridden,
latencySamples,
}: {
error?: Error
isOverridden?: boolean[]
latencySamples?: number[]
}): void {
if (isValidSamples(latencySamples)) {
this.#runs = latencySamples.length

sortSamples(latencySamples)

this.#detectedResolution = estimateResolution(latencySamples)

const overhead = this.#bench.timerOverhead
const hasOverhead = overhead !== undefined && overhead > 0
let saturated = false

if (hasOverhead) {
const rawStatistics = computeStatistics(latencySamples, false)
saturated = detectTimerSaturation(latencySamples, rawStatistics.mad)

let needsResort = false
for (let i = 0; i < latencySamples.length; i++) {
if (isOverridden?.[i] === true) {
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
needsResort = true
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
latencySamples[i] = Math.max(0, latencySamples[i]! - overhead)
Comment thread
jerome-benoit marked this conversation as resolved.
}
Comment on lines +630 to +635

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 Compute detectedResolution before subtracting overhead

When subtractTimerOverhead is enabled, this mutates latencySamples before measuredOnly is built and passed to estimateResolution, so Task.detectedResolution is computed from corrected/clamped durations rather than the raw timer deltas. For a timer that really advances in 1 µs ticks with a 0.4 µs calibrated overhead, repeated raw 1 µs samples will be reported as 0.6 µs (or disappear entirely if the overhead clamps them to zero), making the resolution diagnostic misleading exactly when users enable this option for very small benchmarks. Preserve a raw measured-only copy for the diagnostic and only apply this correction to the statistics path.

Useful? React with 👍 / 👎.

}
if (needsResort) sortSamples(latencySamples)
}

const latencyStatistics = computeStatistics(
latencySamples,
this.#retainSamples
)

if (!hasOverhead) {
saturated = detectTimerSaturation(latencySamples, latencyStatistics.mad)
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
}

const latencyStatisticsMean = latencyStatistics.mean

let totalTime = 0
Expand Down Expand Up @@ -606,6 +683,12 @@ export class Task extends EventTarget {
totalTime,
}
/* eslint-enable perfectionist/sort-objects */

if (saturated) {
const warningEv = new BenchEvent('warning', this)
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
this.dispatchEvent(warningEv)
this.#bench.dispatchEvent(warningEv)
Comment on lines +712 to +714

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 Dispatch a fresh warning event to the bench

When a task-level warning listener calls stopImmediatePropagation(), reusing that same BenchEvent for the bench dispatch can prevent bench-level warning listeners from running in Node's EventTarget, even though the new API documents the warning as being emitted on both targets. Create a separate BenchEvent('warning', this, saturationReason) for the bench dispatch so task listener propagation state cannot suppress the bench notification.

Useful? React with 👍 / 👎.

}
} else if (this.#aborted) {
// If aborted with no samples, still set the aborted flag
this.#result = abortedTaskResult
Expand Down
Loading
Loading