从单核到多核:揭秘多核CPU的高效利用策略
从单核到多核:揭秘多核CPU的高效利用策略
在当今的计算环境中,多核CPU已经成为标准配置。然而,要充分利用这些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]也读入缓存的。
为什么两者的执行时间有78倍的差距呢?
其实这与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需要从系统架构、编程模型、数据结构、算法设计等多个角度综合考虑,确保工作负载能够在多个核心上均衡分布,有效减少串行执行和等待时间,从而提升整体系统的并发处理能力和执行效率。