铁锈编译程序的PGO探索

2020-11-12 02:30:48

TLDR--PGO使编译器速度更快,但在CI中实现起来并不简单。

在过去的几个月里,Mozilla一直在使用概要引导优化(PGO)来构建他们自己的优化版本Clang,这使得他们的构建基础设施上的Firefox编译时间减少了9%。Rust编译器是否也有同样的可能,也就是说,我们是否可以对rustc本身应用概要引导优化以使它更快呢?这篇文章正是探讨了这个问题,首先详细介绍了生成一个PGOed版本的rustc(有两种风格)所需的步骤,然后来看一下。

PGO的基本概念是收集有关程序典型执行的数据(例如,它可能采用哪些分支),然后使用这些数据通知优化,如内联、机器代码布局、寄存器分配等。

收集有关程序执行情况的数据有不同的方式。一种方法是在分析器(如perf)中运行程序,另一种方法是创建仪表化的二进制文件,即内置了数据收集的二进制文件,然后运行该程序。后者通常提供更准确的数据,也是rustc支持的。

换句话说,我们首先生成要优化的程序的一个特殊的指令插入版本,然后使用该指令插入版本生成执行配置文件。然后,编译器使用该执行配置文件来更好地优化程序的实际最终版本。

生成pgoed版本的rustc所涉及的基本步骤与生成任何其他类型的程序相同:

使用Rustc的仪表化版本来收集配置文件数据,也就是用它编译一堆程序,最好是以一种代表编译器典型用例的方式。

编译rustc的最终版本,这一次将构建系统指向我们在上一步中生成的概要数据。

然而,与许多其他程序不同的是,rustc是一个有点特殊的情况,因为它由两个用不同编程语言编写的非常大的代码块组成:LLVM后端(用C++编写)和编译器的前端和中间部分(用Rust编写)。因此,构建rustc还涉及两个独立的编译器--它们都支持各自版本的PGO。这使得事情稍微复杂一些,但幸运的是,这两个组件的PGO设置可以单独处理。

PGO是一个特定于工具链的特性,因此对于不同的C++编译器,它的工作方式可能会有所不同。在本文中,我将只讨论它是如何使用Clang的,因为(A)我没有在其他编译器中使用PGO的经验,(B)Clang是Rust项目在生产中实际使用的。

为了为Rustc的LLVM启用PGO,我们基本上遵循上一节中列出的步骤。

我们通过对Rust签出根目录中的config.toml文件应用以下更改来确保我们的LLVM得到检测:

[llvm]#将额外的编译器和链接器标志传递给LLVM CMake内部版本。#<;PROFDATA_DIR&>必须是指向可写目录的绝对路径,例如/tmp/my-rustc-Profdatacflag=";-fprofile-generate=<;PROFDATA_DIR>;";cxxflags=";-fprofile-generate=<;PROFDATA_DIR>;";#确保LLVM构建为dyliblink-Shared=true#确保我们使用Clang编译LLVM#(假设我们是为x86_64 Linux构建的)[target.x86_64-未知-Linux-gnu]cc=";clang";cxx=";linker=";clang";clang";

-fprofile-Generate标志告诉Clang创建一个检测的二进制文件,将其生成的任何配置文件数据写入给定的目录。建议始终使用绝对路径,因为我们不希望一切依赖于编译器的工作目录。我们还设置了link-Shared=TRUE,以确保Rustc的链接器不必处理将检测运行时链接到C++代码的问题。这是可以实现的,但是。现在我们只需要运行./x.py构建,并等待我们有一个带指令插入的LLVM的有效rustc。

接下来,我们通过运行在上一步中构建的编译器来收集配置文件数据。这很简单,因为数据收集是完全透明的。只需像往常一样运行该编译器(例如,通过货物),配置文件数据就会显示在我们在上面的-fprofile-Generate标志中指定的<;PROFDATA_DIR&>;中。为了使收集的数据尽可能有用,我们应该尝试执行编译器中的所有公共代码路径。I通常使用";标准&#。用于此目的的rustc-perf基准测试套件,包括调试版本、优化版本、检查版本(增量和非增量版本)。完成此操作后,您将在<;PROFDATA_DIR>;中找到许多.profraw文件。如Clang用户手册中所述,需要使用Clang安装附带的llvm-Profdata工具将这些.profraw文件合并为一个.Profdata文件:

现在可以在<;PROFDATA_DIR>;/rustc-llvm.Profdata中找到所有rustc调用的组合配置文件数据,是时候重新编译LLVM和rustc了,这一次指示Clang利用这个有价值的新信息。为此,我们修改了config.toml,如下所示:

现在,我们通过删除旧版本并重新构建所有内容来确保正确重建LLVM:

