我是如何在一周内让Rustdoc快33%的

Lobsters Hottest 工具

摘要

一位Rustdoc团队成员通过一系列优化和错误修复,在Rustdoc中实现了33%的性能提升,解决了影响文档生成的递归限制问题。

<p><a href="https://lobste.rs/s/ulkqhi/how_i_made_rustdoc_33_faster_one_week">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/28 15:38

# 我如何在一周内让 Rustdoc 快了 33% 来源: https://noahlev.org/blog/2026/08/27/making-rustdoc-faster/ > 我是 Rustdoc 团队的成员,最近为 Rustdoc 提交了一系列 PR,这使得平均挂钟时间减少了 **25%**(即提速了 33%)。在一些实际代码库如 `hyper` 和 `bitmaps` 上,提速高达 **40%**;而在像 helloworld 这样的微基准测试中,提速甚至达到了 **60%**。这篇博文详细介绍了我发现和实现这些性能改进的过程。如果你对了解参与 Rust 本身开发(特别是 Rustdoc)是怎样的体验感兴趣,我想这篇文章会很有意思。但如果你只想直接跳到最后展示最终结果的漂亮图表,也完全可以! ## 问题所在 上个月,Rust 发布团队的成员 @theemathas 在 Rustdoc Zulip 频道上报告了我们最新测试版中一个奇怪的回退。如果你不熟悉,Rustdoc 是 `cargo doc` 背后的工具。如果你打开过标准库文档或在 docs.rs 上查看过某个 crate 的文档,那么你看到的就是 Rustdoc 的输出。无论如何,在每个 Rust 稳定版发布前,发布团队会运行一个叫做 Crater 的工具来测试新版本在公共 Rust 生态系统中的表现。Crater 发现 Rustdoc 在一个名为 `indented-blocks` 的 crate 上出现了新的错误,该 crate 的代码类似于这样: ``` #![recursion_limit = "8"] ``` 仅包含这段代码的 crate,Rustdoc 在分析 `core::fmt` 中的一个内部 trait 时失败,报错为“达到了配置的最大栈帧数”。相比之下,Rustc 成功完成了编译。这个 `recursion_limit` 属性允许用户控制 Rustc 自身的递归,因为许多语言特性可能在编译时触发过度递归[^1]。例如,当使用深度嵌套的宏或复杂的 trait 逻辑时,用户有时必须将递归限制提高到默认值以上。Rustc 使用的栈帧数并非我们稳定性保证的一部分,因此这个回退不一定是问题。但它立刻引起了我的警觉。 Rustdoc 的大部分工作围绕调用 Rustc API,然后将结果信息组织并呈现给用户。因此,如果 Rustc 能成功编译这段代码,那么 Rustdoc 却失败就令人担忧了。我们确实有一些跨 crate 的特性,比如为你的 crate 重新导出的项内联文档:`std::vec::Vec` 实际上是 `alloc::vec::Vec`,但在文档中看起来天衣无缝。我们也会展示你的工作空间中哪些 impl 适用于你 crate 中的类型。但我实在想不出任何理由,为何 `core::fmt` 中的一个普通 trait 会需要将其文档内联到一个几乎为空的 crate 中! 然而,Rustdoc 的日志确实显示它正在尝试为这个 trait 内联文档: ``` DEBUG rustdoc::clean::inline record_extern_trait: DefId(2:13427 ~ core[195b]::fmt::num_buffer::NumBufferTrait) DEBUG rustdoc::clean trait_ref=Binder { value: , bound_vars: [] } ``` 当我打开负责内联外部 impl 的文件 `collect_trait_impls.rs` 时,我发现了这段代码: ``` // 在名为 "build_extern_trait_impls" 的 pass 中 for &cnum in tcx.crates(()) { for &impl_def_id in tcx.trait_impls_in_crate(cnum) { cx.with_param_env(impl_def_id, |cx| { inline::build_impl(cx, impl_def_id, None, &mut new_items_external); }); } } ``` 对于当前 crate 的每个依赖,这段代码会遍历其中定义的每个 trait impl,并构建一个适合在文档中显示的 impl 表示。因此,该算法的复杂度与你整个依赖图中 trait impl 的数量呈线性关系,并且由于 `build_impl` 是一个相当复杂的函数,还带有一个较大的常数因子。这非常昂贵!当然,Rustdoc 实际上并不会在文档中显示所有(甚至大多数)这些 impl,因为它在收集 impl 之后的文件中会进行过滤。 这时我灵光一现:如果我们先进行过滤,只对实际需要的 impl 调用 `build_impl` 呢?我猜想之前没人试过,是因为过滤代码假设它接收的是已经处理过的表示,而且中间还有一些棘手的逻辑需要处理 `Deref` impl 链。但我想,管他呢,试试看呗。 ## 先过滤 我首先调整了一个过于宽松版本的过滤逻辑,使其能在 Rustc 原始的 `rustc_middle::ty` 数据结构上工作,然后将其作为守卫放在每个 `build_impl` 调用之前。我运行了 Rustdoc 的主要测试套件……它通过了。哇!这太鼓舞人心了。既然新的过滤规则已经冗余,我删除了收集后的过滤步骤。测试套件仍然通过,即使我的新过滤规则过于宽松。实际上,我意识到保留不需要的 impl 总是没问题的。它们只在相关的文档页面上显示,例如,当页面属于它们的自类型或它们的 trait 时。所以,多余的 impl 只会拖慢 Rustdoc 的速度,但不影响正确性。 现在是时候面对那个处理 `Deref` impl 链的恐怖代码了。我感到有些大胆。如果我直接把它删了呢?这其实是我为了了解一段代码对行为有多大影响而经常尝试的方法。我等着一大片红色的测试失败出现,但始终没有。然后我运行了使用 Puppeteer 测试实时 GUI 行为的扩展测试套件。只有一个测试失败了,而且奇怪的是它与 `Deref` 无关;相反,它是关于 `#[doc(notable_trait)]` 的测试。好吧,简单插叙一下背景:Rustdoc 有一个名为“重要 trait”的不稳定特性,标记了特殊属性的 trait 会在实现它们的类型作为函数返回值时触发小注解。要明白为什么这有用,可以考虑 `Iterator::map()`。它返回一个名为 `Map` 的类型,作为用户我觉得它没什么特别意义。然而,`Iterator` 被标记为重要 trait,所以在 `Map` 旁边有一个小信息图标,告诉我它本身也是一个 `Iterator`。 原来,我们的 trait impl 内联代码在做决策时从未考虑过重要 trait 的状态。因此,我们关于那个提示工具提示的 GUI 测试是碰巧通过的。它测试了一个返回 `Vec` 的函数是否显示了 `Write` 的重要 trait 提示工具。通过通用的 `Vec` 到 `&[T]` 的解引用 impl,`Vec` 解引用到 `&[u8]`,而后者又实现了 `Write`,从而导致 `Write` impl 被加载到 Rustdoc 的上下文中,并可用于重要 trait 弹出窗口!在添加了对 `#[doc(notable_trait)]` 的必要考虑后,GUI 测试通过了,但一个快照测试需要更新,因为重要 trait 弹出窗口突然在很多之前缺失的地方出现了。当清理代码无意中修复了一个潜在 bug 时,总是令人愉快的! 当然,这个 PR 最令人兴奋的部分是性能结果。基准测试显示平均挂钟时间改进了 **20%**。最大驻留集大小(峰值内存使用量的度量)也减少了 **12%**。这个更改的影响如此之大,是因为 `build_extern_trait_impls` 这个 pass 占了 Rustdoc 运行时间的很大比例,从我更改之前的火焰图可以看出: 我在更改前对 Serde 运行 Rustdoc 的火焰图 我认为这次性能提升真正展示了结合经验证的分析数据与质疑现有代码的意愿的力量。从这次成功中受到鼓舞,我决定更进一步。 ## 原始类型和合成 Impl 我接下来的两个 PR 通过更智能地处理原始类型和合成 impl 来提高性能。Rustdoc 有特殊支持来记录像 `usize`、`str` 和 `[T]` 这样的原始类型,它们不在任何库代码中定义,而是内置在编译器中。虽然类型本身是内置的,但标准库为它们定义了 impl。例如,想想 `str::as_bytes`。为了便于显示这些文档,标准库使用特殊的 `#[rustc_doc_primitive]` 属性,以便 Rustdoc 有地方放置它们。 我注意到 `collect_trait_impls.rs` 中用于内联原始类型 impl 的逻辑在每个 crate 中运行,即使该 crate 没有声明任何原始类型(几乎所有 crate 都没有)。由于复杂的技术原因,这个 pass 很昂贵,因此它给 Rustdoc 的运行时间增加了相当一部分。通过使其只在本地原始类型上运行(从而为大多数 crate 跳过),我平均提高了 Rustdoc **12%** 的挂钟时间和 **6%** 的最大 RSS。 另一个 PR 是关于合成 impl 的。这是我们在 Rustdoc 代码库中对为自动 trait 和泛型 impl 在文档页面上*合成*的 impl 的称呼。让我解释一下这些术语。`Send` 和 `Sync` 是自动 trait 的例子。它们的实现实现由编译器为满足要求的类型动态确定,因此 impl 并未在你的代码中定义。然而,Rustdoc 构造了这些 impl 的表示,以便在文档中显示它们,就像它们是普通 impl 一样。泛型 impl 有点不同,但精神类似。它们确实在用户代码中有定义,但它们是为泛型类型实现的。例如,如果 `T: Clone`,那么每个类型 `T` 都适用于泛型 `impl ToOwned for T`。所以我们把这些 impl 复制到每个适用类型的页面上。 合成所有这些自动和泛型 impl 是昂贵的,因为它需要迭代 crate 中定义的每个类型,然后检查每个自动 trait 和每个泛型 impl 是否适用于它。我注意到 Rustdoc 甚至为那些从未出现在最终文档中的类型(例如私有类型)进行此分析。通过添加仅分析有文档类型的过滤器,平均挂钟时间减少了 **6%**,在一些实际代码库如 `hyper` 上最多减少了 **13%**。 ## 自类型 本系列中我的最后一个 PR 是基于检查我之前的更改之后收集的 Rustdoc 火焰图。我注意到 Rustc 的 `param_env` 查询占用了 `build_extern_trait_impls` 这个 pass 所用时间的 50%: 本 PR 之前 build_extern_trait_impls 的火焰图 这个查询本质上只是计算和规范化项(在我们的例子中是 impl)上的 `where` 子句,包括它从父项继承的子句。虽然这个操作并不便宜,但也不是特别昂贵。它占用这么多时间的原因是,它被调用在 crate 依赖图中的每个 impl 上——比实际上*内联*每个 impl 要好(像之前那样),但仍然不够好。 为了决定是否内联 impl,我们需要检查 impl 的自类型,看该类型是否被内联。计算 Rustdoc 版本的此类型需要参数环境,因为我们可能需要对其进行规范化。规范化只是指根据我们对其的了解,尽可能简化类型。例如,如果我们知道 `MyIter: Iterator`,那么我们可以将 `::Item` 规范化为 `MyStruct`。然而,我意识到在大多数情况下我们可以避免计算完整的 Rustdoc 版本类型。我们只需要检查自类型的*头部*。所谓“头部”,我指的是决定其引用哪个文档类型的最核心部分——`Vec<T>` 中的 `Vec`,或 `&'a mut String` 中的 `String`。并且我们只需要在自类型的头部是我们必须规范化的类似 `::Item` 的东西时,才调用 `param_env`[^2]。 根据这些原则更改 Rustdoc,使得基准测试套件(包括 `clap_derive` 等实际代码库)的平均挂钟时间加快了 **18%**。我通过这个更改后的另一个火焰图验证了改进是由于避免了对未内联 impl 的 `param_env` 调用: 本 PR 之后 build_extern_trait_impls 的火焰图 注意 `param_env` 在运行时中的份额显著减少,使得 `build_impl` 成为主要贡献者。现在,该 pass 的运行时间主要由执行我们实际想要内联的 impl 的工作所主导,而不是那些我们最终跳过的 impl。 ## 最终结果 经过所有这些更改,Rustdoc 达到了什么状态?我认为以下显示我们基准测试套件中 Rustdoc 挂钟时间的图表最能说明问题[^3]。 最终挂钟时间图 这四个下降中的每一个都对应我的一个 PR。Rustdoc 现在平均快了 33%!这个改进已经存在于 nightly 版本中,并将随 Rust 1.99 进入稳定版。这种影响在火焰图上也很明显。观察改进最大的基准测试之一 `hyper`,在更改之前 `build_extern_trait_impls` 在运行时所占的比例…… hyper 在我更改之前的火焰图 ……以及在我更改之后…… hyper 在我更改后的火焰图 ## 暂时告一段落 我希望你喜欢阅读这篇关于我最近在 Rustdoc 上工作的简要介绍。比起任何技术细节,我最希望你学到的是要相信你的直觉并质疑假设。仅仅因为一段代码(或任何东西,不仅仅是代码!)以某种方式存在了很长时间,并不意味着它就是最优的,甚至是正确的。如果你的直觉告诉你什么,倾听它并深入挖掘看看你能发现什么。你可能偶然发现一个等待被发现的重大改进。 附注:感谢我的 Rust 团队伙伴们让这个项目成为一件乐事。 ***勘误:** 我的标题最初是“我如何在一周内让 Rustdoc 快了 25%”。Nicholas Nethercote 指出,实际上,25% 的时间减少对应的是 33% 的提速(1/0.75=1.333…)。甚至更好!我已相应地更新了这篇文章。* [^1]: 我们的 trait 系统以图灵完备著称。 [^2]: 事实上,Rustdoc 的算法……

相似文章

如何在2026年7月加速Rust编译器

Lobsters Hottest

Nicholas Nethercote报道了Rust编译器近期性能改进,包括平均墙钟时间总体减少5.59%,rustdoc大幅加速总计28%,以及通过PR和PGO训练更改实现的显著Clippy优化。

使用Rust arena关闭一个三年之久的issue

Lobsters Hottest

一位Gleam核心团队成员通过用arena分配的引用替换装箱文档,改进了语言的漂亮打印性能,减少了10%的峰值内存使用,并关闭了一个三年之久的issue。

用 Rust 重写

Hacker News Top

本文评估了2026年的‘Rewrite It In Rust’运动,讨论了现实世界中的性能提升、诸如新错误和平台支持等挑战,并提倡增量重写而非完全重写。