cache优化基础(精华)

一、cache特点六讲
存储器是分层次的,离CPU越近的存储器,速度越快,每字节的成本越高,同时容量也因此越小。寄存器速度最快,离CPU最近,成本最高,所以个数容量有限,其次是高速缓存(缓存也是分级,有L1,L2等缓存),再次是主存(普通内存),再次是本地磁盘。

寄存器的速度最快,可以在一个时钟周期内访问,其次是高速缓存,可以在几个时钟周期内访问,普通内存可以在几十个或几百个时钟周期内访问。

存储器分级,利用的是局部性原理。我们可以以经典的阅读书籍为例。我在读的书,捧在手里(寄存器),我最近频繁阅读的书,放在书桌上(缓存),随时取来读。当然书桌上只能放有限几本书。我更多的书在书架上(内存)。如果书架上没有的书,就去图书馆(磁盘)。我要读的书如果手里没有,那么去书桌上找,如果书桌上没有,去书架上找,如果书架上没有去图书馆去找。可以对应寄存器没有,则从缓存中取,缓存中没有,则从内存中取到缓存,如果内存中没有,则先从磁盘读入内存,再读入缓存,再读入寄存器。

cache系统代表性的包括三种级别:
(1)第一级cache(L1)位于CPU芯片上并且运算于CPU工作频率;
(2)第二级cache(L2)也位于芯片上比L1速度慢而体积大;
(3)第三极cache(L3)位于CPU外部,是速度最慢、体积最大的存储器。

cache是一种快速小型的内存,用以存储最近访问内存位置。这种描述合理而准确,但是更多地了解一些处理器缓存工作中的“烦人”细节对于理解程序运行性能有很大帮助。
1、内存访问
猜下两个不同操作量的函数的运算时间
int[] arr = new int[64 * 1024 * 1024];

// Loop 1
for (int i = 0; i < arr.Length; i++) arr[i] *= 3;

// Loop 2
for (int i = 0; i < arr.Length; i += 16) arr[i] *= 3;
第一个循环将数组的每个值乘3,第二个循环将每16个值乘3,第二个循环只做了第一个约6%的工作,但在现代机器上,两者几乎运行相同时间:在我机器上分别是80毫秒和78毫秒。

控制变量递进测试
for (int i = 0; i < arr.Length; i += K) arr[i] *= 3;
以下为步长为K时的运行时间:

注意当步长在1到16范围内,循环运行时间几乎不变。但从16开始,每次步长加倍,运行时间减半。
背后的原因是今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存,并且访问同一个缓存行内的其它值的开销是很小的。

由于16个整型数占用64字节(一个缓存行),for循环步长在1到16之间必定接触到相同数目的缓存行:即数组中所有的缓存行。当步长为32,我们只有大约每两个缓存行接触一次,当步长为64,只有每四个接触一次。
理解缓存行对某些类型的程序优化而言可能很重要。比如,数据字节对齐可能决定一次操作接触1个还是2个缓存行。那上面的例子来说,很显然操作不对齐的数据将损失一半性能。

一个更直观的例子:
int arr[10][128];

for (i = 0; i < 10; i++)
        for (j = 0; j < 128; j++)
                arr[i][j] = 1;

 
for (i = 0; i < 128; i++)
        for (j = 0; j < 10; j++)
                arr[j][i] = 1;

功能完全一样,但是我们一直在重复着第一种写法(或许很多的书中也是建议这么编码),你是否想过这其中的缘由?cache是如何影响这2段code的呢?

我们先分析下最上面片段1导致的cache miss/hit情况。当执行arr[0][0] = 1时, cache控制器发现arr[0][0]的值不在cache中,此时发生一次cache miss。然后从主存中读取arr[0][0]到arr[0][15] 的内存值到cache中。当执行访问arr[0][1] = 1 时会发生一次cache hit(数组的存取按照行优先的原则进行)。此时内存访问速度极快。接着继续往下执行,会一直cache hit。直到执行arr[0][16] = 1,此时会cache miss。总结来说就是访问内存每发生一次cache miss。接下来会发生15次cache hit。因此这种初始化方法cache命中率很高。

