Rust 构建为什么这么慢?从工具链底层到实际优化的完整排查指南

本文是对 Why is my Rust build so slow?的整理与翻译


内容结构概览

markdown 复制代码
一、问题的起点:32 核、128GB RAM 的机器,热构建 71 秒
    - 项目规模:334 个 crate,54 个源文件,6483 行代码
    - "超过几秒就是慢" ------ 作者的基准认知

二、cargo 在做什么:构建流水线的全貌
    - .rlib 是 GNU ar 归档,里面是按 RCGU 分组的 .o 文件
    - nm / rustfilt / llvm-nm 查看符号
    - cargo 的三层结构:cargo → rustc(每 crate 一次)→ 链接器
    - 用 strace 实际验证 cargo 的系统调用序列
    - 用 RUSTFLAGS 切换到 lld 的方法

三、cargo --timings:构建时间的可视化
    - --timings 生成 cargo-timing.html,交互式时间线
    - 冷构建:420 个编译单元,"最慢 crate 排行榜"
    - 热构建的致命盲区:bin crate 的 72 秒变成不透明黑块
    - lld vs GNU ld:只快 3 秒,说明链接不是瓶颈

四、rustc 的自剖析:-Zself-profile
    - 启用方法(nightly 或 RUSTC_BOOTSTRAP)
    - measureme 工具套件:summarize / flamegraph / crox
    - summarize 输出:LLVM_lto_optimize、LLVM_module_codegen 的时间占比
    - crox + chrome://tracing 可视化每个函数的时间线

五、关键发现:codegen 和 LLVM 优化是主要瓶颈
    - 大量函数(包括泛型展开)在自己的 crate 里被重复实例化
    - 热构建慢是因为主 crate 的代码量大 + 单 crate 无法并行
    - 拆分成更小的 crate 能让更多工作并行

六、实用优化方案(全部可用、可配置)
    6.1 切换到更快的链接器(lld 或 mold)
        - 配置方法:.cargo/config.toml
        - lld vs GNU ld vs mold 的对比
        - sccache 不支持 mold 的注意事项
    6.2 开启增量编译(release 模式下默认关闭)
        - profile.release 中 incremental = true
        - CI 里用 CARGO_INCREMENTAL=0 关掉
    6.3 split-debuginfo:macOS 上减少 dsymutil 开销
    6.4 把大 crate 拆小:最根本的并行化手段
    6.5 dev-dependencies 的 opt-level 覆盖:
        调试慢不是因为你的代码,是因为依赖没有优化
    6.6 cargo check 替代 cargo build 做快速迭代

七、小结:一张优化决策地图

一、问题的起点

作者最近回到一个老项目(就是驱动他这个博客的 CMS),升级了几个 crate 版本,升级了 rustc,然后发现:构建花的时间太长了。

配置并不差。AMD Ryzen 9 5950X,32 个逻辑核,128GB 内存,快速 NVMe SSD。但:

bash 复制代码
$ time cargo build --release
      Finished release [optimized + debuginfo] target(s) in 2m 09s
cargo build --release  890.49s user 117.77s system 779% cpu 2:09.31 total

$ # 只改一行 main.rs,重新构建(热构建):
$ time cargo build --release
    Finished release [optimized + debuginfo] target(s) in 1m 11s
cargo build --release  127.99s user 8.42s system 191% cpu 1:11.32 total

冷构建 2 分 9 秒,热构建(只改一行代码)1 分 11 秒。

项目的规模:

bash 复制代码
$ cat Cargo.lock | toml2json | jq '.package | length'
334

334 个 crate(含传递依赖),54 个 Rust 源文件,6483 行代码。

作者的判断:冷构建慢可以理解,但热构建超过几秒就是慢。超过一分钟,是显然过分的。


二、cargo 在做什么:构建流水线的全貌

在开始找问题之前,先要理解 cargo 的构建过程。

.rlib 是什么

target/ 目录里有大量 .rlib 文件:

bash 复制代码
$ find target -name '*.rlib' | head -5
target/release/deps/libmatches-db00cdc86371b34a.rlib
target/release/deps/libcfg_if-038689491f275bed.rlib
target/release/deps/libpin_project_lite-06e0655a601f73df.rlib
target/release/deps/libfnv-663a3fe7793aefd3.rlib
target/release/deps/libtinyvec_macros-4b4139e126989f5f.rlib

它们其实是 GNU ar 归档(和 C 静态库格式完全相同):

bash 复制代码
$ file ./target/release/deps/libtree_sitter_highlight-dbbf005203d40df6.rlib
./target/release/deps/libtree_sitter_highlight-dbbf005203d40df6.rlib: current ar archive

每个 .rlib 里是一组 .o 文件,每个 .o 文件对应一个 Rust Codegen Unit(RCGU)------这是 rustc 并行代码生成时的工作单元:

bash 复制代码
$ ar t ./target/release/deps/libtree_sitter_highlight-dbbf005203d40df6.rlib
tree_sitter_highlight-dbbf005203d40df6.tree_sitter_highlight.f125e660-cgu.0.rcgu.o
tree_sitter_highlight-dbbf005203d40df6.tree_sitter_highlight.f125e660-cgu.1.rcgu.o
...(共 16 个 .o 文件)
lib.rmeta

查看 .rlib 里的符号,通过 nm 再配合 rustfilt 反混淆:

bash 复制代码
$ nm ./target/release/deps/libtree_sitter_highlight-...rlib | rustfilt | tail -5
core::ptr::drop_in_place<alloc::raw_vec::RawVec<tree_sitter::Node>>
core::ptr::drop_in_place<alloc::vec::Vec<tree_sitter_highlight::LocalScope>>
<tree_sitter::Tree as core::ops::drop::Drop>::drop
<tree_sitter::QueryCursor as core::ops::drop::Drop>::drop
<alloc::vec::into_iter::IntoIter<T,A> as core::ops::drop::Drop>::drop

cargo 构建的三层结构

rust 复制代码
cargo → rustc(每个 crate 调用一次)→ 链接器(最后一步,生成二进制)

区别于 C/C++ 的"每个源文件生成一个 .o",Rust 是"每个 crate 调用一次 rustc",rustc 内部再分 RCGU 并行。

cargo build --verbose 可以看到每次 rustc 调用的完整参数,包括 --crate-name--edition-C metadata(用于缓存失效的哈希)等。

用 strace 直接偷听 cargo 的系统调用

不想依靠 verbose 输出,可以直接用 strace 拦截所有 execve 调用:

bash 复制代码
$ cargo clean && strace -f -e execve -- cargo build --quiet 2>&1 | grep -E 'execve\(.*= 0'

输出中可以看到:cargo 调用了 rustc -vV(查版本),调用了多次 rustc --print=file-names(探测功能支持),然后是真正的编译调用,最后是链接器(cccollect2ld)。

切换到 lld(LLVM 的链接器):

bash 复制代码
$ RUSTFLAGS="-C link-args=-fuse-ld=lld" cargo build --quiet

strace 可以确认最后的链接调用从 /usr/bin/ld 变成了 /usr/bin/ld.lld

更规范的配置方式是写入 .cargo/config.toml

toml 复制代码
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

三、cargo --timings:让构建时间可视化

知道了 cargo 在做什么,还不够。需要知道每个步骤花了多少时间。

bash 复制代码
$ cargo clean && cargo build --release --quiet --timings

这会在项目目录下生成一个 cargo-timing.html,是一个交互式的时间线报告,包含:

汇总视图:显示编译单元总数、Fresh(缓存命中)数量、并行利用率等。

时间线视图:所有 420 个编译单元的并行执行情况,横轴是时间,每行是一个编译单元。拖动"Min unit time"滑块可以隐藏那些编译很快的单元,只看慢的。

最慢 crate 排行榜:直接点出哪些 crate 占用了最多编译时间。

热构建的盲区:72 秒黑块

对于热构建 (只改一行 main.rs 后重新构建),--timings 的报告几乎没有信息:

由于这是一个 bin crate,cargo --timings 不会报告 codegen 时间,也不会报告链接时间------这两个环节在热构建中往往是主要成本,但它们对 --timings 来说是不透明的。

结果是:71 秒变成了一个大黑块,显示 cargo 在"做某些事",但不知道是什么。

lld 和 GNU ld 的对比

用 GNU ld(系统默认链接器)重测后发现:lld 比 GNU ld 快 3 秒。

但总构建时间是 71 秒,节省 3 秒意味着链接本身大约只占了 3-4 秒,剩下 67-68 秒在别的地方。

结论:链接不是瓶颈。作者项目的慢,主要在 codegen(代码生成)阶段。


四、rustc 的自剖析:-Zself-profile

--timings 看不到 codegen 和链接的细节,需要更深的工具:rustc 自带的自剖析功能。

启用方式

这是 nightly 特性,但可以通过 RUSTC_BOOTSTRAP=1 在稳定版 rustc 上启用(注意:这是"非官方"用法):

bash 复制代码
$ RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Zself-profile" cargo build --release

编译完成后,当前目录里会生成一批 .mm_profdata 文件,每个 crate 一个:

css 复制代码
main-1234567.mm_profdata
serde-8901234.mm_profdata
tokio-2345678.mm_profdata
...

measureme 工具套件

Rust 编译器团队维护了一套叫 measureme 的工具来分析这些文件:

bash 复制代码
$ cargo install --git https://github.com/rust-lang/measureme summarize flamegraph crox

