一个普通的周二早上,CI 突然快了三成

那天下午茶时间,同事突然在群里发了条消息:「诶,今天 CI 是不是换机器了?怎么跑这么快?」

我一愣,切到 CI 面板一看——我们那个跑了八个月、每次都雷打不动 42 分钟的流水线,这次显示 27 分钟。我刷新了一下,没变,27 分 14 秒。

不是我改了什么代码,不是我加了新的缓存,也不是我半夜起来优化了什么配置。是 Rust 编译器悄悄升级了。

就这么悄无声息地,我们的 CI 墙钟时间(wall-clock time)一夜之间少了 35%。那个早上,我第一次真切地感受到——编译器团队的优化,对我们这些写业务代码的人来说,是真的可以"白嫖"的。

但先别急着高兴,这事儿没那么简单。

编译慢起来,真的会死人

你可能会说,编译慢就慢呗,开个会回来不就完事了?

但如果你每天都要面对这种场景,你就不会这么说了。

当一次完整编译要 40 分钟以上时,工程师们会开始逃避——不愿意 rebase,因为又要从头编译一遍;不愿意做大规模的 refactor,因为改一个文件可能要等半小时才能验证;CI 队列开始堆积,紧急的 hotfix 只能排在 feature branch 后面干等。

我们团队当时就是这个状态。每次发版前那几天,大家都不太敢动代码了,因为知道一改就可能要重新等编译。更糟糕的是,有些人干脆放弃增量编译,每次都跑 clean build——反正机器不是自己的,浪费的又不是我的时间。

这种现象,学术上叫"编译疲劳"。翻译成人话就是:编译器慢到一定程度,它就开始偷走团队的生产力、信心和睡眠。

那些年我们试过的"偏方"

面对编译慢这个问题,大多数团队的第一反应是一些看起来很合理的解决方案:

买更快的机器。8核不够换16核,16核不够换32核。这招管用,直到你发现 CI 账单开始失控,而且机器再快也架不住代码量膨胀。

打开增量编译。Rust 的增量编译默认是开的,但实际效果嘛…有时候改一行代码几乎不触发重编,有时候却像触发了一场小型核爆,整个依赖图谱全部重新编译。不可预测,就等于不可信任。

拆分大 crate。这个也有效,但拆分是有成本的——你要重新设计模块边界,要处理跨 crate 的依赖,要接受 IDE 支持可能会变差。

这些方法都试过之后,我们发现一个问题:当代码库大到一定程度,瓶颈已经不是某一个环节了,而是编译器整个 pipeline 本身的协调开销。

前端分析、单态化、代码生成、链接——这些阶段在理论上都是可以并行的,但在实践中,它们都在争抢全局锁和共享队列。8核的 CI runner,CPU 使用率长期徘徊在 40% 到 50%,眼睁睁看着那么多核在摸鱼。

Rust 编译器管道到底改了啥?

新管道并不是让 Rust 变快了,而是把工作重新排列组合了。

它更激进地解耦了分析、代码生成和链接这几个阶段。这让调度变得更细粒度,减少了全局同步点——那些曾经让整个构建流程干等的关键阻塞点没了。

Rust 编译器管道对比:旧管道 vs 新管道

对我们这种大型 crate、泛型用的飞起、依赖图谱深不见底的项目来说,这改动简直是量身定制的。

举个具体的例子。以前在代码生成阶段,编译器虽然名义上是并行的,但实际上很多工作都在排队等一个全局的调度器。现在,每个编译单元更独立了,可以更充分地利用多核。

所以我们看到了这样的变化:CPU 使用率从 40-50% 飙升到 85-90%,不是变热了,是终于把该干的活都干完了。

数据不说谎,但会让人纠结

升级后的第一周,我们收集了一堆数据,结论有点意思:

编译时间改进数据对比

全量编译(clean build):

  • P50 时间:从 31 分钟降到 20 分钟
  • P99 时间:从 46 分钟降到 30 分钟
  • 什么都没改,什么配置都没调,就是快了

增量编译(incremental build):

  • P50 快了约 20%,小修改依然很快
  • P99 反而慢了 10-15%,特别是那些泛型-heavy 的模块

这就有点尴尬了。

以前增量编译表现不稳定,但至少方差没那么大。现在中位数变好了,但极端情况的方差变大了。有些改动的编译时间和以前差不多,有些反而更慢。

为什么会这样?因为新的管道让各编译单元更独立了,这提高了并行度,但也暴露了一些以前被全局 stall 隐藏的问题。有些模块其实偷偷依赖了整个世界,只是以前被全局等待掩盖了。现在,每个模块的真实成本都被放到了火焰图上,清清楚楚。

