问题背景
原文:https://sharnoff.io/blog/why-rust-compiler-slow
原作者在将个人网站从传统部署方式迁移到容器化部署时,遇到了一个让人头疼的问题:每次代码变更后,Docker构建都需要将近4分钟时间,其中大部分时间都花在了Rust项目的编译上。
初始的简单Dockerfile构建时间分布:
- 下载依赖:10秒
- 编译项目:3分51秒
这对于快速迭代开发来说完全无法接受。
第一步:使用cargo-chef优化依赖缓存
首先尝试使用cargo-chef来缓存依赖构建,这是Docker Rust构建的标准优化方案:
FROM rust:1.87-alpine3.22 AS planner
COPY . .
RUN cargo chef prepare --recipe-path=/workdir/recipe.json
FROM rust:1.87-alpine3.22 AS cooker
COPY /workdir/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path=/workdir/recipe.json
FROM cooker AS builder
COPY . .
RUN cargo build --release --package web-http-server
然而,优化效果令人失望:
- 依赖编译:1分7秒
- 最终binary编译:2分50秒
问题发现:75%的编译时间仍然花在最终binary上,而不是依赖上!
深入分析:rustc到底在做什么?
使用cargo —timings
cargo build --release --timings
这个命令生成了详细的时间报告,但对于单一crate的编译,信息有限。唯一的收获是确认了174.1秒的精确编译时间。
使用rustc自分析功能
RUSTC_BOOTSTRAP=1 RUSTFLAGS='-Zself-profile' cargo build --release
通过measureme工具分析结果:
$ summarize summarize web_http_server.mm_profdata | head
+-------------------------------+-----------+-----------------+----------+------------+
| Item | Self time | % of total time | Time | Item count |
+-------------------------------+-----------+-----------------+----------+------------+
| LLVM_lto_optimize | 851.95s | 33.389 | 851.95s | 1137 |
| LLVM_module_codegen_emit_obj | 674.94s | 26.452 | 674.94s | 1137 |
| LLVM_thin_lto_import | 317.75s | 12.453 | 317.75s | 1137 |
| LLVM_module_optimize | 189.00s | 7.407 | 189.00s | 17 |
重大发现:80%的时间都花在了Link Time Optimization (LTO)上!
LTO性能调优
理解LTO设置
作者的配置中设置了lto = "thin"
,这是问题的根源。测试不同LTO和debug设置的组合:
LTO设置 | debug=none | debug=full |
---|---|---|
禁用LTO | 50.0s / 21.0Mi | 67.6s / 214.3Mi |
Thin local LTO | 67.5s / 20.1Mi | 88.2s / 256.8Mi |
”Thin” LTO | 133.7s / 20.3Mi | 172.2s / 197.5Mi |
”Fat” LTO | 189.1s / 15.9Mi | 287.1s / 155.9Mi |
关键洞察:
- 完全禁用LTO和debug符号后,编译时间降至50秒
- Fat LTO比禁用LTO慢4倍
- Debug符号增加30-50%编译时间
优化级别调优
既然依赖已经缓存,可以对最终binary和依赖设置不同的优化级别:
[profile.release]
lto = "off"
debug = "none"
opt-level = 0 # 最终binary禁用优化
[profile.release.package."*"]
opt-level = 3 # 依赖使用高优化级别
测试结果显示:
opt-level = 0
:14.7秒(但性能损失大)opt-level = 1
:48.8秒(较好的平衡)opt-level = 2/3
:~51秒
深入LLVM层面分析
使用LLVM时间跟踪
RUSTFLAGS='-Zllvm-time-trace' cargo build --release
生成了1.4GB的Chrome tracing格式文件!虽然现有的可视化工具都无法处理如此大的文件,但可以通过命令行分析:
# 转换为每行一个事件的格式
cat web_http_server.llvm_timings.json \
| sed -E 's/},/}\n/g;s/^\{"traceEvents":\[//g;s/\],"beginningOfTime":[0-9]+}$//g' \
> web-http-server.llvm_timings.jsonl
分析最耗时的操作
$ cat web_http_server.llvm_timings.jsonl | jq -r 'select(.name | startswith("Total ")) | "\(.dur / 1e6) \(.name)"' | sort -rn | head
665.369662 Total ModuleInlinerWrapperPass
656.465446 Total ModuleToPostOrderCGSCCPassAdaptor
632.441396 Total DevirtSCCRepeatedPass
182.250077 Total InlinerPass
189.621119 Total OptFunction
发现两个主要瓶颈:
- 内联优化 (InlinerPass)
- 函数优化 (OptFunction)
针对性优化
调节内联阈值
LLVM提供了内联相关的参数:
RUSTFLAGS="-Cllvm-args=-inline-threshold=50 -Cllvm-args=-inlinedefault-threshold=50 -Cllvm-args=-inlinehint-threshold=50"
将内联阈值从225降到10,编译时间从48.8秒减少到42.2秒。
分析函数级别优化
使用更新的symbol mangling v0格式获得更详细的函数信息:
RUSTFLAGS="-Csymbol-mangling-version=v0"
发现最耗时的函数优化:
web_http_server::photos::PhotosState::new::{closure#0}
: 1.99秒web_http_server::run::{closure#0}
: 1.56秒
async函数的问题
重要发现:rustc内部将async函数表示为闭包,导致符号显示为{closure#0}
。大型async函数特别难以优化。
解决方案:
- 拆分大型async函数
- 将复杂的Future包装为
Pin<Box<dyn Future>>
以简化类型
通过这种方法,PhotosState::new的优化时间从5.3秒减少到2.14秒。
最终优化结果
采用综合优化策略:
RUN RUSTFLAGS='-Cllvm-args=-inline-threshold=10 -Cllvm-args=-inlinedefault-threshold=10 -Cllvm-args=-inlinehint-threshold=10' \
cargo build --timings --release --target=x86_64-unknown-linux-musl --package web-http-server
优化历程:
- 初始状态:~175秒
- 禁用LTO和debug符号:51秒 (-71%)
- 调整opt-level到1:48.8秒 (-4%)
- 减少内联:40.7秒 (-16%)
- 本地代码优化:37.7秒 (-7%)
- 依赖项优化:32.3秒 (-14%)
最终结果:从175秒优化到32.3秒,提升81.5%!
关键教训
- LTO是双刃剑:虽然能提升运行时性能,但严重影响编译速度
- async函数复杂性:大型async函数会生成复杂的状态机,LLVM优化困难
- 工具链的力量:rustc的self-profiling和LLVM的时间跟踪提供了宝贵的性能洞察
- 渐进式优化:通过系统性分析,找到真正的瓶颈而非盲目优化
对Rust生态的思考
这次深度分析暴露了几个值得关注的问题:
- async函数编译性能需要改进
core::ptr::drop_in_place<T>
可能应该在定义T
的crate中编译- 需要更好的工具来帮助开发者识别编译瓶颈