写一个 JS Benchmark 框架 ESBench

发布时间:

最后更新:

你做项目写基准测试吗?就是测一个函数调用花多少时间。据我在 GitHub 上的的观察大部分人是不写的,毕竟做功能和单测就够费时间了。对于 JavaScript 的来说更是如此,毕竟它不像些编译型语言对性能这么敏感。也就我这几年家里蹲,才会闲得无聊去比较各种写法的性能吧。

就这样我写了不少 Benchmark 零零散散在各个项目里,在这个过程中我越来越感觉 JS 生态里没有一个像样的 Benchmark 框架,于是就自己动手丰衣足食!

本项目开源:https://github.com/ESBenchmark/ESBench

测个用时为什么要用框架?

看我三行就给你测出来:

javascript
const start = performance.now();
fn();
console.log(performance.now() - start);

这段代码有什么问题呢?首先它有 4 个坑:

  • 为了支持浏览器这里用了 performance.now(),它的精度是这样的:

    • NodeJS 里它跟 process.hrtime 一样底层调用uv_hrtime(1) (2),精度很高,跟系统相关。
    • 浏览器里最高 5us。
    • 但是自从牙膏厂的 CPU 出了几个大漏洞之后,浏览器的默认精度就只有 100us 了,需要设置两个响应头才能使用 5us 的。

    要是没注意设置响应头,那测出来的时间可就拉跨了。

  • 大部分函数都运行的很快,调用一次的用时可能跟计时器的精度差不多,所以你得循环多次,然后把结果除以次数。

  • 主流的三大引擎都有 JIT 功能,调用次数多了会给你优化成机器码,这意味着前几次调用跟后几次性能差远了。你必须先进行大量的循环运行,让引擎优化完,然后再测试,也就是预热。

  • 函数的执行时间受环境影响,比如硬件、当前 CPU 是否繁忙等等,特别是现在的系统里都有一堆后台服务跟你的代码同时运行。为了更精确的结果就必须多次采样,然后用统计学方法来减弱噪声的影响。

然后你可能还需要的功能:

  • 易读的输出,至少能算个平均值、标准差和几个常用的百分位数吧。
  • 参数化测试,试试不同的参数下函数运行的怎么样,然后打印一个表格。
  • 把结果画成图表,这样更直观一些,毕竟 JS 最初就是做页面的,总搁那黑框框里跳字可不行。
  • 待测的函数不止一个,得设计下 API 以便重复使用。

当你处理完这些之后,会发现代码已经多到可以单独整个项目了,于是就有了 Benchmark 框架。

测一个函数有意义吗?

我经常听到这样的论调:测试一个函数的性能是没意义的,脱离了实际。你应该去测量整个应用的响应时间。

好吧,首先“整个应用”这东西并不一定存在,比如你写的是一个库。

其次对内部细节的测试有没有意义,业界在几十年前就有了答案。这可以拿单元测试和集成测试来类比——Micro Benchmark 不就是性能方面的单元测试么。你可以问问自己:有了集成测试后单元测试是不是就没有意义了?

现有方案的问题 #

我用过的 Benchmark 框架有:

JS 这边就很多了,虽然不是都用过:

以及 NodeJS 自己的测试工具

了解它们之后就知道,这里头 BenchmarkDotNet 吊打其它的,特别是 JS 生态里的工具都差太多,甚至一些必要的功能都没有。

比较结果 #

首先是参数化,就是能设定几个参数,每个有一组值,然后测试代码在这些参数下的表现,最后这些参数也能在报告中看到。

比如 BenchmarkDotNet 就能指定参数,该类将创建 4 个实例并运行,分别对应两个参数的所有组合。

C#
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 的主要原因。

IDE 集成 #

基准测试跟单元测试挺像,可以类比一下,在运行单测的时候是不是经常要仅运行一个?这是很常见的,基准测试也是这样,至少我经常会在修改代码之后运行下与其相关的 benchmark。

一些工具可以通过命令行参数来过滤测试用例,但比起命令行显然点一下鼠标更轻松,而我就是喜欢这种极致的懒。另外 JMH 就有这种插件,然而 JS 的这些工具都没有。

所以我还给 ESBench 写了俩插件,支持 VSCode 和 WebStorm。

IDE PluginsIDE Plugins

火焰图? #

除了 Lighthouse 这种整体计时和 Benchmark 库之外,还有一种观察性能的工具,就是浏览器和 IDE 自带的用时跟踪,它的报告看上去是这样:

火焰图火焰图

这类工具并不能代替 Benchmark:

就算真的需要,依靠本项目的插件式架构,实现这种报告并不是什么难事。

架构设计 #

好的既然现有的方案都不能满足我的需求,那就自己干吧!

主体流程 #

首先从核心功能:“跨平台运行”入手。要支持多平台,那么程序就不可能只用一个进程里运行,所以必须分为两部分:

基本流程基本流程

执行和通信 #

第一个难点就是执行器的设计,它的功能可以分为三部分:启动 Runtime,运行代码,传输结果。

启动这部分比较简单,我能想到的有这几种:

接下来就是运行代码,这个可谓是五花八门。evalnew Function能直接把字符串当代码执行、Node 里还有更安全的vm模块能够创建隔离的环境、当然服务端的 Runtime 都支持在启动参数里指定文件、Playwright 也有page.evaluate方法……

作为更高层的框架,我当然希望选择最通用的方式,而在这里面,最简单、最通用的就是加载文件,所有后端 Runtime 都支持,Playwright 也有page.route可以拦截请求。所以我选择将构建的结果写到临时目录,然后再去运行这些文件。

