以下文章来自微信公众号“嘻话 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 代码生成之前把它们消除? 而且还不能搞坏任何东西?

...未完待续...

Logo

开放原子旋武开源社区(简称“旋武社区”)是由开放原子开源基金会孵化及运营的技术社区,致力于在中国推广和发展Rust编程语言生态,推动Rust在操作系统、终端设备、安全技术、基础软件等关键领域的产业落地,构建安全、可靠、高效的软件基础设施。

更多推荐