转自:https://msdn.microsoft.com/zh-cn/magazine/dn973015
欢迎来到编译器优化系列的第二部分。在第一篇文章 (msdn.microsoft.com/magazine/dn904673) 中,我探讨了函数内联、循环展开、循环不变量代码移动、自动矢量化和 COMDAT 优化。在此第二篇文章中,我将探讨另外两个优化,即寄存器分配和指令调度。如往常一样,我将重点放在 Visual C++ 编译器上,并简短地介绍各项内容在 Microsoft .NET Framework 中的工作方式。我将使用 Visual Studio 2013 来编译代码。我们现在开始吧。
寄存器分配是将一组变量分配到可用寄存器的进程,从而不需要在内存中分配这些变量。通常,在整个函数的层面上执行这一进程。不过,尤其当启用了链接时间代码生成 (/LTCG) 后,可以在各个函数中执行该进程,这可以带来更高效的分配。(在本节中,所有变量都是自动产生的,且它们的生存期通过语法确定,除非另行指定)。
寄存器分配是一项非常重要的优化。若要了解这一点,让我们一起看看访问不同级别的内存需要多长时间。与一个处理器周期相比,访问寄存器耗时更短。访问缓存的速度稍慢一些,且耗时几个周期到几十个周期。访问(远程)DRAM 内存甚至比那还要慢。最后,访问硬盘的速度极其缓慢,需要耗时数百万个周期。此外,内存访问增加了到共享缓存和主内存的流量。寄存器分配通过尽可能使用可用的寄存器来减少内存访问量。
该编译器尝试将寄存器分配到每个变量,理想情况下直到涉及该变量的所有指令都被执行为止。如果不可行(这很正常,稍后我将讨论原因),则一个或多个变量需要溢入内存,因此必须频繁地加载和存储它们。寄存器不足是指由于寄存器不可用而溢出的寄存器的数量。较大的寄存器压力表示更多的内存访问,而更多的内存访问不仅放缓了程序本身,还会使整个系统放缓。
新型 x86 处理器提供以下可以通过编译器进行分配的寄存器:8 个 32 位的通用寄存器、8 个 80 位的浮点寄存器和 8 个 128 位的矢量寄存器。所有 x64 处理器提供 16 个 64 位的通用寄存器、8 个 80 位的浮点寄存器和至少 16 个矢量寄存器(每个矢量寄存器至少 128 位)。新型 32 位 ARM 处理器提供 15 个 32 位的通用寄存器和 32 个 64 位的浮点寄存器。所有 64 位的 ARM 处理器提供 31 个 64 位的通用寄存器、32 个 128 位的浮点寄存器和 16 个 128 位的矢量寄存器 (NEON)。所有这些寄存器均可用于寄存器分配(或者您还可以向列表中添加图形卡提供的寄存器)。当本地变量不能被分配到任意可用寄存器时,则需要在堆栈上分配该变量。出于各种原因,这种情况几乎在每个函数上都会发生,关于这方面我将在下文进行讨论。我们来看一下图 1 中显示的程序。该程序没有任何意义,但是在演示寄存器分配方面是一个非常好的示例。
图 1 寄存器分配示例程序
#include <stdio.h> int main() { int n = 0, m; scanf_s("%d", &m); for (int i = 0; i < m; ++i){ n += i; } for (int j = 0; j < m; ++j){ n += j; } printf("%d", n); return 0; }
在该程序将可用的寄存器分配到变量之前,该编译器首先会分析该函数(或 /LTCG 情况下的所有函数)中所有已声明变量的使用情况,以确定哪些变量组同时有效,并评估访问每个变量的次数。可以将不同组的两个变量分配到同一个寄存器。对于同组的某些变量,如果没有适当的寄存器,则不得不溢出这些变量。该编译器会尝试选择溢出访问最少的变量,以最小化内存访问的总量。大体上就是这个思路。但是,在很多特殊情况下,可能找到更好的分配。新型编译器能够设计出一个良好的分配,但不是最佳分配。不过,对于常人而言,真的很难做到更好。
考虑到这一点,我将使用已启用的优化编译图 1 中的程序并查看该编译器是如何将本地变量分配到寄存器的。需要分配的变量有 4 个:n、m、i 和 j。假设本例中的目标是 x86 平台。通过检查生成的程序集代码 (/FA),我注意到变量 n 已被分配到寄存器 ESI,变量 m 已被分配到 ECX,而变量 i 和 j 均被分配到 EAX。请注意该编译器是如何巧妙地为两个变量重复使用 EAX 的,因为它们的生存期没有交集。另请注意,该编译器在堆栈上为 m 预留了空间,因为已提取了该变量的地址。在 x64 平台上,变量 n 将被分配到寄存器 EDI,变量 m、i 和 j 分别被分配到 EDX、EAX 和 EBX。由于某种原因,该编译器没有将 i 和 j 分配到同一个寄存器。
这是最佳分配吗?否。问题在于使用 ESI 和 EDI 上。这些寄存器都是被调用方保存的寄存器,这表示调用的函数必须要确保这些寄存器在出口中保留的值与在入口中保留的值相一致。这就是该编译器不得不进行以下操作的原因:在函数入口处发出一个指令以在堆栈上推送 ESI/EDI,以及在出口处发出另一个指令以从堆栈中弹出它们。使用调用方保存的寄存器(如 EDX),该编译器就无法避免在两个平台上出现上述问题。有时,寄存器分配算法中的此类缺陷可以通过函数内联来缓解。很多其他优化可以将代码呈现为适于更高效的寄存器分配,例如死码消除、公用子表达式消除和指令调度。
实际上,变量具有不相交的生存期是很正常的,因此向所有这些变量分配同一个寄存器是非常经济的做法。但是,如果耗尽所有寄存器来满足这些变量中的任意一个,那怎么办呢?您需要将它们溢出。不过,您可以使用一个巧妙的方式来进行。您可以将它们全部溢入到堆栈上的相同位置。此优化称为堆栈打包,并且受 Visual C++ 的支持。堆栈打包可减少堆栈帧的大小并且可提高数据缓存命中率,从而更好地提高性能。
遗憾的是,事情并非这么简单。从理论角度而言,可以实现(接近)最佳的寄存器分配。但实际上,造成无法实现的原因有很多:
所有这些让您感觉当前的编译器在寄存器分配方面不尽人意。不过,从某种程度上来说,它们在寄存器分配方面做得还不错,并且在慢慢地朝着更好的方向发展。而且,即便想到这些,您能想象自己编写程序集代码吗?
在面向 x86 体系结构时,通过启用 /LTCG,您可以帮助编译器潜在地找到更好的分配。如果您指定 /GL 编译器开关,生成的 OBJ 文件将包含 C 中间语言 (CIL) 代码,而非程序集代码。无法将函数调用约定合并到 CIL 代码中。如果特定函数不是定义为从可执行输出中导出,则该编译器可以违反其调用约定以提高性能。这种情况是可能的,因为它可以识别函数的所有调用站点。不管调用约定如何,Visual C++ 确实通过使函数的所有参数都符合寄存器分配条件来利用这一点。即使无法改进寄存器分配,该编译器仍会尝试重新对参数进行排序以获得更加经济的对齐方式,甚至会删除无用的参数。如果没有 /GL 开关,生成的 OBJ 文件包含二进制代码,其中已考虑调用约定。如果程序集 OBJ 文件含有指向 CIL OBJ 文件中的函数的调用站点,或者如果已在任意位置提取该函数的地址,又或者它是虚拟的,则该编译器不再优化其调用约定。默认情况下,如果没有 /LTCG,所有函数和方法都含有外部链接,因此该编译器无法应用此技术。但是,如果 OBJ 文件中的函数已通过内部链接明确定义,则该编译器可将此技术应用于该函数,不过只限于 OBJ 文件内部。当面向 x86 体系结构时,此技术(在本文档中称为自定义调用约定)非常重要,因为默认的调用约定 __cdecl 无效。另一方面,x64 体系结构上的 __fastcall 调用约定非常有效,因为前四个参数通过寄存器进行传递。出于这一原因,只有在面向 x86 时才执行自定义调用约定。
请注意,即使启用了 /LTCG,导出函数或方法的调用约定仍不可违背,因为编译器不可能找到所有调用网站,就像之前提到的所有情况一样。
寄存器分配的有效性取决于估计的变量访问量的准确性。大多数函数包含条件语句,损害了这些估计值的准确性。配置文件引导的优化可用于微调这些估计值。
在启用 /LTCG 且目标平台是 x64 时,该编译器会执行过程间寄存器分配。这意味着,根据每个函数中代码强制执行的限制,它会考虑函数链中声明的变量并尝试找到更好的分配。否则,该编译器会执行全局寄存器分配。在这种分配中,会分别处理每个函数(“全局”在此处表示整个函数)。
C 和 C++ 都提供寄存器关键字,使编程人员能够向编译器提供有关在寄存器中存储哪些变量的提示。事实上,C 的第一个版本引入了此关键字并且该关键字在当时(大约 1972 年)非常有用,因为没有人知道如何有效地执行寄存器分配。(不过,在上个世纪六十年代,IBM Corp. 为 S/360 系列开发了 FORTRAN IV 编译器,该编译器可以执行简单的寄存器分配。大多数 S/360 模型提供 16 个 32 位的通用寄存器和 4 个 64 位的浮点寄存器!)此外,正如 C 的很多其他功能一样,该寄存器关键字使得编写 C 编译器更加容易了。大约十年后,C++ 问世了并且它提供寄存器关键字,因为 C 被视为 C++ 的子集。(遗憾的是,二者之间存在很多细微的差别。)自上个世纪八十年代初以来,已实现很多有效的寄存器分配算法,因此时至今日,关键字的存在产生了很多混乱。自从那时起,问世的大多数生产语言都不提供此类关键字(包括 C# 和 Visual Basic)。自从 C++11 起,此关键字已被弃用,但是最新版的 C 和 C11 中仍保留。此关键字应该只用于编写基准。如果可能的话,Visual C++ 编译器不会接受此关键字。C 不允许提取寄存器变量的地址。C++ 确实允许提取寄存器变量的地址,但是该编译器随后需要在可寻址的位置中存储该变量,而不是在寄存器中进行存储,这样会违反其手动指定的存储类。
面向 CLR 时,该编译器需要发出对堆栈计算机建模的公共中间语言 (CIL) 代码。在这种情况下,该编译器不会执行寄存器分配(尽管发出的代码有些是本机代码,但是寄存器分配仍会在其上执行)并且会推迟寄存器分配,直到实时 (JIT) 编译器(或 .NET 本机编译情况中的 Visual C++ 后端)执行运行时为止。RyuJIT(随附 .NET Framework 4.5.1 和更高版本的 JIT 编译器)实施非常棒的寄存器分配算法。
在编译器发出二进制之前,寄存器分配和指令调度是编译器执行的最新优化中的两个优化。
在多个阶段中几乎执行了所有最简单的指令,其中每个阶段都由一个特定的处理器单元进行处理。若要尽量使用所有这些单元,该处理器以管道的方式发布了多个指令,以便于在相同时间在不同阶段执行不同的指令。这可以显著提高性能。但是,如果出于某种原因,其中一个指令尚未准备好进行执行,整个管道将会停转。出现这种情况的原因有很多,包括等待另一个指令提交其结果;等待来自内存或磁盘的数据;或等待需要完成的 I/O 操作。
指令调度是一项可以缓解此问题的技术。有以下两种指令调度:
还有其他原因可能造成该编译器对特定的指令进行重新排序。例如,该编译器可能对嵌入的循环重新排序,以便该代码更好地展示引用的位置(此优化称之为循环交换)。另一个示例就是,减少寄存器溢出的成本,即通过制定使用从连续内存加载的同一个值(因此该值只需加载一次即可)的指令实现。当然,另外一个示例旨在减少数据和指令缓存未命中数。
作为一名程序员,您无需知道编译器或处理器如何执行指令调度。但是,您应该清楚此技术的结果以及如何处理它们。
虽然指令调度会保留大多数程序的正确性,但是可能产生一些非直观且令人惊讶的结果。从图 2 中的示例可以看出,指令调度导致编译器发出错误代码。若要进行查看,请在“发布”模式下将该程序编译为 C 代码 (/TC)。您可以将目标平台设置为 x86 或 x64。由于您打算检查产生的程序集代码,请指定 /FA,以便该编译器发出一个程序集列表。
图 2 指令调度示例程序
#include <stdio.h> #include <time.h> __declspec(noinline) int compute(){ /* Some code here */ return 0; } int main() { time_t t0 = clock(); /* Target location */ int result = compute(); time_t t1 = clock(); /* Function call to be moved */ printf("Result (%d) computed in %lld ticks.", result, t1 - t0); return 0; }
在此程序中,我想要测量计算函数的运行时间。为此,我常通过调用一个计时函数(如时钟)来将该调用打包到该函数中。然后,通过计算时钟中不同值的差异,我获得了执行该函数的预计时间。请注意,此代码的目的并非向您展示衡量某些代码段性能的最佳方法,而是为了演示指令调度的风险。
因为这是 C 代码而且该程序非常简单,因此很容易理解产生的程序集代码。通过查看该程序集代码并关注该调用指令,您会发现,对时钟函数的第二个调用先于对计算函数的调用(它已被移到“目标位置”),从而导致衡量完全错误。
请注意,此类重新排序不违反遵循实施情况的标准强制实行的最低要求,因此是合法的。
但是,为什么该编译器要执行该操作?该编译器认为对于时钟的第二次调用不依赖对计算的调用(实际上,对于编译器而言,这些函数相互之间根本不受影响)。此外,在对时钟的第一次调用之后,指令缓存很可能包含该函数的某些指令并且数据缓存包含这些指令所需的某些数据。调用计算可能导致这些指令和数据被覆盖,因此该编译器相应地对代码重新排序。
Visual C++ 编译器不提供关闭指令调度同时保持所有其他优化处于打开状态的开关。而且,如果计算函数是内联其中的,则动态执行可能导致产生此问题。根据计算函数的执行方式以及处理器可以展望的范围,3OE 处理器可能决定首先开始执行对时钟的第二次调用,然后才完成该计算函数。正如编译器一样,大多数处理器不会允许您关闭动态执行。但是,为了公平起见,此问题不太可能是由动态执行造成的。不管怎样,您怎么知道是否会发生呢?
事实上,在执行此优化时,Visual C++ 编译器非常谨慎。正是因为谨慎,也因此有很多内容阻止它对指令(如调用指令)重新排序。我已经注意到以下函数造成该编译器无法将时钟函数调用移到特定位置(目标位置):
__declspec(noinline) int compute(){ int x; scanf_s("%d", &x); /* Calling an imported function */ return x; }
int main() { time_t t0 = clock(); int result = compute(); printf("%d", result); /* Calling an imported function */ time_t t1 = clock(); printf("Result (%d) computed in %lld.", result, t1 - t0); return 0; }
int x = 0; __declspec(noinline) int compute(){ return x; }
还有其他情况会阻止编译器对指令重新排序。归根结底都是 C++ as-if 规则,其表明编译器可以按照任意方式转换不包含未定义操作的程序,只要代码的可观察行为可确保保持不变即可。Visual C++ 不仅遵循此规则,而且在降低编译代码所耗费的时间方面更加保守。导入的函数可能带来副作用。库 I/O 函数和访问易失的变量会带来副作用。
使变量具备易失关键字资格会影响寄存器分配和指令重新排序。首先,该变量不会被分配到任何寄存器。(大多数指令需要其中的一些操作数存储在寄存器中,这表示该变量将被加载到寄存器,但只为了执行使用该变量的某些指令)。也就是说,读取或写入变量将始终会导致内存访问。第二,写入易失变量具有 Release 语义,这表示语法上,在写入该变量之前发生的所有内存访问都在写入该易失变量之前发生。第三,读取易失变量具有 Acquire 语义,这表示语法上,在读取该变量后发生的所有内存访问都在读取该易失变量后发生。但这有一个问题:这些重新排序保证只能通过指定 /volatile:ms 开关来提供。相比之下,/volatile:iso 开关指示编译器遵循语言标准,这不会通过此关键字提供此类保证。对于 ARM,默认情况下,/volatile:iso 会生效。对于其他体系结构,默认是 /volatile:ms。在 C++11 之前,/volatile:ms 开关非常有用,因为该标准不为多线程程序提供任何内容。但是,自 C11/C++11 以来,/volatile:ms 的使用会使您的代码不可移植并且强烈建议不要使用,相反您应该使用原子。值得注意的是,如果您的程序在 /volatile:iso 下能够正常工作,则它能够在 /volatile:ms 下正常工作。不过,更重要的是,如果它能够在 /volatile:ms 下正常工作,则它可能无法在 /volatile:iso 下正常工作,因为前者比后者提供的保证更强大。
/volatile:ms 开关同时实施 Acquire 和 Release 语义。无法在编译时间里维护这些语义;该编译器(依靠目标平台)可能发出额外的指令(如 mfence 和 xchg)以指示 3OE 处理器维护这些语义,同时执行该代码。因此,易失变量会降低性能,这不仅是因为这些变量无法在寄存器中进行缓存,还因为发出了其他指令。
根据 C# 语言规范,易失关键字的语义类似于含有指定 /volatile:ms 开关的 Visual C++ 编译器提供的语义。但是,这之间有区别。C# 中的易失关键字实施顺序一致 (SC) Acq/Rel 语义,而 /volatile:ms 下的 C/C++ 易失关键字实施纯 Acq/Rel 语义。请记住,/volatile:iso 下的 C/C++ 易失关键字不含 Acq/Rel 语义。不过,相关详细信息不在本文的讨论范围。一般情况下,内存范围可能会使该编译器无法跨越它们执行很多优化。
了解以下内容很重要:如果该编译器没有在第一时间提供此类保证,则处理器提供的所有相应保证都会自动失效。
__restrict 关键字(或限制)还会影响寄存器分配和指令调度的有效性。但是,与易失关键字相比,限制可以显著改进这些优化。使用某个范围内的此关键字标记的指针变量表示没有其他指向相同对象、在范围外部创建以及用于修改它的变量。此关键字还可以使编译器在指针上执行很多优化(自信地包括自动矢量化和循环优化),并且这减少了生成代码的大小。您可以将限制关键字视为顶级机密、高科技和反反优化的武器。它应该用一整篇文章进行探讨,因此,我不在此处加以探讨。
如果变量标记了易失性和 __restrict,则在决定如何优化代码时,优先考虑易失关键字。事实上,编译器完全可以忽略限制,但是必须重视易失性。
通过 /favor 开关,该编译器可以执行转向指定体系结构的指令调度。它还可以减少生成的代码大小,因为该编译器可以不发出查看该处理器是否支持某个特定功能的指令。反过来,这会提高指令缓存的命中率并提高性能。默认是 /favor:blend,它会使代码在 Intel Corp. 和 AMD 的 x86 和 x64 处理器中表现良好。
我探讨了 Visual C++ 编译器执行的两个重要的优化:寄存器分配和指令调度。
寄存器分配是该编译器执行的最重要的优化,因为访问寄存器比访问缓存更快。指令调度也很重要。但是,最新的处理器拥有卓越的动态执行能力,因此与以前相比,指令调度的重要性降低了。尽管如此,该编译器可以查看函数的所有指令,无论大小,而处理器只能查看有限数量的指令。此外,无序执行硬件非常耗电,因为只要核心在工作,该硬件就始终在工作。此外,x86 和 x64 处理器实现强于 C11/C++11 内存模型的内存模型,并且阻止提高性能的特定指令的重新排序。因此,对于电量有限的设备,基于编译器的指令调度仍然极其重要。
若干个关键字和编译器开关可能以积极或消极的方式影响性能,因此请务必适当使用它们以确保您的代码尽可能快速运行,并产生正确的结果。还有很多优化需要讨论,敬请期待!
Hadi Brais 是印度新德里理工大学 (IITD) 博士,主要研究针对下一代内存技术的编译器优化。他将大部分精力用在编写 C/C++/C# 代码方面,并深入研究了 CLR 和 CRT。他的博客网址是hadibrais.wordpress.com。您可以通过 [email protected] 与他联系。
衷心感谢以下技术专家对本文的审阅:Jim Hogg(Microsoft Visual C++ 团队)