Skip to content

基准测试

Vitest 允许你使用来自 测试上下文bench fixture,在测试旁边编写基准测试。基准测试由 Tinybench 提供支持,并定义在常规的 test() 调用内部,这使你能够使用 Vitest 测试运行器的全部能力:重试、生命周期钩子、过滤以及断言。

定义基准测试

使用 bench fixture 来定义一个基准测试。调用 .run() 来执行它:

ts
import { expect, test } from 'vitest'

test('parsing performance', async ({ bench }) => {
  const result = await bench('parse', () => {
    JSON.parse('{"key":"value"}')
  }).run()
})

bench() 函数会注册一个基准测试,但不会执行它。调用 .run() 会运行该基准测试并返回结果。测试完成后,Vitest 会打印一个 比较表 的单行版本(ops/sec、平均时间、百分位数等),因此你得到的单次基准测试输出与 bench.compare() 的输出是一致的。

WARNING

bench fixture 仅在由 benchmark.include 匹配的文件中可用(默认:**/*.{bench,benchmark}.?(c|m)[jt]s?(x))。在常规测试文件中使用 { bench } 会抛出错误。

某个文件是否参与基准测试运行是由文件名决定的,而不是由测试是否使用 bench fixture 决定的。将 parser.test.ts 重命名为 parser.bench.ts(或调整 benchmark.include)才会把它移入基准测试项目。

运行基准测试

基准测试文件由 benchmark.include 匹配(默认:**/*.{bench,benchmark}.?(c|m)[jt]s?(x)),并在它们自己的项目中运行,与常规测试分开。根据你是想跳过它们、与测试一起运行,还是单独运行,有三种运行方式。

vitest(默认)

如果没有设置 benchmark.enabledvitest 命令只会运行常规测试。基准测试文件会被完全忽略。这是默认行为,也是日常开发的正确选择,因为基准测试运行缓慢且噪声较大,不应在每次保存时都运行。

启用 benchmark.enabledvitest

在配置中设置 benchmark.enabled: true,即可让基准测试与常规测试一起运行:

vitest.config.ts
ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    benchmark: {
      enabled: true,
    },
  },
})

使用此配置时,vitest 会先运行常规测试,然后在一个单独的隔离组中运行基准测试(因此基准测试的执行绝不会与测试执行重叠,也不会给结果增加噪声)。当你希望在 CI 中使用一个命令同时验证正确性和性能时,这很有用。

vitest bench

bench 子命令只运行基准测试,跳过常规测试:

bash
vitest bench

这会在本次运行中隐式启用 benchmark.enabled,因此你无需在配置中设置它。与 vitest 命令一样,它也支持文件名过滤器以及 -t/--testNamePattern 来缩小运行范围:

bash
# only benchmarks in files matching "parser"
vitest bench parser

# only benchmarks whose test name matches "JSON"
vitest bench -t JSON

比较基准测试

使用 bench.compare() 来相互比较多个基准测试:

ts
import { expect, test } from 'vitest'

test('compare JSON libraries', async ({ bench }) => {
  const input = '{"key":"value","nested":{"a":1}}'

  const result = await bench.compare(
    bench('JSON.parse', () => {
      JSON.parse(input)
    }),
    bench('custom parser', () => {
      customParse(input)
    }),
  )
})

在比较基准测试时,Vitest 会使用交错迭代来运行它们,以减少环境偏差(CPU 降频、GC 压力等),并在测试完成后打印一个比较表:

  bench  test/basic.bench.ts (1 test) 3446ms
 different libraries 3445ms
     name              hz     min     max    mean     p75     p99    p995    p999     rme  samples
     lib 1  40,678,348.31  0.0000  0.0002  0.0000  0.0000  0.0000  0.0000  0.0000  ±0.95%    53989   fastest
     lib 3  39,152,132.23  0.0000  0.0002  0.0000  0.0000  0.0000  0.0000  0.0000  ±0.93%    52080
     lib 2  38,088,138.97  0.0000  0.0066  0.0000  0.0000  0.0000  0.0000  0.0000  ±1.58%    50618   slowest

 Test Files  1 passed (1)
      Tests  1 passed (1)

选项

你可以将 选项 作为最后一个参数传给 bench.compare()

ts
test('compare with options', async ({ bench }) => {
  const result = await bench.compare(
    bench('lib1', () => { lib1() }),
    bench('lib2', () => { lib2() }),
    {
      iterations: 100,
      time: 1000,
    },
  )
})

你也可以像 test() 接受选项那样,将每个基准测试的 选项 作为第二个参数传入:

ts
test('benchmarks with setup', async ({ bench }) => {
  const result = await bench.compare(
    bench('with-cache', () => {
      readFromCache()
    }),
    bench(
      'without-cache',
      { beforeEach: () => clearCache() },
      () => { readFromDisk() },
    ),
  )
})

