这篇文章讲述了消除冗余代码(Dead Code Elimination)的优化方法,我简写为DCE。顾名思义:只要其计算结果没有被程序所使用, 该计算就被丢弃。
这时你可能会说,你的代码只会计算有用的结果,从没有无用的东西,只有白痴才会平白无故地添加那些无用的代码—–例如,会在做一些有用事情的同时,还在计算着圆周率的前1000位。那么消除冗余代码的优化,到底什么时候才有用呢?
我之所以这么早就开始讲述DCE的优化,是因为如果不清楚DCE的话,在探索其他一些更有趣的优化方法中会造成一些破坏和混乱。看一下下面的小例子,文件Sum.cpp:
int main() {
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
}
我们对于在计算这十亿个数的和时,循环的执行速度很感兴趣。(的确,这种做法太愚蠢了,我们高中时就学过有一个闭公式可以计算出结果,但这不是重点)
使用命令 CL /Od /FA Sum.cpp来构建这个程序,并用Sum命令来运行程序。注意这个构建使用/Od开关禁用了代码优化。 在我的PC机上,运行这个程序花费了4秒。 现在试着使用CL /O2 /FA Sum.cpp 来编译优化过的代码。这次运行很快,几乎察觉不到有延迟。编译器对我们的代码的优化有这么出色吗?答案是否定的(但它的确是以一种奇怪的方式改变了我们的代码)
我们看一下/Od版本生成的代码,它保存在Sum.asm里。我精减了一些代码并注释了一些文本,让它只显示循环体:
mov QWORD PTR s$[rsp], 0 ;; long long s = 0
mov QWORD PTR i$1[rsp], 1 ;; long long i = 1
jmp SHORT $LN3@main
$LN2@main:
mov rax, QWORD PTR i$1[rsp] ;; rax = i
inc rax ;; rax += 1
mov QWORD PTR i$1[rsp], rax ;; i = rax
$LN3@main:
cmp QWORD PTR i$1[rsp], 1000000000 ;; i <= 1000000000 ?
jg SHORT $LN1@main ;; no – we’re done
mov rax, QWORD PTR i$1[rsp] ;; rax = i
mov rcx, QWORD PTR s$[rsp] ;; rcx = s
add rcx, rax ;; rcx += rax
mov rax, rcx ;; rax = rcx
mov QWORD PTR s$[rsp], rax ;; s = rax
jmp SHORT $LN2@main ;; loop
$LN1@main:
这些指令跟你预料中的差不多。 变量i保存以在RSP为寄存器,i$1为偏移量的栈上;在asm文件的其他地方,我们发现i$1=0. 使用RAX寄存器让i自增长。同样地,变量s保存在RSP为寄存器,S$为偏移量的栈上,s$=8. 并在RCX中来计算每次循环的累积和。
我们注意到每次循环时,先从栈上获取i的值,再把新值写回去,变量s同样如此。可以说这段代码很幼稚—–它是由很愚蠢的编译器生成的(也就说,优化被禁用了)。 例如,我们本来可以将变量i和s一直保存在寄存器中,而不用每次循环迭代时都要访问内存。
关于未优化的代码就说这么多了。那么进行优化后所生成代码是什么样呢? 来看一下使用/O2构建的程序对应的Sum.asm文件,再一次把文件精简到只剩下循环体的实现,
结果是:
there’s nothing here!
对,它是空的,没有任何计算s的指令。
你可能会说,那这个答案肯定错了.但是我们怎么知道这个答案就是错的呢?因为优化器已经推断出程序在任何时候都没用到S, 所以才懒得计算它。你不能说答案是错的,除非你需要核对该答案,对吧?
我们这不是被DCE优化给耍了吗? 如果你不需要观察计算结果,程序是不会进行计算的。
优化器的这个问题其实跟量子物理学的基本原理很类似,可以用大众科普文章里经常提到的一句话来解释,“如果森林里一棵树倒下了,但是如果周围都没人,它还会有声音吗?”。
我们可以在代码里添加打印变量s的语句来观察计算结果,代码如下:
#include <stdio.h>
int main() {
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
printf("%lld ", s);
}
运行/Od版本的程序打印出了正确结果,还是花费了4秒,/O2版本打印出同样的结果,速度却快得多(具体快多少,可以看下下面的可选部分,实际上,速度高达7倍多)。
到现在为止,我已经讲述了这篇文章的主要观点:在进行编译器优化分析的时候一定要十分小心,衡量它们的优点时,千万不要被DCE给误导了。 下面是使用DCE优化时需要注意的四个步骤:
检查计时,确保没有突然提高一个数量级;
检查生成的代码(使用 /FA)
如果不太确定,可以添加一个printf语句
把你感兴趣的代码放到一个独立的.CPP文件里,和含有main函数的文件分开。只要你不进行整个程序的优化,就一定会奏效(使用/GL,我们后面会讲到)。
不管怎么样,我们从这个例子中还是学到了一些很有意思的东西,下面四个小节为可选部分。
可选1:/O2版本的Codegen 部分
为什么/O2版本(添加printf语句来阻止优化)会比/Od版本快这么多呢? 下面是从Sum.asm文件中提取的/O2版本的循环部分:
xor edx, edx
mov eax, 1
mov ecx, edx
mov r8d, edx
mov r9d, edx
npad 13
$LL3@main:
inc r9
add r8, 2
add rcx, 3
add r9, rax ;; r9 = 2 8 18 32 50 ...
add r8, rax ;; r8 = 3 10 21 36 55 ...
add rcx, rax ;; rcx = 4 12 24 40 60 ...
add rdx, rax ;; rdx = 1 6 15 28 45 ...
add rax, 4 ;; rax = 1 5 9 13 17 ...
cmp rax, 1000000000 ;; i <= 1000000000 ?
jle SHORT $LL3@main ;; yes, so loop back
注意看循环体包含了和未优化版本一样多的指令,为什么会快很多呢?那是因为优化后的循环体的指令使用的是寄存器,而不是内存地址。 我们都知道,寄存器的访问速度比内存快得多。 下面的延迟就展示了内存访问时如何将你的程序降到蜗牛般的速度:
Location延时
Register1 cycle
L14 cycles
L210 cycles
L375 cycles
DRAM60 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版本的程序中说过,“运行速度之快以致于你察觉不到有任何延迟”, 下面就用一个例子更准确地描述下这种说法:
#include <stdio.h>
#include <windows.h>
int main() {
LARGE_INTEGER start, stop;
QueryPerformanceCounter(&start);
long long s = 0;
for (long long i = 1; i <= 1000000000; ++i) s += i;
QueryPerformanceCounter(&stop);
double diff = stop.QuadPart - start.QuadPart;
printf("%f", diff);
}
程序中使用了QueryPerformanceCounter 来计算运行时间(这就是我之前的博客里写到的精简版本的高分辨率计时器)。 在测量性能的时候,心中一定谨记一些注意事项(我以前有写过一个列表),但是对这个特殊的例子,其实也没什么用,我们一会儿就能看到:
我在PC机上运行了/Od版本的程序,打印diff的值,大约为7百万。(计算结果的单位并不重要,只需知道 这个值越大,程序运行的时间就越长)。而/O2版本,diff的值则为0.原因就得归功于DCE优化了。
我们为了阻止DCE,添加一个printf函数,/Od版本的diff值大约为1百万—-速度提升了7倍。
可选3:x64 汇编程序 “扩展”
我们在回头看看文章里的汇编代码部分,在初始化寄存器部分,会发现有点奇怪:
xor edx, edx ;; rdx = 0 (64-bit!)
mov eax, 1 ;; rax = i = 1 (64-bit!)
mov ecx, edx ;; rcx = 0 (64-bit!)
mov r8d, edx;; r8 = 0 (64-bit!)
mov r9d, edx ;; r9 = 0 (64-bit!)
npad 13 ;; multi-byte nop alignment padding
$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 字节。
网友评论