C++、Rust 编译到底谁快?我用 1.7 万行代码试了试
2024-06-21 09:47 阅读(46)

众所周知,C++ 的编译速度时常被诟病。编程圈子有一个著名的梗:“代码正在编译”,这个梗就来自 C++。

图片

像 Google Chromium 这样的项目在最新硬件上也需要一个小时才能构建完成,在旧硬件上则需要长达六个小时。文档里记载了数不清的加速编译的技巧,还有许多很容易出错的捷径,用来减少每次编译的代码量。即使使用数千美元的云计算,Chromium 的构建时间也需要几十分钟。我完全无法接受这一点。这样如何能正常工作?

巧合的是,Rust 也有类似传言:编译时间是个大问题。但这真的是 Rust 的问题,还只是黑 Rust 的谣言?跟 C++ 的编译时间相比,Rust 又如何呢?

我很关心编译速度和运行时性能。更快的构建测试循环可以提高生产力,而且可以让编程更快乐,那么我就能让软件的运行速度更快,客户也能更开心。所以,我决定亲眼看看 C++ 和 Rust 是否真的像他们说的那么差。我的计划如下:

  1. 找一个开源 C++ 项目;
  2. 将项目的某一部分单独分离出来变成一个小项目;
  3. 用 Rust 逐行重写 C++ 代码;
  4. 优化 C++ 项目和 Rust 项目的编译时间;
  5. 比较两个项目的编译和测试时间。

我的假设是(猜测,不是结论):

  1. Rust 的代码行数比 C++ 略少。因为C++中大多数函数和方法都需要定义两次(头文件中一次,实现中一次)。Rust 则不需要这样做,因此代码行数就会减少。
  2. 需要进行完整编译时,C++ 比 Rust 需要更多时间(即 Rust 胜出)。这是因为 C++ 的 #include 和模板需要在每个 .cpp 中进行编译。虽然可以并行进行,但并行并不完美。
  3. 对于增量构建,Rust 的编译时间比 C++ 多(即 C++ 胜出)。这是因为 Rust 一次编译一个 crate,而不像 C++ 那样一次编译一个文件,所以即使只有很小的变化,Rust 也要重新编译更多的代码。

你认为结果如何?为此我进行了一项调查:42% 的人认为 C++ 会获胜,35% 的人认为需要具体分析,17% 的人认为 Rust 会获胜。

下面,实验开始!


一、寻找 C++ 和 Rust 的实验对象


寻找项目

如果需要花一个月来移植代码,我要移植哪个呢?我的挑选条件如下:

