以下文章来自微信公众号“嘻话 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的发展前沿动态和未来发展愿景,请持续关注

Logo

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

更多推荐