Skip to content

Commit 5cda681

Browse files
feat(vitest-plugin): add walltime support
1 parent 8410705 commit 5cda681

File tree

9 files changed

+251
-17
lines changed

9 files changed

+251
-17
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { bench, describe } from "vitest";
2+
3+
const sleep = (ms: number): Promise<void> => {
4+
return new Promise((resolve) => setTimeout(resolve, ms));
5+
};
6+
7+
describe("timing tests", () => {
8+
bench("wait 1ms", async () => {
9+
await sleep(1);
10+
});
11+
12+
bench("wait 500ms", async () => {
13+
await sleep(500);
14+
});
15+
16+
bench("wait 1sec", async () => {
17+
await sleep(1_000);
18+
});
19+
});

packages/vitest-plugin/moon.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ tasks:
66
local: true
77
options:
88
cache: false
9+
deps:
10+
- build
911

1012
test:
1113
command: vitest --run

packages/vitest-plugin/rollup.config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ export default defineConfig([
2121
external: ["@codspeed/core", /^vitest/],
2222
},
2323
{
24-
input: "src/runner.ts",
25-
output: { file: "dist/runner.mjs", format: "es" },
24+
input: "src/instrumented.ts",
25+
output: { file: "dist/instrumented.mjs", format: "es" },
26+
plugins: jsPlugins(pkg.version),
27+
external: ["@codspeed/core", /^vitest/],
28+
},
29+
{
30+
input: "src/walltime.ts",
31+
output: { file: "dist/walltime.mjs", format: "es" },
2632
plugins: jsPlugins(pkg.version),
2733
external: ["@codspeed/core", /^vitest/],
2834
},

packages/vitest-plugin/src/__tests__/index.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fromPartial } from "@total-typescript/shoehorn";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
33
import codspeedPlugin from "../index";
44

55
const coreMocks = vi.hoisted(() => {
@@ -25,6 +25,18 @@ vi.mock("@codspeed/core", async (importOriginal) => {
2525
console.warn = vi.fn();
2626

2727
describe("codSpeedPlugin", () => {
28+
beforeAll(() => {
29+
// Set environment variables to trigger instrumented mode
30+
process.env.CODSPEED_ENV = "1";
31+
process.env.CODSPEED_RUNNER_MODE = "instrumentation";
32+
});
33+
34+
afterAll(() => {
35+
// Clean up environment variables
36+
delete process.env.CODSPEED_ENV;
37+
delete process.env.CODSPEED_RUNNER_MODE;
38+
});
39+
2840
it("should have a name", async () => {
2941
expect(resolvedCodSpeedPlugin.name).toBe("codspeed:vitest");
3042
});
@@ -96,7 +108,9 @@ describe("codSpeedPlugin", () => {
96108
],
97109
},
98110
},
99-
runner: expect.stringContaining("packages/vitest-plugin/src/runner.ts"),
111+
runner: expect.stringContaining(
112+
"packages/vitest-plugin/src/instrumented.ts"
113+
),
100114
},
101115
});
102116
});

packages/vitest-plugin/src/__tests__/runner.test.ts renamed to packages/vitest-plugin/src/__tests__/instrumented.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fromPartial } from "@total-typescript/shoehorn";
22
import { describe, expect, it, Suite, vi } from "vitest";
33
import { getBenchFn } from "vitest/suite";
4-
import CodSpeedRunner from "../runner";
4+
import { InstrumentedRunner as CodSpeedRunner } from "../instrumented";
55

