September 2015

第 30 卷,第 9 期

编译器优化 - 借助按本机配置优化来简化代码

作者 Hadi Brais | September 2015

编译器经常会做出不明智的优化决策,这些决策无法真正地提升代码性能,更糟糕的是,执行这些策略可能会降低代码性能。前两篇文章中介绍的优化对提升应用程序性能而言至关重要。

本文介绍了按配置优化 (PGO),这一重要技术可以帮助编译器后端更有效地优化代码。实验结果显示,性能提升从 5% 提高到 35%。此外,如果您使用谨慎,此技术绝不会降低您的代码性能。

本文是在前两篇文章的基础上编写而成:(msdn.microsoft.com/magazine/dn904673msdn.microsoft.com/magazine/dn973015)。如果您是初次接触 PGO 概念,我建议您先阅读 Visual C++ 团队博客文章 (bit.ly/1fJn1DI)。

PGO 简介

编译器执行的最重要优化任务之一就是函数内联。默认情况下,只要调用方规模没有过度增长,Visual C++ 编译器就会内联函数。虽然许多函数调用都会进行扩展,但只有在调用经常发生时这才会有用。否则,只会增加代码大小,同时浪费指令和统一缓存占用的空间,并会扩大应用工作集。编译器如何知道调用是否经常发生? 这最终取决于传递给函数的自变量。

大多数优化缺少做出明智决策所需的可靠启发。我遇到过许多寄存器分配不佳的情况,结果就是性能大幅下降。在编译代码时,您能做的就是希望全部优化带来的所有性能提升和下降在相互抵消影响后,仍可以形成积极效果。情况几乎总是这样,但可能会生成过大的可执行文件。

最好能杜绝此类性能下降。如果您可以告知编译器代码在运行时的具体行为,即可更好地优化代码。我们将在运行时记录程序行为的相关信息的过程称为配置处理,将生成的信息称为配置文件。您可以向编译器提供一个或多个配置文件,以便其按配置进行优化。这就是 PGO 技术必须完成的任务。

您可以对本机代码和托管代码使用此项技术。不过,工具是不同的,因此我在这篇文章中将仅介绍本机 PGO,并将托管 PGO 留到另一篇文章中再做介绍。本部分的剩余篇幅讨论了如何将 PGO 应用于应用。

PGO 是一项非常不错的技术。但如同其他所有技术一样,PGO 也存在不足之处。此技术不仅耗时(具体视应用大小而定),而且还费力。幸运的是,Microsoft 提供了工具,可大大降低将 PGO 应用于应用所需的时间(如下文所述)。将 PGO 应用于应用的过程分为以下 3 个阶段:检测版本、定型和 PGO 版本。

检测版本

对正在运行的程序进行配置处理的方法有多种。Visual C++ 编译器使用的是静态二进制文件检测,这会生成最准确的配置文件,但耗时比较长。借助检测,编译器可以在代码的所有函数中的相关位置上插入少量机器指令,如图 1 所示。这些指令会记录代码的相关部分的执行时间,并将此信息添加到生成的配置文件中。

按配置优化应用的检测版本
图 1:按配置优化应用的检测版本

要生成应用的检测版本,您需要执行多个步骤。首先,您需要使用 /GL 开关编译所有源代码文件,以启用全程序优化 (WPO)。检测程序时需要使用 WPO(从技术上讲,并不一定要使用,但它有助于提高生成的配置文件的实用性)。只有已使用 /GL 进行过编译的文件才会得到检测。

为了让下一阶段尽可能顺利进行,请避免使用任何可能会生成额外代码的编译器开关。例如,禁用函数内联 (/Ob0)。此外,还请禁用安全检查 (/GS-) 和删除运行时检查(无 /RTC)。也就是说,您不得使用 Visual Studio 的默认发布和调试模式。对于未使用 /GL 进行编译的文件,对其进行速度优化 (/O2)。对于检测代码,请至少指定 /Og。

