对于软件的缓存访问问题进行优化的第一步应该是选择合适的编译器选项,使得编译器能够根据你的应用和要针对的处理器进行优化。每个处理器采用不同的缓存,比如通过QxW针对P4处理器进行优化,/O3允许一些循环分割、合并等激进优化,/Qipo通过过程间优化可以减少代码的大小,通过代码移动优化使得经常调用的变量和函数可以放在一起,增加了空间的局部性,采用Profile导向的优化,可以知道哪些分支经常使用,提高指令缓存和数据缓存的利用率。
虽然编译器的功能非常强大,但是也不是万能的,只有程序员才知道程序的具体行为,有的时候程序员也可以手工修改代码来帮助编译器进行更进一步的优化。那些具有更好的时间局部性的空间和时间局部性的代码一般会有更低的缓存缺失率,从而在大部分情况下回比那些高缺失率的代码运行得更快。考虑到好的时间局部性,那些经常访问的数据可以通过一个局部变量来保存,考虑到空间局部性,步长为1的内存访问显然是最好的。大部分的缓存优化都是针对那些访问数组元素的循环的,而提高这些循环的局部性,需要考虑循环访问数组的模式,从而分析是什么原因引起的缓存缺失,然后通过改变循环的顺序、结构、数组的布局等来减少缓存的缺失。
示例1:考虑下面的代码:

点击(此处)折叠或打开

  1. int sumarrarcols(int a[M][N])
  2. {
  3. int i,j,sum=0;
  4. for(j=0;j<N;j++)
  5.   for(i=0;i<M;i++)
  6.     sum+=a[i][j];

  7. return sum;
  8. }

由于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,这些不连续的元素可能会位于不同的缓存行,会出现较多的强制性缺失,因此在优化时可以通过循环交换来改变循环的顺序来提高空间的局部性。

点击(此处)折叠或打开

  1. int sumarraryrows(int a[M][N])
  2. {
  3. int i,j,sum=0;
  4. for(i=0;i<M;i++)
  5.    for(j=0;j<N;j++)
  6.        sum+=a[i][j];


  7. return sum;
  8. }
对于第二个函数,二维数组a求和访问的内存顺序与内存中的属性一致,步长为1,在第一次访问a[0][0]时会出现缓存缺失,但是因为内存中的数据是以缓存行为单位加载进缓存的,因此访问后面的元素就会缓存命中,知道数组元素属于另外衣蛾缓存行,这时再把下一个缓存行加载到缓存中。

示例2  考虑下面的代码:

点击(此处)折叠或打开

  1. for(i=0;i<N;i++)
  2.    a[i]=b[i]+1;

  3. for(i=0;i<N;i++)
  4.    c[i]=b[i]*3;
两个循环都要访问数组b[i],可以把两个循环合并,这样可以通过时间局部性来减少对于数组b[i]的访问:


点击(此处)折叠或打开

  1. for(i=0;i<N;i++)
  2. {
  3.        a[i]=b[i]+1;
  4.        c[i]=b[i]*3;
  5. }
示例3 考虑下面的代码:

点击(此处)折叠或打开

  1. for(i=0;i<N;i++)
  2.     {
  3.            a[i]=b[i]+1;
  4.            c[i]=b[i]*3;
  5.            f[i] = g[i]+h[i];
  6.     }
假设N非常大,采用4-way缓存,但是在循环里面需要访问六个数组,这些数组的地址间的距离比较大,可能会导致这些数组都映射为同一个组,那么在每次循环过程中后面两次对于数组的访问都会导致缓存缺失冲突,这时可以把循环分割成为两个,每次循环访问的数组不超过四个,从而减少冲突缺失:

点击(此处)折叠或打开

  1. for(i=0;i<N;i++)
  2. {
  3.   a[i]=b[i]+1;
  4.   c[i]=b[i]*3;
  5.  }

  6. for(i=0;i<N;i++)
  7. {
  8.     f[i] = g[i]+h[i];
  9. }

