gitbook/系统性能调优必知必会/docs/230194.md
2022-09-03 22:05:03 +08:00

167 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 01 | CPU缓存:怎样写代码能够让CPU执行得更快?
你好,我是陶辉。
这是课程的第一讲,我们先从主机最重要的部件CPU开始,聊聊如何通过提升CPU缓存的命中率来优化程序的性能。
任何代码的执行都依赖CPU,通常,使用好CPU是操作系统内核的工作。然而,当我们编写计算密集型的程序时,CPU的执行效率就开始变得至关重要。由于CPU缓存由更快的SRAM构成(内存是由DRAM构成的),而且离CPU核心更近,如果运算时需要的输入数据是从CPU缓存,而不是内存中读取时,运算速度就会快很多。所以,了解CPU缓存对性能的影响,便能够更有效地编写我们的代码,优化程序性能。
然而,很多同学并不清楚CPU缓存的运行规则,不知道如何写代码才能够配合CPU缓存的工作方式,这样,便放弃了可以大幅提升核心计算代码执行速度的机会。而且,越是底层的优化,适用范围越广,CPU缓存便是如此,它的运行规则对分布式集群里各种操作系统、编程语言都有效。所以,一旦你能掌握它,集群中巨大的主机数量便能够放大优化效果。
接下来,我们就看看,CPU缓存结构到底是什么样的,又该如何优化它?
## CPU的多级缓存
刚刚我们提到,CPU缓存离CPU核心更近,由于电子信号传输是需要时间的,所以离CPU核心越近,缓存的读写速度就越快。但CPU的空间很狭小,离CPU越近缓存大小受到的限制也越大。所以,综合硬件布局、性能等因素,CPU缓存通常分为大小不等的三级缓存。
CPU缓存的材质SRAM比内存使用的DRAM贵许多,所以不同于内存动辄以GB计算,它的大小是以MB来计算的。比如,在我的Linux系统上,离CPU最近的一级缓存是32KB,二级缓存是256KB,最大的三级缓存则是20MB(Windows系统查看缓存大小可以用wmic cpu指令,或者用[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)这个工具)。
![](https://static001.geekbang.org/resource/image/de/87/deff13454dcb6b15e1ac4f6f538c4987.png)
你可能注意到,三级缓存要比一、二级缓存大许多倍,这是因为当下的CPU都是多核心的,每个核心都有自己的一、二级缓存,但三级缓存却是一颗CPU上所有核心共享的。
程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被CPU使用,就像下面这张图。
![](https://static001.geekbang.org/resource/image/92/0c/9277d79155cd7f925c27f9c37e0b240c.jpg)
缓存要比内存快很多。CPU访问一次内存通常需要100个时钟周期以上,而访问一级缓存只需要4~5个时钟周期,二级缓存大约12个时钟周期,三级缓存大约30个时钟周期(对于2GHZ主频的CPU来说,一个时钟周期是0.5纳秒。你可以在LZMA的[Benchmark](https://www.7-cpu.com/)中找到几种典型CPU缓存的访问速度)。
如果CPU所要操作的数据在缓存中,则直接读取,这称为缓存命中。命中缓存会带来很大的性能提升,**因此,我们的代码优化目标是提升CPU缓存的命中率。**
当然,缓存命中率是很笼统的,具体优化时还得一分为二。比如,你在查看CPU缓存时会发现有2个一级缓存(比如Linux上就是上图中的index0和index1),这是因为,CPU会区别对待指令与数据。比如,“1+1=2”这个运算,“+”就是指令,会放在一级指令缓存中,而“1”这个输入数字,则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中,代码指令与数据是放在一起的,但执行时却是分开进入指令缓存与数据缓存的,因此我们要分开来看二者的缓存命中率。
## 提升数据缓存的命中率
我们先来看数据的访问顺序是如何影响缓存命中率的。
比如现在要遍历二维数组,其定义如下(这里我用的是伪代码,在GitHub上我为你准备了可运行验证的C/C++、Java[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),你可以参照它们编写出其他语言的可执行代码):
```
int array[N][N];
```
你可以思考一下,用array\[j\]\[i\]和array\[i\]\[j\]访问数组元素,哪一种性能更快?
```
for(i = 0; i < N; i+=1) {
for(j = 0; j < N; j+=1) {
array[i][j] = 0;
}
}
```
在我给出的GitHub地址上的C++代码实现中,前者array\[j\]\[i\]执行的时间是后者array\[i\]\[j\]的8倍之多(请参考[traverse\_2d\_array.cpp](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),如果使用Python代码,traverse\_2d\_array.py由于数组容器的差异,性能差距不会那么大)。
为什么会有这么大的差距呢?这是因为二维数组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\]也读入缓存的。
到这里我们还有2个问题没有搞明白:
1. 为什么两者的执行时间有约7、8倍的差距呢?
2. 载入array\[0\]\[0\]元素时,缓存一次性会载入多少元素呢?
其实这两个问题的答案都与CPU Cache Line相关,它定义了缓存一次载入数据的大小,Linux上你可以通过coherency\_line\_size配置查看它,通常是64字节。
![](https://static001.geekbang.org/resource/image/7d/de/7dc8d0c5a1461d9aed086e7a112c01de.png)
因此,我测试的服务器一次会载入64字节至缓存中。当载入array\[0\]\[0\]时,若它们占用的内存不足64字节,CPU就会顺序地补足后续元素。顺序访问的array\[i\]\[j\]因为利用了这一特点,所以就会比array\[j\]\[i\]要快。也正因为这样,当元素类型是4个字节的整数时,性能就会比8个字节的高精度浮点数时速度更快,因为缓存一次载入的元素会更多。
**因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。**
再来看为什么执行时间相差8倍。在二维数组中,其实第一维元素存放的是地址,第二维存放的才是目标元素。由于64位操作系统的地址占用8个字节(32位操作系统是4个字节),因此,每批Cache Line最多也就能载入不到8个二维数组元素,所以性能差距大约接近8倍。(用不同的步长访问数组,也能验证CPU Cache Line对性能的影响,可参考我给你准备的[Github](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_1d_array)上的测试代码)。
关于CPU Cache Line的应用其实非常广泛,如果你用过Nginx,会发现它是用哈希表来存放域名、HTTP头部等数据的,这样访问速度非常快,而哈希表里桶的大小如server\_names\_hash\_bucket\_size,它默认就等于CPU Cache Line的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但Nginx官网上明确建议它应该是CPU Cache Line的整数倍。
![](https://static001.geekbang.org/resource/image/4f/2b/4fa0080e0f688bd484fe701686e6262b.png)
为什么要做这样的要求呢?就是因为按照cpu cache line(比如64字节)来访问内存时,不会出现多核CPU下的伪共享问题,可以**尽量减少访问内存的次数**。比如,若桶大小为64字节,那么根据地址获取字符串时只需要访问一次内存,而桶大小为50字节,会导致最坏2次访问内存,而70字节最坏会有3次访问内存。
如果你在用Linux操作系统,可以通过一个名叫Perf的工具直观地验证缓存命中的情况(可以用yum install perf或者apt-get install perf安装这个工具,这个[网址](http://www.brendangregg.com/perf.html)中有大量案例可供参考)。
执行perf stat可以统计出进程运行时的系统信息(通过-e选项指定要统计的事件,如果要查看三级缓存总的命中率,可以指定缓存未命中cache-misses事件,以及读取缓存次数cache-references事件,两者相除就是缓存的未命中率,用1相减就是命中率。类似的,通过L1-dcache-load-misses和L1-dcache-loads可以得到L1缓存的命中率),此时你会发现array\[i\]\[j\]的缓存命中率远高于array\[j\]\[i\]。
当然,perf stat还可以通过指令执行速度反映出两种访问方式的优劣,如下图所示(instructions事件指明了进程执行的总指令数,而cycles事件指明了运行的时钟周期,二者相除就可以得到每时钟周期所执行的指令数,缩写为IPC。如果缓存未命中,则CPU要等待内存的慢速读取,因此IPC就会很低。array\[i\]\[j\]的IPC值也比array\[j\]\[i\]要高得多):
![](https://static001.geekbang.org/resource/image/29/1c/29d4a9fa5b8ad4515d7129d71987b01c.png)
![](https://static001.geekbang.org/resource/image/94/3d/9476f52cfc63825e7ec836580e12c53d.png)
## 提升指令缓存的命中率
说完数据的缓存命中率,再来看指令的缓存命中率该如何提升。
我们还是用一个例子来看一下。比如,有一个元素为0到255之间随机数字组成的数组:
```
int array[N];
for (i = 0; i < TESTN; i++) array[i] = rand() % 256;
```
接下来要对它做两个操作:一是循环遍历数组,判断每个数字是否小于128,如果小于则把元素的值置为0;二是将数组排序。那么,先排序再遍历速度快,还是先遍历再排序速度快呢?
```
for(i = 0; i < N; i++) {
if (array [i] < 128) array[i] = 0;
}
sort(array, array +N);
```
我先给出答案:先排序的遍历时间只有后排序的三分之一(参考GitHub中的[branch\_predict.cpp代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict))。为什么会这样呢?这是因为循环中有大量的if条件分支,而CPU**含有分支预测器**。
当代码中出现if、switch等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如if还是else中的指令),就可以提前把这些指令放在缓存中,CPU执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当array数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高。
究竟有多高呢?我们还是用Linux上的perf来做个验证。使用 -e选项指明branch-loads事件和branch-load-misses事件,它们分别表示分支预测的次数,以及预测失败的次数。通过L1-icache-load-misses也能查看到一级缓存中指令的未命中情况。
下图是我在GitHub上为你准备的验证程序执行的perf分支预测统计数据(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict)),你可以看到,先排序的话分支预测的成功率非常高,而且一级指令缓存的未命中率也有大幅下降。
![](https://static001.geekbang.org/resource/image/29/72/2902b3e08edbd1015b1e9ecfe08c4472.png)
![](https://static001.geekbang.org/resource/image/95/60/9503d2c8f7deb3647eebb8d68d317e60.png)
C/C++语言中编译器还给应用程序员提供了显式预测分支概率的工具,如果if中的条件表达式判断为“真”的概率非常高,我们可以用likely宏把它括在里面,反之则可以用unlikely宏。当然,CPU自身的条件预测已经非常准了,仅当我们确信CPU条件预测不会准,且我们能够知晓实际概率时,才需要加入这两个宏。
```
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (likely(a == 1)) …
```
## 提升多核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可用。我在GitHub上提供了一个示例程序(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/cpu_migrate)),你可以看到,当多线程同时执行密集计算,且CPU缓存命中率很高时,如果将每个线程分别绑定在不同的CPU核心上,性能便会获得非常可观的提升。Perf工具也提供了cpu-migrations事件,它可以显示进程从不同的CPU核心上迁移的次数。
## 小结
今天我给你介绍了CPU缓存对程序性能的影响。这是很底层的性能优化,它对各种编程语言做密集计算时都有效。
CPU缓存分为数据缓存与指令缓存,对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据CPU Cache Line批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。
对于指令缓存,有规律的条件分支能够让CPU的分支预测发挥作用,进一步提升执行效率。对于多核系统,如果进程的缓存命中率非常高,则可以考虑绑定CPU来提升缓存命中率。
## 思考题
最后请你思考下,多线程并行访问不同的变量,这些变量在内存布局是相邻的(比如类中的多个变量),此时CPU缓存就会失效,为什么?又该如何解决呢?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。