2.4 内存延时与带宽实测
本节内容对应原书第六章“内存性能测试数据”,涵盖 6.1 内存延时实测、6.2 内存带宽实测 和 6.3 NUMA架构下内存延时实测。通过实验数据量化内存在不同访问模式下的性能表现。
6.1 内存延时实测
在4.2 内存工作过程中,我们理解了内存IO的内部实现过程,知道了内存的随机IO比顺序IO要慢,并对延迟时间进行了大概的估算。那么我们今天来用代码的方式来实践一下,看看在我们的项目工程中,内存访问在不同的访问场景下延时究竟是个什么表现。
先测试顺序情况
测试原理就是定义一个指定大小的double(8字节)数组,然后以指定的步长去循环。里面的变量有两个。核心代码如下:
void init_data(double *data, int n){
int i;
for (i = 0; i < n; i++) {
data[i] = i;
}
}
void seque_access(int elems, int stride) {
int i;
double result = 0.0;
volatile double sink;
for (i = 0; i < elems; i += stride) {
result += data[i];
}
sink = result;
}在这个核心代码的基础上,我们有两个可调节变量:
- 一是数组大小,数组越小,高速缓存命中率越高,平均延时就会越低。
- 二是循环步长,步长越小,顺序性越好,同样也会增加缓存命中率,平均延时也低。
我们再测试的过程中采取的办法是,固定其中一个变量,然后动态调节另外一个变量来查看效果。
另外说明一下,这个代码测试中考虑的几个额外的开销的处理情况。
- 加法开销:由于加法指令简单,一个CPU周期就可完成,CPU周期比内存周期要快,所以暂且忽略它。
- 耗时统计:这涉及到高开销的系统调用,本实验通过跑1000次取一次耗时的方式来降低影响。
完整的测试实验使用的代码参见 Github
飞哥在我自己的机器上的一次测试结果数据如下:
场景一: 固定数组大小2K,调节步长
数组足够小的时候,L1 cache全部都能装的下。内存IO发生较少,大部分都是高效的缓存IO,所以我这里看到的内存延时只有1ns左右,这其实只是虚拟地址转换+L1访问的延时。
| 步长 | 1 | 9 | 17 | 25 | 33 | 41 | 49 | 57 |
|---|---|---|---|---|---|---|---|---|
| 延时ns | 1.28 | 1.28 | 1.33 | 1.30 | 1.30 | 1.41 | 1.45 | 1.4 |
场景二: 固定步长为8,数组从32K到64M
当数组越来越大,Cache装不下,导致穿透高速缓存,到内存实际IO的次数就会变多,平均耗时增加。
| 数组大小 | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|
| 延时ns | 1.27 | 1.73 | 2.03 | 2.62 | 2.62 | 2.88 | 5.17 | 5.84 |
场景三: 步长为32,数组从32K到64M
| 数组大小 | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|
| 延时ns | 1.25 | 1.74 | 2.03 | 2.47 | 2.47 | 3.29 | 7.73 | 8.89 |
和场景二相比,步长变大以后,局部性变差,穿透的内存IO进一步增加。虽然数据量一样大,但是平均耗时就会继续有所上涨。不过虽然穿透增加,但由于访问地址仍然相对比较连续,所以即使发生内存IO也绝大部分都是行地址不变的顺序IO情况。所以耗时在9ns左右,和之前估算大致相符!
另外注意一个细节,就是随着数组从64M到32M变化的过程中。耗时有几个明显的下降点,分别是8M、256K和32K。这是因为本机的CPU的L1大小是32K,L2是256K,L3是12M。在数据集32K的时候,L1全能装的下,所有基本都是高速缓存IO。256K的时候、8M的时候,虽然L1命中率下降,但是L2、L3访问速度仍然比真正的内存IO快。但是超过12M以后越多,真正的内存IO就越来越多了。
再测随机IO情况
在顺序的实验场景里,数组的下标访问都是比较有规律地递增。在随机IO的测试中,我们要彻底打乱这个规律,提前随机好一个下标数组,实验时不停地访问数组的各个随机位置。
这实际比上面的实验多了一次内存IO,但由于对 random_index_arr 的访问时顺序的,而且该数组也比较小。我们假设它全部能命中高速缓存,所以暂且忽略它的影响。
void init_data(double *data, int n){
int i;
for (i = 0; i < n; i++) {
data[i] = i;
}
}
void random_access(int* random_index_arr, int count) {
int i;
double result = 0.0;
volatile double sink;
for (i = 0; i < count; i++) {
result += data[*(random_index_arr+i)];
}
sink = result;
}飞哥在我自己的机器上测试数据如下:
随机实验场景: 数组从32K到64M
这次的数组访问就没有步长的概念了,全部打乱,随机访问。当数据集比较小的时候、L1、L2、L3还能抗一抗。但当增加到16M、64M以后,穿透到内存的IO情况会变多,穿透过去以后极大可能行地址也会变。在64M的数据集中,内存的延时竟然下降到了38.4ns,和我们估算的也基本一致。
| 数组大小 | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|
| 延时ns | 2.4 | 2.4 | 2.4 | 4.8 | 4.8 | 19.2 | 24 | 38.4 |
结论
有了实验数据的佐证,进一步证实了4.2 内存工作过程中关于深入理解内存IO的实际过程这部分的结论。内存也存在随机访问比顺序访问慢的多的情况,大概是 4:1 的关系。所以不要觉得内存很快,就用起来太随性了!
6.2 内存带宽实测
带宽是存储系统一个的非常重要的衡量指标,内存带宽指的是CPU从内存读取或写入数据到内存的速率。我们今天来思考一个问题,在你的应用程序里内存的访问带宽能够达到多少?
各代内存带宽规格
理论上内存带宽的计算公式是:Band Width = Speed * Data Width。在这个公式的基础上,我们来看一下各代规格的内存带宽表现。
(此处原书有一张各代内存带宽对比图,内容为DDR、DDR2、DDR3、DDR4的Speed、Data Width和理论带宽。由于文字提取无法保留图片,根据上下文推断,该图展示了从DDR 400到DDR4 3200的理论带宽增长。)
从上图来看,DDR3代的内存 DDR3代1066MHz的带宽都已经达到了8.5GB/s, DDR4代3200MHz更是达到了惊人的25.6G。 厂家在进行销售的时候也都是用这个公式来算,言外之意就是告诉你:“我家的产品非常快,大家赶快来买啊!”
厂家这么宣传似乎也不算错,但是有诱导性的谎言在其中。 厂家这个数据的基础是内存严格以顺序IO的方式工作,而且把传输的内存地址也当数据来看,进而计算出的结果。但我们在4.2 内存工作过程中明白了的是,内存并一定是所有的Data Speed周期都在源源不断往外吐数据。在CPU传入了行地址后,内存打开一行需要 tRCD + tRP 个时钟周期的延迟。输入列地址后,又需要等待CL个时钟周期。而内存作为易失性存储元器件,又需要定时对所有的行进行充电,来保证数据不丢失。所以,在实践中,我们并不是总是能够达到厂家宣传的理论带宽值。
理论带宽值计算时采用的是内存的Speed,也就是其数据频率。而内存的延迟呢,用的是时钟周期。现代的内存在时钟周期的上沿和下沿都可以传输数据,所以数据频率比Speed又慢了一半。对于笔者Speed为1067MHz的内存条,其时钟频率是553MHz。
邓爷爷说过,实践是检验真理的唯一标准。我们今天就来进行一次实际的测试,看看内存的到底每秒能给我们吐出多少的数据。和前文《实际测试内存在顺序IO和随机IO时的访问延时差异》的测试方法类似,我们今天对方法进行下小改动,用它来测试带宽。
顺序IO情况核心测试方法
测试代码主体上和延迟测试差不多,定义一个指定大小的数组,然后以指定步长对其进行访问。
考虑到内存对齐能提高性能,所以公平起见,我们每次都是按内存位宽去取的(一个double 8个字节,正好是一个内存位宽)。带宽就是一秒内访问过的字节总数,所以我们通过如下代码进行计算。
void init_data(double *data, int n){
int i;
for (i = 0; i < n; i++) {
data[i] = i;
}
}
void seque_access(int elems, int stride) {
int i;
double result = 0.0;
volatile double sink;
for (i = 0; i < elems; i += stride) {
result += data[i];
}
sink = result;
}
// 带宽计算
result = total_accessed_bytes * 1000 / used_microseconds;完整的测试代码参见 Github
顺序IO情况带宽测试结果
飞哥我自己的服务器上的内存条是DDR3, 1067MHz,延迟参数为7-7-7-24。我进行了多场景的测试,数据如下。
场景一: 固定数组大小2K,调节步长
数组为2k,足够小到L1 cache全部都能装的下。这时候其实基本实际内存IO发生的很少,大部分都是更高效的L1 cache的IO,在CPU内部就完成了。但最高值也才6G而已,也没有达到厂家宣称的8GB。
| 步长 | 1 | 9 | 17 | 25 | 33 | 41 | 49 | 57 | 64 |
|---|---|---|---|---|---|---|---|---|---|
| 带宽MB | 6185 | 6263 | 6001 | 5930 | 6116 | 5660 | 5523 | 5682 | 6316 |
场景二: 固定步长为8,数组从32K到64M
数组越大,Cache越装不下,数据访问的IO会更多地往后穿透到L2、L3和内存。L1、L2、L3和内存IO的性能依次递减,因此数组越大,平均带宽就会越低。
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽MB | 6066 | 6314 | 4577 | 3960 | 3059 | 3047 | 2769 | 1551 | 1386 |
场景三: 步长为32,数组从32K到64M
步长增加后,穿透到内存的次数进一步增加,带宽进一步下降。这个时候,应用程序视角看到的数据带宽已经下降到1GB以下了。
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽MB | 6376 | 6394 | 4647 | 3735 | 3193 | 3179 | 1481 | 1002 | 902 |
再测随机IO情况
前面的测试情况,虽然步长也在变化,但都是有序递增。这样内存的连续两次IO之间,虽然列地址会变,但是行地址极有可能不发生变化,因此效率还是算比较高。 我们这次是来彻底随机进行访问,再来看一下。
随机访问,数组从8K到64M
当数组比较小的时候,虽然乱序访问,但是cache能兜住,因此内存IO实际上发生的很少。但当数组增加到64M的时候,再加上哪一级Cache都兜不住了。再加上访问又足够随机,因此都穿透到了内存,而且行的行地址也极大可能发生变化。这时,内存带宽竟然下降到了474M。
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽MB | 6378 | 5850 | 4764 | 3405 | 3193 | 2948 | 967 | 749 | 474 |
结论
厂家在宣传的内存带宽数据的时候,用的是严格顺序IO情况下的数据,而且把地址的传输也当数据算了。但是厂家不会告诉你,在实际中内存的带宽并没有你想象的那么快,在随机IO工作模式的情况下,带宽只有 474M 而已。 现在SSD固态硬盘顺序IO也差不多能达到这个数量级了。所以,我们以后不要鲁莽地说内存比硬盘要快很多。快不快,关键取决于你怎么用!
6.3 NUMA架构下内存延时实测
现在的服务器物理机CPU一般都是多个CPU,核数也是十几甚至几十核。内存几十GB甚至是上百G,也是由许多的内存条组成的。那么我这里思考一下,这么多的CPU和内存它们之间是怎么互相连接的?同一个CPU核访问不同的内存条延时一样吗?
在4.2 内存工作过程中我们了解了内存访问时芯片内部的执行过程,在6.1 内存延时实测中我们又进行了实际的代码测试。不过这两文中我们都把精力聚焦在内存内部机制,而回避了上面的问题,那就是CPU和内存的连接方式,也就是总线架构。
回顾CPU与内存的简单连接:FSB时代
我们先来我们先来回顾下在历史上CPU、内存数量比较少的年代里的总线方案-FSB。FSB的全称是Front Side Bus,因此也叫前端总线。CPU通过FSB总线连接到北桥芯片,然后再连接到内存。内存控制器是集成在北桥里的,Cpu和内存之间的通信全部都要通过这一条FSB总线来进行。
在这个年代里,当时提高计算机系统整体性能的方式就是不断地提高CPU、FSB总线、内存条的数据传输频率。
如今多CPU多内存条复杂互联:NUMA时代
当CPU的主频提升到了3GHz每秒以后,硬件制造商们发现单个CPU的已经到了物理极限了。所以就改变了性能改进的方法,改成为向多核、甚至是多CPU的方向来发展。在这种情况下,如果仍然采用FSB总线,会导致所有的CPU和内存通信都经过总线,这样总线就成为了瓶颈,无法充分发挥多核的优势与性能。所以CPU制造商们把内存控制器从北桥搬到了CPU内部,这样CPU便可以直接和自己的内存进行通信了。那么,如果CPU想要访问不和自己直连的内存条怎么办呢?所以就诞生了新的总线类型,它就叫QPI总线。
图中CPU1如果想要访问内存3的话,就需要经过QPS总线才可以。
动手查看Linux下NUMA架构
我们先通过dmidecode命令查看一下内存插槽,单条大小等信息。大家可以在linux上执行以下该命令。输出结果很长,大家可以有空仔细研究。我这里不全部介绍,这里只挑选一些和内存相关的:
# dmidecode|grep -P -A5 "Memory\s+Device"|grep Size
Size: 8192 MB
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: 8192 MB
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB 可以看出,我当前使用的机器上共有16个内存插槽,共插了8条8G的内存。所以总共是64GB。如我们前面所述,在NUMA架构里,每一个物理CPU都有不同的内存组,通过numactl 命令可以查看这个分组情况。
# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 12 13 14 15 16 17
node 0 size: 32756 MB
node 0 free: 19642 MB
node 1 cpus: 6 7 8 9 10 11 18 19 20 21 22 23
node 1 size: 32768 MB
node 1 free: 18652 MB
node distances:
node 0 1
0: 10 21
1: 21 10通过上述命令可以看到,每一组CPU核分配了32GB(4条)的内存。 node distance 是一个二维矩阵,描述node访问所有内存条的延时情况。 node 0 里的CPU访问node 0 里的内存相对距离是10 ,因为这时访问的内存都是和该CPU直连的。而node 0 如果想访问node 1 节点下的内存的话,就需要走QPI总线了,这时该相对距离就变成了21。
所以、在NUMA架构下,CPU访问自己同一个node里的内存要比其它内存要快!
动手测试NUMA架构内存延迟差异
numactl 命令有 --cpubind 和 --membind 的选项,通过它们我们可以指定我们要用的node节点。还沿用《用代码让你来实际感受内存在不同情况下的访问延时差异》里的测试代码:
- 让内存和CPU处于同一个node
- 让内存和CPU处于不同node
# numactl --cpubind=0 --membind=0 ./main
Delay (ns)
2k 8k 32k 128k 512k 2m 8m 32m 128m
s1 1.28 1.28 1.26 1.25 1.26 1.26 1.28 1.43 1.43
s32 1.27 1.26 1.32 1.78 2.67 2.73 3.27 9.95 10.37
s64 1.28 1.26 1.26 1.82 2.43 2.48 3.15 8.82 8.92
andom 2.40 2.40 2.40 2.40 4.80 4.80 19.20 28.80 52.80# numactl --cpubind=0 --membind=1 ./main
Delay (ns)
2k 8k 32k 128k 512k 2m 8m 32m 128m
s1 1.29 1.28 1.26 1.26 1.26 1.26 1.31 1.62 1.63
s32 1.29 1.26 1.33 1.77 2.80 2.92 3.95 13.69 13.77
s64 1.30 1.27 1.26 1.82 2.47 2.48 3.96 12.93 12.90
andom 2.40 2.40 2.40 2.40 4.80 4.80 19.20 31.20 52.80结论
通过上面的各个小节我们可以看到,现代的服务器里,CPU和内存条都有多个,它们之前目前主要采用的是复杂的NUMA架构进行互联,NUMA把服务器里的CPU和内存分组划分成了不同的node。从上述实验结果来看,拿8M数组,循环步长为64的case来说,同node耗时3.15纳秒,跨node为3.96纳秒。所以属于同一个node里的CPU和内存之间访问速度会比较快。而如果跨node的话,则需要经过QPI总线,总体来说,速度会略慢一些。
2.4 内存延时与带宽实测
内存带宽实测
带宽是存储系统的一个非常重要的衡量指标,内存带宽指的是 CPU 从内存读取或写入数据到内存的速率。我们今天来思考一个问题,在你的应用程序里内存的访问带宽能够达到多少?
各代内存带宽规格
理论上内存带宽的计算公式是:Band Width = Speed * Data Width。在这个公式的基础上,我们来看一下各代规格的内存带宽表现。
| 内存类型 | Speed (MHz) | Data Width (bit) | 理论带宽 (GB/s) |
|---|---|---|---|
| DDR3-1066 | 1066 | 64 | 8.5 |
| DDR4-3200 | 3200 | 64 | 25.6 |
从上图来看,DDR3 代 1066MHz 的带宽都已经达到了 8.5GB/s,DDR4 代 3200MHz 更是达到了惊人的 25.6G。厂家在进行销售的时候也都是用这个公式来算,言外之意就是告诉你:“我家的产品非常快,大家赶快来买啊!”
厂家宣传的误区
厂家这个数据的基础是内存严格以顺序 IO 的方式工作,而且把传输的内存地址也当数据来看,进而计算出的结果。但在内存随机访问也⽐顺序慢,带你深⼊理解内存IO过程中我们明白了,内存并不是所有的 Data Speed 周期都在源源不断往外吐数据。在 CPU 传入了行地址后,内存打开一行需要
tRCD + tRP个时钟周期的延迟。输入列地址后,又需要等待CL个时钟周期。而内存作为易失性存储元器件,又需要定时对所有的行进行充电,来保证数据不丢失。所以,在实践中,我们并不是总是能够达到厂家宣传的理论带宽值。
理论带宽计算使用的频率
理论带宽值计算时采用的是内存的 Speed,也就是其数据频率。而内存的延迟呢,用的是时钟周期。现代的内存在时钟周期的上沿和下沿都可以传输数据,所以数据频率比 Speed 又慢了一半。对于笔者 Speed 为 1067MHz 的内存条,其时钟频率是 553MHz。
顺序 IO 情况核心测试方法
测试代码主体上和延迟测试差不多,定义一个指定大小的数组,然后以指定步长对其进行访问。考虑到内存对齐能提高性能,所以公平起见,我们每次都是按内存位宽去取的(一个 double 8 个字节,正好是一个内存位宽)。带宽就是一秒内访问过的字节总数,所以我们通过如下代码进行计算。
void init_data(double *data, int n){
int i;
for (i = 0; i < n; i++) {
data[i] = i;
}
}
void seque_access(int elems, int stride) {
int i;
double result = 0.0;
volatile double sink;
for (i = 0; i < elems; i += stride) {
result += data[i];
}
sink = result;
}带宽计算公式:
result = total_accessed_bytes * 1000 / used_microseconds;完整的测试代码参见 github
顺序 IO 情况带宽测试结果
飞哥我自己的服务器上的内存条是 DDR3, 1067MHz,延迟参数为 7-7-7-24。我进行了多场景的测试,数据如下。
场景一:固定数组大小 2K,调节步长
| 步长 | 1 | 9 | 17 | 25 | 33 | 41 | 49 | 57 | 64 |
|---|---|---|---|---|---|---|---|---|---|
| 带宽 MB | 6185 | 6263 | 6001 | 5930 | 6116 | 5660 | 5523 | 5682 | 6316 |
数组为 2k,足够小到 L1 cache 全部都能装的下。这时候其实基本实际内存 IO 发生的很少,大部分都是更高效的 L1 cache 的 IO,在 CPU 内部就完成了。但最高值也才 6G 而已,也没有达到厂家宣称的 8GB。
场景二:固定步长为 8,数组从 32K 到 64M
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽 MB | 6066 | 6314 | 4577 | 3960 | 3059 | 3047 | 2769 | 1551 | 1386 |
数组越大,Cache 越装不下,数据访问的 IO 会更多地往后穿透到 L2、L3 和内存。L1、L2、L3 和内存 IO 的性能依次递减,因此数组越大,平均带宽就会越低。
场景三:步长为 32,数组从 32K 到 64M
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽 MB | 6378 | 5850 | 4764 | 3405 | 3193 | 2948 | 967 | 749 | 474 |
步长增加后,穿透到内存的次数进一步增加,带宽进一步下降。这个时候,我们应用程序视角看到的数据带宽已经下降到 1GB 以下了。
再测随机 IO 情况
前面的测试情况,虽然步长也在变化,但都是有序递增。这样内存的连续两次 IO 之间,虽然列地址会变,但是行地址极有可能不发生变化,因此效率还是算比较高。我们这次是来彻底随机进行访问,再来看一下。
void init_data(double *data, int n){
int i;
for (i = 0; i < n; i++) {
data[i] = i;
}
}
void random_access(int* random_index_arr, int count) {
int i;
double result = 0.0;
volatile double sink;
for (i = 0; i < count; i++) {
result += data[*(random_index_arr+i)];
}
sink = result;
}随机访问,数组从 8K 到 64M
| 数组大小 | 8K | 32K | 64K | 256K | 512K | 2M | 8M | 16M | 64M |
|---|---|---|---|---|---|---|---|---|---|
| 带宽 MB | 6378 | 5850 | 4764 | 3405 | 3193 | 2948 | 967 | 749 | 474 |
当数组比较小时,虽然乱序访问,但是 cache 能兜住,因此内存 IO 实际上发生的很少。但当数组增加到 64M 的时候,再加上哪一级 Cache 都兜不住了。再加上访问又足够随机,因此都穿透到了内存,而且行的行地址也极大可能发生变化。这时,内存带宽竟然下降到了 474M。
结论
厂家在宣传的内存带宽数据的时候,用的是严格顺序 IO 情况下的数据,而且把地址的传输也当数据算了。但是厂家不会告诉你,在实际中内存的带宽并没有你想象的那么快,在随机 IO 工作模式的情况下,带宽只有 474M 而已。现在 SSD 固态硬盘顺序 IO 也差不多能达到这个数量级了。所以,我们以后不要鲁莽地说内存比硬盘要快很多。快不快,关键在于你怎么用!
NUMA 架构下内存延时实测
现在的服务器物理机 CPU 一般都是多个 CPU,核数也是十几甚至几十核。内存几十 GB 甚至上百 G,也是由许多的内存条组成的。那么我这里思考一下,这么多的 CPU 和内存它们之间是怎么互相连接的?同一个 CPU 核访问不同的内存条延时一样吗?
在内存随机访问也比顺序慢,带你深入理解内存IO过程中我们了解了内存访问时芯片内部的执行过程,在实际测试内存在顺序IO和随机IO时的访问延时差异中我们又进行了实际的代码测试。不过这两文中我们都把精力聚焦在内存内部机制,而回避了上面的问题,那就是 CPU 和内存的连接方式,也就是总线架构。
回顾 CPU 与内存的简单连接:FSB 时代
我们先来回顾下在历史上 CPU、内存数量比较少的年代里的总线方案 - FSB。FSB 的全称是 Front Side Bus,因此也叫前端总线。CPU 通过 FSB 总线连接到北桥芯片,然后再连接到内存。内存控制器是集成在北桥里的,Cpu 和内存之间的通信全部都要通过这一条 FSB 总线来进行。
在这个年代里,当时提高计算机系统整体性能的方式就是不断地提高 CPU、FSB 总线、内存条的数据传输频率。
如今多 CPU 多内存条复杂互联:NUMA 时代
当 CPU 的主频提升到了 3GHz 每秒以后,硬件制造商们发现单个 CPU 的已经到了物理极限了。所以就改变了性能改进的方法,改成为向多核、甚至是多 CPU 的方向来发展。在这种情况下,如果仍然采用 FSB 总线,会导致所有的 CPU 和内存通信都经过总线,这样总线就成为了瓶颈,无法充分发挥多核的优势与性能。所以 CPU 制造商们把内存控制器从北桥搬到了 CPU 内部,这样 CPU 便可以直接和自己的内存进行通信了。那么,如果 CPU 想要访问不和自己直连的内存条怎么办呢?所以就诞生了新的总线类型,它就叫 QPI 总线。
图中 CPU1 如果想要访问内存 3 的话,就需要经过 QPS 总线才可以。
动手查看 Linux 下 NUMA 架构
我们先通过 dmidecode 命令查看一下内存插槽,单条大小等信息。大家可以在 linux 上执行以下该命令。输出结果很长,大家有空仔细研究。我这里不全部介绍,这里只挑选一些和内存相关的:
# dmidecode | grep -P -A5 "Memory\s+Device" | grep Size
Size: 8192 MB
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: 8192 MB
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB
Size: No Module Installed
Size: 8192 MB可以看出,我当前使用的机器上共有 16 个内存插槽,共插了 8 条 8G 的内存。所以总共是 64GB。如我们前面所述,在 NUMA 架构里,每一个物理 CPU 都有不同的内存组,通过 numactl 命令可以查看这个分组情况。
# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 12 13 14 15 16 17
node 0 size: 32756 MB
node 0 free: 19642 MB
node 1 cpus: 6 7 8 9 10 11 18 19 20 21 22 23
node 1 size: 32768 MB
node 1 free: 18652 MB
node distances:
node 0 1
0: 10 21
1: 21 10通过上述命令可以看到,每一组 CPU 核分配了 32GB(4 条)的内存。node distance 是一个二维矩阵,描述 node 访问所有内存条的延时情况。node 0 里的 CPU 访问 node 0 里的内存相对距离是 10,因为这时访问的内存都是和该 CPU 直连的。而 node 0 如果想访问 node 1 节点下的内存的话,就需要走 QPI 总线了,这时该相对距离就变成了 21。
所以,在 NUMA 架构下,CPU 访问自己同一个 node 里的内存要比其它内存要快!
动手测试 NUMA 架构内存延迟差异
numactl 命令有 --cpubind 和 --membind 的选项,通过它们我们可以指定我们要用的 node 节点。还沿用用代码让你来实际感受内存的在不同情况下的访问延时差异里的测试代码。
1. 让内存和 CPU 处于同一个 node
# numactl --cpubind=0 --membind=0 ./main
Delay (ns)
2k 8k 32k 128k 512k 2m 8m 32m 128m
s1 1.28 1.28 1.26 1.25 1.26 1.26 1.28 1.43 1.43
s32 1.27 1.26 1.32 1.78 2.67 2.73 3.27 9.95 10.37
s64 1.28 1.26 1.26 1.82 2.43 2.48 3.15 8.82 8.92
andom 2.40 2.40 2.40 2.40 4.80 4.80 19.20 28.80 52.802. 让内存和 CPU 处于不同 node
# numactl --cpubind=0 --membind=1 ./main
Delay (ns)
2k 8k 32k 128k 512k 2m 8m 32m 128m
s1 1.29 1.28 1.26 1.26 1.26 1.26 1.31 1.62 1.63
s32 1.29 1.26 1.33 1.77 2.80 2.92 3.95 13.69 13.77
s64 1.30 1.27 1.26 1.82 结论
通过上面的各个小节我们可以看到,现代的服务器里,CPU和内存条都有多个,它们之前目前主要采用的是复杂的NUMA架构进行互联,NUMA把服务器里的CPU和内存分组划分成了不同的node。从上述实验结果来看,拿8M数组,循环步长为64的case来说,同node耗时3.15纳秒,跨node为3.96纳秒。所以属于同一个node里的CPU和内存之间访问速度会比较快。而如果跨node的话,则需要经过QPI总线,总体来说,速度会略慢一些。
> [!TIP] 实际建议
>
> 在设计高性能应用时,应尽量让进程或线程绑定到同一个NUMA节点上运行(通过`numactl --cpubind`和`--membind`),以减少跨节点访问带来的延迟惩罚。这一点在[[4.2 内存工作过程]]和[[6.4 实测NUMA陷阱]]中也有进一步验证。