虽然编译器的功能非常强大,但是也不是万能的,只有程序员才知道程序的具体行为,有的时候程序员也可以手工修改代码来帮助编译器进行更进一步的优化。那些具有更好的时间局部性的空间和时间局部性的代码一般会有更低的缓存缺失率,从而在大部分情况下回比那些高缺失率的代码运行得更快。考虑到好的时间局部性,那些经常访问的数据可以通过一个局部变量来保存,考虑到空间局部性,步长为1的内存访问显然是最好的。大部分的缓存优化都是针对那些访问数组元素的循环的,而提高这些循环的局部性,需要考虑循环访问数组的模式,从而分析是什么原因引起的缓存缺失,然后通过改变循环的顺序、结构、数组的布局等来减少缓存的缺失。
示例1:考虑下面的代码:
点击(此处)折叠或打开
- int sumarrarcols(int a[M][N])
- {
- int i,j,sum=0;
- for(j=0;j<N;j++)
- for(i=0;i<M;i++)
- sum+=a[i][j];
- return sum;
- }
由于C语言的数组是按照行来进行存储的,也就是a[0][0],a[0][1],...a[0][N-1],a[1][0],a[1][1],....,而sumarrarcols则是按照列顺序访问的,也就是a[0][0],a[1][0],a[2][0],...,a[M-1][0],a[0][1],a[1][1]...,步长为M,这些不连续的元素可能会位于不同的缓存行,会出现较多的强制性缺失,因此在优化时可以通过循环交换来改变循环的顺序来提高空间的局部性。
点击(此处)折叠或打开
- int sumarraryrows(int a[M][N])
- {
- int i,j,sum=0;
- for(i=0;i<M;i++)
- for(j=0;j<N;j++)
- sum+=a[i][j];
- return sum;
- }
示例2 考虑下面的代码:
点击(此处)折叠或打开
- for(i=0;i<N;i++)
- a[i]=b[i]+1;
- for(i=0;i<N;i++)
- c[i]=b[i]*3;
点击(此处)折叠或打开
- for(i=0;i<N;i++)
- {
- a[i]=b[i]+1;
- c[i]=b[i]*3;
- }
点击(此处)折叠或打开
- for(i=0;i<N;i++)
- {
- a[i]=b[i]+1;
- c[i]=b[i]*3;
- f[i] = g[i]+h[i];
- }
点击(此处)折叠或打开
- for(i=0;i<N;i++)
- {
- a[i]=b[i]+1;
- c[i]=b[i]*3;
- }
- for(i=0;i<N;i++)
- {
- f[i] = g[i]+h[i];
- }
如果要访问的对象占用的空间非常大,而缓存行相对来说比较小,这个时候对缓存的利用变得非常重要,看看下面的矩阵加法的运算代码.
示例4
点击(此处)折叠或打开
- void add(int a[MAX][MAX],int b[MAX][MAX])
- {
- int i,j;
- for(i=0;i<MAX;i++)
- for(j=0;j<MAX;j++)
- a[i][j] = a[i][j]+b[j][i];
- }
点击(此处)折叠或打开
- #define BS //block size is selected as the loop-blocking factor
- void add(int a[MAX][MAX],int b[MAX][MAX])
- {
- int i,j,ii,jj;
- for(i=0;i<MAX;i+=BS)
- for(j=0;j<MAX;j+=BS)
- for(ii=0;ii<BS;ii++)
- for(jj=0;jj<BS;jj++)
- a[ii][jj]=a[ii][jj]+b[jj][ii];
- }
除了通过改变循环的顺序和循环的结构来进行缓存优化之外,还可以通过选择合适的数据布局来进行优化,其基本原则就是把那些经常使用的数据放在一起,这样可以利用空间的局部性。比如有两个数组,每次访问一个数组时,总是要访问另外一个数组:
示例5:
点击(此处)折叠或打开
- #define N 1024
- int a[N],b[N];
- for(i=0;i<N;i++)
- a[i]=Func(b[i]);
这个时候可以把两个数组合并起来,先定义一个结构体类型,然后再顶一个结构体数组:
点击(此处)折叠或打开
- #define N 1024
- struct ALL{int a,b};
- struct ALL M[N];
- for(i=0;i<N;i++)
- M[i].a = Func(M[i].b);
如果一个大的结构中只有少数几个字段会被经常访问,但是大部分字段的访问次数相对比较小,这个时候可以把原来的结构数组分割成多个数组,考虑下面的代码:
点击(此处)折叠或打开
- typdef struct __LISY{
- char key[8];
- cahr val[48];
- struct _LIST *next;
- }List;
- List *lookup(char *key,List *head){
- while(head != NULL){
- if(strncmp(key,head->key,strlen(key))==0)
- return head;
- head = head->next;
- }
- }
查找List的循环中只用到结构中的8个字节,但是val占用的字节相对比较大,这样按照顺序访问时容易出现缓存的浪费,代码可以优化为:
点击(此处)折叠或打开
- typdef struct __LIST{
- cahr val[64];
- struct _LIST *next;
- }List;
- List list[MAX];
- char keylist[MAX*8];
- List *lookup(char *key,List *head,char *keyList,int nlist){
- int i=0;
- while(i<nlist){
- if(strncmp(key,keylist,strlen(keylist))==0)
- return head[i];
- i++;
- headList += strlen(keyList)+1;
- }
- }
除了缓存缺失影响内存的访问速度之后,另外一个影响内存的访问速度的因素就是数据对齐。一般来说,一个对齐的数据的访问是最为有效的,这就要求变量的地址应该是能够被该变量占用的字节数整除。比如,一个double需要8个字节的存储空间,因此,如果double的地址不是8的倍数时,访问该变量会带来额外的延迟。值得注意的是,如果一个变量分配的空间跨越缓存行时,比如考虑变量的地址为0xFF0000FC,前面四个字节和后面四个字节在不同的缓存行,访问该变量需要加载进两个缓存行,这种跨越L缓存行(64B)边界的未对齐数据访问被称为载入分离或者存储分离。考虑下面的代码行:
double *p = (double *)malloc(sizeof(double)*number_of_doubles);
这行代码不能保证p的地址一定是8字节对齐的,这是因为malloc访问的变量会保证4字节对齐,那么访问double数据时的性能将会非常糟糕,上面的代码可以优化为:
double *p;
double *np;
p== (double *)malloc(sizeof(double)*(number_of_doubles+7L));
np = (double *)(((long )(p)+7L)&(-8L));
有时你可能希望大的对象或者数组能够与缓存行对齐,这是可以采用declspec来说明:
__declspec(align(64))
int BigArray[1024];
在使用多维数组时,维数不是2的幂时,可能会导致访问后面的数组元素出现不对齐的情况,考虑下面的代码:
int a[64][63];
for(i=0;i<64;i++)
for(j=0;j<63;j++)
a[i][j] = i*64 +j;
假设数组的起始位置是16字节对齐的,a[0][0],a[0][1],...,a[0][62]为16字节对齐,但是现在a[1][0]的距离16字节的边界差12字节,不是16字节对齐的,显然上面的代码可能通过填充来保证每个数组元素都是16字节对齐。牺牲了空间来换取对齐带来的内存访问的性能提升:
int a[64][64];
for(i=0;i<64;i++)
for(j=0;j<63;j++)
a[i][j] = i*64 +j;