如上所述,使用PGOed编译器,Firefox的构建时间缩短了9%。Clang自己的文档甚至报告了20%的改进。我们评估Rust编译器性能的最好方法是rustc-perf基准测试套件。由于使用PGO编译不太符合Rust项目的CI的工作方式,我们不能使用Perf.rust-lang.org版本的基准测试套件。幸好,由于性能良好,我们不能使用Perf.rust-lang.org版本的基准测试套件。由于性能良好,我们无法使用Perf.rust-lang.org版本的基准测试套件。幸运的是,由于性能良好,使用PGO进行编译与Rust项目的CI的工作方式不太匹配,所以我们不能使用Perf.rust-lang.org版本的基准测试套件。让我们来看一看PGOed LLVM对Rustc性能的影响:

这一结果并不像Clang文档中传闻的20%的改进那样惊人,但也相当鼓舞人心,也没有表现出明显的性能倒退。深入到更多的细节中可以看到预期的情况:

在LLVM中花费大部分时间的工作负载(例如优化的版本)会显示出最大的改进,而根本不调用LLVM的工作负载(例如检查版本)也不会从更快的LLVM中获益。让我们来看看如何通过将PGO应用到编译器的另一半来进一步提高性能。

基本原则保持不变:创建一个插装的编译器,使用它来收集配置文件数据,并在编译最终版本的编译器时使用这些数据。唯一的区别是,这次我们插装的是编译器代码的不同部分,即由Rustc本身生成的部分。现在,编译器已经支持这一点一段时间了,正如在Rustc书的相应章节中所看到的那样,命令行界面是仿照Clang的一组标志来设计的。不幸的是,该编译器已经支持这样做了一段时间了,正如在Rustc书的相应章节中所看到的那样,命令行界面是仿照Clang的一组标志来设计的。不幸的是,该编译器已经支持了这一点。的构建系统不支持开箱即用的pgo,因此我们必须直接修改src/bootstrap/pile.rs以设置所需的标志。我们只想检测编译器本身,而不是其他工具或标准库,请参见我们将标志添加到rustc_Cargo_env():