我们再来分析下片段2。当执行arr[0][0] = 1时, cache控制器发现arr[0][0]的值不在cache中,此时发生一次cache miss。然后从主存中读取arr[0][0]到arr[0][15] 的内存值到cache中。当执行访问arr[1][0] = 1 时依然发生一次cache miss。一直执行到arr[9][0] = 1依然是一次cache miss。现在思考下,访问arr[0][1]会是怎么情况呢? 此时就需要考虑cache的大小了。如果cache大小大于数组arr大小,cache此时相当于缓存了整个arr数组的内容。那么后续访问其他元素,确实是cache hit。似乎和片段1代码分析结果差不多。但是如果cache的大小很小,例如只有数组一半大小,那么cache命中率就很明显会降低。同样的cache大小,片段1的代码依然会获得很高的cache命中率。

2、L1,L2和L3缓存
今天的计算机具有两级或三级缓存,通常叫做L1、L2以及可能的L3。如果你想知道不同缓存的大小,你可以使用Linux命令$ lscpu,$ sudo dmidecode -t cache,$ cat /sys/devices/system/cpu/cpu0/cache/index0/size,或者Windows API调用GetLogicalProcessorInfo。两者都将告诉你缓存行以及缓存本身的大小。
在我的机器上,CoreInfo现实我有一个32KB的L1数据缓存,一个32KB的L1指令缓存,还有一个4MB大小L2数据缓存。L1缓存是处理器独享的,L2缓存是成对处理器共享的。
Logical Processor to Cache Map:
— Data Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
— Instruction Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
-
– Data Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
-
– Instruction Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
– Unified Cache 0, Level 2, 4 MB, Assoc 16, LineSize 64
- Data Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64
- Instruction Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64
Data Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64
Instruction Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64
–** Unified Cache 1, Level 2, 4 MB, Assoc 16, LineSize 64
(译者注:作者平台是四核机,所以L1编号为03,数据/指令各一个,L2只有数据缓存,两个处理器共享一个,编号01。关联性字段在后面例子说明。)

让我们通过一个实验来验证这些数字。遍历一个整型数组,每16个值自增1——一种节约地方式改变每个缓存行。当遍历到最后一个值,就重头开始。我们将使用不同的数组大小,可以看到当数组溢出一级缓存大小,程序运行的性能将急剧滑落。
int steps = 64 * 1024 * 1024;
// Arbitrary number of steps
int lengthMod = arr.Length - 1;
for(int i = 0; i < steps; i++)
{
arr[(i * 16) & lengthMod]++; // (x & lengthMod) is equal to (x % arr.Length)
}
下图是运行时间图表:

你可以看到在32KB和4MB之后性能明显滑落——正好是我机器上L1和L2缓存大小。

因此,用L1,L2 cache来处理会极大的提高程序的运行速度,具体的做法见后文。

3、指令级别并发
现在让我们看一看不同的东西。下面两个循环中你以为哪个较快?
int steps = 256 * 1024 * 1024;
int[] a = newint[2];