接下来,使用 /LTCG:PGI 开关将生成的对象文件与所需的静态库相关联。链接器需要执行 3 项任务。它会指示编译器后端检测代码,并生成 PGO 数据库 (PGD) 文件。此文件用于在第 3 阶段中存储所有配置文件。此时,PGD 文件不包含任何配置文件,仅包含对象文件的标识信息,用于在 PGD 文件获得使用时检测这些文件是否发生了变化。默认情况下,PGD 文件以可执行文件的名称为文件名。您还可以使用可选的 /PGD 链接器开关指定 PGD 文件名。第 3 个任务是关联 pgort.lib 导入库。输出可执行文件依赖于 PGO 运行时 DLL pgortXXX.dll,其中 XXX 表示 Visual Studio 版本。

此阶段最终会生成包含大量检测代码的可执行文件(EXE 或 DLL),以及将在第 3 阶段中填充和使用的空 PGD 文件。您只能检测静态库,前提是此库与要检测的项目相关联。此外,同一版本的编译器必须生成所有 CIL OBJ 文件;否则,链接器会出错。

配置处理探测器

在进入下一阶段之前,我想讨论一下编译器插入以进行配置处理的代码,以便您能够估计程序附加开销,并了解在运行时收集的信息。

为了记录配置文件,编译器在使用 /GL 编译的每个函数中都插入了大量探测器。探测器是一小串指令序列(2 到 4 个指令),包含大量推送指令和一个最终向探测器处理程序发出的调用指令。必要时,探测器由两个函数调用进行封装,以保存和还原所有 XMM 寄存器。探测器分为以下 3 种类型:

  • 计数探测器: 这是最常见的探测器类型。此类探测器在代码块每执行一次时都会使计数器递增一次计数,从而统计代码块执行次数。就其大小和速度而言,此类探测器的成本是最低的。每个计数器在 x64 上为 8 字节,在 x86 上为 4 字节。
  • 条目探测器: 编译器在每个函数的开头位置插入条目探测器。此类探测器的用途是告知与其位于同一函数的其他探测器使用与此函数相关的计数器。此为必需探测器,因为探测器跨函数共享探测器处理程序。主函数的条目探测器负责初始化 PGO 运行时。条目探测器也是计数探测器。这是速度最慢的探测器。
  • 值探测器: 此类探测器在所有虚拟函数调用和 switch 语句之前插入,用于记录值的直方图。值探测器也是计数探测器,因为它会统计每个值的出现次数。这是最大的探测器。

如果函数仅有一个基本块(包含一个进入和退出的指令序列),则任何探测器都不会对其进行检测。事实上,尽管存在 /Ob0 开关,此函数也可能会进行内联。除了值探测器外,每个 switch 语句也都会让编译器创建具有描述用途的常量 COMDAT 部分。此部分的大小计算方式约为,用案例数量乘以控制开关的变量大小。

每个探测器最终都会调用探测器处理程序。主函数的条目探测器会创建一系列探测器处理程序指针(在 x64 上为 8 字节,在 x86 上为 4 字节),其中每个条目均指向不同的探测器处理程序。在大多数情况下,探测器处理程序只有几个。探测器在每个函数中的插入位置如下所示:

  • 条目探测器在函数的开头位置插入
  • 计数探测器在以调用或 ret 指令为结尾的每个基本块中插入
  • 值探测器在所有 switch 语句之前插入
  • 值探测器在所有虚拟函数调用之前插入

因此,检测程序的内存开销大小由探测器的数量、所有 switch 语句中的案例数量、switch 语句数量和虚拟函数调用次数而定。

在某个时间点,所有探测器处理程序都会让计数器递增一次计数,以记录相应代码块的一次执行。编译器使用 ADD 指令来使 4 字节的计数器递增一次计数;在 x64 上,编译器使用 ADC 指令让高达 4 字节的计数器执行进位加法。这些指令为非线程安全。也就是说,默认情况下所有探测器均为非线程安全。如果多个线程可能同时执行至少一个函数,则结果就不可靠。在这种情况下,您可以使用 /pogosafemode 链接器开关。这样,编译器就可以为这些指令添加前缀 LOCK,让所有探测器均变成线程安全。当然,这也会减缓它们的速度。遗憾的是,您无法有选择性地应用此功能。

如果您的应用程序由 PGO 输出是 EXE 或 DLL 文件的多个项目组成,则您必须对每个项目重复执行此流程。

定型阶段

在第 1 阶段后,您会获得可执行文件的检测版本和 PGD 文件。第 2 阶段是定型。在此阶段中,可执行文件会生成一个或多个配置文件,以存储在单独的 PGO 计数 (PGC) 文件中。您将在第 3 阶段中使用这些文件优化代码。

