Skip to content

Commit 60d2e15

Browse files
fix(vitest-plugin): bracket walltime window around the measured loop only
The runner wrapped the entire tinybench Task.run() in the instrumentation window, folding the run-mode setup/teardown hooks and processRunResult (which sorts the latency samples twice and computes the full statistics) into the recorded sample. That framework overhead showed up in the walltime flamegraph. Drive the window from each bench's run-mode setup/teardown hooks instead, so it brackets only the measured loop. Warmup already runs as a separate Vitest call and is now excluded, and the post-loop statistics computation falls outside the window. User-provided hooks are preserved. The tinybench module namespace is frozen, so the instrumented Bench is handed back through a fresh module-shaped object that Vitest destructures from rather than reassigning the export. Closes COD-2925 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5935e1a commit 60d2e15

1 file changed

Lines changed: 116 additions & 32 deletions

File tree

  • packages/vitest-plugin/src/walltime

packages/vitest-plugin/src/walltime/index.ts

Lines changed: 116 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
wrapWithRootFrame,
77
writeWalltimeResults,
88
} from "@codspeed/core";
9+
import type * as tinybench from "tinybench";
910
import {
1011
RunnerTaskEventPack,
1112
RunnerTaskResultPack,
@@ -15,6 +16,27 @@ import { NodeBenchmarkRunner } from "vitest/runners";
1516
import { patchRootSuiteWithFullFilePath } from "../common";
1617
import { 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

Comments
 (0)