// Loop 1
for(int i=0; i

// Loop 2
for(int i=0; i 结果是第二个循环约比第一个快一倍,至少在我测试的机器上。为什么呢?这跟两个循环体内的操作指令依赖性有关。
第一个循环体内,操作做是相互依赖的,下一次依赖于前一次:

但第二个例子中,依赖性就不同了:

现代处理器中对不同部分指令拥有一点并发性(跟流水线有关,比如Pentium处理器就有U/V两条流水线,后面说明)。这使得CPU在同一时刻访问L1两处内存位置,或者执行两次简单算术操作。在第一个循环中,处理器无法发掘这种指令级别的并发性,但第二个循环中就可以。

有关编译器优化的问题,像{ a[0]++; a[0]++; }能否优化为{ a[0]+=2; }。实际上,C#编译器和CLR JIT没有做优化——在数组访问方面。我用release模式编译了所有测试(使用优化选项),但我查询了JIT汇编语言证实优化并未影响结果。
4、缓存关联性
此处更适用于完全底层的优化,尤其是高性能存储部分,在日常的编码中不是首先要考虑的问题。

缓存设计的一个关键决定是确保每个主存块(chunk)能够存储在任何一个缓存槽里,或者只是其中一些(译者注:此处一个槽位就是一个缓存行)。
有三种方式将缓存槽映射到主存块中:

  1. 直接映射(Direct mapped cache)
    每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引chunk_index映射到对应的槽位(chunk_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。(译者注:chunk_index可以通过物理地址/缓存行字节计算得到)

  2. N路组关联(N-way set associative cache)
    每个内存块能够被映射到N路特定缓存槽中的任意一路。比如一个16路缓存,每个内存块能够被映射到16路不同的缓存槽。一般地,具有一定相同低bit位地址的内存块将共享16路缓存槽。(译者注:相同低位地址表明相距一定单元大小的连续内存)

  3. 完全关联(Fully associative cache)
    每个内存块能够被映射到任意一个缓存槽。操作效果上相当于一个散列表。
    直接映射缓存会引发冲突——当多个值竞争同一个缓存槽,它们将相互驱逐对方,导致命中率暴跌。另一方面,完全关联缓存过于复杂,并且硬件实现上昂贵。N路组关联是处理器缓存的典型方案,它在电路实现简化和高命中率之间取得了良好的折中。

(此图由译者给出,直接映射和完全关联可以看做N路组关联的两个极端,从图中可知当N=1时,即直接映射;当N取最大值时,即完全关联。读者可以自行想象直接映射图例,具体表述见参考资料。)
举个例子,4MB大小的L2缓存在我机器上是16路关联。所有64字节内存块将分割为不同组,映射到同一组的内存块将竞争L2缓存里的16路槽位。

L2缓存有65,536个缓存行(译者注:4MB/64),每个组需要16路缓存行,我们将获得4096个集。这样一来,块属于哪个组取决于块索引的低12位bit(2^12=4096)。因此缓存行对应的物理地址凡是以262,144字节(4096*64)的倍数区分的,将竞争同一个缓存槽。我机器上最多维持16个这样的缓存槽。(译者注:请结合上图中的2路关联延伸理解,一个块索引对应64字节,chunk0对应组0中的任意一路槽位,chunk1对应组1中的任意一路槽位,以此类推chunk4095对应组4095中的任意一路槽位,chunk0和chunk4096地址的低12bit是相同的,所以chunk4096、chunk8192将同chunk0竞争组0中的槽位,它们之间的地址相差262,144字节的倍数,而最多可以进行16次竞争,否则就要驱逐一个chunk)。

为了使得缓存关联效果更加明了,我需要重复地访问同一组中的16个以上的元素,通过如下方法证明:
public static long UpdateEveryKthByte(byte[] arr, int K)
{
Stopwatch sw = Stopwatch.StartNew();
const int rep = 1024*1024; // Number of iterations – arbitrary
int p = 0;
for (int i = 0; i < rep; i++)
{
arr[p]++;
p += K;
if (p >= arr.Length) p = 0;
}
sw.Stop();
return sw.ElapsedMilliseconds;
}
该方法每次在数组中迭代K个值,当到达末尾时从头开始。循环在运行足够长(2^20次)之后停止。
我使用不同的数组大小(每次增加1MB)和不同的步长传入UpdateEveryKthByte()。以下是绘制的图表,蓝色代表运行较长时间,白色代表较短时间:

蓝色区域(较长时间)表明当我们重复数组迭代时,更新的值无法同时放在缓存中。浅蓝色区域对应80毫秒,白色区域对应10毫秒。

让我们来解释一下图表中蓝色部分:

1.为何有垂直线?垂直线表明步长值过多接触到同一组中内存位置(大于16次)。在这些次数里,我的机器无法同时将接触过的值放到16路关联缓存中。
一些糟糕的步长值为2的幂:256和512。举个例子,考虑512步长遍历8MB数组,存在32个元素以相距262,144字节空间分布,所有32个元素都会在循环遍历中更新到,因为512能够整除262,144(译者注:此处一个步长代表一个字节)。
由于32大于16,这32个元素将一直竞争缓存里的16路槽位。
(译者注:为何512步长的垂直线比256步长颜色更深?在同样足够多的步数下,512比256访问到存在竞争的块索引次数多一倍。比如跨越262,144字节边界512需要512步,而256需要1024步。那么当步数为2^20时,512访问了2048次存在竞争的块而256只有1024次。最差情况下步长为262,144的倍数,因为每次循环都会引发一个缓存行驱逐。)
有些不是2的幂的步长运行时间长仅仅是运气不好,最终访问到的是同一组中不成比例的许多元素,这些步长值同样显示为蓝线。

2.为何垂直线在4MB数组长度的地方停止?因为对于小于等于4MB的数组,16路关联缓存相当于完全关联缓存。
一个16路关联缓存最多能够维护16个以262,144字节分隔的缓存行,4MB内组17或更多的缓存行都没有对齐在262,144字节边界上,因为16*262,144=4,194,304。

3.为何左上角出现蓝色三角?在三角区域内,我们无法在缓存中同时存放所有必要的数据,不是出于关联性,而仅仅是因为L2缓存大小所限。
举个例子,考虑步长128遍历16MB数组,数组中每128字节更新一次,这意味着我们一次接触两个64字节内存块。为了存储16MB数组中每两个缓存行,我们需要8MB大小缓存。但我的机器中只有4MB缓存(译者注:这意味着必然存在冲突从而延时)。
即使我机器中4MB缓存是全关联,仍无法同时存放8MB数据。

4.为何三角最左边部分是褪色的?注意左边0~64字节部分——正好一个缓存行!就像上面示例1和2所说,额外访问相同缓存行的数据几乎没有开销。比如说,步长为16字节,它需要4步到达下一个缓存行,也就是说4次内存访问只有1次开销。
在相同循环次数下的所有测试用例中,采取省力步长的运行时间来得短。
将图表延伸后的模型:

缓存关联性理解起来有趣而且确能被证实,但对于本文探讨的其它问题比起来,它肯定不会是你编程时所首先需要考虑的问题。

5、缓存行的伪共享(false-sharing)
在多核机器上,缓存遇到了另一个问题——一致性。不同的处理器拥有完全或部分分离的缓存。在我的机器上,L1缓存是分离的(这很普遍),而我有两对处理器,每一对共享一个L2缓存。这随着具体情况而不同,如果一个现代多核机器上拥有多级缓存,那么快速小型的缓存将被处理器独占。
当一个处理器改变了属于它自己缓存中的一个值,其它处理器就再也无法使用它自己原来的值,因为其对应的内存位置将被刷新(invalidate)到所有缓存。而且由于缓存操作是以缓存行而不是字节为粒度,所有缓存中整个缓存行将被刷新!
为证明这个问题,考虑如下例子:
private static int[] s_counter = new int[1024];
private void UpdateCounter(int position)
{
for (int j = 0; j < 100000000; j++)
{
s_counter[position] = s_counter[position] + 3;
}
}
在我的四核机上,如果我通过四个线程传入参数0,1,2,3并调用UpdateCounter,所有线程将花费4.3秒。
另一方面,如果我传入16,32,48,64,整个操作进花费0.28秒!
为何会这样?第一个例子中的四个值很可能在同一个缓存行里,每次一个处理器增加计数,这四个计数所在的缓存行将被刷新,而其它处理器在下一次访问它们各自的计数(译者注:注意数组是private属性,每个线程独占)将失去命中(miss)一个缓存。这种多线程行为有效地禁止了缓存功能,削弱了程序性能。

因此,我们在多线程中应让内存操作控制在不同缓存行中进行,避免因伪共享造成的cache miss
6、硬件复杂性
即使你懂得了缓存的工作基础,有时候硬件行为仍会使你惊讶。不用处理器在工作时有不同的优化、探试和微妙的细节。
有些处理器上,L1缓存能够并发处理两路访问,如果访问是来自不同的存储体,而对同一存储体的访问只能串行处理。而且处理器聪明的优化策略也会使你感到惊讶,比如在伪共享的例子中,以前在一些没有微调的机器上运行表现并不良好,但我家里的机器能够对最简单的例子进行优化来减少缓存刷新。
下面是一个“硬件怪事”的奇怪例子:
private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
for (int i = 0; i < 200000000; i++)
{
// do something…
}
}
当我在循环体内进行三种不同操作,我得到如下运行时间:
操作 时间
A++; B++; C++; D++; 719 ms
A++; C++; E++; G++; 448 ms
A++; C++; 518 ms

增加A,B,C,D字段比增加A,C,E,G字段花费更长时间,更奇怪的是,增加A,C两个字段比增加A,C,E,G执行更久!
我无法肯定这些数字背后的原因,但我怀疑这跟存储体有关,如果有人能够解释这些数字,我将洗耳恭听。
这个例子的教训是,你很难完全预测硬件的行为。你可以预测很多事情,但最终,衡量及验证你的假设非常重要。

Goz:我询问Intel的工程师最后的例子,得到以下答复:
“很显然这涉及到执行单元里指令是怎样终止的,机器处理存储-命中-加载的速度,以及如何快速且优雅地处理试探性执行的循环展开(比如是否由于内部冲突而多次循环)。但这意味着你需要非常细致的流水线跟踪器和模拟器才能弄明白。在纸上预测流水线里的乱序指令是无比困难的工作,就算是设计芯片的人也一样。对于门外汉来说,没门,抱歉!”

二、基于cache的优化操作
程序的运行存在时间和空间上的局部性,前者是指只要内存中的值被换入缓存,今后一段时间内会被多次引用,后者是指该内存附近的值也被换入缓存。如果在编程中特别注意运用局部性原理,就会获得性能上的回报。

比如C语言中应该尽量减少静态变量的引用,这是因为静态变量存储在全局数据段,在一个被反复调用的函数体内,引用该变量需要对缓存多次换入换出,而如果是分配在堆栈上的局部变量,函数每次调用CPU只要从缓存中就能找到它了,因为堆栈的重复利用率高。

再比如循环体内的代码要尽量精简,因为代码是放在指令缓存里的,而指令缓存都是一级缓存,只有几K字节大小,如果对某段代码需要多次读取,而这段代码又跨越一个L1缓存大小,那么缓存优势将荡然无存。

关于CPU的流水线(pipeline)并发性简单说说,Intel Pentium处理器有两条流水线U和V,每条流水线可各自独立地读写缓存,所以可以在一个时钟周期内同时执行两条指令。但这两条流水线不是对等的,U流水线可以处理所有指令集,V流水线只能处理简单指令。

CPU指令通常被分为四类,第一类是常用的简单指令,像mov, nop, push, pop, add, sub, and, or, xor, inc, dec, cmp, lea,可以在任意一条流水线执行,只要相互之间不存在依赖性,完全可以做到指令并发。
第二类指令需要同别的流水线配合,像一些进位和移位操作,这类指令如果在U流水线中,那么别的指令可以在V流水线并发运行,如果在V流水线中,那么U流水线是暂停的。
第三类指令是一些跳转指令,如cmp,call以及条件分支,它们同第二类相反,当工作在V流水线时才能通U流水线协作,否则只能独占CPU。
第四类指令是其它复杂的指令,一般不常用,因为它们都只能独占CPU。
如果是汇编级别编程,要达到指令级别并发,必须要注重指令之间的配对。尽量使用第一类指令,避免第四类,还要在顺序上减少上下文依赖。

此外,还有一些总结类型的优化操作:
代码层次的优化是最直接,也是最简单的,但前提是要对代码很熟悉,对系统很熟悉。很多事情做到后来,都是一句话:无他,但手熟尔-
在展开这个话题之前,有必要先简单介绍一下Cache相关的内容,如果对这部分内容不熟悉,建议先补补课,做性能优化对Cache不了解,基本上就是盲人骑瞎马。

Cache的关注点

Cache一般来说,需要关心以下几个方面
1)Cache hierarchy
Cache的层次,一般有L1, L2, L3 (L是level的意思)的cache。通常来说L1,L2是集成 在CPU里面的(可以称之为On-chip cache),而L3是放在CPU外面(可以称之为Off-chip cache)。当然这个不是绝对的,不同CPU的做法可能会不太一样。这里面应该还需要加上 register,虽然register不是cache,但是把数据放到register里面是能够提高性能的。
2)Cache size
Cache的容量决定了有多少代码和数据可以放到Cache里面,有了Cache才有了竞争,才有 了替换,才有了优化的空间。如果一个程序的热点(hotspot)已经完全填充了整个Cache,那 么再从Cache角度考虑优化就是白费力气了,巧妇难为无米之炊。我们优化程序的目标是把 程序尽可能放到Cache里面,但是把程序写到能够占满整个Cache还是有一定难度的,这么大 的一个Code path,相应的代码得有多少,代码逻辑肯定是相当的复杂(基本上是不可能,至少 我没有见过)。
3)Cache line size
CPU从内存load数据是一次一个cache line;往内存里面写也是一次一个cache line,所以一个 cache line里面的数据最好是读写分开,否则就会相互影响。
4)Cache associative
Cache的关联。有全关联(full associative),内存可以映射到任意一个Cache line;也有N-way 关联,这个就是一个哈希表的结构,N就是冲突链的长度,超过了N,就需要替换。
5)Cache type
有I-cache(指令cache),D-cache(数据cache),TLB(MMU的cache),每一种又有L1, L2等等,有区分指令和数据的cache,也有不区分指令和数据的cache。
更多与cache相关的知识,可以参考这个链接:
http://en.wikipedia.org/wiki/CPU_cache