这是最重要的阶段,因为配置文件的准确性对整个过程的成功至关重要。配置文件必须反映程序的常见使用方案才有用。编译器优化程序的前提是已执行的方案是常见的。如果情况并非如此,那么您程序的实际效果可能会更差。通过常见的使用方案生成的配置文件可有助于编译器了解用于优化速度的热路径和用于优化大小的冷路径,如图 2 所示。

创建 PGO 应用的定型阶段
图 2:创建 PGO 应用的定型阶段

这一阶段的复杂性取决于使用方案的数量和程序的性质。如果程序不需要用户输入任何内容,则定型很简单。如果有许多种使用方案,那么依序生成每个方案的配置文件可能不是最快速的方法。

在复杂的定型方案中(如图 2 所示),pgosweep.exe 是一项命令行工具,可方便您控制配置文件的内容,此配置文件在运行时由 PGO 运行时进行维护。您可以生成多个程序实例,同时应用使用方案。

假设您有两个实例在进程 X 和 Y 中运行。当一个方案即将在进程 X 上启动时,调用 pgosweep 并向其传递进程 ID 和 /onlyzero 开关。这会让 PGO 运行时仅针对此进程清除内存中的配置文件的部分内容。无需指定进程 ID,即可清除整个 PGC 配置文件。然后,方案可以启动。您可以通过类似的方式在进程 Y 上启动第二个使用方案。

仅当所有正在运行的程序实例终止时,PGC 文件才会生成。不过,如果程序的启动时间较长,且您不想对每个方案都运行此程序,则可以强制运行时生成配置文件,并清除内存中的配置文件,为同时运行的其他方案做好准备。为此,请运行 pgosweep.exe,并传递进程 ID、可执行文件名和 PGC 文件名。

默认情况下,PGC 文件的生成目录与可执行文件相同。您可以使用 VCPROFILE_PATH 环境变量更改此目录,但必须在运行首个程序实例之前进行设置。

我已经介绍过检测代码的数据和指令开销。在大多数情况下,此开销是可以调节的。默认情况下,PGO 运行时的内存占用不会超过特定的阈值。如果需要更多内存,则会出错。在这种情况下,您可以使用 VCPROFILE_ALLOC_SCALE 环境变量来提高此阈值。

PGO 版本

在您已执行所有常见的使用方案后,您便会获得一组 PGC 文件,这些文件可用于生成程序的优化版本。您可以放弃不想使用的任何 PGC 文件。

