写一个 JS Benchmark 框架 ESBench
发布时间:
最后更新:
你做项目写基准测试吗?就是测一个函数调用花多少时间。据我在 GitHub 上的的观察大部分人是不写的,毕竟做功能和单测就够费时间了。对于 JavaScript 的来说更是如此,毕竟它不像些编译型语言对性能这么敏感。也就我这几年家里蹲,才会闲得无聊去比较各种写法的性能吧。
就这样我写了不少 Benchmark 零零散散在各个项目里,在这个过程中我越来越感觉 JS 生态里没有一个像样的 Benchmark 框架,于是就自己动手丰衣足食!
本项目开源:https://github.com/ESBenchmark/ESBench
测个用时为什么要用框架?
看我三行就给你测出来:
const start = performance.now();
fn();
console.log(performance.now() - start);
这段代码有什么问题呢?首先它有 4 个坑:
-
为了支持浏览器这里用了
performance.now()
,它的精度是这样的:- NodeJS 里它跟
process.hrtime
一样底层调用uv_hrtime
(1) (2),精度很高,跟系统相关。 - 浏览器里最高 5us。
- 但是自从牙膏厂的 CPU 出了几个大漏洞之后,浏览器的默认精度就只有 100us 了,需要设置两个响应头才能使用 5us 的。
要是没注意设置响应头,那测出来的时间可就拉跨了。
- NodeJS 里它跟
-
大部分函数都运行的很快,调用一次的用时可能跟计时器的精度差不多,所以你得循环多次,然后把结果除以次数。
-
主流的三大引擎都有 JIT 功能,调用次数多了会给你优化成机器码,这意味着前几次调用跟后几次性能差远了。你必须先进行大量的循环运行,让引擎优化完,然后再测试,也就是预热。
-
函数的执行时间受环境影响,比如硬件、当前 CPU 是否繁忙等等,特别是现在的系统里都有一堆后台服务跟你的代码同时运行。为了更精确的结果就必须多次采样,然后用统计学方法来减弱噪声的影响。
然后你可能还需要的功能:
- 易读的输出,至少能算个平均值、标准差和几个常用的百分位数吧。
- 参数化测试,试试不同的参数下函数运行的怎么样,然后打印一个表格。
- 把结果画成图表,这样更直观一些,毕竟 JS 最初就是做页面的,总搁那黑框框里跳字可不行。
- 待测的函数不止一个,得设计下 API 以便重复使用。
当你处理完这些之后,会发现代码已经多到可以单独整个项目了,于是就有了 Benchmark 框架。
测一个函数有意义吗?
我经常听到这样的论调:测试一个函数的性能是没意义的,脱离了实际。你应该去测量整个应用的响应时间。
好吧,首先“整个应用”这东西并不一定存在,比如你写的是一个库。
其次对内部细节的测试有没有意义,业界在几十年前就有了答案。这可以拿单元测试和集成测试来类比——Micro Benchmark 不就是性能方面的单元测试么。你可以问问自己:有了集成测试后单元测试是不是就没有意义了?
现有方案的问题 #
我用过的 Benchmark 框架有:
- C#: BenchmarkDotNet
- JAVA: Java Microbenchmark Harness(简称 JMH)
- Rust: Criterion.rs
JS 这边就很多了,虽然不是都用过:
- benchmark.js(最近停止了维护)
- bema(底层是 benchmark.js)
- benny(底层是 benchmark.js)
- tinybench
- mitata
- bench-node
- isitfast
- cronometro
以及 NodeJS 自己的测试工具。
了解它们之后就知道,这里头 BenchmarkDotNet 吊打其它的,特别是 JS 生态里的工具都差太多,甚至一些必要的功能都没有。
比较结果 #
首先是参数化,就是能设定几个参数,每个有一组值,然后测试代码在这些参数下的表现,最后这些参数也能在报告中看到。
比如 BenchmarkDotNet 就能指定参数,该类将创建 4 个实例并运行,分别对应两个参数的所有组合。
public class MyBenchmark
{
[Params("small.json", "big.json")]
public string DataSet { get; set; }
[Params(1, 1000)]
public int Size { get; set; }
[Benchmark(Baseline = true)]
public void CLRImpl() { /*...*/ }
[Benchmark]
public void MyImpl() { /*...*/ }
}
我惊讶于如此重要的功能,却只有 isitfast 和 bema 支持,而且它们的 API 设计得也不太好。其它的基本上是要求自己写循环,然后把参数带进用例的名字中,这种非结构化的设计使其难以扩展。
其次是 baseline,将一个参数或者用例作为基准,在结果中显示其它参数或用例跟基准的差值。这在对比不同的实现方案时是一个很有用的功能。从文档来看,只有 mitata 有相关的 API。
还有将结果保存下来,下一次运行时自动跟它对比,这在不断优化一个功能时很有用。
精确度 #
但凡涉及测量,就一定有精度问题,在这方面做了多少努力也是衡量一个工具的重要因素。
除了谁都知道的预热、大量循环以及多次采样之外,BenchmarkDotNet 在这方面还给我了很多参考,包括:
- 通过循环展开减少无关代码的影响。
- 排除空载开销。
- 调整进程优先级以降低外部干扰。
- 修改系统的电源配置提升性能。
后两个跟系统相关不太好做,但前两条在纳秒级别的用例上是可以提升精度的。
在这方面 isitfast 做的不错,mitata 也有循环展开,而 tinybench 是最差的——什么都没做,但它反而是 star 数量最多的……
多工具链 #
JS 跟其它的语言有个显著的不同,就是主流的平台有好几种:
- V8:NodeJS, Deno, Chrome, Edge.
- SpiderMonkey: Firefox, WinterJS.
- JavaScriptCore: Safari, Bun.
- 还有 QuickJS 等等从头实现的。
听说 Youtube 曾经使用了某些技巧来恶性竞争,故意在 Firefox 上运行的特别慢。且不论真伪,同样的代码在不同的引擎上的性能可能是不一样的,自己在写代码的时候会不会也意外地遇到这种情况呢?所以测试得做全,在不同的平台上都测一遍,才能避免它的发生。
再就是编译器,像是 ESbuild,SWC,Babel 等等,现在大项目几乎都要打包处理,这期间你的代码已经被转换了,包括引入 Polyfill、压缩,构建的结果跟原始的代码性能也会有差异。
想要通过运行代码来了解它们之间的性能差别,那么多工具链支持就必不可少。可惜的是没有一个 JS 工具去做跨平台运行的,这也是我开发 ESBench 的主要原因。
IDE 集成 #
基准测试跟单元测试挺像,可以类比一下,在运行单测的时候是不是经常要仅运行一个?这是很常见的,基准测试也是这样,至少我经常会在修改代码之后运行下与其相关的 benchmark。
一些工具可以通过命令行参数来过滤测试用例,但比起命令行显然点一下鼠标更轻松,而我就是喜欢这种极致的懒。另外 JMH 就有这种插件,然而 JS 的这些工具都没有。
所以我还给 ESBench 写了俩插件,支持 VSCode 和 WebStorm。
火焰图? #
除了 Lighthouse 这种整体计时和 Benchmark 库之外,还有一种观察性能的工具,就是浏览器和 IDE 自带的用时跟踪,它的报告看上去是这样:
这类工具并不能代替 Benchmark:
- 火焰图只运行一次,而 Benchmark 追求稳定会运行多次函数,精度更高。
- 火焰图并不适合做对比,而这是 Benchmark 的目标之一。
- 这些工具都跟平台绑定,没法解决测一个函数在三大浏览器中的性能这样的需求。
就算真的需要,依靠本项目的插件式架构,实现这种报告并不是什么难事。
架构设计 #
好的既然现有的方案都不能满足我的需求,那就自己干吧!
主体流程 #
首先从核心功能:“跨平台运行”入手。要支持多平台,那么程序就不可能只用一个进程里运行,所以必须分为两部分:
- 运行代码的执行器,调用不同的程序来运行测试用例,然后发送回结果。
- 主控端,分发任务,调用执行器,最后汇总结果生成报告。
执行和通信 #
第一个难点就是执行器的设计,它的功能可以分为三部分:启动 Runtime,运行代码,传输结果。
启动这部分比较简单,我能想到的有这几种:
- 服务端的 Runtime 直接用
child_process
即可 - 浏览器可以通过 Playwright、WebdriverIO、Puppeteer 等库来操作,本项目选择较新的 Playwright。
- 其它环境比如远程执行也总会有 API 可以调,实现起来不会太难。
接下来就是运行代码,这个可谓是五花八门。eval
和new Function
能直接把字符串当代码执行、Node 里还有更安全的vm
模块能够创建隔离的环境、当然服务端的 Runtime 都支持在启动参数里指定文件、Playwright 也有page.evaluate
方法……
作为更高层的框架,我当然希望选择最通用的方式,而在这里面,最简单、最通用的就是加载文件,所有后端 Runtime 都支持,Playwright 也有page.route
可以拦截请求。所以我选择将构建的结果写到临时目录,然后再去运行这些文件。
最后一步就是怎么传输结果,浏览器这边 Playwright 自带通信机制,但服务端的这些就得选一下了。
-
StdIO 流肯定是不能用的,因为用户的代码也能调用它。那么把
console.log
以及process.std*
替换成空函数行不行呢,这似乎能屏蔽用户代码的调用,但会对性能产生影响,跟性能测试有冲突。 -
写文件是一种可行的方案,对于日志这种连续的消息也可以通过监听修改来响应,好像 WebStorm 的 Vitest 插件就是这么搞得。但文件这东西并设计的目的是存储,而不是通信,用起来总有点怪。
-
共享内存、管道之类的高度依赖于平台,不具备通用性,不优先考虑。
-
最后还是得走网络,在 JS 里兼容性最好的网络 API 是什么?当然是
fetch
,主流的几个(Node, Deno, Bun)都支持。
于是最终的方案就是这样:
- 浏览器使用 Playwright 的
exposeFunction
设置通信函数。 - 服务端的 JS 引擎可以通过
fetch
传消息,本项目里开个 HTTP 服务器即可。 - 不支持
fetch
的考虑些文件或管道来通信。
构建过程 #
想在浏览器里运行代码,还需要解决一个问题就是导入的处理。由于浏览器和 Node 具有不同的解析算法,对第三方库的导入是不通用的。
比如import "esbench"
在 Node 里会去 node_modules 下查找安装的库,但是在浏览器中却是入当前目录下的 esbench 文件。
在把代码送到浏览器之前,必须转换导入的路径。对此可以选择自己处理,或者直接使用现有的构建器,本项目都支持。
-
自己处理的思路就是搜索代码里的导入语句,推荐用 es-module-lexer 这个库,然后把它们替换成绝对路径,Vite 也是这么搞的可以作为参考。
解析模块到绝对路径可以使用
require.resolve
或import.meta.resolve
,分别对应 CommonJS 和 ESM,注意后者目前得加个参数--experimental-import-meta-resolve
-
适配构建器就简单了,只需要通过插件创建入口模块,然后输出到临时目录即可。
另外支持构建过程还有一些好处:
- 跟现有的方案集成,用户的项目可能必须得构建才能运行。
- 前面提到了构建对性能的影响也是测试的目标,把它也当作变量,真正做到一切皆可测量。
工具链配置 #
有时候一个项目同时包含了浏览器端和服务端,并且两者里面都有代码要测,那么它们就需要走不同的运行流程。
这就要求配置文件能够支持文件-构建器-执行器
三者的自由组合,所以最终设计出来是这样的:
export default defineConfig({
toolchains: [{
// 匹配这些文件。
include: ["./es/*.js", "./web/*-bench.js"],
// 用这些构建器构建它们。
builders: [
new ViteBuilder(),
// 其它的构建配置……
],
// 然后用这些执行器运行。
executors: [
new PlaywrightExecutor(firefox),
new PlaywrightExecutor(webkit),
],
}, {
// 另一部分文件可以定义不同的组合……
}],
});
在运行时为了提高性能,会对它们做聚合,比如ViteBuilder
只会构建一次,其中包含了toolchains[0].include
中的所有文件,如果toolchains
的其它项里也有同一个构建器的实例,则它的include
也会被包含进去。
被加入构建的文件将使用动态导入,这避免了顶层语句的副作用。
执行器同理,每个实例只会初始化一次,然后依次运行所有匹配到的构建器的构建结果,在运行时仅导入include
中的文件。
这样就实现了灵活的组合,同时也尽量减少了调用次数,说起来挺复杂,但实际代码还挺简单。
套件的 API #
跟其他的库一样,ESBench 也要求将功能相同,相互之间需要对照的实现放在一起,称为套件(Suite),每个套件都得在单独的文件里定义。
套件也是用户需要编写的部分,它的 API 设计需要兼顾功能和简洁,这还是需要一番取舍的。
先看看现有的工具,OOP 语言 JAVA 和 C# 采用的是注解 + 类的方式:
@Measurement(iterations = 5, time = 5)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class MySuite {
@Setup(Level.Iteration)
public void setUp() {}
@Benchmark
public void case1() { /*...*/ }
@Benchmark
public void case2() { /*...*/ }
}
可惜 JS 的注解迟迟没有稳定,而且 JS 开发者似乎也不太喜欢 OOP,所以不能采用这种方案。
相同的语言大部分(benchmark.js, tinybench, mitata, bema)选择了命令式 API:
const suite = new Suite(optinos);
suite.on("cycle", () => {});
suite.on("complete", () => {});
suite.add("case 1", fn1);
suite.add("case 2", fn2);
const result = await suite.run();
这种写法又太过自由,不利于参数的类型推导和上下文传递,当然不是说做不到,而是要麻烦很多。另外现在都在推崇声明式 API,而且对于 setup 和 cleanup 主流的方案是闭包(ReactHooks,Vue Composite API……)。
那么答案就出来了,声明式 + 闭包才是新时代 JS 的写法,故本框架的写法是这样的:
export default defineSuite({
beforeAll() {},
afterAll() {},
...otherOptions,
setup(scene) {
// SetUp code here...
scene.teardown(cleanUp);
scene.bench("case 1", fn1);
scene.bench("case 2", fn2);
},
});
数据结构 #
套件运行时会不断地生成结果,紧接着又要把它序列化传输到主控端,最后用它来生成报告。数据也贯穿了整个项目,在设计数据结构时我是这样想的:
保存 #
在运行套件的时候产生原始结果,因为此时不需要使用它,所以数据结构应当自然与执行过程对应,无需做什么处理。套件的运行过程从上到下是一个 5 层结构:
- 主控端运行工具链的每种组合。
- 执行器将运行所有匹配的套件。
- 套件的每个参数组合将生成一个场景(Scene)。
- 每个场景里有多个函数需要运行。
- 每个函数都可以有多个指标。
为了后续好处理,主控端收集的时候把套件放到了顶层,所以结果的结构是这样:
{
// 第一层,每个套件的结果。
"benchmark/module.js": [
// 第二层,工具链和套件的选项。
{
"executor": "node",
"builder": "None",
"meta": {},
"paramDef": [],
// 第三层,每个场景。
"scenes": [{
// 第四层,每个函数。
"For-index": {
// 第五层,指标。
"time": [1,2,3]
}
}]
}
]
}
这样虽然不是最紧凑的,但胜在简单,而且结果数据不大不用过多考虑性能。
使用 #
显然嵌套太多就难以使用,为了方便报告器的编写,需要对数据进行转换。
从整体上看,作为测试,它所输出的就只需要有用例及其结果。前面的几层嵌套(工具链、场景、函数)实际上是在生成用例,比如一个函数使用不同的参数是不同的用例。在 ESBench 里我将这些称为变量,每一种变量的组合就是一个用例,而结果当然就是指标了。
于是本项目决定引入一个中间的数据结构,对原始数据进行展平,变为变量: 指标
的映射,这样后续的处理就简单多了。
对于衍生的指标,标准差、百分位数等等,都交给报告器来决定,因为像图表这种就不需要自己去计算它们。
其它 #
关于内存的测量 #
通常内存指标分为两种:
-
一个过程中所分配的内存总量,较少的分配意味着速度快和 GC 压力小,这是最常见的关注点。BenchmamrkDotNet 就支持这个,它使用 C# 提供的 API 直接获取一个线程分配过的内存总量。
-
某个对象占用的内存大小,要测量这个必须能够刚暂停 GC,或者手动执行 GC 把死对象全部回收。
遗憾的是与后端语言不同,JS 在内存控制方面十分羸弱,浏览器上连个能摸 GC 的函数都没有。Node 里虽然有个--expose_gc
能拿到个gc
函数,但它既无法等待,也不保证把垃圾全收干净。
我尝试过gc
搭配process.memoryUsage()
来测内存,但总是得不到稳定的结果,故暂时没有实现这个功能。
在手机里运行 #
移动设备性能较 PC 而言更差,换句话说也就是对性能更敏感,为了让我们的测试跑到手机里,本项目还整了一个远程运行的功能。
它的思路是这样的:
- 首先在本机上开一个 HTTP 服务器,里头有一个页面。
- 在手机上打开浏览器访问该页面。
- 在页面里会不断的拉取套件并运行,然后传回结果。
- 全部运行完了也继续轮询拉取,只要页面还活着,下次运行 ESBench 就无需再点手机。
写完了我才发现:哦艹好简单,原来远程运行这么容易的吗……
Logo #
大一点的项目还是得有个 Logo,我先是尝试了免费的 AI 生成,结果基本上以速度表和秒表为主体,我也想不到更好的设计,于是也就按照这个方向画。
该 Logo 以 NodeJS 的绿色六边形为基础,融合了秒表的元素,放大了看还行,可惜在 favicon 那个尺寸下有点像礼物盒……不过 Logo 以后也是可以换的,没必要一次就做到完美。
未来的想法 #
扩展性 #
在前面的架构设计中,已经定义了构建器、执行器和报告器三个扩展点,ESBench 也自然成为了插件化的框架。除此之外 ESBench 还对套件的运行进行了可扩展设计,支持自定义指标。
这也就是说不仅是函数的运行时间,ESBench 还可以测量其它的东西,比如同时测 zlib 模块的压缩率、压缩速度和解压速度。
目前的设计比较简单,就是在套件运行期间选了几个扩展点:
export interface Profiler {
// 在开始运行时调用。
onStart?: (ctx: ProfilingContext) => Awaitable<void>;
// 在每个场景初始化后调用。
onScene?: (ctx: ProfilingContext, scene: Scene) => Awaitable<void>;
// 每个函数调用一次。
onCase?: (ctx: ProfilingContext, case_: BenchCase, metrics: Metrics) => Awaitable<void>;
// 全部结束后调用。
onFinish?: (ctx: ProfilingContext) => Awaitable<void>;
}
实际用起来还不错,我自己的需求都能搞定。
这或许意味着 ESBench 有作为更通用的框架的潜力。Benchmark 用来测量 performance,如果从广义上理解,压缩率、构建结果的体积、或者其它一些与函数相关的指标是不是也能称之为 performance 呢?
分布式运行 #
由于测量速度时需要大量的调用,基准测试都运行得很慢,特别是要运行的用例一多,那就是以小时为单位的等待。
除了减少调用次数以外,我还考虑了分布式运行,这个灵感来自于 Jest 的--shared
参数,通过同时启动多个实例,每个运行一部分测试来加速整体。
相比于单元测试,基准测试在这方面有更高的要求:
- 因为性能跟系统和硬件相关,所以各个实例必须是镜像的。
- 单元测试一旦失败,通过进程的退出码和日志就能找到必要的信息,而基准测试需要收集结果,以便汇总成报告。
不过一次测很多套件这种需求似乎并不常见,我自己也没用到过,所以目前还没有实现。
开发时长 #
ESBench 的开发时间估计有十个月,之所以是估计是因为本项目在 2021 年就创建了,但断断续续地写了一点就被废弃,原因就是本文开头反驳的——价值不够。
当去花费大量的时间开发一个库的时候,自然希望它有足够的价值,像 JQuery、Webpack、React 那样成为 JS 历史上的里程碑。但是 Benchmark 这个领域并没有多少人感兴趣,可以预见 ESBench 不会有多火,甚至可能不如 BenchmarkDotNet。
但两年之后我的想法变了,我见过许多人前赴后继地去做早已烂大街的记账软件,见过层出不穷的响应式框架,还见过把一个时钟做到极致的独立开发者。代码的价值并不是那么简单就能判断,至少在同类工具里 ESBench 还是有些创新的。
虽然这个项目不能成为什么惊世骇俗的东西,但它能解决问题,我用得上,那就足够了。