如何让程序跑的更快
本文内容
前言
我们编写的程序最终都是要交给 CPU 来执行的,想让程序跑的更快,就要想办法让 CPU 执行的更快,利用率更高。
这里我就不谈增加 CPU 核数,或提高 CPU 主频这种改变硬件的优化方法了,毕竟得加钱嘛。我会从程序的角度,来分析什么样的程序在 CPU 上执行得更快、效率更高。
提示:本文需要一些 CPU 的前置知识,比如 飞速一般的 CPU Cache
1. 提高 CPU 缓存命中率
我们都知道 CPU 有三级缓存,L1 Cache、L2 Cache 和 L3 Cache。L1 Cache 离 CPU 最近,其速度也最快。
CPU 在执行程序时,会先去 Cache 中查看是否有缓存,若 缓存命中 则可直接使用缓存执行,而不用去访问相对较慢的内存,这将会 大大提高 CPU 的执行效率。因此我们的程序要 让 CPU 的缓存命中率尽可能的高。
CPU 执行程序的过程其实就是 取指执行,有的指令是加载数据,有的指令是将数据执行某种运算。
所以一般 L1 Cache 会分成 数据缓存 和 指令缓存:
- 数据缓存:缓存程序所需的数据;
- 指令缓存:缓存程序执行的指令。
1.1 提高数据缓存的命中率
根据 空间局部性原理,我们要 尽量写内存连续的程序,比如在访问数组时,按数组下标顺序访问比随机下标访问速度要快很多,因为 CPU 在访问内存时,会顺带把连续的一块内存都加载到 Cache,这样下次在访问附近的数据时,就可以直接在数据缓存中取到了。
至于一次性加载多大的数据,这取决于 CPU Cache Line 的大小。
类似的,在用数据结构时,可以 尽量选择紧凑型的数据结构(比如数组,而不是链表),这样在访问时也可以很好的利用 CPU Cache。
1.2 提高指令缓存的命中率
对于 指令缓存 来说,比如 if
分支语句,如果 连续多次都是走一个分支,那么 CPU 的指令缓存就可以充分发挥作用了。所以我们 尽量让执行次数多的分支部分放在最开头。
而且 CPU 还有个 分支预测器,能 提前预测判断分支的执行路径,可以 避免执行分支指令带来的延迟,而且 对 CPU 缓存也很友好。
提前预测分支很好理解,为什么对 CPU 缓存友好呢?
分支预测器能 提高 CPU 缓存(指令缓存)命中率,它的工作原理是:CPU 在执行分支语句时,如果能预测到后续的多数判定都走同一个分支,那么它除了能避免分支指令执行的延迟外,还可以 提前将这个分支中的指令加载到 CPU 的指令缓存中,这样后续的多数判定都能 命中指令缓存,这样便可以直接从指令缓存中取指执行了,从而 增加 CPU 的缓存命中率,提高执行效率。
比如我们要执行下面两个操作:
操作一:遍历数组,将小于 10 的元素 +10
for (int i = 0; i < arr.length; i++) if (arr[i] < 10) arr[i] += 10;
操作二:给数组排序
Arrays.sort(arr);
那么我们在编写这个程序时,是先执行操作一,还是先执行操作二呢?怎样能提高 CPU 的执行效率?
如果你了解过分支预测器,肯定是先执行操作二。因为将数组排序后,前面一部分元素肯定都是小于 10 的(如果存在),那么就可以充分利用 CPU 分支预测器,避免分支指令执行的延迟,而且还能 提前将 array[i] += 10
的加法/赋值指令加载到指令缓存中。而如果数组中的数据是随机的话,分支预测器就无法进行正常工作了。
2. CPU 多核对程序的影响
现在的 CPU 一般都是多核的,虽然多核可以让并行的处理多个线程,提高系统的整体性能,但同时也带来了一些弊端。
在多核心 CPU 上,线程在运行时是会在不同的 CPU 核心之间来回切换的,而一个线程在 CPU 核心之间切换运行就会发生 context switch(上下文切换)。当发生 context switch 时,这个线程的运行时信息就需要被重新加载到另一个 CPU 核心上,因此就会 增加这段上下文切换的时间,导致 CPU 执行效率降低。
而且发生 context switch 还会 降低了 CPU 的缓存命中率,因为 CPU 多核之间的 L1 Cache 和 L2 Cache 都是不共享的,当一个线程切换到另一个核心时,L1 和 L2 Cache 都是没有该线程的缓存的,此时就需要从 L3 Cache 甚至是内存中加载,因此大大降低了 CPU 的缓存命中率。
所以如果我们的程序对 CPU 要求很高,比如一些计算密集型的程序,为了避免上面核心切换导致的性能影响,可以进行 绑核操作。
所谓绑核,就是 将某个线程与一个 CPU 核心绑定,让其只在某个 CPU 核心上运行,避免频繁被切换到不同的核心上。
Redis 性能优化之绑核
这里简单提一下绑核对 Redis 性能的优化,会涉及到一些 CPU 架构和 Redis 线程模型的知识,比如 CPU 的物理核、逻辑核,Redis 的子线程、后台线程等。
在对 Redis 做性能优化的时候,就可以让 Redis 实例与一个核心进行绑定,以避免 Redis 运行时在多个核之间切换,造成上面的问题,这样可以让 Redis 达到最理想的速度。
不过 Redis 除了主线程外,还有其他的后台线程和子线程,比如 RDB 快照生成和AOF 重写子线程,异步删除后台线程等。如果 Redis 实例绑定了核心,其他线程在执行时,主线程就会阻塞(因为将 Redis 实例绑定在一个核心上了,其他线程执行时,另一个线程自然要阻塞)。
此时我们就要把 Redis 实例绑在物理核上,而不是逻辑核,因为一个物理核一般有两个逻辑核,此时就可以让它们共享两个逻辑核,从而缓解 CPU 的竞争。
当然了,最好的策略是直接把子进程和后台线程绑到不同的 CPU 核上,从而大大降低 CPU 的竞争。
Redis 6.0 以后支持 CPU 核绑定的配置。
3. 总结
我们从程序本身的角度入手,来分析怎么编写让 CPU 执行更快的代码,从而提高 CPU 的执行效率。
影响 CPU 执行效率的主要原因主要是 CPU 缓存,我们要尽量提高 CPU L1 和 L2 Cache 的命中率。
L1 Cache 又分为 数据缓存 和 指令缓存:
- 对于数据缓存:我们要尽量 写内存连续的程序 和尽量 选择紧凑型的数据结构;
- 对于指令缓存:我们要尽量 让执行次数多的分支部分放在最开头 和 充分利用 CPU 分支预测器。
最后,现代的 CPU 多核对程序的执行影响也是不可忽视的,比如如下影响:
- context switch 消耗的时间;
- context switch 后导致 CPU L1 和 L2 Cache 失效。
为了避免上面核心切换导致的影响,一般可以进行 绑核操作。
最后提到了 Redis 通过绑核来优化性能,最好的实战是直接把子进程和后台线程绑到不同的 CPU 核上,否则其他线程在执行时会阻塞主线程。在 Redis 6.0 之后也支持了核绑定的配置。