三个工具:

  • summarize:文本形式的汇总表,每行是一个编译阶段和它的耗时
  • flamegraph:生成火焰图 SVG,直观看哪些阶段占比最大
  • crox :生成 Chrome Tracing 格式 JSON,可以在 chrome://tracing 里打开,看到每个函数的时间线和调用层次

summarize 的输出格式

bash 复制代码
$ summarize summarize main-1234567.mm_profdata | head -20
+-------------------------------+-----------+-----------------+----------+------------+
| Item                          | Self time | % of total time | Time     | Item count |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_lto_optimize             | 42.35s    | 58.3%           | 42.35s   | 16         |
| LLVM_module_codegen           | 12.18s    | 16.8%           | 12.18s   | 16         |
| LLVM_module_optimize          | 8.44s     | 11.6%           | 8.44s    | 16         |
| codegen_crate                 | 3.21s     | 4.4%            | 65.93s   | 1          |
| type_check_crate              | 1.82s     | 2.5%            | 2.11s    | 1          |
...

数据很清楚:LLVM 相关的阶段(LTO 优化、模块代码生成、模块优化)占据了绝大部分时间

真正的罪魁祸首

热构建慢的根本原因:泛型代码的单态化(monomorphization)

Rust 的泛型(和 C++ 的模板一样)是在编译时展开的------每次使用 Vec<MyType>,rustc 都要为 MyType 生成一份完整的 Vec 实现。这些展开后的代码都要经过 LLVM 的完整优化流水线,对于 release 构建,这个成本非常高。

当主 crate 代码量大(作者的 6483 行都在一个 crate 里),所有的单态化都在最后这个 crate 里发生,无法并行,串行跑完整个 LLVM 优化。


五、优化方案:从快到慢,从简单到彻底

1. 切换到更快的链接器

虽然对这个项目影响不大(只快 3 秒),但对很多大型项目,链接可能占到热构建时间的大头。

永久配置在 ~/.cargo/config.toml(全局生效)或项目的 .cargo/config.toml

toml 复制代码
# Linux(使用 lld)
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

# macOS(使用 lld)
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

# Linux(使用 mold,更快但不支持 sccache)
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

注意:mold 是目前最快的链接器,但与 sccache 不兼容。如果 CI 环境使用 sccache,不能用 mold。

2. 开启 release 模式的增量编译

增量编译默认对 debug 构建开启,对 release 构建关闭。在频繁做 release 构建的场景下,打开它对热构建很有帮助:

toml 复制代码
# Cargo.toml
[profile.release]
incremental = true

CI 环境里用环境变量关掉(增量编译的工件不好缓存,占空间,而且会使 sccache 失效):

bash 复制代码
export CARGO_INCREMENTAL=0

作者的推荐:本地开发永远开增量,CI 永远关增量。

3. macOS 专属:split-debuginfo

macOS 上,当调试信息开启时(cargo build 默认开启),rustc 编译完后会调用 dsymutil 把 DWARF 调试信息合并。这个工具不支持增量,每次都要完整跑,哪怕只改了一行代码。

对于增量热构建,这可能占到总时间的 60-70%。

~/.cargo/config.toml 中配置:

toml 复制代码
[profile.dev]
split-debuginfo = "unpacked"

[profile.release]
split-debuginfo = "unpacked"

unpacked 让调试信息保持拆散状态,不调用 dsymutil。代价是调试器(lldb)加载稍慢,但如果你只需要崩溃时的行号,完全不影响使用。

4. 把大 crate 拆成小 crate

这是最根本的解法

Rust 的并行单位是 crate,不是文件。一个有 54 个文件、6483 行代码的单一 crate,哪怕有 32 个 CPU 核,编译时也只能用一个核在跑(等依赖都编完之后)。

把主 crate 拆成若干更小的 crate(可以用 Cargo workspace 管理),让它们能并行编译,同时每个 crate 的单态化代码量也减少,LLVM 优化的工作量就小。

实际操作:把相对独立的功能模块提取为单独的 crate,放在 crates/ 目录下,主 crate 依赖它们。

bash 复制代码
your-app/
├── Cargo.toml          # workspace 根
├── crates/
│   ├── db/             # 数据库层
│   ├── api/            # HTTP 接口
│   ├── models/         # 数据模型
│   └── utils/          # 工具函数
└── src/
    └── main.rs         # 主入口,只做组装

5. dev-dependencies 的 opt-level 覆盖

有时候开发中需要跑 debug 构建,但某些依赖(压缩、图像处理、加密)在未优化时太慢,影响开发体验。

不需要对自己的代码开优化,只需要对依赖开优化:

toml 复制代码
# Cargo.toml
[profile.dev.package."*"]
opt-level = 3  # 所有依赖用 release 优化级别

[profile.dev]
opt-level = 0  # 自己的代码不优化(保持快速编译)