最后的选择很简单:选我前几年写过的项目!我将移植之前在 quick-lint-js(https://quick-lint-js.com/blog/cpp-vs-rust-build-times/#:~:text=quick%2Dlint%2Djs%20project)项目中编写的 JavaScript 词法分析器。

修剪 C++ 代码

quick-link-js 的 C++ 部分包含大约 10 万行代码。我不会把这么多代码全都移植到 Rust,否则要花费一年时间!所以只选了 JavaScript 词法分析器部分。这需要涉及项目中的其他部分:

不幸的是,这个子集并不包含任何并发或 I/O 代码。也就是说,我没办法测试 Rust 的 async/await 在编译时间上的额外开销。不过在 quick-lint-js 中这种代码并不多,所以不是什么大问题。

首先,我复制了所有 C++ 代码,然后删掉了与词法分析器无关的东西,如语法分析器和 LSP 服务器等,直到无法删除任何代码为止。整个过程中都要保证 C++ 测试通过。

将 quick-lint-js 的代码精简到词法分析器(以及它所需的任何其他代码)之后,得到了大约 1.7 万行 C++ 代码:

图片

重写

如何重写数千行 C++ 代码呢?只能一次重写一个文件。下面是具体的过程:

  1. 从某个模块着手;
  2. 复制代码和测试,用查找替换的方法修正某些语法,然后不断运行 cargo test,直到编译通过、测试通过;
  3. 如果需要先转换其他模块,则返回第二步对其进行转换,然后再回到该模块;
  4. 如果还有模块尚未转换,则返回第一步。

Rust 和 C++ 项目有一个主要区别可能会影响编译时间。在 C++ 项目中,诊断系统中包含许多代码生成、宏和 constexpr。而在 Rust 移植中,我采用了代码生成、proc 宏、普通的宏,还有一些 const。我听说 proc 宏很慢的原因只是它们很难写好。我希望我的 proc 宏写得还不错。

最后的 Rust 项目要比 C++ 项目略大一些。C++有 16,600 行代码,而 Rust 有 17,100 行。

图片

二、优化 Rust 的编译时间


我很在意编译时间。因此,我的 C++ 项目已经针对编译时间做了许多优化。我需要针对 Rust 项目进行类似的优化。

我们来尝试一下以下手段,以优化 Rust 项目的编译时间:

更快的连接器

第一步是对构建进行性能测试。首先通过 -Zself-profile 标志进行测试。在我的项目中,该标志会输出两个不同的文件。在其中一个文件中,run_linker 阶段的时间最长:

图片

我曾经将连接器换成 mold linker,成功地改善了 C++ 的编译时间。我们在 Rust 项目上试试看:

图片

很可惜,几乎看不到显著的改善。

上面是 Linux 的情况。macOS 也有另一个连接器:lld 和 zld。我们试试看:

图片

在 macOS 上,换成另一种连接器也没有任何显著的改善。可能是因为 Linux 和 macOS 的默认连接器对于我的小项目来说已经非常优秀了。进一步优化的连接器(Mold、lld、zld)可能在大型项目上表现更好。

Cranelift 后端

我们再来看看 -Zself-profile 性能测试。对于另一个文件来说,LLVM_module_­codegen_emit_obj  和  LLVM_passes 阶段时间最长:

图片

我听说,除了默认的 rustc 后端 LLVM 之外,还有一个名为 Cranelift 的后端。我用 rustc Cranelift 后端尝试编译了一下,-Zself-profile 的结果很令人振奋:

图片

但很可惜,使用 Cranelift 的实际编译时间甚至还不如 LLVM:
图片

编译器和连接器选项

编译器有许多开关,可以加速编译(或减缓编译)。我们来尝试一部分:

注意:quick, -Zshare-generics=y 相当于 quick, incremental=true 加上启用 -Zshare-generics=y 标志。其他条形图没有启用 -Zshare-generics=y,因为该选项仍不稳定(因此只能用仍在开发中的Rust编译器)。

大部分选项都有文档,但我没看到有人说过使用 -s 连接选项。-s 能删除调试信息,包括静态连接的 Rust 标准库中的调试信息。这就意味着连接器的工作量更少,从而能减少连接的时间。

工作区和测试布局

Rust 和 Cargo 对于文件的位置有一定的灵活性。该项目有三种合理的布局:

图片

理论上,如果将代码分割到多个 crate 中,Cargo 就能并行调用 rustc。由于我的 Linux 机器有一个 32 线程的 CPU,macOS 机器有一个 10 线程 CPU,所以感觉启用并行应该能降低构建时间。

对于给定的 crate,Rust 项目中也有多个地方可以放置测试用例:

图片

由于依赖循环,我没办法针对 tests 位于 src 内的布局进行测试。但我针对其他布局的各种组合进行了测试:

图片

工作区配置(不论是分离的测试可执行文件(即多个测试用的exe文件)或合并成一个测试可执行文件(只有一个测试用的exe))似乎效果最好。所以我们后文采用工作区、多个测试可执行文件的配置。

尽可能减少依赖特性

许多 crate 支持可选的特性。有时,可选特性是默认启用的。我们用 cargo tree 看看启用了哪些特性:

图片

libc crate 有一个特性名为 std。我们将其禁用并测试,看看构建时间是否有改善:

图片

构建时间并没有任何提高。也许std特性并没有什么有意义的工作?

cargo-nextest

cargo-nextest工具宣称“相较于cargo test,速度最多可以提高60%”。我的Rust代码中有44%都是测试,也许cargo-nextest有用。我们来试试并比较一下构建和测试的时间。

图片

在我的Linux机器上,cargo-nextest并没有改善,也没有变差。虽然输出结果漂亮了许多……

图片

在macOS上会怎样呢?

在我的MacBookPro上,cargo-nextest的确快了那么一点点。不知道为什么加速跟操作系统有关。也许实际上跟硬件有关?

采用通过PGO定制的工具链

对于C++构建来说,我发现通过PGO(profile-guided optimizations,根据性能测试进行的优化,有时也称FDO)编译出的C++编译器,在性能上有很大提升。我们针对Rust工具链尝试一下PGO,然后再尝试用LLVM BOLT优化rustc,以及-Ctarget-cpu=native。

图片

与C++编译器相比,似乎通过rustup发布的Rust工具链已经优化得很好了。PGO+BOLT带来的性能提升不到10%。但提升就是提升,所以接下来我们使用优化后的工具链与C++作比较。


三、优化C++构建


在原始的C++项目quick-lint-js上工作时,我已经使用常见的技术对其进行了优化,如PCH、禁用异常和RTTI、调整构建选项、删除无用的#include、将代码移出头文件、将模板实例化外置等。但C++有多种编译器和连接器。我们来比较一下它们,然后选择最好的一个跟Rust进行比较。

图片

在Linux上,GCC显然是个异类。Clang要快得多。而我自己构建的Clang(与Rust构建一样,采用了PGO和BOLT)比Ubuntu自带的Clang又有很大提升。libstdc++构建平均而言比libc++快一点点。我们采用自己构建的Clang和libstd++,代表C++与Rust进行比较。

图片

在macOS上,Xcode自带的Clang似乎比LLVM网站上提供的Clang工具链更好。我采用Xcode的Clang与Rust比较。

C++20模块

我的C++代码使用了#include。但C++20的import怎样呢?C++20的模块会让编译更快吗?

我在项目中尝试了C++20,但在做这项实验时(2022年), Linux上的CMake对于模块的支持仍处于早期试验阶段,就连基本的helloworld都不能正常工作,所以我只能用C++传统的#include。


四、C++和Rust的构建时间比较


我把C++项目移植到了Rust,并尽可能优化了Rust的构建时间。现在哪个编译器更快,C++还是Rust?

图片

在我的Linux机器上,Rust构建有时候比C++快,但有时慢,或者不相上下。在incremental lex测试中(该测试修改的文件最大),Clang比rustc更快。但对于其他增量测试,rustc领先。

图片

但是,在macOS上,结论完全不同。C++构建通常比Rust构建快得多。在incremental test-utf-8测试中(该测试修改的文件为中等大小),rustc编译得比Clang略快。但在所有其他增量测试以及完整构建中,Clang显然要快得多。

对于超过1.7万行的大项目

我只测试了1.7万行代码的小项目。对于大项目(比如10万行),构建时间如何呢?

为了测试C++和Rust编译器在大项目上的表现,我选择了最大的模块(词法分析器)并将其代码和测试用例复制了多个副本(8个、16个以及24个)。

由于我的性能测试也包括了运行测试的时间,所以我认为时间应该会线性增加。


图片

Rust和Clang的编译时间都是线性增长的,符合我的预期。

对于C++而言,头文件(incremental diag-types)的变化对构建时间的影响最大,这一点符合预期。在其他增量测试中,构建时间增长的幅度较小,这要归功于Mold连接器。

我对Rust感到失望,即使在incremental test-utf-8测试中,rust的表现也不尽如人意(该测试添加了一些不相关的文件,因此不应该会受到太大影响)。该测试使用了工作区、多个测试exe文件,这意味着test-utf-8应该有自己的可执行文件,应该是单独编译的。


五、结论


Rust的编译时间是问题吗?是。有许多技巧可以加快构建,但我没找到任何方法能够带来数量级上的提升。

Rust的构建时间是否和C++一样差?是。对于大型项目,开发的编译时间甚至比C++更差,至少对于我的编程风格是这样。

回顾一下我的假设,可以看到假设的所有方面都错了:

  1. Rust移植版本比C++版本的代码行数更多,而不是更少。
  2. 对于完整编译(1.7万行代码),C++消耗的时间基本上与Rust相同,甚至更少(10万+代码),而不会更多。
  3. 对于增量构建,Rust有时候消耗时间更短,有时更长(1.7万行代码),或者长得多(10万+代码),但也不一定。

说实话,我挺失望的。基于以上,我决定不再移植quick-lint-js的其余部分到Rust。但如果构建时间能显著改善,也许我会改变主意。