IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    写一个 JS Benchmark 框架 ESBench

    Kaciras 的博客发表于 2024-06-14 14:23:10
    love 0

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

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

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

    1. 现有方案的问题
      1. 比较结果
      2. 精确度
      3. 多工具链
      4. IDE 集成
      5. 火焰图?
    2. 架构设计
      1. 主体流程
      2. 执行和通信
      3. 构建过程
      4. 工具链配置
      5. 套件的 API
      6. 数据结构
        1. 保存
        2. 使用
    3. 其它
      1. 关于内存的测量
      2. 在手机里运行
      3. Logo
    4. 未来的想法
      1. 扩展性
      2. 分布式运行
    5. 开发时长
    测个用时为什么要用框架?

    看我三行就给你测出来:

    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 框架有:

    • 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 个实例并运行,分别对应两个参数的所有组合。

    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 跟其它的语言有个显著的不同,就是主流的平台有好几种:

    • 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。

    IDE PluginsIDE Plugins

    火焰图? #

    除了 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

    • 适配构建器就简单了,只需要通过插件创建入口模块,然后输出到临时目录即可。

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

    • 跟现有的方案集成,用户的项目可能必须得构建才能运行。
    • 前面提到了构建对性能的影响也是测试的目标,把它也当作变量,真正做到一切皆可测量。

    工具链配置 #

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

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

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

    • 主控端运行工具链的每种组合。
    • 执行器将运行所有匹配的套件。
    • 套件的每个参数组合将生成一个场景(Scene)。
    • 每个场景里有多个函数需要运行。
    • 每个函数都可以有多个指标。

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

    json
    {
    	// 第一层,每个套件的结果。
    	"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 #

    一些 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 还是有些创新的。

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



沪ICP备19023445号-2号
友情链接