一、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 // Loop 2 但第二个例子中,依赖性就不同了: 现代处理器中对不同部分指令拥有一点并发性(跟流水线有关,比如Pentium处理器就有U/V两条流水线,后面说明)。这使得CPU在同一时刻访问L1两处内存位置,或者执行两次简单算术操作。在第一个循环中,处理器无法发掘这种指令级别的并发性,但第二个循环中就可以。 有关编译器优化的问题,像{ a[0]++; a[0]++; }能否优化为{ a[0]+=2; }。实际上,C#编译器和CLR JIT没有做优化——在数组访问方面。我用release模式编译了所有测试(使用优化选项),但我查询了JIT汇编语言证实优化并未影响结果。 缓存设计的一个关键决定是确保每个主存块(chunk)能够存储在任何一个缓存槽里,或者只是其中一些(译者注:此处一个槽位就是一个缓存行)。 直接映射(Direct mapped cache) N路组关联(N-way set associative cache) 完全关联(Fully associative cache) (此图由译者给出,直接映射和完全关联可以看做N路组关联的两个极端,从图中可知当N=1时,即直接映射;当N取最大值时,即完全关联。读者可以自行想象直接映射图例,具体表述见参考资料。) 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个以上的元素,通过如下方法证明: 让我们来解释一下图表中蓝色部分: 1.为何有垂直线?垂直线表明步长值过多接触到同一组中内存位置(大于16次)。在这些次数里,我的机器无法同时将接触过的值放到16路关联缓存中。 2.为何垂直线在4MB数组长度的地方停止?因为对于小于等于4MB的数组,16路关联缓存相当于完全关联缓存。 3.为何左上角出现蓝色三角?在三角区域内,我们无法在缓存中同时存放所有必要的数据,不是出于关联性,而仅仅是因为L2缓存大小所限。 4.为何三角最左边部分是褪色的?注意左边0~64字节部分——正好一个缓存行!就像上面示例1和2所说,额外访问相同缓存行的数据几乎没有开销。比如说,步长为16字节,它需要4步到达下一个缓存行,也就是说4次内存访问只有1次开销。 缓存关联性理解起来有趣而且确能被证实,但对于本文探讨的其它问题比起来,它肯定不会是你编程时所首先需要考虑的问题。 5、缓存行的伪共享(false-sharing) 因此,我们在多线程中应让内存操作控制在不同缓存行中进行,避免因伪共享造成的cache miss 增加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,可以在任意一条流水线执行,只要相互之间不存在依赖性,完全可以做到指令并发。 此外,还有一些总结类型的优化操作: Cache的关注点 Cache一般来说,需要关心以下几个方面 代码层次的优化 主要是从以下两个角度考虑问题: 下面是一个代码优化技巧列表,需要不断地补充,优化和筛选。 (2) Cache line alignment (cache对齐),推荐指数:4颗星 (3) Branch prediction (分支预测),推荐指数:3颗星 (4) Data prefetch (数据预取),推荐指数:4颗星 (5) Memory coloring (内存着色),推荐指数:不推荐 (6)Register parameters (寄存器参数),推荐指数:4颗星 (7) Lazy computation (延迟计算),推荐指数:5颗星 (8) Early computation (提前计算),推荐指数:5颗星 (9)Inline or not inline (inline函数),推荐指数:5颗星 (10) Macro or not macro (宏定义或者宏函数),推荐指数:5颗星 (11) Allocation on stack (局部变量),推荐指数:5颗星 (12) Multiple conditions (多个条件的判断语句),推荐指数:3颗星 (13) Per-cpu data structure (非共享的数据结构),推荐指数:5颗星 (14) 64 bits counter in 32 bits environment (32位环境里的64位counter),推荐指数:5颗星 (15) Reduce call path or call trace (减少函数调用的层次),推荐指数:4颗星 (16) Move exception path out (把exception处理放到另一个函数里面),推荐指数:5颗星 (17) Read, write split (读写分离),推荐指数:5颗星 (18) Reduce duplicated code(减少冗余代码),推荐指数:5颗星
for(int i=0; i
for(int i=0; i
第一个循环体内,操作做是相互依赖的,下一次依赖于前一次:
4、缓存关联性
此处更适用于完全底层的优化,尤其是高性能存储部分,在日常的编码中不是首先要考虑的问题。
有三种方式将缓存槽映射到主存块中:
每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引chunk_index映射到对应的槽位(chunk_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。(译者注:chunk_index可以通过物理地址/缓存行字节计算得到)
每个内存块能够被映射到N路特定缓存槽中的任意一路。比如一个16路缓存,每个内存块能够被映射到16路不同的缓存槽。一般地,具有一定相同低bit位地址的内存块将共享16路缓存槽。(译者注:相同低位地址表明相距一定单元大小的连续内存)
每个内存块能够被映射到任意一个缓存槽。操作效果上相当于一个散列表。
直接映射缓存会引发冲突——当多个值竞争同一个缓存槽,它们将相互驱逐对方,导致命中率暴跌。另一方面,完全关联缓存过于复杂,并且硬件实现上昂贵。N路组关联是处理器缓存的典型方案,它在电路实现简化和高命中率之间取得了良好的折中。
举个例子,4MB大小的L2缓存在我机器上是16路关联。所有64字节内存块将分割为不同组,映射到同一组的内存块将竞争L2缓存里的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毫秒。
一些糟糕的步长值为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的幂的步长运行时间长仅仅是运气不好,最终访问到的是同一组中不成比例的许多元素,这些步长值同样显示为蓝线。
一个16路关联缓存最多能够维护16个以262,144字节分隔的缓存行,4MB内组17或更多的缓存行都没有对齐在262,144字节边界上,因为16*262,144=4,194,304。
举个例子,考虑步长128遍历16MB数组,数组中每128字节更新一次,这意味着我们一次接触两个64字节内存块。为了存储16MB数组中每两个缓存行,我们需要8MB大小缓存。但我的机器中只有4MB缓存(译者注:这意味着必然存在冲突从而延时)。
即使我机器中4MB缓存是全关联,仍无法同时存放8MB数据。
在相同循环次数下的所有测试用例中,采取省力步长的运行时间来得短。
将图表延伸后的模型:
在多核机器上,缓存遇到了另一个问题——一致性。不同的处理器拥有完全或部分分离的缓存。在我的机器上,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)一个缓存。这种多线程行为有效地禁止了缓存功能,削弱了程序性能。
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
我无法肯定这些数字背后的原因,但我怀疑这跟存储体有关,如果有人能够解释这些数字,我将洗耳恭听。
这个例子的教训是,你很难完全预测硬件的行为。你可以预测很多事情,但最终,衡量及验证你的假设非常重要。
“很显然这涉及到执行单元里指令是怎样终止的,机器处理存储-命中-加载的速度,以及如何快速且优雅地处理试探性执行的循环展开(比如是否由于内部冲突而多次循环)。但这意味着你需要非常细致的流水线跟踪器和模拟器才能弄明白。在纸上预测流水线里的乱序指令是无比困难的工作,就算是设计芯片的人也一样。对于门外汉来说,没门,抱歉!”
程序的运行存在时间和空间上的局部性,前者是指只要内存中的值被换入缓存,今后一段时间内会被多次引用,后者是指该内存附近的值也被换入缓存。如果在编程中特别注意运用局部性原理,就会获得性能上的回报。
第二类指令需要同别的流水线配合,像一些进位和移位操作,这类指令如果在U流水线中,那么别的指令可以在V流水线并发运行,如果在V流水线中,那么U流水线是暂停的。
第三类指令是一些跳转指令,如cmp,call以及条件分支,它们同第二类相反,当工作在V流水线时才能通U流水线协作,否则只能独占CPU。
第四类指令是其它复杂的指令,一般不常用,因为它们都只能独占CPU。
如果是汇编级别编程,要达到指令级别并发,必须要注重指令之间的配对。尽量使用第一类指令,避免第四类,还要在顺序上减少上下文依赖。
代码层次的优化是最直接,也是最简单的,但前提是要对代码很熟悉,对系统很熟悉。很多事情做到后来,都是一句话:无他,但手熟尔-。
在展开这个话题之前,有必要先简单介绍一下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都有效的优化, 虽然做到这一点比较难。
数据跨越两个cache line,就意味着两次load或者两次store。如果数据结构是cache line对齐的, 就有可能减少一次读写。数据结构的首地址cache line对齐,意味着可能有内存浪费(特别是 数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。
代码在内存里面是顺序排列的。对于分支程序来说,如果分支语句之后的代码有更大的执行几率, 那么就可以减少跳转,一般CPU都有指令预取功能,这样可以提高指令预取命中的几率。分支预测 用的就是likely/unlikely这样的宏,一般需要编译器的支持,这样做是静态的分支预测。现在也有 很多CPU支持在CPU内部保存执行过的分支指令的结果(分支指令的cache),所以静态的分支预测 就没有太多的意义。如果分支是有意义的,那么说明任何分支都会执行到,所以在特定情况下,静态 分支预测的结果并没有多好,而且likely/unlikely对代码有很大的侵害(影响可读性),所以一般不 推荐使用这个方法。
指令预取是CPU自动完成的,但是数据预取就是一个有技术含量的工作。数据预取的依据是预取的数据 马上会用到,这个应该符合空间局部性(spatial locality),但是如何知道预取的数据会被用到,这个 要看上下文的关系。一般来说,数据预取在循环里面用的比较多,因为循环是最符合空间局部性的代码。
但是数据预取的代码本身对程序是有侵害的(影响美观和可读性),而且优化效果不一定很明显(命中 的概率)。数据预取可以填充流水线,避免访问内存的等待,还是有一定的好处的。
内存着色属于系统层次的优化,在代码优化阶段去考虑内存着色,有点太晚了。所以这个话题可以放到 系统层次优化里面去讨论。
寄存器做为速度最快的内存单元,不好好利用实在是浪费。但是,怎么用?一般来说,函数调用的参数 少于某个数,比如3,参数是通过寄存器传递的(这个要看ABI的约定)。所以,写函数的时候,不要 带那么多参数。c语言里还有一个register关键词,不过通常都没什么用处(没试过,不知道效果,不过 可以反汇编看看具体的指令,估计是和编译器相关)。尝试从寄存器里面读取数据,而不是内存。
延迟计算的意思是最近用不上的变量,就不要去初始化。通常来说,在函数开始就会初始化很多数据,但是 这些数据在函数执行过程中并没有用到(比如一个分支判断,就退出了函数),那么这些动作就是浪费了。
变量初始化是一个好的编程习惯,但是在性能优化的时候,有可能就是一个多余的动作,需要综合考虑函数 的各个分支,做出决定。
延迟计算也可以是系统层次的优化,比如COW(copy-on-write)就是在fork子进程的时候,并没有复制父 进程所有的页表,而是只复制指令部分。当有写发生的时候,再复制数据部分,这样可以避免不必要的复制, 提供进程创建的速度。
有些变量,需要计算一次,多次使用的时候。最好是提前计算一下,保存结果,以后再引用,避免每次都 重新计算一次。函数多了,有时就会忽略这个函数都做了些什么,写程序的人可以不了解,但是优化的时候 不能不了解。能使用常数的地方,尽量使用常数,加减乘除都会消耗CPU的指令,不可不查。
Inline or not inline,这是个问题。Inline可以减少函数调用的开销(入栈,出栈的操作),但是inline也 有可能造成大量的重复代码,使得代码的体积变大。Inline对debug也有坏处(汇编和语言对不上)。所以 用这个的时候要谨慎。小的函数(小于10行),可以尝试用inline;调用次数多的或者很长的函数,尽量不 要用inline。
Macro和inline带来的好处,坏处是一样的。但我的感觉是,可以用宏定义,不要用宏函数。用宏写函数, 会有很多潜在的危险。宏要简单,精炼,最好是不要用。中看不中用。
如果每次都要在栈上分配一个1K大小的变量,这个代价是不是太大了哪?如果这个变量还需要初始化(因 为值是随机的),那是不是更浪费了。全局变量好的一点是不需要反复的重建,销毁;而局部变量就有这个 坏处。所以避免在栈上使用数组等变量。
(但是在cache优化中,则建议少用全局变量,因为全局存储在静态存储区,每次调用都会发生一次cache miss,,此外全局变量在多核多线程问题中还会导致不同cache中的同步消耗,会造成的性能损还是者优化需要具体情况具体考虑)
多个条件判断时,是一个逐步缩小范围的过程。条件的先后,决定了前面的判断是否多余的。根据code path 的情况和条件分支的几率,调整条件的顺序,可以在一定程度上减少code path的开销。但是这个工作做 起来有点难度,所以通常不推荐使用。
Per-cpu data structure 在多核,多CPU或者多线程编程里面一个通用的技巧。使用Per-cpu data structure的目的是避免共享变量的锁,使得每个CPU可以独立访问数据而与其他CPU无关。坏处是会 消耗大量的内存,而且并不是所有的变量都可以per-cpu化。并行是多核编程追求的目标,而串行化 是多核编程里面最大的伤害。有关并行和串行的话题,在系统层次优化里面还会提到。
局部变量肯定是thread local的,所以在多核编程里面,局部变量反而更有好处。
32位环境里面用64位counter很显然会影响性能,所以除非必要,最好别用。有关counter的优化可以多 说几句。counter是必须的,但是还需要慎重的选择,避免重复的计数。关键路径上的counter可以使用 per-cpu counter,非关键路径(exception path)就可以省一点内存。
函数越多,有用的事情做的就越少(函数的入栈,出栈等)。所以要减少函数的调用层次。但是不应该 破坏程序的美观和可读性。个人认为好程序的首要标准就是美观和可读性。不好看的程序读起来影响心 情。所以需要权衡利弊,不能一个程序就一个函数。
把exception path(异常路径)和critical path(关键路径:指设计中从输入到输出经过的延时最长的逻辑路径)放到一起(代码混合在一起),就会影响critical path的cache性能。 而很多时候,exception path都是长篇大论,有点喧宾夺主的感觉。如果能把critical path和 exception path完全分离开,这样对i-cache有很大帮助。
在cache.pdf里面提到了伪共享(false sharing),就是说两个无关的变量,一个读,一个写,而这 两个变量在一个cache line里面。那么写会导致cache line失效(通常是在多核编程里面,两个变量 在不同的core上引用)。读写分离是一个很难运用的技巧,特别是在code很复杂的情况下。需要 不断地调试,是个力气活(如果有工具帮助会好一点,比如cache miss时触发cpu的execption处理 之类的)。
代码里面的冗余代码和死代码(dead code)很多。减少冗余代码就是减小浪费。但冗余代码有时 又是必不可少(copy-paste太多,尾大不掉,不好改了),但是对critical path,花一些功夫还 是必要的。
代码优化有时与编程规则是冲突的,比如直接访问成员变量,还是通过接口来访问。编程规则上肯定是说要通过接口来访问,但直接访问效率更高。还有就是许多ASSERT之类的代码,加的多了,也影响性能,但是不加又会给debug带来麻烦。所以需要权衡。代码层次的优化是基本功课,但是指望代码层次的优化来解决所有问题,无疑是缘木求鱼。从系统层次和算法层次考虑问题,可能效果会更好。
代码层次的优化需要相关工具的配合,没有工具,将会事倍功半。所以在优化之前,先把工具准备好。有关工具的话题,会在另一篇文章里面讲。
还有什么,需要好好想想。这些优化技巧都是与c语言相关的。对于其他语言不一定适用。每个语言都有一些与性能相关的编码规范和约定俗成,遵守就可以了。有很多Effective, Exceptional 系列的书籍,可以看看。