你做项目写基准测试吗?就是测一个函数调用花多少时间。据我在 GitHub 上的的观察大部分人是不写的,毕竟做功能和单测就够费时间了。对于 JavaScript 的来说更是如此,毕竟它不像些编译型语言对性能这么敏感。也就我这几年家里蹲,才会闲得无聊去比较各种写法的性能吧。
就这样我写了不少 Benchmark 零零散散在各个项目里,在这个过程中我越来越感觉 JS 生态里没有一个像样的 Benchmark 框架,于是就自己动手丰衣足食!
本项目开源:https://github.com/ESBenchmark/ESBench
看我三行就给你测出来:
const start = performance.now();
fn();
console.log(performance.now() - start);
这段代码有什么问题呢?首先它有 4 个坑:
为了支持浏览器这里用了 performance.now()
,它的精度是这样的:
process.hrtime
一样底层调用uv_hrtime
(1) (2),精度很高,跟系统相关。要是没注意设置响应头,那测出来的时间可就拉跨了。
大部分函数都运行的很快,调用一次的用时可能跟计时器的精度差不多,所以你得循环多次,然后把结果除以次数。
主流的三大引擎都有 JIT 功能,调用次数多了会给你优化成机器码,这意味着前几次调用跟后几次性能差远了。你必须先进行大量的循环运行,让引擎优化完,然后再测试,也就是预热。
函数的执行时间受环境影响,比如硬件、当前 CPU 是否繁忙等等,特别是现在的系统里都有一堆后台服务跟你的代码同时运行。为了更精确的结果就必须多次采样,然后用统计学方法来减弱噪声的影响。
然后你可能还需要的功能:
当你处理完这些之后,会发现代码已经多到可以单独整个项目了,于是就有了 Benchmark 框架。
我经常听到这样的论调:测试一个函数的性能是没意义的,脱离了实际。你应该去测量整个应用的响应时间。
好吧,首先“整个应用”这东西并不一定存在,比如你写的是一个库。
其次对内部细节的测试有没有意义,业界在几十年前就有了答案。这可以拿单元测试和集成测试来类比——Micro Benchmark 不就是性能方面的单元测试么。你可以问问自己:有了集成测试后单元测试是不是就没有意义了?
我用过的 Benchmark 框架有:
JS 这边就很多了,虽然不是都用过:
以及 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 跟其它的语言有个显著的不同,就是主流的平台有好几种:
听说 Youtube 曾经使用了某些技巧来恶性竞争,故意在 Firefox 上运行的特别慢。且不论真伪,同样的代码在不同的引擎上的性能可能是不一样的,自己在写代码的时候会不会也意外地遇到这种情况呢?所以测试得做全,在不同的平台上都测一遍,才能避免它的发生。
再就是编译器,像是 ESbuild,SWC,Babel 等等,现在大项目几乎都要打包处理,这期间你的代码已经被转换了,包括引入 Polyfill、压缩,构建的结果跟原始的代码性能也会有差异。
想要通过运行代码来了解它们之间的性能差别,那么多工具链支持就必不可少。可惜的是没有一个 JS 工具去做跨平台运行的,这也是我开发 ESBench 的主要原因。
基准测试跟单元测试挺像,可以类比一下,在运行单测的时候是不是经常要仅运行一个?这是很常见的,基准测试也是这样,至少我经常会在修改代码之后运行下与其相关的 benchmark。
一些工具可以通过命令行参数来过滤测试用例,但比起命令行显然点一下鼠标更轻松,而我就是喜欢这种极致的懒。另外 JMH 就有这种插件,然而 JS 的这些工具都没有。
所以我还给 ESBench 写了俩插件,支持 VSCode 和 WebStorm。
除了 Lighthouse 这种整体计时和 Benchmark 库之外,还有一种观察性能的工具,就是浏览器和 IDE 自带的用时跟踪,它的报告看上去是这样:
这类工具并不能代替 Benchmark:
就算真的需要,依靠本项目的插件式架构,实现这种报告并不是什么难事。
好的既然现有的方案都不能满足我的需求,那就自己干吧!
首先从核心功能:“跨平台运行”入手。要支持多平台,那么程序就不可能只用一个进程里运行,所以必须分为两部分:
第一个难点就是执行器的设计,它的功能可以分为三部分:启动 Runtime,运行代码,传输结果。
启动这部分比较简单,我能想到的有这几种:
child_process
即可接下来就是运行代码,这个可谓是五花八门。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)都支持。
于是最终的方案就是这样:
exposeFunction
设置通信函数。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
中的文件。
这样就实现了灵活的组合,同时也尽量减少了调用次数,说起来挺复杂,但实际代码还挺简单。
跟其他的库一样,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 层结构:
为了后续好处理,主控端收集的时候把套件放到了顶层,所以结果的结构是这样:
{
// 第一层,每个套件的结果。
"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 而言更差,换句话说也就是对性能更敏感,为了让我们的测试跑到手机里,本项目还整了一个远程运行的功能。
它的思路是这样的:
写完了我才发现:哦艹好简单,原来远程运行这么容易的吗……
大一点的项目还是得有个 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 还是有些创新的。
虽然这个项目不能成为什么惊世骇俗的东西,但它能解决问题,我用得上,那就足够了。