介绍一种新的高级Visual C++代码优化程序

我们很兴奋地宣布了一个新的高级代码优化器的预览版本,用于VisualC++编译器后端。它为代码大小和性能提供了许多改进,使优化器达到了现代本机编译器所期望的新的质量标准。

null

这是第一次公开发布,我们鼓励人们尝试,并提供有关潜在错误的建议和反馈。新优化器的官方版本预计是visualstudioupdate3,而今天提供的版本是不受支持的,主要用于测试目的。

如何尝试

新优化器的编译器位非常容易获得:只需安装最新的 可视化应用工具 包装使用 努吉 . 有关如何执行此操作的详细信息,请参阅 这篇博文。 安装后,以通常的方式编译应用程序–优化器在所有体系结构上默认启用。

更新06/10/2016:新的优化器现在也可以作为 Visual Studio更新3 RC。

报告错误和建议

我们希望得到尽可能多的反馈,关于你发现的错误或建议,你可能。如果您认为您发现了一个bug,您可以通过使用以下未记录的标志禁用它来确认它是由新的优化器引起的: -D2SSA优化器-

  • 在 这个 VisualStudio IDE,将标志添加到Project属性页-> C/C++ + >命令行>附加选项文本框
  • 如果使用cl.exe从命令行编译,请在任何/link选项之前添加标志

如果-d2SSAOptimizer-不再显示该错误,请执行以下步骤:

为什么是新的优化器?

新的优化器框架的主要动机是希望进行更积极的优化,例如利用更多编译时信息和现代编译器开发的优化。一些旧的优化过程的设计使得实现更高级的转换和以更快的速度进行改进变得困难。由于新框架旨在成为未来许多优化工作的基础,因此一个核心设计目标是使新优化更易于实现、测试和度量。

项目的一些主要目标:

  • 提高标量码和矢量码的编码质量

在许多情况下,性能和代码大小都可以得到提高,有时甚至可以大幅度提高。该框架试图解决旧优化器的几个缺陷:

    • 旧的表达式优化器有一小部分已知的转换和有限的函数视图-这会阻止发现所有可以优化的表达式。
    • 许多基于识别模式的小型优化,称为 窥视孔优化 –缺少或仅针对某些目标体系结构实现。
    • 矢量代码——无论是来自内部函数还是由自动矢量器生成——都可以得到更好的优化。

新的优化器利用了 静态单一赋值 窗体,它允许处理可能跨越整个函数的更复杂表达式。SSA形式的另一个优点是,它可以编写更简单、更高效的算法,不需要使用更复杂、更慢的技术,例如 数据流分析 .

窥视孔优化现在可以用一种与目标无关的方式实现,使用一种模式匹配系统,这种系统速度非常快(基于模板元编程),只需要编写很少的代码。这样就可以使用通常的模式识别方法,在添加所需时间的一小部分时间内添加大量的模式。

同样的模式匹配机制也可以用于向量运算,使得现在可以像使用标量运算的表达式一样轻松地使用整数和浮点向量运算来优化表达式。请注意,此功能尚未完成并启用。

  • 设计一个易于开发的框架,减少出错的可能性

新框架的主要优点之一是能够快速地建立想法的原型并进行可靠的实现。它包括各种帮助程序,用于更容易地操作SSA表单、表达式的模式匹配、构建新表达式以及在存在 指针混淆现象 以及异常处理。

  • 执行更好的代码静态分析

新的优化器还添加了新的静态分析模块,包括那些可以识别值何时为布尔值(正好是0或1)、值何时始终为正以及值何时不能为零的模块。它还有一个功能强大的模块,可以估计一个值的已知1/0位,以及一个值可能落入的范围。结果要么作为某些优化的前提条件,要么完全消除一些无用的操作,要么将操作转换成一种可以更好地优化的形式。

  • 强调测试和正确性

