前言
前文 <一行机器指令感受下内存操作到底有多慢> 中,我们体验到了 CPU 流水线阻塞带来的数量级性能差异。当时只是根据机器码,分析推断出来的,这次我们做一些更小的实验来分析验证。
动手之前,我们先了解一些背景。在 <CPU 提供了什么> 一文中介绍过,CPU 对外提供了运行机器指令的能力。那 CPU 又是如何执行机器指令的呢?
CPU 是如何执行机器指令的
一条机器指令,在 CPU 内部也分为好多个细分步骤,逻辑上来说可以划分为这么五个阶段:
- 获取指令
- 解析指令
- 执行执行
- 访问内存
- 结果写回
流水线作业
例如连续的 ABCD 四条指令,CPU 并不是先完整的执行完 A,才会开始执行 B;而是 A 取指令完成,则开始解析指令 A,同时继续取指令 B,依次类推,形成了流水线作业。
理想情况下,充分利用 CPU 硬件资源,也就是让流水线上的每个器件,一直保持工作。然而实际上,因为各种原因,CPU 没法完整的跑满流水线。
比如:
- 跳转指令,可能跳转执行另外的指令,并不是固定的顺序执行。
例如这样的跳转指令,可能接下来就需要跳转到400553
的指令。
1 | je 400553 |
对于这种分支指令,CPU 有分支预测技术,基于之前的结果预测本次分支的走向,尽量减少流水线阻塞。
2. 数据依赖,后面的指令,依赖前面的指令。
例如下面的两条指令,第二条指令的操作数 r8
依赖于第一条指令的结果。
1 | mov r8,QWORD PTR [rdi] |
这种时候,CPU 会利用操作数前推技术,尽量减少阻塞等待。
多发射
现代复杂的 CPU 硬件,其实也不只有一条 CPU 流水线。简单从逻辑上来理解,可以假设是有多条流水线,可以同时执行指令,但是也并不是简单的重复整个流水线上的所有硬件。
多发射可以理解为 CPU 硬件层面的并发,如果两条指令没有前后的顺序依赖,那么是完全可以并发执行的。CPU 只需要保证执行的最终结果是符合期望的就可以,其实很多的性能优化,都是这一个原则,通过优化执行过程,但是保持最终结果一致。
实践体验
理论需要结合实践,有实际的体验,才能更清晰的理解原理。
这次我们用 C 内联汇编来构建了几个用例来体会这其中的差异。
基准用例
1 | #include <stdio.h> |
我们用如下命令才执行,只需要 1.38
秒。
注意,需要使用 -O1
编译,因为 -O0
下,基准代码本身的开销也会很大。
1 | $ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c |
以上的代码,我们主要是构建了一个空的 for
循环,可以看下汇编代码来确认下。
一下是 test
函数对应的汇编,确认空的 for
循环代码没有被编译器优化掉。
1 | 000000000040052d <test>: |
加入两条简单指令
这次我们在 for
循环中,加入了 “加一” 和 “写内存” 的两条指令。
1 | for (long i = 0; i <= 0xffffffff; i++) { |
本次执行时间,跟基础测试基本无差别。
说明新加入的两条指令,和基准测试用的空 for
循环,被“并发” 执行了,所以并没有增加执行时间。
1 | $ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c |
再加入内存读
这个例子,也就是上一篇中优化 LuaJIT 时碰到的情况。
新加入的内存读,跟原有的内存写,构成了数据依赖。
1 | for (long i = 0; i <= 0xffffffff; i++) { |
再来看执行时间,这次明显慢了非常多,是的,流水线阻塞的效果就是这么感人😅
1 | $ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c |
更多的类似指令
这次我们加入另外三组类似的指令,每一组都构成一样的数据依赖。
1 | for (long i = 0; i <= 0xffffffff; i++) { |
我们再看执行时间,跟上一组几乎无差别。因为 CPU 的乱序执行,并不会只是在那里傻等。
反过来说,其实流水线阻塞也不是那么可怕,有指令阻塞的时候,CPU 还是可以干点别的。
1 | $ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c |
总结
现代 CPU 几十亿个的晶体管,不是用来摆看的,内部其实有非常复杂的电路。
很多软件层面常见的优化技术,在 CPU 硬件里也是有大量使用的。
流水线,多发射,这两个在我个人看来,是属于很重要的概念,对于软件工程师来说,也是需要能深入理解的。不仅仅是理解这个机器指令,在 CPU 上是如何执行,也是典型的系统构建思路。
最近有学习一些分布式事务的知识,其实原理上跟 CPU 硬件系统也是非常的类似。
多动手实践,还是很有好处的。
其他知识点:
- CPU 的 L1 cache,指令和数据是分开缓存的,这样不容易缓存失败。
参考资料:
https://techdecoded.intel.io/resources/understanding-the-instruction-pipeline