Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
25 changes: 25 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,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 ?? false
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
assert(
!(this.subtractTimerOverhead && this.concurrency === 'task'),
'`subtractTimerOverhead` is incompatible with `concurrency: "task"` — overhead is calibrated sequentially and does not reflect concurrent execution cost'
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
)
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
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,16 @@ export type {
TimestampProvider,
TimestampValue,
} from './types'
export { formatNumber, hrtimeNow, performanceNow as now, nToMs } from './utils'
export type {
CalibrateTimerOverheadOptions,
TimerOverheadEstimator,
} from './utils'
export {
calibrateTimerOverhead,
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,
performanceNow as now,
nToMs,
} from './utils'
Comment thread
jerome-benoit marked this conversation as resolved.
148 changes: 121 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,84 @@ 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. Apply overhead correction in-place on the collection-order sample array
* (alignment with `isOverridden` preserved — `latencySamples[i]` still
* matches `isOverridden[i]` because no sort has been performed yet).
* Samples whose duration was supplied via `overriddenDuration` are skipped.
* 2. Build a measured-only view (excluding `overriddenDuration` samples) used
* for timer-saturation detection. Constant `overriddenDuration` values would
* otherwise trigger a spurious low-distinct-count warning.
* 3. Sort the working array for the final statistics and diagnostics.
* 4. Compute `detectedResolution` from the sorted samples.
* 5. Compute the final statistics on the (possibly corrected) sorted samples.
* 6. Run timer-saturation detection on the measured-only subset.
* 7. 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 (collection order) 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

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

// Phase 1 — Subtract overhead while isOverridden[i] is still aligned with
// latencySamples[i] (both in collection order, pre-sort).
if (hasOverhead) {
for (let i = 0; i < latencySamples.length; i++) {
if (isOverridden?.[i] !== true) {
// 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 thread
jerome-benoit marked this conversation as resolved.
}
}

// Phase 2 — Capture measured-only samples (alignment with isOverridden
// is still valid since the array has not been sorted yet).
const hasAnyOverridden = isOverridden?.some(v => v) ?? false
const measuredOnly: number[] = hasAnyOverridden
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? latencySamples.filter((_, i) => isOverridden![i] !== true)
: latencySamples

// Phase 3 — Single sort of the working array.
sortSamples(latencySamples)

// Phase 4 — Resolution diagnostic on sorted samples.
this.#detectedResolution = estimateResolution(latencySamples)
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated

// Phase 5 — Final statistics on (possibly corrected) sorted samples.
const latencyStatistics = computeStatistics(
latencySamples,
this.#retainSamples
)

// Phase 6 — Saturation detection on measured-only samples.
let saturated = false
if (measuredOnly === latencySamples) {
saturated = detectTimerSaturation(latencySamples, latencyStatistics.mad)
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
} else if (isValidSamples(measuredOnly)) {
sortSamples(measuredOnly)
const measuredStats = computeStatistics(measuredOnly, false)
Comment thread
jerome-benoit marked this conversation as resolved.
Outdated
saturated = detectTimerSaturation(measuredOnly, measuredStats.mad)
}

const latencyStatisticsMean = latencyStatistics.mean

let totalTime = 0
Expand Down Expand Up @@ -606,6 +694,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 thread
jerome-benoit marked this conversation as resolved.
}
} else if (this.#aborted) {
// If aborted with no samples, still set the aborted flag
this.#result = abortedTaskResult
Expand Down
Loading
Loading