优化C++代码(4):消除冗余代码

开发 后端
这篇文章讲述了消除冗余代码(Dead Code Elimination)的优化方法,我简写为DCE。顾名思义:只要其计算结果没有被程序所使用, 该计算就被丢弃。

这篇文章讲述了消除冗余代码(Dead Code Elimination)的优化方法,我简写为DCE。顾名思义:只要其计算结果没有被程序所使用, 该计算就被丢弃。

这时你可能会说,你的代码只会计算有用的结果,从没有无用的东西,只有白痴才会平白无故地添加那些无用的代码—–例如,会在做一些有用事情的同时,还在计算着圆周率的前1000位。那么消除冗余代码的优化,到底什么时候才有用呢?

我之所以这么早就开始讲述DCE的优化,是因为如果不清楚DCE的话,在探索其他一些更有趣的优化方法中会造成一些破坏和混乱。看一下下面的小例子,文件Sum.cpp:

  1. int main() { 
  2.     long long s = 0; 
  3.     for (long long i = 1; i <= 1000000000; ++i) s += i; 
  4. }

我们对于在计算这十亿个数的和时,循环的执行速度很感兴趣。(的确,这种做法太愚蠢了,我们高中时就学过有一个闭公式可以计算出结果,但这不是重点)

使用命令 CL /Od /FA Sum.cpp 来 构建这个程序,并用Sum命令来运行程序。注意这个构建使用/Od开关禁用了代码优化。 在我的PC机上,运行这个程序花费了4秒。 现在试着使用CL /O2 /FA Sum.cpp 来编译优化过的代码。这次运行很快,几乎察觉不到有延迟。编译器对我们的代码的优化有这么出色吗?答案是否定的(但它的确是以一种奇怪的方式改变了我们的 代码)

我们看一下/Od版本生成的代码,它保存在Sum.asm里。我精减了一些代码并注释了一些文本,让它只显示循环体:

这些指令跟你预料中的差不多。 变量i保存以在RSP为寄存器,i$1为偏移量的栈上;在asm文件的其他地方,我们发现i$1=0.  使用RAX寄存器让i自增长。同样地,变量s保存在RSP为寄存器,S$为偏移量的栈上,s$=8.  并在RCX中来计算每次循环的累积和。

我们注意到每次循环时,先从栈上获取i的值,再把新值写回去,变量s同样如此。可以说这段代码很幼稚—–它是由很愚蠢的编译器生成的(也就说,优化被禁用了)。 例如,我们本来可以将变量i和s一直保存在寄存器中,而不用每次循环迭代时都要访问内存。

关于未优化的代码就说这么多了。那么进行优化后所生成代码是什么样呢? 来看一下使用/O2构建的程序对应的Sum.asm文件,再一次把文件精简到只剩下循环体的实现,

结果是:

  1. ;; there’s nothing here! 

对,它是空的,没有任何计算s的指令。

你可能会说,那这个答案肯定错了.但是我们怎么知道这个答案就是错的呢?因为优化器已经推断出程序在任何时候都没用到S, 所以才懒得计算它。你不能说答案是错的,除非你需要核对该答案,对吧?

我们这不是被DCE优化给耍了吗? 如果你不需要观察计算结果,程序是不会进行计算的。

优化器的这个问题其实跟量子物理学的基本原理很类似,可以用大众科普文章里经常提到的一句话来解释,“如果森林里一棵树倒下了,但是如果周围都没人,它还会有声音吗?”。