跨项目比较

当你的工作区定义了多个项目(例如,不同的浏览器或运行时)时,在基准测试选项中传入 perProject: true,即可比较相同基准测试在所有项目中的表现。Vitest 仍会在当前项目中以内联方式打印结果,并且会在测试运行结束时额外收集每个项目的结果,汇总到一个比较表中。

ts
import { test } from 'vitest'

test('simple example', async ({ bench }) => {
  await bench('1 + 1', { perProject: true }, () => {
    1 + 1
  }).run()
})

同一个测试文件会在每个项目中运行(chromium、firefox、webkit 等),Vitest 会对结果进行分组:

 Cross-Project Benchmark Comparison

  test/parser.bench.ts > regular parsing > parsing
   name                        hz     min     max    mean     p75     p99    p995    p999     rme  samples
   webkit (bench)    6,730,808.15  0.0000  1.0000  0.0001  0.0000  0.0000  0.0000  0.0000  ±6.20%  6731808   fastest
   chromium (bench)  3,841,599.95  0.0000  0.2000  0.0003  0.0000  0.0000  0.0000  0.1000  ±1.96%  3851571
   firefox (bench)   3,546,066.28  0.0000  5.0000  0.0003  0.0000  0.0000  0.0000  0.0000  ±6.26%  3547062   slowest

你也可以在 bench.compare() 中将 perProject 基准测试与常规基准测试混合使用:

ts
test('compare implementations across browsers', async ({ bench }) => {
  await bench.compare(
    bench('JSON.parse', { perProject: true }, () => {
      JSON.parse('{"key":"value"}')
    }),
    bench('custom parser', () => {
      customParse('{"key":"value"}')
    }),
  )
})

在这种情况下,custom parser 会出现在每个项目的普通内联比较表中,而 JSON.parse 还会额外收集到最后的跨项目比较表中。

断言性能

使用 toBeFasterThan()toBeSlowerThan() matcher 来断言基准测试之间的相对性能:

ts
import { expect, test } from 'vitest'

test('lib1 is faster than lib2', async ({ bench }) => {
  const result = await bench.compare(
    bench('lib1', () => { lib1() }),
    bench('lib2', () => { lib2() }),
  )

  expect(result.get('lib1')).toBeFasterThan(result.get('lib2'))
})

delta 选项指定了断言通过所需的最小相对差异。这有助于避免由基准测试噪声引起的不稳定测试:

ts
// lib1 must be at least 10% faster than lib2
expect(result.get('lib1')).toBeFasterThan(result.get('lib2'), {
  delta: 0.1,
})

// lib2 must be at least 20% slower than lib1
expect(result.get('lib2')).toBeSlowerThan(result.get('lib1'), {
  delta: 0.2,
})

你也可以使用标准 matcher 来断言绝对性能:

ts
test('parsing is fast enough', async ({ bench }) => {
  const result = await bench('parse', () => {
    parse(largeInput)
  }).run()

  expect(result.throughput.mean).toBeGreaterThan(10_000)
})

重试

由于基准测试可能存在噪声,可以使用 retry 选项自动重试失败的基准测试:

ts
test('performance comparison', { retry: 3 }, async ({ bench }) => {
  const result = await bench.compare(
    bench('lib1', () => { lib1() }),
    bench('lib2', () => { lib2() }),
  )

  expect(result.get('lib1')).toBeFasterThan(result.get('lib2'))
})

存储和回放结果

有两个原语可以让你将基准测试结果持久化到磁盘,并在未来的运行中与之比较:writeResult 选项用于保存结果,而 bench.from() 用于读取结果。

writeResult

writeResult 作为每个基准测试的选项传入,即可在每次基准测试运行时把结果写入一个 JSON 文件。路径会相对于项目根目录解析:

ts
test('parse', async ({ bench }) => {
  await bench(
    'parse',
    { writeResult: './benchmarks/parse.json' },
    () => parse(largeInput),
  ).run()
})
  • 基准测试总会运行。没有“缓存时跳过”的行为,也没有 CLI 标志;文件会在每次成功运行后被覆盖。
  • 如果函数抛出错误,则不会写入文件。
  • 将这些文件与你的代码一并提交,这样评审者和 CI 就能共享相同的参考点。

WARNING

如果你提交这些文件,请记住基准测试结果会因环境而显著变化(开发者机器、CI 运行器、不同操作系统)。请指定单一环境(通常是 CI)来生成这些文件,并避免在本地重新生成它们。

bench.from()

bench.from(name, source) 是一种注册方式,不会执行函数。它会读取先前保存的结果,并将其传入 bench.compare()(或者在你调用 .run() 时直接返回它)。

