笔者在学习cache时总结了一些在软件层面充分利用cache,来写出高性能代码方法。
我们知道cache性能的关键指标如下:
miss penalty和time for hit 与系统硬件架构的具体实现紧密相关,作为软件层,如果想提高缓存的性能以及系统性能,我们唯一能做的就是提高命中率或者减小miss的概率。下面笔者将围绕这一主题,提出一些解决方法。
像链表、哈希图以及字典等数据结构在某些方面有很高的性能,但是对cache 并不友好。拿链表举例,链表里的每个node一般都是用malloc临时分配的,这导致链表在内存上并不是连续的,而数组在内存上的一定是连续的存在,如下图所示:
如果是一次只访问单个元素,数组和链表在性能上可能没有区别。但如果是连续访问,或者是遍历整个数据结构时,二者在性能上将有很大差异:链表发生cache miss的概率要远高于数组。
事实上,有一些优秀的数据结构兼顾了cache 性能以及灵活的增删和搜索功能,这里列举两种:
如果程序中某些变量使用频率很高,并且有可能会一起访问这些变量,那么应该将这些变量放在一起声明定义。这样做的目的就是访问其中一个变量后,在该变量之后声明定义的几个变量也有可能被放入cache 中,这样子有助于减小后几个变量在cache中发生miss的概率。
如下Example 1和Example 2,如果variable 1,variable 2,variable 3经常被一起访问,在Example 2中,访问variable 1时,variable 2和variable 3如果刚好和variable 1在同一个cache line范围,则也会被放入cache中。而Example 1中,variable 1,variable 2,variable 3不是放在一起声明定义的,它们在内存上的位置很大可能并不是连在一起,所以访问这三个变量极大概率会发生3次cache miss。
//Example 1
variable 1;
{...} //code block
variable 2;
{...} //code block
variable 3;
{...} //code block
//Example 2
variable 1;
variable 2;
variable 3;
{...} //code block
我们随机访问数组内的元素,不可避免地会遇到一些cache miss,但是我们可以通过管理数组内元素的数据布局,或多或少地减小cache miss的概率。
假设当前架构的cache line 大小为64 bytes,有一个由4个结构体my_struct(sizeof(my_struct) = 48)所组成的数组array,下图为四个结构体以cache line 为视角,在cache中的摆放位置:
由上图可知:数组元素array[0]/array[1]/array[3]横跨了两个cache line,所以当访问数组元素array[0]/array[1]/array[3]时,有可能或导致两次cache miss,也就是说CPU要读取完整的单个数组元素array[0]/array[1]/array[3]时,需要加载两个cache line的数据到cache中。
如何避免这种现象?需要根据cache line的大小,进行内存对齐,有如下两条规则:
我们可以用 GCC/CLANG编译器提供的 attribute((aligned (64)))让数组的起始地址按照64bytes大小对齐,也可以利用该属性对结构体或者类中的子元素进行自定义对齐,使其产生padding,达到类或者结构体的大小需要是cache line大小的整数倍的目的。
同样是4个48bytes结构体的数组,以下为数组起始地址为64的整数倍的示意图:
从图中可以看出,数组中横跨两个cache line的元素减少了一个,但仍有两个结构体元素横跨两个cache line。如果对结构体内部也进行内存对齐,使得结构体的大小为64bytes。如下图所示,虽然产生了16 bytes的内存padding,但这样做的使得每个结构体元素不再横跨cache line,随机访问该数组元素时,cache miss的概率将减小。
假设有一个二维数组:char arr[n][m],需要对其进行遍历赋初值,我们有两种初始化方式:
如下所示代码:
#define N 2048
#define M 64
char arr[N][M];
//initialization 1
for(int i = 0; i < N; i++) {
for(int j = 0; j < M; j++) {
// 按照行访问
arr[i][j] = 0;
}
}
//initialization 2
for(int i = 0; i < N; i++) {
for(int j = 0; j < M; j++) {
// 按照列访问
arr[j][i] = 0;
}
}
}
如果我们在遍历二维数组时考虑到了cache 的基本结构以及原理:以cache line为最小单位,发生miss时会进行cache linefill。
就会发现以行优先策略遍历二维数组是最优选择。我们来分析下为什么选择行优先策略,也就是上述代码中的initialization 1。
按行优先策略充分地利用了cache 的空间局限性原理:被缓存进cache的数据都是未来将会被访问的数据。
如下图按行优先策略示意图,当访问数组第一个元素arr[0][0]发生miss时,会进行linefill,将arr[0][0]到arr[0][63]内的64个数据填充到cache line。而这些数据恰恰是按行访问所需的数据:
而列优先策略恰恰相反,如下图按列优先策略示意图,当访问数组第一个元素arr[0][0]发生miss时,会进行linefill,将arr[0][0]到arr[0][63]内的64个数据填充到cache line。由于是按列访问,下一个访问的元素为arr[1][0],所以红色部分在短时间内不会被访问。白白浪费了cache 的空间。
并不是说按列优先访问一定就比按行访问的性能要差。如果cache的容量较大(假设有32KB),而数组又比较小(假设为64✖64的char数组),此时按行访问和按列访问的性能是差不多的:经过一次内层循环,按行或者按列访问都是迭代64次,虽然按列访问发生了64次miss+linefill,按行访问只发生一次miss+linefill,由于数组比较小,按列访问的方式已经把所有的未来需要被缓存进cache的数据都加载进来了,从整体来看二者最终都会发生64次miss+linefill。
但是当cache的容量较小(假设有1KB),而数组又比较大(假设为2048*64的char数组,2KB),此时二者的差距将会明显拉大:由于cache的容量不够存下整个数组,按列访问时,之前被缓存进cache的数据可能还未被使用,就又被驱逐出去,会严重影响性能。
关于结构体的内存对齐,可以参考博文:C/C++计算类/结构体和联合体(union)所占内存大小(内存对齐问题)
内存对齐的三条规则:
对齐要求通常由硬件产生,并由编译器尽可能强制执行。为了确保类和结构体中的数据正确对齐,C和c++编译器可以添加填充:这些是在类成员之间添加的未使用的字节,以确保所有成员都正确对齐。
考虑以下示例:
class my_struct1 {
int my_int;//4
double my_double;//8
int my_second_int;//4
};
我们会想当然地以为: sizeof(int) + sizeof(double) + sizeof(int) = 16 Bytes. 然而,my_struct1 的实际大小按照内存对齐规则为24 Bytes。
为什么结构体里的空洞会影响到cache的效率呢?假设我们的cache line大小为64 bytes,64 ➗ 24 ≈ 2.7,也就是说一个cache line里最多可以放 2.7个my_struct1 ,但是每个my_struct1 里有效的数据只有16 bytes,存在 8 bytes 的padding,一个cache line 也就是有 2.7 ✖ 8 ≈ 21.3 Bytes 的padding。64 bytes的cache line存放这样的结构体,cache line的利用率只有 67.5% !
如何解决这个问题呢?让我们重新给这个结构体的内部元素排个序:
class my_struct2{
double my_double;
int my_int;
int my_second_int;
};
我们将结构体里的元素从大到小依次摆放,得到my_struct2,按照结构体内存对齐规则,sizeof(my_struct2) = 16 bytes,等于所有元素之和,所以结构体内没有padding,一个64 bytes 的cache line能放下4个my_struct2,cache line的利用率达到了100%。
除此之外,为了尽可能避免跨CacheLine访问,还需要:
如果我们提前知道某段地址空间内的数据将会被频繁访问,或者想加速某段地址空间的数据访问。可以使用prefetch指令提前将数据加载到cache中。软件预取的目的就是告诉处理器,未来哪些地址空间上的数据我即将要访问,需要先放到cache中。
软件可以使用 Prefetch from Memory (PRFM) 指令加载指定地址上的数据到cache 中,这是条 hint instruction,hint的效果由具体的架构实现定义。该指令的语法如下:
PRFM <prfop>, <addr> | label
prfop可以是如下的选项:
如下指令,将会把地址0xA0000000起始的一个cache line加载到 Data cache 中:
PRFM PLDL1KEEP 0xA0000000
__builtin_prefetch是GCC编译器提供的一个内置函数,用于预取数据到CPU的缓存中,以便提高程序的执行效率。它的语法如下:
__builtin_prefetch (const void *addr, int rw, int locality)
其中,addr是一个指向要预取数据的地址的指针,rw是一个表示读写属性的整数,locality是一个表示预取数据的局部性的整数。__builtin_prefetch的返回值是void类型,它只是告诉CPU预取数据到缓存中,而不会等待数据被加载到缓存中。__builtin_prefetch的使用背景是,现代CPU的缓存系统可以预取数据到缓存中,以便提高程序的执行效率。但是,如果预取的数据与程序的执行流程不符,就会导致CPU的缓存被清空,从而降低程序的执行效率。因此,为了让CPU的缓存预取机制更加准确,我们可以使用__builtin_prefetch来告诉CPU要预取哪些数据,从而让CPU的缓存预取机制更加准确。
__builtin_prefetch的内部原理是,它会向CPU发送一个预取数据的请求,然后CPU会将请求加入到预取队列中。当CPU空闲时,它会从预取队列中取出请求,并将请求的数据预取到缓存中。
–来自__builtin_xxx指令学习【2】__builtin_prefetch
Arm嵌入式编译器可以执行一些优化来减少代码量并提高应用程序的性能。不同的优化级别有不同的优化目标,不仅如此,针对某个目标进行优化会对其他目标产生影响。比如想减小生成的代码量,势必会影响到该代码的性能。所以优化级别总是这些不同目标(代码量,程序性能,debug信息)之间的权衡。
详情可以参考笔者之前的博文:
ARM嵌入式编译器编译优化选项 -O
https://jetpackcompose.cn/docs/principle/gapBuffer/
https://lwn.net/Articles/255364/ http://lwn.net/Articles/250967/
https://blog.feabhas.com/2020/11/introduction-to-the-arm-cortex-m7-cache-part-3-optimising-software-to-use-cache/
https://softwareengineering.stackexchange.com/questions/125874/what-is-important-when-optimising-for-the-cpu-cache-in-c
https://johnnysswlab.com/make-your-programs-run-faster-by-better-using-the-data-cache/#tip-variables-you-access-often-together-should-be-close-to-one-another-in-memory