当 rustc 爆炸时:一次 Rust 编译器性能病理排查

52 阅读25分钟

本文是对 When rustc explodes 的整理与翻译。

内容结构概览

  1. 为什么关心编译时间:紧反馈循环对大型 Rust 项目非常重要。
  2. 前情回顾:普通 Rust 编译慢可以先看 cargo timings、linker、debug info、incremental、LTO、self-profile。
  3. 真正的问题:有些慢不是普通配置问题,而是 rustc 内部遇到了病理案例。
  4. 真实来源:Tower 服务栈:Tower 的 Service / Layer 抽象会生成非常大的嵌套类型。
  5. 为什么 Tower 类型会膨胀:没有 async trait method,于是使用 associated type Future,层层 wrapper 组合后类型巨大。
  6. 最小复现代码:一个带生命周期参数、4 个 associated type、递归引用实现的 trait。
  7. 指数级编译时间:depth 从 7 到 10,每加一层引用,rustc --emit=metadata 时间大约乘以 4。
  8. 先用 RUSTC_LOG=info 看 rustc 在做什么:观察 metadata 读取、type checking、trait selection。
  9. 核心线索:projection obligationsTrait::A/B/C/D = () 这些 associated type projection 反复出现。
  10. 去掉 projection 后瞬间变快:说明 associated type projection 是爆炸的重要触发点。
  11. -Z self-profile:用 rustc 自带 profiling 观察时间花在哪里。
  12. 普通用户有没有义务这么查:没有,但知道怎么查可以帮助定位 rustc 病理问题。
  13. 为什么要自编译 rustc:稳定版 rustc 的 debug tracing 被静态裁掉,需要自己构建才能看更深层日志。
  14. perf 采样 rustc:从函数采样、call graph、DWARF 栈信息看 trait selection 路径。
  15. perf 的坑:缺 debug info、frame pointer 不完整、默认 stack 捕获大小太小。
  16. 用 Firefox Profiler / Speedscope 看 profile:把采样结果可视化,看到 evaluate_predicate_recursively 深层递归。
  17. nperf 更舒服:生成更小 profile,并自动做 symbolication、flamegraph 和 chrome tracing。
  18. callgrind 尝试失败:Valgrind 对 DWARF5 支持问题导致这条路不顺。
  19. 用 jq 分析 self-profile JSON:不只看火焰图,还能数 span、看嵌套深度和参数。
  20. 给 rustc 加自定义 profiling 点:在 evaluate_predicate_recursively 里记录 obligation 参数。
  21. 用 Jaeger 看 rustc tracing:把 rustc 内部 tracing span 发到 Jaeger,在浏览器里展开递归树。
  22. 最终观察:内层 obligations 本该缓存复用,但在这个案例里被反复递归求解。
  23. 结论:问题根源在 trait solver 对某类 projection / higher-ranked obligation 的缓存或复用不足。
  24. 这篇文章的意义:不是要求普通 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 编得久;换 lldmold 这类更快 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 会解析 stdcore 这些依赖,读取 rlib metadata。然后进入 type checking。日志中开始出现 rustc_typeckrustc_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、等待编译器优化。没有人应该要求普通用户自己拿 perfself-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_tointern_substsmake_eqregionmk_regionLeakCheckopt_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 reportperf 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-opentelemetryopentelemetry-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>Layerpoll_readycall、各种 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 上可以试 lldmold。对 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-profileperf、自编译 rustc、Jaeger tracing 这些深水区。


十八、这篇文章的价值

这篇文章最大的价值,不只是指出了一个 rustc 性能 bug,而是展示了一种排查方法。

编译器看起来像黑盒。我们写代码,rustc 要么通过,要么报错,要么慢。但实际上,rustc 也只是一个大型 Rust 程序。它有 crates,有 logging,有 tracing,有 profiling,有函数调用栈,有数据结构,有递归,有缓存,也会有 bug。只要工具足够,你可以像分析普通服务性能一样分析它。

这件事本身很鼓舞人。很多人觉得编译器内部不可接近,但文章展示的是:从 RUSTC_LOG=info 开始,你就已经能看到一点内部活动;从 -Z self-profile 开始,你能看到 query 和阶段耗时;从 perfnperf 开始,你能看到函数调用栈;从自编译 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 的 ServiceLayer 让异步服务组合非常灵活,但组合后的具体类型会层层嵌套。由于 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-profileperf、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 生态来说,这种调查记录很重要,因为很多复杂问题只有在真实项目和极端类型组合中才会浮现,而把它们最小化、可视化、解释清楚,是推动编译器变好的第一步。