考虑到项目的大范围,确保和维护正确性是重中之重。这是通过使用正式的验证和随机生成的程序进行测试来实现的( 模糊测试 )以及流行的程序和库,如Chrome、Firefox、CoreCLR和Chakra。看到了吗 测试方法 下面的部分了解更多详细信息。

实施优化的示例

下面的示例仅说明了新优化器实现的许多新转换中的一小部分。这种代码通常出现在编解码器中:

int test(int a) {
    return a % 2 != 0 ? 4 : 2;
}
带有旧优化器的x64组件 带有新优化器的x64组件
?test@@YAHH@Z PROC
and   ecx, -2147483647
jge   SHORT $LN3@test
dec   ecx
or    ecx, -2
inc   ecx
$LN3@test:
test  ecx, ecx
mov   eax, 2
mov   edx, 4
cmovne eax, edx
ret   0
?test@@YAHH@Z PROC
and   ecx, 1
lea   eax, DWORD PTR [rcx*2+2]
ret   0

旧优化器的执行时间在最佳情况下约为5个周期(这假设执行无序和完美的分支预测),在最坏情况下至少为10个周期。使用新的优化器,执行时间总是2个周期。显然,在代码大小方面也有重要的节省。

通过组合多个较小的变换可以获得非常有趣的结果。在这种情况下,有两种模式用于生成最终结果:

  • a%%2==0->a&1==0 自从 余数 esed为零,表示 不影响比较结果,余数可替换为和。
  • 一个?C1:C2->C2+a*(C1-C2) 在两个常数之间选择的三元问句运算。第一个要求是条件值是布尔值,静态分析包可以确定该值。第二个是 C1-C2型 是二的幂,所以 转移 利亚 是生成的而不是 乘法 .

让我们再看几个有趣的优化和实现的模式的例子。重点放在以前没有很好优化的操作上,例如比较、转换、分割、问题和控制流相关表达式(SSA形式的PHI操作)。尽管有些示例似乎不太可能像在源代码中那样编写,但它们在内联和其他转换之后确实经常出现。

  • 改进了算术表达式的优化,包括标量浮点运算

SSA表单公开了更大的表达式,可以跨越整个函数–这允许发现更多的优化机会,尤其是与表达式重新关联相结合时。此外,还添加了几十种新模式,例如:

(a / C1) / C2 -> a / (C1 * C2)
(a * C1) / C2 -> a * (C1 / C2)
a / (x ? C1 : C2) -> a >> (x ? log2(C1), log2(C2)) // C1 and C2 must be power of two constants

大多数新的浮点优化只在-fp:fast下启用,但其中一些在默认的-fp:precise下有效。有关不同浮点模型下允许的优化的更多信息,请参阅以下文档: 微软Visual C++浮点优化

  • 优化控制流相关表达式

我在上面提到,SSA格式简化了处理更大、更复杂的表达式。它的一个优点是,可以更容易地对重新定义的变量进行推理,或者根据函数中的路径使用不同的值来定义变量。顾名思义,SSA通过每次重新定义变量时创建不同版本的变量来解决这个问题;如果函数中存在一个变量具有多个可能值的点,则插入一个称为PHI的伪操作,合并所有值。

尽管构建SSA格式相当复杂,但下面的示例应该足够简单,以便对SSA和PHI操作的作用有很好的直觉:

原始代码 SSA转换后
int test(int a, int b) {
    int x, y, z;

    if(a > 3) {
        x = 4;
        y = 1;
        z = b & 0xFF00;
    }
    else {
        x = 9;
        y = 2;
        z = b << 8;
    }

    int p = (x * y) * 4;
    int q = z & 0xF;
    return p >= 16 && q == 0;
}
int test(int a1, int b1) {
    int x0, y0, z0; // undefined

    if(a1 > 3) {
        x1 = 4;
        y1 = 1;
        z1 = b1 & 0xFF00;
    }
    else {
        x2 = 9;
        y2 = 2;
        z2 = b1 << 8;
    }
    x3 = PHI(x1, x2)
    y3 = PHI(y1, y2)
    z3 = PHI(z1, z2)

    int p1 = (x3 * y3) * 4;
    int q1 = z3 & 0xF;
    return p1 >= 16 && q1 == 0;
}

