在这篇文章中,我们想给大家一个更新,在过去一年中Visual C++代码优化器的重大进展,主要集中在15.3和15.5版本中发布的特性。与VS2015 Update 3相比,VS2017 15.5平均提供 SPEC 2017基准中运行速度提高8.9% (有关详细数字,请参阅 CppCon演示 或者 CppCon会议视频 ).
本文的以下部分将介绍最新版本提供的一些新的优化和改进,希望对现代本机编译器后端的内部工作提供一个有趣的概述。
对SSA优化器的一般改进
SSA优化器是去年推出的一个新框架 在Visual Studio 2015 Update 3中 静态单一赋值形式 . 正如预期的那样,它允许我们在短时间内取得重大进展,这里描述的大部分优化都是在框架内实现的。在最新的编译器版本中,有几个常规的改进:
- SSA优化器现在运行两次,在循环优化器之前和之后。这使得它能够利用循环优化和其他二阶效应带来的新机会。
- 使用地址获取变量和间接内存加载的表达式可以通过使用别名SSA形式和加载的值编号(标识具有相同值的内存位置)得到更好的处理。
- 一个扩展的模式集合,进一步简化代码并帮助减少代码大小。
公共子表达式消除和部分冗余消除
公共子表达式消除(CSE) 是一种优化,通过标识相同的表达式并保留一个实例,用预先计算的值替换其他实例来删除冗余代码。它是基本优化之一,通常有助于提高执行速度和减少代码大小。SSA优化器中的新方法基于 全局值编号 ,重点是消除冗余的间接内存负载,这可能非常昂贵,特别是当数据不再在CPU缓存中找到时。下面的示例演示了一个加载的源现在如何可以是另一个加载、存储或指向同一内存位置的memset/memcpy。 CSE引入的临时文件用每个路径上加载的值初始化,现在可以注册:
之前 | 加载后CSE |
if (condition1) { x = * p; use(x); } else if (condition2) { * p = 1; } else { memset(p, 0, 100); } y = * p; use(y); |
if (condition1) { x = * p; use(x); temp = x; } else if (condition2) { * p = 1; temp = 1; } else { memset(p, 0, 100); temp = 0; } y = temp; use(y); |
对三元运算符和SSA Phi指令执行特殊形式的CSE加载,如下例所示:
之前 | CSE后 |
x = * p; use(x); y = * q; use(y); a = condition ? p : q; b = * a; |
x = * p; use(x); y = * q; use(y); b = condition ? x : y; |
在未能找到*a的可用源之后,将搜索所选值p、q的加载/存储,并用condition替换*a?x:是的。这种情况的一个实际例子是使用std::min/max的代码, 如本文所述 .
部分冗余消除(PRE) 是一个新的添加项,通过将表达式插入到函数缺少的路径上,使其完全冗余,从而通过函数处理仅在某些路径上冗余的表达式。PRE的一个简单示例:
之前 | 预处理后 | 代码吊装后 |
if (condition1) { x = a * b; use(x); } y = a * b; use(y); |
if (condition1) { x = a * b; use(x); temp = x; } else { temp = a * b; } y = temp; use(y); |
temp = a * b; if (condition1) { x = temp; use(x); } y = temp; use(y); |
PRE的一个更复杂的例子可以在SPEC2017 Imagick基准测试的hot函数中找到。在这种情况下,消除了5个冗余负载和4个冗余浮点乘法,并且由于图像通常是RGB(A)格式的,所以总是执行大多数消除的表达式。
之前 | 预处理后 |
if ((channel & RedChannel) != 0) pixel.red += ( * k) * alpha * GetPixelRed(p); if ((channel & GreenChannel) != 0) pixel.green += ( * k) * alpha * GetPixelGreen(p); if ((channel & BlueChannel) != 0) pixel.blue += ( * k) * alpha * GetPixelBlue(p); if ((channel & OpacityChannel) != 0) pixel.opacity += ( * k) * GetPixelOpacity(p); if (((channel & IndexChannel) != 0) && (image - > colorspace == CMYKColorspace)) pixel.index += ( * k) * alpha * GetPixelIndex(…); gamma += ( * k) * alpha; |
temp1 = * k; temp2 = temp1 * alpha; if ((channel & RedChannel) != 0) pixel.red += temp2 * GetPixelRed(p); if ((channel & GreenChannel) != 0) pixel.green += temp2 * GetPixelGreen(p); if ((channel & BlueChannel) != 0) pixel.blue += temp2 * GetPixelBlue(p); if ((channel & OpacityChannel) != 0) pixel.opacity += temp1 * GetPixelOpacity(p); if (((channel & IndexChannel) != 0) && (image - > colorspace == CMYKColorspace)) pixel.index += temp2 * GetPixelIndex(…); gamma += temp2; |
内联线改进
内联是最重要的优化之一,不仅消除了函数调用的开销,而且更重要的是,使内联代码适应其内联到的函数的上下文,从而提供有关参数的更精确信息,从而实现更好的优化。VS 2015 Update 3和VS2017 15.5之间的性能提升很大一部分是由于对内联线进行了一些改进,使得内联线更具侵略性,采用了更准确的启发式方法来估计盈利能力。其中一些更改包括在嵌套循环中进行更多的内联,总是内联一次调用的内部/静态函数,并在内联之后使用有关参数实际值的更多上下文信息。
非常小的函数现在总是内联的,只要这不会产生不合理的大函数。同样的改进也适用于 配置文件引导优化 ,其中非常小的函数和只转发给其他函数的函数更有可能是内联的,因为通常这会减少代码大小,内联代码比调用序列小。内联现在还能够处理由值C++对象返回的函数内联,这些对象可能引发异常。
新的CFG优化模块
SSA优化器的最初版本主要针对表达式和 窥视孔优化 s。现在除了新的CSE/PRE模块外,它还包括一个用于执行 控制流图(CFG) SSA形式的优化。这分为两部分,一部分用于执行实际优化,另一部分用于清除,例如删除函数中无用的分支/跳转和无法访问的代码。
首先实现的优化是相似表达式的早期提升和下沉。这里使用的算法比后期编译阶段的算法更具攻击性,它依赖于值编号,并且即使在编译开始/结束时存在不匹配,也能够提取指令 基本块 . 例如,相似的指令可能位于基本块的中间,提取的指令序列不必是连续的。通过这种方式,它可以找到多个独立的表达式并提升/下沉它们。除了减少代码大小外,早期提升/下沉还可以暴露其他优化机会,例如用条件移动表达式(CMOV)替换分支,如下例所示:
之前 | 沉入仓库后 | 建立CMOV后 |
if (condition) { * p = x; } else { * p = x + 1; } |
if (condition) { temp = x; } else { temp = x + 1; }* p = temp; |
temp = condition ? x : x + 1; * p = temp; |
计划在新模块中实现更多的CFG优化–在测试阶段已经有三个新的优化将在编译器的未来版本中发布。
-fp:fast下浮点优化的改进
在下进行的优化有了显著的改进 -快速浮点模型 在SSA优化器中,扩展现有的算术简化并添加对处理标准库中常见函数的支持:
- POW强度缩减,当指数是POW(x,16.0)的精确值时,用一系列乘法替换对POW的调用。在微基准测试中,调用pow函数比计算相同值所需的4次乘法慢31倍。替换表达式是以最小形式生成的–例如,pow(a,8.0)被3个计算[(a^2)^2]^2的乘法替换。处理的情况有四种:pow(a,N.0)、pow(a,N.5)、pow(a,-N.0)和pow(a,-N.5)。
- 基于超越函数恒等式的大量简化集合。举几个例子:
sqrt(a) * sqrt(b) - > sqrt(a * b) pow(a, x) * pow(a, y) - > pow(a, x + y) pow(a, x) * a - > pow(a, x + 1) exp(a) * exp(b) - > exp(a + b) sin(a) / cos(a) - > tan(a)
- 将sin(x)和cos(x)的调用合并到对数学库的单个调用中,在相同的时间内计算这两个值。这在x86和x64上可用,其中默认情况下启用SSE2代码生成。
- 更多的算法简化侧重于消除除法/乘法,并改进了对分支和新身份的MIN/MAX/ABS操作的检测。举几个例子:
a / (1 / b) - > a * b a / b / c / d - > a / (b * c * d) abs(a known positive) - > a max(min(a, b), a) - > a
我们强烈鼓励人们使用-fp:fast标志以获得最佳性能,除非需要精确到最后一位。在几个基准测试套件中,通过以类似于整数的方式优化浮点表达式,以及通过特殊处理上述常见模式,可以获得显著的性能优势。
删除更多不必要的说明
SSA优化器包括位估计器组件,该组件能够确定已知值的哪些位总是1/0,以及其他事实(例如,请参见 上一篇博文 ). 这一点现在得到了一个复杂的分析的补充,该分析可以估计受操作影响的值的位和实际需要的位,从而可以删除不影响表达式最终结果的不必要的指令。一些例子:
之前 | 之后 |
x = a | 3; // Sets lowest 2 bits, useless. y = x >> 4; // Lowest 4 bits not required, shifted out. |
y = a >> 4; |
x = a & 0x00FFFFFF; // Clears highest 8 bits, useless. y = x | 0xFFFF0000; // Highest 16 bits not required, always set. |
y = a | 0xFFFF0000; |
这种情况在实践中经常出现,一些最有趣的例子出现在Windows内核/驱动程序中。删除这些不必要的指令也是 苏泊尔超优化器 .
循环展开改进
循环展开 是一种优化,它通过多次复制循环体和减少(或完全消除)迭代计数器的开销来公开更多的指令级并行性。VisualC++中的循环完全展开看到了很大的改进,由于计算出收益的更好的启发和计算循环的迭代次数(行程计数)的改进方法,现在由于展开量大大减少了保守。完全循环展开通常允许对表达式进行更多的后续优化,并允许存储加载转发(将加载替换为以前存储在同一内存位置的值),如下面的示例所示,其中索引变量替换为常量,从而允许表达式在以后进行常量折叠:
之前 | 循环展开后 | 在随后的优化之后 |
for (int i = 0; i < 4; i++) { p[i] = i * 4 + 2; } |
i = 0; p[i] = i * 4 + 2; i++; p[i] = i * 4 + 2; i++; p[i] = i * 4 + 2; i++; p[i] = i * 4 + 2; |
p[0] = 2; p[1] = 6; p[2] = 10; p[3] = 14; |
太大而无法完全展开的循环将部分展开,并且在不增加代码大小的情况下仍能提供性能优势。一些SPEC2017基准测试得益于改进的循环展开,最多可获得5%的性能优势。
循环if无开关改进
不切换时循环 是一种优化,通过创建循环的两个版本(每个版本的代码来自分支的一侧)从循环中删除分支,而原始分支在两个循环之间进行选择。当分支条件在循环内没有改变(循环不变)时,就可以这样做,它通过创建较短的循环而有利于现代cpu,而控制流不会污染分支预测表。Visual C++有一个更简单的IF切换版本,它现在被改进来处理更一般的情况,如下面的例子,在分支之前/之后有额外的代码。
之前 | 如果不切换 |
for (int i = 0; i < n; i++) { // Code before branch. if (invariant_condition) { // “then” code. } else { // “else” code. } // Code after branch. } |
if (invariant_condition) { for (int i = 0; i < n; i++) { // Code before branch. // “then” code. // Code after branch. } } else { for (int i = 0; i < n; i++) { // Code before branch. // “else” code. // Code after branch. } } |
使用附近的负载下沉
这是一种优化,也称为部分死代码消除。它的目的是将昂贵的表达式移近它们实际使用的位置,希望在if条件下推送或函数提前退出时永远不会执行它们。另一个被处理的情况是一个表达式,它被分配给一个变量,这个变量稍后在某些路径上被重新定义,如下面的第二个示例中所示。目前这仅限于下沉加载,未来版本的编译器将扩展到更一般的表达式。
之前 | 下沉荷载后 |
x = * p; if (condition) { return -1; } use(x); |
if (condition) { return -1; } x = * p; // Delay load *p. use(x); |
x = * p; if (condition) { x = * q; } use(x); |
if (condition) { x = * q; } else { x = * p; // Avoid load *p on *q path. } use(x); |
矢量器改进
更多的循环,不管有没有分支,现在都是矢量化的,这要归功于一种改进的启发式方法,用于估计矢量化的好处,并为指针提供更准确的别名信息。在数组中搜索最小/最大值的代码矢量化现在也支持需要所选值的索引的情况,如以下示例中所示:
for (i = 0; i < N; i++) { if (values[i] > max_value) { max_value = values[i]; max_value_index = i; } } use(max_value, max_value_index);
改进的CMOV生成和std::min/max的处理
改进了从分支生成条件移动指令(CMOV)的方法,特别是对于浮点值,这有助于处理分支不可预测的情况。下面是Geekbench 4基准测试的一个示例:
offset = lo + delta; if (curve[offset] > log_exposure) { hi = hi - delta; } else { lo = lo + delta; }
x64之前 | x64现在 |
comiss xmm0, xmm4 jbe SHORT $LN4@log_exposu sub ecx, r8d jmp SHORT $LN5@log_exposu $LN4@log_exposu: mov edx, r9d $LN5@log_exposu: |
sub eax, ecx comiss xmm3, xmm2 cmovbe eax, r9d cmovbe edx, r8d |
对于优化器来说,std::min/max以前有些问题,因为它们通过引用获取值,将局部变量的直接访问转换为通过指针的间接访问。消除整数的这些间接访问情况的改进现在也适用于浮点类型。例如,钳制操作现在具有最佳代码生成:
float clamp(float n, float lower, float upper) { return std::max(lower, std::min(n, upper)); }
x64之前 | x64现在 |
n$ = 8 upper$ = 24 clamp comiss xmm0, xmm2 lea rax, QWORD PTR upper$[rsp] lea rcx, QWORD PTR n$[rsp] movss DWORD PTR [rsp+24], xmm2 movss DWORD PTR [rsp+8], xmm0 cmovbe rax, rcx movss xmm0, DWORD PTR [rax] comiss xmm1, xmm0 jb SHORT $LN10@clipf movaps xmm0, xmm1 $LN10@clipf: ret 0 |
clamp minss xmm0, xmm2 maxss xmm0, xmm1 ret 0 For integer values: clamp_int cmp r8d, ecx cmovl ecx, r8d cmp edx, ecx cmovl edx, ecx mov eax, edx ret 0 |
最后
我们很高兴最终在编译器后端发布所有这些新的和改进的优化,并帮助您的程序更快。预计在未来的版本中会有更多的添加—我们正在不断努力实现新的优化,改进现有的优化,或者用更新、更好的方法替换一些旧的优化,比如在SSA优化器中完成的工作。
请让我们知道,如果您有任何反馈或建议的情况下,可以更好地优化。我们可以通过以下评论和电子邮件联系到您(visualcpp@microsoft.com)您还可以通过 帮助>报告产品中的问题 ,或通过 开发者社区 .