Rustdoc 性能改进

2021 年 1 月 15 日 · Jynn Nelson 和 Guillaume Gomez 代表 Rustdoc 团队

大家好! @GuillaumeGomez 最近发了一条关于 rustdoc 性能改进的推文,并建议我们写一篇博文来介绍它

这条推文收到了很多赞同博文想法的评论,所以我们开始吧!

性能变化

实际上只有两个 PR 是专门为了提高 rustdoc 性能而提交的

  1. Rustdoc:缓存已解析的链接 #77700

    这项工作正如标题所示。特别是,它将为 stm32h7xx 生成文档内部链接(intra-doc links)的时间大幅加速了惊人的 90,000%@bugadani 在这方面做得非常出色,恭喜!

  1. 不在文档内部链接中查找 blanket impls #79682

    这个 PR 写起来很让人沮丧。关键是,如果你有

    trait Trait {
        fn f() {}
    }
    
    impl<T> Trait for T {}
    

    那么链接到 usize::f 不仅不起作用,而且运行所需的时间会比其他文档内部链接更长。这项改动暂时禁用了 blanket impls,直到 bug 修复并且性能得到改进,这使得 stm32h7xx 的速度同样提升了 90 倍

    你可能想知道为什么 stm32h7xx 之前如此慢;详细信息请参阅博文末尾。

全是清理工作的功劳

随着 rustdoc 团队最近的发展壮大,我们终于有时间来偿还一段时间以来积累的技术债务了。总而言之:移除 rustdoc 中的自定义实现,直接使用编译器类型。首先,我们需要稍微解释一下 rustdoc 是如何工作的。当运行它生成 HTML 文档时,它会经历几个步骤

  • 运行编译器的一部分,以获取所需的信息。
  • 移除编译器提供的我们不需要的信息(例如,如果一个条目是 doc(hidden),我们就不需要它)。这部分有很多内容可以说,所以也许我们会另写一篇博文来详细介绍。
  • doctree 阶段,它在编译器的某些条目上添加 rustdoc 所需的一些额外信息。
  • clean 阶段,它将编译器类型转换为 rustdoc 类型:基本上,它将所有内容转换为“可打印”的内容。
  • render 阶段,然后生成所需的输出(HTML,或者在 nightly 版本中是 JSON)。

@jyn514 不久前注意到 Rustdoc 中的大部分工作都是重复的:实际上存在 个不同的抽象语法树(AST)!一个用于 doctree,一个用于 clean,还有一个是编译器使用的原始 HIR。Rustdoc 花费了大量时间在它们之间进行转换。大部分的速度提升都来自于彻底消除部分 AST。

修剪树结构

doctree 所做的大部分工作都是 100% 不必要的。它拥有的所有信息都已经存在于 HIR 中,而递归遍历 crate 并构建新类型需要花费相当长的时间。

@jyn514 第一次尝试是 彻底移除这个阶段。结果...糟透了。事实证明它毕竟还是做了一些有用的工作。

话虽如此,它确实做了一些不必要的工作,那就是为所有东西添加自己的类型。如果你比较一下 3 个月前的类型今天的类型,你会发现差异真的令人吃惊!它从复制代码中几乎所有编译器类型的 300 行代码减少到只有 75 行和 6 种类型。

清理 clean 阶段

