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)优雅性。

 

 

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

posted @ 2011-06-24 22:11  chkkch  阅读(22033)  评论(29编辑  收藏  举报