Pub FN rustc_Cargo_env(建筑商:&;Builder<;,货物:&;mut Cargo,目标:TargetSelection){//...。省略了..。If Builder.config.rustc_Parallel{cargo.rustflag(";--cfg=parallel_compiler";);}if Builder.config.rust_Verify_llvm_ir{cargo.env(";RUSTC_Verify_LLVM_IR";,";1";);}//这是新的:生成//编译器cargo.rustflag(";-Cprofile-generate=<;PROFDATA_DIR>;";);的Cargo调用的//RUSTFLAGS中的硬编码指令插入。//...。省略...}。

与前面一样,<;PROFDATA_DIR>;必须是指向目录的实际绝对路径。一旦我们收集了足够的配置文件数据,我们就返回到src/bootstrap/pile.rs,并将-Cprofile-Generate标志替换为-Cprofile-use标志:

Pub FN rustc_Cargo_env(建筑商:&;Builder<;,货物:&;mut Cargo,目标:TargetSelection){//...。省略了..。If builder.config.rustc_parally{cargo.rustflag(";--cfg=parallel_compiler";);}if builder.config.rust_Verify_llvm_ir{cargo.env(";RUSTC_Verify_LLVM_IR";,";1";);}//将`-CProfile-Generate`替换为`-CProfile-use`,//假设我们使用了`llvm-Profdata`工具对采集到的`<;PROFD`进行了//合并。/*.profraw`文件//放入名为//`<;PROFDATA_DIR>;/rustc-rust.Profdata`的公共文件中。Cargo.rustflag(";-Cprofile-use=<;PROFDATA_DIR>;/rustc-rust.profdata";);//...。省略...}

让我们来看看PGO对这部分编译器的影响。

正如预期的那样,结果与将PGO应用于LLVM时类似:指令计数减少了大约5%。注意:这些数字显示了将PGO专门应用于编译器的Rust部分所带来的改进。LLVM部分不是使用PGO编译的,如下所示:

由于不同的工作负载执行不同数量的Rust代码(与C++/LLVM代码相比),因此对于LLVM繁重的情况,总的减少可能要少得多。例如,完整的webrender-opt构建将在LLVM上花费超过80%的时间,因此将剩余20%的时间减少5%只能减少1%的总数。另一方面,检查构建或增量未改变的构建在LLVM中几乎不花费时间,因此5%的Rust性能改进可以转化为。

简而言之,答案是肯定的。更长远的答案是,我们必须注意配置文件数据的不兼容性。Clang和Rust编译器在底层都使用相同的基于LLVM的PGO机制。如果Clang和Rust编译器使用完全相同的LLVM版本,我们甚至可以将两者合并为一个.Profdata文件。但是,如果两个LLVM版本不同,我们最好确保这两个编译器不会互相干扰。

我们需要为各自的-fprofile-Generate和-Cprofile-Generate(和*-use)标志指定不同的目录。这样,来自Clang的检测代码将写入一个目录,而来自rustc的代码将写入另一个目录。

我们需要确保为每组.profraw文件使用正确的llvm-Profdata工具。使用Clang附带的工具处理Clang目录中的文件,使用Rust编译器附带的工具处理Rust目录中的文件。

如果我们这样做,我们将得到一个通过PGO优化了这两个部分的编译器,编译时间的减少也很不错。

当我看到最终的数字时,我有点失望。当然,PGO似乎导致基准测试套件中几乎所有实际工作负载的指令计数减少了5%,这与检查、调试和OPT构建类似。这相当不错--但与Clang文档中提到的20%的改进相去甚远。考虑到PGO给编译器本身的构建过程增加了相当多的复杂性(更不用说几乎三倍的构建时间),我开始考虑应用

然后,我扫了一眼基准';墙时间测量(而不是指令计数测量),看到的是完全不同的画面:webrender-opt减15%,style-servic-opt减14%,serde-check减15%?这看起来显然比指令计数要好。但是墙时间测量可能非常嘈杂(这就是为什么大多数人只在Perf.rust-lang.org上查看指令计数),并且rustc-perf只对每个基准进行一次迭代。所以我还没有准备好相信这些数字。我决定试着把基准测试的迭代次数从1次增加到20次,以减少噪音。我只做了#34;由于PGO的影响,这种配置下的完整构建似乎可以很好地转化为增量构建。大约8小时后,我完成了PGO和非PGO版本的基准测试,以下是我得到的数据:

正如你所看到的,在真实世界的测试案例中,我们几乎全面减少了10%-16%的构建时间。这更符合我最初希望从PGO获得的结果。指令计数和挂起时间之间的差异如此明显,这有点令人惊讶。一个看似合理的解释是,PGO提高了指令高速缓存的利用率,这对执行时间有影响,但不会反映在执行的指令量上。我也不知道分支错误预测是如何影响指令计数-分支预测的。

尽管这些数字看起来不错,但请记住,它们来自同一台机器。我使用的Ryzen 1700X处理器可能具有一些特性,支持PGO所做的那种优化,而具有不同缓存系统和分支预测器的不同处理器将生成完全不同的数字。尽管如此,这些数字无疑是非常令人鼓舞的,值得进一步研究。

以上数字表明,PGO确实可以提供显著的编译时间减少。不幸的是,将这些改进带给最终用户并不像在dist构建中添加几个编译标志那么简单。PGO与大多数其他优化的不同之处在于它。

由于额外的检测和数据收集阶段,需要不同的、扩展的构建工作流,以及。

它会带来持续的构建时间成本(这是它与其他自动化优化(如LTO)的共同特征)。

这两个问题都对在编译器本身上实际使用PGO造成了很大的障碍。Rust的CI构建时间一直太长,因此我们已经放弃了一些优化(例如,MacOS仍然不能从使用ThinLTOed LLVM中获得10%的性能提升,因为该平台上的构建机器特别慢)。但是,我认为仍有一条路要走。在上面提到的两个障碍之间有一个权衡:

如果构建时间不是问题,那么在编译器的构建系统中支持PGO的工程工作量就相当低。也就是说,如果检测、数据收集和最终构建都作为单一的构建出现在同一台机器上,那么扩展构建系统来支持这一点应该是直截了当的。

如果将大量工程工作投入到更复杂的构建设置中,使用带外检测和配置文件数据缓存,则对构建时间的影响可以保持在相当低的水平。

我估计第一种方法更有成效,因为低工程和维护成本总是比低计算时间更有价值。拥有一种获得PGOed编译器的直接方法(例如,通过在config.toml中添加一个简单的设置)将打开通向以下几个场景的道路:

不频繁切换编译器版本的组织和个人可以很容易地编译自己的Rustc优化版本以供内部使用,就像Mozilla已经在使用Clang所做的那样。让一台计算机花费几个小时以便在接下来的几个月内减少15%的编译时间似乎是一项不错的投资。

Rust项目本身可以开始考虑提供更优化的构建,至少在测试版和稳定的渠道上是这样。如果只需要每六周完成一次,而不是针对每个合并的拉请求,那么显著增加编译器在官方构建基础设施上的构建时间将更加可行。

我不太可能在这件事上花很多时间--但我希望其他人能接过接力棒。我很乐意就如何具体使用PGO提供指导。

PS--特别感谢Mark Rousskov将我本地的基准测试数据上传到Perf.rust-lang.org,这使它更便于探索!