加速 Rust 编译:缝合的效果
前面我们发现编译 Zed 要 17 分钟,而且已有的优化手段都无能为力。之后发现了原因:37% 的编译器工作花在了不可达代码上——"独立编译缝隙"。 现在,让我们把这个缝隙填补上。
以下文章来自微信公众号“嘻话 Tech”,作者俞一峻,已获授权。
前面我们发现编译 Zed 要 17 分钟,而且已有的优化手段都无能为力。之后发现了原因:37% 的编译器工作花在了不可达代码上——"独立编译缝隙"。 现在,让我们把这个缝隙填补上。
方案:四个步骤
核心想法很简单:搞清楚从 main() 出发,跨所有 crate 到底哪些代码是可达的,然后告诉编译器跳过其余的。实际操作起来,有几个细节需要处理好。
我们把这个方案叫做 PRECC(Predictive Precompilation Cutting,预测性预编译裁剪)。具体分为四个步骤:
第一步:提取(Extract)
编译开始之前,我们扫描所有 workspace crate 的源码,构建一个统一的跨 crate 调用图。对 Rust,我们用基于 syn 的解析器,从每个 .rs 文件中提取函数定义、调用点和公开 API。即使是大型项目,这也只需要几秒钟。
cargo-slicer pre-analyze # 构建跨 crate 调用图
对于 Zed,这会生成一个覆盖全部 198 个 workspace crate 的调用图:哪些函数存在、谁调用了谁、哪些是公开导出的。
第二步:分析(Analyze)
从 main() 出发,我们对调用图做 BFS(广度优先搜索)。从 main 可达的每个函数被标记为"需要"。其余的标记为"不可达"。
对特殊情况我们格外小心。Drop 实现?一定要保留(编译器会隐式插入 drop 调用)。Trait 实现?一定要保留(通过 dyn Trait 的动态分发可能调用到它们)。#[no_mangle] 的 FFI 函数?一定要保留。闭包、异步函数、unsafe 函数?一律保留。我们维护了 9 个类别的排除规则来确保安全。
结果:每个 crate 中可以被安全移除的函数集合。
第三步:预测(Predict)
这是最有意思的地方。天真的想法是"把所有不可达的都砍掉",但分析下来这会有开销——加载驱动、遍历 MIR、做缓存 I/O。对于不可达函数很少的 crate,这些开销比省下来的时间还多。
我们是吃了亏才学到这个教训的。对 bevy 的每个 crate 都做裁剪,编译反而慢了 4.4%。缝隙只有 0.4%,分析开销把那点微薄的收益都吞掉了。
所以对每个 crate,我们做一个预测:裁剪省下的时间,会不会超过分析的成本?如果会,裁剪。如果不会,跳过——正常编译。
我们的基线启发式规则很简单:
-
如果预测可替换的函数数为 0:跳过。
-
如果小于 5 且替换比率低于 2%:跳过。
-
否则:裁剪。
这在受益项目上达到了 92-100% 的精确率,同时正确跳过了那些裁剪反而有害的项目。
第四步:裁剪(Cut)
对标记为"裁剪"的 crate,我们拦截编译器。作为 RUSTC_WRAPPER 运行,我们在类型检查之后介入 rustc,把不可达的函数体替换为 MIR 级别的 abort 桩。函数签名保留(这样下游 crate 还能引用它),但函数体被替换为一条 abort() 指令。
当 rustc 的单态化收集器遇到一个被替换的函数时,它找不到任何被调用者——没有下游函数、没有泛型实例化、没有任何需要编译的东西。整个单态化图的子树被剪掉了。LLVM 永远看不到它们。
不修改源代码,不改 Cargo.toml,不动 feature flag。编译器只是做了更少的工作。
# 完整命令
cargo-slicer pre-analyze
CARGO_SLICER_VIRTUAL=1 CARGO_SLICER_CODEGEN_FILTER=1 \
RUSTC_WRAPPER=$(which cargo_slicer_dispatch) \
cargo +nightly build --release
或者,更简单地:
cargo-slicer.sh /path/to/your/project
结果
那 Zed 到底快了多少?
|
项目 |
基线 |
使用 PRECC |
编译时间 |
CPU 指令 |
峰值内存 |
|---|---|---|---|---|---|
| zed |
1,012秒 |
719秒 | -29% |
-37% |
-45% |
| rustc |
135.8秒 |
112.4秒 | -17% |
-26% |
-7% |
| zeroclaw |
192.9秒 |
170.4秒 | -12% |
-13% |
-11% |
| helix |
71.2秒 |
66.6秒 | -6% |
-11% |
-18% |
| ripgrep |
11.1秒 |
10.7秒 | -4% |
-5% |
-6% |
|
nushell |
106.5秒 |
108.9秒 |
+2.3% |
-0.4% |
— |
|
bevy |
81.8秒 |
85.4秒 |
+4.4% |
-0.4% |
— |
Zed 的编译从 17 分钟降到了 12 分钟。每次全新编译省 5 分钟。内存少用 45%。CPU 指令减少 37%。
Rust 编译器自身的编译快了 17%。Helix 快了 6%。Ripgrep 快了 4%。
看看最后两行。Nushell 和 bevy 的缝隙很小(0.4%),所以预测步骤正确地判断了它们不值得裁剪。如果不做预测,bevy 会慢 4.4%——开销超过了收益。有了预测,我们完全避免了这个回归。
局限性
那么,这个工具不能做什么呢?
-
增量编译:cargo-slicer 针对的是全新/干净的编译。对于增量的
cargo check和小改动,rustc 自带的增量编译已经很快了。我们解决的是 CI 和全新编译的问题。 -
小项目:如果你的项目 5,000 行代码、3 个依赖,缝隙很小,开销不值得。这个工具在较大的代码库(5 万行以上)上效果最好。
-
正确性保证:我们把函数体替换成了 abort 桩。如果可达性分析判断失误,一个被"替换"的函数在运行时被调用了,程序会 abort。实际上,我们的 9 个安全排除类别防止了这种情况——我们在所有基准项目上都测试过——但值得了解。
-
仅限 nightly:MIR 级别的钩子需要不稳定的 rustc API,所以需要 nightly 工具链。
来试试吧:
一条命令安装:
curl -fsSL https://raw.githubusercontent.com/yijunyu/cargo-slicer/main/install.sh | bash
然后编译任意 Rust 项目:
cargo-slicer.sh /path/to/your/project
就这样。不需要配置文件,不需要改源代码,不需要动 Cargo.toml。把它指向任何有 Cargo.toml 的 Rust 项目,看看效果。
我们想听到你的声音
我们是做研究的,不是算命的。上面的数据来自我们的测试机器(48 核、128 GB 内存、Linux)。你的结果会因项目的依赖结构、硬件配置和月相而异。
我们真心想知道这个工具在你的项目上表现如何。 加速了?还是出了问题?缝隙大不大?每一个数据点都帮助我们改进。
联系我们:
-
GitHub Issues: — bug 报告、基准测试结果、功能需求
-
微信:yijunyu8824001 — 详细的测试结果、合作、或者就是打个招呼
我们特别关注有 10 个以上 workspace crate、依赖使用量大的项目——通常那是缝隙较大的项目。
前瞻
独立编译缝隙不是 Rust 特有的。我们也把同样的原理应用到了 C 项目——把 SQLite 25.6 万行的 sqlite3.c 单体实现拆分成 2,503 个独立的编译单元,通过并行化实现了 5.8 倍的加速。
缝隙是独立编译本身的属性,跟特定的语言或编译器无关。只要编译器在隔离中处理代码、不知道什么是真正需要的,就存在潜在的浪费。
有浪费的地方,就有优化的空间。
感谢阅读。现在去编译点什么吧——会快一些的。
参考链接
-
GitHub: cargo-slicer
了解Rust的发展前沿动态和未来发展愿景,请持续关注
开放原子旋武开源社区(简称“旋武社区”)是由开放原子开源基金会孵化及运营的技术社区,致力于在中国推广和发展Rust编程语言生态,推动Rust在操作系统、终端设备、安全技术、基础软件等关键领域的产业落地,构建安全、可靠、高效的软件基础设施。
更多推荐


所有评论(0)