《架构师》2023年2月
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

案例研究|Case Study

我用Rust改写了自己的C++项目:这两个语言都很折磨人!

作者 Strager 译者 马可薇

C++漫长的构建时间可谓臭名昭著,编程圈的“我的代码在编译”只是个段子,但C++让这个段子长盛不衰。

谷歌Chromium规模的项目在新硬件上的构建时间长达一小时,而在老硬件上的构建时间更是达到了六个小时。虽然也有海量的调整方案能加速构建速度,还有不少削减构建内容但极易出错的捷径供人选择,再加上数千美元的云计算能力,Chromium的构建时间仍是接近十分钟。这点我完全无法接受,人们每天都是怎么干活的啊?

有人说Rust也是一样,构建时间同样令人头疼。但事实就是如此,还是这仅仅是一种反Rust的宣传手段?在构建时间方面Rust和C++究竟谁能更胜一筹呢?

构建速度和运行时性能对我来说非常重要。构建测试的周期越短,我编程就越高效、越快乐。我会不遗余力地让我的软件速度更快,让我的客户也越快乐。因此,我决定亲自试试Rust的构建速度到底怎么样,计划如下:

1. 找一个C++项目

2. 把项目中的一部分单独拿出来

3. 逐行将C++代码重写为Rust

4. 优化C++和Rust项目的构建

5. 对比两个项目的构建测试时间

我的猜想如下(有理有据的猜测,但不是结论):

1. Rust的代码行数比C++少。C++中多数函数和方法都需要声明两次:一次在header里,一次在实现文件里。但Rust不需要,因此代码行数会更少

2. C++的完整构建时间比Rust长(Rust更胜一筹)。在每个.cpp文件里,都需要重新编译一次C++的#include功能和模板,虽然都是并行运行,但并行不等于完美。

3. Rust的增量构建时间比C++长(C++更胜一筹)。Rust一个crate(独立可编译单元)一编译,但C++是按文件编译。因此代码每次变动,Rust要读取的比C++多。·

对此,大家怎么看呢?我在推特上的投票结果如下:

42%的人认为C++会赢,35%同意“看情况”,另外17%的则觉得Rust会让我们大吃一惊。

那么结果到底如何呢?下面让我们进入正题。

编写C++和Rust的测试对象

找个项目

考虑到我未来一个月都要花在重写代码上,什么样的代码最合适?我认为得满足以下几点:

1. 很少或不用第三方依赖(标准库可以使用);

2. 能在Linux和macOS上运行(我不怎么管Windows上的构建时间);

3. 大量测试套组(不然我没法确定Rust代码的正确性);

4. FFI(外部函数接口)、指针、标准或自定义容器、功能类和函数、I/O、并发、泛型、宏、SIMD(单指令多数据流)、继承等等,多少都有使用。

其实答案也很简单,直接找我前几年一直在做的项目就行。我用的是一个JavaScript词法分析器,quick-lint-js项目。

quick-lint-js的吉祥物Dusty

截取C++代码

quick-lint-js项目中C++部分的代码行数超过10万,要把这些全改成Rust得花上我半年时间,不如只关注JavaScript词法分析部分,其中涉及项目中的:

·诊断系统

·翻译系统(用于诊断)

·各种内存分配器和容器(如bump分配器、适用于SIMD的字符串)

·各种功能类函数(如UTF-8解码器、SIMD内在包装器)

·测试的辅助代码(如自定义断言宏)

·C的API

可惜这部分代码里不涉及并发或I/O,我测试不了Rust里async/await的编译时间开销,但这只是quick-lint-js项目里的一小部分,所以我还不用太担心。

我首先把所有的C++代码都复制到新项目里,然后删掉已知与词法分析无关的部分,比如分析器和LSP服务器。我甚至一不小心删多了代码,最后不得不重新把这些代码添了回去。在我不断截代码的过程中,C++的测试一直保持了通过状态。

在彻底将quick-lint-js项目中涉及词法分析的部分全截出来之后,项目中C++的代码大约有1.7万行。

重写代码

至于要怎么重写这上千行的C++代码,我选择按部就班:

