程序性能优化探讨(4)——直接映射高速缓存命中率问题的模拟

来源:互联网 发布:婚庆门户源码 编辑:程序博客网 时间:2024/06/05 21:54

        前一节初步介绍了高速缓存的结构和地址划分策略,以及高速缓存“读”处理规则,这一节从讨论“写”开始。


一、高速缓存写的处理

        缓存处理读的过程是,根据编号查找相应的值,如果不命中,就从下一集缓存调入新的数据,再根据替换策略(不细数),将新数据替换缓存中的旧数据。而对于缓存处理写的情况,稍复杂些。

        假如CPU要对缓存中某个存在的块进行写操作,什么时候才去更新内存里的与之对应的该字段呢?如果内存和缓存同时更新,这称为直写,那写操作对于缓存设计又有什么优势呢?所以更常见的方法是,你这个块先委托缓存保管,你写你的,修改你的。什么时候等该块要被驱逐出缓存时,再更新到内存,这称为写回。从缓存的思想上看,这是非常美妙的策略,增加了时间局部性优势,减少低层存储设备的读取时间。但是,实现写回意味着更复杂的逻辑,比如每个缓存行都要维护一个修改位用于识别块是否被修改。  

        还有个问题,如果写不命中呢?CPU修改缓存里的某个字段时,发现这个字段不在缓存内,是从内存把字段读进缓存再修改缓存里的块,还是直接修改内存呢?前者利用空间局部性,但增加步骤,称为写分配,可以理解为分配缓存进行写;同理,后者就成为非写分配。很明显,直写一般就对应非写分配,而写回就对应写分配。

        一般来说,写回+写分配的策略是主流,随着逻辑电路密度的提高,高复杂性的实现越来越容易。采用直写更容易实现,但是增加了总线事物,存储层次越往下走,数据传送的耗时越长,因此越可能采用写回而不是直写策略。

二、高速缓存分配策略

        我们要讨论缓存对程序优化的影响,就要考虑缓存的不命中率(miss rate),还要考虑各级存储的不命中率处罚。一般L1不命中,需要5~10个周期从L2获得数据;L2和L3类似,而缓存不命中从内存获得数据则需要耗费25~100个周期。

        高速缓存的总大小与性能的关系可以做定性讨论。一方面,高速缓存越大,命中率肯定更高;但另一方面,高速缓存更大,命中时间就越长,因此不同的缓存可能采用不同的策略。比如L1,要求不命中处罚时间只能几个周期,那么L1就不能采用大缓存。

        高速缓存的块大小和行数量(相联度)与性能的关系也可以做定性讨论。一方面,有较大的块,每行存的数据就更大,而缓存中这些不同的块可能对应完全不相干的低层存储区域,相邻行之间的块数据可能毫不相干,因此本行的数据越大,则代表某个区域的数据集体被缓存的就越多,这是利用了空间局部性;但另一方面,高速缓存总大小一旦确定,块越大就意味着行越少,行越少,就意味着缓存可能映射更少的低层存储区域,这就破坏了时间局部性。所谓优化时间局部性即当缓存某块数据后,在短时间内尽可能多重复使用这些数据,而CPU调用的数据可能涉及到存储区很多不同区域,如果你的行太少,覆盖的各存储区域就不够广泛,自然也就很难保证更多区域的数据能够及时被缓存并且被重复使用,即便你利用空间局部性在缓存某几个区域数据时让他们被缓存得更完整。当应对不同数据处理顺序导致某些情况造成不命中的区域增多时,程序实际运行效率参数就可能出现明显的抖动现象。事实上,时间局部性比空间局部性有更好的命中率,如果从命中率来考虑,那么时间局部性的优先级更高。

        然而我们不能无限制的提高行数(提高相联度参数E),如果行数太多,需要更多的标记位,实现起来更昂贵并且访问速度很难提高,实现复杂度的增加会使得其命中时间加大,而且不命中处罚也很大,因为选择牺牲行(缓存慢时需要清除旧行移入不命中新行)的复杂性也增加了。

        以上的分配策略讨论,也是CPU生产商经过权衡依据,他们在设计L1、L2甚至L3时,会反映出他们的折中结果,而且这个结果除了依据理论推导外,还会引入大量的程序运行实践。