source 可以是路径(相对于项目根目录),也可以是返回结果数据的函数,包括 Promise:

ts
test('compare against the stored baseline', async ({ bench }) => {
  const result = await bench.compare(
    bench(
      'current',
      { writeResult: './benchmarks/parse.json' },
      () => parse(largeInput),
    ),
    bench.from('previous', './benchmarks/parse.json'),
    bench.from('remote', () => fetch('https://path/to/external/file.json').then(r => r.json())),
  )

  expect(result.get('current')).toBeFasterThan(result.get('previous'))
})

你可以为旧版本保留历史产物,并将它们与当前实现进行比较。由于 bench.from() 从不调用生成该文件的函数,一旦产物被提交,原始基准测试代码就可以删除:

ts
test('compare parser versions', async ({ bench }) => {
  const input = '{"key":"value"}'

  await bench.compare(
    bench.from('v1', './benchmarks/parse.v1.json'),
    bench.from('v2', './benchmarks/parse.v2.json'),
    bench(
      'current',
      { writeResult: './benchmarks/parse.current.json' },
      () => customParser(input),
    ),
  )
})

要生成一个新的历史产物,可以将一个新的 bench() 指向该版本的实现,把 writeResult 设置为一个带版本号的路径(./benchmarks/parse.v3.json),运行一次,然后将该调用替换为 bench.from('v3', './benchmarks/parse.v3.json')

如果想按需重新生成基准基线,可以通过环境变量控制写入,使同一个测试要么刷新产物,要么与其比较:

ts
test('compare parser versions', async ({ bench }) => {
  if (import.meta.env.VITE_WRITE_BENCH) {
    const baseline = bench('baseline', { writeResult: './my-bench.json' }, () => fn())
    await baseline.run()
  }
  else {
    const baseline = bench.from('baseline', './my-bench.json')
    await bench.compare(bench('current', () => fn()), baseline)
  }
})

运行 VITE_WRITE_BENCH=1 vitest bench 来刷新已保存的结果,运行 vitest bench 则将当前实现与之比较。

每项目产物

在多项目工作区中(不同浏览器、不同运行时),可以在路径中包含 ${projectName},以便在各项目之间共享同一个基准测试文件。该占位符会在写入时被替换为当前项目名称:

ts
test('cross-project baseline', async ({ bench }) => {
  await bench(
    'parse',
    // eslint-disable-next-line no-template-curly-in-string
    { perProject: true, writeResult: './benchmarks/parse.${projectName}.json' },
    () => parse(largeInput),
  ).run()
})

bench.from() 中使用相同的模板,这样每个项目都会读取自己的产物。

稳定性

基准测试本质上是不稳定的:CPU 负载、热降频、GC 压力以及后台进程都会影响结果。Vitest 采取了几项措施来尽量减少这种噪声:

  • 独立项目:基准测试文件会根据 benchmark.include 模式分组到它们自己的项目中。bench fixture 仅在匹配该模式的文件中可用。在普通测试文件中使用它会抛出错误。
  • 无并发:基准测试文件内的测试总是按顺序运行。基准测试文件本身也一次只运行一个,绝不会并行运行。这可以防止基准测试彼此干扰。

为了进一步提高稳定性:

  • 使用 retry 选项自动重跑不稳定的基准断言。
  • toBeFasterThan / toBeSlowerThan 中使用 delta 选项,允许可接受的波动。
  • 避免在运行基准测试时同时进行 CPU 密集型进程。
  • 关闭浏览器、IDE 以及其他会竞争 CPU 时间的应用程序。

死代码消除

JavaScript 引擎可以优化掉没有可观察副作用的代码。如果你的基准测试函数没有使用其结果,引擎可能会完全跳过该计算,从而产生误导性的极快数值:

ts
test('parsing', async ({ bench }) => {
  // 糟糕:引擎可能会消除这段工作
  await bench('parse', () => {
    JSON.parse(input)
  }).run()

  // 良好:结果被消费了
  await bench('parse', () => {
    const result = JSON.parse(input)
    doSomething(result)
  }).run()
})

这适用于所有引擎(V8、JavaScriptCore、SpiderMonkey),但在 V8 的 TurboFan 和 JavaScriptCore 的 FTL 编译层级中尤其激进。

模块运行器开销

默认情况下,Vitest 会使用 Vite 的模块运行器在 Node.js 中运行测试(由 experimental.viteModuleRunner 配置)。这会把所有模块导出转换为 getter,因此每次访问导入的绑定都会经过类似 __vite_ssr_module__.value 的东西。在普通测试中,这种开销可以忽略不计,但在函数会被调用数百万次的基准测试中,getter 调用本身可能会主导测量结果。

