1、存储器层次结构中的缓存
存储器的层次结构自顶向下通常是:寄存器——高速缓存L1——高速缓存L2——高速缓存L3——主存——本地磁盘——远程二级存储 (比如分布式文件系统、web服务器)。自顶向下,存储设备的速度越来越慢,价格越来越便宜。
上述层次结构中,每一层都缓存来自较低一层的数据对象。本地磁盘可以作为通过网络从远程磁盘取出的文件 (比如web页面) 的缓存,主存可以作为本地磁盘数据的缓存,高速缓存可以作为主存的缓存,依次类推。每一层的缓存包含了下一层数据子集的副本。
- 缓存命中:当程序需要第k+1层的某个数据对象d时,首先会在第k层的存储设备的一个块中查找d。如果d刚好在第k层,那么就是缓存命中。
- 缓存不命中:如果第k层中没有数据d,就是缓存不命中,此时需要从第k+1层取出包含数据d的块,放到第k层,如果放之前第k层的缓存已满,就需要按照一定的策略 (比如最近最少使用LRU) 替换现存的一个块。
2、利用局部性写出对缓存友好的代码
- 时间局部性。由于时间局部性,同一个数据对象可能多次被使用。当一个数据对象在第一次不命中时被复制到缓存中,我们就会期望后面对这个对象有一系列的访问命中。这样可以加速对数据的访问。
- 空间局部性。块通常包含多个数据对象。当把包含某一个数据对象的块复制到高一层的缓存设备中,我们就会期望后面可以对该块中其他数据对象进行访问。
示例代码1:
/*
* 函数 sumArrayRows 的目的是求得二维数组 a 的每个元素的和
*/
int sumArrayRows(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;
}
C语言是以行优先的顺序存储数组。假设高速缓存块共16个字节,也就是可以存放4个int类型的整数。本例中,数组的大小超过了缓存的容量 (实际中更可能是这种情况) 。
现在来看看缓存的命中情况。
首先,我们来看局部变量i,j,sum,由于一直在循环内都被使用,因此具有良好的时间局部性,编译器会把它们缓存在寄存器中。
然后,我们来看对二维数组的访问情况。访问a[0] [0],缓存未命中,把包含a[0] [0]、a[0] [1]、a[0] [2]、a[0] [3]的块复制到缓存中,然后访问a[0] [1]、a[0] [2]、a[0] [3]时,都获得缓存命中。接下来访问a[0] [4],缓存不命中,把包含a[0] [4]、a[0] [5]、a[0] [6]、a[0] [7]的块复制到缓存中,然后访问a[0] [5]、a[0] [6]、a[0] [7]时,都获得缓存命中依次类推…
缓存不命中率为1/4。
接下来看一下示例代码2,在计算结果上与示例代码1一致。
示例代码2:
/*
* 函数 sumArrayRows 的目的是求得二维数组 a 的每个元素的和
*/
int sumArrayRows(int a[M][N]){
int i, j, sum = 0;
for(j = 0; i < M; ++i){
for(i = 0; j < N; ++j){
sum += a[i][j];
}
}
return sum;
}
示例代码2更改了二维数组中元素的访问顺序,是一列一列的访问。这会导致缓存全部不命中。
访问a[0] [0],缓存未命中,把包含a[0] [0]、a[0] [1]、a[0] [2]、a[0] [3]的块复制到缓存中。然后访问a[1] [0],缓存不命中,把包含把包含a[1] [0]、a[1] [1]、a[1] [2]、a[1] [3]的块复制到缓存中,原先的块被替换。然后访问a[2] [0],缓存依然不命中,依次类推…
如果整个数组都可以被放入到缓存中,此时获得的是1/4的缓存不命中。但更可能是,数组比高速缓存大,这样缓存将全部不命中。
小结:
1)一旦从存储器中读入一个数据对象,应该利用程序的时间局部性,尽可能多地使用它;
2)按照数据对象存储在内存中的顺序、以步长为1依次读数据,利用空间局部性,提升程序的性能。
参考资料:《深入理解计算机系统》