从右侧可以看到,每个变量都被重命名为多个版本(由数字后缀表示)。在if-then-else语句之后,所有三个变量都可以有两个不同的值,这取决于a>3的运行时结果,因此有必要插入PHI操作。

新的优化器能够利用PHI操作并将整个函数转换为 返回1 ,正在删除所有其他代码 死码消除 . 与之前在x64上生成的18条指令相比,这是1条指令。对于p1>= 它计算每个可能的值,并将其与16(最小可能值)进行比较。对于q1==0,它检查z1和z2中的低位是否为0。

旧的表达式优化器无法对涉及这些PHI操作的更大表达式进行推理-这导致它错过了许多优化机会,如上面所示。在新的优化器中,每个操作和静态分析都支持PHI。再举几个例子:

(phi 3, 5) + 2 -> phi 5, 7     // constant-fold by pushing operand inside a PHI
(phi b+3, b+5) - b -> phi 3, 5 // eliminate operation by pushing operand inside a PHI
phi a+x, b+x -> (phi a, b) + x // extract a common operand from a PHI
(phi 1,2) + 3 < (phi 3,4) + 5 -> true                 // fold compare by testing all combinations
(phi 1,2) * (phi 2,3) > (phi 6,7) * phi(2,3) -> false // similar to above example
(phi 1,0) * 5 > (phi 1,2) -> undecidable              // 0 * 5 < (phi 1,2)

下面是mozillafirefox中一个有趣的例子。生成if-then-else语句的布尔表达式以否定形式使用 如果(!表达式)。 新算法试图通过反转每个子表达式来取消反转布尔运算,它进行了以下转换,消除了反转:

(phi 0, (x ? 1 : 0)) ^ 1 -> phi 1, (x ? 0 : 1)
  • 更好的条件移动生成

将分支转换为CMOV会产生更紧凑的代码,通常执行速度更快。在新的优化器中,通过生成问题操作来增强CMOV生成的后期阶段。这样,就可以应用已经存在的转换,进一步简化事情。在以下示例中,左侧是新检测到的CMOV模式,右侧是应用变换后的代码:

a < 0 ? 1 : 0 ->  a >> 31           // logical shift
a < 0 ? 4 : 0 -> (a >> 31) & 4      // arithmetic shift 
a<bool> != b<bool> ? 1 : 0 -> a ^ b // a, b must be Boolean values

CMOV性能有时很难估计,特别是在具有良好分支预测的现代cpu上。在分支速度更快的情况下提供帮助 配置文件信息 如果分支是高度可预测的(严重偏向于执行或不执行),则不会生成CMOV。

  • 改进的比较操作优化

比较是改进最多的操作。由于减少分支的数量对代码大小和性能都有好处,所以重点主要放在分支折叠上(通过证明分支被执行或未执行来消除分支)。除了常用的比较常数的测试外,静态分析还用于估计值范围和已知的1/0位,使处理更复杂的情况成为可能。在简化比较的几十种转换中,下面的一种是大大减少执行时间的示例:

a / 12 == 15 -> a in range [180, 192) -> (a – 180) < 12 // unsigned compare

除法(20+周期)由简单的范围检查(2周期)代替。即使应用“除以常数”优化,它仍然比范围检查慢几倍。

  • 位估计器

这是一个强大的静态分析,可以用来提取有关值的更多编译时信息。提供的一些功能:

    • 估计已知为1或0的位
    • 证明值不是零
    • 估计最小值和最大值
    • 估计值范围
    • 改进了加法和减法的溢出检查

