Skip to content

Commit f8d3d22

Browse files
feat(benchmark): support comparing benchmark result (#5398)
Co-authored-by: Vladimir <[email protected]>
1 parent 21e58bd commit f8d3d22

File tree

19 files changed

+414
-167
lines changed

19 files changed

+414
-167
lines changed

docs/config/index.md

+26
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,32 @@ By providing an object instead of a string you can define individual outputs whe
312312

313313
To provide object via CLI command, use the following syntax: `--outputFile.json=./path --outputFile.junit=./other-path`.
314314

315+
#### benchmark.outputJson <Version>1.6.0</Version> {#benchmark-outputJson}
316+
317+
- **Type:** `string | undefined`
318+
- **Default:** `undefined`
319+
320+
A file path to store the benchmark result, which can be used for `--compare` option later.
321+
322+
For example:
323+
324+
```sh
325+
# save main branch's result
326+
git checkout main
327+
vitest bench --outputJson main.json
328+
329+
# change a branch and compare against main
330+
git checkout feature
331+
vitest bench --compare main.json
332+
```
333+
334+
#### benchmark.compare <Version>1.6.0</Version> {#benchmark-compare}
335+
336+
- **Type:** `string | undefined`
337+
- **Default:** `undefined`
338+
339+
A file path to a previous benchmark result to compare against current runs.
340+
315341
### alias
316342

317343
- **Type:** `Record<string, string> | Array<{ find: string | RegExp, replacement: string, customResolver?: ResolverFunction | ResolverObject }>`

docs/guide/features.md

+3
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ describe('sort', () => {
210210
})
211211
```
212212

213+
<img alt="Benchmark report" img-dark src="https://github.com/vitest-dev/vitest/assets/4232207/6f0383ea-38ba-4f14-8a05-ab243afea01d">
214+
<img alt="Benchmark report" img-light src="https://github.com/vitest-dev/vitest/assets/4232207/efbcb427-ecf1-4882-88de-210cd73415f6">
215+
213216
## Type Testing <Badge type="warning">Experimental</Badge> {#type-testing}
214217

215218
Since Vitest 0.25.0 you can [write tests](/guide/testing-types) to catch type regressions. Vitest comes with [`expect-type`](https://github.com/mmkal/expect-type) package to provide you with a similar and easy to understand API.

packages/vitest/src/defaults.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { isCI } from './utils/env'
44

55
export const defaultInclude = ['**/*.{test,spec}.?(c|m)[jt]s?(x)']
66
export const defaultExclude = ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*']
7-
export const benchmarkConfigDefaults: Required<Omit<BenchmarkUserOptions, 'outputFile'>> = {
7+
export const benchmarkConfigDefaults: Required<Omit<BenchmarkUserOptions, 'outputFile' | 'compare' | 'outputJson'>> = {
88
include: ['**/*.{bench,benchmark}.?(c|m)[jt]s?(x)'],
99
exclude: defaultExclude,
1010
includeSource: [],

packages/vitest/src/node/cli/cac.ts

+18-12
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { normalize } from 'pathe'
2-
import cac, { type CAC } from 'cac'
2+
import cac, { type CAC, type Command } from 'cac'
33
import c from 'picocolors'
44
import { version } from '../../../package.json'
55
import { toArray } from '../../utils/base'
66
import type { Vitest, VitestRunMode } from '../../types'
77
import type { CliOptions } from './cli-api'
8-
import type { CLIOption } from './cli-config'
9-
import { cliOptionsConfig } from './cli-config'
8+
import type { CLIOption, CLIOptions as CLIOptionsConfig } from './cli-config'
9+
import { benchCliOptionsConfig, cliOptionsConfig } from './cli-config'
1010

11-
function addCommand(cli: CAC, name: string, option: CLIOption<any>) {
11+
function addCommand(cli: CAC | Command, name: string, option: CLIOption<any>) {
1212
const commandName = option.alias || name
1313
let command = option.shorthand ? `-${option.shorthand}, --${commandName}` : `--${commandName}`
1414
if ('argument' in option)
@@ -56,17 +56,20 @@ interface CLIOptions {
5656
allowUnknownOptions?: boolean
5757
}
5858

59+
function addCliOptions(cli: CAC | Command, options: CLIOptionsConfig<any>) {
60+
for (const [optionName, option] of Object.entries(options)) {
61+
if (option)
62+
addCommand(cli, optionName, option)
63+
}
64+
}
65+
5966
export function createCLI(options: CLIOptions = {}) {
6067
const cli = cac('vitest')
6168

6269
cli
6370
.version(version)
6471

65-
for (const optionName in cliOptionsConfig) {
66-
const option = (cliOptionsConfig as any)[optionName] as CLIOption<any> | null
67-
if (option)
68-
addCommand(cli, optionName, option)
69-
}
72+
addCliOptions(cli, cliOptionsConfig)
7073

7174
cli.help((info) => {
7275
const helpSection = info.find(current => current.title?.startsWith('For more info, run any command'))
@@ -158,9 +161,12 @@ export function createCLI(options: CLIOptions = {}) {
158161
.command('dev [...filters]', undefined, options)
159162
.action(watch)
160163

161-
cli
162-
.command('bench [...filters]', undefined, options)
163-
.action(benchmark)
164+
addCliOptions(
165+
cli
166+
.command('bench [...filters]', undefined, options)
167+
.action(benchmark),
168+
benchCliOptionsConfig,
169+
)
164170

165171
// TODO: remove in Vitest 2.0
166172
cli

packages/vitest/src/node/cli/cli-config.ts

+13
Original file line numberDiff line numberDiff line change
@@ -640,4 +640,17 @@ export const cliOptionsConfig: VitestCLIOptions = {
640640
name: null,
641641
includeTaskLocation: null,
642642
snapshotEnvironment: null,
643+
compare: null,
644+
outputJson: null,
645+
}
646+
647+
export const benchCliOptionsConfig: Pick<VitestCLIOptions, 'compare' | 'outputJson'> = {
648+
compare: {
649+
description: 'benchmark output file to compare against',
650+
argument: '<filename>',
651+
},
652+
outputJson: {
653+
description: 'benchmark output file',
654+
argument: '<filename>',
655+
},
643656
}

packages/vitest/src/node/config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ export function resolveConfig(
381381

382382
if (options.outputFile)
383383
resolved.benchmark.outputFile = options.outputFile
384+
385+
// --compare from cli
386+
if (options.compare)
387+
resolved.benchmark.compare = options.compare
388+
if (options.outputJson)
389+
resolved.benchmark.outputJson = options.outputJson
384390
}
385391

386392
resolved.setupFiles = toArray(resolved.setupFiles || []).map(file =>
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { VerboseReporter } from '../verbose'
2-
import { JsonReporter } from './json'
32
import { TableReporter } from './table'
43

54
export const BenchmarkReportsMap = {
65
default: TableReporter,
76
verbose: VerboseReporter,
8-
json: JsonReporter,
97
}
108
export type BenchmarkBuiltinReporters = keyof typeof BenchmarkReportsMap

packages/vitest/src/node/reporters/benchmark/json.ts

-82
This file was deleted.

packages/vitest/src/node/reporters/benchmark/table/index.ts

+100-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import fs from 'node:fs'
12
import c from 'picocolors'
3+
import * as pathe from 'pathe'
24
import type { TaskResultPack } from '@vitest/runner'
35
import type { UserConsoleLog } from '../../../../types/general'
46
import { BaseReporter } from '../../base'
5-
import { getFullName } from '../../../../utils'
7+
import type { BenchmarkResult, File } from '../../../../types'
8+
import { getFullName, getTasks } from '../../../../utils'
69
import { getStateSymbol } from '../../renderers/utils'
710
import { type TableRendererOptions, createTableRenderer, renderTree } from './tableRender'
811

@@ -20,11 +23,24 @@ export class TableReporter extends BaseReporter {
2023
super.onWatcherStart()
2124
}
2225

23-
onCollected() {
26+
async onCollected() {
27+
this.rendererOptions.logger = this.ctx.logger
28+
this.rendererOptions.showHeap = this.ctx.config.logHeapUsage
29+
this.rendererOptions.slowTestThreshold = this.ctx.config.slowTestThreshold
30+
if (this.ctx.config.benchmark?.compare) {
31+
const compareFile = pathe.resolve(this.ctx.config.root, this.ctx.config.benchmark?.compare)
32+
try {
33+
this.rendererOptions.compare = flattenFormattedBenchamrkReport(
34+
JSON.parse(
35+
await fs.promises.readFile(compareFile, 'utf-8'),
36+
),
37+
)
38+
}
39+
catch (e) {
40+
this.ctx.logger.error(`Failed to read '${compareFile}'`, e)
41+
}
42+
}
2443
if (this.isTTY) {
25-
this.rendererOptions.logger = this.ctx.logger
26-
this.rendererOptions.showHeap = this.ctx.config.logHeapUsage
27-
this.rendererOptions.slowTestThreshold = this.ctx.config.slowTestThreshold
2844
const files = this.ctx.state.getFiles(this.watchFilters)
2945
if (!this.renderer)
3046
this.renderer = createTableRenderer(files, this.rendererOptions).start()
@@ -56,6 +72,18 @@ export class TableReporter extends BaseReporter {
5672
await this.stopListRender()
5773
this.ctx.logger.log()
5874
await super.onFinished(files, errors)
75+
76+
// write output for future comparison
77+
let outputFile = this.ctx.config.benchmark?.outputJson
78+
if (outputFile) {
79+
outputFile = pathe.resolve(this.ctx.config.root, outputFile)
80+
const outputDirectory = pathe.dirname(outputFile)
81+
if (!fs.existsSync(outputDirectory))
82+
await fs.promises.mkdir(outputDirectory, { recursive: true })
83+
const output = createFormattedBenchamrkReport(files)
84+
await fs.promises.writeFile(outputFile, JSON.stringify(output, null, 2))
85+
this.ctx.logger.log(`Benchmark report written to ${outputFile}`)
86+
}
5987
}
6088

6189
async onWatcherStart() {
@@ -80,3 +108,70 @@ export class TableReporter extends BaseReporter {
80108
super.onUserConsoleLog(log)
81109
}
82110
}
111+
112+
export interface FormattedBenchamrkReport {
113+
files: {
114+
filepath: string
115+
groups: FormattedBenchmarkGroup[]
116+
}[]
117+
}
118+
119+
// flat results with TaskId as a key
120+
export interface FlatBenchmarkReport {
121+
[id: string]: FormattedBenchmarkResult
122+
}
123+
124+
interface FormattedBenchmarkGroup {
125+
fullName: string
126+
benchmarks: FormattedBenchmarkResult[]
127+
}
128+
129+
export type FormattedBenchmarkResult = Omit<BenchmarkResult, 'samples'> & {
130+
id: string
131+
sampleCount: number
132+
}
133+
134+
function createFormattedBenchamrkReport(files: File[]) {
135+
const report: FormattedBenchamrkReport = { files: [] }
136+
for (const file of files) {
137+
const groups: FormattedBenchmarkGroup[] = []
138+
for (const task of getTasks(file)) {
139+
if (task && task.type === 'suite') {
140+
const benchmarks: FormattedBenchmarkResult[] = []
141+
for (const t of task.tasks) {
142+
const benchmark = t.meta.benchmark && t.result?.benchmark
143+
if (benchmark) {
144+
const { samples, ...rest } = benchmark
145+
benchmarks.push({
146+
id: t.id,
147+
sampleCount: samples.length,
148+
...rest,
149+
})
150+
}
151+
}
152+
if (benchmarks.length) {
153+
groups.push({
154+
fullName: getFullName(task, ' > '),
155+
benchmarks,
156+
})
157+
}
158+
}
159+
}
160+
report.files.push({
161+
filepath: file.filepath,
162+
groups,
163+
})
164+
}
165+
return report
166+
}
167+
168+
function flattenFormattedBenchamrkReport(report: FormattedBenchamrkReport): FlatBenchmarkReport {
169+
const flat: FlatBenchmarkReport = {}
170+
for (const file of report.files) {
171+
for (const group of file.groups) {
172+
for (const t of group.benchmarks)
173+
flat[t.id] = t
174+
}
175+
}
176+
return flat
177+
}

0 commit comments

Comments
 (0)