C++编译优化实战1-2:简析C++编译优化器以及Loop Unrolling功能演示

Blog
Author:
Amir KirshAmir Kirsh
Published On:
2月 10, 2022
Estimated reading time:
1 minute

编译器优化是通过改进运行时性能或最小化代码大小来提高已编译代码效率的过程。

在本视频中(转录文本见下文),Incredibuild 的开发推广工程师 Amir Kirsh 将重点讨论 C++ 编译器执行的一些特定的和有趣的优化。

1-浅析 C++ 编译器

c++ 优化器是如何优化代码的呢?

让我们深入研究一段代码。我将和大家分享我的 Coliru 在线编译器。

无锁多线程

在这个例子中,我给出了一个多线程程序,为什么向您展示多线程程序呢,我很快会为您解释原因。这里有一个叫做加法器的函数,它可以将数字添加到全局变量和中。在这个程序中,全局变量和是由多个线程并行处理的,并没有锁定,这一点并不好。这是众所周知的问题。我们有一个竞争环境,结果可以是任何东西。但是这个想法的目的是检查实际结果是什么。因此,如上所述,和是一个全局变量,加法器运行一个循环,并将指数 “i” x 的乘积加入和中。总的来说,我们启动了 10 个线程。每个线程都运行加法器,且所有 10 个线程都将数字相加到同一全局变量和中。

因此,如果我们想要检查结果会是什么,可能我们只运行一次,就能得到预期的结果,我们可以计算并看看有锁时会发生什么。但是,如果我们多次运行程序,我们会发现结果确实并不总是相同的。所以我在这里做的是运行一个小的 bash 脚本。在这里,我将程序运行了 500 次,然后我对结果排序并计算唯一的结果,最终我得到了我得到每个特定输出的次数。我们可以看到,500 次中有 490 次,我得到了一些结果,这是预期的结果,这是正确的计算,如果我有锁的话。但是我们还有 10 个其他结果,则是另外一个结果。这很棒。

它确实表明,我们有理由需要在这里使用一个原子整数或使用锁。但我的问题是,为什么我们得到的所有结果都是 50 的乘积呢。为什么我们没有其他的结果,比如 267355267384、或者很多其他的结果。答案就是编译器优化。经过优化器的处理,结果成为了约为 10 的乘积或 50 的乘积。而优化器所作的这种处理就是循环展开。

循环展开

优化器看到这个循环,然后它说,好吧,我不确定你是否真的需要一个循环。也许我可以不用循环计算整个过程。所以函数加法器变成了一个单独的计算,循环没有了,这叫做循环展开。

一旦循环消失,那么线程之间的竞争,也就是竞争条件,就会出现在由优化器计算的整个计算中。循环中的和是多少,是 50 的乘积之类的。现在,我们能验证这个理论吗? 是的,我们可以。我们试着将优化标志从 -O2 改为 -O0,并重新编译。因此,我在这里再次将优化标志更改为 -O0 编译。我们可以看到,结果现在更广泛地分布在各种数字中,因为现在没有循环展开。循环实际上在代码中。所以我们实际上进入了循环,竞争条件现在是循环中的每一个加法,而不是单次计算。大多数时候,我们得到的数据与之前相同,但其他的数据更加分散。

汇编

说到汇编代码,我们可以使用另一个在线工具 Compiler Explorer。我们可以看到相同代码上的 -O0 -O2 会导致不同的汇编代码。使用 -O0,我们在这里谈到的加法器函数确实有一个循环。我们可以看到,我们转到有一些计算的代码,然后在这一行进行比较。如果比较大于某个数字,则继续,否则跳转回循环。

这就是我们看到的循环,在使用 -O2 优化的代码中,汇编代码,在加法器函数中,汇编命令只是计算,一行或两行计算,两个计算命令。就是这样。没有任何内部循环,这就是我们在这看到的循环展开。

以上我们谈论了由 C++ 编译器所做的优化,也就是循环展开。在我们的示例中,我们看到了它如何影响多线程应用程序。它并没有将我们从竞争条件中拯救出来,因为无论如何竞争条件都在加法器函数中,但是如果我们没有循环展开,竞争条件的表现会有所不同。这就是今天的内容。我们将在下一次会谈中继续讨论,看看优化器能为我们做些什么。

2-C++ 优化器及Loop Unrolling功能演示

今天,我想继续我之前的讨论,并介绍一些更为简单的 C++ 编译器、优化器和循环展开。

Compiler Explorer

