55 MARKER_TYPE_BENCHMARK_START ,
66 msToNs ,
77 msToS ,
8+ optimizeFunction ,
89 wrapWithRootFrame ,
910 writeWalltimeResults ,
1011 type Benchmark ,
@@ -14,6 +15,21 @@ import type * as tinybench from "tinybench";
1415
1516export type Tinybench = typeof tinybench ;
1617
18+ /** tinybench's per-task lifecycle hooks (a subset of `FnOptions`). */
19+ export interface TinybenchFnOptions {
20+ beforeAll ?: ( mode ?: "run" | "warmup" ) => unknown ;
21+ beforeEach ?: ( mode ?: "run" | "warmup" ) => unknown ;
22+ afterEach ?: ( mode ?: "run" | "warmup" ) => unknown ;
23+ afterAll ?: ( mode ?: "run" | "warmup" ) => unknown ;
24+ }
25+
26+ /** The captured registration for a task: its fn and options. */
27+ export interface CapturedTask {
28+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29+ fn : ( ...args : any [ ] ) => any ;
30+ fnOpts ?: TinybenchFnOptions ;
31+ }
32+
1733/** A tinybench task, exposing the `fn` the runner wraps with the root frame. */
1834export interface TinybenchTask {
1935 name : string ;
@@ -34,17 +50,6 @@ export interface TinybenchBench {
3450 teardown : TinybenchHook ;
3551}
3652
37- /** The minimal task shape `patchTaskRunWithRootFrame` mutates. */
38- interface RunnableTask {
39- // eslint-disable-next-line @typescript-eslint/no-explicit-any
40- fn : ( ...args : any [ ] ) => any ;
41- }
42-
43- /** The tinybench Task prototype whose `run` we wrap. */
44- interface TinybenchTaskClass {
45- // eslint-disable-next-line @typescript-eslint/no-explicit-any
46- prototype : { run : ( this : any ) => Promise < unknown > } ;
47- }
4853
4954/**
5055 * The tinybench statistics shape (latency/throughput) shared across the v2 and
@@ -77,7 +82,7 @@ interface InstrumentWindow {
7782 runStart : bigint | null ;
7883}
7984
80- let isTaskPatched = false ;
85+ let isBenchAddPatched = false ;
8186
8287/**
8388 * The window bracketing the currently running task's measured loop, driven by
@@ -86,14 +91,85 @@ let isTaskPatched = false;
8691 */
8792const instrumentWindow : InstrumentWindow = { runStart : null } ;
8893
94+ // tinybench keeps a task's fn and options as `#private` fields (v6+), so we
95+ // capture them ourselves when `Bench.add` runs, keyed by bench then task name.
96+ // The analysis seam needs the raw fn to run it under its own tight window
97+ // instead of tinybench's timing loop.
98+ const capturedTasks = new WeakMap < object , Map < string , CapturedTask > > ( ) ;
99+
100+ /** The minimal tinybench Bench prototype we patch to capture registrations. */
101+ interface TinybenchBenchClass {
102+ prototype : {
103+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104+ add : ( ...args : any [ ] ) => unknown ;
105+ } ;
106+ }
107+
108+ /**
109+ * Patch `Bench.prototype.add` to record each task's fn and options, keyed by
110+ * bench then task name. Idempotent, and applied to the prototype so it captures
111+ * registrations on every Bench the host constructs.
112+ *
113+ * `BenchClass` must be the exact class the host instantiates. In tinybench v6 a
114+ * task's fn is a true `#private` field — it cannot be read or replaced on the
115+ * task afterwards — so capturing (and, for walltime, root-frame-wrapping) has to
116+ * happen here, as the fn is registered.
117+ *
118+ * `registerFn` transforms the fn actually handed to tinybench: identity for
119+ * analysis (which runs the captured fn itself), or a root-frame wrap for
120+ * walltime (where tinybench drives the fn and the frame must already be baked
121+ * in).
122+ */
123+ export function captureBenchAddOnce (
124+ BenchClass : TinybenchBenchClass ,
125+ registerFn : ( fn : CapturedTask [ "fn" ] ) => CapturedTask [ "fn" ] ,
126+ ) : void {
127+ if ( isBenchAddPatched ) {
128+ return ;
129+ }
130+ isBenchAddPatched = true ;
131+
132+ const originalAdd = BenchClass . prototype . add ;
133+ BenchClass . prototype . add = function (
134+ this : object ,
135+ name : string ,
136+ fn : CapturedTask [ "fn" ] ,
137+ fnOpts ?: TinybenchFnOptions ,
138+ ) {
139+ let byName = capturedTasks . get ( this ) ;
140+ if ( ! byName ) {
141+ byName = new Map < string , CapturedTask > ( ) ;
142+ capturedTasks . set ( this , byName ) ;
143+ }
144+ byName . set ( name , { fn, fnOpts } ) ;
145+ return originalAdd . call ( this , name , registerFn ( fn ) , fnOpts ) ;
146+ } ;
147+ }
148+
149+ /** Retrieve the fn/options captured for a task on a given bench, if any. */
150+ export function getCapturedTask (
151+ bench : object ,
152+ taskName : string ,
153+ ) : CapturedTask | undefined {
154+ return capturedTasks . get ( bench ) ?. get ( taskName ) ;
155+ }
156+
157+ /** The tinybench Task prototype whose `run` the legacy seam wraps. */
158+ interface TinybenchTaskClass {
159+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160+ prototype : { run : ( this : any ) => Promise < unknown > } ;
161+ }
162+
163+ let isTaskPatched = false ;
164+
89165/**
90- * Wrap every task's fn with the root frame so collected stacks are attributed to
91- * a benchmark. Idempotent: patching the shared `Task.prototype.run` in place hits
92- * every Bench instance, so repeat calls are no-ops .
166+ * Wrap every task's fn with the root frame by patching `Task.prototype.run` in
167+ * place. Used only by the legacy (Vitest 3/4) walltime seam, which runs on
168+ * tinybench v2 where a task's `fn` is a plain, reassignable property .
93169 *
94- * `TaskClass` must be the exact prototype the host constructed its tasks against
95- * (taken from a live task, not imported) so the patch applies even when multiple
96- * copies of tinybench are installed .
170+ * The Vitest 5 seam cannot use this: tinybench v6 made `fn` a true `#private`
171+ * field, so reassigning ` task.fn` is a silent no-op there — the frame must be
172+ * baked in at registration time instead (see rootFrameRegisterFn) .
97173 */
98174export function patchTaskRunOnce ( TaskClass : TinybenchTaskClass ) : void {
99175 if ( isTaskPatched ) {
@@ -102,7 +178,7 @@ export function patchTaskRunOnce(TaskClass: TinybenchTaskClass): void {
102178 isTaskPatched = true ;
103179
104180 const originalRun = TaskClass . prototype . run ;
105- TaskClass . prototype . run = async function ( this : RunnableTask ) {
181+ TaskClass . prototype . run = async function ( this : CapturedTask ) {
106182 const originalFn = this . fn ;
107183 this . fn = wrapWithRootFrame ( ( ) => originalFn . call ( this ) ) ;
108184
@@ -114,6 +190,53 @@ export function patchTaskRunOnce(TaskClass: TinybenchTaskClass): void {
114190 } ;
115191}
116192
193+ /**
194+ * The root-frame wrap to hand tinybench at registration time (walltime, v5).
195+ * Post-hoc assignment to a task's `fn` is a no-op on tinybench v6 (private
196+ * field), so the frame must be baked into the registered fn instead.
197+ */
198+ export function rootFrameRegisterFn (
199+ fn : CapturedTask [ "fn" ] ,
200+ ) : CapturedTask [ "fn" ] {
201+ return wrapWithRootFrame ( ( ) => fn ( ) ) ;
202+ }
203+
204+ /** Identity registration: analysis runs the captured fn itself, unwrapped. */
205+ export function identityRegisterFn ( fn : CapturedTask [ "fn" ] ) : CapturedTask [ "fn" ] {
206+ return fn ;
207+ }
208+
209+ /**
210+ * Run one benchmark under instrumentation, matching the analysis window the
211+ * Vitest 3/4 runner uses exactly: warm the JIT with `optimizeFunction` outside
212+ * the window, run the user hooks around a single measured `fn()`, and bracket
213+ * only that call with `startBenchmark`/`stopBenchmark` under the root frame. The
214+ * measurement comes from the instrument, so no wall-clock markers are emitted
215+ * and tinybench's timing loop is not involved.
216+ */
217+ export async function runAnalysisTask (
218+ { fn, fnOpts } : CapturedTask ,
219+ uri : string ,
220+ ) : Promise < void > {
221+ await fnOpts ?. beforeAll ?.( "run" ) ;
222+ await optimizeFunction ( async ( ) => {
223+ await fnOpts ?. beforeEach ?.( "run" ) ;
224+ await fn ( ) ;
225+ await fnOpts ?. afterEach ?.( "run" ) ;
226+ } ) ;
227+
228+ await fnOpts ?. beforeEach ?.( "run" ) ;
229+ global . gc ?.( ) ;
230+ await wrapWithRootFrame ( async ( ) => {
231+ InstrumentHooks . startBenchmark ( ) ;
232+ await fn ( ) ;
233+ InstrumentHooks . stopBenchmark ( ) ;
234+ InstrumentHooks . setExecutedBenchmark ( process . pid , uri ) ;
235+ } ) ( ) ;
236+ await fnOpts ?. afterEach ?.( "run" ) ;
237+ await fnOpts ?. afterAll ?.( "run" ) ;
238+ }
239+
117240/**
118241 * Drive the instrumentation window from each bench's run-mode setup/teardown
119242 * hooks so it brackets only tinybench's measured loop, excluding the warmup
@@ -148,24 +271,29 @@ export function installInstrumentHooks(
148271}
149272
150273function closeInstrumentWindow ( uri : string ) : void {
151- const runEnd = InstrumentHooks . currentTimestamp ( ) ;
274+ emitBenchmarkWindow ( uri , instrumentWindow . runStart ! ) ;
275+ instrumentWindow . runStart = null ;
276+ }
277+
278+ /**
279+ * Close the currently open instrumentation window: emit the benchmark markers
280+ * bracketing [start, now], stop the benchmark, and attribute the sample to `uri`.
281+ *
282+ * Benchmark markers must land inside the sample window opened by
283+ * startBenchmark(), so they are emitted before stopBenchmark() closes it. The
284+ * runner consumes the FIFO stream in order, so a marker sent after stopBenchmark
285+ * would fall outside the sample and break the expected
286+ * SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
287+ */
288+ function emitBenchmarkWindow ( uri : string , start : bigint ) : void {
289+ const end = InstrumentHooks . currentTimestamp ( ) ;
152290 const pid = process . pid ;
153291
154- // Benchmark markers must land inside the sample window opened by
155- // startBenchmark(), so they have to be emitted before stopBenchmark()
156- // closes it. The runner consumes the FIFO stream in order, so a marker
157- // sent after StopBenchmark falls outside the sample and breaks the
158- // expected SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
159- InstrumentHooks . addMarker (
160- pid ,
161- MARKER_TYPE_BENCHMARK_START ,
162- instrumentWindow . runStart ! ,
163- ) ;
164- InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_END , runEnd ) ;
292+ InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_START , start ) ;
293+ InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_END , end ) ;
165294
166295 InstrumentHooks . stopBenchmark ( ) ;
167296 InstrumentHooks . setExecutedBenchmark ( pid , uri ) ;
168- instrumentWindow . runStart = null ;
169297}
170298
171299/**
0 commit comments