毫秒必争之如何搞定cache(上)
本文以SmartPro 6000F使用的nios ii内核为例,详述了如何搞定cache,将程序的运行时间从最开始的30s优化到25s,再从25s优化到最终的24s。尤其是那最后1s的优化,遇到了很多问题,而这些问题在嵌入式系统里,任何一款配置了cache的处理器都可能会碰到,所以特撰此文献给那些还在倍受cache折磨的工程师们。全文分上下两部,上部为如何搞定指令cache,下部为如何搞定数据cache。
虽然不同优化等级之间时间差别就不到5s,我们完全可以很省事的开启O1级别优化时序,然后就交付给客户,但是当时我们并没有这么做。因为理论上,优化等级越高,编程时间应该越少才对,但是现在测试的编程时间结果是 O0 > O2 > O1,冥冥中感觉时序还可以再优化些,速度还可再快一点。
目前提速遇到的问题是:使用更高的优化等级,编程时间反而更多了,这可不符合gcc的优化规律阿。那是什么的存在破坏了优化规律呢?宏观的考虑,在嵌入式系统里,能破坏程序运行规律的家伙,嫌疑最大的就是cache了。
cache存在的初衷是为了提速,因为程序指令如果完全运行在内存里,速度会非常慢,而在cache里运行将非常快,但是cache的容量是有限的,无法缓存所有程序,所以nios ii内核在设计的时候做了一个折中处理,先将内存里的程序搬运到cache里,然后在cache里运行程序,由于cache无法一次性缓存所有程序,如果运行的程序大小超过了cache容量,必须要重新访问内存更新cache,如果更新频率越高,则访问内存的次数就越多,运行效率自然就会被拉低,而cache的更新频率是不可预测的,所以配置了cache的嵌入式系统的运行时间一般都很难预测。
但是,SmartPro 6000F里的每一个nios ii内核都配置了4KB的指令cache和2KB的数据cache,而编程时序只有不到2KB,理论上完全可以缓存到cache里运行,根本不用去更新cache,也就不应该存在运行不规律的问题了。
但是,但是,上述只是我们的一个理论分析,实际运行到底有没有更新cache,还需实际测试说了算。我们将6000F的内存sram的读(rd)、写(wr)和地址(addr)连上逻辑分析仪观察,如果rd线或者wr线有出现脉冲,就说明存在访问内存更新cache的操作。不同的优化等级下,测试波形如下所示:
O1 25s
O2 29s
O0 30s
由上图发现,运行时间较长的,都不同程度的出现了访问内存的现象,而且访问的越频繁,速度越慢。编程时序的大小已经完全可以加载到cache里运行,为什么还会访问内存呢?
看来cache的加载方式好像没有之前想的那么简单,为了解决这个疑惑,让我们再来研究下在O2优化等级时编程时序的汇编代码。编程函数Program在内存sram的地址分布区域从0x1014到0x15F0,里面会调用到的一个定时器函数Timer在内存的分布从0x2020到0x2170。
Program( ) { 0x1014 0x1018 0x101c call Timer( ) … 0x15F0 } … Timer( ) { 0x2020 0x2024 ... 0x2170 }
之前天真的以为在运行Program时,这两个函数会按照如下所示顺序的加载到cache里。
cache_addr sram_addr 0x0 0x1014 Program( ) 0x4 0x1018 0x8 0x101c 0xc 0x2020 Timer( ) 0x10 0x2024 0x14 0x2028 ... 0x5c 0x2070 0x60 0x1020 ... 0x62c 0x15f0
研究了一下cache的数据结构后发现,数据并不是简单的顺序存储在cache里,不同原理的cache,使用的数据结构也不同,从nios ii开发手册里获知,当前平台使用的是直接映射结构的cache,数据以散列的格式存储,为了简化和提高cache的效率,nios ii 里的cache利用了一个最简单的散列函数:
cache_addr = sram_addr mod cache_size
其中cache_addr为cache地址,sram_addr为内存sram地址,cache_size为cache大小,这里为4K,所以Program和Timer函数在cache里的实际存储格式是
cache_addr Program( ) Timer( ) 0x0 ... 0x14 0x1014 0x18 0x1018 0x1c 0x101c 0x20 0x1020 0x2020 0x24 0x1024 0x2024 ... ... 0x70 0x1070 0x2070 0x74 0x1074 ... 0x5F0 0x15f0
由上可以看到,cache里,从0x20地址开始,Program和Timer的加载发生了冲突。编程时序运行时,cache里首先存的是Program,当运行到Timer时,nios ii会从内存调取Timer的函数存入0x20开始的cache,并覆盖Program的一部分函数,当Timer执行完后,继续运行Program,nios ii又要从内存获取Program中被覆盖的那部分程序,调入cache里执行,这样每执行一次Program函数,就会更新两次cache。
到这里,所有问题似乎都豁然开朗了,不同等级的优化设置后,在改变函数大小的同时,也会改变它们在内存的地址分布。Program和Timer的分布地址,通过cache的散列后如果没有冲突,那么在运行时就不会访问内存,如果产生了冲突,并且冲突的地址越多,则访问内存的时间就会越长,整体速度就会越慢。O2的优化等级比O1高,虽然前者优化后的程序更小,但是前者在cache的散列加载地址发生了冲突,速度自然就更慢了。
要解决冲突问题,必须从cache的散列函数入手。
方法一:增大cache的容量
由于Program和Timer的函数分布地址跨度过大,超过了cache_size,才导致散列后发生冲突,如果将cache_size增大到8KB,Program在cache里的加载地址是0x1014,Timer在cache里的加载地址是0x20,不会发生冲突。但是嵌入式系统里的资源都非常精贵,很多系统无法提供这么大的cache,此时可以采用另一种更实惠的方法。
方法二:通过分散加载,将Program和Timer的分布地址跨度缩小到cache_size内
在bsp里,新增一个从0x1000到0x2000的段.UserCache,然后将Program和Timer强制分布到这个段里,这样两个函数在cache里的存储地址也不会冲突了。gcc里,将函数到分布到指定的段的语法如下:
int Program( ) __attribute__ ((section(“.UserCache ")))
通过方法二的改进后,不同优化等级下Program的运行时间变成了:O0 30s, O1 25s,O2 24s,又比之前缩短了1s。
以前一直听说加入cache后,程序的运行就会变的不可预测,这次算是彻底的感受到了,但不可预测并不代表不可控,通过上述的两种方法,就可以控制函数尽量不去访问内存,提高执行效率和运行的一致性。
但是好景不长,在给客户增加了一个小功能后,cache又犯毛病了,不过这次出问题的不是指令cache,而是数据cache,详文请看“毫秒必争之如何搞定cache(下)”。