如何利用好cache写出高性能的代码(Make your programs run faster by better using cache )

39 篇文章 14 订阅
15 篇文章 4 订阅

笔者在学习cache时总结了一些在软件层面充分利用cache,来写出高性能代码方法。
我们知道cache性能的关键指标如下:

  • hit/miss ratio:命中率,或者说时miss的概率。
  • miss penalty:当发生miss时,系统需要额外进行哪些操作(associated costs of a miss penalty)。
  • time for hit :发生hit/miss所需的时间,cache controler收到一个地址,需要进行cache look-up(cache 遍历查找),才能确定是否hit或者miss,而这个查找的时间与cache的结构(组相联、直接映射、全相联映射)有关。

miss penalty和time for hit 与系统硬件架构的具体实现紧密相关,作为软件层,如果想提高缓存的性能以及系统性能,我们唯一能做的就是提高命中率或者减小miss的概率。下面笔者将围绕这一主题,提出一些解决方法。

一,当线性访问数据(需要遍历数据结构)时,使用数组(arrays)而不是链表(Linked lists)

像链表、哈希图以及字典等数据结构在某些方面有很高的性能,但是对cache 并不友好。拿链表举例,链表里的每个node一般都是用malloc临时分配的,这导致链表在内存上并不是连续的,而数组在内存上的一定是连续的存在,如下图所示:
在这里插入图片描述
如果是一次只访问单个元素,数组和链表在性能上可能没有区别。但如果是连续访问,或者是遍历整个数据结构时,二者在性能上将有很大差异:链表发生cache miss的概率要远高于数组。
事实上,有一些优秀的数据结构兼顾了cache 性能以及灵活的增删和搜索功能,这里列举两种:

  • Gap buffer:这种数据结构是链表和数组的结合体,不仅有优秀的cache 性能,而且还很方便进行插入和删除元素。
  • Judy array:一种由树结构实现的分散的数组,对cache 友好,并且也方便进行插入和删除元素。

二,经常使用的变量应该放在一起声明定义,(Variables you access often together should be close to one-another in memory)

如果程序中某些变量使用频率很高,并且有可能会一起访问这些变量,那么应该将这些变量放在一起声明定义。这样做的目的就是访问其中一个变量后,在该变量之后声明定义的几个变量也有可能被放入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的大小,进行内存对齐,有如下两条规则:

  • 类或者结构体的大小需要是cache line大小的整数倍(Size of class needs to be a multiple of cache line size)
  • 数组起始地址需要是cache line大小的整数倍(Starting address of the array needs to be a multiple of cache line size)

我们可以用 GCC/CLANG编译器提供的 attribute((aligned (64)))让数组的起始地址按照64bytes大小对齐,也可以利用该属性对结构体或者类中的子元素进行自定义对齐,使其产生padding,达到类或者结构体的大小需要是cache line大小的整数倍的目的。
同样是4个48bytes结构体的数组,以下为数组起始地址为64的整数倍的示意图:
在这里插入图片描述
从图中可以看出,数组中横跨两个cache line的元素减少了一个,但仍有两个结构体元素横跨两个cache line。如果对结构体内部也进行内存对齐,使得结构体的大小为64bytes。如下图所示,虽然产生了16 bytes的内存padding,但这样做的使得每个结构体元素不再横跨cache line,随机访问该数组元素时,cache miss的概率将减小。
在这里插入图片描述

四,高效地访问矩阵(二维数组)Access data in your matrices efficiently

假设有一个二维数组:char arr[n][m],需要对其进行遍历赋初值,我们有两种初始化方式:

  • 行优先策略 row-wise
  • 列优先策略 column-wise

如下所示代码:

#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的数据可能还未被使用,就又被驱逐出去,会严重影响性能。

五,避免结构体或类中出现空洞padding(Avoid padding in your classes and structs)

关于结构体的内存对齐,可以参考博文:C/C++计算类/结构体和联合体(union)所占内存大小(内存对齐问题)

内存对齐的三条规则:

  1. 数据成员对齐规则,结构体(struct)(或联合(union))的数据成员,第一个数据成员存放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员(只要该成员有子成员,比如数组、结构体等)大小的整数倍开始(如:int 在 64bit 目标平台下占用 4Byte,则要从4的整数倍地址开始存储,double为8 Byte,则要从8的整数倍地址开始存储)
  2. 结构体作为成员,如果一个结构体里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储
  3. 结构体的总大小,即sizeof的结果,必须是其内部最大成员长度(即前面内存对齐指令中提到的有效值)的整数倍,不足的要补齐

对齐要求通常由硬件产生,并由编译器尽可能强制执行。为了确保类和结构体中的数据正确对齐,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中( Use software prefetching)

如果我们提前知道某段地址空间内的数据将会被频繁访问,或者想加速某段地址空间的数据访问。可以使用prefetch指令提前将数据加载到cache中。软件预取的目的就是告诉处理器,未来哪些地址空间上的数据我即将要访问,需要先放到cache中。

ARM prefetch 指令: PRFM

软件可以使用 Prefetch from Memory (PRFM) 指令加载指定地址上的数据到cache 中,这是条 hint instruction,hint的效果由具体的架构实现定义。该指令的语法如下:

PRFM <prfop>, <addr> | label

prfop可以是如下的选项:

  • 操作类型,可以是预加载或者是预存储(Type, PLD or PST (prefetch for load or store))
  • 目标,可以是 L1,L2或者L3(Target L1, L2, or L3 (which cache to target))
  • 策略,可以是KEEP 或者STRM( Policy KEEP or STRM (keep in cache, or streaming data))

如下指令,将会把地址0xA0000000起始的一个cache line加载到 Data cache 中:

PRFM PLDL1KEEP 0xA0000000

GCC/CLANG : __builtin_prefetch

__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

七,使用编译器的优化选项来提高软件性能:COMPILER optimization options

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

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SOC罗三炮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值