“你的时间有限,所以不要为别人而活。不要被教条所限,不要活在别人的观念里。不要让别人的意见左右自己内心的声音。最重要的是,勇敢的去追随自己的心灵和直觉,只有自己的心灵和直觉才知道你自己的真实想法,其他一切都是次要。 ——史蒂夫·乔布斯”
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: }
len = 10^8时,sum3相对于sum2提高了172ms(k=1.74),相对sum1提高312ms(k=2.33)。为什么会有这种提高?可以看到在循环中步长变为了3,那么为什么要是3而不是其他呢?
要讲清楚就不得不先讲讲这幅图:
这幅图是什么呢?就是讲在一个理想的国度,世界上的资源是无穷无尽的,包括计算机中的硬件资源。于是呢,每个周期我们都有无限的计算器件可用。看到在第三周期用到了jl, compl, incl的硬件资源,而这三个硬件其实都是要用到加法器的。因为有无限的器件所以没事,同时此时效率当然是最高的。
可是,现实总是很残酷的。漂亮小姑娘总是有限,计算机的硬件也不可能无限多。神告诉我们,你只能有两个加法器,不可太贪。于是在同一周期,我们只能有两个涉及加法的指令。那么就有了如下的现实中的计算版本。显然效率要比理想版本差,效率拖后约1倍。
很自然的,如何用手头有限的资源创造最大的财富,就是我们关心的。看到load指令的周期是一般指令的3倍,而load是可以流水执行的,那么尽量让load干活就是我们所期望的。如何改进呢?你可能想到了。邪恶的sum3版本登场:
图中目的很明显,尽量减少addl, compl和jl(ACJ)这些指令的执行,这样就不至于太受加法器资源的限制,另外尽量利用load的流水特性。那么如何减少ACJ的执行呢?终于想到了加大每次步长了吧。又为什么是3呢?想到了load的周期是3了吧。谜团终于解开,看看最终版本的sum3是如何在机器中执行的:
看到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计算奇数下标。最后合并。看看到底有多快:
Len= 10^8,差异140ms, k=1.43,加速实在有限,而且还是在数据规模这么大的时候。另外,由于之前的loop unrolling技术也基本上能猜出个大概了。乘法器件耗费的周期巨大,又由于其流水特性。所以考虑增加每次循环中的乘法次数,而不必等到乘积结果迭代到下次而产生的延时。
这又是一个极度依赖特定机器的优化。这里我们可以猜到有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)优雅性。
最后也是最重要的一点:能不优化就别优化吧,有时间爱干嘛干嘛去。