从缓存角度来提高程序性能(二):高速缓存Cache

版权声明:转载请注明出处!谢谢! https://blog.csdn.net/qq_28114615/article/details/86576230

目录

1 高速缓存Cache机制

1.1 通用的高速缓存存储器结构

1.2 直接映射高速缓存

1.3 组相联高速缓存

1.4 全相联高速缓存

2 编写高速缓存友好的代码


1 高速缓存Cache机制

1.1 通用的高速缓存存储器结构

      通用的高速缓存存储器的结构如图所示:

       由图可见,高速缓存存储器实际上是由多个缓存行组成的,共分为S个组,每个组中有E个缓冲行,对于每个缓冲行来说,其主要由三部分组成:有效位(1bit)、标记位(t bits)以及1个缓存块组成,这个缓存块由B个字节组成。因此,高速缓存存储器的大小为C=S×E×B。其中有效位用来表明当前缓冲行的缓存块数据是否有意义,有意义即为1;标记位用于判别是否缓存命中的关键信息。

       而另一方面,内存中每一个单元都拥有其地址,而每个地址则对应高速缓存存储器中的一个块。假设地址为m位的,那么这m位的地址构成如图所示:

                     

       如图所示,m位的地址中,低b位表示块偏移,这b位共对应有2^b种偏移情况,即是0~B-1字节;中间的s位为组索引,用来确定该地址映射到哪一个缓冲组里的,s位共对应2^s种情况,相应的就是组0~组S-1;然后就是高t位标记位了,只有当高速缓存存储器中某一缓存行的有效位为1,标记位与该地址中的t个标记位完全相同时,才能认为高速缓存命中,然后根据b位偏移来选择起始字节。

        当CPU执行一条读内存字w的命令时,它会向L1高速缓存请求这个字,来看w是否在L1缓存命中。这时就会先根据w的地址的中间s位来确定w是在哪一个缓存组中,然后再根据t位标记位来确定w是在哪一个缓存行中,最后根据b位来确定w的偏移量从而确定w的起始字节。因此,整个过程分为三步:①组选择;②行匹配;③字抽取

1.2 直接映射高速缓存

        所谓直接映射高速缓存,就是每个缓存组中只有1行,即E=1。如图所示。

      

       由于每个缓存组中只有一行缓存行,因此根据地址的s位即可确定缓存行的位置,然后再通过低b位确定起始字节。现在假设CPU每次读取一个字节的字,且高速缓存的描述为(S,E,B,m)=(4,1,2,4),根据S=4,因此缓存组一共有4组,那么就需要s=2位来选择是哪一个组;根据B=2,那么缓存块有2个字节,因此就需要b=1位来选择偏移字节。它的初始结构如下:

       此时如果CPU需要读地址为0处的字。CPU先像Cache发出请求。根据地址0的4位二进制表示为0000,从最低开始取b(b=1)位为0,因此块偏移字节为0;然后往左数s(s=2)位“00”,因此选择组0。这样也就确定了缓存行为第0行,再往左数剩下的t位标记位为“0”,为标记位。然后再看第0行缓存行,由于第0行缓存行有效位为0,发生冷不命中,因此Cache需要从内存中加载这个,而这个块中包含了两个字节的数据,对应地址为0000~0001(将b位所有情况列出),因此,Cache将地址为0和地址为1的两个字节加载到组0的缓存块中;标记位与地址0的标记位相同,置为0。然后Cache从缓存行的缓存块中取得偏移字节为0(由最低1位决定)的一个字节(由CPU一次读取字节数决定)的值,返回给CPU,此时Cache结构如下所示。

        此时如果CPU需要读地址为1处的字。同样的,CPU先像Cache发出请求,根据地址1的二进制表示为0001,因此从最低取b(b=1)位为1,表示偏移字节为1;然后往左数s(s=2)位“00”,选择组0。再往左数剩下的t位标记位为“0”,标记位为0。然后再看第0行缓存行,此时第0行的有效位为1,并且标记位与地址1的标记位相同,因此缓存命中,直接从该行缓存块中偏移1个字节取得m[1]返回给CPU,Cache结构不变。

        此时如果CPU需要读地址13处的字。地址13的二进制表示为1101,从最低开始取b(b=1)位为1,表示偏移字节为1;再接着往左数s(s=2)位“10”,选择组2。再往左数剩下的t位标记位为“1”,标记位为1.再来看组2中的缓存行,发现有效位为0,发生冷不命中,因此Cache从内存中加载该缓存行对应的块,块对应的地址为1100~1101,即将地址12与地址13的字加载到块中,标记位置为1。然后Cache从缓存行的缓存块中取得偏移字节为1(由最低1位决定)的一个字节(由CPU一次读取字节数决定)的值m[13],返回给CPU,此时Cache结构如图所示。

       此时如果CPU需要读地址8处的字。地址8的二进制表示为1000,从最低开始取b(b=1)位为0,表示偏移字节为0;再接着往左数s(s=2)位“00”,选择组0;再往左数剩下的t位为“1”,标记位为1。此时再看组0,有效位为1,但是标记位为0不为1,因此发生冲突不命中,因此Cache从内存中加载该缓存行对应的块,块对应的地址为1000~1001,即将地址8与地址9处的字加载到缓存块中,并将标记位改为1,然后再看偏移字节为0的一个字节即是m[8]返回给CPU,此时Cache的结构如图所示。