如果要访问的对象占用的空间非常大,而缓存行相对来说比较小,这个时候对缓存的利用变得非常重要,看看下面的矩阵加法的运算代码.
示例4

点击(此处)折叠或打开

  1. void add(int a[MAX][MAX],int b[MAX][MAX])
  2. {
  3.    int i,j;
  4.    for(i=0;i<MAX;i++)
  5.       for(j=0;j<MAX;j++)
  6.          a[i][j] = a[i][j]+b[j][i];
  7. }
在上面的循环中,对于a的访问步长为1,可以利用空间的局部性,但是b的访问是按列进行访问的,也就是b[0][i],b[1][i]...,步长为MAX,如果数组比较大,数组中的一行业大于缓存的大小,这样每次访问b都可能会出现缺失,要重新载入一个缓存行。循环块优化可以把数组分成一个个的小块数据,这个数据块可以容纳入缓存之中,在对于块缓存进行处理之后就可以完全丢弃,然后继续处理下一个数据,优化后的代码为:

点击(此处)折叠或打开

  1. #define BS //block size is selected as the loop-blocking factor
  2. void add(int a[MAX][MAX],int b[MAX][MAX])
  3. {
  4.   int i,j,ii,jj;
  5. for(i=0;i<MAX;i+=BS)
  6.    for(j=0;j<MAX;j+=BS)
  7.       for(ii=0;ii<BS;ii++)
  8.          for(jj=0;jj<BS;jj++)
  9.             a[ii][jj]=a[ii][jj]+b[jj][ii];
  10. }


除了通过改变循环的顺序和循环的结构来进行缓存优化之外,还可以通过选择合适的数据布局来进行优化,其基本原则就是把那些经常使用的数据放在一起,这样可以利用空间的局部性。比如有两个数组,每次访问一个数组时,总是要访问另外一个数组:
示例5:

点击(此处)折叠或打开

  1. #define N 1024
  2. int a[N],b[N];
  3. for(i=0;i<N;i++)
  4.   a[i]=Func(b[i]);

这个时候可以把两个数组合并起来,先定义一个结构体类型,然后再顶一个结构体数组:

点击(此处)折叠或打开

  1. #define N 1024
  2. struct ALL{int a,b};
  3. struct ALL M[N];
  4. for(i=0;i<N;i++)
  5.   M[i].a = Func(M[i].b);


如果一个大的结构中只有少数几个字段会被经常访问,但是大部分字段的访问次数相对比较小,这个时候可以把原来的结构数组分割成多个数组,考虑下面的代码:

点击(此处)折叠或打开

  1. typdef struct __LISY{
  2. char key[8];
  3. cahr val[48];
  4. struct _LIST *next;
  5. }List;

  6. List *lookup(char *key,List *head){
  7. while(head != NULL){
  8. if(strncmp(key,head->key,strlen(key))==0)
  9. return head;

  10. head = head->next;
  11. }
  12. }

查找List的循环中只用到结构中的8个字节,但是val占用的字节相对比较大,这样按照顺序访问时容易出现缓存的浪费,代码可以优化为:

点击(此处)折叠或打开

  1. typdef struct __LIST{
  2.     cahr val[64];
  3.     struct _LIST *next;
  4.     }List;
  5.     List list[MAX];
  6.     char keylist[MAX*8];
  7.     List *lookup(char *key,List *head,char *keyList,int nlist){
  8. int i=0;
  9.     while(i<nlist){
  10.     if(strncmp(key,keylist,strlen(keylist))==0)
  11.      return head[i];
  12.     i++;
  13.     headList += strlen(keyList)+1;
  14.     }
  15.     }

除了缓存缺失影响内存的访问速度之后,另外一个影响内存的访问速度的因素就是数据对齐。一般来说,一个对齐的数据的访问是最为有效的,这就要求变量的地址应该是能够被该变量占用的字节数整除。比如,一个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;



09-21 14:10