三、直接映射高速缓存命中率问题的模拟

        这里详细套用教材上两个很有意思的练习题。

        1、转置矩阵行列互换的实现:

        typedef int array [2][2];

        void transpose(array dst, array src)

        {

                int  i, j;

                for(i = 0; i < 2; i++){

                        for(j = 0; j < 2; j++){

                                dst[j][i] = src[i][j];

                        }

                }

        }

        假设代码运行环境:

        (1)sizeof(int)= 4。

        (2)src数组从地址0开始,dst数组从地址16开始。

        (3)只有一个L1缓存,是直接映射的、直写、写分配,块大小为8字节。

        (4)高速缓存总大小16字节,一开始空的。

        (5)对src和dst数组的访问分别是读和写不命中的唯一原因。

        

        关于这个例子,很明显能看到,缓存块大小8字节,缓存总大小16字节,说明只有两组,也就是说两行,而两个数组dst和src在存储上是平权的,这两个二维数组,一共就两行,每行两列,那么每行刚好就是8字节,二维数组总大小16字节。也就是说,整个缓存只能存下一个二维数组,而现在有dst和src两个二维数组,为了实现缓存对两个二维数组的缓存,只能把缓存平均分配给两个二维数组。比如,缓存的第一行8字节用来缓存dst[0][0]、dst[0][1]或者src[0][0]、src[0][1];而缓存的第二行8个字节用来缓存dst[1][0]、dst[1][1]或者src[1][0]、src[1][1]。也就是说,缓存有可能出现以下四种满的情况:


src[0][0]src[0][1]src[1][0]src[1][1]
dst[0][0]dst[0][1]dst[1][0]dst[1][1]
src[0][0]src[0][1]dst[1][0]dst[1][1]
dst[0][0]dst[0][1]src[1][0]src[1][1]
        从上可看出,缓存的行,刚好对应了二维数组的行,至于是哪个二维数组不重要,反正都可以共享缓存的行,有了这样的分配方式,我们就能很容易分析出命中关系了。

由于C语言是从右到左的计算顺序,先读取的是src[0][0],src[0]这一行进缓存:

src[0][0]src[0][1]  
接下来是写入dst[0][0],由于都是第一行,因此dst[0]就会占用缓存第一行,而移出src[0]:

dst[0][0]dst[0][1] 
然后又会读取src[0][1],我去,刚才src[0][1]还在缓存呆着,不幸被dst[0]取代了,现在又要读取src[0],所以果断移出dst[0]:

src[0][0]src[0][1]  
然后又是写入dst[1][0],缓存第二行终于第一次被用上:

src[0][0]src[0][1]dst[1][0]dst[1][1]
接下来是读取src[1][0],哦豁,dst[1]只能滚蛋:

src[0][0]src[0][1]src[1][0]src[1][1]
然后就是写入dst[0][1],以牙还牙,赶出src[0]:

dst[0][0]dst[0][1]src[1][0]src[1][1]
接下来是读取src[1][1],老天有眼,缓存里终于有个现成的了,读取命中!缓存不变:

dst[0][0]dst[0][1]src[1][0]src[1][1]

然后就是写入dst[1][1],又把src[1]取代:

dst[0][0]dst[0][1]dst[1][0]dst[1][1]
        至此循环结束,我们发现,无论读写几乎全部不命中,唯一的例外就是读取src[1][1]时缓存命中。如果我们增加缓存的行,让缓存大小增加到32字节呢?那么dst和src两个二维数组的两个行就可以映射到不同的缓存空间中:

src[0][0]src[0][1]src[1][0]src[1][1]dst[0][0]dst[0][1]dst[1][0]dst[1][1]        这样一来会不会全部命中呢?No!要知道还有冷不命中的概念,缓存为空时,第一次读取肯定是不命中的,有兴趣的读者可以使用四行缓存重复上面的步骤,最后你将会得出,dst[0][0]、src[0][0]、dst[1][0]、src[1][0]四个读或者写时产生冷不命中,而其余四个读写都会是命中!


        有了上面的例子做帮助,再来研究下面的例子就会容易许多了:

        2、紧密循环实现的命中

        (1)直接映射高速缓存,块大小16字节,总大小1024字节;

        (2)sizeof(int),grid从存储器地址0开始

        (3)高速缓存开始时是空的,唯一内存访问是对数组grid的元素访问,其余临时变量都存放在寄存器中。


struct algae_position {