1. 找一个适合转换的模块;

2. 复制黏贴代码、测试、搜索替换并修改部分语法、继续运行cargo(Rust的构建系统和包管理器)测试直到构建测测试都通过;

3. 如果这个模块依赖另一个模块,那就找到被依赖的模块,继续进行第二步,然后再回到现在这个模块;

4. 如果还有模块没转换,再回到第一步。

主要影响Rust和C++构建时间的问题在于,C++的诊断系统是通过大量代码生成、宏、constexpr(常量表达式)实现的,而我在重写Rust版时,则用了代码生成、proc宏、普通宏以及一点点const实现。传闻proc宏速度很慢,也有说是因为代码质量太差导致的proc宏速度慢。希望我写的proc宏还可以(祈祷~)。

我写完才发现,原来Rust项目比C++项目还要大,Rust代码17.1k行,而C++只有16.6k行。

优化Rust构建

构建时间很重要,因为我在截取C++代码之前就已经做好了C++项目构建时间的优化,所以我现在只需要对Rust项目的构建时间做同样的优化即可。以下是我觉得可能会优化Rust构建时间的条目:

·更快的链接器

·Cranelift后端

·编译器和链接器标志

·工作区与测试布局区分

·最小化依赖功能

·cargo-nextest

·使用PGO自定义工具链

更快的链接器

我第一步要做的是分析构建,我用的是-Zself-profile rustc标志。在这个标志所生成的两个文件里,其中一个文件中的run_linker阶段颇为突出:

第一轮-Zself-profile结果

之前我通过向Mold链接器的转换成功优化了C++的构建时间,那这套对Rust能否行得通?

Linux:链接器性能几乎一致。(数据越小越好)

可惜,Linux上虽然确实有提升,但效果不明显。那macOS上的优化又表现如何?在macOS上默认链接器的替代品有两种,lld和zld,效果如下:

macOS:链接器性能几乎不变。(数据越小越好)

可以看出,macOS上替换默认链接器的效果同样不明显,我怀疑这可能是因为Linux和macOS上的默认链接器对我的小项目而言已经做到了最好,这些优化后的链接器(Mold、lld、zld)在大型项目上效果非常好。

Cranelift后端

让我们再回到-Zself-profile的另一篇报告上,LLVM_module_­codegen_emit_obj和LLVM_passes阶段颇为突出:

-Zself-profile的第二轮结果

传闻可以把rustc的后端从LLVM换成Cranelift,于是我又用rustc Cranelift后端重新构建了一遍,-Zself-profile结果看起来不错:

使用Cranelife的-Zself-profile第二轮结果

可惜,在实际的构建中Cranelife比LLVM慢。

Rust后端:默认LLVM比Cranelift强。(测试于Linux,数据越小越好)

2023年1月7日更新:rustc的Cranelift后端维护者bjorn3帮我看了下为什么Cranelift在我的项目上效果不佳:可能是rustup的开销导致的。如果绕过这部分Cranelife效果可能会有提升,上图中的结果没有采用任何措施。

编译器和链接器标志

编译器里有一堆可以加快(或减缓)构建速度的选项,让我们一一试过:

·-Zshare-generics=y (rustc) (Nightly only)

·-Clink-args=-Wl,-s (rustc)

·debug=false (Cargo)

·debug-assertions=false (Cargo)

·incremental=true且incremental=false (Cargo)

·overflow-checks=false (Cargo)

·panic='abort' (Cargo)

·lib.doctest=false (Cargo)

·lib.test=false (Cargo)

rustc标志:快速构建优于调试构建。(测试于Linux,数据越小越好)

注:图中的“quick, -Zshare-generics=y”与“quick, incremental=true”且启用“-Zshare-gener ics=y”标志相等同,其余柱状图没有标识“-Zshare-generics=y”是因为没有启用该标志,后者意味着需要nightly rust编译器。

上图中使用的多数选项都有文档可查,但我还没找到有人写过加-s的链接。子命令s将包括Rust标准库静态链接在内的所有调试信息全部剥离,让链接器做更少的工作,从而减少链接时间。

