原文:https://matklad.github.io/2021/09/04/fast-rust-builds.html
作者:Alex Kladov
译者:ChatGPT 4 Turbo
众所周知,Rust 代码编译速度很慢。但我强烈地感觉,大多数 Rust 代码的编译速度比可能的要慢得多。
例如,一篇相当近期的帖子说:
与 Rust 相比,运行一个 CI 流水线需要 15 到 45 分钟,这取决于您的项目和 CI 服务器的性能。
这对我来说没有意义。rust-analyzer CI 在 GitHub actions 上需要 8 分钟。这是一个相当大且复杂的项目,拥有 20 万行自有代码以及 100 万行的依赖代码。
Rust 在编译上确实以一种相当基本的方式慢。它在通用的困境中选择了“慢编译器”,其整体哲学优先考虑运行时间而不是编译时间(关于这点有一系列出色的帖子:1, 2, 3, 4)。但 rustc
并不是一个慢编译器 —— 它实现了工业编译器中最先进的增量编译,利用了基于适当模块(箱)的编译模型,并且已经被精心优化。快速编译 Rust 项目是现实,即使它们不常见。诚然,这需要一些关怀和领域知识才能做到。
那么,让我们更仔细地看看,为了保持 rust-analyzer 的编译时间在合理范围内,我们采取了哪些措施!
为什么要关心构建时间
我想明确的一点是,优化项目构建时间在某种意义上是繁琐工作。减少编译时间对用户直接好处很小,纯粹是偶然的复杂性。
话虽如此,编译时间基本上是一切的乘数。无论你想要发布更多功能,加快代码速度,适应需求变化,还是吸引新的贡献者,构建时间都是一个因素。
它也是一个非线性因素。只是等待编译器是较小的问题。更大的问题是失去了工作流的状态或者(更糟)在代码编译时进行其他事情的心理上下文切换。编译器工作一分钟,对于人来说浪费的时间不止一分钟。
很难让我量化影响,但我的直觉理解是,只要项目超出了某个人写的几千行,构建时间就变得非常重要了!
构建时间最狡猾的特性是它们会悄悄增长。当项目较小的时候,构建时间通常是可以接受的。随着项目逐渐增长,构建时间也会开始缓慢增加。如果你让它们继续增长,稍后想要控制它们可能会相当困难!
如果项目编译已经太慢,那么:
- 提高构建时间将会非常耗时,因为每一次“尝试一个更改,触发构建,测量改进”都需要很长时间(是的,构建时间是一切的倍增器,包括构建时间本身!)
- 不会有轻而易举的胜利:与运行时性能相比,帕累托原则不适用!如果你写了一千行代码,可能其中一百行对性能敏感,但每一行都会增加编译时间!
- 小胜利看起来太小,直到它们累积起来:对于一个五分钟的构建过程来说,减少五秒是一个重要的进步,而对于一个小时长的构建来说,就没有那么大的影响了。
- 相应地,小的回归将不会被注意到。
这也涉及到一种文化层面:如果你加入一个项目,它的 CI 要花一个小时,那么一个小时的 CI 就是正常的,对吗?
幸运的是,有一个简单的技巧可以解决构建时间的问题……
银弹
你需要关心构建时间,留意它们,并在它们成为问题之前解决它们。构建时间是一个相对容易优化的问题:直接反馈很简单(只需计时构建),有很多用于分析性能的工具,甚至你不需要提出一个有代表性的基准测试。任务是优化特定项目的构建时间,而不是通常意义上编译器的性能。这是大多数偶然复杂性实例的一个好特性——它们往往是定义明确的工程问题,有着众所周知的解决方案。
关于编译时间唯一困难的地方是,你不知道这是个问题,直到它真的成为一个问题!因此,你从这篇文章中能获得的最宝贵的东西是:如果你正在进行一个 Rust 项目,花一些时间优化它的构建,今天就开始,并尝试偶尔重复这个练习。
现在,软件工程的部分已经讲清楚了,让我们终于来到一些可行的编程建议吧!
bors
我喜欢使用 CI 时间作为主要关注的指标之一。
部分原因是 CI 时间本身很重要。虽然在开发功能时你不受 CI 的约束,但 CI 时间直接影响了完成一项工作并开始下一项时切换上下文的烦恼程度。同时操纵五个等待 CI 完成的未决 PR 并不高效。较长的 CI 还会造成不将工作拆分成独立块的压力。如果更正一个打字错误需要保持一个 PR 标签页打开半小时,那么在下一个功能分支中顺便修正它不是更好吗?
但更重要的是,CI 为你提供了一个标准化的基准。在本地,你增量编译,构建时间会因为你所做的更改类型而大不相同。通常,你只编译项目的一部分。由于这种固有的可变性,本地构建提供的关于构建时间的连续反馈很差。然而,标准化的 CI 对每次更改都运行,并为你提供一个时间序列,其中的数字是直接可比的。
为了增加 CI 的标准化压力,我建议遵循不是火箭科学的规则,并设置一个合并机器人,确保主分支的每个状态都通过 CI。我使用的是 bors,但还有其他的实现。
虽然这远非使用 bors 这类工具的最大理由,但它为健康的编译时间带来了两大好处:
- 它确保每个更改都通过 CI 进行,并对保持 CI 的整体健康状况产生压力
- 在留下对 PR 的评论和收到 “PR 已合并” 通知之间的时间,为你提供了一个始终在线的反馈循环。你无需特别计时构建,每个 PR 都是一个构建基准。
CI 缓存
如果你仔细想想,CI 的良好缓存策略应该如何运作是相当明显的。缓存那些很少变化的东西是有道理的,但是缓存经常变化的东西就没什么用了。也就是说,缓存所有的依赖项,但不要缓存项目自己的 crates。
遗憾的是,几乎没有人这样做。一个典型的例子就是直接缓存整个 ./target
目录。这是错误的 —— ./target
非常大,而且其中大部分在 CI 上是无用的。
虽然不是非常简单就能修复——遗憾的是,Cargo 并不容易判断 ./target
中哪些部分是持久依赖,哪些部分是不稳定的本地包。所以,在存储缓存之前,你需要编写一些代码来清理 ./target
。特别是对于 GitHub actions,你还可以使用 Swatinem/rust-cache。
CI 工作流
缓存通常是最容易采摘的西瓜,但还有其他几项可以调整。
将 CI 分为独立的 cargo test --no-run
和 cargo test
。了解 CI 中哪部分是构建,哪些是测试至关重要。
禁用增量编译。CI 构建通常更接近于从头构建,因为更改通常比本地编辑-编译周期的更改要大。对于从头构建,增量编译增加了额外的依赖跟踪开销。它还显著增加了 IO 和 ./target
的大小,这使得缓存效果降低。
禁用 debuginfo —— 它会让 ./target
的大小大幅增加,这反过来又会影响缓存。根据您偏好的工作流程,您可能会考虑无条件禁用 debuginfo,这对本地构建也有一些好处。
在此过程中,添加 -D warnings
到 RUSTFLAGS
环境变量中,以同时拒绝所有 crates 的警告。在代码中 #![deny(warnings)]
是个坏主意:你需要对每个 crate 重复这个过程,它无谓地使本地开发变得更困难,并且当用户升级他们的编译器时,可能会破坏你的用户。提升 cargo 网络重试限制也许也是有道理的。
阅读 Lockfile
另一个明显的建议是使用更少、更小的依赖项。
这是微妙的:库确实解决了实际问题,对于 crates.io 已经解决的问题,自己重新发明轮子是愚蠢的。并且,并不是说你的解决方案一定会更小。
但重要的是要意识到你的应用程序解决了什么问题,以及没有解决什么问题。如果你正在为成千上万的人构建一个 CLI 工具,你绝对需要具备所有功能的 clap。如果你正在编写一个快速脚本在 CI 中运行,只有团队会使用,那么开始时使用简单的命令行解析可能就足够了,但构建速度更快。
一个极其有用的练习是阅读 Cargo.lock
(不是 Cargo.toml
),并且对于每一个依赖,思考这个依赖为你应用程序面前的用户解决了什么实际问题。很频繁地,你会发现一些在 你的上下文中 完全没有意义的依赖。
作为一个说明性的例子,rust-analyzer 依赖于 regex
。这并不合理 —— 我们已经有了精确的 Rust 和 Markdown 解析器和词法分析器,我们不需要在运行时解释正则表达式。regex
也是较重的依赖之一 —— 它是一个小语言的完整实现!之所以存在这个依赖,是因为我们使用的日志库允许像这样表述:
RUST_LOG=rust_analyzer=very complex filtering expression
在这里,过滤表达式的解析是通过正则表达式完成的。
这无疑是一些应用程序非常有用的功能,但在 rust-analyzer 的上下文中,我们不需要它。简单的 env_logger
风格过滤就足够了。
一旦你发现了一个相似的冗余依赖,通常只需在某处调整 features
字段,或者向上游发送 PR 以使非必要部分可配置。
有时候这是一个更大的问题需要解决 🙂 例如,rust-analyzer 可选地使用 jemalloc
crate,它的构建脚本会拉取 fs_extra
和(所有的东西中!) paste
。理想的解决方案当然是拥有一个生产级别的、稳定的、纯 Rust 内存分配器。
优化前的概况
现在我们已经处理了一些合理的事情,是时候开始在切割前进行测量了。此处要使用的工具是 Cargo 的 timings
标志(文档)。遗憾的是,我缺乏足够的雄辩才能来充分表达这一功能的质量和完善程度,所以让我只说 ❤️,继续我的枯燥叙述。
cargo build -Z timings
在构建过程中记录性能分析数据,然后将其渲染为非常易读且信息密集的 HTML 文件。这是一个夜间特性,所以你需要启用 +nightly
开关。实际上这不是问题,因为你只需偶尔手动运行一次。
这是来自 rust-analyzer 的一个示例:
$ cargo +nightly build -p rust-analyzer --bin rust-analyzer \
-Z timings --release
不仅可以看到每个 crate 编译花费了多长时间,还能看到个别编译是如何被调度的,每个 crate 开始编译的时间,以及它的关键依赖。
编译模块:Crates
这最后一点很重要 —— crates 形成了一个依赖的有向无环图,在多核 CPU 上,这个图的形状对编译时间影响很大。
这个编译很慢,因为所有的 crates 需要依次编译:
A -> B -> C -> D -> E
这个版本速度快得多,因为它能显著增加并行性:
+- B -+
/ \
A -> C -> E
\ /
+- D -+
并行性和增量性之间也存在联系。在宽图中,更改 B
不需要重新编译 C
和 D
。
当你抱怨 Rust 的编译时间时,你得到的第一个建议是:“将代码分割成多个 crate”。这并不容易——如果你最终得到的是像第一张图那样的依赖图,你并没有赢得太多。重要的是要设计应用程序,使其看起来像第二张图片——一个公共的词汇 crate,一些独立的功能,以及一个叶子 crate 来将所有内容整合在一起。crate 的最重要属性是它不(传递性地)依赖于哪些 crate。
另一个重要的考虑因素是最终产物(通常是二进制文件)的数量。Rust 是静态链接的,所以,如果两个不同的二进制文件使用同一库,每个二进制文件都包含一个单独链接的库副本。如果你有 n
个二进制文件和 m
个库,且每个二进制文件都使用每个库,那么在链接过程中要做的工作量是 m * n
。因此,最好减少产物的数量。这里一个常见的技术是 BusyBox 风格的瑞士军刀可执行文件。这个想法是你可以将同一个可执行文件以不同名称的几个文件硬链接。程序然后可以查看第零个命令行参数来了解它被调用的名称,并有效地将其作为子命令的名称使用。这里一个特定于 cargo 的陷阱是, 默认情况下,./examples
或 ./tests
文件夹中的每个文件都会创建一个新的可执行文件。
译模型:宏和流水线
但 Cargo 更加智能!它执行流水线编译 —— 将 crate 的编译分为元数据和代码生成阶段,并在元数据阶段结束后立即开始编译依赖的 crate。
这与过程宏(和构建脚本)有着有趣的交互。 rustc
需要运行过程宏来计算 crate 的元数据。这意味着过程宏不能被管线化,使用过程宏的 crates 会被阻塞,直到 proc 宏完全编译成二进制代码。
与此同时,过程宏需要解析 Rust 代码,这是一个相对复杂的任务。事实上,用于此任务的 crate,syn
,需要相当长的时间来编译(并不是因为它臃肿——只是因为解析 Rust 很困难)。
这通常意味着在编译期间,项目倾向于在 CPU 利用率配置文件中出现 syn
/ serde
形状的空洞。在过程宏真正发挥作用的地方使用它们是相对重要的,并尝试在 cargo -Z timings
图中将 crate 推向 syn
之前。
后者可能比较棘手,因为 proc 宏依赖可能悄然接近你。这里的问题在于,它们经常隐藏在功能标志后面,而这些功能标志可能被下游 crates 启用。考虑这个例子:
你有一个方便的工具类型 —— 例如,在一个 small_string
crate 中的 SSO 字符串。为了实现序列化,你实际上不需要 derive(只需委托给 String
即可),因此你添加了一个(可选的)依赖 serde
:
[package]
name = "small-string"
[dependencies]
serde = { version = "1" }
SSO 字符串是一个相当有用的抽象,因此它在整个代码库中被广泛使用。然后在某些叶子 crate 中,例如需要暴露一个 JSON API 的情况下,你添加了对 small_string
的依赖,并启用了 serde
特性,同时也添加了 serde
及其派生特性:
[package]
name = "json-api"
[dependencies]
small-string = { version = "1", features = [ "serde" ] }
serde = { version = "1", features = [ "derive" ] }
这里的问题是 json-api
启用了 serde
的 derive
功能,这意味着 small-string
及其所有反向依赖现在需要等待 syn
编译!同样,如果一个包依赖于 syn
的一部分功能,但是包图中的其他内容启用了所有功能,那么原始包也会作为额外收获得到这些功能!
这并不一定意味着世界末日,但这表明,有了功能的存在,依赖关系图可能会变得复杂。幸运的是,cargo -Z timings
使得我们容易注意到有一些奇怪的事情正在发生,即使可能不总是很明显到底出了什么问题。
还有一种对于过程宏来说减缓编译速度更直接的方式 —— 如果宏生成了大量代码,结果编译起来就需要一些时间。也就是说,有些宏允许你只写很少的源代码,这感觉足够无害,但扩展后却包含了大量的逻辑。最典型的例子是序列化 —— 我注意到将值转换为 / 从 JSON 转换占了意外大的编译时间。从整个 crate 图的角度思考在这里有帮助 —— 你希望将序列化保留在系统的边界处,即叶子 crates 中。如果你将序列化放在基础层附近,那么所有中间的 crates 都必须支付其构建时间的成本。
尽管如此,有一个有趣的旁注是,过程宏(procedural macros)本身并不是编译缓慢的原因。而是大多数过程宏需要解析 Rust 代码或生成大量代码,这才使它们变慢。有时,宏可以接受一种简化的语法,这种语法可以在没有复杂操作的情况下被解析,并且基于这种语法发出少量的 Rust 代码。生成有效的 Rust 代码并不像解析 Rust 那样复杂!
编译模型:单态化
既然我们已经讨论了 crate 层面的宏观问题,现在是时候更深入地看看代码级别的问题了。这里主要需要关注的是泛型。理解它们是如何被编译的至关重要,对于 Rust 来说,这是通过单态化来实现的。考虑一个普通的泛型函数:
fn frobnicate<T: SomeTrait>(x: &T) {
...
}
当 Rust 编译这个函数时,它实际上并没有生成机器代码。相反,它在库中存储了函数体的抽象表示。当你用特定的类型参数实例化函数时,实际的编译才会发生。这里 C++ 的术语给出了正确的直觉 —— frobnicate
是一个“模板”,当具体类型替代参数 T
时,它产生一个实际的函数。
换句话说,在以下情况下:
fn frobnicate_both(x: String, y: Widget) {
frobnicate(&x);
frobnicate(&y);
}
在机器码层面,将会有两个分开的 frobnicate
副本,这些副本在处理参数的细节上会有所不同,但在其他方面则完全相同。
听起来很糟糕,对吧?看起来你可以编写一个巨大的泛型函数,然后只需编写一小段代码,用一堆类型来实例化它,就能给编译器带来很大的负担。
嗯,我有个坏消息要告诉你——现实情况要糟糕得多。你甚至不需要不同的类型来创建重复。假设我们有四个箱子形成了一个菱形
+- B -+
/ \
A D
\ /
+- C -+
frobnicate
在 A
中定义,并被 B
和 C
使用
// A
pub fn frobnicate<T: SomeTrait>(x: &T) { ... }
// B
pub fn do_b(s: String) { a::frobnicate(&s) }
// C
pub fn do_c(s: String) { a::frobnicate(&s) }
// D
fn main() {
let hello = "hello".to_owned();
b::do_b(&hello);
c::do_c(&hello);
}
在这种情况下,我们只用 frobincate
实例化 String
,但它会被编译两次,因为单态化是按 crate 进行的。 B
和 C
分别被编译,每个都包含了 do_*
函数的机器代码,所以它们需要 frobnicate<String>
。如果优化被禁用,rustc 可以与依赖共享模板实例化,但这对兄弟依赖不适用。在启用优化的情况下,即使是直接依赖,rustc 也不会共享单态化。
换句话说,Rust 中的泛型可能会导致在许多 crates 中不经意间出现二次方的编译时间!
如果你想知道是否还有比这更糟的情况,答案是肯定的。我认为单态化的实际单位是代码生成单元,所以即使在一个 crate 中,也可能出现重复。
监视实例化情况
除了简单的复制之外,泛型还增加了一个问题 —— 它们将编译时间的责任转移给了使用者。泛型函数的大部分编译时间成本由使用该功能的 crates 承担,而定义 crate 仅对代码进行类型检查而不进行任何代码生成。再加上有时根本不清楚什么在哪里以及为什么被实例化(例子),这使得直接看到泛型 API 的占用空间变得困难。
幸运的是,这不是必需的 —— 有一个工具可以做到!cargo llvm-lines
可以告诉你在特定的 crate 中发生了哪些单态化。
以下是来自最近一项调查的一个例子:
$ cargo llvm-lines --lib --release -p ide_ssr | head -n 12
Lines Copies Function name
----- ------ -------------
533069 (100%) 28309 (100%) (TOTAL)
20349 (3.8%) 357 (1.3%) RawVec<T,A>::current_memory
18324 (3.4%) 332 (1.2%) <Weak<T> as Drop>::drop
14024 (2.6%) 332 (1.2%) Weak<T>::inner
11718 (2.2%) 378 (1.3%) core::ptr::metadata::from_raw_parts_mut
10710 (2.0%) 357 (1.3%) <RawVec<T,A> as Drop>::drop
7984 (1.5%) 332 (1.2%) <Arc<T> as Drop>::drop
7968 (1.5%) 332 (1.2%) Layout::for_value_raw
6790 (1.3%) 97 (0.3%) hashbrown::raw::RawTable<T,A>::drop_elements
6596 (1.2%) 97 (0.3%) <hashbrown::raw::RawIterRange<T> as Iterator>::next
它显示了每个泛型函数生成了多少个副本,以及它们的总大小。大小是以 llvm ir 行数来粗略测量的。一个有用的事实:llvm 没有泛型函数,将函数模板和一组实例化转换成一组实际函数是 rustc
的工作。
保持实例化检查
既然我们理解了单态化的陷阱,一个经验法则变得显而易见:不要在 crate 之间的边界处放置泛型代码。在设计一个大型系统时,应将其构建为一组组件,其中每个组件执行具体的操作,并且具有非泛型的接口。
如果您确实需要通用接口来提高类型安全性和人体工学性能,请确保接口层是薄的,并且它立即委托给非通用实现。这里需要内化的经典例子是各种从 str::fs
模块中操作路径的函数:
pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> {
fn inner(path: &Path) -> io::Result<Vec<u8>> {
let mut file = File::open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
inner(path.as_ref())
}
外部函数是参数化的 —— 它使用起来很方便,但是对于每个下游 crate 来说都会重新编译。不过,这不是问题,因为它非常小,并且会立即委托给一个在 std 中编译的非泛型函数。
如果你正在编写一个函数,该函数接受路径作为参数,要么使用 &Path
,要么使用 impl AsRef<Path>
并委托给非泛型实现。如果你足够关心 API 的易用性以至于使用 impl trait,你应该使用 inner
技巧——编译时间是易用性的重要部分,就像调用函数所用的语法一样。
在这里的第二个常见情况是闭包:默认情况下,优先选择 &dyn Fn()
而不是 impl Fn()
。与路径相似,一个基于 impl
的优雅 API 可能是围绕基于 dyn
的实现的薄包装,后者完成了大部分工作。
另一个类似的想法是“泛型,内联热路径;具体,外部冷路径”。在 once_cell 包中,有一个奇特的模式(简化了,这里是实际的源码):
struct OnceCell<T> {
state: AtomicUsize,
inner: Option<T>,
}
impl<T> OnceCell<T> {
#[cold]
fn initialize<F: FnOnce() -> T>(&self, f: F) {
let mut f = Some(f);
synchronize_access(self.state, &mut || {
let f = f.take().unwrap();
match self.inner {
None => self.inner = Some(f()),
Some(_value) => (),
}
});
}
}
fn synchronize_access(state: &AtomicUsize, init: &mut dyn FnMut()) {
// One hundred lines of tricky synchronization code on atomics.
}
在这里,initialize
函数有两次泛型:首先,OnceCell
用存储的值的类型进行参数化,然后 initialize
接受一个泛型闭包参数。 initialize
的工作是确保(即使它被多个线程并发调用)最多只有一个 f
被运行。这种互斥任务实际上并不依赖于特定的 T
和 F
,并且作为非泛型的 synchronize_access
实现,以提高编译时间。这里的一个问题是,理想情况下,我们希望有一个 init: dyn FnOnce()
参数,但在今天的 Rust 中这是无法表达的。 let mut f = Some(f) / let f = f.take().unwrap()
是这种情况的标准解决方法。
结论
就这些了!重申一下主要观点:
构建时间是影响项目参与人员整体生产力的一个重要因素。优化这一点是一个直接的工程任务 —— 工具都是现成的。可能困难的是不让它们慢慢退化。我希望这篇文章能提供足够的动力和灵感!作为一个粗略的基准,200k 行 Rust 项目在合理优化构建时间后,应该在 GitHub Actions 上的 CI 大约需要 10 分钟。