我们可以在代码里添加打印变量s的语句来观察计算结果,代码如下:

  1. #include <stdio.h> 
  2. int main() { 
  3.     long long s = 0; 
  4.     for (long long i = 1; i <= 1000000000; ++i) s += i; 
  5.     printf("%lld ", s); 

运行/Od版本的程序打印出了正确结果,还是花费了4秒,/O2版本打印出同样的结果,速度却快得多(具体快多少,可以看下下面的可选部分,实际上,速度高达7倍多)。

到现在为止,我已经讲述了这篇文章的主要观点:在进行编译器优化分析的时候一定要十分小心,衡量它们的优点时,千万不要被DCE给误导了。 下面是使用DCE优化时需要注意的四个步骤:

  1. 检查计时,确保没有突然提高一个数量级;
  2. 检查生成的代码(使用 /FA)
  3. 如果不太确定,可以添加一个printf语句
  4.  把你感兴趣的代码放到一个独立的.CPP文件里,和含有main函数的文件分开。只要你不进行整个程序的优化,就一定会奏效(使用/GL,我们后面会讲到)。

不管怎么样,我们从这个例子中还是学到了一些很有意思的东西,下面四个小节为可选部分。

  1. xor    edx, edx 
  2. mov    eax, 1 
  3. mov    ecx, edx 
  4. mov    r8d, edx 
  5. mov    r9d, edx 
  6. npad   13 
  7. $LL3@main: 
  8. inc    r9 
  9. add    r8, 2 
  10. add    rcx, 3 
  11. add    r9, rax                           ;; r9  = 2  8 18 32 50 ... 
  12. add    r8, rax                           ;; r8  = 3 10 21 36 55 ... 
  13. add    rcx, rax                          ;; rcx = 4 12 24 40 60 ... 
  14. add    rdx, rax                          ;; rdx = 1  6 15 28 45 ... 
  15. add    rax, 4                            ;; rax = 1  5  9 13 17 ... 
  16. cmp    rax, 1000000000                   ;; i <= 1000000000 ? 
  17. jle    SHORT $LL3@main                   ;; yes, so loop back 

注意看循环体包含了和未优化版本一样多的指令,为什么会快很多呢?那是因为优化后的循环体的指令使用的是寄存器,而不是内存地址。 我们都知道,寄存器的访问速度比内存快得多。 下面的延迟就展示了内存访问时如何将你的程序降到蜗牛般的速度:

Location

延时

Register

1 cycle

L1

4 cycles

L2

10 cycles

L3

75 cycles

DRAM

60 ns

所以,未优化版本是在栈上进行读写的,比起在寄存器中进行计算慢多了(寄存器的存取时间只需一个周期)。

但是还有其他原因的,注意/Od版本的执行循环的时候计数器每次加1,/O2版本的计数器(保存在RAX寄存器中)每次加4。

优化器已经展开循环,每次迭代都会把四项加起来,像这样:

s = (1 + 2 + 3 + 4) + (5 + 6 + 7 + 8) + (9 + 10 + 11 + 12) + (13 + . . .

通过展开这个循环,可以看到每四次迭代才对循环做一个判断,而不是每次都进行判断,这样CPU可以节省更多的时间做一些有用的事,而不是在不停地进行循环判断。

还有,它不是将结果存在一个地方,而是使用了四个独立的寄存器,分别求和,像这样:

RDX = 1 + 5 +  9 + 13 + ...  =  1,  6, 15, 28 ...
R9  = 2 + 6 + 10 + 14 + ...  =  2,  8, 18, 32 ...
R8  = 3 + 7 + 11 + 15 + ...  =  3, 10, 21, 36 ...
RCX = 4 + 8 + 12 + 16 + ...  =  4, 12, 24, 40 ...

循环结束时,再把四个寄存器的和加起来,得到最终结果。

(读者朋友们可以思考下这个练习,如果循环总数不是4的倍数,那优化器会怎么处理?)

可选2: 精确的性能测试

之前,我在没有使用printf函数的/O2版本的程序中说过,“运行速度之快以致于你察觉不到有任何延迟”, 下面就用一个例子更准确地描述下这种说法:

  1. #include <stdio.h> 
  2. #include <windows.h> 
  3. int main() { 
  4.   LARGE_INTEGER start, stop; 
  5.   QueryPerformanceCounter(&start); 
  6.     long long s = 0; 
  7.     for (long long i = 1; i <= 1000000000; ++i) s += i; 
  8.   QueryPerformanceCounter(&stop); 
  9.   double diff = stop.QuadPart - start.QuadPart; 
  10.   printf("%f", diff); 

程序中使用了QueryPerformanceCounter 来计算运行时间(这就是我之前的博客里写到的精简版本的高分辨率计时器)。 在测量性能的时候,心中一定谨记一些注意事项(我以前有写过一个列表),但是对这个特殊的例子,其实也没什么用,我们一会儿就能看到:

  我在PC机上运行了/Od版本的程序,打印diff的值,大约为7百万。(计算结果的单位并不重要,只需知道 这个值越大,程序运行的时间就越长)。而/O2版本,diff的值则为0.原因就得归功于DCE优化了。

我们为了阻止DCE,添加一个printf函数,/Od版本的diff值大约为1百万—-速度提升了7倍。

可选3:x64 汇编程序 “扩展”

我们在回头看看文章里的汇编代码部分,在初始化寄存器部分,会发现有点奇怪:

  1. xor    edx, edx                          ;; rdx = 0     (64-bit!) 
  2. mov    eax, 1                            ;; rax = i = 1 (64-bit!) 
  3. mov    ecx, edx                          ;; rcx = 0     (64-bit!) 
  4. mov    r8d, edx                          ;; r8  = 0     (64-bit!) 
  5. mov    r9d, edx                          ;; r9  = 0     (64-bit!) 
  6. npad   13                                ;; multi-byte nop alignment padding 
  7. $LL3@main: 

记得原始C++语言会使用long long类型的变量来保存循环计数器和总和。 在VC++编译器中,会映射成64位的整数,所以我们会料到生成码应该会用x64的64位寄存器。

上一篇文章中,我已经讲过了,指令xor  reg reg是 用来将reg的值置为0的一种高效的方法。但是第一条指令是在对EDX寄存器(RDX寄存器的低32位字节)进行xor运算,下一条指令是将 EAX(也就是RAX寄存器的低32位字节)赋值为1。下面的三条指令也是同样的方式。从表面来看,这样每一个目标寄存器的高32位字节都存储的是一个任 意的随机数,而循环体的计算部分是在扩展的64位寄存器上进行的,这样的计算结果怎么可能是对的?

答案是因为最初由AMD发布的x64位指令集,会将64位的目标寄存器的高32位字节自动扩展为零。 下面是该手册的3.4.5小节的两个知识点:

1. 32位寄存器的零扩展: 如果寄存器为32位,自动将通用目标寄存器的高32位扩展为零。

2. 8位和16位字节寄存器 无扩展: 如果是8位和16位寄存器,则不对64位通用寄存器做改变。

最后,注意一下npad 13 这条指令(其实是一个伪操作,一条汇编指令)。用来确保下一条指令(从循环体开始)遵循16字节的内存对齐,可以提高性能(有时,用于微架构)。

可选4:  printf 和 std::out

 你也许会问,在上个实验中,为什么我使用了C的printf函数,而不是C++的std::out呢? 试试看,其实两者都可以,但是后者生成的asm文件要大很多,所以浏览起来不太方便: 相比前面的1.7K字节文件, 后者生成的文件达0.7M 字节。

原文链接:http://blog.jobbole.com/47231/

译文链接:http://blog.jobbole.com/47231/

责任编辑:陈四芳 来源: 伯乐在线
相关推荐

2012-07-27 10:30:12

重构

2020-11-10 08:54:55

Lombok

2013-09-04 09:55:32

C++

2013-09-03 09:35:10

2024-01-25 16:19:27

2010-01-13 13:27:00

C++优化

2023-11-15 17:58:58

C++代码

2010-01-14 14:40:21

C++代码

2010-01-18 16:17:53

C++代码

2023-10-30 10:29:50

C++最小二乘法

2020-07-07 10:55:01

C++C语言代码

2010-01-22 13:45:36

C++代码

2010-02-05 10:23:09

C++基本函数

2010-01-18 13:42:51

C++代码

2010-02-02 15:59:32

C++赋值函数

2011-05-18 18:05:47

C#C++

2013-09-03 09:30:42

C++代码优化

2010-01-21 10:23:53

C++代码

2011-05-18 17:56:38

C#C++

2010-04-13 08:49:08

JavaLombokJavabean
点赞
收藏

51CTO技术栈公众号