美文网首页C/C++知识点程序员
破解/优化C++代码:消除冗余代码

破解/优化C++代码:消除冗余代码

作者: Python编程导师 | 来源:发表于2018-12-25 20:38 被阅读20次

    这篇文章讲述了消除冗余代码(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 字节。

    相关文章

      网友评论

        本文标题:破解/优化C++代码:消除冗余代码

        本文链接:https://www.haomeiwen.com/subject/srsqlqtx.html