共计 3160 个字符,预计需要花费 8 分钟才能阅读完成。
前言
前文 < 一行机器指令感触下内存操作到底有多慢 > 中,咱们体验到了 CPU 流水线阻塞带来的数量级性能差别。过后只是依据机器码,剖析推断进去的,这次咱们做一些更小的试验来剖析验证。
入手之前,咱们先理解一些背景。在 \<CPU 提供了什么 > 一文中介绍过,CPU 对外提供了运行机器指令的能力。那 CPU 又是如何执行机器指令的呢?
CPU 是如何执行机器指令的
一条机器指令,在 CPU 外部也分为好多个细分步骤,逻辑上来说能够划分为这么五个阶段:
- 获取指令
- 解析指令
- 执行执行
- 拜访内存
- 后果写回
流水线作业
例如间断的 ABCD 四条指令,CPU 并不是先残缺的执行完 A,才会开始执行 B;而是 A 取指令实现,则开始解析指令 A,同时持续取指令 B,顺次类推,造成了流水线作业。
现实状况下,充分利用 CPU 硬件资源,也就是让流水线上的每个器件,始终放弃工作。然而实际上,因为各种起因,CPU 没法残缺的跑满流水线。
比方:
- 跳转指令,可能跳转执行另外的指令,并不是固定的程序执行。
例如这样的跳转指令,可能接下来就须要跳转到400553
的指令。
je 400553
对于这种分支指令,CPU 有分支预测技术,基于之前的后果预测本次分支的走向,尽量减少流水线阻塞。
- 数据依赖,前面的指令,依赖后面的指令。
例如上面的两条指令,第二条指令的操作数r8
依赖于第一条指令的后果。
mov r8,QWORD PTR [rdi]
add r8,0x1
这种时候,CPU 会利用操作数前推技术,尽量减少阻塞期待。
多发射
古代简单的 CPU 硬件,其实也不只有一条 CPU 流水线。简略从逻辑上来了解,能够假如是有多条流水线,能够同时执行指令,然而也并不是简略的反复整个流水线上的所有硬件。
多发射能够了解为 CPU 硬件层面的并发,如果两条指令没有前后的程序依赖,那么是齐全能够并发执行的。CPU 只须要保障执行的最终后果是合乎冀望的就能够,其实很多的性能优化,都是这一个准则,通过优化执行过程,然而放弃最终后果统一。
实际体验
实践须要联合实际,有理论的体验,能力更清晰的了解原理。
这次咱们用 C 内联汇编来构建了几个用例来领会这其中的差别。
基准用例
#include <stdio.h>
void test(long *a, long *b, long *c, long *d) {
__asm__ (
"mov r8, 0x0;"
"mov r9, 0x0;"
"mov r10, 0x0;"
"mov r11, 0x0;"
);
for (long i = 0; i <= 0xffffffff; i++) { }
__asm__ ("mov [rdi], r8;"
"mov [rsi], r9;"
"mov [rdx], r10;"
"mov [rcx], r11;"
);
}
int main(void) {
long a = 0;
long b = 0;
long c = 0;
long d = 0;
test(&a, &b, &c, &d);
printf("a = %ldn", a);
printf("b = %ldn", b);
printf("c = %ldn", c);
printf("d = %ldn", d);
return 0;
}
咱们用如下命令才执行,只须要 1.38
秒。
留神,须要应用 -O1
编译,因为 -O0
下,基准代码自身的开销也会很大。
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 0
b = 0
c = 0
d = 0
real 0m1.380s
user 0m1.379s
sys 0m0.001s
以上的代码,咱们次要是构建了一个空的 for
循环,能够看下汇编代码来确认下。
一下是 test
函数对应的汇编,确认空的 for
循环代码没有被编译器优化掉。
000000000040052d <test>:
40052d: 49 c7 c0 00 00 00 00 mov r8,0x0
400534: 49 c7 c1 00 00 00 00 mov r9,0x0
40053b: 49 c7 c2 00 00 00 00 mov r10,0x0
400542: 49 c7 c3 00 00 00 00 mov r11,0x0
400549: 48 b8 00 00 00 00 01 movabs rax,0x100000000
400550: 00 00 00
400553: 48 83 e8 01 sub rax,0x1 // 在 -O1 的优化下,变成了 -1 操作
400557: 75 fa jne 400553 <test+0x26>
400559: 4c 89 07 mov QWORD PTR [rdi],r8
40055c: 4c 89 0e mov QWORD PTR [rsi],r9
40055f: 4c 89 12 mov QWORD PTR [rdx],r10
400562: 4c 89 19 mov QWORD PTR [rcx],r11
400565: c3 ret
退出两条简略指令
这次咱们在 for
循环中,退出了“加一”和“写内存”的两条指令。
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"add r8, 0x1;"
"mov [rdi], r8;"
);
}
本次执行工夫,跟根底测试根本无差别。
阐明新退出的两条指令,和基准测试用的空 for
循环,被“并发”执行了,所以并没有减少执行工夫。
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 0
c = 0
d = 0
real 0m1.381s
user 0m1.381s
sys 0m0.000s
再退出内存读
这个例子,也就是上一篇中优化 LuaJIT 时碰到的状况。
新退出的内存读,跟原有的内存写,形成了数据依赖。
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ ("mov r8, [rdi];"
"add r8, 0x1;"
"mov [rdi], r8;"
);
}
再来看执行工夫,这次显著慢了十分多,是的,流水线阻塞的成果就是这么感人😅
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 0
c = 0
d = 0
real 0m8.723s
user 0m8.720s
sys 0m0.003s
更多的相似指令
这次咱们退出另外三组相似的指令,每一组都形成一样的数据依赖。
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ ("mov r8, [rdi];"
"add r8, 0x1;"
"mov [rdi], r8;"
"mov r9, [rsi];"
"add r9, 0x1;"
"mov [rsi], r9;"
"mov r10, [rdx];"
"add r10, 0x1;"
"mov [rdx], r10;"
"mov r11, [rcx];"
"add r11, 0x1;"
"mov [rcx], r11;"
);
}
咱们再看执行工夫,跟上一组简直无差别。因为 CPU 的乱序执行,并不会只是在那里傻等。
反过来说,其实流水线阻塞也不是那么可怕,有指令阻塞的时候,CPU 还是能够干点别的。
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 4294967296
c = 4294967296
d = 4294967296
real 0m8.675s
user 0m8.674s
sys 0m0.001s
总结
古代 CPU 几十亿个的晶体管,不是用来摆看的,外部其实有非常复杂的电路。
很多软件层面常见的优化技术,在 CPU 硬件里也是有大量应用的。
流水线,多发射,这两个在我集体看来,是属于很重要的概念,对于软件工程师来说,也是须要能深刻了解的。不仅仅是了解这个机器指令,在 CPU 上是如何执行,也是典型的零碎构建思路。
最近有学习一些分布式事务的常识,其实原理上跟 CPU 硬件零碎也是十分的相似。
多入手实际,还是很有益处的。
其余知识点:
- CPU 的 L1 cache,指令和数据是离开缓存的,这样不容易缓存失败。
参考资料:
https://techdecoded.intel.io/…