利用计算机缓存优化代码

85 阅读5分钟

在之前的文章中,我们探讨了高速缓存的重要性,尤其是「局部性原理」如何影响程序的性能。

局部性比较好的程序在缓存逻辑中有较高的命中率,而较高命中率的程序比较低命中率的程序运行速度要快。

因此从具有良好的局部性的意义上来说,优秀程序员总是致力于编写「高速缓存友好」代码,以最大程度地利用这一原理。

以下是一些基本的方法,帮助实现这一目标:

优化常见情景

程序通过把大部分时间都花在少量的核心代码上,而核心代码又把大部分时间都花在了少量循环上,所以,我们需要着重关注核心代码循环。

提高循环内缓存命中率

在相同的数据加载和存储次数下,拥有更高命中率的循环会运行得更快。因此,在设计循环时,务必关注如何最大程度地利用缓存,减少不必要的数据访问,从而提高命中率。

全是理论文字,不直观,我们来看代码:

const sumvec = (v) => {
	let i,sum = 0
	for (i = 0; i < v.length; i++ ){
		sum += v[i]
	}
	return sum
}

我们来看下这个函数是不是「高速缓存」友好的?

首先,对于局部变量 i 和 sum,因为它们都是局部变量,编译器会把它们缓存在寄存器文件中,也就是存储器层次结构的最高层中,因此,就时间局部性而言,循环体内具有良好的性能。

而对于步长为 1 的引用,它对高速缓存友好。假设每个字 是 4 字节,高速缓存块的大小也是 4 个字节,考虑到第一次高速缓存初始为空(冷高速缓存)中,我们可以得出以下命中和不命中的模式:

image.png

  1. 对于 v[0] 的引用,是不命中的,因为初始时缓存为空(冷缓存),所以需要从内存加载 v[0];
  2. 对于 v[1] ~ v[3] 的引用,这些数据块在第一次循环迭代中已经加载到了高速缓存中,所以这三个引用都会命中。
  3. 之前缓存只取了 v[0] ~ v[3],在访问 v[4] 的时候,缓存中不存在,则去内存中重新访问,并加载到高速缓存中,然后剩下的 v[5] ~ v[7] 会命中。

在四个引用中,三个会命中,在冷缓存的情况下,已经是最好的情况了。

强调两个重点:

  1. 对局部变量的反复引用是好的,因为编译器能够将它们缓存在寄存器文件中,会显著提高访问速度,这是时间的局部性。
  2. 步长为 1 的引用模式是好的,存储器的缓存是将数据存储为连续的块,减少了缓存不命中的概率,这是空间局部性。

在对多维数组操作的时候,空间局部性尤其重要:

const array = [
    [1, 2, 3],
    [4, 5, 6]
];

const sumArrayRows = (arr) => {
    let sum = 0;
		const M = arr.length;
		const N = arr[0].length;
    for (let i = 0; i < M; i++) {
        for (let j = 0; j < N; j++) {
            sum += arr[i][j];
        }
    }
    return sum;
};

对于数组 a 的引用会得到下面命中与不命中的模式:

image.png

如果我们改一下:

const sumArrayCols = (arr) => {
		let sum = 0;
    const M = arr.length;
    const N = arr[0].length; // Assuming all rows have the same length
    
    for (let j = 0; j < N; j++) {
        for (let i = 0; i < M; i++) {
            sum += arr[i][j];
        }
    }
    
    return sum;
};

我们移动了 M 和 N 的循环顺序,虽然改动不是很大,我们将一行一行的循环,改成一列一列的循环。我们每次对 arr[i][j] 的访问都不会命中。

image.png

以「行」为循环的运行速度比「列」循环的速度快了将近 25 倍,所以,程序员应该注意他们程序中的局部性,要编写有利于局部性的程序。

优秀的程序员会试图构造他们的程序,利用时间局部性频繁的从 L1 中取出,还要利用空间局部性,便利尽可能的从一个 L1 的「缓存行」中访问到。

所以,我们推荐以下几个注意事项:

  1. 优秀的程序员将注意力集中在内循环上,因为内循环中的大部分操作和数据访问会对程序的性能产生重大影响;
  2. 通过使用步长为 1 的数据访问模式,最大限度地发挥空间局部性的优势,将连续的数据块加载到高速缓存中,从而降低缓存不命中的概率。
  3. 充分利用从存储器中读取的数据对象,以优化时间局部性,减少不必要的数据加载操作,从而提高程序性能。
  4. 选择合适的数据结构,避免碎片和内存占用过多,确保数据的存储方式有助于缓存的有效利用;
  5. 简化分支逻辑可以减少流水线阻塞和预测失败,有助于保持程序的高效性能。

内容来源:《深入理解计算机系统》

如果您对本篇文章中提到的问题有任何疑问或想法,请在评论区留言,我将尽力回复。

微信公众号「小道研究」,获取更多关于前端技术的深入分析和实践经验。