加速Rust编译:度量缝隙
17 分钟的全新优化编译还是慢得像蜗牛。问题是否不在于编译器怎么工作,而在于我们让它编译了什么呢?
以下文章来自微信公众号“嘻话 Tech”,作者俞一峻,已获授权。
上篇我们用尽了所有已知的手段来加速Zed的编译——并行前端、快速链接器、编译缓存、替代后端。17 分钟的全新优化编译还是慢得像蜗牛。问题是否不在于编译器怎么工作,而在于我们让它编译了什么呢?
让我们深入探究一下。
一个 Library 的两难困境
假设你正在用 serde_json 这个 crate,它对外暴露了好几十个丰富的 API:from_str()、from_slice()、from_reader()、to_string()、to_string_pretty()、to_vec()、to_writer(),等等。而你的项目就调用了 serde_json::from_str() 和 serde_json::to_string()这两个API。
但是, rustc 在编译 serde_json 的时候并不知道你只需要这两个函数。事实上,按照设计它也不可能知道:crate 的边界是不透明的——rustc 独立编译每个 crate,把每个公开函数都当作潜在的入口点。它必须为所有函数生成优化后的机器码。
因此这不是 bug,而是独立编译的固有工作方式:让 crate 可以独立编译、缓存、复用,虽然这是一个正确的架构决策,但它是有代价的。
独立编译的缝隙
我们把这个代价叫做独立编译缝隙(Separate Compilation Gap):编译器必须编译的内容(所有可见的)和程序实际需要的内容(从 main 可达的)之间的差距。
用公式表达,对于一个编译单元 u:
Gap(u) = (|Visible(u)| - |Reachable(u)|) / |Visible(u)|
其中:
-
Visible(u) = 编译器处理的所有符号(每个公开函数、每个 impl、每个 trait 方法)
-
Reachable(u) = 通过全程序调用图分析,从
main()实际可达的子集
如果 Gap = 0%,编译器做的工作恰到好处。如果 Gap = 50%,编译器一半的力气白费了。
度量 Zed 的缝隙
那么 Zed 的缝隙到底有多大?
我们开发了一个工具,对全部 198 个 workspace crate 进行跨 crate 的可达性分析。从 main() 出发,追踪每一个函数调用、每一个 trait 方法调用、每一个泛型实例化,标记出真正需要的代码。
然后数一数编译器在"全量编译"和"只编译可达代码"两种情况下,分别执行了多少 CPU 指令。
|
|
全量 |
可达 |
缝隙 |
|---|---|---|---|
|
CPU 指令 |
28,559G |
18,067G |
37% |
|
分析的函数数 |
32,579 |
23,095 |
29% |
编译 Zed 时,编译器执行的 CPU 指令里,有 37% 花在了永远不会被调用的代码上。
你品一品。超过三分之一的工作是白做的。这不是四舍五入的误差,也不是等着被挖掘的微优化。这是 10 万亿条 CPU 指令哪!每次全新编译都白白烧掉。
而且这还不只是 Zed 的问题:
|
项目 |
代码行数 |
基线指令 |
可达指令 |
缝隙 |
|---|---|---|---|---|
| zed |
50万 |
28,559G |
18,067G |
37% |
| rustc |
60万 |
5,746G |
4,268G |
26% |
| zeroclaw |
8.6万 |
1,507G |
1,314G |
13% |
| helix |
10万 |
2,256G |
2,004G |
11% |
| ripgrep |
5万 |
314G |
298G |
5% |
| nushell |
20万 |
3,695G |
3,682G |
0.4% |
| bevy |
30万 |
3,807G |
3,791G |
0.4% |
有些项目的缝隙很小。Bevy 和 nushell 几乎把导入的东西都用到了——好样的。但 Zed 有 37% 的缝隙,rustc 有 26%,连 zeroclaw(一个相对较小的的Rust实现的OpenClaw)都浪费了 13%。
为什么有的缝隙大,有的小?遇到六脉神剑了吗?
缝隙大小取决于项目如何使用它的依赖。
大缝隙的项目(比如 Zed)有很多 library crate,API 很丰富,但 binary 只用了其中一小部分。Zed 引入了几百个 crate 来支持编辑器、终端、协作和 AI 功能。每个 crate 都被完整编译,即使 Zed 的 binary 只走了特定的代码路径。
小缝隙的项目(比如 bevy)更充分地使用了它们的依赖。一个游戏引擎导入了数学库,大概率会用到大部分数学函数。浪费就少。
这里还有一个有趣的放大效应。在 Rust 里,泛型是单态化(monomorphize)的——每个泛型函数针对它用到的每种具体类型编译一次。当你把一个不可达的函数替换成空桩,你同时也消除了它所有下游的单态化实例。这就是为什么 Zed 的指令缝隙(37%)比函数缝隙(29%)更大——每个被替换的函数级联消除了许多单态化实例。
客观评估
Rust 编译器并不慢。它只是做了太多工作。
它做了太多工作是因为独立编译——正是这个让 Cargo 增量编译快、让 crates 生态成为可能的架构——阻止了编译器知道什么它是真正需要处理的代码。
链接时优化(LTO)可以在编译之后消除死代码,但它不减少编译阶段本身的工作量。活儿已经干完了。
我们需要的是在编译之前就起作用的东西。在 crate 边界上告诉编译器:"这些公开函数里,这些才是下游真正调用的——其余的你可以跳过。"
现在,我们知道了缝隙的存在,也能精确地测量它。比如,对于 Zed,37% 的编译器工作是可以证明不必要的。
那么,怎么填补上这个缝隙呢?
我们能不能造一个工具,不修改编译器,就能识别出不可达的函数并在 LLVM 代码生成之前把它们消除? 而且还不能搞坏任何东西?
...未完待续...
开放原子旋武开源社区(简称“旋武社区”)是由开放原子开源基金会孵化及运营的技术社区,致力于在中国推广和发展Rust编程语言生态,推动Rust在操作系统、终端设备、安全技术、基础软件等关键领域的产业落地,构建安全、可靠、高效的软件基础设施。
更多推荐



所有评论(0)