这次清理工作的第一个也是最重要的部分是一个 PR,名为“在 rustdoc 中添加 Item::from_def_id_and_kind 以减少重复”(#77820)。在该更改之前,rustdoc 中的每个 Item 都在几十个不同的地方构建——对于结构体、枚举、trait 等等不一而足。这使得对 Item 结构体进行更改变得非常困难,因为任何更改都会破坏几十个调用点,每个调用点都必须单独修复。#77820 的作用是将所有这些条目构建在同一个地方,这使得更改 Item 在内部的表示方式变得容易得多。

在此过程中,@jyn514 发现了一些首先需要在编译器中进行的清理工作

  • 在 resolve 阶段一次性计算可见性 #78077。感谢 @petrochenkov 解决了这个问题!
  • 修复 HIR 条目名称的处理 #78345

删除 Item 的部分内容

完成之后,我们能够通过按需计算信息(而不是预先计算)并使用编译器内部机制来移除 Item 类型的大部分内容。这带来了两个好处

  1. 内存使用更少,因为信息不再需要时就不会一直存储。
  2. 总体时间更少,因为并非每个条目都需要所有可用的信息。

这很大程度上受益于 查询系统,我强烈建议大家阅读相关内容。

以下是一些按需计算信息的示例更改

  • 不要不必要地覆盖 Module 的 attrs #80340
  • 移除 clean::Deprecation #80041
  • 移除 clean::{Method, TyMethod} #79125
  • 移除重复的 Trait::auto 字段 #79126
  • 移除一些 doctree 条目 #79264
  • 移除 doctree::{ExternalCrate, ForeignItem, Trait, Function} #79335
  • 移除 doctree::Impl 79312
  • 移除 doctree::Macro 并区分 macro_rules!pub macro #79455
  • 在 doctree 中传递 Symbols 而非 Idents #79623

正如你所见,所有这些更改不仅加速了 rustdoc,还发现了存在多年的 bug 和重复代码。

复用编译器类型

以及一些使用现有编译器类型而不是添加自定义类型的示例

  • [rustdoc] 将 item.name 切换到 Symbol #80044
  • 在 rustdoc 中使用更多 symbol #80047
  • 尽可能将 String 替换为 Symbol #80091
  • 继续在 rustdoc 中进行 String 到 Symbol 的转换 (1) #80119
  • 继续在 rustdoc 中进行 String 到 Symbol 的转换 (2) #80154
  • 移除 rustdoc 中的自定义 pretty-printing #80799

它们将用于条目名称的 String 替换为使用 Symbol。Symbol 是 interned strings,因此我们不仅避免了不必要的转换,还极大地改善了内存使用。你可以在 rustc-dev-guide 中阅读更多关于 Symbol 的信息。

有趣的是,这也使得编译器本身获得了一些 小改进

按照同样的逻辑,#80261(需要先有 #80295)被引入,它保留了原始文档属性 Symbol 及其“转换信息”,而不是存储转换后的字符串。如果你想了解更多关于 rustdoc 如何处理文档注释格式的信息,@GuillaumeGomez这里写了一篇博文。这里的想法再次是“按需”计算这些信息,而不是提前存储结果以备(潜在)使用。

为什么我们之前没有更多地依赖 rustc 内部机制?

现在,你可能想知道为什么 rustdoc 在这次清理之前没有更多地依赖 rustc 内部机制。答案其实很简单:rustdoc 很老了。在它编写时,rustc 内部机制变化非常频繁(甚至每天都在变),这使得 rustdoc 维护者很难跟上。为了让他们工作时不过多担心这些变化,他们决定对编译器内部机制进行抽象,这样他们就可以使用那些 rustdoc 类型进行工作,而不必担心每天都有破坏性的更改。

从那时起,情况得到了改善,Rust 1.0 版本终于发布,事情也稳定下来了。然后,重点主要放在添加新功能上,以使 rustdoc 尽可能地出色。随着新的 rustdoc 团队成员的加入,我们终于能够重新关注这方面的工作。保留所有这些抽象意义不大,因为内部机制现在相对稳定了,而且我们都能看到成果。:)

后续步骤

正如你从显示的基准测试中看到的,结果是非常积极的。然而,我们离完成还远着呢。在我们交谈的同时,我们仍在继续简化和重构大量的 rustdoc 源代码。

彻底移除 doctree

这是 doctree 今天所做的“有用的工作”(与不必要的复杂性相对)

  • 检测哪些条目是公开可达的。理想情况下,这应该直接使用编译器 API,但那些 API 有问题

  • 内联仅通过导出(export)可达的条目。“内联”是指在重新导出(re-export)(pub use std::process::Command)的地方显示条目的完整文档,而不是只显示 use 语句。标准库和像 futures 这样的 facade crates 普遍使用它来将相关文档显示在一个地方,而不是分散在许多 crate 中。@jyn514 希望这可以在 clean 阶段完成,但尚不清楚如何实现。

  • 将宏从始终位于 crate 的根目录移动到它们可访问的模块中。例如,这个宏

    #![crate_name="my_crate"]
    #![feature(decl_macro)]
    mod inner {
        pub macro m() {}
    }
    

    应该在 my_crate::inner::m 文档化,但编译器却将其显示在 my_crate::m。这个问题的修复是一个糟糕的 hack,它遍历 Rustdoc 已知的每个模块,查看模块的名称是否与宏的父模块的名称匹配。将来某个时候,如果能修复编译器 API 以使其不再必要,那将太好了。

    非常感谢 @danielhenrymantilla,他不仅编写了修复方案,还在过程中发现并修复了其他几个与宏相关的 bug!

如果所有这些问题都能得到解决,那将带来更大的速度提升——因为一开始就完全不需要遍历树结构了!

继续精简 clean::Item

现有的大部分清理工作都集中在按需计算 rustdoc 中 每个 条目都需要的信息,因为这影响最大。但是,仍然有很多其他部分是提前计算的:特别是 ItemKind 在开始渲染文档之前会完全经过 clean 阶段。

加速 collect_blanket_impls

在整个 rustdoc 中,最慢的函数之一是名为 get_auto_trait_and_blanket_impls 的函数。对于包含许多 blanket implementation 的 crate(例如 stm32 生成的 crate),这可能会占用 rustdoc 处理该 crate 时间的近一半。

我们还不确定如何加速它,但肯定有很大的改进空间。如果你对此感兴趣并想参与工作,请在 Zulip 上联系我们。

总的来说,rustdoc 在性能方面取得了快速进展,但仍有很多工作要做。

勘误

博文的早期版本将关于精简 doctree 的部分描述为“烧毁树结构”。这个名称后来改为更加环保的说法。