工作区与测试布局

在文件的物理位置问题上,Rust和Cargo都提供了部分灵活性。对我的项目而言,以下是三种合理布局:

理论上来说,如果我们把代码拆成多个crate,cargo就可以并行化rustc的调用。鉴于我的Linux机器上有一个32线程的CPU,macOS机器上有一个10线程的CPU,并行化应该可以降低构建时间。

对一个crate而言,Rust项目中的测试有很多可运行的地方:

由于依赖周期的存在,我没办法做“源码文件内的测试”这个布局的基准,但其他布局组合里我都做了基准:

Rust完整构建:工作区布局最快。(测试于Linux,数据越小越好)

Rust增量构建:最佳布局不明。(测试于Linux,数据越小越好)

工作区设置中,无论是分成多个可执行测试(many test exes),还是合并成一个可执行测试,似乎都能斩获头筹。所以后续我们还是按照“工作区+多个可执行文件”的配置吧。

最小化依赖功能

多个crate的拆分支持可选功能,而部分可选功能都是默认启用的,具体功能可以通过cargo tree命令查看:

让我们把crate之一,libc中的std功能关掉,测试后再看看构建时间有没有变化。

Cargo.toml

 [dependencies] 
+libc = { version = "0.2.138", default-features = false } 
-libc = { version = "0.2.138" } 

关掉libc功能后没有任何变化。(测试于Linux,数据越小越好)

构建时间没有任何变化,有可能std功能实际没什么大影响。不管怎么说,让我们进入下一个环节。

cargo-nextest

作为一款据说“比cargo测试快60%”的工具,cargo-nextest对于我这个代码中44%都是测试的项目来说非常合适。让我们来对比下构建和测试时间:

Linux:cargo-nextest减慢了测试速度。(数据越小越好)

在我的Linux机器上,cargo-nextest帮了倒忙,虽然输出不错,不过……

示例cargo-nextest测试输出:

PASS [   0.002s]        cpp_vs_rust::test_locale no_match 
PASS [   0.002s]     cpp_vs_rust::test_offset_of fields_have_different_offsets 
PASS [   0.002s]     cpp_vs_rust::test_offset_of matches_memoffset_for_primitive_fields 
PASS [   0.002s] cpp_vs_rust::test_padded_string as_slice_excludes_padding_bytes 
PASS [   0.002s]     cpp_vs_rust::test_offset_of matches_memoffset_for_reference_fields 
PASS [   0.004s] cpp_vs_rust::test_linked_vector push_seven

那macOS上怎么说?

macOS:cargo-nextest加快了构建测试。(数据越小越好)

在我的MacBook pro上,cargo-nextest确实提高了构建测试的速度。但为什么Linux上没有呢?难道是和硬件有关?

在下面测试中,我会在macOS上使用cargo-nextest,但Linux上的测试不用。

使用PGO自定义工具链

我发现C++编译器的构建如果用配置文件引导的优化(PGO,也称作FDO),会有明显的性能提升。因此,让我们试试用PGO优化Rust工具链的同时,也用LLVM BOLT加上Ctarget-cpu=native进一步优化rustc。

Rust工具链:自定义工具链是最快的。(测试于Linux,数据越小越好)

如果你好奇的话,可以看看这段工具链构建脚本。可能不适用于你的机器,但只要我能运行就行:https://github.com/quick-lint/cpp-vs-rust/blob/953429a4d92923ec030301e5b00face1c13b b92b/tools/build-toolchains.sh

与C++编译器相比,通过rustup发布的Rust工具链似乎已经是优化完成的结果。PGO加上BOLT的组合只带来了不到10%的性能提升。但有提升就是好的,所以在后续与C++的竞争中我们会继续使用这个速度最快的工具链。

我第一次搭建的Rust自定义工具链比Nightly还要慢2%,我在Rust config.toml的各种选项中反复调整,不断交叉检查Rust的CI构建脚本以及我自己的脚本,最终在好几天的挣扎后才让这二者性能持平。在我最终润色这篇文章时,我进行了rustup更新,拉取git项目,并重头又建了一遍工具链。结果这次我的自定义工具链速度更快了!有可能是我在Rust仓库里提交错了代码……

