提速 Rust 编译器!
Nethercote 是一位研究 Rust 编译器的软件工程师。最近,他正在探索如何提升 Rust 编译器的性能,在他的博客文章中介绍了 Rust 编译器是如何将代码分割成代码生成单元(CGU)的以及 rustc 的性能加速。
他解释了不同数量和大小的 CGU 之间的权衡以及 Rustc 是如何使用 LLVM 并行化代码生成和优化的。此外,Nethercote 还探索了一些形成和排序 CGU 的替代方法,并报告了他的实验结果。
Nethercote 发现,很多时候,无法在编译速度、内存占用、编译体积和质量上都实现提升,一个指标的提升,经常伴随另一个性能指标的下降。尽管他没有发现比现有方法更明显的改进,但还是希望在未来继续研究这个问题。
如何提升 Rust 编译器速度?这篇文章或许能帮助到你!
1、LLVM:Rust 编译加速的秘诀
Rust 的 MIR 是 HIR 到 LLVM IR 的中间产物,将 MIR 转换为 LLVM IR,然后将其传递给 LLVM,从而生成机器代码。在此过程中,LLVM 能通过处理多个模块实现并行。Rustc 使用 LLVM 加速 Rust 的编译。我们称其中的每个模块为“代码生成单元(CGU)”。
图:时间位于 x 轴上,每条水平线代表一个线程。主线程显示在顶部,标有 PID。它在开始时处于活动状态,时间足以产生另一个标记为 的线程 rustc。rustc 底部显示的线程在大部分执行过程中都处于活动状态。还有 16 个 LLVM 线程标记 opt cgu.00 为 到 opt cgu.15,每个线程都会在短时间内处于活动状态。
CGU 实际上是如何形成的呢?粗略地说,Rust 程序由许多函数组成,这些函数形成一个有向图,其中从一个函数到另一个函数的调用构成了一条边。我们需要将这个图分割成块(CGU),这是一个图分区问题。
我们希望创建大小大致相等的 CGU(因此 LLVM 处理它们所需的时间长度大致相同),并最大限度地减少它们之间的边数(因为这使 LLVM 的工作更轻松,并带来更好的代码质量)。
实际上,由于我们上面看到的阶梯效应,我们不希望 CGU 的大小完全相同。理想的情况是 CGU 大小存在与梯度相匹配的轻微梯度。这样,所有 CGU 将完全相同地完成处理,以实现最大程度的并行化。
Nethercote 认为在合并之前“调整”CGU 可能会有所帮助,在某些情况下将函数从一个 CGU 移动到另一个。例如,如果在 CGU A 中被调用 f 的叶函数(即不调用任何其他函数的叶函数)在 CGU B 中有一个调用方 g,那么将 f 从 A 移动到 B 是有意义的,从而去除 CGU 间的边。(还有其他类似的情况涉及非叶函数,移动也有意义)。我实现了这一点,它给出了一些适度的改进,但我目前还没有决定它是否值得额外的复杂性。
在实现这一点的同时,我还花了一些时间来可视化调用图。我从 GraphViz 开始。这些图表对于非常小的程序来说看起来不错,但对于较大的程序来说,它们很快就变得无法读取和导航。我在 Mastodon 上抱怨过这一点,并得到了使用 d2 的建议,d2 速度较慢,但图形可读性更强。
2、后端并行方法的软肋
图划分是一个 NP 难题。有几种常见的算法,实现起来相当复杂。相反,rustc 做了一些更简单的事情。首先简单地为每个 Rust 模块创建一个 CGU:模块中的每个函数都放入同一个 CGU 中。然后,如果 CGU 数量超过限制(默认情况下,非增量构建为 16 个,增量构建为 256 个),它会重复合并两个最小的 CGU,直到达到限制。这种方法简单、快速,并以有用的方式利用特定领域的知识——程序模块往往提供良好的自然边界。
所有这一切都依赖于测量 CGU 大小的方法。目前使用 CGU 中的 MIR 语句的数量来估计 LLVM 处理 CGU 需要多长时间。这里有很大的设计空间,有许多其他可能的形成和规划 CGU 的方法。
这种转换对 Rust 众多语法糖进行了脱糖,并且极大精简了 Rust 的语法(但并非其语法子集),是观察和分析 Rust 代码的常用手段,尤其是在控制流图和借用检查等方面。
在这篇文章的最后,Nethercote 提供了几个数据集的链接,每个数据集都记录了编译 rust -performance 基准时每个 CGU 的测量值。这些数据集包括许多测量静态代码大小的输入(独立变量),例如,函数数量和 MIR 数量等。
Nethercote 试着用 scikit-learn 做一些基本的分析。并且,通过这些基本的分析,能让 Nethercote 仔细推敲到底应该搜集哪些测量值。
通过一系列的改进优化,他获得的最终数据集比刚开始时的数据更准确。但是,并没有通过这些数据获得多少实际的结果。实际上,每次我对测量的内容改变后都会得到完全不同的结果。
3、实现更快的 Lexer
词法分析(lexical analysis)是编译器的第一个阶段,实现词法分析的代码称为 lexer。
有人最近研究了 logos(https://github.com/maciejhirsz/logos)这个在 rust 中广受欢迎的 lexer。
此前,logos 声称其目标是能比手动实现的 lexer 更快,作者提出了质疑,因为在他看来,通用性和性能无法兼得。因此,他一步步实现了 lexer,探索了多种优化技巧,并与 logos 进行了多轮性能对比。
最终的结果表明,手动实现的基于状态机的 lexer 比 logos 实现了 20%左右的性能提升。
4、从错误中学习:使用 Rust 实现 DLL 注入
Rust 是一种注重安全性的编程语言,但在某些情况下,开发人员可能需要使用 unsafe 关键字来执行某些操作。unsafe 可以提供更高的性能,但可能会牺牲安全性。因此,开发人员在使用时需要非常小心。几个使用 unsafe 的常见场景包括:访问裸指针、调用外部 C 函数等,并提供了一些建议和最佳实践,以确保在使用 unsafe 时不会引入潜在的安全隐患。
举个应用方面的例子:原来,作者一直在用 C++编写逆向工具,但是,C++这门语言并不友好,于是研究了下如何使用 Rust 实现 DLL 注入的“工具”。
大致原理就是让 Rust 首先生成一个 C 样式的 DLL,然后,使用 unsafe 操作裸指针,操作程序内存,最后实现 DLL 注入就可以了。
5、期待更准确的估计函数
Nethercote 希望具有数据分析专业知识的人可以做得更好,重点关注以下几个方面:
1)更匹配的估计函数
2)想要使编译器比现在更快,一个更好的估计函数也许不会达到预期的效果。我提出了一些更好的统计方法,但并没有提升编译速度,甚至变差。
3)CGU 调度效果不可预测,你不能假设一个估计函数好几个百分点就会使编译器更快。话虽如此,我希望改进力度足够大,能够转化为实际的加速。
4)对于估计函数来说,最好高估 CGU 编译所需的时间,而不是低估。
5)我很担心过度拟合。数据集来自一台机器,但实际上,rustc 会运行在不同的机器上,具有各种各样的体系结构和微体系结构。
6)这些数据集来自单一版本的 rustc,使用单一版本的 LLVM。我担心随着时间的推移准确性可能会漂移。
7)我更喜欢不太复杂且易于理解的估计函数。当前的函数非常简单,在大多数情况下只是增加了基本模块和语句的数量。例如:0 大小的 CGU 应该别估计为花费非常接近于 0 的时间。
8)估计函数有一个明确的问题,即如果不考虑其内部公式,计算 MIR 语句可能非常不准确。特别是,单个 MIR 语句可能变得很长。举个例子:深度向量压力测试的 MIR 包含一条语句,该语句定义了包含超过 100,000 个元素的向量字面量。不出所料,当前的估计函数严重低估了编译这个基准所需的时间。
Nethercote 最后提醒:希望以上的请求是合理的!
以下是上文提到的数据集:
调试构建,主要基准测试
https://nnethercote.github.io/aux/2023/07/25/Debug-Primary.txt
选择构建,主要基准
https://nnethercote.github.io/aux/2023/07/25/Opt-Primary.txt
调试构建,二级基准测试
https://nnethercote.github.io/aux/2023/07/25/Debug-Secondary.txt
选择构建,二级基准
https://nnethercote.github.io/aux/2023/07/25/Opt-Secondary.txt
顺便说一句:在这些数据集中,主要基准测试比次要基准测试更重要,次要基准测试包括压力测试、微基准测试和其它不符合实际的代码。
参考资料:
1.https://nnethercote.github.io/2023/07/25/how-to-speed-up-the-rust-compiler-data-analysis-assistance-requested.html
2.https://geo-ant.github.io/blog/2023/unsafe-rust-exploration/
3.https://nnethercote.github.io/2023/07/11/back-end-parallelism-in-the-rust-compiler.html
版权声明: 本文为 InfoQ 作者【这我可不懂】的原创文章。
原文链接:【http://xie.infoq.cn/article/2c67f40903cb1ba79aecc0777】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论