本文是对 When rustc explodes 的整理与翻译。
内容结构概览
- 为什么关心编译时间:紧反馈循环对大型 Rust 项目非常重要。
- 前情回顾:普通 Rust 编译慢可以先看 cargo timings、linker、debug info、incremental、LTO、self-profile。
- 真正的问题:有些慢不是普通配置问题,而是 rustc 内部遇到了病理案例。
- 真实来源:Tower 服务栈:Tower 的
Service/Layer抽象会生成非常大的嵌套类型。 - 为什么 Tower 类型会膨胀:没有 async trait method,于是使用 associated type
Future,层层 wrapper 组合后类型巨大。 - 最小复现代码:一个带生命周期参数、4 个 associated type、递归引用实现的 trait。
- 指数级编译时间:depth 从 7 到 10,每加一层引用,
rustc --emit=metadata时间大约乘以 4。 - 先用
RUSTC_LOG=info看 rustc 在做什么:观察 metadata 读取、type checking、trait selection。 - 核心线索:projection obligations:
Trait::A/B/C/D = ()这些 associated type projection 反复出现。 - 去掉 projection 后瞬间变快:说明 associated type projection 是爆炸的重要触发点。
-Z self-profile:用 rustc 自带 profiling 观察时间花在哪里。- 普通用户有没有义务这么查:没有,但知道怎么查可以帮助定位 rustc 病理问题。
- 为什么要自编译 rustc:稳定版 rustc 的 debug tracing 被静态裁掉,需要自己构建才能看更深层日志。
- 用
perf采样 rustc:从函数采样、call graph、DWARF 栈信息看 trait selection 路径。 perf的坑:缺 debug info、frame pointer 不完整、默认 stack 捕获大小太小。- 用 Firefox Profiler / Speedscope 看 profile:把采样结果可视化,看到
evaluate_predicate_recursively深层递归。 nperf更舒服:生成更小 profile,并自动做 symbolication、flamegraph 和 chrome tracing。- callgrind 尝试失败:Valgrind 对 DWARF5 支持问题导致这条路不顺。
- 用 jq 分析 self-profile JSON:不只看火焰图,还能数 span、看嵌套深度和参数。
- 给 rustc 加自定义 profiling 点:在
evaluate_predicate_recursively里记录 obligation 参数。 - 用 Jaeger 看 rustc tracing:把 rustc 内部 tracing span 发到 Jaeger,在浏览器里展开递归树。
- 最终观察:内层 obligations 本该缓存复用,但在这个案例里被反复递归求解。
- 结论:问题根源在 trait solver 对某类 projection / higher-ranked obligation 的缓存或复用不足。
- 这篇文章的意义:不是要求普通 Rust 用户都去修 rustc,而是展示遇到编译器性能病理时,可以如何一步步把黑盒打开。
Rust 编译时间是一个老生常谈的话题。有人觉得还好,自己的项目几十秒就能跑完;也有人在真实工作项目里等到怀疑人生,CI 一跑几个小时,本地改一行代码也要等很久。讨论这类问题很容易变成两种极端:一边说“我这边没问题”,另一边说“你根本没见过我们项目有多痛苦”。
这篇文章讨论的不是普通意义上的“Rust 编译慢”,而是更具体的一类问题:当你已经看过 Cargo timings,已经换过更快的 linker,已经调过 debug info、incremental compilation、LTO,甚至已经用过 rustc self-profile,但仍然怀疑自己撞上了 rustc 内部某个病理案例时,可以继续怎么查。
所谓“病理案例”,不是项目依赖多、宏多、代码多导致的正常慢,而是某种类型系统结构让编译器重复做大量工作,复杂度突然从线性或多项式变成指数级。文章里复现的例子非常小,不依赖任何 crate,只有一个 trait、几个 associated type、多层引用和一个高阶生命周期约束。就是这么一点代码,depth 每加一层,编译时间大约乘以 4。
这就很值得拆开看看了。
一、为什么编译时间会影响开发体验
工程里有很多痛苦是没办法完全消除的。需求会变,团队沟通会有损耗,测试覆盖和代码灵活性之间要取舍,架构演进也总是会留下历史包袱。这些问题有内在复杂性,不能靠一个开关解决。
但有些痛苦不一定非忍不可。比如每次改一行代码都要等很久,眼睁睁看编译器转半天。
对一个需要快速迭代的项目来说,紧反馈循环非常重要。你写一点、测一点、重构一点、再测一点,这种小步快跑能帮助你保持上下文。如果每次反馈都要等几分钟甚至几十分钟,你就会开始减少验证次数,倾向于一次改很多东西,然后一次性构建,最后出错时也更难定位。
所以编译时间不是单纯的“等待成本”,它会改变开发方式。Rust 的类型系统能在编译期帮你抓很多问题,但如果编译反馈太慢,就会削弱这种体验。
之前已经有很多方法可以排查 Rust 编译慢:看 cargo build --timings,看哪个 crate 编得久;换 lld 或 mold 这类更快 linker;降低 debug info 等级;确认 debug 构建开启 incremental;避免不必要的 LTO;用 -Z self-profile 和 measureme 工具看 rustc 内部阶段;把大 crate 拆小,让 cargo 有更多并发机会。
这些都很实用。但这篇文章想问的是:如果这些都看过了,你还是觉得 rustc 在某段代码上表现异常,那接下来怎么办?
二、真实来源:Tower 服务栈里的巨大类型
最初遇到这个性能问题,是在一些 Tower 相关代码里。
Tower 是 Rust 异步网络生态里非常重要的一层抽象。Hyper 使用它,Warp、Axum 这类上层 Web 框架也会间接依赖它。Tower 的核心抽象之一是 Service。一个 Service<Request> 大致表达:给它一个请求,它返回一个 future,future 完成后得到响应或错误。
由于很长一段时间 Rust trait 里不能直接写 async method,Service 不能简单写成:
trait Service<Request> {
async fn call(&self, req: Request) -> Result<Response, Error>;
}
它必须通过 associated type 表达返回的 future:
trait Service<Request> {
type Response;
type Error;
type Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
这套设计很强大。poll_ready 可以表达 backpressure。也就是说,一个服务可以告诉上游:“我现在还不能接更多请求,等我准备好了再唤醒你。”这和异步运行时里的 Waker、socket readiness、timer、semaphore 等机制配合起来,可以构建非常灵活的网络服务。
Tower 还有 Layer。一个 layer 可以包住一个 service,给它加功能:日志、限流、重试、错误处理、压缩、请求改写、WebSocket 升级、负载均衡等。你可以用 ServiceBuilder 把这些层一层层套起来。
从使用体验看,这很舒服:
let service = ServiceBuilder::new()
.layer(TraceLayer)
.layer(RetryLayer)
.layer(HandleErrorsLayer)
.service(MyService);
但类型系统看到的不是“一个 service builder”,而是一棵巨大的嵌套类型。每加一层 wrapper,类型就长一截。最终传给 Hyper 的不只是 MyService,而可能是 Trace<Retry<HandleErrors<...<MyService>>>> 这种层层嵌套的类型。
这会带来两个问题。
第一个问题是错误信息很难看。类型错误里可能塞满几十层泛型 wrapper,普通人很难一眼看懂。
第二个问题更严重:这些巨大类型会让 rustc 的 trait 求解撞上某些慢路径。不是普通慢,而是随着层数增加呈指数级变慢。
三、最小复现:几行代码让 rustc 时间爆炸
为了排除真实项目中的噪音,文章使用了一个非常小的复现代码。它没有依赖,没有宏,没有 cargo,只需要直接调用 rustc。
核心结构大概是这样:定义一个带生命周期参数的 trait,里面有几个 associated type:
trait Trait<'a> {
type A;
type B;
type C;
type D;
fn method() {}
}
然后给引用类型实现这个 trait:
impl<T> Trait<'_> for &'_ T
where
for<'x> T: Trait<'x, A = (), B = (), C = (), D = ()>,
{
type A = ();
type B = ();
type C = ();
type D = ();
}
再给 () 实现这个 trait:
impl Trait<'_> for () {
type A = ();
type B = ();
type C = ();
type D = ();
}
最后,通过 cfg 控制嵌套引用深度:
fn main() {
#[cfg(depth = "7")]
<&&&&&&&() as Trait>::method();
#[cfg(depth = "8")]
<&&&&&&&&() as Trait>::method();
}
这个代码的特点是:每多一个 &,rustc 就要证明“这个更深一层的引用类型也满足 Trait,并且 associated type A/B/C/D 都是 ()”。按直觉看,这应该可以递归证明,而且中间结果应该可以缓存。证明过 &() 满足条件后,证明 &&() 应该复用上一层结果,继续往上推。
但实际测量非常糟糕。用 rustc --emit=metadata 避开完整 LLVM codegen,只看接近 cargo check 的阶段,结果大概是:
depth = 7 0.17s
depth = 8 0.76s
depth = 9 3.38s
depth = 10 15.53s
每增加一层,时间大约乘以 4。这就是指数增长。不是“稍微慢一点”,而是“层数再高一点就爆炸”。
四、先用 RUSTC_LOG 看 rustc 在忙什么
rustc 内部使用了 tracing,所以可以通过 RUSTC_LOG 打开日志。先用最保守的 info 级别:
RUSTC_LOG=info rustc src/main.rs --emit=metadata --cfg 'depth = "1"'
输出里一开始是加载 crate metadata。rustc 会解析 std、core 这些依赖,读取 rlib metadata。然后进入 type checking。日志中开始出现 rustc_typeck、rustc_trait_selection 这些模块名。
真正值得注意的是 trait selection 相关日志。里面反复出现类似 normalize_with_depth_to 的记录,值里带着 ProjectionPredicate。所谓 projection,大致就是 associated type 相关的约束,比如:
<T as Trait<'x>>::A = ()
<T as Trait<'x>>::B = ()
<T as Trait<'x>>::C = ()
<T as Trait<'x>>::D = ()
这些 associated type projection 是关键。
为什么?因为如果把那个 where 约束改简单,只保留:
for<'x> T: Trait<'x>
也就是去掉 A = ()、B = ()、C = ()、D = () 这些 projection,编译瞬间变快。depth 8 不再慢到接近一秒,而是几乎立刻完成。
这说明问题不只是“引用嵌套很深”,也不只是“高阶生命周期约束很复杂”。真正让复杂度爆炸的是:高阶生命周期约束、递归 impl、associated type projection 组合在一起。
五、普通 Rust 用户有没有义务查到这一步
文章中间有一个很重要的态度:普通 Rust 用户没有义务深入 rustc 内部。
如果你只是写业务代码,遇到编译慢,能做的事情通常是:减少不必要依赖、拆 crate、调 profile、换 linker、报告 issue、等待编译器优化。没有人应该要求普通用户自己拿 perf、self-profile、自编译 rustc 去追 trait solver。
但另一方面,如果你有兴趣,或者你维护的是一个广泛使用的库,或者你已经有了一个很小的复现代码,那么了解这些工具很有价值。因为它能把“rustc 很慢”变成更具体的问题:“rustc 在这个函数里对这些 obligations 做了重复递归求解,缓存似乎没有命中。”
这种信息对编译器开发者更有用,也能帮助生态更快定位病理案例。
所以这篇文章不是在说“大家都应该这么做”,而是在记录一条可以走的路。
六、用 rustc self-profile 看编译阶段
Rust nightly 提供 -Z self-profile,可以让 rustc 自己记录内部活动。使用时通常需要:
RUSTC_BOOTSTRAP=1 rustc src/main.rs \
--emit=metadata \
--cfg 'depth = "8"' \
-Z self-profile \
-Z self-profile-events=default,args
然后用 measureme 里的工具,比如 summarize,查看 profile 数据。
当去掉 projection 后,profile 里大部分时间花在很普通的地方,比如宏展开、metadata decode 等。但保留 projection 时,self-profile 会暴露出大量 trait selection 相关活动。
不过 self-profile 一开始也有局限。默认记录的信息可能不够细。它能告诉你很多时间花在某些 query 或活动上,但不一定告诉你“具体哪个 predicate / obligation 导致递归”。这也是后面要自编译 rustc、加更详细 instrumentation 的原因。
七、为什么要自己编译 rustc
一听“自己编译 rustc”,很多人会本能退缩。Rust 编译器听起来太大了,肯定很难改、很难构建。
文章里的实际体验没那么恐怖。Rust 有 rustc dev guide,按照说明 clone 仓库、配置、运行 ./x.py build library,在作者机器上构建 stage1 大约 1 分 20 秒。这个时间当然不算“紧反馈循环”,但比想象中好很多。
为什么需要自己编译 rustc?原因之一是稳定版 rustc 的 debug 级 tracing 很多被静态裁掉了。tracing 可以在编译时通过 feature 控制最大日志级别。如果 stable rustc 构建时只保留到 info,那么你设置 RUSTC_LOG=debug 也看不到真正想看的 debug spans。
要看更深层的 tracing,就需要改 rustc 构建配置,或者自己构建一个包含这些 debug spans 的 rustc。
这一步的目标不是修 bug,而是获得更好的观察能力。对于黑盒系统,能看到内部状态,排查就从猜测变成了测量。
八、用 perf 采样 rustc
除了 rustc 自己的 self-profile,还可以用系统级采样 profiler。Linux 上常用的是 perf。
最简单可以这样:
perf record rustc src/main.rs --emit=metadata --cfg 'depth = "8"'
perf report --stdio
这会看到一些函数占比,比如 rollback_to、intern_substs、make_eqregion、mk_region、LeakCheck、opt_normalize_projection_type 等。已经能看出热点大多在 trait selection、region/inference、projection normalization 附近。
但第一次结果不够好,因为没有 call stack。于是要加 call graph:
perf record --call-graph fp rustc src/main.rs --emit=metadata --cfg 'depth = "8"'
这里 fp 表示用 frame pointer 收集栈。但如果编译器二进制没有保留足够 frame pointer,栈可能仍然不完整。于是可以改用 DWARF:
perf record --call-graph dwarf rustc src/main.rs --emit=metadata --cfg 'depth = "8"'
DWARF 栈信息更完整,但也有自己的坑。perf 默认捕获栈大小有限,默认大约 8KB。如果栈很深,会丢失一部分。可以调大,比如接近上限的 65528 字节,但 profile 文件会变得更大,perf report 和 perf script 也会更慢。
这部分很现实:性能分析工具很强,但每一步都有细节。你不是按一个按钮就得到答案,而是在符号、debug info、栈展开、文件大小、可视化之间不断调参。
九、把 perf 结果放进 Firefox Profiler 和 Speedscope
perf report --stdio 是文字表格,能看热点,但不适合观察复杂调用关系。可以把 perf script 的结果导入 Firefox Profiler。Firefox Profiler 是一个很好用的 Web 可视化工具,能显示调用栈、时间线,还能分享 profile 链接。
流程大概是:
perf record --call-graph dwarf rustc src/main.rs --emit=metadata --cfg 'depth = "8"'
perf script -F +pid > test.perf
如果系统里的 addr2line 对新 DWARF 支持不好,可以临时在 $PATH 前面放一个 wrapper,让程序调用 LLVM 的 llvm-addr2line。这是一种通用小技巧:当某个工具硬编码或默认找某个命令名时,可以用 PATH 里的 wrapper 偷梁换柱。
把 profile 拖进 Firefox Profiler 后,能看到一层层调用。重点路径里会出现:
resolve_instance
inner_resolve_instance
codegen_fulfill_obligation
select_all_or_error
process_obligations
evaluate_obligation
evaluate_root_obligation
evaluate_predicate_recursively
poly_project_and_unify_type
opt_normalize_projection_type
这些名字已经非常指向 trait selection 和 obligation evaluation。
再用 Speedscope 看,会更直观看到调用栈非常深。这里文章还顺带提到 rustc 内部使用 stacker,因为某些递归路径真的会很深,需要防止栈不够。
十、nperf:更现代一点的 perf 体验
接着尝试的是 not-perf,简称 nperf。它不是系统自带工具,而是一个更现代的采样 profiler。它的优点是 profile 更小,能即时 symbolication,也能生成 flamegraph 和 chrome tracing。
使用方式大概是一个终端等待 rustc 进程:
nperf record --process rustc --wait
另一个终端运行 rustc:
rustc src/main.rs --emit=metadata --cfg 'depth = "8"'
记录完成后生成 flamegraph:
nperf flamegraph profile.nperf -o rustc.svg
这次调用栈更完整,能更清楚地看到 evaluate_predicate_recursively 相关路径。它也能生成 chrome_profiler.json,用 chrome://tracing 打开。
在 tracing 视图里,放大之后可以看到一条非常长的递归链。大致流程是:rustc 解析实例、满足 obligation、选择 impl、处理 changed obligations、检查 predicate,最后进入多层 evaluate_predicate_recursively,再进入 projection normalization、unification、commit/rollback 等逻辑。
这一步已经很接近“看见爆炸现场”了。我们能看到 rustc 不是随机慢,而是在同一类 trait obligation 上深度递归。
十一、callgrind 这条路没有走通
文章还尝试了 Valgrind 的 callgrind。Valgrind 不只是内存检查工具,也包含 profiler。理论上 callgrind 可以提供非常细的指令级信息。
但这条路没有走通。原因和 DWARF5 debug 信息有关。rustup shim 或相关二进制包含 Valgrind 当时不太支持的 DWARF 信息,导致 Valgrind debuginfo reader 直接失败。尝试去掉 shim 或使用真正 rustc 可执行文件也没有完全解决,还遇到 LLVM 参数相关问题。
这段的意义不是 callgrind 没用,而是提醒:工具链本身也会失败。性能分析经常不是一条直路。某个工具在某个版本、某个发行版、某种 debug info 格式下就是不好使,这很正常。换工具继续查。
十二、用 jq 直接挖 self-profile JSON
火焰图和时间线很好,但有时你想回答更具体的问题:到底有多少 span?最大嵌套深度是多少?某个函数的参数是什么?哪些 obligation 被重复求解?
rustc self-profile 生成的数据可以转成 JSON,再用 jq 直接分析。这个方向比较粗暴,但非常有用。图形界面适合看整体,JSON + jq 适合数东西、过滤东西、验证假设。
文章进一步修改 rustc,在 evaluate_predicate_recursively 里加 profiling activity,并把当前 obligation 的 Debug 表示作为参数记录下来。然后重新构建 rustc,用 -Z self-profile 运行复现代码,再解析生成的 JSON。
这样就能看到一棵带参数的调用树。比如顶层 obligation 可能是关于 &&&&() 的某个 associated type projection;下一层会变成 &&&();再下一层变成 &&();最后到 &() 或 ()。引用层数逐渐减少,递归深度逐渐增加。
这个可视化非常关键。它让人看到:rustc 并不是简单地“慢”,而是在证明某个 projection 时,不断展开内部 obligations。每一层都要重新证明一组类似约束。
十三、用 Jaeger 看 rustc 内部 tracing
既然 rustc 已经用 tracing,那能不能把 tracing span 发到 Jaeger 这类分布式 tracing UI 里看?
这听起来有点疯狂:把编译器内部日志当作后端服务 trace 发出去。但技术上是可行的。需要改 rustc 的 rustc_log crate,加入 tracing-opentelemetry 和 opentelemetry-jaeger,当设置某个环境变量时,不再用普通 stderr logger,而是把 span 发送到本地 Jaeger collector。
思路大概是:
如果设置 RUSTC_JAEGER
初始化 Jaeger tracer
用 tracing_opentelemetry layer
否则
继续使用原来的 tracing-tree 输出
然后启动本地 Jaeger,再运行自编译 rustc:
RUSTC_JAEGER=1 \
RUSTC_LOG="rustc_trait_selection::traits::select[evaluate_predicate_recursively]=debug" \
rustc +stage1 src/main.rs --emit=metadata --cfg 'depth = "3"'
在 Jaeger UI 里,就能看到 rustc 的 span 树。每个 span 有属性,比如文件位置、函数名、耗时、thread id,还有当前 obligation。可以展开、折叠、查看嵌套关系。
对这个问题来说,Jaeger 的优势是:它非常适合看嵌套 span。rustc 的 trait evaluation 正好是一棵递归树。通过 Jaeger,可以直观看到某个 top-level predicate 下面展开了哪些 obligations,每一层又继续展开什么。
十四、最终看到的问题:内层结果没有被有效复用
通过这些工具,最终观察到的模式大致如下。
要证明类似:
for<'x> <&&() as Trait<'x>>::A = ()
rustc 需要先证明一组内层 obligations:
for<'x> &(): Trait<'x>
for<'x> <&() as Trait<'x>>::A = ()
for<'x> <&() as Trait<'x>>::B = ()
for<'x> <&() as Trait<'x>>::C = ()
for<'x> <&() as Trait<'x>>::D = ()
for<'x> &(): Sized
问题在于,对于每个更深层的引用,这些内层 obligations 又会被反复求解。因为有 4 个 associated type projection,加上 trait 本身和 Sized 之类的约束,每一层都会展开一组相似问题。层数增加后,重复计算以指数级增长。
按直觉,这些内层结果应该可以缓存。证明过 for<'x> &(): Trait<'x> 以及相关 projection 后,证明上层时应该复用它们,而不是每次重新展开。文章最后没有直接修掉 rustc bug,但已经把问题定位到非常具体的方向:某些 inner evaluations 本该被缓存复用,但没有。
这也是整篇文章的核心结论。rustc 不是因为 LLVM codegen 慢,也不是因为链接慢,也不是因为依赖多。这个复现甚至没有依赖。慢发生在 trait selection / obligation evaluation 阶段,和 higher-ranked trait bound、associated type projection、递归引用实现之间的组合有关。
十五、这和真实项目有什么关系
看到这个最小复现,可能会觉得它太人为。谁会在真实代码里写 <&&&&&&&&() as Trait> 这种东西?
确实,没人会直接写这种代码。但真实项目会通过泛型组合生成类似结构。
Tower 的 service stack 就是例子。你写的是:
ServiceBuilder::new()
.layer(A)
.layer(B)
.layer(C)
.service(inner)
但类型系统看到的是:
A<B<C<Inner>>>
再叠加 async future associated type、Service<Request>、Layer、poll_ready、call、各种 wrapper 的 bounds,很容易生成又深又复杂的类型和 trait obligations。你没有手写病理复现代码,但抽象组合帮你生成了它的精神等价物。
这就是为什么 Hyper、Warp、Axum、Tower 生态里的某些编译时间问题会让人痛苦。不是这些库“不好”,而是它们充分利用了 Rust 类型系统表达组合关系,而 rustc 在某些组合上还会遇到复杂度问题。
这也解释了为什么一些 workaround 有用。比如在某些位置装箱 future、打断泛型链、减少 layer 数量、拆 crate、隐藏具体类型,可能会显著降低编译时间。它们的共同点是:不让类型系统继续把所有层都静态展开到底。
十六、这篇文章真正教了什么
这篇文章不是一篇“如何让 Rust 编译变快”的清单。它更像一次侦探记录:当你怀疑 rustc 自己在某段代码上出现病理性能时,可以怎样逐层打开黑盒。
第一层是最小复现。没有最小复现,排查就很难。真实项目太大,依赖太多,变量太多。把问题缩到一个文件、没有依赖、能用 rustc 直接跑,是整个调查的基础。
第二层是测量复杂度。通过 depth cfg 参数,控制引用嵌套层数,并记录每个 depth 的耗时。这样才能看出它不是线性慢,而是指数级变慢。
第三层是普通日志。RUSTC_LOG=info 让你看到 rustc 大致进入了哪些模块,开始怀疑 trait selection 和 projection。
第四层是 self-profile。-Z self-profile 让 rustc 自己告诉你时间花在哪些 query 或 activity 上。
第五层是系统采样。perf、Firefox Profiler、Speedscope、nperf 能从另一个角度观察函数调用栈和热点路径。
第六层是自编译 rustc。稳定版 rustc 的 debug tracing 不够,就自己构建一个带更完整 instrumentation 的 rustc。
第七层是定制观测点。给 evaluate_predicate_recursively 记录 obligation 参数,把原本不可见的“编译器正在证明什么”显式打出来。
第八层是 trace 可视化。把 rustc span 发进 Jaeger,像看后端请求链路一样看 trait solver 的递归树。
这些步骤不一定每次都要全走一遍。但知道它们存在,就能在遇到极端问题时多一条路。
十七、对普通 Rust 开发者的实际建议
如果你只是想让项目编译更快,不必从自编译 rustc 开始。可以按更现实的顺序来。
先用:
cargo build --timings
看哪个 crate 慢,是否有并发不足,是否某个巨型 crate 成了瓶颈。
再检查 linker。Linux 上可以试 lld 或 mold。对 debug 构建来说,linker 经常是可优化点。
然后看 profile 设置。debug = 2 会生成更多 debug info,可能显著增加编译和链接成本。开发阶段有时可以用 debug = 1 或对某些 crate 调整。
再看 feature 和依赖。某些默认 feature 会拉入巨大依赖图。用 cargo tree 看看有没有意外依赖。
如果你使用 Tower/Axum/Warp 这类组合式框架,并且发现某个服务栈编译特别慢,可以尝试减少泛型嵌套、在边界处使用 boxed service 或 boxed future、拆小模块、打断巨大类型传播。虽然这可能损失一些零成本抽象,但能换回更好的编译反馈。
如果你能提取最小复现,那就可以报告给 rustc 或相关库。最小复现越小,越容易被编译器团队定位。文章里的问题就是 Rust issue 99188 相关方向。
只有当你真的想深入 rustc,或者你正在协助定位编译器性能 bug 时,才需要进入 RUSTC_LOG、-Z self-profile、perf、自编译 rustc、Jaeger tracing 这些深水区。
十八、这篇文章的价值
这篇文章最大的价值,不只是指出了一个 rustc 性能 bug,而是展示了一种排查方法。
编译器看起来像黑盒。我们写代码,rustc 要么通过,要么报错,要么慢。但实际上,rustc 也只是一个大型 Rust 程序。它有 crates,有 logging,有 tracing,有 profiling,有函数调用栈,有数据结构,有递归,有缓存,也会有 bug。只要工具足够,你可以像分析普通服务性能一样分析它。
这件事本身很鼓舞人。很多人觉得编译器内部不可接近,但文章展示的是:从 RUSTC_LOG=info 开始,你就已经能看到一点内部活动;从 -Z self-profile 开始,你能看到 query 和阶段耗时;从 perf 和 nperf 开始,你能看到函数调用栈;从自编译 rustc 开始,你甚至能加入自己的观测点。
当然,这不代表 rustc 内部很简单。trait solver、higher-ranked lifetimes、associated type projection、region constraints 都是很复杂的东西。但“复杂”不等于“不可进入”。你可以先不理解所有理论,先找到某个函数、某个 span、某个重复模式。
这也是文章标题 “When rustc explodes” 的含义:当 rustc 真的炸了,不要只盯着终端里越来越慢的秒数。可以把爆炸现场一点点打开,看看火从哪里冒出来。
十九、总结
这篇文章从 Rust 编译时间问题讲起,但没有停留在常见优化建议上。普通编译慢可以先看 cargo timings、linker、debug info、incremental、LTO、self-profile;而这次遇到的问题更像 rustc 内部病理案例:一个由高阶生命周期约束、associated type projection 和多层引用递归实现组成的最小复现,会让 rustc --emit=metadata 的时间随嵌套深度指数级增长。depth 从 7 到 10,每加一层大约慢 4 倍。
真实背景来自 Tower 生态。Tower 的 Service 和 Layer 让异步服务组合非常灵活,但组合后的具体类型会层层嵌套。由于 async trait method 长期受限,Service 使用 associated type 表达 future,再叠加各种 wrapper 和 bounds,就容易生成巨大类型和复杂 trait obligations。这类结构可能撞上 rustc trait selection 的慢路径。
文章先用 RUSTC_LOG=info 观察 rustc 内部日志,发现 trait selection 中反复出现 projection obligations,也就是 <T as Trait>::A = () 这类 associated type 约束。去掉这些 projection 后,复现代码瞬间变快,说明 associated type projection 是复杂度爆炸的重要触发点。接着用 -Z self-profile、perf、Firefox Profiler、Speedscope、nperf 等工具观察 rustc 的调用栈和时间线,热点集中在 obligation evaluation、projection normalization、trait selection、region/inference 等路径上。
为了看得更深,文章进一步自编译 rustc。稳定版 rustc 的 debug tracing 很多被静态裁掉,所以需要自己构建一个能输出 debug spans 的版本。然后在 evaluate_predicate_recursively 中加入 profiling activity,记录当前 obligation 参数,再用 self-profile JSON 和 jq 分析递归树。最后甚至把 rustc 的 tracing 接到 Jaeger,用浏览器可视化 trait solver 的递归 span。
最终观察到的问题是:要证明某一层引用的 associated type projection,rustc 会继续证明更内层的一组 obligations,包括 trait 本身、A/B/C/D 四个 associated type projection 和 Sized。这些内层结果本该被缓存和复用,但在这个案例中反复展开,导致递归次数随着深度快速增长。文章没有直接修复 rustc bug,但把问题定位到非常具体的区域:trait solver 对某类 projection / higher-ranked obligation 的缓存或复用不足。
这篇文章的意义在于,它把“Rust 编译慢”从一句模糊抱怨变成了一套可操作的调查流程。普通用户不需要天天自编译 rustc,也没有义务深入 trait solver;但如果你能提取最小复现,并愿意继续挖,就可以用日志、profile、采样、trace、可视化等工具,把 rustc 这个黑盒打开一条缝。对 Rust 生态来说,这种调查记录很重要,因为很多复杂问题只有在真实项目和极端类型组合中才会浮现,而把它们最小化、可视化、解释清楚,是推动编译器变好的第一步。