优化C++构建

在最初的C++项目quick-lint-js中,我已经用常见的手段优化了编译时间,比如用PCH、禁用异常和RTTI、调整编译标志、删除非必要 #include、将代码从头中移出、外置模板实例等方法。但此外还有一些C++编译器和链接器我没试过,在我们进入C++和Rust的对比之前,先从这些里面挑出最适合我们的。

Linux:自定义Clang是最快的工具链。(数据越小越好)

很明显,Linux上的GCC是个特例,而Clang的表现则要好上很多。我自定义构建的Clang(和Rust工具链一样,也是用PGO和BOLT构建的)相较于Ubuntu的Clang,显著优化了构建时间,而libstdc++的构建略快于平均libc++的速度。

那我的自定义Clang加上libstdc++在C++和Rust的对比中表现如何呢?

macOS:Xcode是最快的工具链。(数据越小越好)

在macOS上,搭配Xcode的Clang工具链似乎要比LLVM网站上的Clang工具链优化得更好。

C++20模块

我的C++代码用的是 #include,但如果用C++20中新增加的import又会怎么样呢?C++20的模块是不是理论上来说应该会让编译速度超级快?

我在项目了尝试过C++20模块,但直到2023年的1月3日,Linux上的CMake模块支持过于实验性质了,我甚至连“hello world”都没跑起来。

或许2023年中C++20模块会大放异彩,对于我这种超级在意构建时间的人来说,真是这样就太好了。但目前为止,我还是继续用经典C++的#include和Rust做对比吧。

对比C++和Rust的构建时间

通过把C++项目改写成Rust,并尽可能地优化Rust的构建时间后,问题来了:C++和Rust究竟谁更快呢?

很可惜,答案是“看情况”!

Linux:Rust部分情况下构建速度超越C++。(数据越小越好)

在我的Linux机器上,部分情况下Rust的构建速度确实优于C++,但也有速度持平或逊于C++的情况。在增量lex的基准上,我们修改了大量源码,Clang比rustc速度快,但在其他增量基准上,rustc又会反超Clang。

macOS:C++构建速度通常快于Rust。(数据越小越好)

但我的macOS机器上情况却截然不同。C++的构建速度常常快上Rust许多。在增量测试utf-8的基准,我们修改中等数量测试文件,rustc编译速度会略微超过Clang,但在包括全量构建等其他基准上,Clang很明显效果要更好。

超过17k行代码

我基准测试的项目只有17k行代码,算是小型项目,那么对超过10万行代码的大型项目来说,又是什么情况呢?

我把最大的模块,也就是词法分析器的代码复制粘贴了8、16以及24遍,分别用来测试。因为我的基准里也包括了运行测试的时间,我觉得构建时间即使是对于那些能瞬间构建完的项目,也应该会线性增长。

倍数扩大后C++完整构建优于Rust。(测试于Linux,数据越小越好)

倍数扩大后C++增量构建优于Rust。(测试于Linux,数据越小越好)

Rust和Clang确实都是线性扩大,这点很好。

正如预期中一样,修改C++的头文件,也就是增量diag-type会大幅影响构建时间。而由于Mold链接器的存在,其他增量基准中构建时间的扩展系数很低。

Rust构建的扩展性让我很失望,即使只是增量utf-8测试的基准,无关文件的加入也不应该让它的构建时间如此受影响。测试所用的crate布局时“工作区且多个可执行测试”,因此utf-8测试应该能独立编译可执行文件。

结论

编译时间对Rust而言算是问题吗?答案是肯定的。虽然也有一些可以加快编译速度的提示和技巧,但却没有效果非常显著的数量级改进,这让我在开发Rust时非常高兴。(手动狗头)

Rust的编译时间和C++相比呢?确实也很糟。至少对我的编码风格来说,Rust在大型项目上开发的编译时间甚至更加远比C++还要糟糕。

再回过头看看我当初的假设,几乎全军覆没:

1. Rust改写版代码行数比C++多;