代码层次的优化

主要是从以下两个角度考虑问题:
(1)I-cache相关的优化
例如精简code path,简化调用关系,减少冗余代码等等。尽量减少不必要的调用。但是有用还是无用,是和应用相关的,所以代码层次的优化很多是针对某个应用或者性能指标的优化。有针对性的优化,更容易得到可观的结果。
(2)D-cache相关的优化
减少D-cache miss的数量,增加有效的数据访问的数量。这个要比I-cache优化难一些。

下面是一个代码优化技巧列表,需要不断地补充,优化和筛选。
(1) Code adjacency (把相关代码放在一起),推荐指数:5颗星
把相关代码放在一起有两个涵义,一是相关的源文件要放在一起;二是相关的函数在object文件 里面,也应该是相邻的。这样,在可执行文件被加载到内存里面的时候,函数的位置也是相邻的。 相邻的函数,冲突的几率比较小。而且相关的函数放在一起,也符合模块化编程的要求:那就是 高内聚,低耦合。
如果能够把一个code path上的函数编译到一起(需要编译器支持,把相关函数编译到一起), 很显然会提高I-cache的命中率,减少冲突。但是一个系统有很多个code path,所以不可能面 面俱到。不同的性能指标,在优化的时候可能是冲突的。所以尽量做对所以case都有效的优化, 虽然做到这一点比较难。