最后一步就是怎么传输结果,浏览器这边 Playwright 自带通信机制,但服务端的这些就得选一下了。

于是最终的方案就是这样:

构建过程 #

想在浏览器里运行代码,还需要解决一个问题就是导入的处理。由于浏览器和 Node 具有不同的解析算法,对第三方库的导入是不通用的。

比如import "esbench"在 Node 里会去 node_modules 下查找安装的库,但是在浏览器中却是入当前目录下的 esbench 文件。

在把代码送到浏览器之前,必须转换导入的路径。对此可以选择自己处理,或者直接使用现有的构建器,本项目都支持。

另外支持构建过程还有一些好处:

工具链配置 #

有时候一个项目同时包含了浏览器端和服务端,并且两者里面都有代码要测,那么它们就需要走不同的运行流程。

这就要求配置文件能够支持文件-构建器-执行器三者的自由组合,所以最终设计出来是这样的:

javascript
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# 采用的是注解 + 类的方式:

java
@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:

javascript
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 的写法,故本框架的写法是这样的:

javascript
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 层结构:

为了后续好处理,主控端收集的时候把套件放到了顶层,所以结果的结构是这样:

json
{
	// 第一层,每个套件的结果。
	"benchmark/module.js": [
		// 第二层,工具链和套件的选项。
		{
			"executor": "node",
			"builder": "None",
			"meta": {},
			"paramDef": [],
			// 第三层,每个场景。
			"scenes": [{
				// 第四层,每个函数。
				"For-index": {
					// 第五层,指标。
					"time": [1,2,3]
				}
			}]
		}
	]
}

这样虽然不是最紧凑的,但胜在简单,而且结果数据不大不用过多考虑性能。

使用 #

显然嵌套太多就难以使用,为了方便报告器的编写,需要对数据进行转换。

从整体上看,作为测试,它所输出的就只需要有用例及其结果。前面的几层嵌套(工具链、场景、函数)实际上是在生成用例,比如一个函数使用不同的参数是不同的用例。在 ESBench 里我将这些称为变量,每一种变量的组合就是一个用例,而结果当然就是指标了。

于是本项目决定引入一个中间的数据结构,对原始数据进行展平,变为变量: 指标的映射,这样后续的处理就简单多了。

表结构表结构

对于衍生的指标,标准差、百分位数等等,都交给报告器来决定,因为像图表这种就不需要自己去计算它们。

其它 #

关于内存的测量 #

通常内存指标分为两种:

遗憾的是与后端语言不同,JS 在内存控制方面十分羸弱,浏览器上连个能摸 GC 的函数都没有。Node 里虽然有个--expose_gc能拿到个gc函数,但它既无法等待,也不保证把垃圾全收干净。

我尝试过gc搭配process.memoryUsage()来测内存,但总是得不到稳定的结果,故暂时没有实现这个功能。

在手机里运行 #

移动设备性能较 PC 而言更差,换句话说也就是对性能更敏感,为了让我们的测试跑到手机里,本项目还整了一个远程运行的功能

它的思路是这样的:

写完了我才发现:哦艹好简单,原来远程运行这么容易的吗……

一些 AI 生成的图标一些 AI 生成的图标

大一点的项目还是得有个 Logo,我先是尝试了免费的 AI 生成,结果基本上以速度表和秒表为主体,我也想不到更好的设计,于是也就按照这个方向画。

设计方案设计方案

该 Logo 以 NodeJS 的绿色六边形为基础,融合了秒表的元素,放大了看还行,可惜在 favicon 那个尺寸下有点像礼物盒……不过 Logo 以后也是可以换的,没必要一次就做到完美。

未来的想法 #

扩展性 #

在前面的架构设计中,已经定义了构建器、执行器和报告器三个扩展点,ESBench 也自然成为了插件化的框架。除此之外 ESBench 还对套件的运行进行了可扩展设计,支持自定义指标。

这也就是说不仅是函数的运行时间,ESBench 还可以测量其它的东西,比如同时测 zlib 模块的压缩率、压缩速度和解压速度

目前的设计比较简单,就是在套件运行期间选了几个扩展点:

typescript
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参数,通过同时启动多个实例,每个运行一部分测试来加速整体。

相比于单元测试,基准测试在这方面有更高的要求:

  1. 因为性能跟系统和硬件相关,所以各个实例必须是镜像的。
  2. 单元测试一旦失败,通过进程的退出码和日志就能找到必要的信息,而基准测试需要收集结果,以便汇总成报告。

不过一次测很多套件这种需求似乎并不常见,我自己也没用到过,所以目前还没有实现。

开发时长 #

ESBench 的开发时间估计有十个月,之所以是估计是因为本项目在 2021 年就创建了,但断断续续地写了一点就被废弃,原因就是本文开头反驳的——价值不够。

当去花费大量的时间开发一个库的时候,自然希望它有足够的价值,像 JQuery、Webpack、React 那样成为 JS 历史上的里程碑。但是 Benchmark 这个领域并没有多少人感兴趣,可以预见 ESBench 不会有多火,甚至可能不如 BenchmarkDotNet。

但两年之后我的想法变了,我见过许多人前赴后继地去做早已烂大街的记账软件,见过层出不穷的响应式框架,还见过把一个时钟做到极致的独立开发者。代码的价值并不是那么简单就能判断,至少在同类工具里 ESBench 还是有些创新的。

虽然这个项目不能成为什么惊世骇俗的东西,但它能解决问题,我用得上,那就足够了。

评论加载中