此文转载。原文地址:https://msdn.microsoft.com/zh-cn/magazine/dn904673.aspx
高级编程语言提供了许多抽象的编程构造(如函数、条件语句和循环),可以让我们获得令人惊讶的工作效率。但是,在高级编程语言中编写代码的一个缺点是性能可能显著降低。理想情况下,您应编写易于理解、可维护的代码 — 同时不影响性能。出于此原因,编译器将尝试自动优化代码以提高其性能,如今在这方面的操作已经相当复杂。他们可以转换循环、条件语句和递归函数;消除整个代码块;并且利用目标指令集体系结构 (ISA) 使代码变得快速紧凑。更好的方法是专注于编写易于理解的代码,而手动优化会导致含义模糊、难于维护的代码。实际上,手动优化代码可能会阻止编译器执行其他或效率更高的优化操作。
不必手动优化代码,您应考虑设计方面,如使用更快的算法、合并线程级并行性和使用特定于框架的功能(如使用移动构造函数)。
本文介绍了 Visual C++ 编译器优化。下面我要讨论最重要的优化技术,以及为了应用这些技术编译器必须做出的决策。目的并不是告诉您如何手动优化代码,而是向您说明为什么可以信赖编译器来代表您优化代码。本文绝非由 Visual C++ 编译器执行的完整优化检查。但是,本文介绍了您真正想要了解的优化内容,以及如何通过与编译器通信来应用优化。
还介绍了超越当前任何编译器功能的其他重要优化 — 例如,将无效算法替换为有效算法,或更改数据结构布局改善其局部性。但是,此类优化都超出了本文的讨论范围。
定义编译器优化
优化是将一段代码转换为其他功能上等效的代码以提高一个或多个特征的过程。两个最重要的特征是代码速度和大小。其他特征包括执行代码所需的能量、编译代码所花费的时间、生成的代码需要实时 (JIT) 编译的情况,以及 JIT 编译代码所花费的时间。
编译器不断改善用于优化代码的技术。但是,它们不是完美的。当然,与其花费时间手动调整程序,通常更富有成效的做法是,使用编译器提供的特定功能并让编译器对代码进行调整。
有四种方法来帮助编译器更有效地优化您的代码:
- 编写易于理解、可维护的代码。不应将 Visual C++ 的面向对象的功能视为性能的敌人。最新版本的 Visual C++ 可以将此类开销保持在最低限度,并有时可以完全消除开销。
- 使用编译器指令。例如,告知编译器使用函数调用约定,速度比默认的更快。
- 使用编译器内部函数。内部函数是一个由编译器自动提供其实现的特殊函数。编译器特别了解此函数,并将函数调用替换为利用目标 ISA 的效率极高的指令序列。目前,Microsoft.NET Framework 不支持内部函数,因此任何托管语言均不支持内部函数。但是,Visual C++ 可以广泛支持此功能。请注意,虽然使用内部函数可提高代码的性能,但会减少其可读性和可移植性。
- 使用按配置优化 (PGO)。使用此技术,编译器更了解代码在运行时的行为方式,并对其进行相应优化。
本文的目的是通过演示在效率低下但可理解的代码上执行优化(应用第一种方法)来向您说明为什么可以信任编译器。此外,我将对按配置优化作简短介绍,并提到一些可以微调代码某些部分的编译器指令。
有许多编译器优化技术,包括简单转换(如常量折叠)和极端转换(如指令调度)等。但是,在本文中,我将仅对某些最重要的优化进行讨论 — 这些优化可以显著(通过两位数的百分比表示)提高性能并减少代码大小:函数内联、COMDAT 优化和循环优化。我将在下一节中讨论前两个,然后向您演示如何控制由 Visual C++ 执行的优化。最后,我将简单介绍下 .NET Framework 中的优化。在整篇文章中,我将使用 Visual Studio 2013 构建代码。
链接时代码生成
链接时代码生成 (LTCG) 是一种对 C/C++ 代码执行全程序优化 (WPO) 的技术。C/C++ 编译器单独编译每个源文件,并生成相应的对象文件。这意味着编译器仅可将优化应用于单个源文件,而不是应用于整个程序。但是,可以仅通过查看整个程序执行一些重要的优化。可以在链接时(而不是在编译时)应用这些优化,因为链接器具有该程序的完整视图。
启用 LTCG(通过指定 /GL 编译器开关)后,编译器驱动程序 (cl.exe) 将仅调用编译器前端(c1.dll 或 c1xx.dll),并将其后端 (c2.dll) 的工作推迟到链接时。生成的对象文件包含 C 中间语言 (CIL) 代码,而不包含依赖于计算机的程序集代码。然后,当调用链接器 (link.exe) 时,会认为对象文件包含 CIL 代码,并调用编译器后端,而后执行 WPO、生成二进制对象文件,并返回到链接器以整合所有对象文件并生成可执行文件。
前端实际上会执行一些优化(如常量合并),而不考虑是否已启用或禁用优化。但是,所有的重要优化由编译器后端执行,并且可以使用编译器开关进行控制。
LTCG 可以使后端主动执行许多优化(通过指定 /GL 与 /O1 或 /O2 和 /Gw 编译器开关以及 /OPT:REF 和 /OPT:ICF 链接器开关执行)。在本文中,我将只讨论函数内联和 COMDAT 优化。请参阅本文档,获取 LTCG 优化的完整列表。请注意,该链接器可以对本机对象文件、混合本机/托管对象文件、纯托管对象文件、安全的托管对象文件和安全的 .netmodules 执行 LTCG。
我将构建一个包含两个源文件(source1.c 和 source2.c)和一个头文件 (source2.h) 的程序。source1.c 和 source2.c 文件,分别如图 1 和图 2 中所示。头文件非常简单,包含 source2.c 中所有函数的原型,因此本文将不对其进行介绍。
图 1 source1.c 文件
#include <stdio.h> // scanf_s and printf.
#include "Source2.h"
int square(int x) { return x*x; }
main() {
int n = 5, m;
scanf_s("%d", &m);
printf("The square of %d is %d.", n, square(n));
printf("The square of %d is %d.", m, square(m));
printf("The cube of %d is %d.", n, cube(n));
printf("The sum of %d is %d.", n, sum(n));
printf("The sum of cubes of %d is %d.", n, sumOfCubes(n));
printf("The %dth prime number is %d.", n, getPrime(n));
}
图 2 source2.c 文件
#include <math.h> // sqrt.
#include <stdbool.h> // bool, true and false.
#include "Source2.h"
int cube(int x) { return x*x*x; }
int sum(int x) {
int result = 0;
for (int i = 1; i <= x; ++i) result += i;
return result;
}
int sumOfCubes(int x) {
int result = 0;
for (int i = 1; i <= x; ++i) result += cube(i);
return result;
}
static
bool isPrime(int x) {
for (int i = 2; i <= (int)sqrt(x); ++i) {
if (x % i == 0) return false;
}
return true;
}
int getPrime(int x) {
int count = 0;
int candidate = 2;
while (count != x) {
if (isPrime(candidate))
++count;
}
return candidate;
}
source1.c 文件包含两个函数:square 函数(获取一个整数并返回其平方值)和该程序的 main 函数。main 函数调用 square 函数和 source2.c 的所有函数(isPrime 除外)。Source2.c 文件包含五个函数:cube 函数返回给定整数的立方值;sum 函数返回从 1 到给定整数的所有整数之和;sumOfcubes 函数返回从 1 到给定整数的所有整数立方和;isPrime 函数确定给定的整数是否为质数;getPrime 函数返回 xth 质数。我省略了错误检查,因为这不是本文的要点。
该代码简单但很有用。有许多执行简单计算的函数;有一些需要简单的 for 循环。getPrime 函数最复杂,因为它包含了一个 while 循环,并在循环中调用 isPrime 函数,其中 isPrime 函数还包含一个循环。我将使用此代码来说明其中一个最重要的编译器优化(即函数内联)和一些其他优化。
我将构建三种不同配置的代码,并检查结果以确定编译器如何对该代码进行转换。如果您继续进行,您将需要汇编程序输出文件(通过 /FA[s] 编译器开关生成)来检查生成的程序集代码,并需要映射文件(通过 /MAP 链接器开关生成)来确定已执行的 COMDAT 优化(如果使用 /verbose:icf 和 /verbose:ref 开关,链接器还可以对此进行报告)。因此请确保在以下我讨论的所有配置中指定这些开关。此外,我将使用 C 编译器 (/TC),以便更轻松地检查生成的代码。不过,我在这里讨论的所有内容也适用于 C++ 代码。
调试配置
使用调试配置的主要原因是,当指定 /Od 编译器开关而未指定 /GL 开关时,将禁用所有后端优化。当在此配置下构建代码时,生成的对象文件将包含与源代码完全对应的二进制代码。您可以检查生成的汇编程序输出文件和映射文件,以确认这一点。此配置等同于 Visual Studio 的调试配置。
编译时代码生成发布配置
此配置类似于已启用优化的发布配置(通过指定 /O1、/O2 或 /Ox 编译器开关启用),而无需指定 /GL 编译器开关。在此配置下,生成的对象文件将包含优化的二进制代码。但是,不会在整个程序级别执行任何优化。
通过检查生成的 source1.c 程序集列表文件,您会注意到已执行两个优化。首先,图 1 中首次调用的 square 函数(即 square(n))已在编译时通过运算求值完全消除。为什么会这样呢?编译器判定 square 函数比较小,因此应进行内联。内联之后,编译器判定本地变量 n 的值已知,且不会在赋值语句和函数调用之间更改。因此,得出的结论是,可以安全地执行乘法并替换结果 (25)。在第二个优化中,第二次调用的 square 函数(即 square(m))同样已被内联。但是,由于在编译时 m 值是未知的,编译器无法运算求值,因此发出实际代码。
现在,我将检查 source2.c 程序集列表文件,这会变得更加有意义。对 sumOfCubes 中 cube 函数的调用已经内联。反过来,这已使编译器可以在循环(正如“循环优化”一节所示)上执行重要的优化。此外,SSE2 指令集正用于 isPrime 函数,以在调用 sqrt 函数时将 int 转换为 double,还可以在从 sqrt 返回时将 double 转换为 int。并且在循环开始之前,sqrt 只调用一次。请注意,如果没有将 /arch 开关指定给编译器,则默认情况下 x86 编译器使用 SSE2。大多数部署的 x86 处理器以及所有 x86-64 处理器支持 SSE2。
链接时代码生成发布配置
LTCG 发布配置与 Visual Studio 中的发布配置相同。在此配置中,启用了优化,并指定了 /GL 编译器开关。在使用 /O1 或 /O2 时,隐式指定此开关。这会告知编译器发出 CIL 对象文件,而不是程序集对象文件。这样,链接器调用编译器后端来执行 WPO,如前文所述。现在,我将介绍几个 WPO 优化,以显示 LTCG 的巨大优点。使用此配置生成的程序集代码列表可在线获取。
只要启用函数内联(/Ob,请求优化时均会开启),/GL 开关就会允许编译器内联其他转换单元中定义的函数,无论是否指定了 /Gy 编译器开关(稍后讨论)。/LTCG 链接器开关是可选的,并仅提供有关该链接器的指导。
通过检查 source1.c 程序集文件列表,您可以看到除 scanf_s 之外的所有函数调用都已经内联。因此,编译器就能够执行 cube、sum 和 sumOfCubes 计算。只有 isPrime 函数还没有内联。但是,如果它已经在 getPrime 中手动内联,编译器仍将在 main 中内联 getPrime。
如您所见,函数内联非常重要,不只是因为它优化了函数调用,而且还因为它使编译器能够最终执行许多其他优化。内联函数通常会提高性能,但是会增加代码大小。过多使用这种优化会导致出现代码膨胀现象。在每个调用站点,编译器执行成本/收益分析,然后决定是否内联函数。
由于内联的重要性,Visual C++ 编译器提供了比标准所规定的更多的内联控制支持。您可以通过使用 auto_inline 杂注来告诉编译器永远不内联的函数范围。通过使用 __declspec(noinline) 标记一个特定函数或方法,您可以告知编译器永远不内联此特定函数或方法。您可以使用内联关键字标记函数,来提示编译器内联此函数(但是如果内联将为净亏损,编译器可以选择忽略此提示)。内联关键字自第一版 C++ 问世以来便可用,它在 C99 中引入。您可在 C 和 C++ 代码中使用 Microsoft 专用关键字 __inline;在使用不支持此关键字的旧版本 C 时,这是很有用的。此外,您可以使用 __forceinline 关键字(C 和 C++),以强制编译器在任何可能的情况下始终内联函数。最后同样重要的是,通过使用 inline_recursion 杂注内联递归函数,您可以告知编译器展开递归函数到特定深度或无限深度。请注意,编译器当前不提供使您能够在调用站点上(而不是在函数定义上)控制内联的功能。
/Ob0 开关完全禁用内联,默认情况下生效。调试时应使用此开关(在 Visual Studio 调试配置中自动指定此开关)。/Ob1 开关告知编译器仅考虑用于内联且标记有 inline、__inline 或 __forceinline 的函数。/Ob2 开关在指定 /O[1|2|x] 时生效,此开关告知编译器考虑所有要内联的函数。在我看来,使用内联或 __inline 关键字的唯一原因是通过 /Ob1 开关控制内联。
在某些情况下,编译器将不能内联函数。其中一种情况是,以虚拟方式调用虚函数;因为编译器可能不知道要调用哪个函数,所以无法内联该函数。另一种情况是,通过指向该函数的指针(而不是使用其名称)调用函数。若要启用内联,应该努力避免出现这种情况。有关此类情况的完整列表,请参阅 MSDN 文档。
在用于整个程序级别时,函数内联并不是最有效的优化。实际上,大多数优化在该级别都变得更加高效。在本节的其余部分中,我将讨论一类特定的优化,即 COMDAT 优化。
默认情况下,在编译转换单元时,所有代码都存储在所生成对象文件的单个部分中。链接器在部分级别上操作。也就是说,它可以删除、合并以及重新排列各部分。这将阻止链接器执行三个优化,可以显著(两位数的百分比)减小可执行文件的大小并提高其性能。第一个是消除未引用的函数和全局变量。第二个是折叠相同的函数和常量全局变量。第三个是重新排列函数和全局变量的顺序,以便位于相同执行路径的函数和共同访问的变量在内存中的物理位置更接近,从而提高定域性。
若要启用这些链接器优化,通过分别指定 /Gy(函数级链接)和 /Gw(全局数据优化)编译器开关,可以告知编译器将函数和变量打包到单个部分。这些部分称为 COMDAT。此外,还可以使用 __declspec( selectany) 标记特定的全局数据变量,以告知编译器将变量打包到 COMDAT。然后,通过指定 /OPT:REF 链接器开关,链接器将消除未引用的函数和全局变量。此外,通过指定 /OPT:ICF 开关,链接器将折叠相同的函数和全局常量变量。(ICF 代表相同的 COMDAT 折叠。)使用 /ORDER 链接器开关,您可以指示链接器按特定的顺序将 COMDAT 放置到生成的图像。请注意,所有这些优化均是链接器优化,不需要 /GL 编译器开关。在出于显而易见的原因进行调试时,应禁用 /OPT:REF 和 /OPT:ICF 开关。
应尽可能使用 LTCG。不使用 LTCG 的唯一情况是,分发生成的对象和库文件的情况。正如上文所述,这些文件包含 CIL 代码,而不包含程序集代码。CIL 代码仅可供生成其相同版本的编译器/链接器使用,这会极大地限制对象文件的可用性,因为开发人员必须具有相同版本的编译器来使用这些文件。在这种情况下,除非您愿意对每个编译器版本分发对象文件,否则您应使用编译时代码生成。除了有限的可用性之外,这些对象文件的大小要比相应的程序集对象文件大许多倍。但是,请记住,CIL 对象文件的巨大优势是启用 WPO。
循环优化
Visual C++ 编译器支持多个循环优化,但我将只讨论以下三个:循环展开、自动向量化和循环不变量代码移动。如果您修改图 1 中的代码,以将 m 传递给 sumOfCubes 而不是传递 n,则编译器将无法确定参数的值,因此必须编译函数来处理所有参数。生成的函数经高度优化且大小相当大,因此编译器不会内联此函数。
使用 /O1 开关编译代码,会产生空间进行了优化的程序集代码。在这种情况下,将不会对 sumOfCubes 函数执行任何优化。使用 /O2 开关编译会产生速度进行了优化的代码。代码的大小会明显变大但速度显著变快,因为 sumOfCubes 内的循环已展开且向量化。请务必明白,如果没有内联 cube 函数,则不可以向量化。此外,如果没有内联,则循环展开不会那么高效。生成的程序集代码的简化图形表示形式,如图 3 中所示。X86 和 x86-64 体系结构的流向图相同。
图 3 SumOfCubes 控制流向图
在图 3 中,绿色菱形是入口点,红色矩形是出口点。蓝色菱形代表在运行时正在作为 sumOfCubes 函数的一部分执行的条件。如果处理器支持 SSE4,且 x 大于或等于 8,那么将使用 SSE4 指令同时执行四个乘法运算。同时对多个值执行相同运算的过程称为向量化。此外,编译器将展开循环两次;也就是说,在每次迭代中将重复两次循环主体。组合的效果是,将为每次迭代执行八个乘法运算。当 x 小于 8 时,将使用传统的指令来执行计算的其余部分。请注意,编译器已在函数中发出三个(而不仅仅是一个)包含单独结语的出口点。这将减少跳转的次数。
循环展开是在循环内重复循环主体的过程,以便在已展开循环的单个迭代中执行多个循环迭代。这样可以提高性能的原因是,将不再频繁执行循环控制指令。或许更重要的是,它可能会使编译器能够执行许多其他优化,例如向量化。展开的缺点在于它会增加代码大小和注册压力。但是,它可以以两位数的百分比提高性能,具体取决于循环主体。
与 x86 处理器不同、所有 x86-64 处理器均支持 SSE2。此外,通过指定 /arch 开关,可以利用 Intel 和 AMD 最新 x86-64 微体系结构的 AVX/AVX2 指令集。同样,指定 /arch:AVX2 可以使编译器能够使用 FMA 和 BMI 指令集。
目前,Visual C++ 编译器无法让您控制循环展开。但是,可以通过结合使用模板与 __ forceinline 关键字模拟这种技术。可以通过使用带有 no_vector 选项的循环杂注禁用特定循环上的自动向量化。
通过查看生成的程序集代码,敏锐的眼睛会注意到可以进一步优化代码。但是,编译器已经做得非常好,并且不会在分析代码和应用次要优化方面花费过多的时间。
SomeOfCubes 不是唯一一个已展开循环的函数。如果您修改代码,以便将 m 传递到 sum 函数(而不是传递 n),则编译器将无法对该函数求值,因此它必须发出其代码。在这种情况下,该循环将展开两次。
我将讨论的最后一个优化是循环不变量代码移动。请考虑以下代码段:
int sum(int x) {
int result = 0;
int count = 0;
for (int i = 1; i <= x; ++i) {
++count;
result += i;
}
printf("%d", count);
return result;
}
此处的唯一更改是,我有一个附加的变量在每次迭代中均会增加,然后打印。不难看到,可以通过将计数变量增量移出循环来优化此代码。也就是说,我可以只将 x 分配给计数变量。此优化称为循环不变量代码移动。循环不变量部分清楚地表明了这种技术仅在代码不依赖于任何循环标头中的表达式时才有效。
现在有个问题要注意:如果您手动应用这种优化,则生成的代码可能会在某些情况下出现性能下降现象。您能找出其中的原因吗?请考虑当 x 为非正数时,会发生什么情况。循环永远不会执行,这意味着在未优化的版本中,不会接触变量计数。但是,在手动优化的版本中,会不必要的在循环外部将 x 赋值给计数。此外,如果 x 为负,则计数将保留错误值。人类和编译器均易受这种缺陷的影响。幸运的是,通过在赋值之前发出循环条件,Visual C++ 编译器就会有足够的智能意识到这种缺陷,从而提高 x 所有值的性能。
总之,如果您既没有编译器也不是编译器优化专家,则应避免手动转换到代码,这只是为了使其看起来更快。请勿手动操作,要信任编译器可以优化您的代码。
控制优化
除了编译器开关 /O1、/O2 和 /Ox 之外,您还可以使用类似以下的优化杂注控制特定函数的优化:
#pragma optimize( "[optimization-list]", {on | off} )
优化列表可以是空的,也可以包含一个或多个下列值:g、s、t 和 y。它们分别对应于编译器开关 /Og、/Os、/Ot 和 /Oy。
具有 off 参数的空列表会导致关闭所有这些优化,无论是否已指定编译器开关。具有 on 参数的空列表会使指定的编译器开关生效。
/Og 开关可以启用全局优化,即通过只查看优化的函数便可以执行这些优化,而不是查看它调用的所有函数。如果启用了 LTCG,则 /Og 会启用 WPO。
如果您希望采用不同的方式优化不同的函数(一些优化空间而另一些优化速度),则优化杂注将非常有用。但是,如果您确实想要具有该级别的控制,则应考虑按配置优化 (PGO),即通过在运行检测版代码时使用包含行为信息的配置文件来优化代码的过程。编译器使用该配置文件来更好地决定如何优化代码。Visual Studio 提供了必要的工具来将此技术应用于本机和托管代码。
.NET 中的优化
在 .NET 编译模型中不涉及任何链接器。但是,有一个源代码编译器(C# 编译器)和一个 JIT 编译器。源代码编译器仅执行次要优化。例如,它不会执行函数内联和循环优化。相反,JIT 编译器会处理这些优化。随附 .NET Framework 4.5 及之前所有版本的 JIT 编译器不支持 SIMD 指令。但是,随附 .NET Framework 4.5.1 和更高版本的 JIT 编译器(即 RyuJIT)支持 SIMD。
在优化功能方面,RyuJIT 和 Visual C++ 之间的区别是什么?因为 RyuJIT 会在运行时工作,所以它可以执行 Visual C++ 无法执行的优化。例如,在运行时,RyuJIT 可能能够确定,在应用程序的此特定运行中,if 语句的条件永远不会是 true,因此可以将其优化。此外,RyuJIT 可以利用它正在其中运行的处理器的功能。例如,如果处理器支持 SSE4.1,JIT 编译器将只对 sumOfcubes 函数发出 SSE4.1 指令,从而使生成的代码更为紧凑。但是,它不能花太多时间优化代码,因为 JIT 编译所花费的时间长短会影响应用程序的性能。而另一方面,Visual C++ 编译器可花费更多的时间找出和利用其他优化机会。.NET Native 是 Microsoft 研发的优秀新技术,通过使用 Visual C++ 后端,.NET Native 可以将托管代码编译为优化的独立可执行文件。目前,这一技术仅支持 Windows 应用商店应用。
控制托管代码优化的能力目前很有限。C# 和 Visual Basic 编译器仅可以使用 /optimize 开关打开或关闭优化。若要控制 JIT 优化,可以将 System.Runtime.CompilerServices.MethodImpl 属性应用于具有某个选项(来自指定的 MethodImplOptions)的方法。NoOptimization 选项可关闭优化,NoInlining 选项可防止内联此方法,AggressiveInlining (.NET 4.5) 选项可以为 JIT 编译器提供内联方法建议(不仅仅提供提示)。
总结
本文讨论的所有优化技术均可以按以两位数的百分比显著提高代码性能,并且 Visual C++ 编译器支持所有这些优化技术。这些技术的重要之处在于,应用这些技术可以使编译器能够执行其他优化。本文绝不是关于 Visual C++ 所执行的编译器优化的全面讨论。但是,我希望通过本文您已领略到编译器的强大功能。Visual C++ 可以做的远不止这些,因此请继续关注第 2 部分。
Hadi Brais 是印度新德里理工大学 (IITD) 博士,主要研究针对下一代内存技术的编译器优化。他将大部分精力用在编写 C/C++/C# 代码方面,并深入研究了 CLR 和 CRT。他的博客网址是hadibrais.wordpress.com。您可以通过 hadi.b@live.com 与他联系。