• CSAPP第五章就在“扯淡”!


    “你的时间有限,所以不要为别人而活。不要被教条所限,不要活在别人的观念里。不要让别人的意见左右自己内心的声音。最重要的是,勇敢的去追随自己的心灵和直觉,只有自己的心灵和直觉才知道你自己的真实想法,其他一切都是次要。 ——史蒂夫·乔布斯”

     

     

     

    CSAPP的第五章“优化程序性能”,从机器底层的角度阐述了如何去优化。说实话,这章就应该撕掉,然后扔进垃圾桶。真是越看越火大,越看越觉得扯淡。要是你想成为一个三流程序员,就应该一丝不苟地按照书中的做。

     

     

    如果你写了个程序,觉得它太慢。那么你可以花半年去优化它,也可以跑去找小姑娘玩半年回来,然后更强大的硬件就会让你的程序更快。

     

     

    优化仅仅是在万不得已之时才应该去做,只要程序能工作,还能忍受它的速度,何必要去优化。程序员的时间宝贵,牺牲机器的时间,换取程序员的轻松时光不是天经地义的事吗!想当年编程是要在纸带上打孔输入机器的,所以后来有了汇编,又后来有了C,再后来有了Python。每一层级的递进都让程序员的开发效率得到了进一步提高,同时稍微牺牲了点机器时间。

     

     

    要记住:程序员的时间远比机器时间宝贵。

     

     

    下面就说说为什么不应该按书中的进行优化。

     

     

    测试机器:

    CPU Intel Core i5 M520 2.40GHz

    RAM 4GB

    Windows 7

     

    举些例子来说明问题:

     

     

    1)消除不必要的存储器引用

    有如下两个累加函数,ret为返回的地址参数

    sum1:

       1:  void sum1(int *a, int len, int *ret)
       2:  {
       3:      *ret = 0;
       4:   
       5:      for(int i = 0; i < len; i++)
       6:          *ret += a[i];
       7:  }

     

    sum2:

       1:  void sum2(int *a, int len, int *ret)
       2:  {
       3:      int s = 0;
       4:   
       5:      for(int i = 0; i < len; i++)
       6:          s += a[i];
       7:   
       8:      *ret = s;
       9:  }

    两个函数的功能是完全一样的,而且sum1也来得更直观些,sum2有时就会让人不解为何要引入一个中间变量s来保存累加和。原因就是,你得先把两段代码反汇编:

    sum1片段:

    movl (%edi), %eax

    imull (%ecx, %edx, 4), %eax

    mov %eax, (%edi)

     

     

    sum2片段:

    imull (%ecx, %edx, 4), %eax

     

     

    看懂了没,sum1中对ret的解引用会导致从%edi (ret) 的地址中取值(*ret)赋值给%eax,然后再从%eax赋值回(%edi)这个过程。而循环len次就会多出2 * len条指令。所以sum2的效率要高,那么高多少呢,如下图:

    存储器引用

    注意,这里的时间单位是ms,1000ms=1s。当len的长度不断以10的数量级递增时,sum2的时间优势其实并不明显。在len = 10^8时,差距仅仅是140ms(加速因子k=1.34,k=sum1时间/sum2时间),这点加速实在是太少了。况且由于现代硬件速度的提升,这种差异在以后也会越来越小。

     

     

     

    2)循环展开技术(Loop Unrolling)

    依然是对sum1函数的进一步优化,于是有了邪恶的sum3:

       1:  void sum3(int *a, int len, int *ret)
       2:  {
       3:      int s = 0;
       4:      int limit = len - 2;
       5:   
       6:      int i;
       7:      for(i = 0; i < limit; i += 3)
       8:          s += a[i] + a[i + 1] + a[i + 2];
       9:   
      10:      for( ; i < len; i++)
      11:          s += a[i];
      12:   
      13:      *ret = s;
      14:  }
     

    loop unrolling

    len = 10^8时,sum3相对于sum2提高了172ms(k=1.74),相对sum1提高312ms(k=2.33)。为什么会有这种提高?可以看到在循环中步长变为了3,那么为什么要是3而不是其他呢?

     

     

    要讲清楚就不得不先讲讲这幅图:

    unbounded_add

    这幅图是什么呢?就是讲在一个理想的国度,世界上的资源是无穷无尽的,包括计算机中的硬件资源。于是呢,每个周期我们都有无限的计算器件可用。看到在第三周期用到了jl, compl, incl的硬件资源,而这三个硬件其实都是要用到加法器的。因为有无限的器件所以没事,同时此时效率当然是最高的。

     

     

    可是,现实总是很残酷的。漂亮小姑娘总是有限,计算机的硬件也不可能无限多。神告诉我们,你只能有两个加法器,不可太贪。于是在同一周期,我们只能有两个涉及加法的指令。那么就有了如下的现实中的计算版本。显然效率要比理想版本差,效率拖后约1倍。

    bound_add

     

     

    很自然的,如何用手头有限的资源创造最大的财富,就是我们关心的。看到load指令的周期是一般指令的3倍,而load是可以流水执行的,那么尽量让load干活就是我们所期望的。如何改进呢?你可能想到了。邪恶的sum3版本登场:

    loop unrolling_csapp

    图中目的很明显,尽量减少addl, compl和jl(ACJ)这些指令的执行,这样就不至于太受加法器资源的限制,另外尽量利用load的流水特性。那么如何减少ACJ的执行呢?终于想到了加大每次步长了吧。又为什么是3呢?想到了load的周期是3了吧。谜团终于解开,看看最终版本的sum3是如何在机器中执行的:

    lopp unrolling_add

     

     

    看到3步一循环的方法能有效地降低ACJ的执行,同时充分地利用了load的流水特性。最后,这个版本相对理想版本效率拖后约0.33倍。

     

     

    既然能增加性能又为什么要谨慎呢?问题是,以后呢?再以后呢?让我们看得远一点,再远一点。硬件的发展总是如此之快,加法器会有的,load会更快的。然后呢,然后就是,你做的优化还得随着硬件不断修正,几个月后当你或者其他人重新审视你的代码时,你或他都不知道为什么这家伙写了这么恐怖的代码。于是,一切必须推倒重来。代码根本不具可维护性。

     

     

    程序首先是写给人看的,然后顺便让机器读懂。

     

     

    3)循环分割(Loop Splitting)

    继续sum1的讨论,如果我们把加法操作改为乘法呢?

       1:  void multi1(int *a, int len, int *ret)
       2:  {
       3:      *ret = 1;
       4:   
       5:      for(int i = 0; i < len; i++)
       6:          *ret *= a[i];
       7:  }

     

    书中再次给出了一个诡异的优化:

       1:  void multi2(int *a, int len, int *ret)
       2:  {
       3:      int limit = len - 1;
       4:      int m0 = 1;
       5:      int m1 = 1;
       6:   
       7:      int i;
       8:      for(i = 0; i < limit; i += 2)
       9:      {
      10:          m0 *= a[i];
      11:          m1 *= a[i + 1];
      12:      }
      13:   
      14:      for( ; i < len; i++)
      15:          m0 *= a[i];
      16:   
      17:      *ret = m0 * m1;
      18:  }

     

     

    m0计算偶数下标的乘积,而m1计算奇数下标。最后合并。看看到底有多快:

    loop splitting

    Len= 10^8,差异140ms, k=1.43,加速实在有限,而且还是在数据规模这么大的时候。另外,由于之前的loop unrolling技术也基本上能猜出个大概了。乘法器件耗费的周期巨大,又由于其流水特性。所以考虑增加每次循环中的乘法次数,而不必等到乘积结果迭代到下次而产生的延时。

     

     

    loop splitting_csapp

    这又是一个极度依赖特定机器的优化。这里我们可以猜到有2路并行,必定也能有3路,4路……真是没完了。

     

     

    这样的优化严重地破坏了程序的优雅性。

     

     

    其他

    另外还有其他的一些比较小方面的,同时让人不爽的优化。

     

     

    1) 将数组版本转换成指针版本有时会快一点(真的是没那么大区别,同学爱怎么写就怎么写,指针有时真是万恶之源!)

     

     

    2) 将sum1中的len显示的放入lenReg中(即:int lenReg = len),为什么呢?因为len被调用时是从栈中取出的,这样显示地放入放入一个寄存器中,省去了循环中每次从内存中取值的过程。(能差多少呢!?)

     

     

    3) 另外小心地设置乘法顺序也能提高程序性能哦!看如下的乘法:

    r = ((r * x) * y) * z; //(a)

    r = (r * (x * y)) * z; //(b)

    r = r * ((x * y) * z); //(c)

    r = r * (x * (y * z)); //(d)

    r = (r * x) * (y * z); //(e)

     

     

    知道哪个最快吗?是(c)和(d),想知道为什么吗?我都懒得讨论如此无聊的问题了。往那该死的并行性和流水性考虑吧。

     

     

    硬币总是有两面的。喷了这么久,还是回到这章的一些优点:1)减少对相同函数的调用,用一个变量保存返回值是一个好办法。2)对于隐性增加复杂度的系统函数,特别是在循环中的函数要注意。3)指针的调用所引起的一些意想不到的效果的注意事项。4)最后利用profile来分析程序瓶颈,着重优化瓶颈函数。5)Amdahl定理。

     

     

    但这章中90%的东西都不应该去学习,学了就真的成了三流程序员了。优化程序首先应当关注其宏观方面:1)数据结构。2)算法。3)对问题刻画和建模的准确性。4)整体结构。5)优雅性。

     

     

    最后也是最重要的一点:能不优化就别优化吧,有时间爱干嘛干嘛去。

  • 相关阅读:
    C# WinForm开发系列
    C# Tcp协议收发数据(TCPClient发,Socket收)
    Tcpclient简单聊天程序
    大白话系列之C#委托与事件讲解大结局
    大白话系列之C#委托与事件讲解(三)
    poj3009
    poj 3083
    poj 2488
    POJ 3320
    poj 3061
  • 原文地址:https://www.cnblogs.com/chkkch/p/2089378.html
Copyright © 2020-2023  润新知