1.3 组相联高速缓存

       组相联高速缓存与直接映射高速缓存相比,其主要的差别在于每一个缓存组中的缓存行可以为多个,即1<E<C/B,比如常见的二路缓存就是每个缓存组中有两个缓存行,即E=2。如图所示。

       给定一个地址,Cache根据这个地址依然可以得出标记位、组索引和块偏移字节,根据组索引可以找到准确的缓存组,那么怎么确定是哪一个缓存行呢?其实很简单,直接将标记位与该缓存块中各个缓存行的标记位做对比,如果有一个缓存行的标记位与地址的标记位相同,并且有效位为1,那么就说明缓存命中,然后根据块偏移字节到这个缓存行中找到数据返回。

       如果缓存不命中,那么就需要去内存中取得相应的块,然后执行某种替换策略(LFU、LRU、随机选择等)来选取替换行。

       其余的操作参照直接映射高速缓存。

1.4 全相联高速缓存

        全相联是指整个高速缓存只有一个组,即S=1,所有缓存行都包含在这个组中,即E=C/B。如图所示。

       对于全相联高速缓存,在使用地址进行映射时组索引位直接默认为0,m个地址位只由b位的块偏移位与t=m-b位的标记位组成,依然是以有效位为1,标记位相同为标准来判断是否缓冲命中。

2 编写高速缓存友好的代码

       简单来说,缓存命中率越高,自然程序效率越高。那什么是高速缓存友好的代码呢?我们先来看看以下几个例子。

struct algae_position{
    int x;
    int y;    
};

struct algae_position grid[16][16];
int total_x = 0,total_y = 0;
int i,j;

Eg1:

        对于这个例子,我们先来看结构体algae_position,它的大小为8个字节,而Cache的大小为1024B,并且缓存块的大小为16字节,即S=64,E=1,B=16,那么一个缓存块中可以存放两个结构体变量,总共有64个缓存行(0~63)。因此最多只能放下64×2=128个结构体变量,即grid数组的一半。

        先来看第一个双层循环,访问g[0][0]~g[7][15]之间,每两个元素之间必定有一个元素为缓存冷不命中,而另一个元素则缓存命中。举个例子,访问g[0][0]时会缓存冷不命中,然后此时将g[0][0]和g[0][1]加载到Cache中,然后下一次循环访问g[0][1]时就缓存命中了。而在访问g[8][0]~g[15][15]之间时,每两个元素之间必定有一个元素缓存冲突不命中,而另一个元素则缓存命中。举个例子,访问g[8][0]时,必定是对应组0中的缓存行的,此时就会缓存冲突不命中,然后加载g[8][0]和g[8][1]到块中,然后下一次循环访问g[8][1]时就缓存命中了。因此第一个双层循环的命中率为50%。

         再来看第二个双层循环,它实际上和第一个双层循环是相类似的,不同的只是每次访问的地址的偏移字节不同而已,但是并不影响缓存是否命中,因此第二个双层循环的命中率依然为50%。因此整个程序的不命中率就是50%。

         Cache及变量定义保持不变,再来看第二个例子:

Eg2:

       在这段程序中,是按列优先访问的。在第一轮循环中(i=0),先访问g[0][0].x,缓存冷不命中,此时加载g[0][0]和g[0][1]到块0中,然后访问g[0][0].y肯定是命中的,接着第二次循环访问g[1][0],缓存冷不命中,此时加载g[1][0]和g[1][1]到块8中...访问g[7][0].x,缓存冷不命中,此时加载g[7][0]和g[7][1]到块56中,然后访问g[7][0].y肯定是命中的,接下来加载g[8][0].x~g[15][0].x时,就会和块0、块8、...、块56缓存冲突不命中,但是跟着访问的.y是命中的。因此,在第一轮循环中,所有.x访问都是缓存不命中的,而.y的访问是命中的;

        第二轮循环(i=1)中各个.x的访问也是在块0、块8、..块56缓冲冲突不命中,.y访问命中;到了第3轮循环(i=2)时,此时的g[0][2].x、g[1][2].x...g[7][2].x则会在块1、块9...块57冷不命中。由此可见,每一次循环中,.x的访问要么是冷不命中,要么就是冲突不命中的,但是.y是肯定命中的,因此不命中率为50%。

       如果高速缓存有两倍大,即缓存行数加倍,S=128,此时高速缓存可以装下整个数组,不会发生冲突不命中,因此只会冷不命中,因此不命中率只有25%。

       Cache及变量定义不变,继续看第三个例子:

Eg3:

        这段循环是按行优先访问的。这种形式其实是最有效率的,因为g[0][0].x访问冷不命中后,就会加载g[0][0]~g[0][1],那么后面接着的三次访问都是缓存命中的,然后g[0][2]也是一样,直到g[7][15]访问后Cache被填满,此时再访问g[8][0]就是缓存冲突不命中了,但是接着的三次访问也都是缓存命中,因此不命中率为25%。

       在这种情况下,即使将Cache的容量加倍,改变的只是将所有缓存冲突不命中变成了缓存冷不命中,但是不命中率依然是25%,即使扩大3倍、4倍、....n倍,不命中率还是25%不变。

       综合以上三个例子可知,编写高速缓存友好的程序,即是使Cache缓存命中率高,在第一个例子中,变量的访问步长为“2”(连续的x访问相当于跳过了二者之间的y),在第二个例子中,变量的访问步长在同一轮循环中为“1”,在相邻轮循环中为“32”(g[0][0].x~g[1][0].x);而在第三个例子中,变量的访问步长都是"1"。可见,访问步长为“1”的情况下,Cache的命中率是相对较高的,在编写程序,尤其是循环时一定要注意这一点。

猜你喜欢

转载自blog.csdn.net/qq_28114615/article/details/86576230