66
const coreMocks = vi.hoisted(() => {
77
return {
@@ -53,24 +53,24 @@ describe("CodSpeedRunner", () => {
5353
// setup
5454
expect(coreMocks.setupCore).toHaveBeenCalledTimes(1);
5555
expect(console.log).toHaveBeenCalledWith(
56-
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/runner.test.ts"
56+
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/instrumented.test.ts"
5757
);
5858

5959
// run
6060
expect(coreMocks.mongoMeasurement.start).toHaveBeenCalledWith(
61-
"packages/vitest-plugin/src/__tests__/runner.test.ts::test bench"
61+
"packages/vitest-plugin/src/__tests__/instrumented.test.ts::test bench"
6262
);
6363
expect(coreMocks.Measurement.startInstrumentation).toHaveBeenCalledTimes(1);
6464
expect(benchFn).toHaveBeenCalledTimes(8);
6565
expect(coreMocks.Measurement.stopInstrumentation).toHaveBeenCalledTimes(1);
6666
expect(coreMocks.mongoMeasurement.stop).toHaveBeenCalledTimes(1);
6767
expect(console.log).toHaveBeenCalledWith(
68-
"[CodSpeed] packages/vitest-plugin/src/__tests__/runner.test.ts::test bench done"
68+
"[CodSpeed] packages/vitest-plugin/src/__tests__/instrumented.test.ts::test bench done"
6969
);
7070

7171
// teardown
7272
expect(console.log).toHaveBeenCalledWith(
73-
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/runner.test.ts done"
73+
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/instrumented.test.ts done"
7474
);
7575
expect(coreMocks.teardownCore).toHaveBeenCalledTimes(1);
7676
});
@@ -107,24 +107,24 @@ describe("CodSpeedRunner", () => {
107107
// setup
108108
expect(coreMocks.setupCore).toHaveBeenCalledTimes(1);
109109
expect(console.log).toHaveBeenCalledWith(
110-
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/runner.test.ts"
110+
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/instrumented.test.ts"
111111
);
112112

113113
// run
114114
expect(coreMocks.mongoMeasurement.start).toHaveBeenCalledWith(
115-
"packages/vitest-plugin/src/__tests__/runner.test.ts::nested suite::test bench"
115+
"packages/vitest-plugin/src/__tests__/instrumented.test.ts::nested suite::test bench"
116116
);
117117
expect(coreMocks.Measurement.startInstrumentation).toHaveBeenCalledTimes(1);
118118
expect(benchFn).toHaveBeenCalledTimes(8);
119119
expect(coreMocks.Measurement.stopInstrumentation).toHaveBeenCalledTimes(1);
120120
expect(coreMocks.mongoMeasurement.stop).toHaveBeenCalledTimes(1);
121121
expect(console.log).toHaveBeenCalledWith(
122-
"[CodSpeed] packages/vitest-plugin/src/__tests__/runner.test.ts::nested suite::test bench done"
122+
"[CodSpeed] packages/vitest-plugin/src/__tests__/instrumented.test.ts::nested suite::test bench done"
123123
);
124124

125125
// teardown
126126
expect(console.log).toHaveBeenCalledWith(
127-
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/runner.test.ts done"
127+
"[CodSpeed] running suite packages/vitest-plugin/src/__tests__/instrumented.test.ts done"
128128
);
129129
expect(coreMocks.teardownCore).toHaveBeenCalledTimes(1);
130130
});

packages/vitest-plugin/src/common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ export function patchRootSuiteWithFullFilePath(suite: Suite) {
3636
throw new Error("Could not find a git repository");
3737
}
3838
suite.name = path.relative(gitDir, suite.filepath);
39-
}
39+
}

packages/vitest-plugin/src/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
getCodspeedRunnerMode,
23
getV8Flags,
34
Measurement,
45
mongoMeasurement,
@@ -19,32 +20,51 @@ function getCodSpeedFileFromName(name: string) {
1920
return join(__dirname, `${name}.${fileExtension}`);
2021
}
2122

23+
function getRunnerFile(): string | undefined {
24+
const codspeedRunnerMode = getCodspeedRunnerMode();
25+
if (codspeedRunnerMode === "disabled") {
26+
return undefined;
27+
}
28+
29+
return getCodSpeedFileFromName(codspeedRunnerMode);
30+
}
31+
2232
export default function codspeedPlugin(): Plugin {
2333
return {
2434
name: "codspeed:vitest",
2535
apply(_, { mode }) {
2636
if (mode !== "benchmark") {
2737
return false;
2838
}
29-
if (!Measurement.isInstrumented()) {
39+
if (
40+
getCodspeedRunnerMode() == "instrumented" &&
41+
!Measurement.isInstrumented()
42+
) {
3043
console.warn("[CodSpeed] bench detected but no instrumentation found");
3144
}
3245
return true;
3346
},
3447
enforce: "post",
3548
config(): UserConfig {
36-
return {
49+
const runnerFile = getRunnerFile();
50+
const config: UserConfig = {
3751
test: {
3852
pool: "forks",
3953
poolOptions: {
4054
forks: {
4155
execArgv: getV8Flags(),
4256
},
4357
},
44-
runner: getCodSpeedFileFromName("runner"),
4558
globalSetup: [getCodSpeedFileFromName("globalSetup")],
4659
},
4760
};
61+
62+
// Only set custom runner when CODSPEED_ENV is set
63+
if (runnerFile) {
64+
config.test!.runner = runnerFile;
65+
}
66+
67+
return config;
4868
},
4969
};
5070
}

packages/vitest-plugin/src/instrumented.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,4 @@ export class InstrumentedRunner extends NodeBenchmarkRunner {
106106
}
107107
}
108108

109+
export default InstrumentedRunner;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
calculateQuantiles,
3+
writeWalltimeResults,
4+
type Benchmark,
5+
type BenchmarkStats,
6+
} from "@codspeed/core";
7+
import { BenchTaskResult, Suite, Task } from "vitest";
8+
import { NodeBenchmarkRunner } from "vitest/runners";
9+
import { patchRootSuiteWithFullFilePath } from "./common";
10+
11+
declare const __VERSION__: string;
12+
13+
// Vitest's task result structure extends Tinybench with additional properties
14+
interface VitestTaskResult {
15+
state: "pass" | "fail" | "skip" | "todo";
16+
startTime: number;
17+
duration?: number;
18+
benchmark?: BenchTaskResult;
19+
}
20+
21+
/**
22+
* WalltimeRunner uses Vitest's default benchmark execution
23+
* and extracts results from the suite after completion
24+
*/
25+
export class WalltimeRunner extends NodeBenchmarkRunner {
26+
async runSuite(suite: Suite): Promise<void> {
27+
patchRootSuiteWithFullFilePath(suite);
28+
29+
console.log(
30+
`[CodSpeed] running with @codspeed/vitest-plugin v${__VERSION__} (walltime mode)`
31+
);
32+
33+
// Let Vitest's default benchmark runner handle execution
34+
await super.runSuite(suite);
35+
36+
// Extract benchmark results from the completed suite
37+
const benchmarks = await this.extractBenchmarkResults(suite);
38+
39+
if (benchmarks.length > 0) {
40+
writeWalltimeResults(benchmarks);
41+
console.log(
42+
`[CodSpeed] Done collecting walltime data for ${benchmarks.length} benches.`
43+
);
44+
} else {
45+
console.warn(
46+
`[CodSpeed] No benchmark results found after suite execution`
47+
);
48+
}
49+
}
50+
51+
private async extractBenchmarkResults(
52+
suite: Suite,
53+
parentPath = ""
54+
): Promise<Benchmark[]> {
55+
const benchmarks: Benchmark[] = [];
56+
const currentPath = parentPath
57+
? `${parentPath}::${suite.name}`
58+
: suite.name;
59+
60+
for (const task of suite.tasks) {
61+
if (task.meta?.benchmark && task.result?.state === "pass") {
62+
const benchmark = await this.processBenchmarkTask(task, currentPath);
63+
if (benchmark) {
64+
benchmarks.push(benchmark);
65+
}
66+
} else if (task.type === "suite") {
67+
const nestedBenchmarks = await this.extractBenchmarkResults(
68+
task,
69+
currentPath
70+
);
71+
benchmarks.push(...nestedBenchmarks);
72+
}
73+
}
74+
75+
return benchmarks;
76+
}
77+
78+
private async processBenchmarkTask(
79+
task: Task,
80+
suitePath: string
81+
): Promise<Benchmark | null> {
82+
const uri = `${suitePath}::${task.name}`;
83+
84+
const result = task.result;
85+
if (!result) {
86+
console.warn(` ⚠ No result data available for ${uri}`);
87+
return null;
88+
}
89+
90+
try {
91+
const stats = this.convertVitestResultToBenchmarkStats(
92+
result as VitestTaskResult
93+
);
94+
95+
if (stats === null) {
96+
console.log(` ✔ No walltime data to collect for ${uri}`);
97+
return null;
98+
}
99+
100+
const coreBenchmark: Benchmark = {
101+
name: task.name,
102+
uri,
103+
config: {
104+
warmup_time_ns: null, // Vitest doesn't expose this in task.result
105+
min_round_time_ns: null, // Vitest doesn't expose this in task.result
106+
},
107+
stats,
108+
};
109+
110+
console.log(` ✔ Collected walltime data for ${uri}`);
111+
return coreBenchmark;
112+
} catch (error) {
113+
console.warn(
114+
` ⚠ Failed to process benchmark result for ${uri}:`,
115+
error
116+
);
117+
return null;
118+
}
119+
}
120+
121+
private convertVitestResultToBenchmarkStats(
122+
result: VitestTaskResult
123+
): BenchmarkStats | null {
124+
const benchmark = result.benchmark;
125+
126+
if (!benchmark) {
127+
throw new Error("No benchmark data available in result");
128+
}
129+
130+
// All tinybench times are in milliseconds, convert to nanoseconds
131+
const ms_to_ns = (ms: number) => ms * 1_000_000;
132+
133+
const { totalTime, min, max, mean, sd, samples } = benchmark;
134+
135+
// Get individual sample times in nanoseconds and sort them
136+
const sortedTimesNs = samples.map(ms_to_ns).sort((a, b) => a - b);
137+
const meanNs = ms_to_ns(mean);
138+
const stdevNs = ms_to_ns(sd);
139+
140+
if (sortedTimesNs.length == 0) {
141+
// Sometimes the benchmarks can be completely optimized out and not even run, but its beforeEach and afterEach hooks are still executed, and the task is still considered a success.
142+
// This is the case for the hooks.bench.ts example in this package
143+
return null;
144+
}
145+
146+
const {
147+
q1_ns,
148+
q3_ns,
149+
median_ns,
150+
iqr_outlier_rounds,
151+
stdev_outlier_rounds,
152+
} = calculateQuantiles({ meanNs, stdevNs, sortedTimesNs });
153+
154+
return {
155+
min_ns: ms_to_ns(min),
156+
max_ns: ms_to_ns(max),
157+
mean_ns: meanNs,
158+
stdev_ns: stdevNs,
159+
q1_ns,
160+
median_ns,
161+
q3_ns,
162+
total_time: totalTime / 1_000, // convert from ms to seconds
163+
iter_per_round: sortedTimesNs.length,
164+
rounds: 1, // Tinybench only runs one round
165+
iqr_outlier_rounds,
166+
stdev_outlier_rounds,
167+
warmup_iters: 0, // TODO: get warmup iters here
168+
};
169+
}
170+
}
171+
172+
export default WalltimeRunner;

0 commit comments

Comments
 (0)