下面是一个简单的例子,演示了如何在编译时计算1/0位,即使对初始值(参数)一无所知 在下面的例子中):

int test(unsigned char a) {
    short b = a;    // b: 00000000________, a: ________ 
    b <<= 4;        // b: 0000________0000 
    b |= 3;         // b: 0000________0011
    return b != 0;  // -> return true   
}

当前使用这些功能的一些地方:

    • 将有符号指令转换为无符号指令 :用常量生成除法/余数的较小代码,允许将常量折叠为 利亚 说明等。
    • 折叠比较和分支 :使用已知位和值范围信息折叠比较。例如,给定 a==b ,如果 已知有一个位设置在一个位置,而这个位置肯定没有设置 b ,两个值不能相等。这可以通过检查符号位应用于其他条件,例如小于。使用值范围时,每个 与各种 b .
    • 改进的溢出检查 :优化 a+C1 进入之内 a 无效,因为 a+C1 可能会溢出,产生不同的结果。使用已知的位或值范围,可以证明加法不会溢出。实际上,这种情况通常发生在 是较小类型的零扩展。
    • 查找布尔值和正值: 用作各种优化的前提条件,例如应用于问题操作的优化。另一个例子是,如果值已经为正,则消除ABS固有值。
    • 删除冗余和/或指令,避免无用的转换:
a % C -> 0  if C is a power of two and the low bits in a are zero (a is a multiple of C)
a & C -> 0  if all bits that are one in C are known to be zero in a
a | C -> a  if all bits that are one in C are known to be one in a
  • 改进的公共子表达式消除

公共子表达式消元 是一种优化方法,通过将冗余操作替换为以前计算相同值的操作的结果来消除冗余操作—这种情况发生的频率比预期的要高。在现有算法的基础上增加了一个基于 全局值编号 ,这将增加发现等价的表达式的数量。虽然这是一个非常简单的初始实现,将变得更加强大,但它显示了代码大小和性能的显著改进。

在执行表达式优化之前消除冗余操作也会带来更多的机会。例如, (a+b)–c -> 如果 b 被发现等同于 c .

  • 利用未定义的有符号整数溢出

从历史上看,VisualC++没有充分利用C和C++标准考虑溢出定义的运算结果未定义的优点。其他编译器在这方面非常积极,这促使我们决定实现一些利用未定义整数溢出行为的模式。我们实现了我们认为是安全的,并且在生成的代码中没有强加任何不必要的安全风险。

添加了一个新的undocumented编译器标志,以在不符合标准的应用程序失败时禁用这些优化: D2未注入溢流 . 由于安全考虑,我们已经看到了这些模式不应该被优化的情况,即使遵循C和C++标准允许我们通过使潜在的添加溢出未定义:

a + Constant  > a -> true   // Constant > 0
a + Constant <= a -> false  // Constant > 0

这两个测试(以及类似的减法测试)经常用于检查文件读取器和内存分配器等位置的溢出。虽然使用不符合标准,这是一个众所周知的问题,但启用这些转换可能会破坏这些应用程序的安全性。

对代码大小的影响

对于大多数应用程序,代码大小会减小,但也会由于与其他优化的交互而增大。例如,较小的函数更有可能被内联到多个位置,从而导致总体大小增加。

以下是在x64上编译几个大型应用程序时产生的一些代码大小结果:

应用 旧优化器 新优化器 还原
窗户 1,112,545,269 1,112,096,059 438 KB
SQL服务器 64,078,336 64,032,256 46 KB
脉轮 5,963,621 5,952,997 10 KB

下表列出了按类别划分的 Windows内核 为x64构建,具有链接时间代码生成和配置文件信息。可以看出,更昂贵的指令(例如分支、除法和乘法)的数量减少了。CMOV和SETcc的增加是更多分支转换为条件代码的结果。