如果检测到过多的 getter 调用,Vitest 会打印警告(你可以通过 benchmark.suppressExportGetterWarnings 将其静音),但在基准测试导入函数时,你应当注意这一点:

ts
import { parse } from './parser.js'

const _parse = parse

test('parsing', async ({ bench }) => {
  // 糟糕:每次调用 `parse` 都要经过 getter
  await bench('parse', () => {
    parse(input)
  }).run()

  // 良好:将引用存到本地以绕过 getter
  await bench('parse', () => {
    _parse(input)
  }).run()
})

如果你是库的作者,同样的开销也会出现在你正在基准测试的库内部:其源码中每一次跨模块调用都会经过相同的 getter 包装。如果你在对自己的库进行基准测试,有两种方法可以消除这一点:

基准测试预构建产物。 通过包名导入该库(解析到的是其构建后的输出),而不是直接访问源码。构建后的文件已经将内部导入折叠为直接引用,因此 Vite 的模块运行器看到的是一个没有内部 getter 的单一模块:

ts
// 糟糕:库内部的每次调用都要经过 getter
import { parse } from '../src/index.ts'

// 良好:发布的入口没有内部 getter
import { parse } from 'my-library'

如果你将自己的库与其他包进行比较,请为每个实现都基准测试同一种类型的产物。对于工作区包,确保包名解析到的是构建输出而不是源码,例如可以在 Vite 中将该包 externalize,或者从 dist 导入。

为基准测试禁用模块运行器。 如果基准测试不需要 Vite 转换、mock,或 Vitest 的模块拦截,则可以为基准测试项目禁用 experimental.viteModuleRunner,这样 Node 就会直接运行原生 ESM。

这只影响 Node.js 模式。Browser 模式使用原生 ESM 导入,因此没有这种开销。

引擎特定注意事项

V8(Node.js,Chrome)

  • JIT 分层:V8 会通过多个优化层级(Sparkplug → Maglev → TurboFan)来编译函数。函数在预热阶段与稳定运行阶段的速度可能不同。Tinybench 会自动处理预热,但非常短的基准运行可能无法达到最高优化层级。

  • 去优化:如果在基准测试过程中遇到意外的类型或结构,V8 可能会从优化代码中“退出”。请在你的基准测试函数中保持类型一致:

    ts
    test('process items', async ({ bench }) => {
      // 糟糕:混合的结构会导致去优化
      await bench('process', () => {
        for (const item of items) {
          // 某些项具有 { name: string },其他项具有 { name: string, id: number }
          process(item)
        }
      }).run()
    
      // 良好:对象结构保持一致
      await bench('process', () => {
        for (const item of items) {
          // 所有项都具有相同的结构 { name: string, id: number }
          process(item)
        }
      }).run()
    })
  • 垃圾回收:基准测试循环中的大量分配会增加 GC 噪声。如果你测量的是计算性能,请在 setup 钩子中预先分配数据,而不是在被测函数内部:

    ts
    test('sorting', async ({ bench }) => {
      const original = Array.from({ length: 10000 }, () => Math.random())
      let data: number[]
    
      // 糟糕:每次迭代都会分配一个新数组,GC 会增加噪声
      await bench('sort', () => {
        const data = Array.from({ length: 10000 }, () => Math.random())
        data.sort()
      }).run()
    
      // 良好:预先分配,在 beforeEach 中复制
      await bench(
        'sort',
        () => { data.sort() },
        {
          beforeEach() {
            data = [...original]
          },
        },
      ).run()
    })

JavaScriptCore(Bun,Safari)

  • 不同的优化阈值:JSC 使用自己的 JIT 层级(LLInt → Baseline → DFG → FTL),并具有不同的内联和优化启发式规则。在 V8 上很快的基准,在 JSC 上可能表现得非常不同。
  • 异步基准测试:Bun 的事件循环实现与 Node.js 不同。如果你的基准测试涉及异步操作或定时器,不同运行时之间的结果可能无法直接比较。

浏览器

  • 计时器分辨率:浏览器可能会降低 performance.now() 的精度(例如降低到 100μs 甚至 1ms)作为安全机制。这会使非常快的操作难以准确测量,因此请增加迭代次数以补偿:

    ts
    test('fast operations', async ({ bench }) => {
      await bench.compare(
        bench('fast-op', () => { fastOp() }),
        bench('other-op', () => { otherOp() }),
        {
          // 更多迭代有助于克服较低的计时器分辨率
          iterations: 1000,
        },
      )
    })
  • 跨浏览器差异:V8(Chrome)、SpiderMonkey(Firefox)和 JSC(Safari)会以不同方式优化不同的模式。在 Chrome 中显示某个库获胜的基准,在 Firefox 中可能会显示相反的结果。