我们的工程师一开始不适应。有人抱怨:“以前改完等 5 分钟,现在要等 7 分钟?“但他们没注意到的是——以前改完等 5 分钟的情况变少了,大多数情况是 3 分钟就搞定。

只是人的大脑天生对负面信息更敏感,所以偶尔的变慢会比稳定的变快更容易被记住。

内存这个老朋友,换了种方式折磨人

我们 CI 之前的另一个痛点是链接器。内存峰值(Peak RSS)经常飙升,导致共享 CI runner OOM 被杀。

新管道做了一件事:把更多的中间状态同时保留在内存里,以支持更好的并行调度。

结果是:内存峰值来得更早,但曲线更平滑了。总内存用量稍微高了一点,但不再有那么吓人的 spikes。对那些内存本身就紧张的 CI 机器来说,这其实是好事——失败来得更早、更确定,至少你知道问题出在哪里,而不是在一个莫名其妙的时间点突然挂掉。

但如果你本身的 CI 机器就配得紧巴巴的,这次升级可能会让你更早触到内存天花板。

几个血泪教训

如果你也想升级 Rust 编译器来获得这个 35% 的加速,以下是我们用实际经历换来的几点建议:

别盲目升级 CI。 如果你的 CI 本身就在内存极限边缘徘徊,这次升级可能不会帮你,反而会让机器更早 OOM。先在小流量机器上试试,确认没问题再全面推广。

这不是银弹。 如果你的编译时间主要花在宏展开、build script、或者 rustc 之外的代码生成(比如 bindgen 之类),这次升级对你的帮助有限。它解决的是编译器内部协调的问题,不是所有编译瓶颈。

这面镜子会照出你的 crate 结构问题。 新管道会让那些结构不合理的 crate 暴露无遗。泛型太多、依赖太乱、模块划分不当——以前可能只是编译慢,现在你会发现某些改动就是会触发大规模的重新编译。某种程度上,这是好事,至少你知道问题在哪了。

升级之后,先跑一次 profiling。 用 CPU 和内存 profiling 跑一次 clean build,看看时间都花在哪了。然后针对性地优化——比如拆分一下那个霸占 30% 代码生成时间的 crate。这才是这次升级的正确用法:当一个 forcing function,逼迫你去解决那些一直拖着没动的结构问题。

所以,这波升级到底值不值?

35% 的编译时间缩减是真实的,我们 CI 面板上的数字不会骗人。但这 35% 不是白给的,它有一些前提条件:

你需要有足够的内存余量来支持更高的峰值并发。你需要接受增量编译的方差变大这件事。你需要把这次升级当作一个契机,去审视和改进那些被忽略已久的 crate 结构问题。

如果你的编译时间本身就很短(比如几分钟),这次升级你可能感受不到明显变化。如果你的 CI 机器内存紧张,可能还会倒退。如果你的 crate 图谱一团糟,编译器现在会把这个问题原原本本地展示给你看。

但如果你和我们一样——中型到大型代码库,有一定的资源余量,只是需要一个契机来认真对待编译优化——那这次升级带来的 35% 加速是实打实的。

更重要的是,它教会了我一件事:性能优化奖励那些愿意测量的人,惩罚那些瞎猜的人。

以前我们讨论编译优化,都是凭感觉:“这个模块应该很慢吧?““那个依赖看起来很重。“现在?拉个火焰图,数据说话。哪个 crate 占用时间最多?拆分哪个最划算?一切都有数据支撑。

最后说几句

这波编译器升级让我意识到一件事:当我们抱怨编译慢的时候,往往不只是"慢"这么简单。慢的背后是资源利用率不合理,是模块边界不清晰,是工具链和代码结构之间的不匹配。

Rust 编译器团队花了大力气重新设计整个管道,才给我们换来这 35% 的加速。作为使用者,我们不能只是躺平等着"白嫖”——我们应该借此机会,去解决那些阻碍我们真正高效的问题。

下次 CI 跑完的时候,不妨花三分钟看看火焰图。也许你会发现,那个你一直觉得"应该没问题"的模块,其实正在偷偷吃掉你 40% 的编译时间。


如果这篇文章对你有帮助,欢迎点赞、转发,让更多还在被编译时间折磨的同行看到。也欢迎在评论区分享你的编译优化经验——你踩过什么坑?有什么独门秘籍?

关注梦兽编程,咱们下篇继续聊 Rust 的性能优化故事。