这让 debug 构建里依赖的性能接近 release,同时自己的代码保持最快的编译速度。

6. cargo check 替代 cargo build

如果只是要验证代码能通过类型检查(不需要真正运行),用 cargo check 代替 cargo build

cargo check 跳过了代码生成阶段,只做类型检查和借用检查,速度通常是 cargo build 的几倍到十几倍。

bash 复制代码
cargo check          # 检查 library
cargo check --bin    # 检查 binary
cargo test --no-run  # 编译测试但不运行(比 cargo test 更快反馈编译错误)

大多数编辑器(VSCode + rust-analyzer、IntelliJ 系)在后台运行的就是 cargo check,这就是为什么保存文件后几秒就能看到错误提示。


六、诊断工具清单

把所有用到的工具整理在一起,方便查阅:

工具 用途 安装方式
cargo --timings 构建时间线可视化,找最慢的 crate 内置,无需安装
cargo build --verbose 查看完整的 rustc 调用参数 内置
strace -f -e execve 监听 cargo 实际执行的所有子进程 系统包管理器
-Zself-profile rustc 内部各编译阶段的时间统计 nightly 或 RUSTC_BOOTSTRAP=1
summarize 文本形式汇总 self-profile 数据 cargo install summarize(measureme)
flamegraph 火焰图可视化 self-profile cargo install flamegraph(measureme)
crox Chrome Tracing 格式,在浏览器里看时间线 cargo install crox(measureme)
rustfilt 反混淆 Rust 符号名 cargo install rustfilt
tokei 统计代码行数 cargo install tokei
nm / llvm-nm 查看编译产物里的符号 系统包 / LLVM 套件
ar / llvm-ar 查看 .rlib 归档内容 系统包 / LLVM 套件

七、关于 LLVM 和 codegen-units 的权衡

文章后半段还提到了一个值得关注的权衡:

codegen-units 控制 rustc 把一个 crate 分成多少个并行编译单元。默认值:debug 模式是 256,release 模式是 1。

  • codegen-units = 1(release 默认):LLVM 可以跨整个 crate 做全局优化,生成最快的代码,但编译最慢(完全串行)
  • codegen-units = 16(甚至更高):编译可以并行,速度快,但 LLVM 的优化视野被切碎,运行时性能可能略有下降

如果你对本地的 release 构建速度有需求(比如本地调试性能问题),可以临时增大 codegen-units:

toml 复制代码
[profile.release]
codegen-units = 16  # 更快编译,但运行时性能略降
lto = "thin"        # thin LTO 是个不错的中间方案

发布到生产的构建,建议保持 codegen-units = 1,必要时再加 lto = true(fat LTO,最慢编译但最快运行)。


小结

文章的结论可以浓缩成一张决策路径:

ini 复制代码
Rust 构建慢了
  ├── 冷构建慢
  │   ├── 用 cargo --timings 看最慢的依赖
  │   ├── 看 crate 之间的并行情况
  │   └── 考虑减少依赖数量,或切换更轻量的替代
  │
  └── 热构建慢(改一行代码重编很慢)
      ├── 检查:主 crate 是不是太大(拆分成更小的 crate)
      ├── 开启增量编译(profile.release incremental = true)
      ├── macOS:开启 split-debuginfo = "unpacked"
      ├── 用 -Zself-profile 确认瓶颈在 LLVM 还是类型检查
      └── 链接慢:切换到 lld 或 mold

作者在文末补了一句诚实的话:热构建 71 秒,即使换了 lld 只节省了 3 秒。真正的问题是主 crate 太大,所有代码在最后被串行编译。拆分 crate 才是根治,但那是另一篇文章(代号 "When rustc explodes")里继续讲的故事。


参考链接

相关推荐
用户9772654613841 小时前
Boto3:Python 开发者操作 AWS 的官方 SDK
后端
程序员cxuan1 小时前
姚顺雨这次访谈,腾讯终于把 AI 下半场讲明白了
人工智能·后端·程序员
神奇小汤圆1 小时前
开源:把自己"博客转推文"蒸馏成一个 Agent Skill
后端
雪隐2 小时前
个人电脑玩AI-02让5060 Ti给你打工——Whisper语音识别篇(下)
人工智能·后端
道友可好3 小时前
Superpowers vs OpenSpec vs Spec Kit:该选哪个?
前端·人工智能·后端
武子康3 小时前
Java-19 深入浅出MyBatis 代理模式:从 Java 动态代理到 Mapper 接口的底层原理
java·后端
郑洁文3 小时前
基于Springboot的足球青训俱乐部管理系统的设计与实现
java·spring boot·后端·足球青训俱乐部管理系统
阿聪谈架构3 小时前
第14章:多模态AI实战 —— 让AI"看懂"图片和文档
人工智能·后端
Oneslide4 小时前
rsync 大数据量同步中断问题
后端