Skip to content

Commit b968485

Browse files
feat(vitest-plugin): add walltime support
1 parent 375d19b commit b968485

File tree

9 files changed

+254
-16
lines changed

9 files changed

+254
-16
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(1);
14+
});
15+
16+
bench("wait 1sec", async () => {
17+
await sleep(1);
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: 13 additions & 1 deletion
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
});

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

0 commit comments

Comments
 (0)