正在开发一个基于Nios II内核的项目,使用的开发环境是nios for eclipse,编译器是GCC,整体功能实现后,开始优化速度。默认没有开启gcc的优化选项,一段关键函数Key的运行时间为30s,开启O1一级优化后,程序大小从15KB减小到12KB,但运行时间增加到了35s,开启O2后,程序大小没有明显的减少,但运行时间明显提速到了23s,为了赶工期,暂时没去追究O1导致降速的原因,一直开着O2继续测试程序。
直到修改完另外一个和关键程序毫无耦合关系的函数A,回过头又去测试了下那段关键函数Key的运行时间,在O2的情况下尽然又降到了29s,改为O1后,又回到了25s,不开优化的时间还是30s。这次出现的问题和上次非常类似,归纳如下:
修改无关函数A之前,Key运行时间 O1 > O0 > O2
修改无关函数A之后,Key运行时间 O0 > O2 > O1
为什么优化等级变高后,运行时间反而更慢了呢?而且在不同的情况下,优化的效果还不一致,没有规律可寻。
对于一个看似毫无规律可寻的问题,有时解决之道可能就蕴藏在问题本身,先不去考虑问题的表象为什么没有规律,在一个嵌入式平台里,能让程序运行时间不可测的家伙,最大的嫌疑可能就是cache了。
cpu从主存里取指令运行的很慢,而从cache里运行的很快,所以cpu会将经常用到程序段先缓存到cache里,然后在cache里运行程序。但是cache容量有限,无法缓存所有的程序,此时就会出现访问主存更新cache的操作,如果更新的频率越频繁,访问主存的次数越多,运行的总体效率就会被拉下来,所以为了提高效率,关键函数及其需要调用的函数大小之和都要尽量控制在cache的容量范围之内。
当前的nios内核配置了4KB的程序cache和2KB的数据cache,执行的关键函数和其需要调用的函数总大小只有不到2KB,理论上都可以缓存到cache里执行,不用去访问主存。
但实际情况并非这么简单。将当前平台使用到的主存sram的rd、wr和地址线连上逻辑分析仪观察,如果rd线或者wr线有出现脉冲就说明CPU有访问主存的操作,不同的情况下测试波形如下所示:
O1 25s
O2 29s
O0 30s
由上图发现,运行时间较长的程序,都出现了访问外存的情况,而且访问的越频繁,速度越慢,关键函数和其调用的所有函数容量小到可以完全加载到cache里,为什么还会访问外存呢?让我们对cache再进行些深入的研究。
关键函数Key里会调用到B函数,编译后,两个函数在map里的分布示意图如下所示,Key在主存的分布地址从0x1014到0x15F0,B函数在主存的分布地址从0x2020到0x2170。
Key( )
{
0x1014
0x1018
0x101c call B()
…
0x15F0
}
…
B( )
{
0x2020
0x2024
...
0x2170
}
曾今,我以为在运行Key时,这两个函数会按如下所示顺序存储在cache里的。
cache地址 程序地址
0x0 0x1014
0x4 0x1018
0x8 0x101c
0xc 0x2020
0x10 0x2024
0x14 0x2028
...
0x5c 0x2070
0x60 0x1020
...
0x62c 0x15f0
研究了一下cache的数据结构后发现,数据并不是简单的顺序存储在cache里,不同原理的cache,使用的数据结构也不同,从nios开发手册里获知,当前平台使用的是直接映射结构的cache,数据以散列的格式存储,为了简化和提高cache的效率,nios的cache利用了一个最简单的散列函数:cache_addr = ram_addr mod cache_size,所以Key和B函数在cache里的实际存储格式是
cache地址 Key( ) B( )
0x0
...
0x14 0x1014
0x18 0x1018
0x1c 0x101c
0x20 0x1020 0x2020
0x24 0x1024 0x2024
... ...
0x70 0x1070 0x2070
0x74 0x1074
...
0x5F0 0x15f0
从0x20地址开始,Key和B发生了冲突。执行时,cache里首先存的是Key,当运行到要调用B时,cpu会从主存调取B的函数存入0x20开始的cache,把原来Key的一部分函数给覆盖了,当B执行完后,继续运行Key时,cpu又要从主存获取Key中被覆盖的那部分程序,调入cache里执行,这样每执行一次Key函数,就要访问两次主存。
修改其他函数或是设置不同的优化,在改变函数大小的同时,也改变了它们在主存的分布地址,如果Key和B的分布地址,通过在cache的散列后没有冲突,那么在运行时就不会取访问主存,如果产生了冲突,两者重叠的地址越多,访问主存的时间就会越长,整体速度就会越慢,这才是越优化越慢的根本原因。
要解决它,必须要从cache的散列函数入手。
方法一:增大cache的容量
由于Key和B的函数分布地址跨度过大,超过了cache_size,才导致散列后发生冲突,如果将cache_size增大到8KB,Key存放在cache里的起始地址是0x1014,B在cache里的起始地址是0x20,冲突自然就消失了。但是嵌入式系统里的资源都非常有限,很多系统无法提供更大的cache,此时可以采用另一种更实惠的方法。
方法二:将Key和B的分布地址跨度缩小到cache_size内
在bsp里,新增一个从0x1000到0x2000的段.KeyCache,然后将Key和B强制分布到这个段里,这样两个函数在cache里的存储地址也不会冲突了。gcc里,将函数到分布到指定的段的语法如下:
int Key( ) __attribute__ ((section(".Keycache ")))
通过方法二改进后,Key的运行时间变为:O0 30s, O1 27s,O2 23s,回到了预期的状态。
一直听说加入cache后,程序的运行就会变的不可预测,这次算是彻底的感受到了,但不可预测并不代表不可控,通过上述的两种方法,就可以控制函数尽量不去访问主存,提高执行效率和运行的一致性。但是好景不长,修改了一些其他函数后,O2情况下的Key函数执行时间又莫名其妙增到了30s,测试发现,cpu又去访问主存了,不过这次,访问的主角从指令变成了数据,而且很奇怪的是,程序中所有的全局变量只有不到1KB,而数据cache开了2KB的空间,根据cache的散列函数,数据存储是永远不会发生冲突的,为什么还会访问主存呢,将在后续的文章里解释。