高速缓存对程序性能的影响

前言

本篇博客以“SSD6-Exercise5:Cache Lab”为例,首先介绍存储器的层次结构,重点阐述了提高程序局部性的重要性,以及编写高速缓存友好代码的方法。


存储器层次结构

如果你了解系统是如何将数据再存储器层次结构中上上下下移动的,那么你就可以编写自己的应用程序,使得它们的数据项存储在层次结构中较高的地方,在那里CPU可以更快地访问到它们。

实际上,存储器系统 (memory system) 是一个具有不同容量、成本和访问时间的存储设备的层次结构。

CPU寄存器保存着最常用的数据,靠近CPU的小的、快速的高速缓存存储器 (cache memory) 作为一部分存储在相对慢速的主存储器 (main memory) 中数据和指令的缓冲区域。主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。

1

如上图所示,L1、L2、L3均为不同级别的高速缓存,本篇博客主要讨论的即为高速缓存(cache)对程序性能的影响。


局部性

这些思想围绕着计算机程序的一个称为局部性 (locality) 的基本属性。具有良好局部性的程序倾向于一次又一次地访问相同的数据项集合,或者倾向于访问邻近的数据项集合。

局部性通常有两种不同的形式:时间局部性 (temporal locality)空间局部性 (spatial locality)

  • 时间局部性:被引用过一次的内存位置,很可能在不远的将来再被多次引用。
  • 空间局部性:如果一个内存位置被引用了一次,那么程序很可能在不远的将来饮用附近的一个内存位置。

在量化评价程序中的局部性时,还有以下的一些原则:

  • 重复引用相同变量的程序,具有良好的时间局部性。
  • 对于具有步长为k的引用模式的程序,步长越小,空间局部性越好。
  • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

性能影响

局部性好的程序更容易有较低的不命中率,而不命中率较低的程序往往比不命中率较高的程序运行得更快。

因此,从具有良好局部性的意义上来说,好的程序员总是应该试着去编写高速缓存友好 (cache friendly) 的代码。

下面是我们用来确保代码高速缓存友好的基本方法:

  1. 让最常见的情况运行得快.
  2. 尽量减少每个循环内部的缓存不命中数量.

实例:编写高速缓存友好的代码

题目要求

题目详情参见SSD6-Exercise5:Cache Lab。下面将题目中的几个重点特别说明:

  • Cache Structure: 16KB directed-mapped cahe, with 32-byte cache lines
  • Assumptions: To make optimization easier, you may assume that the image dimensions will always be a multiple of 32.
  • Hints:
    • The rotate function focuses on spatial locality: because each pixel is used only once,you should focus on using any pixels put into the cache by a previous pixel operation.
    • The smooth function benefits from spatial locality, but also reuses pixels it has readpreviously. Consequently, you should consider trying to improve temporal locality as well.

Naive Functions

未经局部性优化时,smooth()rotate()函数的实现分别如下:

Smooth

void smooth(int dim, pixel *src, pixel *dst) {
	int i, j;
	for(i=0; i<dim;i++) {
		COPY(&dst[PIXEL(i,0,dim)], &src[PIXEL(i,0,dim)]);
		COPY(&dst[PIXEL(i,dim-1,dim)], &src[PIXEL(i,dim-1,dim)]);
	}
	for(j=1; j<dim-1;j++) {
		COPY(&dst[PIXEL(0,j,dim)], &src[PIXEL(0,j,dim)]);
		COPY(&dst[PIXEL(dim-1,j,dim)], &src[PIXEL(dim-1,j,dim)]);
	}
	for(i=1; i<dim-1; i++) {
		for(j=1; j<dim-1; j++) {
			SMOOTH(&dst[PIXEL(j,i,dim)],
					&src[PIXEL(j,i,dim)],
					&src[PIXEL(j-1,i,dim)],
					&src[PIXEL(j+1,i,dim)],
					&src[PIXEL(j,i+1,dim)],
					&src[PIXEL(j,i-1,dim)],
					&src[PIXEL(j-1,i-1,dim)],
					&src[PIXEL(j+1,i+1,dim)],
					&src[PIXEL(j-1,i+1,dim)],
					&src[PIXEL(j+1,i-1,dim)]);
		}
	}
	return;
}

smooth()分别对像素点(j, i)及其周围的8个点进行复制。

Rotate

void rotate(int dim, pixel *src, pixel *dst) {
	int i, j;

	for(i=0; i < dim; i++) {
		for(j=0; j < dim; j++) {
			COPY(&dst[PIXEL(dim-1-j,i,dim)], &src[PIXEL(i,j,dim)]);
		}
	}

	return;
}

rotate()将像素点(i, j)复制到(dim-1-j, i)处。

设计思路

根据上文对局部性的介绍,再根据题目中所提供的线索,我们可以从以下的角度设计对高速缓存友好的代码:

  • Smooth:利用空间局部性,避免读取数组元素时多次跳跃。
  • Rotate:对于源像素集合的遍历,与目标像素集合的遍历,不可能在步长为1的情况下,同时追求二者均达到较好的局部性。因此,可采用矩阵分块的方法,改变步长与循环层数。

具体实现

Smooth

