Rspack 是一个基于 Rust 的高性能构建引擎,它可以与 Webpack 生态系统交互,并提供更好的构建性能。
在处理具有复杂构建配置的巨石应用时,Rspack 可以提供 5~10 倍的编译性能提升。
字节跳动将 Rspack 开源后,它在 GitHub 上已有 4700+ star。
在 2023 年 5 月 28 日 举行的「GOTC 全球开源技术峰会 - Rust 论坛」上,字节跳动前端工程师何相君介绍了 Rspack 这款新一代的前端构建工具,今天我们就为大家介绍这次分享的内容。
内容纲要:
-
Rspack 简介
-
前端工具链 native 化的技术选型
-
遇到问题解决方案
-
Rspack 性能收益
-
对 Rspack 的未来展望
Rspack 简介及技术架构
近几年 Web 应用规模变得越来越大,一个中大型项目,可能有几万个模块,使用 Webpack 进行打包的话可能需要 5~10 分钟。
尽管近几年有一些构建工具解决了 Webpack 构建速度慢的问题,比如 esbuild 和 vite,但是依然无法功能性上完全代替 Webpack。
在这样背景下,我们决定使用 Rust 重新移植 Webpack,在尽可能不降低 Webpack 灵活性与丰富的功能的同时,尽可能的提高构建性能。
简单介绍一下 Rspack 的架构。Rspack 的架构和 Webpack 比较类似,对很多阶段做了多线程的并行加速。
主要可以分两块,第一个阶段是 make 阶段,主要分析项目依赖,然后生成一个模块依赖图;第二个阶段 seal 阶段,主要是做代码产物优化以及最终产物生成。
产物优化主要包括 tree-shaking 和 bundle-splitting, code-splitting 以及 minify。
tree-shaking 使用类似垃圾回收 mark-sweep 算法,遍历所有可能被执行的代码,将所有不会被执行的代码删除。
code-splitting 通过重新将模块进行组合,使用一些策略将其分割生成若干 chunk,最终达到更快速的浏览器加载,更高的 CDN 缓存命中率。
技术选型
那么,我们是如何为 Rspack 做技术选型的呢?
我们的目标,或者说现在大部分市面上的 native 化的工具,目标可能都只有两点:一是和目标移植工具的Javascript API 保持兼容,二是尽可能提高构建速度。
对目标语言生态做简单的调研后,我们留下了 3 个可选项:
-
Rust
-
Javascript(Node.js)
-
Golang
为什么不用 JavaScript(Node.js) ?
使用Node.js我们不用担心 API 兼容的问题,但是Node.js 单线程优化的潜力不大,所以尝试使用Node.js 提供的多线程能力提高性能。
我们在实际使用 Node.js 做多线程编程的时候发现有些问题, Node.js 虽然提供了 worker-thread 来提供多线程,但由于它是通过创建新的 V8 实例来模拟多线程,这些 V8 实例是没有办法共享内存的。
如果你想做线程间通信,只能用消息传递。但 worker-thread 消息传递有个问题,所有的消息都需要结构性拷贝,也就是深拷贝,没有办法像 Rust 中,直接将对象移动到另一个线程,这一定程度上增加了通信的开销。
第二个是它的并发编程的生态比较差,它没有像 Rust 社区提供丰富的底层数据结构以及并发原语,比如没有现成的无锁的并发数据结构,只支持几种基本的原子类型等等。
为了给大家更直观的感受,做了一个比较简单的 Benchmark。
简单的多线程基准测试:使用多线程解决一个生产消费者问题
结果:
为什么不用 Golang ?
Golang 本身在性能方面是足够优秀的,但出于以下两个原因我们没有选择它。
-
由于语言定位和本身生态原因,Golang 对 napi 支持不好。
为什么 napi 对我们这么重要?
因为 Webpack 的插件 API 是非常灵活的,除了字面量和对象类型,它也支持传递函数来做运行时动态配置。
虽然使用传统的 IPC 也可以模拟函数调用,但我们需要在 native 侧调用一个 Javascript 的函数时,把参数先序列化,通过 IPC 传递到 Javascript,然后 Javascript 这边再进行反序列化,最后执行 Javascript 函数再将返回值传输回 native 侧,一次函数调用需要两次跨进程通信。
函数调用次数有可能和模块的数量成正比,当模块数量比较大的时候这些额外消耗就变得无法忽略了。napi 可以将函数指针传递到 native 侧从而降低一些进程间通信的消耗。
-
Golang 自身的前端工具链生态不够成熟和繁荣。
Golang社区提供构建一个前端构建工具的基础设施,比如 Javascript passer、CSS passer,同时也可以做一些简单的分析,但不支持将 ES6 转译到 ES5。我们不得不再找一些其他 transpiler 来做这件事,这无疑又会增加额外消耗 (两次 transpile会严重影响性能)。
为什么转译到低版本的 ES5 对我们很重要?
因为国内平均浏览器版本并不是很高,为了支持一些低版本的用户,我们必须要把代码转译到 ES5。
为什么用 Rust ?
使用 Rust 的理由就比较简单了,因为前面的问题它都没有(这里不是说rust 是完美的,只是在当前场景下没有前两种选型的问题)。
-
Rust 性能很好,和 C/CPP 一个级别。
-
napi 支持良好,降低了我们在兼容 webpack 复杂 API 时的心智负担,除此之外,因为有宏的支持,我们可以少写很多样板代码。
-
Rust 作为 WASM 的一等公民,WASM 特性支持比较好,对新特性跟进的速度也比较快,更方便我们将现有工具迁移到 web。
-
Rust 生态中的 SWC 提供丰富的 AST 修改 API, 且提供转译到低版本 ES5的支持。
小结
现阶段如果你想通过移植来提高前端工具速度的话,Rust 绝对是非常值得一试。原因如下:
-
如果你需要支持在Web端体验该工具,Rust 对于 WebAssembly(WASM)有非常出色的支持。 结合 wasm-pack ,你可以以较小的成本将工具迁移到Web平台。
-
由于 Rust社区(napi-rs) 对 napi 有成熟的支持,你可以较轻松地做复杂的 JavaScript API 兼容。
-
在过去几年中,Rust 社区涌现了许多面向新手友好的教程,使得入门门槛大大降低了。
-
R ust 社区有很多现成的前端工具移植case可以借鉴,相较其他语言的前端工具生态更加繁荣。
性能收益
在我们的实验中,Rspack 的耗时比 Webpack 显著缩短。
在 production 模式中从 146 秒缩到 16 秒,耗时缩短将近九成。
在 development 模式中,耗时缩短 87%。
遇到的问题 & 解决方案
下面我们针对两个问题来介绍一些性能优化的技巧。
多线程优化(举解决 SWC 并发解析性能差的例子)
-
Development 模式下不会做过多的优化,parsing 是阶段的主要瓶颈
-
通过 profiler 发现 parsing 的时候有大量锁的系统调用
-
最后发现是 swc 使用了一个 string-intern 库 string-cache 导致的
简单介绍 string cache
在许多编程语言中,字符串常量(literal)通常是不可变的,这意味着如果在程序中使用相同的字符串常量多次,每个实例都会在内存中创建一个新的对象。这样做会占用大量内存,并可能降低程序的性能。
为了避免这个问题,一些编程语言提供了字符串池(string pool)或字符串缓存(string cache)机制。字符串池是一个存储字符串常量的地方,它会在程序运行时自动维护,并且保证每个字符串常量只有一个实例。这样,如果在程序中使用相同的字符串常量多次,每个实例都会指向池中的同一对象,从而节省内存并提高程序性能。
简单介绍下 string-cache
String cache 的性能瓶颈
使用 Mutux 将整个 string intern 的insert 操作加锁,导致多线程场景下任意时刻只有一个线程可以进行 string intern,其他线程只能等待,这也就是为什么前面 parsing 过程会有很多锁的系统调用。
性能优化方法 :将 insert 级别的大锁移动到 bucket 上,这样只有命中相同的桶序号的两个 string 会互斥,不同bucket index 的 string 在 intern 的时候可以并行。
string-cache 优化结果对比:
Development 模式有 41% 的提升,production 模式有 4% 的提升。
总结
核心, 降低锁的使用, 最大化 CPU 利用率, 以下是一些常见的策略:
- 使用无锁的数据结构 (crossbeam等)
- rayon (
iter
->par\_iter
)。拆分 mutable 和 immutable 代码,尽可能使用 rayon 去并行你的代码。 - 降低锁的粒度, 减少不必要的临界区
算法的优化:不慎引入O(n^2)算法导致性能问题
背景
业务方反馈,开启 source-map 和不开启在生产环境有很大性能差异。
性能优化之前,需要挑选一个趁手的 profile 工具:
-
Instruments
-
Samply
-
tracing (tokio tracing + perfetto / chrome-tracing)
-
Perf
-
flamegraph
使用 samply 生成 profile:
String 在 Rust 中的存储形式:
以"好rspac"为例:
-
获取到"好"byte offset
-
"你".len_utf8()=3
-
获取到"k"byte offset
-
['你','好','r','s','p','a','c'l.iter().map(|ch| ch.len\_utf8()).sum()
= 11 -
使用 range
3..11
获取 string slice
简单介绍 substring::Substring
`1fn substring(&self, start_index: usize, end_index: usize) -> &str { 2 if end_index <= start_index { 3 return ""; 4 } 5 6 let mut indices = self.char_indices(); 7 8 let obtain_index = |(index, _char)| index; 9 let str_len = self.len();1011 unsafe {12 // SAFETY: Since `indices` iterates over the `CharIndices` of `self`, we can guarantee13 // that the indices obtained from it will always be within the bounds of `self` and they14 // will always lie on UTF-8 sequence boundaries.15 self.slice_unchecked(16 indices.nth(start_index).map_or(str_len, &obtain_index),17 indices18 .nth(end_index - start_index - 1)19 .map_or(str_len, &obtain_index),20 ) 21 } 22}`
性能瓶颈产生的原因:
1. substring
被调用的是次数和
mapping
数量成正比
-
通常
mapping
数量比较,所以在大部分情况下,该实现没有性能问题 -
在 minify 场景下,因为除了第一行的代码,剩余代码的位置都讲发生变化,
mapping
数量级与压缩产物大小只差常数倍数(在计算时间复杂度的时候会被忽略) -
该过程的时间复杂度约为
O(n^2)
,n为压缩后产物大小
性能优化:
使用前缀和数组提前计算好每一个 char offset 到 byte offset映射关系。
看一下优化收益:
针对不同大小的产物有 30%~1000% 的提升。
总结:
-
好的profile工具能让你事半功倍
-
任何时间复杂度的算法在的数据规模小的时候差距都不大
-
做算法时间复杂度分析时不能只统计可见的代码,需要统计函数数以及库
未来展望
对于 Rspack,我们未来计划做三个方面的工作:
-
使用 io-uring 加速 IO 部分
-
借鉴 salsa-rs 进一步优化增量构建性能
-
探索使用原生语言写高性能插件
以上就是今天的分享,谢谢大家。
GitHub:
https://github.com/web-infra-dev/rspack
官网: