66 wrapWithRootFrame ,
77 writeWalltimeResults ,
88} from "@codspeed/core" ;
9+ import type * as tinybench from "tinybench" ;
910import {
1011 RunnerTaskEventPack ,
1112 RunnerTaskResultPack ,
@@ -15,6 +16,27 @@ import { NodeBenchmarkRunner } from "vitest/runners";
1516import { patchRootSuiteWithFullFilePath } from "../common" ;
1617import { extractBenchmarkResults } from "./utils" ;
1718
19+ type Tinybench = typeof tinybench ;
20+
21+ /** A tinybench task, exposing the `fn` the runner wraps with the root frame. */
22+ interface TinybenchTask {
23+ name : string ;
24+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25+ fn : ( ...args : any [ ] ) => any ;
26+ }
27+
28+ /** tinybench's per-task setup/teardown hook signature. */
29+ type TinybenchHook = (
30+ task : TinybenchTask ,
31+ mode : "run" | "warmup" ,
32+ ) => Promise < void > | void ;
33+
34+ /** The mutable subset of a tinybench Bench the runner reaches into. */
35+ interface TinybenchBench {
36+ setup : TinybenchHook ;
37+ teardown : TinybenchHook ;
38+ }
39+
1840/**
1941 * WalltimeRunner uses Vitest's default benchmark execution
2042 * and extracts results from the suite after completion
@@ -24,6 +46,9 @@ export class WalltimeRunner extends NodeBenchmarkRunner {
2446 private suiteUris = new Map < string , string > ( ) ;
2547 /// Suite ID of the currently running suite, to allow constructing the URI in the context of tinybench tasks
2648 private currentSuiteId : string | null = null ;
49+ // Carries the window start timestamp from the setup hook to the teardown
50+ // hook. Tasks run strictly sequentially, so a single field is enough.
51+ private runStart : bigint | null = null ;
2752
2853 async runSuite ( suite : RunnerTestSuite ) : Promise < void > {
2954 patchRootSuiteWithFullFilePath ( suite ) ;
@@ -59,59 +84,118 @@ export class WalltimeRunner extends NodeBenchmarkRunner {
5984 }
6085 }
6186
62- async importTinybench ( ) {
87+ private getBenchmarkUri ( taskName : string ) : string {
88+ if ( this . currentSuiteId === null ) {
89+ throw new Error ( "currentSuiteId is null - something went wrong" ) ;
90+ }
91+ const suiteUri = this . suiteUris . get ( this . currentSuiteId ) || "" ;
92+ return `${ suiteUri } ::${ taskName } ` ;
93+ }
94+
95+ async importTinybench ( ) : Promise < Tinybench > {
6396 const tinybench = await super . importTinybench ( ) ;
6497
65- if ( this . isTinybenchHookedWithCodspeed ) {
66- return tinybench ;
98+ // `tinybench` is a frozen ES module namespace, so the `Bench` export cannot
99+ // be reassigned. Mutating the shared `Task.prototype` in place is allowed
100+ // and only needs to happen once; the instrumented `Bench` is handed back
101+ // through a fresh module-shaped object that Vitest destructures from.
102+ if ( ! this . isTinybenchHookedWithCodspeed ) {
103+ this . isTinybenchHookedWithCodspeed = true ;
104+ this . patchTaskWithRootFrame ( tinybench ) ;
67105 }
68- this . isTinybenchHookedWithCodspeed = true ;
69106
70- const originalRun = tinybench . Task . prototype . run ;
71- const pid = process . pid ;
72-
73- const getSuiteUri = ( ) : string => {
74- if ( this . currentSuiteId === null ) {
75- throw new Error ( "currentSuiteId is null - something went wrong" ) ;
76- }
77- return this . suiteUris . get ( this . currentSuiteId ) || "" ;
107+ return {
108+ ...tinybench ,
109+ Bench : this . createInstrumentedBench ( tinybench ) ,
78110 } ;
111+ }
79112
80- tinybench . Task . prototype . run = async function ( ) {
81- const suiteUri = getSuiteUri ( ) ;
113+ /**
114+ * Wrap each task's function with the root frame so collected stacks can be
115+ * attributed to a benchmark. The window itself is driven by the bench's
116+ * setup/teardown hooks (see createInstrumentedBench).
117+ */
118+ private patchTaskWithRootFrame ( tinybench : Tinybench ) : void {
119+ const originalRun = tinybench . Task . prototype . run ;
82120
83- // eslint-disable-next-line @typescript-eslint/no-explicit-any
84- const task = this as any ;
121+ tinybench . Task . prototype . run = async function ( ) {
122+ const task = this as unknown as TinybenchTask ;
85123 const originalFn = task . fn ;
86124 task . fn = wrapWithRootFrame ( ( ) => originalFn . call ( task ) ) ;
87125
88- InstrumentHooks . startBenchmark ( ) ;
89- const runStart = InstrumentHooks . currentTimestamp ( ) ;
90126 try {
91127 await originalRun . call ( this ) ;
92128 } finally {
93- const runEnd = InstrumentHooks . currentTimestamp ( ) ;
94129 task . fn = originalFn ;
130+ }
95131
96- // Benchmark markers must land inside the sample window opened by
97- // startBenchmark(), so they have to be emitted before stopBenchmark()
98- // closes it. The runner consumes the FIFO stream in order, so a marker
99- // sent after StopBenchmark falls outside the sample and breaks the
100- // expected SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
101- InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_START , runStart ) ;
102- InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_END , runEnd ) ;
132+ return this ;
133+ } ;
134+ }
103135
104- InstrumentHooks . stopBenchmark ( ) ;
136+ /**
137+ * Drive the instrumentation window from each bench's run-mode setup/teardown
138+ * hooks so it brackets only tinybench's measured loop, excluding the warmup
139+ * that Vitest runs beforehand and the statistics computation tinybench
140+ * performs after the loop. Wrapping the whole `Task.run()` would otherwise
141+ * fold all of that framework overhead into the recorded sample.
142+ *
143+ * User-provided hooks are preserved and keep their order relative to the work
144+ * under test.
145+ */
146+ private createInstrumentedBench (
147+ tinybench : Tinybench ,
148+ ) : typeof tinybench . Bench {
149+ // eslint-disable-next-line @typescript-eslint/no-this-alias
150+ const runner = this ;
151+ const OriginalBench = tinybench . Bench ;
152+
153+ class InstrumentedBench extends OriginalBench {
154+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
155+ constructor ( ...benchArgs : any [ ] ) {
156+ super ( ...benchArgs ) ;
157+ runner . installInstrumentHooks ( this as unknown as TinybenchBench ) ;
158+ }
159+ }
105160
106- // Look up the URI by task name
107- const uri = `${ suiteUri } ::${ this . name } ` ;
108- InstrumentHooks . setExecutedBenchmark ( pid , uri ) ;
161+ return InstrumentedBench ;
162+ }
163+
164+ private installInstrumentHooks ( bench : TinybenchBench ) : void {
165+ const userSetup = bench . setup ;
166+ const userTeardown = bench . teardown ;
167+
168+ bench . setup = async ( task , mode ) => {
169+ await userSetup ( task , mode ) ;
170+ if ( mode === "run" ) {
171+ InstrumentHooks . startBenchmark ( ) ;
172+ this . runStart = InstrumentHooks . currentTimestamp ( ) ;
109173 }
174+ } ;
110175
111- return this ;
176+ bench . teardown = async ( task , mode ) => {
177+ if ( mode === "run" ) {
178+ this . closeInstrumentWindow ( this . getBenchmarkUri ( task . name ) ) ;
179+ }
180+ await userTeardown ( task , mode ) ;
112181 } ;
182+ }
183+
184+ private closeInstrumentWindow ( uri : string ) : void {
185+ const runEnd = InstrumentHooks . currentTimestamp ( ) ;
186+ const pid = process . pid ;
113187
114- return tinybench ;
188+ // Benchmark markers must land inside the sample window opened by
189+ // startBenchmark(), so they have to be emitted before stopBenchmark()
190+ // closes it. The runner consumes the FIFO stream in order, so a marker
191+ // sent after StopBenchmark falls outside the sample and breaks the
192+ // expected SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
193+ InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_START , this . runStart ! ) ;
194+ InstrumentHooks . addMarker ( pid , MARKER_TYPE_BENCHMARK_END , runEnd ) ;
195+
196+ InstrumentHooks . stopBenchmark ( ) ;
197+ InstrumentHooks . setExecutedBenchmark ( pid , uri ) ;
198+ this . runStart = null ;
115199 }
116200
117201 // Allow tinybench to retrieve the path to the currently running suite
0 commit comments