void smooth(int dim, pixel *src, pixel *dst) {
    int i, j;
    for (i = 0; i < dim; i++) {
        COPY(&dst[PIXEL(i, 0, dim)], &src[PIXEL(i, 0, dim)]);
        COPY(&dst[PIXEL(i, dim - 1, dim)], &src[PIXEL(i, dim - 1, dim)]);
    }
    for (j = 1; j < dim - 1; j++) {
        COPY(&dst[PIXEL(0, j, dim)], &src[PIXEL(0, j, dim)]);
        COPY(&dst[PIXEL(dim - 1, j, dim)], &src[PIXEL(dim - 1, j, dim)]);
    }
    for (j = 1; j < dim - 1; j++) {
        for (i = 1; i < dim - 1; i++) {
            SMOOTH(
                   &dst[PIXEL(j, i, dim)],
                   &src[PIXEL(j, i - 1, dim)],
                   &src[PIXEL(j, i, dim)],
                   &src[PIXEL(j, i + 1, dim)],
                   &src[PIXEL(j - 1, i, dim)],
                   &src[PIXEL(j + 1, i, dim)],
                   &src[PIXEL(j - 1, i - 1, dim)],
                   &src[PIXEL(j + 1, i + 1, dim)],
                   &src[PIXEL(j - 1, i + 1, dim)],
                   &src[PIXEL(j + 1, i - 1, dim)]
                   );
        }
    }
    return;
}

上述代码改变了ij的层顺序,从而实现了较好的空间局部性与时间局部性。

Rotate

void rotate(int dim, pixel *src, pixel *dst) {
    int mat = 4;

    for(int col = 0; col < dim; col += mat)
        for (int row = 0; row < dim; row += mat)
            for (int j = col + mat - 1;j >= col;j-- )
                for (int i = row; i < row + mat; i++)
                    COPY(&dst[PIXEL(dim - 1 - j, i, dim)], &src[PIXEL(i, j, dim)]);
}

根据“Cache Structure: 16KB directed-mapped cahe, with 32-byte cache lines”的高速缓存结构,在这里采取步长为4的策略,并加深循环的层数以达到良好的空间局部性。

测试结果

测试结果如下:

DEBUG: dimension=64.0
DEBUG: work=4096.0
DEBUG: dimension=128.0
DEBUG: work=16384.0
DEBUG: dimension=256.0
DEBUG: work=65536.0
DEBUG: dimension=512.0
DEBUG: work=262144.0
DEBUG: dimension=1024.0
DEBUG: work=1048576.0
Rotate: Version = Rotate Reference Naive Implementation!:
Dim	64	128	256	512	1024	Mean
hitrate	86.8	43.8	43.8	43.7	43.7
Incr.	1.00	1.00	1.00	1.00	1.00	1.00

DEBUG: dimension=64.0
DEBUG: work=4096.0
DEBUG: dimension=128.0
DEBUG: work=16384.0
DEBUG: dimension=256.0
DEBUG: work=65536.0
DEBUG: dimension=512.0
DEBUG: work=262144.0
DEBUG: dimension=1024.0
DEBUG: work=1048576.0
Rotate: Version = Exchanged-Matrix Row-wise Traversal of src:
Dim	64	128	256	512	1024	Mean
hitrate	87.1	81.2	81.2	80.9	81.0
Incr.	1.00	1.86	1.86	1.85	1.85	1.64

Best algo here: Exchanged-Matrix Row-wise Traversal of src, 	1.640945


DEBUG: dimension=64.0
DEBUG: work=4096.0
DEBUG: dimension=128.0
DEBUG: work=16384.0
DEBUG: dimension=256.0
DEBUG: work=65536.0
DEBUG: dimension=512.0
DEBUG: work=262144.0
DEBUG: dimension=1024.0
DEBUG: work=1048576.0
Smooth: Version = Smooth Reference Naive Implementation!:
Dim	64	128	256	512	1024	Mean
hitrate	63.1	45.4	45.6	45.7	45.8
Incr.	1.00	1.00	1.00	1.00	1.00	1.00

DEBUG: dimension=64.0
DEBUG: work=4096.0
DEBUG: dimension=128.0
DEBUG: work=16384.0
DEBUG: dimension=256.0
DEBUG: work=65536.0
DEBUG: dimension=512.0
DEBUG: work=262144.0
DEBUG: dimension=1024.0
DEBUG: work=1048576.0
Smooth: Version = Exchanged-row-column Traversal of src:
Dim	64	128	256	512	1024	Mean
hitrate	63.1	63.8	64.2	64.4	64.5
Incr.	1.00	1.41	1.41	1.41	1.41	1.31

Best algo here: Exchanged-row-column Traversal of src, 	1.314475

Program ended with exit code: 0

我们可以看到每个函数在处理各尺寸图像时cache的命中率,通过局部性的改良,程序的运行速度得到了极大的提升。


总结

高速缓存友好的代码特点

  1. 让最常见的情况运行得快。程序通常把大部分时间都花在少量的核心函数上,而这些函数通常把大部分时间都花在了少量的循环上。所以要把注意力集中在核心函数的循环上,而忽略其他部分。

  2. 在每个循环内部使缓存不命中数量最小。在其他条件,例如加载和存储的总次数相同的情况下,不命中率低的程序运行得更快。

    注意:编译器将局部变量存储到寄存器中,因此循环内对局部变量的引用不需要任何加载或存储指令。

#### 高速缓存对程序性能的影响

  1. 通过重新排列循环以提高空间局部性:降低高速缓冲的不命中率。例子(求两个矩阵的乘积)
  2. 使用分块来提高时间局部性。

分块的大致思想是将一个程序中的数据结构组织成称为块(block)组块(chunk)。这里的“块”指的是一个应用级的块,不是高速缓冲块。这样构造程序,使得能够将一个块加载到L1高速缓存中,并在这个块中进行所需的所有的读和写,然后丢掉这个块,加载下一个块,以此类推。


参考资料

[1] 《深入理解计算机系统》(第3版).Randal E. Bryant, David R.O’Hallaron 著.

[2] 博客:程序设计原则——局部性原理.

[3] 博客:SSD6-Exercise5:Cache Lab