面向处理器结构的C程序优化
把常见的几种方法写下来,免得以后忘了。
面向处理器结构的优化可以从以下几个方向入手:缓存命中,指令预测,数据预取,数据对齐,内存拷贝优化,ddr访问延迟,指令优化,硬件内存管理优化。
缓存未命中,这个实际上是处理器的最大性能瓶颈之一。只要你从内存中取得数据不是用一次就丢,那么他就制约着你的性能。在powerpc上,访问一级缓存是3个时钟周期,二级是12个,3级30个,访问内存100个以上。所以如果预先取到缓存,和缓存完全未命中相比,能快30多倍。我们可以算一下,100条存取指令,100%命中和95%命中,前者300周期,后者95*3+5*100=785周期,差了1.6倍。这个结果的前提是powerpc上每个核心只有1个存取单元,哪怕多发射也无法流水到1个周期,所以最小执行时间是3个周期。当然,如果未命中的存取指令分布的好(预取和流水的妙用),开头5个未命中,后来全命中,那可能就是100+12+95*3=397,也有30%差别的。另外提一句,如果所有数据只用一次,那么瓶颈就变成了访存带宽,有点类似于显卡。所以显卡不强调缓存大小。当然他也有寄存器组,效果类似,只不过没那么大。
那么怎么提高缓存命中率?指令预测和数据预取就可以。
指令预测很常见,处理器预测将要执行的一个分支,把后续指令取出来先执行。等真正确定判断条件的时候,如果预测对了,提交结果,如果不对,丢掉预先执行的结果,重新抓取指令。此时,结果正确性不会有问题,但是会损失一点时间。
指令预测有一个方法叫做btb (branch target buffer),意思是,对于跳转指令,把它最近几次的跳转结果记录下来,作为下一次预测的依据,从而提高预测准确率。举个例子,for循环1000次,从第二次开始,每次都预取前一次的跳转地址,那么准确率接近99.9%。这是好的情况。不好的情况,在for循环里面,有个if(a[i])。这个a[i]是个0,1,0,1序列,这样每次if的预测都会错误,预取效率就很低了。改进方法,把if拆开成两个,一个专门判断奇数次a[i],一个判断偶数次,整体循环次数减少一半,每次循环的判断增加一倍,这样每次都是正确的。如果序列的数字预先不可知,只是知道0多或者1多,那么可以用c语言里面的LIKELY/UNLIKELY修饰判断条件,也能提高准确率。需要注意的是,btb表项是会用完的,也就是说,如果程序太久没有走到上次的记录点,那么记录就会被清掉,下次再跑到这就得重新记录了。
数据预取,这个和指令预测类似,也是处理器把可能会用到的数据先拿到缓存,用的之后就不必去读内存了。分为软件预取和硬件预取两种,硬件的是处理器自己有个算法去预测抓哪里的数据,比如在访问一组相同数据结构的相同元素,处理器会认为下次取的数据地址是当前地址自动加上一固定个偏移。当然,具体算法不会这么简单。软件预取就是用编译器的prefetch宏修饰某个将要用到的变量,如果处理器支持软件预取指令,编译器会把他翻译成相应指令,手工去内存抓某个程序员认为快要用到的数据。有人会问,为什么不等下抓呢?假设抓了之后,在我真正用到数据前,我有100条非内存访问指令,那么等我真正用到时候数据就在缓存了,省了很多时间。
需要注意的是,如果不是计算密集型的代码,不会跑了100个周期再有下一条存取指令。访存密集型程序可能10条指令就来一次。如果全都未命中,那么这个预取效果就会打不少折扣。并且,同时不宜预取过多数据,因为取进来的是一个缓存行,32,64或者128字节,如果取得过多,会把本来有用的局部数据替换出去。按照经验同时一般不要超过4条预取。此外,预取指令本身也至少占3个周期,过多的话,会增加每次循环执行时间。要知道有时候1%的时间都是要省的。
在访问指令或者数据的时候,有一个非常重要的地方,就是对齐。一般的四字节对齐我们都知道,在32位机器上很有用。还有一个重要的是缓存行对齐,一般是在做内存拷贝,DMA或者数据结构赋值的时候用到。我们知道处理器在读取数据结构时,是一行一起读的,比如32字节。那么,如果你的数据结构能够调整为缓存行对齐,那么就可以用最少的次数读取。在dma的时候一般都以缓存行为单位。如果不对齐,那么就会多出一些开销,甚至出错。还有,在SoC系统上,对有些设备模块进行DMA时,如果不是缓存行对齐,那么可能每32字节都会被拆成2段分别做DMA,这个效率就要差了1倍了。
还有一种需要对齐情况是数据结构赋值。假设有个32字节的数据结构,里面全是4字节元素。正常初始化清零需要32/4=8次赋值。在powerpc上,有一种指令,可以直接把缓存行置全0或1。这样时间就变成1/8了。更重要的是,写缓存实际上是需要先从内存读取数据到缓存,然后再写入。这就是说写的未命中和读未命中需要一样的时间。而用了这个指令,可以不用去读,直接写入全0/1。这在逻辑上是没问题的,因为你本来就知道要写入的数据(全0/1),不需要去读。以后如果这行被替换出去,那么就写回到内存,完全不需要读。当然,这个指令的限制也很大,就是必须全缓存行替换,没法单个字节修改。这其实就是我要说的优化后的memset函数。如果你调整下你的大数据结构,把同一时期需要清掉的元素都放一起,效率会高很多。同理,在memcpy函数里面,由于存在读取源地址和写入目的地址,按上文所述,可能需要两个读取未命中操作。现在我们可以先写入一个缓存行(没有读未命中),然后再读源,再写入,就变成了1个读取操作。写回数据那是处理器以后自己去做的事情,我们不用管。这个是不是很神奇?
标准的libc库里面的内存操作函数都可以用类似方法优化,而不仅仅是四字节对齐优化。不过需要注意的是,如果给出的地址不是缓存行对齐的,那么开头和结尾的数据需要额外处理,不然整个行被替换了了,会影响到别的数据。此外,可以把预取也结合起来,把要用的头尾东西先拿出来,再作一堆判断逻辑,这样又可以提高效率。不过如果先处理尾巴,那么当内存重叠时,会发生源地址内容被改写,也需要注意。
未完待续