系统性能优化-2 CPU
其实除了 CPU 的频率,多核架构以及多 CPU 架构对系统运行的性能也是很大影响的,那么该如何充分利用 CPU 呢?
CPU 架构
首先介绍一下当前主流的 CPU 架构,现在的系统基本都是多 CPU,一个 CPU 处理器中一般有多个运行核心
,我们把一个运行核心称为一个物理核
,每个物理核都可以运行应用程序
。每个物理核都拥有私有的一级缓存(Level 1 cache,简称 L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称 L2 cache)。同时,一个 CPU 上的所有物理核共享一个三级缓存。

Centos 可以使用 lscpu
查看系统 CPU 信息
bash
[root@VM-16-11-centos zwj]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 2
On-line CPU(s) list: 0,1
Thread(s) per core: 1
Core(s) per socket: 2
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 94
Model name: Intel(R) Xeon(R) Gold 6148 CPU @ 2.40GHz
Stepping: 3
CPU MHz: 2394.364
BogoMIPS: 4788.72
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 32K
L1i cache: 32K
L2 cache: 4096K
L3 cache: 28160K
NUMA node0 CPU(s): 0,1
程序在执行时,会先将内存中的数据载入三级缓存,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被 CPU 使用。
缓存比内存的访问速度要快很多,访问内存要100个时钟周期以上,一级缓存只需要4~5个时钟周期,二级缓存大约12个时钟周期,三级缓存大约30个时钟周期。因此如果 CPU 所要操作的数据可以命中缓存,会带来一定的性能提升。
NUMA 架构
一个 CPU 的结构我们清楚了,但现在的系统基本都是多 CPU(也称为多 CPU Socket),CPU 之间通过总线连接,如下图所示:

在多 CPU 架构上,应用程序可以在不同的 CPU 上执行,如果应用程序先在一个 Socket 上运行,并且把数据保存到了内存,然后被调度到另一个 Socket 上运行,此时,应用程序再进行内存访问时,就需要访问之前 Socket 上连接的内存,这种访问属于远端内存访问
。和访问 Socket 直接连接的内存相比,远端内存访问会增加应用程序的延迟。
在多 CPU 架构下,一个应用程序访问所在 Socket 的本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA 架构)。
所以 CPU 架构对应用程序的运行主要有以下影响:
- cpu 缓存访问速度远远大于内存,因此尽量缓存命中,多利用缓存
- NUMA 架构可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间
提升缓存命中率
Centos 运行 lscpu
bash
[root@VM-16-11-centos zwj]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
L1d cache: 32K
L1i cache: 32K
L2 cache: 4096K
L3 cache: 28160K
NUMA node0 CPU(s): 0,1
可以看到 L1 cache 并不是单独的,而是分为 L1d cache 和 L1i cache,也就是 数据缓存 和 指令缓存,因此提高缓存命中率也需要分别考虑
数据缓存
思考这样一个场景,有一个二维数组需要进行一次遍历
c
int arr[10][10];
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; i++) {
printf("%d", arr[i][j])
}
}
c
int arr[10][10];
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; i++) {
printf("%d", arr[j][i])
}
}
不要思考这个场景是否合理,比如需要遍历一个数组把所有值加入到一个 set 中,思考这两种方式的执行速度,其实方式1是远优于方式2的(如果是 Python,会由于数组的设计效果没那么明显)。
原因是 CPU 其实有 Cache Line 的概念,CPU 在读取数据时会一次性读取 Cache Line 大小的数据到缓存中,而我们又知道数组在内存中是紧密排放的
c
arr[0][0] arr[0][1] arr[0][2]
arr[1][0]
arr[2][0]
如果按照第一种方式遍历,就可以利用 CPU 缓存加快访问
Linux 可以通过 cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size
查看 Cache Line 大小
bash
[root@VM-16-11-centos zwj]# cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size
64
Cache Line 在 nginx、redis 的设计中都有所体现:
- nginx 使用哈希表存放域名、http头部信息,哈希表里桶的大小如 server_names_hash_bucket_size,它默认就等于 CPU Cache Line 的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但 Nginx 官网上明确建议它应该是 CPU Cache Line 的整数倍。
- redis 的 sds 中,embstr 的 44 字节,其实也是 CPU Cache Line 的体现,当 robj + 字符串 + '\0' 的长度不超过 64 时,此时编码就是 embstr,在读取到 robj 时就直接取到了字符串数据,无需再次访问内存。
执行 perf stat 可以统计出进程运行时的系统信息(通过 -e 选项指定要统计的事件,如果要查看三级缓存总的命中率,可以指定缓存未命中 cache-misses 事件,以及读取缓存次数 cache-references 事件,两者相除就是缓存的未命中率,用 1 相减就是命中率。类似的,通过 L1-dcache-load-misses 和 L1-dcache-loads 可以得到 L1 缓存的命中率)
指令缓存
上面只是介绍了提高数据缓存的命中率,还有指令缓存的命中率
假如有一个数组,里面是一些 0~255 的数字,需要找出其中 < 128 的置为 0 并进行排序,方法1是先找到对应数据并置为0再排序,方法2是先排序再置为0。
bash
for(i = 0; i < N; i++) {
if (array [i] < 128) array[i] = 0;
}
sort(array, array + N);
bash
sort(array, array + N);
for(i = 0; i < N; i++) {
if (array [i] < 128) array[i] = 0;
}
先排序的方法速度其实是要更快的,原因是因为循环中有大量的 if 条件分支,而 CPU含有分支预测器 。当代码中出现 if、switch 等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如 if 还是 else 中的指令),就可以提前把这些指令放在缓存中,CPU 执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当 array 数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高
绑核
多 CPU 和多核心是不可避免的,为了缓解 NUMA 及应用程序重新调度后需要再次加载数据到 CPU 缓存的问题,操作系统提供了绑核指令,ls cpu
指令可以查看每个 CPU 对应的核心编号,可以使用 taskset
命令行指令在启动时将应用程序与 CPU 进行绑定,也可以使用不同语言的 API (如sched_setaffinity)通过系统调用在程序代码中设置进程的 CPU 亲和性。此外,Perf 工具也提供了 cpu-migrations 事件,它可以显示进程从不同的 CPU 核心上迁移的次数。
绑核是把双刃剑,如果你的程序有很多的后台线程,例如 redis 需要子线程生成 rdb、aof 重写、删除过期 key,就会导致子进程、后台线程和主线程竞争 CPU 资源,一旦子进程或后台线程占用 CPU 时,主线程就会被阻塞,导致 Redis 请求延迟增加。当然也有缓解的办法,比如让程序绑定一个具有多个逻辑核心的核,可以在一定程度上缓解 CPU 资源竞争。
不过 Redis 6 好像已经提供了支持 CPU 核绑定的配置操作了~