(2) Cache line alignment (cache对齐),推荐指数:4颗星
数据跨越两个cache line,就意味着两次load或者两次store。如果数据结构是cache line对齐的, 就有可能减少一次读写。数据结构的首地址cache line对齐,意味着可能有内存浪费(特别是 数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。

(3) Branch prediction (分支预测),推荐指数:3颗星
代码在内存里面是顺序排列的。对于分支程序来说,如果分支语句之后的代码有更大的执行几率, 那么就可以减少跳转,一般CPU都有指令预取功能,这样可以提高指令预取命中的几率。分支预测 用的就是likely/unlikely这样的宏,一般需要编译器的支持,这样做是静态的分支预测。现在也有 很多CPU支持在CPU内部保存执行过的分支指令的结果(分支指令的cache),所以静态的分支预测 就没有太多的意义。如果分支是有意义的,那么说明任何分支都会执行到,所以在特定情况下,静态 分支预测的结果并没有多好,而且likely/unlikely对代码有很大的侵害(影响可读性),所以一般不 推荐使用这个方法。

(4) Data prefetch (数据预取),推荐指数:4颗星
指令预取是CPU自动完成的,但是数据预取就是一个有技术含量的工作。数据预取的依据是预取的数据 马上会用到,这个应该符合空间局部性(spatial locality),但是如何知道预取的数据会被用到,这个 要看上下文的关系。一般来说,数据预取在循环里面用的比较多,因为循环是最符合空间局部性的代码。
但是数据预取的代码本身对程序是有侵害的(影响美观和可读性),而且优化效果不一定很明显(命中 的概率)。数据预取可以填充流水线,避免访问内存的等待,还是有一定的好处的。

(5) Memory coloring (内存着色),推荐指数:不推荐
内存着色属于系统层次的优化,在代码优化阶段去考虑内存着色,有点太晚了。所以这个话题可以放到 系统层次优化里面去讨论。

(6)Register parameters (寄存器参数),推荐指数:4颗星
寄存器做为速度最快的内存单元,不好好利用实在是浪费。但是,怎么用?一般来说,函数调用的参数 少于某个数,比如3,参数是通过寄存器传递的(这个要看ABI的约定)。所以,写函数的时候,不要 带那么多参数。c语言里还有一个register关键词,不过通常都没什么用处(没试过,不知道效果,不过 可以反汇编看看具体的指令,估计是和编译器相关)。尝试从寄存器里面读取数据,而不是内存。

(7) Lazy computation (延迟计算),推荐指数:5颗星
延迟计算的意思是最近用不上的变量,就不要去初始化。通常来说,在函数开始就会初始化很多数据,但是 这些数据在函数执行过程中并没有用到(比如一个分支判断,就退出了函数),那么这些动作就是浪费了。
变量初始化是一个好的编程习惯,但是在性能优化的时候,有可能就是一个多余的动作,需要综合考虑函数 的各个分支,做出决定。
延迟计算也可以是系统层次的优化,比如COW(copy-on-write)就是在fork子进程的时候,并没有复制父 进程所有的页表,而是只复制指令部分。当有写发生的时候,再复制数据部分,这样可以避免不必要的复制, 提供进程创建的速度。

(8) Early computation (提前计算),推荐指数:5颗星
有些变量,需要计算一次,多次使用的时候。最好是提前计算一下,保存结果,以后再引用,避免每次都 重新计算一次。函数多了,有时就会忽略这个函数都做了些什么,写程序的人可以不了解,但是优化的时候 不能不了解。能使用常数的地方,尽量使用常数,加减乘除都会消耗CPU的指令,不可不查。

(9)Inline or not inline (inline函数),推荐指数:5颗星
Inline or not inline,这是个问题。Inline可以减少函数调用的开销(入栈,出栈的操作),但是inline也 有可能造成大量的重复代码,使得代码的体积变大。Inline对debug也有坏处(汇编和语言对不上)。所以 用这个的时候要谨慎。小的函数(小于10行),可以尝试用inline;调用次数多的或者很长的函数,尽量不 要用inline。

(10) Macro or not macro (宏定义或者宏函数),推荐指数:5颗星
Macro和inline带来的好处,坏处是一样的。但我的感觉是,可以用宏定义,不要用宏函数。用宏写函数, 会有很多潜在的危险。宏要简单,精炼,最好是不要用。中看不中用。

(11) Allocation on stack (局部变量),推荐指数:5颗星
如果每次都要在栈上分配一个1K大小的变量,这个代价是不是太大了哪?如果这个变量还需要初始化(因 为值是随机的),那是不是更浪费了。全局变量好的一点是不需要反复的重建,销毁;而局部变量就有这个 坏处。所以避免在栈上使用数组等变量。
(但是在cache优化中,则建议少用全局变量,因为全局存储在静态存储区,每次调用都会发生一次cache miss,,此外全局变量在多核多线程问题中还会导致不同cache中的同步消耗,会造成的性能损还是者优化需要具体情况具体考虑)

(12) Multiple conditions (多个条件的判断语句),推荐指数:3颗星
多个条件判断时,是一个逐步缩小范围的过程。条件的先后,决定了前面的判断是否多余的。根据code path 的情况和条件分支的几率,调整条件的顺序,可以在一定程度上减少code path的开销。但是这个工作做 起来有点难度,所以通常不推荐使用。

(13) Per-cpu data structure (非共享的数据结构),推荐指数:5颗星
Per-cpu data structure 在多核,多CPU或者多线程编程里面一个通用的技巧。使用Per-cpu data structure的目的是避免共享变量的锁,使得每个CPU可以独立访问数据而与其他CPU无关。坏处是会 消耗大量的内存,而且并不是所有的变量都可以per-cpu化。并行是多核编程追求的目标,而串行化 是多核编程里面最大的伤害。有关并行和串行的话题,在系统层次优化里面还会提到。
局部变量肯定是thread local的,所以在多核编程里面,局部变量反而更有好处。

(14) 64 bits counter in 32 bits environment (32位环境里的64位counter),推荐指数:5颗星
32位环境里面用64位counter很显然会影响性能,所以除非必要,最好别用。有关counter的优化可以多 说几句。counter是必须的,但是还需要慎重的选择,避免重复的计数。关键路径上的counter可以使用 per-cpu counter,非关键路径(exception path)就可以省一点内存。

(15) Reduce call path or call trace (减少函数调用的层次),推荐指数:4颗星
函数越多,有用的事情做的就越少(函数的入栈,出栈等)。所以要减少函数的调用层次。但是不应该 破坏程序的美观和可读性。个人认为好程序的首要标准就是美观和可读性。不好看的程序读起来影响心 情。所以需要权衡利弊,不能一个程序就一个函数。

(16) Move exception path out (把exception处理放到另一个函数里面),推荐指数:5颗星
把exception path(异常路径)和critical path(关键路径:指设计中从输入到输出经过的延时最长的逻辑路径)放到一起(代码混合在一起),就会影响critical path的cache性能。 而很多时候,exception path都是长篇大论,有点喧宾夺主的感觉。如果能把critical path和 exception path完全分离开,这样对i-cache有很大帮助。

(17) Read, write split (读写分离),推荐指数:5颗星
在cache.pdf里面提到了伪共享(false sharing),就是说两个无关的变量,一个读,一个写,而这 两个变量在一个cache line里面。那么写会导致cache line失效(通常是在多核编程里面,两个变量 在不同的core上引用)。读写分离是一个很难运用的技巧,特别是在code很复杂的情况下。需要 不断地调试,是个力气活(如果有工具帮助会好一点,比如cache miss时触发cpu的execption处理 之类的)。

(18) Reduce duplicated code(减少冗余代码),推荐指数:5颗星
代码里面的冗余代码和死代码(dead code)很多。减少冗余代码就是减小浪费。但冗余代码有时 又是必不可少(copy-paste太多,尾大不掉,不好改了),但是对critical path,花一些功夫还 是必要的。
代码优化有时与编程规则是冲突的,比如直接访问成员变量,还是通过接口来访问。编程规则上肯定是说要通过接口来访问,但直接访问效率更高。还有就是许多ASSERT之类的代码,加的多了,也影响性能,但是不加又会给debug带来麻烦。所以需要权衡。代码层次的优化是基本功课,但是指望代码层次的优化来解决所有问题,无疑是缘木求鱼。从系统层次和算法层次考虑问题,可能效果会更好。
代码层次的优化需要相关工具的配合,没有工具,将会事倍功半。所以在优化之前,先把工具准备好。有关工具的话题,会在另一篇文章里面讲。
还有什么,需要好好想想。这些优化技巧都是与c语言相关的。对于其他语言不一定适用。每个语言都有一些与性能相关的编码规范和约定俗成,遵守就可以了。有很多Effective, Exceptional 系列的书籍,可以看看。

你可能感兴趣的:(性能优化,后端,性能优化,linux,ubuntu,缓存)