2. 在全量构建上,C++相比Rust在1.7万行代码上构建时间相似,在10万行代码上构建时间要少;

3. 在增量构建上,Rust相比C++在部分情况构建时间要短,在1.7万行上构建时间要长,在10万行代码上构建时间甚至更长。

我不爽吗?确实。在改写过程中,我不断学习着Rust相关的知识,比如proc marco能替代三个不同代码生成器,简化构建流水线,让新开发者们日子更好过。但我完全不想念头文件,以及Rust的工具类真的很好用,特别是Cargo、rustup以及miri。

但我决定不把quick-lint-js项目中剩下的代码也改成Rust,但如果Rust的构建时间能有明显优化,或许我会改变主意。当然,前提是我还没被Zig迷走心神。

附注

源码

删减后的C++项目源码、移植版Rust(包括不同的项目布局)、代码生成脚本和基准测试脚本、GPL-3.0及以上。


Linux机器

名称:strapurp

CPU:AMD Ryzen 9 5950X (PBO; stock clocks) (32 threads) (x86_64)

RAM:G.SKILL F4-4000C19-16GTZR 2x16 GiB (overclocked to 3800 MT/s)

操作系统:Linux Mint 21.1

内核:Linux strapurp 5.15.0-56-generic #62-Ubuntu SMP Tue Nov 22 19:54:14 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

Linux性能治理器:schedutil

CMake:版本3.19.1

Ninja:版本1.10.2

GCC:版本12.1.0-2ubuntu1~22.04

Clang(Ubuntu):版本14.0.0-1ubuntu1

Clang (自定义):版本15.0.6 ( Rust fork; 代码提交3dfd4d93fa013e1c0578-d3ceac5c8f4ebba4b6ec

libstdc++ for Clang:版本11.3.0-1ubuntu1~22.04

Rust稳定版:1.66.0 (69f9c33d7 2022-12-12)

Rust Nightly:版本1.68.0-nightly (c7572670a 2023-01-03)

Rust(自定义):版本1.68.0-dev (c7572670a 2023-01-03)

Mold:版本0.9.3 (ec3319b37f653dccfa4d­-1a859a5c687565ab722d)

binutils:版本2.38


macOS机器

名称:strammer

CPU:Apple M1 Max (10 threads) (AArch64)

RAM:Apple 64 GiB

操作系统:macOS Monterey 12.6

CMake:版本3.19.1

Ninja:版本1.10.2

Xcode Clang:Apple clang版本14.0.0 (clang-1400.0.29.202) (Xcode 14.2)

Clang 15:版本15.0.6 (LLVM.org website)

Rust稳定版:1.66.0 (69f9c33d7 2022-12-12)

Rust Nightly:版本1.68.0-nightly (c7572670a 2023-01-03)

Rust(自定义):版本1.68.0-dev (c7572670a 2023-01-03)

lld:版本15.0.6

zld:代码提交d50a975a5fe6576ba0fd­2863897c6d016eaeac41


基准

用deps的构建和测试

C++:cmake -S build -B . -G Ninja && ninja -C build quick-lint-js-test && build/test/quick-lint-j s-test计时

Rust:cargo fetch未计时,再用cargo test计时


不用deps的构建和测试

C++:cmake -S build -B . -G Ninja && ninja -C build gmock gmock_main gtest未计时,再用ninja -C build quick-lint-j s-test && build/test/quick-lint-j s-test计时

Rust:cargo build --package lazy_static --package libc --package memoffset"未计时,再用cargo test计时


增量diag-types

C++:构建和测试未计时,随后修改diagnostic-types.h,再用ninja -C build quick-lint-j s-test && build/test/quick-lint-j s-test

Rust:构建和测试未计时,修改diagnostic_types.rs后,cargo test


增量lex

同增量diag-types,但使用lex.cpp/lex.rs


增量utf-8测试

同增量,但使用test-utf-8.cpp/test_utf_8.rs


每个可执行基准均采用12个样本,弃置前两个,基准仅显示最后十个样本的平均性能。误差区间展示最小与最大样本间区别。

原文链接

https://quick-lint-js.com/blog/cpp-vs-rust-build-times/