        int x;
        int y;
};

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

 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
     }
 }

 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_y += grid[i][j].y;
     }
}

        总读数是多少?缓存不命中的读总数是多少?不命中率是多少?如果高速缓存的行有两倍大,不命中率会是多少呢?

        我们看看,高速缓存块大小2^4字节,总大小2^10字节,说明有2^6也就是64个缓存行(也是缓存组),缓存结构(S,B,E,m)= (64,16,64,8)。

        grid内每个元素是8个字节,那么一个缓存块就能容纳两个元素,总共256个元素就需要128个块,而缓存总大小只有64个块(64个行)。

        从代码的调用能看出是顺序访问,不会重复访问相同的grid行,当读取grid[0][0]时,grid[0][1]也会被读入缓存,当读到grid[7][14]时,grid[7][15]也会被读入缓存,此时缓存刚好满(每数组行要占据8个缓存行,因此8个数组行就刚好填满64个缓存行),当此时我们继续读取grid[8][0]时,它会取代grid[0][0]、grid[0][1]所占据的第一个缓存块——反正也不需要的,因此,冷不命中和命中将交替出现,x和y的访问都是完全相同的操作:

g[0][0]g[0][1]g[0][2]g[0][3]…………g[7][14]g[7][15]

grid[8][0]进来时,会驱赶grid[0][0]这行:

g[8][0]g[8][1]g[0][2]g[0][3]…………g[7][14]g[7][15]

x或者y全部读取完时,缓存内的情况就如下:

g[8][0]g[8][1]g[8][2]g[8][3]…………g[15][14]g[15][15]


        有了上面的铺垫,就很容易回答问题了。总读数是512,缓存不命中的读总数是256,不命中率是50%,如果高速缓存的行数有两倍多,不命中率会是多少呢?缓存行再大,有区别么?没有,你肯定是存在冷不命中的,所以不命中率永远是50%。


        如果把例题代码改成如下情形,会有怎样的结果呢?

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[j][i].x;
         total_y += grid[j][i].y;
     }
 }

        很明显,访问顺序变了!这段代码的外层循环是按列进行遍历,里层循环是遍历行,这样的遍历方式和二维数组的存储顺序相悖,用上面的基础,我们再来详细分析一下:

        如果还按上面循环1的顺序,是类似grid[0][0]~grid[0][15],grid[1][0]~grid[1][15]的遍历方式,就像之前那样,缓存结果会这样:

g[0][0]g[0][1]g[0][2]g[0][3]…………g[0][14]g[0][15]g[1][0]g[1][1]g[1][2]g[1][3]…………g[1][14]g[1][15]
        而现在读取方式是grid[0][0]~grid[1][0],什么不会变呢?我想应该是缓存与二维数组的数据空间对应关系不变,当读取grid[0][0],grid[1][0]时,缓存应该是这样:

g[0][0]g[0][1]空……空……空……空……空……空g[1][0]g[1][1]空……空……空……空……空……空……
        在缓存g[1][0]时,CPU给g[0][3]~g[0][15]预留了对应的缓存空间,因此g[1][0]和g[1][1]刚好缓存在整个g[0]行之后的位置,往下g[2]和g[3]都会给前一行预留足够的空间,即便这时程序并没有读上一行的其他列。
        以此类推,当读到g[8][0]时,缓存虽然还有很多空闲,但却没有对应的位置去存储g[8][0],要存储只能取代g[0][0]:

g[8][0]g[8][1]空……空……空……空……空……空……g[1][0]g[1][1]空……空……空……空……空……空……g[2][0]g[2][1]空……空……空……空……g[3][0]g[3][1]空……空……空……空……g[7][0]g[7][1]空……空……
        我们可以预见到,当程序很久以后要读g[0][1]时,由于不命中又会把g[0][0]、g[0][1]移入缓存,这时候肯定是驱逐g[8][0]、g[8][1],等到遥远的未来程序再读到g[8][1]时,又出现不命中,也就是说,所有g[j][i].x的调用都将是冷不命中或者不命中,只有读取g[j][i].y时,能沾沾光x的光,实现命中,因此,总读数是512,缓存不命中的读总数是256,不命中率是50%。答案不变!

         如果缓存行数扩大到两倍,是什么结果?可以想象一下,那将是128行,每行16字节,就是2^(4+7)=2048 = 256*8,也就是说整个grid二维数组都能装入缓存,再也不会出现什么g[8]驱逐g[0]、g[9]驱逐g[1]的情形。那么此时只可能发生冷不命中,也就是第一次读取某grid行时加载进缓存的动作。举个例子,当读取g[2][0]时,g[2][1]会一起进缓存并且一直保存到程序终结,那么等到程序外层列循环到1时,g[2][1]也会命中,那么除了g[2][0].x引用不命中外,其余的g[2][0].y、g[2][1].x、g[2][1].y引用都会命中,不命中率就会是25%!


        接下来分析下面的循环3代码:
 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
         total_y += grid[i][j].y;
     }
 }

        很明显,这个是具有良好时间和空间局部性的循环,如果缓存没有扩大,还是1024字节的话,用心算就能得出(j是偶数):g[i][j].x不命中,g[i][j].y、g[i][j+1].x、g[i][j+1].y都会命中,不命中率是25%!

        如果缓存行数又扩大到两倍多呢?随便你缓存有多大都没用,而即便缓存只有一行,也就是说缓存总共只有16字节,不命中率仍然是25%,因为这个循环的访问顺序是按照二维数组的存储顺序走的,g[i][j]、g[i][j+1]进缓存后也只使用一次,所以驱逐不驱逐对他们的结果没有影响。

        我们发现一个现象,无论怎么改进程序和缓存行大小,不命中率始终无法小于25%,其实关于不命中率的计算,存在一个很有意思的公式:如果一个高速缓存块大小为B字节,那么一个步长为k的应用模式(这个k是以字(wordsize)为单位,而1字又等于4字节,你可以把字理解成int),平均每次循环会有min(1,(wordsize*k)/B)次不命中。取最小值,也就说最惨的情况是每次都不命中,最好的情况,是(wordsize*k)/B次不命中。


        先看第一个循环:
 for (i = 0; i < 16; i++) {
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
     }
 }
        
        很明显,按步长为8字节访问,说明k是2,最小不命中率应该是:(4*2)/16 = 50%。
        再来看第二个循环:

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[j][i].x;
         total_y += grid[j][i].y;
     }
 }

        很明显,这个是按步长4字节访问,说明k是1,最小不命中率应该是:(4*1)/16 = 25%。

        再来看第三个循环:

 for (i = 0; i < 16; i++){
     for (j = 0; j < 16; j++) {
         total_x += grid[i][j].x;
         total_y += grid[i][j].y;
     }
 }

       很明显,这个是按步长4字节访问,说明k是1,最小不命中率应该是:(4*1)/16 = 25%。


       你可能会质疑循环2的计算方式,步长怎么是4字节?不是访问完两列就跳行么?呵呵,题目设计最巧的地方也是这里。如果缓存的行够多,能够存下所有的元素,先访问和后访问又有啥区别呢?想想是不是这个道理吧!从这里我们能得出结论,最小不命中数,有可能跟缓存的大小有关,比如循环2;也可能跟缓存大小无关,比如循环3——哪怕你只有一行都没事,只要有一个块就够用,因此该公式只能算出最小不命中率,也就是最优情况,但如何实现最优,条件是什么?那就具体问题具体分析了。


        这也许是网上对该习题最详细深入的解读了。实话说,我在开写这一节内容时,越来越发觉自己对例题程序循环2的缓存机制理解有误。之前我一直以为,当访问g[0][0]时,会把g[0]的整行数据:g[0][0]~g[0][15]全部加载进缓存的相应位置,以为这样才能解释为什么习题答案中说g[8][0]没地方存,要驱逐g[0][0]了。但后来发现不对劲!这个假设与循环1和循环3的实现相悖!(转置矩阵例题倒是没看出与之矛盾~囧)凭啥循环1和循环3在读取g[0][0]的时候就没有把g[0][0]~g[0][15]打包加载进缓存呢?!而且也不符合本节第二部分得出的结论:缓存块越大,空间局部性越好,但时间局部性相应就越差;明明你块大小不够了(一次加载只够存g[0][0]、g[0][1]),居然还能用多余的缓存行接着加载g[0][2]、g[0][3]……,这样一来,缓存的块大或者行大又有啥区别呢??思考纠结了好几日,今天终于茅塞顿开!CPU对缓存的利用一定有自己的分配策略,事先将二维数组的各行各列映射到缓存的相应区域,如果映射不完,就让不同的二维数组“元素对”共享同一缓存块(行),这与转置矩阵的分配策略非常类似!这才是对教材前后内容统一的最合理解释。如果你刚好也在这里被迷惑的话,能看到这部分文字是不是感到很幸运?嘿嘿,这道题我也是搜遍网络也找不到其他解答,还好自己悟出来。通过梳理博客到现在,已经有很多地方温故而知新了O(∩_∩)O~



0 0
原创粉丝点击