任何代码的执行都依赖 CPU,通常使用好 CPU 是操作系统的工作。然而当我们编写计算密集型程序时,CPU 的执行效率变得至关重要。由于 CPU 缓存由更快的 SRAM 构成(内存由 DRAM 构成),而且离 CPU 核心更近,如果运算时需要的输入数据是从 CPU 缓存读取,而不是内存中读取的,运算速度会变得更快。所以,了解CPU缓存对性能的影响,便能够有效的编写我们的代码,优化程序性能。
一、CPU 架构
一个 CPU 处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(L2 cache)。这里提到一个概念,就是物理核的私有缓存。它其实是指缓存空间只能被当前这个物理核使用,其他物理核无法对这个缓存空间进行数据存取。
程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存中,最后进入最快的一级缓存,之后才会被 CPU 使用。
因为 L1 和 L2 是每个物理核私有的,所以当数据或指令保存在 L1、L2 缓存时,物理核访问它们的延迟不超过 10 纳秒,速度非常快。如果把要运行的指令或存取的数据保存在 L1 和 L2 缓存的话,就能高速访问这些指令和数据了。
但 L1 和 L2 缓存的大小受限于处理器的制造技术,一般只有 KB 级别,存不下太多的数据。如果 L1、L2 缓存中没有所需的数据,应用程序就需要访问内存来获取数据。而应用程序的访问延迟一般在百纳秒级别,是访问 L1、L2 缓存延迟的近 10 倍,不可避免地对性能造成影响。
所以,不同的物理核会共享一个共同的三级缓存(L3),L3 缓存能够使用的存储资源比较多,所以一般比较大,能达到几 MB 到几十 MB,这就能让应用程序缓存更多的数据。当 L1、L2没有数据时,可以访问 L3,尽可能的避免访问内存。
现在主流的 CPU 处理器,每个物理核通常都会运行两个超线程,也叫做逻辑核,同一个物理核共享 L1、L2 缓存。
在多 CPU 架构上,应用程序可以在不同的处理器上运行,比如现在物理核1上运行,然后又切换到物理核2上运行。
如果一个应用程序先在一个核上运行,并且把数据保存到了内存,然后又被调度到了另一个核上运行,此时应用程序在进行内存访问时,就需要访问之前核上的链接内存,这种访问属于远端访问。和访问自身直接的内存相比,远端访问会增加应用程序的延迟。
如果能从自己的内存中获取到数据,称为缓存命中。那如何能提高缓存命中率呢?
二、提升 CPU 缓存命中率
2.1 数据访问顺序对命中率的影响
比如现在要遍历二维数组,其定义如下:
int[][] array;
先提一个问题思考下,用 array[j][i] 和 array[i][j] 访问数组元素,哪一种性能更快?
for(i = 0; i < N; i+=1) {
for(j = 0; j < N; j+=1) {
array[i][j] = 0;
}
}
前者 array[j][i] 执行的时间是后者 array[i][j] 的 8 倍之多。
为什么会有这么大的差距呢?这是因为二维数组 array 所占用的内存是连续的,比如若长度 N 的值为 2,那么内存中从前至后各元素的顺序是:
array[0][0],array[0][1],array[1][0],array[1][1]
如果用 array[i][j] 访问数组元素,则完全与上述内存中元素顺序一致,因此访问 array[0][0]时,缓存已经把紧随其后的 3 个元素也载入了,CPU 通过快速的缓存来读取后续 3 个元素就可以。如果用 array[j][i] 来访问,访问的顺序就是:
array[0][0],array[1][0],array[0][1],array[1][1]
此时内存是跳跃访问的,如果 N 的数值很大,那么操作 array[j][i] 时,是没有办法把 array[j+1][i] 也读入缓存的。
为什么两者的执行时间有 7 8 倍的差距呢?
其实这与 CPU Cache Line 相关,它定义了一次缓存数据的大小,Linux 可以通过coherency_line_size 配置查看它,通常是 64 字节。
因此,当载入 array[0][0] 时,若他们占用的内存不足 64 字节,CPU 就会顺序的补足后续元素。顺序访问 array[i][j] 因为利用了这一特点。所以要比 arrary[j][i] 快。也正因为这样,当元素类型是 4 个字节的整数时,性能就会比 8 个字节的高精度浮点数时速度更快,因为缓存一次载入的元素会更多。
因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将带来很大的性能提升。
在二维数组中,其实第一维元素存放的是地址,第二维存放的才是目标元素。由于 64 位操作系统的地址占用 8 字节(32位操作系统是4字节),因此,每批 Cache Line 最多也就能载入不到 8 个二维数组元素,所以性能差距大约接近 8 倍。
2.2 提升指令缓存命中率
加入有这样一个场景,有一个数组,数组内装的事随机数,接下来对他做两件事:一是循环遍历数组,判断每个数字是否小于 128,如果小于则把元素的值置为 0;二是将数组排序。那么,先排序再遍历速度快,还是先遍历再排序速度快呢?
先给出答案:先排序的遍历时间只有后排序的三分之一。为什么会这样呢?这是因为循环中有大量的 if 条件分支,而 CPU含有分支预测器。
当代码中出现 if、switch 等语句时,意味着此时至少可以选择跳转到两端不同的指令中去执行。如果分支预测期可以预测接下来要在哪段代码执行,就可以提前把这些执行放在缓存中,CPU 执行时就会很快。当数组中的数据完全随机时,分支预测期无法有效的工作,而数组有序时,分支预测期会动态地根据历史命中数据对未来进行预测,命中率就会非常高。
2.3 提升多核 CPU 下的缓存命中率
前面都是面向一个 CPU 核心谈数据及指令缓存的,然而现代 CPU 几乎都是多核的。虽然三级缓存面向所有核心,但一、二级缓存是每颗核心独享的。我们知道,即使只有一个 CPU 核心,现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片,微观上各进程按时间片交替地占用 CPU,这造成宏观上看起来各程序同时在执行。
因此,若进程 A 在时间片 1 里使用 CPU 核心 1,自然也填满了核心 1 的一、二级缓存,当时间片 1 结束后,操作系统会让进程 A 让出 CPU,基于效率并兼顾公平的策略重新调度 CPU 核心 1,以防止某些进程饿死。如果此时 CPU 核心 1 繁忙,而 CPU 核心 2 空闲,则进程 A 很可能会被调度到 CPU 核心 2 上运行,这样,即使我们对代码优化得再好,也只能在一个时间片内高效地使用 CPU 一、二级缓存了,下一个时间片便面临着缓存效率的问题。
因此,操作系统提供了将进程或者线程绑定到某一颗 CPU 上运行的能力。如 Linux 上提供了 sched_setaffinity 方法实现这一功能,其他操作系统也有类似功能的 API 可用。
三、实战应用
关于 CPU Cache Line 的应用其实非常广泛,如果你用过 Nginx,会发现它是用哈希表来存放域名、HTTP 头部等数据的,这样访问速度非常快,而哈希表里桶的大小如 server_names_hash_bucket_size,它默认就等于 CPU Cache Line 的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但 Nginx 官网上明确建议它应该是 CPU Cache Line 的整数倍。
如果对 Redis 的使用有非常苛刻的性能要求,在多核 CPU 场景下,一旦应用程序需要在一个新 CPU 核上运行,那么运行时信息就需要重新加载到新的 CPU 核上。而且,新的 CPU 核的L1、L2 缓存也需要重新加载数据核指令,这会导致程序的运行时间增加。这时我们可以将Redis实例绑定在某一个核上执行,避免多核间的切换。
总结来说,更好的了解 CPU 的多核架构,高效利用多核 CPU 需要从系统架构、编程模型、数据结构、算法设计等多个角度综合考虑,确保工作负载能够在多个核心上均衡分布,有效减少串行执行和等待时间,从而提升整体系统的并发处理能力和执行效率。
欢迎留言讨论并关注。
往期经典推荐
Synchronized同步锁的全方位剖析与实战运用-CSDN博客
Spring Cloud + Nacos 引领服务治理新航向-CSDN博客
标签:缓存,单核,访问,多核,内存,L2,array,CPU From: https://blog.csdn.net/qq_39209927/article/details/136699674