本文是对 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(探测功能支持),然后是真正的编译调用,最后是链接器(cc → collect2 → ld)。
切换到 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")里继续讲的故事。
参考链接
- 原文:fasterthanli.me/articles/wh...
- 续集(pathological case 排查):fasterthanli.me/articles/wh...
- 链接器性能剖析:fasterthanli.me/articles/pr...
- cargo --timings 文档:doc.rust-lang.org/cargo/refer...
- measureme(自剖析工具套件):github.com/rust-lang/m...
- rustfilt(符号反混淆):lib.rs/crates/rust...
- tokei(代码行数统计):lib.rs/crates/toke...
- toml2json(Cargo.lock 转 JSON):github.com/woodruffw/t...
- mold 链接器:github.com/rui314/mold