对程序优化进行,一般是采用良好的算法,合理使用编程语法,但有些时候我们也要考虑硬件的问题,比如CPU cache的优化。
CPU cache对性能会有什么影响呢,先考虑一个例子:
假设我们需要对一个数组求和,代码如下:
int m = 1;
for (int i = 0; i < n; i += m) {
r += data[i];
}
假设上面的代码执行花了1s
那么如果我们只对其中一半数据求和,也就是隔一个加一下
int m = 2;
for (int i = 0; i < n; i += m) {
r += data[i];
}
我们可以猜测由于循环次数少了一般,所以上面代码执行所花时间应该也只有一半,是0.5s
照此类推,当m=4时,执行时间只有0.25s
m=8,执行时间只有0.125s
但实际情况如何?下面是测试结果
m 时间
1 1
2 0.927806
4 0.88343
8 0.875056
15 0.868057
16 0.865811
17 0.821803
31 0.540216
32 0.483735
33 0.471835
63 0.259147
64 0.259133
65 0.252516
127 0.142182
128 0.15844
129 0.135987
255 0.0777359
256 0.0704783
257 0.0746177
我们可以看到,程序的执行时间并不随着m的变大而成比例下降,直到m=32时,执行时间才下降一半
这里就是CPU cache的效果。在我的机器上,cache_alignment 的大小是64,也就是当我读取内存一个int数据时,CPU会把前后处于同一cache line中的16个int数据从内存中载入cache,在CPU执行指令很简单的情况下,这个加载过程占了主要时间。
所有当m=32,64,128等时候,程序可以对应减少1/2,3/4,7/8的cache line的加载,执行时间才减少到1/2,1/4,1/8。
----------------------------------------------------------------------------------------------------
下面是从网上找到文章,也放到这了
http://www.cnblogs.com/kbasm/archive/2011/05/31/cpu-cache-old-optimization-skill-not-work.html
面对处理器缓存,一些旧有的性能优化技巧已然失效
请注意,本文不是讲解处理器缓存,如果你对cpu cache这个概念不清楚,请先Google一下。
另外,本文主要针对像 C,C++ 这种产生机器码的语言的,对于像 Java,.Net 这样的字节码语言,这里所说的可能无效,至少我没研究过。
首先说说我所说的这些旧有的优化技巧从哪里来的。
原因很简单,如果你像我一样,多年只用 J2ME,或者 Flash 这样的技术开发,你是不太可能会关心处理器缓存的,而是用一些其它的性能技巧,这些技巧遇到处理器缓存问题,就失效了。
再如果你的CPU,汇编,优化知识像我一样仍停留在 80386 时代,你我掌握的优化技巧断然也是过时的。
失效技巧一,使用预先计算好的变量或者查找表
现在来怎么用查找表来计算一个32位整数里位为1的个数。
static const unsigned char BitsSetTable256[256] =
{
// 预先计算好的256个8位数的1的个数
};
int calculateBitsCount(unsigned int n)
{
unsigned char * p = (unsigned char *)&n;
return BitsSetTable256[p[0]] +
BitsSetTable256[p[1]] +
BitsSetTable256[p[2]] +
BitsSetTable256[p[3]];
}
很酷,是吧,只用了四次加法运算,我们可以想当然地认为这个算法比那些充满乘除法甚至循环的算法快。
但当有了CPU的数据缓存,情况不一样了。当 calculateBitsCount 第一次取 BitsSetTable256 数据,很有可能导致数据缓存清空重新加载 BitsSetTable256 位置的内存,会导致浪费上百指令周期,而这上百指令周期,足够用普通方法计算位数了。
比如下面这个算法,来自
~seander/bithacks.html
unsigned int v; // count the number of bits set in v
unsigned int c; // c accumulates the total bits set in v
for (c = 0; v; c++)
{
v &= v - 1; // clear the least significant bit set
}
这个算法看似比上面查找表算法多了很多指令,还有循环,但要记住指令成本比数据成本低非常非常多(指令数量很多超出指令缓存的除外),值票价!确实值票价,因为我用这个算法替代查找表以后,确实快了。
失效技巧二,用局部变量来缓存所操作对象的成员变量
请注意,这个技巧在大多数情况下是有效的,这里只是说明某些情况下会失效。
比如有这样一个函数,
void func(SomeObject * obj)
{
int i, k, p;
int count = obj->getCount();
for(i = 0; i < 100; ++i) {
for(int k = 0; k < 100; ++k) {
for(int p = 0; p < count; ++p) {
// 处理 obj 的数据
}
}
}
}
假设 getCount 只是取一个数值。
这看起来很好,很完美,但仔细看却有一个问题。假如所有局部变量都能被放在寄存器,没有问题。但如果 count 不能被放到寄存器里呢?那么每次循环 count 都要从堆栈内存里读取,但同时又要处理 obj 的数据,这两部分极有可能不在一个数据缓存里,这就会导致
频繁的数据缓存交换,慢!
如果抛弃 count,而把最内层循环改成
for(int p = 0; p < obj->getCount(); ++p) {
// 处理 obj 的数据
}
因为读取的数据都在 obj 范围内,如果都在数据缓存范围里,那就会相当快。
失效技巧三,在一个循环里干所有事
我们可能老觉得循环是慢的,因为还要跳转,所以我们宁愿在一个循环里把所有事都做了。
ObjectA * objA;
ObjectB * objB;
for(int i = 0; i < 100; ++i) {
// 对 objA 做点事
// 对 objA 做点别的事
// 对 objB 做点事
}
这有两个问题:
1,一旦循环体里的代码长度超过指令缓存,那么每次循环都要导致指令缓存动荡,无论 CPU 有几级缓存,L1 被清空重新装载,总归比直接命中 L1 缓存慢。
2,更麻烦的事,循环里在两个数据块操作,除非两个对象恰好分配的很近,否则必然导致数据缓存的动荡,慢。
如果把循环切分,
ObjectA * objA;
ObjectB * objB;
for(int i = 0; i < 100; ++i) {
// 对 objA 做点事
}
for(int i = 0; i < 100; ++i) {
// 对 objA 做点别的事
}
for(int i = 0; i < 100; ++i) {
// 对 objB 做点事
}
则指令缓存和数据缓存都会觉得很高兴,自然也就工作快一点了。
总结:
虽然上面这些技巧会失效,并不意味这些技巧是错的,很多情况下也可能真的有效。而且处理器缓存这东西优化起来不定因素很大,并无不变之规,所以具体做时还要仔细测试,方能知道哪种方法好。