那么,我们就来看看 Compiler Explorer 吧。这是一款在线工具,用来查看被汇编的 C++ 代码。希望大家能够看到我的屏幕,我将写一个非常简单的函数。我们将它称为 foo 吧。Foo 得到一个整数并返回一个整数。在 foo 内部,我想要一个简单的循环。我想要在 I 上循环,当 i 达到 10 且增量为 i 时循环停止,最后,我想要返回 i。所以,当我们看这个函数时,嗯,这儿会有两个可能的结果,要么得到小于 10 的 i,在这种情况下,返回值为 10,要么得到大于或等于 10 的 i。然后我们只返回 i,所以要么 foo 将返回 i,要么 foo 将返回 10,但问题是循环是否会出现在实际汇编中,答案是,这取决于优化级别。

如果我们的优化级别为 -O2,那么确实不需要循环,也不会有循环。如果这儿我将其更改为 -O0,那么你会看到一个内部循环,之所以我们在这儿可以看到,是因为我们要求编译器不要做任何优化。实际上这里有一个循环,有人把这个值和 9 进行了比较。然后决定是回到循环还是结束循环。

让我们回到 -O2。这个函数变成了一个简单的 if 条件函数,一个简单的比较,我们取 10 并把它放在整数的返回值寄存器中。然后,我们将返回值寄存器中的值与我们得到的位于 EDI 寄存器中的参数值进行比较。然后我们会有一个条件转移。如果之前的比较结果为大于或等于,那么我们将 EDI 寄存器复制到返回值。否则,我们就不进行复制。我们继续使内部保持为 10。所以最终它会返回 10 或 i,这取决于 i 是否大于 10,这就是一个简单的 if 条件。在这里我们可以看到,编译器可以接受一个循环,并将其转换成不需要循环的情况。这就叫做循环展开。这是一个非常简单的例子,比我们在前一个视频中看到的要简单得多,尽管前一个更逼真,但这是一个虚构的例子。

现在我们在示例中添加一个 main,并在 main 中调用 foo。我们可以再次看到,main 中有一些与 foo 中相同的函数,如果要返回值,那么我们就返回 foo 的返回值。所以我们返回 foo ,我们可以看到实际上没有必要调用 foo。这由编译器或优化器决定,好吧,我们将返回 120,因为在某种程度上,foo 是内联的。结果是 120。如果我们把它变为 5,那么我们可以看到结果给出的是 10。实际上没有必要调用 foo,因为在某种程度上 foo 被内联至 main。优化器可以在编译时执行 if,并根据它们发送给 foo 的初始值决定是从 main 10 返回还是从其他值返回。

循环展开

我们在 main 中还可以做一些其他事情,比如,执行循环。我们将 int i 放入 main 中。然后看看并决定返回 i,所以我们有一些类似于在 foo 中所执行的操作,但是这里的 i 并不是我们作为参数而得到的变量,我们只是定义 i,我们有一个变量 i,它是一个局部变量。我们运行这个循环,然后返回 i。如果我们看一下汇编代码,我们会发现 main 仅返回 10,那 main 为什么会返回 10 呢?这是否意味着局部变量被初始化为零?不,并不是那样,而是意味着优化器假设了一些东西,我们这里有未定义的行为,而这个未定义的行为就是我们使用未初始化的变量。一旦你使用了未初始化的变量,优化器就能假设任何东西。这里优化器决定假设 i 小于 10 或小于等于 10,因为如果 i 大于 10,那么结果应该是 i,而事实并非如此。我们只是返回 10。所以优化器在这假设了一些东西。这没关系。这是合理的,因为一旦我们有了一个未定义的行为,比如,我们对一些未初始化的东西设置了条件,优化器就可以假设它需要或想要假设的任何东西来优化代码。在这个例子中,实际上并不需要检查 i,因为 i 并未初始化,我们假设它小于或等于 10。

结语

我们能在 main 中看到实际的循环吗?好吧,如果我们返回到 -O0,那么我们可能会找到循环,即使是在 main 中,因为我们要求优化器或编译器不要优化我们的代码。我并不是告诉大家使用未定义的行为。不,一定要初始化你的变量,但是你必须要知道是的是,一旦你不初始化一个变量或一旦你的代码依赖于未定义的行为,这就意味着优化器和编译器可以假设一些东西。当你将代码移动至另一编译器或使用其它一些优化标志时,你的代码可能会有不同的行为,而这正是未定义行为的结果。

今天我们讨论了循环展开、C++ 优化器,以及关于未定义行为的一些内容。我们下一个视频见。谢谢观看。再见!