新的 Pass Manager
LLVM 的新的 Pass Manager
什么是 Pass Manager?
Pass Manager 会安排转换 Pass 和分析,以特定顺序在 IR 上运行。Pass 可以作用于整个模块、单个函数,或者更抽象的实体,例如调用图中的强连通分量 (SCC) 或函数内的循环。调度可以很简单,例如运行一组模块 Pass,或者在模块内的每个函数上运行函数 Pass。调度也可以更复杂,例如确保我们以正确的顺序访问调用图中的 SCC。
Pass Manager 还负责管理分析结果。分析(例如支配树)应尽可能在 Pass 之间共享,以提高效率,因为重新计算分析可能很昂贵。为此,Pass Manager 必须缓存结果,并在结果被转换无效时重新计算它们。
为了进行测试,我们可以将特定的 Pass 添加到 Pass Manager 中以测试这些 Pass。但是,典型用例是运行预定的 Pass 管线。例如,clang -O2
会在输入 IR 上运行一组预定的 Pass。
什么是 LLVM 的新的 Pass Manager?
LLVM 目前有两个独立的 Pass Manager:传统 Pass Manager (legacy PM) 和新的 Pass Manager (new PM)。当提到“legacy PM”和“new PM”时,这包括所有围绕的基础设施,而不仅仅是管理 Pass 的实体。
legacy PM 已使用很长时间,并且完成了它的工作。但是,有一些缺少的功能是更好的优化机会所必需的,最值得注意的是能够使用内联程序中任意函数的函数分析结果。具体的动机用例是内联程序希望递归查看被调用者的配置文件数据,特别是在延迟内联方面,内联程序希望查看简单的“包装器”函数。legacy PM 不支持在 CGSCC Pass 中检索任意函数的分析结果。CGSCC Pass 在调用图的特定强连通分量 (SCC) 上运行。Pass Manager 确保我们自下而上地访问 SCC,以便在到达调用者时,被调用者尽可能优化,并且调用者拥有尽可能精确的信息。LLVM 的内联程序是 CGSCC Pass,因为它是一个自下而上的内联程序。legacy PM 的这个主要限制,以及其他缺点,促使人们渴望新的 Pass Manager。
目前,新的 PM 仅适用于使用 LLVM IR 的中间优化管道。后端代码生成管道仍然只与 legacy PM 合作,主要是因为大多数代码生成 Pass 不作用于 LLVM IR,而是作用于机器 IR (MIR),而且还没有人投入时间为 MIR Pass 创建新的 PM 基础设施,并将所有后端迁移到使用新的 PM。由于几乎没有跨过程序的代码生成 Pass,因此将代码生成管道迁移到新的 PM 可能会没有性能增益。但是,这将清理大量的技术债务。
设计
在 legacy PM 中,每个 Pass 都声明它需要和保留的分析,并且 Pass Manager 会将这些分析安排为 Pass 以便运行,如果它们当前没有被缓存或已被无效化。提前声明 Pass 可能需要的分析是多余的样板代码,并且 Pass 并非在所有情况下都会使用所有分析。
新的 PM 采取了不同的方法,完全将分析和普通 Pass 分开。而不是让 Pass Manager 处理分析,一个单独的分析管理器负责计算、缓存和使分析无效。Pass 可以简单地从分析管理器请求分析,从而实现延迟计算分析。为了让 Pass 传达分析已被无效化的信息,它会返回它保留的分析。Pass Manager 会告诉分析管理器处理被无效化的缓存分析。这会导致更少的样板代码,以及 Pass 和分析之间更好的职责分离。
由于 legacy PM 将分析建模为要安排和运行的 Pass,因此我们无法有效地访问任意函数的分析。对于函数分析,相应的分析 Pass 只会包含当前函数的信息,该信息是在分析 Pass 的最新运行期间创建的。我们可以手动为其他函数创建分析,但它们不会被缓存到任何地方,导致大量重复工作和不可接受的编译时间倒退。由于分析由新的 PM 中的分析管理器处理,因此分析管理器可以缓存任意函数的任意分析。
为了支持 CGSCC 分析,我们需要一个键来缓存分析。对于函数和循环之类的东西,我们有持久的数据结构可用于这些作为键。但是,legacy CGSCC Pass Manager 只将当前 SCC 中的函数存储在内存中,并且没有持久调用图数据结构可用作缓存分析的键。因此,我们需要将整个图保存在内存中以获得一些可用作键的东西。如果我们有持久调用图,我们需要确保在 Pass 更改其结构时更新它。为了避免大量重复工作以重新生成可能很大但稀疏的图,我们需要增量更新图。这是新的 PM 中 CGSCC Pass Manager 复杂性的原因。
在 SCC 内,转换可能会破坏调用图循环并拆分 SCC。legacy CGSCC 基础设施的一个问题是,它只是将当前 SCC 中的所有函数存储在一个数组中,然后按顺序遍历这些函数,而从未重新访问过函数。考虑以下包含两个函数的 SCC。
void foo() {
bar();
}
void bar() {
if (false) {
foo();
}
}
假设我们首先访问 foo,然后访问 bar 并删除死调用。
void foo() {
bar();
}
void bar() {}
现在,我们想重新访问 foo,因为我们有更好的信息,最值得注意的是 foo 在它自己的 SCC 中。legacy CGSCC Pass Manager 只会继续执行调用图的下一部分。因此,作为新的 PM 增量调用图更新的一部分,如果 SCC 被拆分,我们会确保自下而上地访问新拆分的 SCC。这可能涉及重新访问我们已经访问过的函数,但这是故意的,以便让 Pass 有机会观察更精确的信息。
在 legacy Pass Manager 中添加 Pass 时,不同 Pass 类型嵌套是隐式的。例如,在模块 Pass 之后添加函数 Pass 会隐式地在连续的函数 Pass 列表上创建一个函数 Pass Manager。这在理论上是可以的,尽管可能会有点令人困惑。有些管线希望独立于紧随其后的函数 Pass 运行 CGSCC Pass,而不是通过 CGSCC Pass Manager 将函数 Pass 嵌套到 CGSCC Pass 中。新的 PM 通过只允许 Pass Manager 包含等效类型的 Pass 来使嵌套更加显式。例如,函数 Pass Manager 只能包含函数 Pass。要将循环 Pass 添加到函数 Pass Manager,循环 Pass 必须包装在循环到函数适配器中以将其转换为函数 Pass。新的 PM 中的 IR 嵌套是模块 (-> CGSCC) -> 函数 -> 循环,其中 CGSCC 嵌套是可选的。需要 CGSCC 嵌套被认为可以简化事情,但构建调用图的额外运行时开销以及正确嵌套以运行函数 Pass 的额外代码足以使 CGSCC 嵌套成为可选的。
legacy Pass Manager 依赖于许多全局标志和注册表。这是通过生成函数和变量以初始化 Pass 的宏来支持的,并且 legacy Pass Manager 的任何用户都必须确保调用一个函数来初始化这些 Pass。但我们需要一种方法让 Pass Manager 构建器能够了解所有 Pass,以便进行测试。新的 PM 的实现方法是让 Pass Manager 构建器包含所有 Pass 的定义,然后使用一个 Pass ID 到 Pass 构造函数的大映射来创建一个函数,该函数解析管线的文本描述并添加 Pass。Pass Manager 构建器的用户可以添加插件,这些插件注册解析回调以处理自定义的树外 Pass。尽管有一个全局函数列表,但没有可变全局状态,因为每个 Pass Manager 构建器都可以解析 Pass 管线,而无需通过全局注册表。其他选项,例如调试 Pass Manager 的执行,也是通过构造函数指定的,而不是通过全局标志。
长期以来,人们一直渴望并行化 LLVM Pass。尽管 Pass Manager 基础设施不是唯一的障碍,但 legacy PM 确实存在几个阻碍并行化的问题。在调用图级别,只有兄弟 SCC 可以并行化。按需创建 SCC 使得很难找到兄弟 SCC。新的 PM 对整个调用图的计算使得很容易找到兄弟 SCC 以在它们上并行化 SCC Pass。模块分析可以从 legacy PM 中的函数 Pass 计算。某些 Pass 仅在分析被缓存时才会使用分析,因此并行化会导致不确定性,因为模块分析可能存在也可能不存在,具体取决于其他并行管道。新的 PM 只允许函数 Pass 访问缓存的模块分析,并且不允许运行它们。这有一个缺点,即需要确保在运行较低级管道之前存在某些较高层次的分析,例如,确保在运行函数管道之前已计算 GlobalsAA。
使新的 Pass Manager 成为默认的 Pass Manager
LLVM 的一些主要用户多年前就默认切换到使用新的 PM。上游有一些努力使新的 PM 适用于所有用例。例如,所有 Clang 测试已经可以使用新的 PM 通过一段时间了。但是,绝大多数 LLVM 测试仍然只测试 legacy PM。opt
,通常用于测试 Pass 的程序,具有使用 legacy PM 运行 Pass 的语法,opt -instcombine
,以及使用新的 PM 运行 Pass 的语法,opt -passes=instcombine
。绝大多数测试使用 legacy PM 语法,因此,如果新的 PM 要默认开启,大多数 LLVM 测试就不会测试新的 PM Pass。(相当多的测试已经手动针对两者运行)
为了使使用 opt
的测试针对新的 PM 运行,我们可以手动让它们运行两次,一次针对 legacy PM,一次针对新的 PM,或者,当新的 PM 默认开启时,我们可以自动将 opt -instcombine
转换为 opt -passes=instcombine
。而不是更新每个测试,opt
中添加了一个 -enable-new-pm
选项,该选项会将 legacy 语法转换为新的语法。
有了这个新选项,我们开始发现 legacy PM 具有哪些现有新的 PM 用户并不关心的功能。当然,在本地打开它最初会导致许多测试失败。许多 Pass 尚未移植到新的 PM,并且某些 opt
功能无法与新的 PM 一起使用。我们将有意义地移植到新的 PM 的 Pass 和功能,并将使用 legacy PM 功能的测试固定下来,这些功能没有意义移植到新的 PM。
使用 -enable-new-pm
发现的与新的 PM 有关的一些更有趣的问题
optnone
函数属性不会导致可选 Pass 被跳过。使用现有的 Pass 检测框架,该框架在运行 Pass 之前和之后调用回调,还允许跳过 Pass,这是一种非常简单的 Pass 检测。但是,某些 Pass 必须运行才能保持正确性,因此我们最终将某些 Pass 标记为必需的。- Opt-bisect 在新的 PM 中不受支持。它用于通过在某个点之后跳过 Pass 来对管道中的哪个 Pass 导致了误编译进行二分搜索。这同样可以通过 Pass 检测相当容易地实现。这同样总是运行必需的 Pass。
- 各种特定目标的测试都失败了。经过检查,一些预期在类似 -O2 的流水线中运行的 Pass 并没有运行。一些后端目标会在默认流水线中添加自定义 Pass。其中一些 Pass 是为了正确性而需要的,例如用于降低特定目标内在函数的 Pass。旧的 PM 有一个方法可以让 `TargetMachine` 通过 `TargetMachine::adjustPassManager()` 将 Pass 插入默认流水线。引入了新的 PM 等价物,并将优化流水线中的特定目标 Pass 移植到了新的 PM 中。这以前不是问题,因为新的 PM 的现有用户主要关注 x86,而 x86 在旧的 PM 中没有使用此功能。
- 一些协程测试在 CGSCC 基础设施中断言。事实证明,新的 PM CGSCC 基础设施不支持在 CGSCC Pass 中将函数的某些部分提取到另一个函数(又称提炼)。在解决这个问题的初始尝试中,有一些失败的尝试,这些尝试没有正确更新调用图,也没有处理来自新提炼函数的递归。最后,我们想出了一个适合现有 CGSCC 基础设施并能正确保持调用图有效的解决方案,尽管不得不适应协程特定的调用图转换。
改进
多年来,各种项目/公司出于性能原因一直在使用新的 PM。另外,Chrome 最近开始使用 PGO 和 ThinLTO 来提高 Chrome 的速度,两者都获得了显著的性能提升。在 LLVM 中默认启用新的 PM 后,Chrome 也随之启用,在 Speedometer 2.0 的 Linux 和 Windows 上,性能提升了 3-4%,同时文件大小减少了 8-9MB。这些改进可能是由于更好地利用了配置文件信息以及更好地处理了更大的 ThinLTO 调用图。
然而,具有微小热点的小型应用程序可能不会从新的 PM 中获得太多好处,因为新的 PM 带来的改进往往与大型代码库更相关。
除了面向用户的改进外,这也通过将优化过程中的两个 Pass Manager 标准化为一个来改善了 LLVM 的代码健康状况。虽然我们还不能删除旧的 Pass Manager,但我们可以开始将其弃用,至少对于优化流水线而言。然后希望在某个时刻我们可以开始删除与旧 PM 特定的优化流水线部分。
下一步是什么?
为了开始删除优化流水线中对旧 PM 的使用,我们需要确保使用旧 PM 的任何东西都具有使用新 PM 的替代方案。列举几个例子:bugpoint、LLVM C API、GPU 发散分析。
如前所述,代码生成流水线仍然只与旧的 PM 配合使用。虽然已经开始进行使代码生成流水线与新的 PM 配合使用的工作,但它仍然距离可用状态非常遥远。这是进入 LLVM 的一个很好的切入点,如果您感兴趣,请在 llvm-dev 上寻求更多信息。