面经标答: 由于跨语言工作的额外负担,JavaScript 中使用的原生解析器并不总是更快。避免跨语言开销以及使用多核心对于性能至关重要。
Rust 正在迅速成为 JavaScript 生态系统中的首选语言,因其性能和安全特性。然而,用 Rust 编写 JavaScript 工具却有着独特的挑战和难点,特别是在设计高效且可移植的插件系统方面。
"用 Rust 重写 JavaScript 工具,只对于不需要广泛外部贡献,以速度为导向的项目才是有利的。" -- Nicholas C. Zakas,ESLint 创建者
由于学习曲线陡峭,学习 Rust 可能令人生畏,而且在不同平台上分发编译后的二进制文件也不是直截了当的。 基于 Rust 的插件需要静态编译所有插件,或者为动态加载精心设计的应用程序二进制接口(ABI)。 然而,这些考虑超出了本文的范围。我们将专注于如何为 JavaScript 插件编写提供强大的工具。
JavaScript 工具的一大关键组成部分是将源代码解析为抽象语法树(AST)。插件通常检查和操作 AST 以转换源代码。因此,仅在 Rust 中解析是不够的;我们还必须让JavaScript 可以访问 AST。
本文将对几个在 JavaScript、Rust 和 C 中实现的流行 TypeScript 解析器进行基准测试。
解析器选择
虽然有许多 JavaScript 解析器可用,但我们专注于本次基准测试的 TypeScript 解析器。现代打包器必须开箱即用地支持 TypeScript,而 TypeScript 是 JavaScript 的超集。基准测试 TypeScript 是模拟真实世界打包器工作负载的明智选择。
这次评估的解析器包括:
- Babel:Babel 解析器(以前叫 Babylon)是 Babel 编译器中使用的 JavaScript 解析器。
- TypeScript:TypeScript 团队的官方解析器实现。
- Tree-sitter :一个增量解析库,可以为源文件构建和更新具体语法树,旨在足够快地解析任何编程语言以用于文本编辑器。
- ast-grep :一个基于抽象语法树的代码结构搜索、lint 和重写的 CLI 工具。我们在这里使用其 napi 绑定。
- swc:一个用 Rust 编写的超快速 TypeScript/JavaScript 编译器,专注于性能并作为 Rust 和 JavaScript 用户的库。
- oxc:氧化编译器是一套高性能的 JS/TS 工具,声称拥有用 Rust 编写的最快且最符合标准的解析器。
Node-API性能特性
在深入基准测试之前,让我们先回顾一下基于 Node-API 的原生插件的性能特征。
Node-API 优点:
- 更好的编译器优化: 原生语言中的代码具有紧凑的数据布局,产生更少的 CPU 指令。
- 无垃圾收集器运行时开销: 这让性能更可预测。
然而,Node-API 并非万能药。
Node-API 缺点:
- 原生函数(FFI)调用开销: 不同编程语言之间的FFI接口的成本。
- 序列化开销: 序列化和反序列化 Rust 数据结构可能非常耗费资源。
- 编码开销: 将 JS 字符串中的 utf-16 转换为 Rust 的 utf-8 字符串可能会引入显著延迟。
我们需要了解使用Node-API的利弊,以便设计出更有见地的基准测试。
基准测试设计
我们考虑了两个主要因素:
- 文件大小: 不同的文件大小揭示了不同的性能特点。因为基于 N-API 的解析器的解析时间由实际解析和跨语言开销组成。解析时间与文件大小成正比,但跨语言开销的增长取决于解析器的实现,并不一成不变。
- 并发级别: 在 JavaScript 的单一主线程中不可能进行并行解析。然而,基于 N-API 的解析器可以在单独的线程中运行,无论是使用 libuv 的线程池还是它们自己的线程模型。话虽如此,开启新线程也会带来开销。
我们在这篇文章中不考虑这些因素:
- VM预热和 JIT: 笔者在预热和非预热运行之间没有观察到显著差异。
- 垃圾回收和内存使用: 在这个基准测试中没有评估。
- Node.js CLI 参数: 为了使基准测试具有代表性,使用了默认的 Node.js 参数,尽管调优可能会提高性能。
基准测试设置
Benchmark的代码可以在这里找到。
测试环境
基准测试在以下规格的系统上执行:
- 操作系统: macOS 12.6
- 处理器: arm64 Apple M1
- 内存 : 16.00 GB
- 基准测试工具 : Benny
文件大小
为了评估解析器在各种代码库中的性能,我们将文件大小分为以下几类:
- 单行文件: 一个最小的 TypeScript 片段,
let a = 123;
,用于测量基线开销。 - 小文件: 一个简洁的 24 行 TypeScript 模块,代表一个常见的实用程序文件。
- 中等文件 : 一个典型的 400 行 TypeScript 文件,反映了日常开发工作负载。
- 大文件: 来自 TypeScript 仓库的庞大的 2.79MB
checker.ts
,挑战解析器处理复杂且庞大的代码库。
并发级别
在这个基准测试中,我们通过同时解析五个文件来模拟现实工作负载。这个数字是一个任意但合理的代理,代表实际的 JavaScript 工具。
值得注意的是,对于经验丰富的 Node.js 开发者来说,会意识到参数设置会影响异步解析性能。然而,这里的设置不会太过偏向于 Rust 解析器。背后的理由留给读者留作思考题。:)
这篇文章旨在提供 TypeScript 解析器基准测试的总体概述,重点关注基于 N-API 解决方案的性能特点和所涉及的权衡。你可以随意调整基准测试设置来模拟自己遇到的工作负载。
现在,让我们深入了解 TypeScript 解析器基准测试的结果!
结果
同步解析
每个解析器的性能以每秒操作数进行量化------这是 Benny 基准测试框架提供的一个指标。为了便于比较,我们对结果进行了标准化:
- 最快的解析器被指定为基准,设置为 100% 效率。
- 其他解析器相对于这个基准进行评估,其性能表现为基准速度的百分比。
TypeScript 在所有文件大小中始终优于竞争对手,速度是 Babel 的两倍。 由于 FFI 开销的相对影响减少,本地语言解析器在处理较大文件时表现出改善的性能。 然而,由于与输入文件大小成比例的序列化和反序列化 (serde) 开销,性能提升并不明显。
异步解析
在异步解析场景中,我们观察到以下情况:
ast-grep 在同时处理多个中等到大型文件时表现出色,有效地利用了多核能力。然而,TypeScript 和 Tree-sitter 在处理较大文件时性能下降。SWC 和 Oxc 保持一致的性能,表明有效利用了多核处理。
解析时间分解
我们需要了解到,Node-API 程序不仅会在执行 Rust 代码上花费时间,还有将一切联系在一起的 Node.js 粘合代码也需要花费时间。这点对解释基准测试结果是至关重要的。解析时间可以分解为三个主要组成部分:
ini
time = ffi_time + parse_time + serde_time
以下是对每个术语的更详细的介绍:
ffi_time
(外部函数接口时间): 这代表了在不同编程语言之间调用函数的开销。通常,ffi_time
是一个固定成本,无论输入文件大小如何都保持不变。parse_time
(解析时间): 解析器分析源代码并生成抽象语法树 (AST) 所需的核心持续时间。parse_time
随输入大小的增加而变化,使其成为解析过程中的一个可变成本。serde_time
(序列化/反序列化时间): 将 Rust 数据结构序列化为与 JavaScript 兼容的格式所需的时间,反之亦然。与parse_time
一样,serde_time
随着输入文件大小的增长而增加。
本质上,对解析器进行基准测试涉及测量实际解析 (parse_time
) 的时间,并考虑来自跨语言函数调用 (ffi_time
) 和数据格式转换 (serde_time
) 的额外开销。理解这些元素有助于我们评估所讨论的解析器的性能。
结果解释
本节基于上述解析时间框架,提供了对基准测试结果的详细和技术分析。寻求高层次概述的读者可能更愿意跳过到总结部分。
FFI 开销
在同步解析和异步解析场景中,"一行" 测试用例,即以 FFI 开销为主,解析或序列化最小,显示了 TypeScript 的卓越性能。令人惊讶的是,Babel 本该在此场景中表现出色却性能稀烂,却显示出它自己特有的开销。
随着文件大小的增加,FFI 开销变得不那么重要,因为它在很大程度上是独立于大小的。例如,ast-grep 在大文件的相对速度为 78%,而在单行的相对速度为 72%,表明在同步解析中大约有 6% 的 FFI 开销。
在异步解析中,FFI 开销更为明显。ast-grep 的性能从同步解析的 72% 下降到异步解析的 60%,当比较同步解析和异步解析时。swc/oxc 没有显著性能差异的缺失可能是由于它们独特的实现细节。
序列化开销 不幸的是,我们未能复制在其他应用程序中见证的 swc/oxc 的惊人性能。 尽管在"大文件"测试用例中 FFI 影响最小,swc 和 oxc 的性能仍然不如 TypeScript 编译器。这可以归因于它们依赖于调用 JSON.parse
来解析从 Rust 返回的字符串。更难堪的是,JSON解析比直接数据结构返回更快。
Tree-sitter 和 ast-grep 通过返回一个树对象而不是完整的 AST 结构来避免 serde 开销。访问树节点需要从 JavaScript 调用 Rust 方法,这将成本分散在读取AST的调用。
并行解析
除了 tree-sitter,所有原生 TS 解析器都支持并行。与 JS 解析器相反,本地解析器在同时解析较大文件时性能不会下降。这要归功于多核的力量。JS 解析器因为必须逐个解析文件而受到 CPU 限制。
解析器性能总结
下表总结了每个解析器的性能,概述了不同操作的时间复杂度。
在表中,constant
表示不随输入大小变化的恒定时间成本,而 proportional
表示随输入大小成比例增长的可变成本。N/A
表示该成本不适用。
基于 JS 的解析器完全在 JavaScript 环境中运行,从而避免了任何 FFI 或 serde 开销。它们的性能完全取决于解析时间,这随输入文件的大小而变化。
基于 Rust 的解析器的性能受到固定 FFI 开销和随输入大小增长的解析时间的影响。然而,它们的 serde 开销根据实现的不同而有所不同:
对于 ast-grep 和 tree-sitter,它们有一个树对象的固定序列化成本,无论输入大小如何。 对于 swc 和 oxc,序列化和反序列化成本随输入大小线性增加,影响总体性能。
讨论
转换与解析
尽管基于 Rust 的工具以转译代码的速度而闻名,但在Rust转换为JS可用的AST这个任务上,我们的基准测试讲了一个截然不同的故事。 这种差异突出了 Rust 工具作者的一个关键考虑:将 Rust 数据结构传递给 JavaScript 的过程是一个复杂的任务,可能会显著影响性能。 优化这种数据交换对于维持 Rust 工具预期的高效率至关重要。
解析器包含标准 (Criteria for Parser Inclusion)
在我们的基准测试中,我们专注于提供 JavaScript API 的解析器,这影响了我们的选择:
- Sucrase: 缺乏解析 API 和无法生成完整的 AST,这对我们的评估标准至关重要,因此被排除。
- Esbuild/Biome: 未包括,因为 esbuild 主要作为打包工具,而不是独立的解析器。它提供转换和构建能力,但不向 JavaScript 暴露 AST。同样,biome 作为没有 JavaScript API 的 CLI 应用程序,也没有纳入。
- Esprima: 由于缺乏对 TypeScript 的支持,这是现代 JavaScript 开发生态系统的一个关键要求,因此未被考虑在此基准测试中。
JS 解析器
Babel: Babel 分为两个主要包:@babel/core
和 @babel/parser
。值得注意的是,与 @babel/parser
相比,@babel/core
的性能更低。这是因为核心包中围绕解析器的额外入口和钩子代码。此外,Babel 核心中的 parseAsync
函数并不是真正的异步;它本质上是一个同步解析器方法包装在一个异步函数中。这个包装提供了额外的钩子,但由于 JavaScript 的单线程特性,它并没有提高 CPU 密集型任务的性能。事实上,管理异步任务的开销可能会进一步加重 @babel/core
的性能负担。
TypeScript:
TypeScript 的解析能力挑战了人们对 TypeScript 编译器 (TSC) 缓慢的普遍看法。基准测试结果表明,TSC 的主要瓶颈不在解析,而在随后的类型检查阶段。
原生解析器评审
SWC: 作为第一个 Rust 写的JavaScript解析器,SWC 采用最直接方法,通过序列化整个 AST 以供 JavaScript 使用。SWC提供了广泛的 JS API 而脱颖而出,使其成为那些寻求基于 Rust 的工具解决方案的人的首选。尽管存在一些固有的开销,但 SWC 的稳健性和开创性地位继续使其成为首选选项。
Oxc: Oxc 是目前最快解析器的有力竞争者,但其性能受到序列化和反序列化(serde)开销的影响。我们的基准测试中包含了 JSON 解析,反映了现实世界的使用情况,尽管省略这一步可能会显著提高 Oxc 的速度。
Tree-sitter Tree-sitter 是一种通用解析器,适用于多种语言,没有专门针对 TypeScript 进行优化。因此,其性能与 Babel 的性能比较接近。Babel 是一个专注于 JavaScript 的解析器,用 JavaScript 实现。唉,即使没有任何 N-API 开销,Rust 解析器也不是默认就更快的。一个通用的 Rust 解析器可能不会比精心制作的 JavaScript 解析器更胜一筹。
ast-grep
ast-grep 由 tree-sitter 提供支持。其性能略快于 tree-sitter,表明 napi.rs 是比手动使用 C++ nan.h 更快的绑定。我无法判断性能提升是来自 napi 还是 napi.rs,但基于 tree-sitter 的 ast-grep 获得了略微更好的性能,表明 napi.rs 了比传统 C++ nan.h 方法的binding更好。虽然这种性能提升的确切来源尚不清楚,但结果证明了napi.rs的高效性。(aka 我也想变得和太狼一样强)
原生解析器性能技巧
tree-sitter & ast-grep 的优势
这两个解析器在解析后返回一个 Rust 对象包装器给 Node.js,设法绕过了 serde 成本。这种策略虽然高效,但可能导致在 JavaScript 中访问 AST 变慢。也就是说,序列化的成本在读取AST的时候被分摊了。
ast-grep 的异步优势:
ast-grep 在并发解析场景中的性能主要归功于利用多了个 libuv 线程。默认情况下,libuv 线程池大小设置为四,但通过扩大线程池大小,从而充分利用可用的 CPU 核心,有潜力进一步提高性能。
未来展望
展望未来,有几个有前途的途径可以进一步提炼 TypeScript 解析器的性能:
- 最小化 Serde 开销: 通过优化序列化和反序列化过程,例如使用 Rust 对象包装器,我们可以减少这些操作的性能损耗。
- 利用多核能力: 有效利用多核架构可以显著提高解析速度,从而提升工具的性能。
- 促进 AST 可重用性: 促进在 JavaScript 中重用抽象语法树可以减少昂贵的解析操作的频率。
- 将工作负载转移到 Rust: 创建一个专门为 AST 节点查询定制的领域特定语言 (DSL) 可以将更大部分的计算工作转移到 Rust 方面,提高整体效率。
这些潜在改进都预示了 Rust 工具在解析性能方面,还会有突破界限的新机会。
希望这篇文章对您有帮助!家人们UU们集美们如果喜欢这篇文章,可以给 ast-grep点个小星星!