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

伯乐在线注:“优化C++代码”系列博文,C++程序媛 JingerJoe@米可_GO)将会持续更新翻译,感兴趣的朋友,请收藏并关注。如果其他朋友也有不错的原创或译文,可以尝试推荐给伯乐在线

 

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

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

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

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

使用命令 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文件,再一次把文件精简到只剩下循环体的实现,

结果是:

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

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

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

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

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

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

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

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

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

 

可选1:/O2版本的Codegen 部分

为什么/O2版本(添加printf语句来阻止优化)会比/Od版本快这么多呢? 下面是从Sum.asm文件中提取的/O2版本的循环部分:

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

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版本的程序中说过,“运行速度之快以致于你察觉不到有任何延迟”, 下面就用一个例子更准确地描述下这种说法:

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

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

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

 

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

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

记得原始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 字节。

4 收藏 3 评论

关于作者:JingerJoe

一枚 C++ 程序媛……(新浪微博:@Aiyaiyaa) 个人主页 · 我的文章 · 13

相关文章

可能感兴趣的话题



直接登录
最新评论
跳到底部
返回顶部