Skip to content

Commit 5f5f1b1

Browse files
committed
feat(vitest,tinybench): emit benchmark markers inside the sample window
Emit benchmark start/end markers for the tinybench plugin and the vitest walltime runner, wrapping the measured function in a root frame. The runner consumes the instrument-hooks FIFO stream in order and expects SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting per benchmark, so the markers must land inside the sample window: - vitest: emit the marker pair before stopBenchmark(), and move stopBenchmark() plus the markers into a finally block so a throwing benchmark cannot leave the profiler started-but-never-stopped - tinybench: emit markers per task between start/stop instead of a single run-level pair; wrap the body in try/finally to keep start/stop balanced when a benchmark throws - benchmark.js: bind wrapWithRootFrame/wrapWithRootFrameSync to the real implementations in the integ test's core mock Add a regression test asserting both markers land between startBenchmark and stopBenchmark in walltime mode.
1 parent 079e488 commit 5f5f1b1

10 files changed

Lines changed: 146 additions & 56 deletions

File tree

packages/benchmark.js-plugin/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
SetupInstrumentsResponse,
1010
teardownCore,
1111
tryIntrospect,
12+
wrapWithRootFrame,
13+
wrapWithRootFrameSync,
1214
} from "@codspeed/core";
1315
import Benchmark from "benchmark";
1416
import buildSuiteAdd from "./buildSuiteAdd";
@@ -195,7 +197,7 @@ async function runBenchmarks({
195197
await optimizeFunction(benchPayload);
196198
await mongoMeasurement.start(uri);
197199
global.gc?.();
198-
await (async function __codspeed_root_frame__() {
200+
await wrapWithRootFrame(async () => {
199201
InstrumentHooks.startBenchmark();
200202
await benchPayload();
201203
InstrumentHooks.stopBenchmark();
@@ -205,7 +207,7 @@ async function runBenchmarks({
205207
} else {
206208
optimizeFunctionSync(benchPayload);
207209
await mongoMeasurement.start(uri);
208-
(function __codspeed_root_frame__() {
210+
wrapWithRootFrameSync(() => {
209211
InstrumentHooks.startBenchmark();
210212
benchPayload();
211213
InstrumentHooks.stopBenchmark();

packages/benchmark.js-plugin/tests/index.integ.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jest.mock("@codspeed/core", () => {
1111
const actual = jest.requireActual("@codspeed/core");
1212
mockCore.getGitDir = actual.getGitDir;
1313
mockCore.getCallingFile = actual.getCallingFile;
14+
mockCore.wrapWithRootFrame = actual.wrapWithRootFrame;
15+
mockCore.wrapWithRootFrameSync = actual.wrapWithRootFrameSync;
1416
return mockCore;
1517
});
1618

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type {
7777
} from "./generated/openapi";
7878
export { getV8Flags, tryIntrospect } from "./introspection";
7979
export { optimizeFunction, optimizeFunctionSync } from "./optimization";
80+
export { wrapWithRootFrame, wrapWithRootFrameSync } from "./rootFrame";
8081
export * from "./utils";
8182
export * from "./walltime";
8283
export type { InstrumentMode };

packages/core/src/rootFrame.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Wrap a benchmark function so it executes under a frame named
3+
* `__codspeed_root_frame__`. CodSpeed uses this frame to locate the
4+
* benchmark root in collected call stacks; samples without it cannot be
5+
* attributed to a benchmark.
6+
*/
7+
export function wrapWithRootFrame<T>(
8+
fn: () => T | Promise<T>,
9+
): () => Promise<T> {
10+
return async function __codspeed_root_frame__() {
11+
return await fn();
12+
};
13+
}
14+
15+
export function wrapWithRootFrameSync<T>(fn: () => T): () => T {
16+
return function __codspeed_root_frame__() {
17+
return fn();
18+
};
19+
}

packages/tinybench-plugin/src/analysis.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
InstrumentHooks,
44
mongoMeasurement,
55
optimizeFunction,
6+
wrapWithRootFrame,
7+
wrapWithRootFrameSync,
68
} from "@codspeed/core";
79
import { Bench, Fn, FnOptions, Task } from "tinybench";
810
import { BaseBenchRunner } from "./shared";
@@ -25,18 +27,6 @@ class AnalysisBenchRunner extends BaseBenchRunner {
2527
return InstrumentHooks.isInstrumented() ? "Measured" : "Checked";
2628
}
2729

28-
private wrapFunctionWithFrame(fn: Fn, isAsync: boolean): Fn {
29-
if (isAsync) {
30-
return async function __codspeed_root_frame__() {
31-
await fn();
32-
};
33-
} else {
34-
return function __codspeed_root_frame__() {
35-
fn();
36-
};
37-
}
38-
}
39-
4030
protected async runTaskAsync(task: Task, uri: string): Promise<void> {
4131
const { fnOpts, fn } = task as unknown as { fnOpts?: FnOptions; fn: Fn };
4232

@@ -50,10 +40,7 @@ class AnalysisBenchRunner extends BaseBenchRunner {
5040
await mongoMeasurement.start(uri);
5141

5242
global.gc?.();
53-
await this.wrapWithInstrumentHooksAsync(
54-
this.wrapFunctionWithFrame(fn, true),
55-
uri,
56-
);
43+
await this.wrapWithInstrumentHooksAsync(wrapWithRootFrame(fn), uri);
5744

5845
await mongoMeasurement.stop(uri);
5946
await fnOpts?.afterEach?.call(task, "run");
@@ -68,7 +55,7 @@ class AnalysisBenchRunner extends BaseBenchRunner {
6855
fnOpts?.beforeAll?.call(task, "run");
6956
fnOpts?.beforeEach?.call(task, "run");
7057

71-
this.wrapWithInstrumentHooks(this.wrapFunctionWithFrame(fn, false), uri);
58+
this.wrapWithInstrumentHooks(wrapWithRootFrameSync(fn), uri);
7259

7360
fnOpts?.afterEach?.call(task, "run");
7461
fnOpts?.afterAll?.call(task, "run");

packages/tinybench-plugin/src/shared.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { InstrumentHooks, setupCore, teardownCore } from "@codspeed/core";
1+
import {
2+
getInstrumentMode,
3+
InstrumentHooks,
4+
MARKER_TYPE_BENCHMARK_END,
5+
MARKER_TYPE_BENCHMARK_START,
6+
setupCore,
7+
teardownCore,
8+
} from "@codspeed/core";
29
import { Bench, Fn, Task } from "tinybench";
310
import { getTaskUri } from "./uri";
411

@@ -40,21 +47,31 @@ export abstract class BaseBenchRunner {
4047

4148
protected wrapWithInstrumentHooks<T>(fn: () => T, uri: string): T {
4249
InstrumentHooks.startBenchmark();
43-
const result = fn();
44-
InstrumentHooks.stopBenchmark();
45-
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
46-
return result;
50+
const runStart = InstrumentHooks.currentTimestamp();
51+
try {
52+
return fn();
53+
} finally {
54+
const runEnd = InstrumentHooks.currentTimestamp();
55+
this.sendBenchmarkMarkers(runStart, runEnd);
56+
InstrumentHooks.stopBenchmark();
57+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
58+
}
4759
}
4860

4961
protected async wrapWithInstrumentHooksAsync(
5062
fn: Fn,
5163
uri: string,
5264
): Promise<unknown> {
5365
InstrumentHooks.startBenchmark();
54-
const result = await fn();
55-
InstrumentHooks.stopBenchmark();
56-
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
57-
return result;
66+
const runStart = InstrumentHooks.currentTimestamp();
67+
try {
68+
return await fn();
69+
} finally {
70+
const runEnd = InstrumentHooks.currentTimestamp();
71+
this.sendBenchmarkMarkers(runStart, runEnd);
72+
InstrumentHooks.stopBenchmark();
73+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
74+
}
5875
}
5976

6077
protected abstract getModeName(): string;
@@ -63,6 +80,23 @@ export abstract class BaseBenchRunner {
6380
protected abstract finalizeAsyncRun(): Task[];
6481
protected abstract finalizeSyncRun(): Task[];
6582

83+
// Benchmark markers bracket a single benchmark and must sit inside the sample
84+
// window opened by startBenchmark(), so they are emitted per task before
85+
// stopBenchmark() closes it. The runner consumes the FIFO stream in order:
86+
// a marker sent after StopBenchmark falls outside the sample and breaks the
87+
// expected SampleStart > BenchmarkStart > BenchmarkEnd > SampleEnd nesting.
88+
private sendBenchmarkMarkers(runStart: bigint, runEnd: bigint): void {
89+
if (getInstrumentMode() !== "walltime") {
90+
return;
91+
}
92+
InstrumentHooks.addMarker(
93+
process.pid,
94+
MARKER_TYPE_BENCHMARK_START,
95+
runStart,
96+
);
97+
InstrumentHooks.addMarker(process.pid, MARKER_TYPE_BENCHMARK_END, runEnd);
98+
}
99+
66100
public setupBenchMethods(): void {
67101
this.bench.run = async () => {
68102
this.setupBenchRun();

packages/tinybench-plugin/src/walltime.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
mongoMeasurement,
44
msToNs,
55
msToS,
6+
wrapWithRootFrame,
7+
wrapWithRootFrameSync,
68
writeWalltimeResults,
79
type BenchmarkStats,
810
type Benchmark as CodspeedBenchmark,
@@ -64,21 +66,10 @@ class WalltimeBenchRunner extends BaseBenchRunner {
6466

6567
private wrapTaskFunction(task: Task, isAsync: boolean): void {
6668
const { fn } = task as unknown as { fn: Fn };
67-
if (isAsync) {
68-
// eslint-disable-next-line no-inner-declarations
69-
async function __codspeed_root_frame__() {
70-
await fn();
71-
}
72-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73-
(task as any).fn = __codspeed_root_frame__;
74-
} else {
75-
// eslint-disable-next-line no-inner-declarations
76-
function __codspeed_root_frame__() {
77-
fn();
78-
}
79-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80-
(task as any).fn = __codspeed_root_frame__;
81-
}
69+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70+
(task as any).fn = isAsync
71+
? wrapWithRootFrame(fn)
72+
: wrapWithRootFrameSync(fn);
8273
}
8374

8475
private registerCodspeedBenchmarkFromTask(task: Task): void {

packages/tinybench-plugin/tests/index.integ.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const mockCore = vi.hoisted(() => {
1616
startBenchmark: vi.fn(),
1717
stopBenchmark: vi.fn(),
1818
setExecutedBenchmark: vi.fn(),
19+
currentTimestamp: vi.fn().mockReturnValue(0n),
20+
addMarker: vi.fn(),
1921
},
2022
optimizeFunction: vi
2123
.fn()
@@ -24,6 +26,7 @@ const mockCore = vi.hoisted(() => {
2426
}),
2527
setupCore: vi.fn(),
2628
teardownCore: vi.fn(),
29+
writeWalltimeResults: vi.fn(),
2730
};
2831
});
2932

@@ -205,6 +208,39 @@ describe("Benchmark.Suite", () => {
205208
expect(afterAll).toHaveBeenCalledTimes(2);
206209
});
207210

211+
it("emits benchmark markers inside the sample window in walltime mode", async () => {
212+
process.env.CODSPEED_RUNNER_MODE = "walltime";
213+
mockCore.InstrumentHooks.isInstrumented.mockReturnValue(true);
214+
215+
let ts = 0n;
216+
mockCore.InstrumentHooks.currentTimestamp.mockImplementation(() => ts++);
217+
218+
await withCodSpeed(
219+
new Bench({ time: 0, iterations: 1, warmup: false }),
220+
)
221+
.add("RegExp", () => {
222+
/o/.test("Hello World!");
223+
})
224+
.run();
225+
226+
const { startBenchmark, stopBenchmark, addMarker } =
227+
mockCore.InstrumentHooks;
228+
229+
const startOrder = startBenchmark.mock.invocationCallOrder[0];
230+
const stopOrder = stopBenchmark.mock.invocationCallOrder[0];
231+
const markerOrders = addMarker.mock.invocationCallOrder;
232+
233+
// A BenchmarkStart/BenchmarkEnd pair must be emitted per benchmark...
234+
expect(markerOrders).toHaveLength(2);
235+
// ...and both must land between startBenchmark (SampleStart) and
236+
// stopBenchmark (SampleEnd), otherwise the runner cannot bracket the
237+
// perf samples and flame graph generation fails.
238+
for (const order of markerOrders) {
239+
expect(order).toBeGreaterThan(startOrder);
240+
expect(order).toBeLessThan(stopOrder);
241+
}
242+
});
243+
208244
it("should call setupCore and teardownCore only once", async () => {
209245
mockCore.InstrumentHooks.isInstrumented.mockReturnValue(true);
210246
const bench = withCodSpeed(new Bench())

packages/vitest-plugin/src/analysis.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
optimizeFunction,
66
setupCore,
77
teardownCore,
8+
wrapWithRootFrame,
89
} from "@codspeed/core";
910
import { Benchmark, type RunnerTestSuite } from "vitest";
1011
import { NodeBenchmarkRunner } from "vitest/runners";
@@ -47,7 +48,7 @@ async function runAnalysisBench(
4748
await callSuiteHook(suite, benchmark, "beforeEach");
4849
await mongoMeasurement.start(uri);
4950
global.gc?.();
50-
await (async function __codspeed_root_frame__() {
51+
await wrapWithRootFrame(async () => {
5152
InstrumentHooks.startBenchmark();
5253
// @ts-expect-error we do not need to bind the function to an instance of tinybench's Bench
5354
await fn();

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

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
InstrumentHooks,
3+
MARKER_TYPE_BENCHMARK_END,
4+
MARKER_TYPE_BENCHMARK_START,
35
setupCore,
6+
wrapWithRootFrame,
47
writeWalltimeResults,
58
} from "@codspeed/core";
6-
import { Fn } from "tinybench";
79
import {
810
RunnerTaskEventPack,
911
RunnerTaskResultPack,
@@ -66,6 +68,7 @@ export class WalltimeRunner extends NodeBenchmarkRunner {
6668
this.isTinybenchHookedWithCodspeed = true;
6769

6870
const originalRun = tinybench.Task.prototype.run;
71+
const pid = process.pid;
6972

7073
const getSuiteUri = (): string => {
7174
if (this.currentSuiteId === null) {
@@ -75,21 +78,35 @@ export class WalltimeRunner extends NodeBenchmarkRunner {
7578
};
7679

7780
tinybench.Task.prototype.run = async function () {
78-
const { fn } = this as { fn: Fn };
7981
const suiteUri = getSuiteUri();
8082

81-
function __codspeed_root_frame__() {
82-
return fn();
83-
}
84-
(this as { fn: Fn }).fn = __codspeed_root_frame__;
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
84+
const task = this as any;
85+
const originalFn = task.fn;
86+
task.fn = wrapWithRootFrame(() => originalFn.call(task));
8587

8688
InstrumentHooks.startBenchmark();
87-
await originalRun.call(this);
88-
InstrumentHooks.stopBenchmark();
89-
90-
// Look up the URI by task name
91-
const uri = `${suiteUri}::${this.name}`;
92-
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
89+
const runStart = InstrumentHooks.currentTimestamp();
90+
try {
91+
await originalRun.call(this);
92+
} finally {
93+
const runEnd = InstrumentHooks.currentTimestamp();
94+
task.fn = originalFn;
95+
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);
103+
104+
InstrumentHooks.stopBenchmark();
105+
106+
// Look up the URI by task name
107+
const uri = `${suiteUri}::${this.name}`;
108+
InstrumentHooks.setExecutedBenchmark(pid, uri);
109+
}
93110

94111
return this;
95112
};

0 commit comments

Comments
 (0)