生成 PGO 版本的第 1 步是,将所有 PGC 文件与 pgomgr.exe 命令行工具合并。您还可以使用此工具来编辑 PGD 文件。若要将两个 PGC 文件合并到您在第 1 阶段生成的 PGD 文件中,请运行 pgomgr,然后传递 /merge 开关和 PGD 文件名。这会合并当前目录中名称与指定 PGD 文件的名称(后跟 !# 和数字)匹配的所有 PGC 文件。编译器和链接器可以使用生成的 PGD 文件来优化代码。

您可以使用 pgomgr 工具捕获更常见或更重要的使用方案。为此,请传递相应的 PGC 文件名和 /merge:n 开关,其中 n 是某正整数,表示 PGD 文件中包含的 PGC 文件的副本数量。默认情况下,n 是 1。这种多重性会令特定的配置文件偏好优化。

第 2 步是运行链接器,传递第 1 阶段中的同一组对象文件。此时,使用 /LTCG:PGO 开关。链接器会在当前目录中查找与可执行文件同名的 PGD 文件。这可确保自 PGD 文件在第 1 阶段中生成后,CIL OBJ 文件没有发生变化,并将它传递给编译器以使用和优化代码。有关此流程的信息,请参见图 3。您可以使用 /PGD 链接器开关明确指定 PGD 文件。请不要忘记为这一阶段启用函数内联。

第 3 阶段中的 PGO 版本
图 3:第 3 阶段中的 PGO 版本

大多数编译器和链接器优化都会变成按配置优化。此阶段最终会生成在大小和速度方面均高度优化的可执行文件。我们建议您此时衡量性能提升幅度。

维护基本代码

如果您使用 /LTCG:PGI 开关对传递给链接器的任意输入文件进行任何更改,则在指定 /LTCG:PGO 的情况下,链接器会拒绝使用 PGD 文件。这是因为此类更改会极大地影响 PGD 文件的实用性。

一种选择是重复执行上述 3 个阶段,生成另一个兼容的 PGD 文件。不过,如果更改量非常小(如添加少量函数、略微增加或降低函数调用频次,或添加不常用的功能),则重复整个流程也不实际。在这种情况下,您可以使用 /LTCG:PGU 开关,而不是 /LTCG:PGO 开关。这会指示链接器跳过 PGD 文件的兼容性检查。

这些细微更改会随着时间的推移进行累积。您最终会需要再次检测应用。通过在 PGO 生成代码时检查编译器输出,您可以确定何时需要这样做。您可以从中了解 PGD 覆盖的基本代码量。如果配置文件的覆盖率下降至 80% 以下(如图 4 中所示),那么我们建议您再次检测代码。不过,此百分比主要取决于应用程序的性质。

PGO 的实际使用场景

PGO 指导编译器和链接器优化。我将使用 NBody 模拟器来展示它的一些优势。您可以从 bit.ly/1gpEaCY 下载此应用程序。此外,您还需要从 bit.ly/1LQnKge 下载和安装 DirectX SDK,以编译此应用程序。

首先,我将在发布模式下编译此应用程序,将其与 PGO 版本进行比较。若要生成应用的 PGO 版本,您可以使用 Visual Studio“生成”菜单的“按配置优化”菜单项。

您还应使用 /FA[c] 编译器开关来启用汇编程序输出(对于此演示,请勿使用 /FA[c]s)。对于此简单应用,定型一次检测应用就足以生成一个 PGC 文件,并将它用于优化此应用。这样一来,您会获得两个可执行文件:一个是盲优化文件,另一个是 PGO 文件。请确保您可以访问最终 PGD 文件,因为稍后您将需要使用此文件。

现在,如果您依次运行两个可执行文件,并比较实现的 GFLOP 计数,则会注意到它们的性能类似。显然,将 PGO 应用于此应用是在浪费时间。经过进一步调查,我们发现此应用的大小已从 531 KB(对于盲优化的应用)下降到 472 KB(对于基于 PGO 的应用),或减少了 11%。因此,将 PGO 应用于此应用后,大小是降低了,但性能仍保持不变。为什么会这样呢?

请考虑 DXUT/Core/DXUT.CPP 文件中第 200 行的函数 DXUTParseCommandLine。通过查看生成的发布版本程序集代码,您会发现二进制代码的大小约为 2700 字节。另一方面,PGO 版本中的二进制代码大小未超过 1650 字节。您可以从检查以下循环的条件的程序集指令中,发现导致此差异出现的原因:

for( int iArg = iArgStart; iArg < nNumArgs; iArg++ ) { ... }

盲优化版本生成了以下代码:

0x044 jge block1
; Fall-through code executed when iArg < nNumArgs
; Lots of code in between
0x362 block1:
; iArg >= nNumArgs
; Lots of other code

另一方面,PGO 版本生成了以下代码:

0x043 jl   block1
; taken 0(0%), not-taken 1(100%)
block2:
; Fall-through code executed when iArg >= nNumArgs
0x05f ret  0
; Scenario dead code below
0x000 block1:
; Lots of other code executed when iArg < nNumArgs

许多用户更愿意在 GUI 中指定参数,而不是在命令行中传递参数。因此,如配置文件信息所示,这里的常见方案是从不循环访问的循环。如果没有配置文件,编译器就无法知晓这一点。所以,它继续工作,并在循环内主动地优化代码。它会扩展许多函数,生成大量毫无意义的代码。在 PGO 版本中,您为编译器提供了一个配置文件,提示循环从未执行。编译器从中了解到,内联从循环主体中调用的任何函数毫无意义。

您会从程序集代码片段中发现另一个有趣的区别。在盲优化的可执行文件中,很少执行的分支位于条件指令的贯穿路径中。几乎总是执行的分支位于距条件指令 800 字节的位置处。这不仅会导致处理器分支预测程序失败,还会导致指令缓存失误。

PGO 版本通过交换分支位置,避免了以上两种问题。事实上,它是将很少执行的分支移到可执行文件中的单独部分内,因此提升了工作集局部性。我们将这种优化称为死代码分离。没有配置文件,就无法执行。不经常调用的函数(如二进制代码中的细微差异)会导致性能出现巨大差异。

当生成 PGO 代码时,编译器会显示为了优化所有检测函数的速度已编译了多少函数。编译器还会在 Visual Studio 输出窗口中显示此信息。通常,为优化速度而编译的函数所占的百分比不得超过 10%(视作主动内联),其余函数可进行编译以优化大小(视作部分内联或不内联)。

请考虑更有趣一点的函数 DXUTStaticWndProc(在同一文件中进行定义)。函数控制结构如下所示:

if (condition1) { /* Lots of code */ }
if (condition2) { /* Lots of code */ }
if (condition3) { /* Lots of code */ }
switch (variable) { /* Many cases with lots of code in each */ }
if-else statement
return

盲优化的代码按照源代码中的相同顺序生成各个代码块。不过,已借助各块的执行频次和执行时间,对 PGO 版本中的代码进行了巧妙地重新排列。前两个条件很少执行,因此,若要提高缓存和内存利用率,相应的代码块现在位于单独的部分中。此外,被识别为贯穿热路径的函数(如 DXUTIsWindowed)现在是内联的:

if (condition1) { goto dead-code-section }
if (condition2) { goto dead-code-section }
if (condition3) { /* Lots of code */ }
{/* Frequently executed cases pulled outside the switch statement */}
if-else statement
return
switch(variable) { /* The rest of cases */ }

大多数优化都受益于可靠的配置文件,另一些也变得可以执行。如果 PGO 没有大大提升性能,则一定会减少生成的可执行文件的大小及其在内存系统上的开销。

PGO 数据库

PGD 配置文件的优势远远超过了指导编译器优化。虽然您可以使用 pgomgr.exe 合并多个 PGC 文件,但还可以将它用于其他用途。它提供了 3 个开关,可便于您查看 PGD 文件的内容,从而就已执行的方案全面了解代码行为。第 1 个开关 /summary 指示工具生成 PGD 文件内容的文本摘要。第 2 个开关 /detail 与第 1 个开关合作,指示工具生成详细的配置文件文本说明。最后一个开关 /unique 指示工具取消修饰函数名称(尤其适用于 C++ 基本代码)。

编程控制

还有最后一项功能值得一提。pgobootrun.h 头文件声明一个 PgoAutoSweep 函数。您可以调用此函数,以编程方式生成 PGC 文件,并清除内存中的配置文件来为下一个 PGC 文件做好准备。此函数采用一个类型为 char* 的自变量,指代 PGC 文件名。您必须关联 pgobootrun.lib 静态库,才能使用此函数。目前,这是与 PGO 相关的唯一编程支持。

总结

PGO 是一项优化技术,可在需要解决在大小和速度之间进行取舍的问题时,引用可靠的配置文件,帮助编译器和链接器做出更明智的优化决策。通过项目的“生成”菜单或上下文菜单,Visual Studio 提供了对此技术的可视化访问。

不过,您可以通过 PGO 插件使用一组更丰富的功能,下载地址为 bit.ly/1Ntg4Bebit.ly/1RLjPDi 中也进行了详细记录。回想一下图 4 中的覆盖率阈值,实现它的最简单方法是使用本文档中所述的插件。不过,如果您希望使用命令行工具,则可以参阅 bit.ly/1QYT5nO 中的文章,参考大量示例。如果您有本机基本代码,我们建议您立即试用此技术。试用时,您可以随时告知我,此操作对您应用程序的大小和速度产生了哪些影响。

PGO 基本代码维护周期
图 4:PGO 基本代码维护周期

更多资源

有关按配置优化数据库的详细信息,请参阅 Hadi Brais 的博客文章 (bit.ly/1KBcffQ)。


Hadi Brais获得了德里印度理工学院 (IITD) 的博士学位,主要研究下一代内存技术的编译器优化。他将大部分精力用于编写 C/C++/C# 代码上,并深入研究了运行时和编译器框架。他的博客网址是 hadibrais.wordpress.com。您可以通过 hadi.b@live.com 与他联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Ankit Asthana