从 C++ 代码中释放峰值性能可能是一项艰巨的任务,需要细致的分析、复杂的内存访问调整和缓存优化。有没有一个技巧可以简化一点?幸运的是,有一条捷径可以用最少的努力实现显着的性能提升——只要您有正确的见解并知道自己在做什么。输入编译器优化可以显着提高代码的性能。
现代编译器是实现最佳性能的过程中不可或缺的盟友,特别是在自动并行化方面。这些复杂的工具具有仔细检查复杂代码模式(尤其是循环内)并无缝执行优化的能力。
本文旨在强调编译器优化的功效,重点关注因受欢迎和广泛使用而闻名的英特尔 C++ 编译器。
在这个故事中,我们揭开了编译器魔法的各个层面,它们可以将您的代码转变为高性能杰作,所需的手动干预比您想象的要少。
亮点:什么是编译器优化? | -开|架构目标 |过程间优化 | -fno-别名 |编译器优化报告
编译器优化涵盖编译器在编译期间应用于源代码的各种技术和转换。但为什么?提高性能、效率,并在某些情况下提高生成的机器代码的大小。这些优化对于影响代码执行的各个方面都至关重要,包括速度、内存使用和能耗。
任何编译器都会执行一系列步骤将高级源代码转换为低级机器代码。这些涉及词法分析、语法分析、语义分析、中间代码生成(或 IR)、优化和代码生成。
在优化阶段,编译器会仔细寻找转换程序的方法,旨在获得语义等效的输出,从而利用更少的资源或更快地执行。此过程中采用的技术包括但不限于常量折叠、循环优化、函数内联和死代码消除。
我不会讨论所有可用的选项,而是讨论如何指示编译器进行可能提高代码性能的特定优化。那么,解决办法???编译器标志。
开发人员可以在编译过程中指定一组编译器标志,这种做法对于使用 GCC 的“ -g”或“-pg”等选项来调试和分析信息的人来说很熟悉。接下来,我们将讨论在使用英特尔 C++ 编译器编译应用程序时可以使用的类似编译器标志。这些可能会帮助您提高代码的效率和性能。
我不会深入研究枯燥的理论,也不会用列出每个编译器标志的乏味文档淹没您。相反,让我们尝试了解这些标志为何以及如何工作。
我们如何实现这一点???
我们将使用一个未优化的 C++ 函数来负责计算雅可比迭代,并逐步阐明每个编译器标志的影响。在这次探索中,我们将通过系统地将每次迭代与基本版本进行比较来测量加速 — 从没有优化标志 (-O0) 开始。
加速(或执行时间)是在Intel® Xeon® Platinum 8174 处理器机器上测量的。此处,雅可比方法求解二维偏微分方程(泊松方程),用于对矩形网格上的热量分布进行建模。
u(x,y,t) 是时间 t 点 (x,y) 的温度。
当分布不再变化时,我们解决稳定状态:
在边界处应用了一组狄利克雷边界条件。
我们本质上有一个 C++ 编码,在可变大小的网格(我们称之为分辨率)上执行雅可比迭代。基本上,网格大小为 500 意味着求解大小为 500x500 的矩阵,依此类推。
执行一次雅可比迭代的函数如下:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
我们继续执行雅可比迭代,直到残差达到阈值(在循环内)。残差计算和阈值评估是在该函数之外完成的,这里不关心。那么,现在让我们来谈谈房间里的大象吧!
在没有优化(-O0)的情况下,我们得到以下结果:
在这里,我们以 MFLOP/s 来衡量性能。这将是我们进行比较的基础。
MFLOP/s 代表“每秒百万次浮点运算”。它是一种测量单位,用于量化计算机或处理器在浮点运算方面的性能。浮点运算涉及以浮点格式表示的小数或实数的数学计算。
MFLOP/s 通常用作基准或性能指标,特别是在复杂数学计算盛行的科学和工程应用中。 MFLOP/s 值越高,系统或处理器执行浮点运算的速度越快。
注 1:为了提供稳定的结果,我针对每个分辨率运行可执行文件 5 次,并取 MFLOP/s 值的平均值。
注 2:需要注意的是,Intel C++ 编译器的默认优化是 -O2。因此,在编译源代码时指定-O0很重要。
让我们继续看看当我们尝试不同的编译器标志时这些运行时间会如何变化!
当人们开始进行编译器优化时,这些是一些最常用的编译器标志。理想情况下, Ofast > O3 > O2 > O1 > O0的性能。然而,这并不一定会发生。这些选项的关键点如下:
-O1:
-氧气:
-O3:
-奥法斯特:
官方指南详细介绍了这些选项提供的优化。
当在雅可比代码上使用这些选项时,我们获得这些执行运行时间:
很明显,所有这些优化都比我们的基本代码(带有“-O0”)快得多。执行运行时间比基本情况低 2-3 倍。 MFLOP/s 怎么样?
嗯,就是这样!
基本情况和优化后的 MFLOP/s 之间存在很大差异。
总体而言,“-O3”表现最好,尽管只有一点点。
“- Ofast ”(“ -no-prec-div -fp-model fast=2 ”)使用的额外标志不会提供任何额外的加速。
机器的架构是影响编译器优化的关键因素。当编译器知道可用的指令集和硬件支持的优化(如矢量化和 SIMD)时,它可以显着提高性能。
例如,我的 Skylake 机器有 3 个 SIMD 单元:1 个 AVX 512 和 2 个 AVX-2 单元。
我真的可以用这些知识做点什么吗???
答案在于战略编译器标志。尝试“ -xHost ”,更准确地说,“ -xCORE-AVX512 ”等选项可以让我们充分利用机器功能的潜力,并进行定制优化以获得最佳性能。
以下是这些标志的含义的快速描述:
-x主机:
-xCORE-AVX512:
目标:明确指示编译器生成利用英特尔高级矢量扩展 512 (AVX-512) 指令集的代码。
主要特点: AVX-512 是一种先进的 SIMD(单指令、多数据)指令集,与 AVX2 等以前的版本相比,它提供更宽的矢量寄存器和附加操作。启用此标志允许编译器利用这些高级功能来优化性能。
注意事项:可移植性又是这里的罪魁祸首。使用 AVX-512 指令生成的二进制文件可能无法在不支持该指令集的处理器上以最佳方式运行。它们可能根本不起作用!
AVX-512设置指令使用Zmm寄存器,这是一组512位宽的寄存器。这些寄存器是矢量处理的基础。
默认情况下,“ -xCORE-AVX512 ”假定程序不太可能从 zmm 寄存器的使用中受益。除非保证性能增益,否则编译器会避免使用 zmm 寄存器。
如果打算不受限制地使用 zmm 寄存器,“ -qopt-zmm-usage ”可以设置为高。这也是我们要做的事情。
不要忘记查看官方指南以获取详细说明。
让我们看看这些标志如何适用于我们的代码:
呜呼!
现在,我们的最小分辨率已突破 1200 MFLOP/s 大关。其他分辨率的 MFLOP/s 值也有所增加。
值得注意的是,我们在没有任何实质性手动干预的情况下就取得了这些结果——只需在应用程序编译过程中合并一些编译器标志即可。
然而,必须强调的是,编译后的可执行文件仅与使用相同指令集的机器兼容。
优化与可移植性的权衡是显而易见的,因为针对特定指令集优化的代码可能会牺牲跨不同硬件配置的可移植性。所以,一定要知道自己在做什么!!
注意:如果您的硬件不支持 AVX-512,请不要担心。英特尔 C++ 编译器支持 AVX、AVX-2 甚至 SSE 的优化。该文档包含您需要了解的一切!
过程间优化涉及跨多个函数或过程分析和转换代码,超越单个函数的范围。
IPO 是一个多步骤过程,重点关注程序内不同功能或程序之间的交互。 IPO 可以包括许多不同类型的优化,包括前向替换、间接调用转换和内联。
英特尔编译器支持两种常见的 IPO 类型:单文件编译和多文件编译(全程序优化)[ 3 ]。有两个常见的编译器标志执行它们中的每一个:
-首次公开募股:
目标:启用过程间优化,允许编译器在编译期间分析和优化整个程序,而不仅仅是单个源文件。
主要特点:-整个程序优化:“ -ipo ”对所有源文件进行分析和优化,考虑整个程序中函数和过程之间的交互。- 跨函数和跨模块优化:该标志有利于内联函数、同步优化以及跨不同程序部分的数据流分析。
注意事项:它需要单独的链接步骤。使用“ -ipo ”编译后,需要特定的链接步骤来生成最终的可执行文件。编译器在链接期间根据整个程序视图执行额外的优化。
-ip:
目标:启用过程间分析传播,允许编译器执行一些过程间优化,而不需要单独的链接步骤。
主要功能:-分析和传播:“ -ip ”使编译器能够在编译期间跨不同函数和模块执行研究和数据传播。但是,它不会执行需要完整程序视图的所有优化。- 更快的编译:与“ -ipo ”不同,“ -ip ”不需要单独的链接步骤,从而加快编译时间。当快速反馈至关重要时,这在开发过程中会很有用。
注意事项:仅发生一些有限的过程间优化,包括函数内联。
-ipo 通常提供更广泛的过程间优化功能,因为它涉及单独的链接步骤,但代价是编译时间更长。 [ 4 ]
-ip 是一种更快的替代方案,可以执行一些过程间优化,无需单独的链接步骤,使其适合开发和测试阶段。[ 5 ]
由于我们只讨论性能和不同的优化、编译时间或可执行文件的大小不是我们关心的,因此我们将重点关注“ -ipo ”。
所有上述优化都取决于您对硬件的了解程度以及您进行的实验程度。但这还不是全部。如果我们尝试确定编译器如何看待我们的代码,我们可能会发现其他潜在的优化。
让我们再次看看我们的代码:
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
jacobi() 函数采用几个指针作为参数,然后在嵌套的 for 循环中执行某些操作。当任何编译器在源文件中看到这个函数时,都必须非常小心。
为什么??
使用u计算unew 的表达式涉及 4 个相邻u值的平均值。如果u和unew都指向同一个位置怎么办?这将成为别名指针的经典问题[ 7 ]。
现代编译器非常聪明,为了确保安全,它们假设可能存在别名。对于这样的场景,他们避免了任何可能影响代码语义和输出的优化。
在我们的例子中,我们知道u和unew是不同的内存位置,并且用于存储不同的值。因此,我们可以轻松地让编译器知道这里不会有任何别名。
我们该怎么做呢?
有两种方法。首先是C 语言的“ restrict ”关键字。但这需要更改代码。我们暂时不想这样。
有什么简单的吗?让我们尝试一下“ -fno-alias ”。
-fno-别名:
目标:指示编译器不要假设程序中存在别名。
主要特点:假设没有别名,编译器可以更自由地优化代码,从而潜在地提高性能。
注意事项:开发人员在使用此标志时必须小心,因为如果出现任何不必要的别名,程序可能会给出意外的输出。
更多细节可以参见官方文档。
这对于我们的代码来说表现如何?
好吧,现在我们有东西了!!!
我们在这里实现了显着的加速,几乎是之前优化的 3 倍。这种提升背后的秘密是什么?
通过指示编译器不要假设别名,我们赋予了它释放强大循环优化的自由。
仔细检查汇编代码(虽然这里没有共享)和生成的编译优化报告(见下文)揭示了编译器对循环交换和循环展开的精明应用。这些转换有助于实现高度优化的性能,展示编译器指令对代码效率的重大影响。
这是所有优化相互比较的方式:
英特尔 C++ 编译器提供了一项有价值的功能,允许用户生成优化报告,总结为优化目的所做的所有调整 [ 8 ]。这份综合报告以 YAML 文件格式保存,提供编译器在代码中应用的优化的详细列表。详细说明请参见官方文档“ -qopt-report ”。
我们讨论了一些编译器标志,这些标志可以显着提高代码的性能,而无需我们实际做太多事情。唯一的前提是:不要盲目做事;确保你知道自己在做什么!
这样的编译器标志有数百个,本故事仅介绍其中的几个。因此,值得查看您首选编译器的官方编译器指南(尤其是与优化相关的文档)。
除了这些编译器标志之外,还有一大堆技术,例如矢量化、SIMD 内在函数、 配置文件引导优化和引导自动并行,它们可以惊人地提高代码的性能。
同样,英特尔 C++ 编译器(以及所有流行的编译器)也支持 pragma 指令,这是非常好的功能。值得在Intel-Specific Pragma Reference上检查一些编译指示,如ivdep、parallel、simd、vector等。
[1] 优化和编程(intel.com)
[2]凯泽斯劳滕-兰道大学的“Elwetritsch”高性能计算 (rptu.de)
[7] 别名 — IBM 文档
[8] 英特尔® 编译器优化报告
Unsplash上Igor Omilaev的精选照片。
也发布在这里。