指令类型 旧优化器 新优化器 差异
转换 28075 27301 -774
利亚 87658 87395 263
班次 15266 15194 -72
SETcc公司 2222 2345 +123
跳跃 19797 19791 -6
分支 143795 142591 -1204
MUL公司 2115 1990 -125
部门 541 530 -11
CMOV公司 4192 5913 +1721

对编译器吞吐量的影响

对于所有这些改进,编译时间基本保持不变,大约有+/-2%%的差异,这取决于正在编译的应用程序。例如,Google Chrome显示编译速度降低了1.7%,而编译Windows内核显示速度提高了2.6%。速度的提高可以通过让更少的代码通过旧的、较慢的优化过程来解释。

测试方法

根据以前的经验和项目的范围,从一开始就很清楚,广泛的测试需要发挥核心作用,以确保正确性。使用了几种测试方法,有些是为了首先防止错误,有些是为了发现实现问题:

  • 通过正式验证模式来防止实现错误

大多数模式都非常简单,比如x&0=>0。但是也有一些模式需要验证,但这些验证并不总是非常明显,容易出错。最常见的验证错误有:

  • 没有检查输入的先决条件,例如要求正数、二的幂、N个高位为0的数字等
  • 无法区分有符号操作和无符号操作。这对于CMP、DIV/REM和SHR等指令尤其危险。

活着的 是一个正式的验证工具,用于在实现模式和前提条件之前确保它们是正确的。它使用的语言与 LLVM红外 以及 Z3型 定理证明器,用于验证输入模式是否等价于输出模式-如果不是,则打印一个反例。live已经被LLVM社区成功地用于发现许多bug。有关Alive的更多详细信息,请访问John Regehr的博客: 活动:自动LLVM验证器 .

  • 使用随机测试覆盖和测试尽可能多的图案

密斯 是一个随机C程序生成器,用于发现各种编译器中的大量错误。使用CSmith生成的超过1500万个程序已经过测试,揭示了新优化器中的几个错误,以及其他优化器组件中的错误。在处理大量失败的测试时非常有用 C-还原 :is能够将200KB测试缩减为2-3KB大小的测试,从而更容易发现有bug的地方。

  • 每三个指令表达式测试一次

选择模糊 , 犹他大学的John Regehr的一个工具,能够生成具有N个指令和有限数量的可能常数的每个小整数表达式作为LLVM IR。这个 叮当声/C2 该项目使测试为三个指令表达式生成的所有2.5亿多个测试成为可能,这揭示了几个微妙的错误。

  • 使用检测和运行时检查

复杂的组件,例如 位估计器 数值编号 ,通过对编译代码插入对运行时库的调用进行测试,运行时库验证编译时静态分析结果是否实际有效。例如,在位估计器的情况下,它将验证在运行时被估计为始终为零的位是否为零。在值编号的情况下,它将确保分配相同值编号的两条指令在运行时具有相同的值。

  • 使用流行的开源项目进行测试

将编译器暴露在更真实的代码中被证明是发现更多bug的有效方法。这包括构建和测试googlechrome、mozillafirefox、CoreCLR和Chakra。

未来改进

正如我在博客文章的开头所提到的,这个框架被设计成将来许多优化器特性将被实现的地方。下面是一些很可能成为下一个主要Visual Studio版本的一部分的优化—它不包括任何计划中的长期项目:

  • 完成并启用向量操作的优化
  • C++代码中布尔表达式的优化
  • 删除对表达式结果没有影响的操作
  • 合并类似分支
  • 位估计器的若干改进

闭幕词

请尝试使用新的优化器构建和测试您的应用程序,并报告您可能发现的任何问题。我们期待您在评论部分提出建议和意见。让我们知道,如果你有案例,可以更好地优化,尚未处理。

我们很高兴终于能够与您分享这一令人兴奋的新工作!这标志着许多优化器改进的开始,这些改进将添加到编译器的未来版本中–我们将随时向您通报。

谢谢,格拉蒂安·卢普Visual C++优化器团队

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享