在CSI-VII一篇中,我们了解了存储器系统的层次结构,并知道了层次结构自上而下使用了缓存(cashing)技术,因此我们着重介绍了存储系统中高速缓存的工作原理。本篇内容,我们通过分析几个代码实例来分析高速缓存如何影响程序,并提出如何编写高速缓存友好代码的方法。
1. 首先让我们看第一个方法transpose,这是一个矩阵转置函数,定义如下:
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];
}
}
通过这个方法我们先看如何分析缓存的命中情况,在这之前我们假设代码运行的机器具有如下属性:
Sizeof(int)=4。
只有一个L1数据高速缓存,它是直接映射、直写、写分配的,块大小为8字节。
缓存大小为16个数据字节,初始为空。
Src数组地址从开始,dSt数组地址从16开始。
据此我们可以给出内存和缓存的映射关系:
根据这样的映射关系,我们可以看出每次对于读src或者写Dst数组,都会加载数据到缓存,并且刚好每行只能够放2个元素。例如当我们读元素src[0][0]时,会加载src[0][0],src[0][1]到同一高速缓存行中,这是有其在主存中的地址所决定的。同样,加载dst[0][0]时,也会将dst[0][1]加载进来。对于两个数组中的每个元素,下面的表格给出了其命中情况,m代表不命中,h代表命中。
Dst 数组 |
列1 |
列2 |
行1 |
m |
m |
行2 |
m |
m |
src 数组 |
列 1 |
列 2 |
行1 |
m |
m |
行2 |
m |
h |
可以看到,对于dst中的每个元素写都不命中,这个很容易分析出来,从内部循环第一次开始,src[0][0],src[0][1]首先被加载到高速缓存行0中,而在写Dst的时候也会从行0加载,而这时候是src的数据,所以不命中,根据写分配的原则,这时会把dst[0][0],dst[0][1]加载到高速缓存行0中,待到内部循环第二次加载src[0][1]时,由于这时缓存行0中的数据变为了dst[0][0],dst[0][1],所以依旧不命中,就这样依次分析,我们可以得到表格中的结果。
在这里,如果我们将缓存大小调大为32字节,情况会不一样?
当然,如果缓存的大小为32时,那么足够容纳下两个数组的数据,内存行和高速缓存的映射会是一一对应的情况,因此,所有的不命中都将会是开始的冷不命中。
Dst 数组 |
列1 |
列2 |
行1 |
m |
h |
行2 |
m |
h |
src 数组 |
列1 |
列2 |
行1 |
m |
h |
行2 |
m |
h |
2. 我们将要看到的第二个例子,问题是我们在白纸上打印出黄方格,在打印过程中,需要设置方格中每个点的CMYK(蓝色,红色,黄色,黑色)值,这里有几个算法,我们试着分析他们在一个具有2048字节、直接映射、块大小为32字节的数据高速缓存上的效率。
Struct point_color
{
Int c;
Int m;
Int y;
Int k;
}
Strcut point_color sequare[16][16];
Int I,j;
同样有下面的假设:
Sizeof(int)=4;
Sequare其实存储地址0
高速缓存初始为空。
唯一的存储器访问是对于sequare数据中的元素。变量i和j被放在寄存器中。
方法一:
For(i=0;i<16;i++){
For(j=0;j<16;j++){
Sequare[i][j].c=0;
Sequare[i][j].m=0;
Sequare[i][j].y=1;
Sequare[i][j].k=0;
}
}
首先,我们要说明,这里衡量算法的效率的标准就是在程序中高速缓存的不命中率,所以我们需要明确在方法中的引用数量,和不命中数量。这里的引用数量即写数量=16*16*4=1024次,数据大小是16*16*sizeof(point_color)=16^3=4096显然大于缓存大小,且对于每个缓存行都可以容纳两个point_color,即每两循环两次会有一次不命中,不命中数量为16*16/2=128,不命中率为128/1024=12.5%.
方法二:
For(i=0;i<16;i++){
For(j=0;j<16;j++){
Sequare[j][i].c=0;
Sequare[j][i].m=0;
Sequare[j][i].y=1;
Sequare[j][i].k=0;
}
}
方法二中改变了引用数组的索引方式,采用了“列主元”的访问方式,我们知道sequare[0][0]和sequare[1][0]相差16个point_color结构,高速缓存总共有2048/32=64组,即64个缓存行,每行两个point_color结构,那么数据在缓存的存储会是这个样子:
Sequare[0][0] Sequare[0][1]
Sequare[0][2] Sequare[0][3]
…….. ….
Sequare[1][0] Sequare[1][1]
Sequare[1][2] Sequare[1][3]
….. ……
可以预见对于每次内部的每次循环的,都会有一次不命中发生,所以这时的不命中率为16*16/1024=25%
方法三:
For(i=0;i<16;i++){
For(j=0;j<16;j++){
Sequare[i][j].y=1;
}
}
For(i=0;i<16;i++){
For(j=0;j<16;j++){
Sequare[i][j].c=0;
Sequare[i][j].m=0;
Sequare[i][j].k=0;
}
}
方法三写操作分两次执行,但这没有降低缓存的不命中率,在第一次循环中依然是每两次循环发生一次不命中的情况,所以有128次不命中,第二个循环中,虽然写的元素增多了,但是对于某个结构来说,一旦不命中,意味着写的第一个元素写不命中,而其他元素将会命中,一旦命中,则写都命中,所以跟第一个循环的情况其实是一样的,也有128次不命中,那么总得不命中率为(128+128)/1024=25%
通过例二我们学到了分析和预测程序缓存行为的能力,这里我们通过计算程序执行过程中的不命中率来衡量利用高速缓存的性能。
3. 考虑一个n*n矩阵相称的问题:C=AB。例如,如果n=2,那么
矩阵乘法函数通常需要三个嵌套的循环来实现,分别用索引I,j,k来标识。如果我们改变循环的次序,对代码进行一些其他的小改动,我们就能够得到矩阵乘法的六个在功能上等价的版本,如下图所示:
对于这六个不同版本,我们内循环访问的矩阵对来表示每个类,例如ijk和jik是类AB的成员,因为最内层的循环使用的是A和B的矩阵。接下来我们对每个类统计和分析每个内循环迭代中加载读和存储写的数量,每次循环迭代中对A、B和C的引用在高速缓存中的不命中数量,以及每次迭代缓存不命中的总数。为了分析的目的,我们假设:
<1>每个数组都是double类型的数组,sizeof(double)=8
<2>只有一个告诉缓存,块大小为32字节
<3>数组大小的n很大,以至于矩阵的一行都不能完全装入高速缓存行。
在类AB的方法中,内层循环每次有两次加载读操作,A以步长1读取数据,因为每个缓存行可以存储4个数组元素,所以每次迭代A不命中0.25次。另外,B以步长n读取数据,因为n很大,对于B的访问都会不命中,所以每次迭代的总不命中次数为1.25.
在类AC的方法中,内层循环每次有两次加载读操作和一次存储写操作,A和C的步长都为n,所以每次迭代的不命中次数都为1,总的不命中次数为2.
类似的我们分析类BC的方法,可以得到下面的数据:
矩阵乘法版本 类 |
每次迭代使用的加载次数 |
每次迭代使用的存储次数 |
每次迭代A不命中次数 |
每次迭代B不命中次数 |
每次迭代C不命中次数 |
每次迭代总不命中次数2 |
Ijk&jik(AB) |
2 |
0 |
0.25 |
1.00 |
0.00 |
1.25 |
Jki&kji(AC) |
2 |
1 |
1.00 |
0.00 |
1.00 |
2.00 |
Kij&ikj(BC) |
2 |
1 |
0.00 |
0.25 |
0.25 |
0.50 |
通过上面的分析我们明白通过重新排列循环可以提高空间局部性从而使得程序变得高速缓存友好的,尤其对于步长为1的引用,有更将良好的程序性能。但是,随着数组大小的不断增大,时间局部性会降低,而高速缓存中不命中的数据也随着增加。为了改进这个问题,我们使用一种分块的技术,来提供其时间局部性。
分块的思想是将一个程序中的数据结构组织成为块的块组,使得每个能够加载到L1高速缓存中,并在这个块中进行所需的所有的读和写,然后丢掉这个块,加载下一个块,直到完成计算。
因此对于矩阵的乘法的分块我们可以这么处理,将每个矩阵划分成子矩阵,例如n=8,那么我们可以将每个矩阵划分成四个4*4的子矩阵。
分块算法的思想是将A和C划分成1*bsize的行条,将B划分成bsize*bsize的块。最内层的循环对用B的一个块去乘以A的一个行条,将结果放到C的一个行条中,用B中同一个块,i循环迭代通过A和C的行条。
注:这个简单的版本假设的数组大小是块大小的整数倍
分块的关键思想是它加载B的一个块到告诉缓存中,使用它,然后丢弃它,对的引用由很好的空间局部性,因为是以步长1来访问每个行条的。它也有很好的时间局部性,因为是连续bsize次引用整个行条的。对B的引用有好的时间局部性,因为是连续n次访问整个bsize*bsize块的。最后,对C的引用由好的空间局部性,因为行条的每个元素时连续写的,但对C的引用没有好的时间局部性,因为每个行条都只被访问一次。
具有良好局部性的程序应该更容易有较低的不命中率,而不命中率较低的程序往往比不命中率较高的程序运行的更快。所以,我们应该尝试着去编写高速缓存友好的代码。下面我看下几种常见的基本方法:
1) 让最常见的情况运行得快。程序通常把大部分时间花在少量的核心函数上,而这些函数通常把大部分时间花在少量循环上。根据Amdahl定律,我们要把注意力集中占系统大部分时间的核心函数中的循环上从而提升系统的整体性能。
2) 在每个循环内部使缓存不命中数量最小。例如,对于下面的方法
Int sumvec(int v[N])
{
Int I,sum=0;
For(i=0;i Sum+=v[i]; Return sum; } 我们注意到,对于局部变量i和sum,循环体有良好的时间局部性。考虑一下对向量v的步长为1的引用。一般而言,如果一个高速缓存的块大小为B字节,那么一个步长为k的引用模式平均每次循环迭代会有min(1,(wordsize*k)/B)次缓存不命中。当k=1时,它取最小值,所以步长为1的引用是高速缓存友好的。 (3)一旦从存储器器中一个数据对象,就尽可能多的使用它,从而使得你程序中的时间局部性最大。