0%

上一篇「C 代码是如何跑起来的」中,我们了解了 C 语言这种高级语言是怎么运行起来的。

C 语言虽然也是高级语言,但是毕竟是很 “古老” 的语言了(快 50 岁了)。相比较而言,C 语言的抽象层次并不算高,从 C 语言的表达能力里,还是可以体会到硬件的影子。

1
旁白:通常而言,抽象层次越高,意味着程序员的在编写代码的时候,心智负担就越小。

今天我们来看下 Lua 这门相对小众的语言,是如何跑起来的。

解释型

不同于 C 代码,编译器将其直接编译为物理 CPU 可以执行的机器指令,CPU 执行这些机器执行就行。

Lua 代码则需要分为两个阶段:

  1. 先编译为字节码
  2. Lua 虚拟机解释执行这些字节码
1
旁白:虽然我们也可以直接把 Lua 源码作为输入,直接得到执行输出结果,但是实际上内部还是会分别执行这两个阶段

字节码

「CPU 提供了什么」 中,我们介绍了物理 CPU 的两大基础能力:提供一系列寄存器,能执行约定的指令集。

那么类似的,Lua 虚拟机,也同样提供这两大基础能力:

  1. 虚拟寄存器
  2. 执行字节码
1
旁白:Lua 寄存器式虚拟机,会提供虚拟的寄存器,市面上更多的虚拟机是栈式的,没有提供虚拟寄存器,但是会对应的操作数栈。

我们来用如下一段 Lua 代码(是的,逻辑跟上一篇中的 C 代码一样),看看对应的字节码。用 Lua 5.1.5 中的 luac 编译可以得到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ./luac -l simple.lua

main <simple.lua:0,0> (12 instructions, 48 bytes at 0x56150cb5a860)
0+ params, 7 slots, 0 upvalues, 4 locals, 4 constants, 1 function
1 [4] CLOSURE 0 0 ; 0x56150cb5aac0
2 [6] LOADK 1 -1 ; 1 # 将常量区中 -1 位置的值(1) 加载到寄存器 1 中
3 [7] LOADK 2 -2 ; 2 # 将常量区中 -2 位置的值(2) 加载到寄存器 1 中
4 [8] MOVE 3 0 # 将寄存器 0 的值,挪到寄存器 3
5 [8] MOVE 4 1
6 [8] MOVE 5 2
7 [8] CALL 3 3 2 # 调用寄存器 3 的函数,寄存器 4,和寄存器 5 作为两个函数参数,返回值放入寄存器 3 中
8 [10] GETGLOBAL 4 -3 ; print
9 [10] LOADK 5 -4 ; "a + b = "
10 [10] MOVE 6 3
11 [10] CALL 4 3 1
12 [10] RETURN 0 1

function <simple.lua:2,4> (3 instructions, 12 bytes at 0x56150cb5aac0)
2 params, 3 slots, 0 upvalues, 2 locals, 0 constants, 0 functions
1 [3] ADD 2 0 1 # 将寄存器 0 和 寄存器 1 的数相加,结果放入寄存器 2 中
2 [3] RETURN 2 2 # 将寄存器 2 中的值,作为返回值
3 [4] RETURN 0 1

稍微解释一下:

  1. 不像 CPU 提供的物理集群器,有不同的名字,字节码的虚拟寄存器,是没有名字的,只有数字编号。逻辑上而言,每个函数有独立的寄存器,都是从序号 0 开始的(实际上会有部分的重叠复用)
  2. Lua 字节码,也提供了定义函数,执行函数的能力
  3. 以上的输出结果是方便人类阅读的格式,实际上字节码是以非常紧凑的二进制来编码的(每个字节码,定长 32 比特)

执行字节码

Lua 虚拟机

Lua 虚拟机是一个由 C 语言实现的程序,输入是 Lua 字节码,输出是执行这些字节码的结果。

对于字节码中的一些抽象,则是在 Lua 虚拟机中来具体实现的,比如:

  1. 虚拟寄存器
  2. Lua 变量,比如 table

虚拟寄存器

对于字节码中用到的虚拟寄存器,Lua 虚拟机是用一段连续的物理内存来模拟。

具体来说:
因为 Lua 变量,在 Lua 虚拟机内部,都是通过 TValue 结构体来存储的,所以实际上虚拟寄存器,就是一个 TValue 数组。

例如下面的 MOVE 指令:

1
MOVE 3 0

实际上是完成一个 TValue 的赋值,这是 Lua 5.1.5 中对应的 C 代码:

1
2
3
4
#define setobj(L,obj1,obj2) \
{ const TValue *o2=(obj2); TValue *o1=(obj1); \
o1->value = o2->value; o1->tt=o2->tt; \
checkliveness(G(L),o1); }

其对应的关键机器指令如下:(主要是通过 mov 机器指令来完成内存的读写)

1
2
3
4
mov    rax,QWORD PTR [rsi]
mov QWORD PTR [r9+0x10],rax
mov eax,DWORD PTR [rsi+0x8]
mov DWORD PTR [r9+0x18],eax

执行

Lua 虚拟机的实现中,有这样一个 for (;;) 无限循环(在 luaV_execute 函数中)。
其核心工作跟物理 CPU 类似,读取 pc 地址的字节码(同时 pc 地址 +1),解析操作指令,然后根据操作指令,以及对应的操作数,执行字节码。
例如上面我们解释过的 MOVE 字节码指令,也就是在这个循环中执行的。其他的字节码指令,也是类似的套路来完成执行的。

pc 指针也只是一个 Lua 虚拟机位置的内存地址,并不是物理 CPU 中的 pc 寄存器。

函数

几个基本点:

  1. Lua 函数,可以简单的理解为一堆字节码的集合。
  2. Lua 虚拟机里,也有栈帧的,每个栈帧实际就是一个 C struct 描述的内存结构体。

执行一个 Lua 函数,也就是执行其对应的字节码。

总结

Lua 这种带虚拟机的语言,逻辑上跟物理 CPU 是很类似的。生成字节码,然后由虚拟机来具体执行字节码。

只是多了一层抽象虚拟,字节码解释执行的效率,是比不过机器指令的。

物理内存的读写速度,比物理寄存器要慢几倍甚至几百倍(取决于是否命中 CPU cache)。
所以 Lua 的虚拟寄存器读写,也是比真实寄存器读写要慢很多的。

不过在 Lua 语言的另一个实现 LuaJIT 中,这种抽象还是有很大机会来优化的,核心思路跟我们之前在 「C 代码是如何跑起来的」 中看到的 gcc 的编译优化一样,尽量多的使用寄存器,减少物理内存的读写。

关于 LuaJIT 确实有很多很牛的地方,以后我们再分享。

上一篇「CPU 提供了什么」中,我们了解了物理的层面的 CPU,为我们提供了什么。

本篇,我们介绍下高级语言「C 语言」是如何在物理 CPU 上面跑起来的。

C 语言提供了什么

C 语言作为高级语言,为程序员提供了更友好的表达方式。在我看来,主要是提供了以下抽象能力:

  1. 变量,以及延伸出来的复杂结构体
    我们可以基于变量来描述复杂的状态。
  2. 函数
    我们可以基于函数,把复杂的行为逻辑,拆分到不同的函数里,以简化复杂的逻辑以。以及,我们可以复用相同目的的函数,现实世界里大量的基础库,简化了程序员的编码工作。

示例代码

构建一个良好的示例代码,可以很好帮助我们去理解。
下面的示例里,我们可以看到 变量函数 都用上了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "stdio.h"

int add (int a, int b) {
return a + b;
}

int main () {
int a = 1;
int b = 2;
int c = add(a, b);

printf("a + b = %d\n", c);

return 0;
}

编译执行

毫无意外,我们得到了期望的 3

1
2
3
$ gcc -O0 -g3 -Wall -o simple simple.c
$ ./simple
a + b = 3

汇编代码

我们还是用 objdump 来看看,编译器生成了什么代码:

  1. 变量
    局部变量,包括函数参数,全部被压入了 里。
  2. 函数
    函数本身,被单独编译为了一段机器指令
    函数调用,被编译为了 call 指令,参数则是函数对应那一段机器指令的第一个指令地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ objdump -M intel -j .text -d simple

# 截取其中最重要的部分

000000000040052d <add>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 89 7d fc mov DWORD PTR [rbp-0x4],edi
400534: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
400537: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
40053a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
40053d: 01 d0 add eax,edx
40053f: 5d pop rbp
400540: c3 ret

0000000000400541 <main>:
400541: 55 push rbp
400542: 48 89 e5 mov rbp,rsp
400545: 48 83 ec 10 sub rsp,0x10
400549: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
400550: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
400557: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
40055a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40055d: 89 d6 mov esi,edx
40055f: 89 c7 mov edi,eax
400561: e8 c7 ff ff ff call 40052d <add>
400566: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
400569: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
40056c: 89 c6 mov esi,eax
40056e: bf 20 06 40 00 mov edi,0x400620
400573: b8 00 00 00 00 mov eax,0x0
400578: e8 93 fe ff ff call 400410 <printf@plt>
40057d: b8 00 00 00 00 mov eax,0x0
400582: c9 leave
400583: c3 ret
400584: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40058b: 00 00 00
40058e: 66 90 xchg ax,ax

函数内的局部变量,为什么会放入栈空间呢?

这个刚好和局部变量的作用域关联起来了:

  1. 函数执行结束,返回的时候,局部变量也应该失效了
  2. 函数返回的时候,刚好要恢复栈高度到上一个调用者函数。

这样的话,只需要栈高度恢复,也就意味着被调用函数的所有的临时变量,全部失效了。

函数内的局部变量,一定会放入栈空间吗?

答案是,不一定。
上面我们是通过 -O0 编译的,接下来,我们看下 -O1 编译生成的机器码。

此时的局部变量直接放在寄存器里了,不需要写入到栈空间了。
不过,此时 main 都已经不再调用 add 函数了,因为已经被 gcc 内联优化了。
好吧,构建个合适的用例也不容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 03 00 00 00 mov esi,0x3
40053a: bf f0 05 40 00 mov edi,0x4005f0
40053f: b8 00 00 00 00 mov eax,0x0
400544: e8 c7 fe ff ff call 400410 <printf@plt>
400549: b8 00 00 00 00 mov eax,0x0
40054e: 48 83 c4 08 add rsp,0x8
400552: c3 ret
400553: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40055a: 00 00 00
40055d: 0f 1f 00 nop DWORD PTR [rax]

禁止内联优化

我们用如下命令,关闭 gcc 的内联优化:

1
gcc -fno-inline -O1 -g3 -Wall -o simple simple.c

再来看下汇编代码,此时的机器码就符合理想的验证结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 02 00 00 00 mov esi,0x2
40053a: bf 01 00 00 00 mov edi,0x1
40053f: e8 e9 ff ff ff call 40052d <add>
400544: 89 c6 mov esi,eax
400546: bf f0 05 40 00 mov edi,0x4005f0
40054b: b8 00 00 00 00 mov eax,0x0
400550: e8 bb fe ff ff call 400410 <printf@plt>
400555: b8 00 00 00 00 mov eax,0x0
40055a: 48 83 c4 08 add rsp,0x8
40055e: c3 ret
40055f: 90 nop

总结

  1. 对于 C 语言的变量,编译器会为其分配一段内存空间来存储
    函数内的局部变量,放入栈空间是理想的映射方式。不过编译的优化模式下,则会尽量使用寄存器来存储,寄存器不够用了,才会使用栈空间。
    全局变量,则有对应的内存段来存储,这个以后可以再聊。
  2. 对于 C 语言的函数,编译器会编译为独立的一段机器指令
    调用该函数,则是执行 call 指令,意思是接下来跳转到执行这一段机器指令。

最近有一个有趣的发现,调整了一行 Lua 代码的顺序,执行时间却少了接近一半 :)

现场案例

情况下面这个 lua 脚本 order-1.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local function f2 (...)
return select('#', ...)
end

local function f1 (...)
local l = select('#', ...)
local m = 0
for i = 1, l do
m = m + select(i, ...)
end

local n = f2(...)

return m + n
end

local n = 0
for i = 1, 1000 * 1000 * 100 do
n = n + f1(1, 2, 3, 4, 5)
end

print("n: ", n)

执行时间为 6.3s

1
2
3
4
5
6
$ time luajit order-1.lua
n: 2000000000

real 0m6.343s
user 0m6.342s
sys 0m0.000s

如果将其中的 f1 函数实现,调整一下顺序:

1
2
3
4
5
6
7
8
9
10
11
local function f1 (...)
local n = f2(...)

local l = select('#', ...)
local m = 0
for i = 1, l do
m = m + select(i, ...)
end

return m + n
end

这个改动是将 n 的计算放到 m 计算的前面。
从逻辑上来说,mn 两个是并没有顺序依赖,先算哪一个都一样的,但是执行时间却少了将近一半:

1
2
3
4
5
6
$ time luajit order-2.lua
n: 2000000000

real 0m3.314s
user 0m3.312s
sys 0m0.002s

原因分析

首先肯定不是什么诡异问题,计算机可是人类最真实的伙伴了,哈哈 😄

这次是 Lua 这种高级语言,也不是 上次那种 CPU 指令级 的影响了。

tracing JIT

这次是因为 LuaJIT 的 tracing JIT 技术的影响。

不像 Java 那种 method based JIT 技术,是按照函数来即时编译的。LuaJIT 是按照 trace 来即时编译的,trace 对应的是一串代码执行路径。
LuaJIT 会把热的代码路径直接即时编译生成机器码,一串热的代码路径也就是一个 trace。同时 trace 也不是无限长的,LuaJIT 有一套机制来控制 trace 的开始结束(以后找时间再详细记录一篇的)。

具体来说是这样子的,因为在 order-1.lua 里,TRACE 1m 计算的那个 for 循环处则停止了,当 TRACE 2 开始的时候,LuaJIT 还不支持这种情况下即时编译 (还处于 NYI 状态)VARG 这个字节码(也就是对应的 ...)。

所以,导致了这部分代码不能被 JIT,回归到了 interpreter 模式,所以导致了这么大的性能差异。

如下,我们可以在 LuaJIT 输的日志中看到 NYI: bytecode 71 这个关键信息。

1
2
3
4
5
6
7
8
$ luajit -jdump=bitmsr order-1.lua

...

---- TRACE 2 start 1/3 order.lua:13
0016 UGET 2 0 ; f2 (order.lua:13)
0017 VARG 4 0 0 (order.lua:13)
---- TRACE 2 abort order.lua:13 -- NYI: bytecode 71

总结

调整了 Lua 代码顺序,影响了 LuaJIT 中 trace 的生成,导致了有字节码没法被 JIT,这部分回退到了解释模式,从而导致了较大的性能差异。

感慨一下

JIT 技术还是蛮好玩,不过需要学习掌握的东西也挺多的。

以我目前的理解,tracing JIT 算是很牛的 JIT 技术了,有其明显的优势。不过任何一项技术,总是少不了非常多的人力投入。
即使像 Lua 这种小巧的语言,也还是有不少的 NYI 没有被 JIT 技术。
像 Java 这种重型语言,JIT 这方面的技术,怕是需要很多大牛才堆出来的。

前言

前文 <一行机器指令感受下内存操作到底有多慢> 中,我们体验到了 CPU 流水线阻塞带来的数量级性能差异。当时只是根据机器码,分析推断出来的,这次我们做一些更小的实验来分析验证。

动手之前,我们先了解一些背景。在 <CPU 提供了什么> 一文中介绍过,CPU 对外提供了运行机器指令的能力。那 CPU 又是如何执行机器指令的呢?

CPU 是如何执行机器指令的

一条机器指令,在 CPU 内部也分为好多个细分步骤,逻辑上来说可以划分为这么五个阶段:

  1. 获取指令
  2. 解析指令
  3. 执行执行
  4. 访问内存
  5. 结果写回

流水线作业

例如连续的 ABCD 四条指令,CPU 并不是先完整的执行完 A,才会开始执行 B;而是 A 取指令完成,则开始解析指令 A,同时继续取指令 B,依次类推,形成了流水线作业。

理想情况下,充分利用 CPU 硬件资源,也就是让流水线上的每个器件,一直保持工作。然而实际上,因为各种原因,CPU 没法完整的跑满流水线。

比如:

  1. 跳转指令,可能跳转执行另外的指令,并不是固定的顺序执行。
    例如这样的跳转指令,可能接下来就需要跳转到 400553 的指令。
1
je    400553

对于这种分支指令,CPU 有分支预测技术,基于之前的结果预测本次分支的走向,尽量减少流水线阻塞。
2. 数据依赖,后面的指令,依赖前面的指令。
例如下面的两条指令,第二条指令的操作数 r8 依赖于第一条指令的结果。

1
2
mov    r8,QWORD PTR [rdi]
add r8,0x1

这种时候,CPU 会利用操作数前推技术,尽量减少阻塞等待。

多发射

现代复杂的 CPU 硬件,其实也不只有一条 CPU 流水线。简单从逻辑上来理解,可以假设是有多条流水线,可以同时执行指令,但是也并不是简单的重复整个流水线上的所有硬件。

多发射可以理解为 CPU 硬件层面的并发,如果两条指令没有前后的顺序依赖,那么是完全可以并发执行的。CPU 只需要保证执行的最终结果是符合期望的就可以,其实很多的性能优化,都是这一个原则,通过优化执行过程,但是保持最终结果一致。

实践体验

理论需要结合实践,有实际的体验,才能更清晰的理解原理。

这次我们用 C 内联汇编来构建了几个用例来体会这其中的差异。

基准用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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 下,基准代码本身的开销也会很大。

1
2
3
4
5
6
7
8
9
10
$ 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 循环代码没有被编译器优化掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 循环中,加入了 “加一” 和 “写内存” 的两条指令。

1
2
3
4
5
6
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"add r8, 0x1;"
"mov [rdi], r8;"
);
}

本次执行时间,跟基础测试基本无差别。
说明新加入的两条指令,和基准测试用的空 for 循环,被“并发” 执行了,所以并没有增加执行时间。

1
2
3
4
5
6
7
8
9
10
$ 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 时碰到的情况。
新加入的内存读,跟原有的内存写,构成了数据依赖。

1
2
3
4
5
6
7
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"mov r8, [rdi];"
"add r8, 0x1;"
"mov [rdi], r8;"
);
}

再来看执行时间,这次明显慢了非常多,是的,流水线阻塞的效果就是这么感人😅

1
2
3
4
5
6
7
8
9
10
$ 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

更多的类似指令

这次我们加入另外三组类似的指令,每一组都构成一样的数据依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 还是可以干点别的。

1
2
3
4
5
6
7
8
9
10
$ 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 硬件系统也是非常的类似。

多动手实践,还是很有好处的。

其他知识点:

  1. CPU 的 L1 cache,指令和数据是分开缓存的,这样不容易缓存失败。

参考资料:

https://techdecoded.intel.io/resources/understanding-the-instruction-pipeline

最近给 LuaJIT 实现一个新功能,在性能优化过程中,通过一点小改动,让 JIT 编译器少生成一行机器指令,性能提升了两个数量级 …

具体是这样子的,JIT 编译器生成出来的机器码,其中主要的部分是这样子的:

1
2
3
4
5
mov rbp, [rcx]  // 优化后,少了这一行了
cmp rbp, +0x05
jb 0x16ae0018 ->2
add rbp, -0x05
mov [rcx], rbp

是的,只是少了一行内存读取指令,性能却可以提升两个数量级。

接下来我们分析一下其中的缘由:

内存读取慢

首先对于 CPU 而言,肯定是多了一个内存读取的操作。但是多了这么一个操作,应该不至于产生数量级的性能差异的。
LuaJIT 是单线程的,读写内存应该是在操作 “独享” 的 CPU cache,这个还是很快的。
而且原来也是有一个写内存操作的,如果是提升了一倍,还是可以理解的,有这么大的差距应该还是另有原因。

流水线阻塞

我们仔细分析下 mov rbp, [rcx] 后面的几条指令,全部都依赖 rbp 这个寄存器的值。
这是一个典型的 “先读后写” 的依赖,如果内存没有读取到寄存器 rbp 中,后续几条指令全部都会因为缺乏操作数,导致 CPU 流水线阻塞等待。

对于现代 CPU 十几级的流水线而言,流水线阻塞的影响可就大多了。

总结

这种指令级的优化,需要对 CPU 内部的执行细节有清楚的了解,而 CPU 实际执行的具体过程又没有直观的过程信息,只能多做实验,通过执行时间的差异来对比分析了。

对于现代 CPU,其实硬件部分就已经很 “智能” 了,CPU 内部也会 “并发” 执行没有依赖的机器指令,也就是常言的乱序执行。所以,有些时候指令数量增加了,但是执行时间不一定会成比例增加。

但是,如果指令之间有依赖关系,那就会导致流水线的阻塞等待。这里的例子,其实并不是内存读取很慢,而是因为内存的读写操作,导致了指令之间的依赖。

为了方便理解,CPU 可以简单认为是:

  1. 一堆的寄存器,用于暂时存放数据
  2. 可以执行机器指令,完成运算 / 数据读写 等操作

寄存器

CPU 有很多的寄存器,这里我们只介绍 指令寄存器 和 通用寄存器。

指令寄存器

64 位下,指令寄存器叫 rip (32 位下叫 eip)。
指令寄存器用于存放下一条指令的地址,CPU 的工作模式,就是从 rip 指向的内存地址取一条指令,然后执行这条指令,同时 rip 指向下一条指令,如此循环,就是 CPU 的基本工作。

也就意味着,通常模式下 CPU 是按照顺序执行指令的。但是,CPU 也有一些特殊的指令,用于直接修改 rip 的地址。比如,jmp 0xff00 指令,就是把 rip 改为 0xff00,让 CPU 接下来执行内存中 0xff00 这个位置的指令。

通用寄存器

以 x86_64 来说,有 16 个“通用”寄存器。“通用”意味着可以放任意的数据,这 16 个寄存器并没有什么区别,但是实际上还是存在一些约定俗称的用法:

先看看这 8 个:
(这是原来 32 位架构下就有的,只是 32 位下是 e 开头的)

1
2
3
4
5
6
7
8
rax: "累加器"(accumulator), 很多加法乘法指令的缺省寄存器,函数返回值一般也放在这里
rbx: "基地址"(base)寄存器, 在内存寻址时存放基地址
rcx: 计数器(counter), 是重复(REP)前缀指令和 LOOP 指令的内定计数器
rdx: 用来放整数除法产生的余数,或者读写I/O端口时,用来存放端口号
rsp: 栈顶指针,指向栈的顶部
rbp: 栈底指针,指向栈的底部,通常用`rbp+偏移量`的形式来定位函数存放在栈中的局部变量
rsi: 字符串操作时,用于存放数据源的地址
rdi: 字符串操作时,用于存放目的地址的,和 rsi 经常搭配一起使用,执行字符串的复制等操作

另外还有 8 个,是 64 位架构下新增的:

1
r8, r9, r10, r11, r12, r13, r14, r15

机器指令

在 CPU 的世界里,只有 0 1 这种二进制的表示,所以指令也是用 0 1 二进制表示的。
然而,二进制对人类并不友好,所以有了汇编这种助记符。

算术运算

比如这段加法:

1
add    rax,rdx

比如这个汇编指令,表示:rax = rax + rdx,这就完成了一个加法的运算。
通常我们用 rax 寄存器来做加法运算,但是其他寄存器一样也可以完成加法运算的,比如:

1
add    rbx,0x1

这个表示 rbx = rbx + 0x1

这里的加法运算,都是在寄存器上完成的,也就是直接修改的寄存器的值。

跳转指令

比如这段无条件跳转指令

1
jmp 0x269e001c

CPU 默认是按照顺序执行指令的,跳转指令则是,让 CPU 不再顺序执行后续的指令,转而执行 0x269e001c 这个内存地址中的指令。
具体来说,将指令寄存器中的值改为 0x269e001c 即可,即:rip = 0x269e001c

内存读写指令

比如这一对 mov 指令:

1
2
mov rbp, [rcx]
mov [rcx], rbp

这里假设 rcx 的值,是一个内存地址,比如:0xff00
第一行 mov 指令,是将内存地址 0xff00 中的值,读取到 rbp 寄存器。
第二行 mov 指令,则是反过来,将 rbp 寄存器的值,写入到内存 0xff00 中。

栈操作

pushpop 这一对用于操作“栈”。
“栈”是内存空间中的一段地址,我们约定是以栈的形式来使用它,并且用 rsp 寄存器指向栈顶。

栈操作本质也是内存读写操作,只是以栈的方式来使用。

比如这一对:

1
2
push   rbp
pop rbp

第一行是将 rbp 寄存器中的值压入栈,等效于:

1
2
sub rsp, 8       // rsp = rsp - 8; 栈顶向下生长 8 byte
mov [rsp], rbp // rbp 的值写入新的栈顶

第二行则是反过来,栈顶弹出一个值,写入到 rbp 寄存器中,等效于:

1
2
mov rbp, [rsp]   // 栈顶的值写入 rbp
add rsp, 8 // rsp = rsp + 8; 栈顶向上缩小 8 byte

注意:因为栈在内存空间中是倒过来的,所以是向下生长的。

还是看实际例子更直接,对于这样一份 C 代码:

1
2
3
4
5
6
7
8
9
10
int add (int a, int b) {
return a + b;
}

int main (void) {
int a = 10;
int b = 20;
int c = add(a, b);
return c;
}

先使用 gcc 编译

1
[dou@localhost ~ 0 ]$ gcc -g -O0 hello.c -o hello

然后使用 objdump 生成 Intel 风格的汇编代码
只摘取其中结果中最重要的汇编代码,# 之后的内容为手动加的注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
[dou@localhost ~ 0 ]$ objdump -M intel -j .text -d hello

00000000004004ed <add>:
4004ed: 55 push rbp # 将 rbp 寄存器的值压入栈
4004ee: 48 89 e5 mov rbp,rsp # 将 rsp 寄存器的值 移动到 rbp 寄存器,栈底(rbp)移动到原来的栈顶的位置(rsp)
4004f1: 89 7d fc mov DWORD PTR [rbp-0x4],edi # 将 edi 寄存器的值(参数 a),移动到 -0x4(相对于 rbp 的
地址)
4004f4: 89 75 f8 mov DWORD PTR [rbp-0x8],esi # 将 esi 寄存器的值(参数 b),移动到 -0x8(相对于 rbp 的
地址)
4004f7: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] # 将 -0x8 的值移动到 eax
4004fa: 8b 55 fc mov edx,DWORD PTR [rbp-0x4] # 将 -0x4 的值移动到 edx
4004fd: 01 d0 add eax,edx # eax += edx // a + b;
4004ff: 5d pop rbp # 从栈顶弹出一个值,放到 rbp 里
400500: c3 ret # 从栈顶弹出一个值,放到 rip 里,也就是相当于 pop rip

0000000000400501 <main>:
400501: 55 push rbp # 将 rbp 压入栈
400502: 48 89 e5 mov rbp,rsp # 将 rsp 寄存器的值 移动到 rbp 寄存器,栈底(rbp)移动到原来的栈顶的位置(rsp)
400505: 48 83 ec 10 sub rsp,0x10 # rsp -= 0x10,栈顶向下生长高度 0x10
400509: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa # 将整数 0xa 移动到 -0x4(相对于 rbp) // a = 10
400510: c7 45 f8 14 00 00 00 mov DWORD PTR [rbp-0x8],0x14 # 将整数 0x14 移动到 -0x8(相对于 rbp) // b = 20
400517: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8] # 将 -0x8 移动到 edx
40051a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] # 将 -0x4 移动到 eax
40051d: 89 d6 mov esi,edx # esi = edx; 为第一个参数寄存器赋值
40051f: 89 c7 mov edi,eax # edi = eax; 为第二个参数寄存器赋值
400521: e8 c7 ff ff ff call 4004ed <add> # 调用函数 add // add(a, b)
400526: 89 45 f4 mov DWORD PTR [rbp-0xc],eax # 将 eax 移动到 -0xc; 返回值入栈
400529: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc] # 将 -0xc 移动到 eax; 准备 main 函数返回值
40052c: c9 leave # 相当于mov rsp, rbp; + pop rbp; 将 rbp 和 rsp 回退到
上一帧
40052d: c3 ret # 从栈顶弹出一个值,放到 rip 里,也就是相当于 pop rip
40052e: 66 90 xchg ax,ax # nop

这里有几个知识点

0> 函数返回值寄存器

函数调用的返回值,会放入 rax 寄存器。

1> 函数参数寄存器

当函数参数少于 6 个的时候,参数从左到右依次放入:rdi, rsi, rdx, rcx, r8, r9
当大于 6 个参数时,剩余的参数从右边往左一次压入栈(取参数的时候,依次弹出,就是自然从左到右的顺序)

2> 函数调用的栈操作

call 相当于 push rip; + jump [address];
ret 相当于 pop rip;

3> callee-saved vs caller-saved

r12, r13, r14, r15, rbx, rbp 是 callee-saved 寄存器。
r10, r11,函数参数、返回值寄存器,都是 caller-saved 寄存器。

rsp 寄存器有一点点特殊,但是严格意义上也属于 callee-saved 寄存器。

豆浆大叔

OpenResty 老司机,MOSN 核心成员

名字来源

“豆浆” 这个外号最早是高中语文送给我的。

当年上高中的时候,语文老师发音又不太标准。在一次课上点名的时候,叫了好几次 “豆浆”,都没人答应,后来喊出全名,才反应过来是我。于是,“豆浆” 这个外号就这么叫开了,我觉得也挺好的,一直沿用至今。

后来上大学的时候,那会流行用微博,“豆浆” 这么大众的名字,肯定是早就被抢注了。

然后那会因为不修边幅,长得又比较成熟,被人嫌弃是大叔,我倒是欣然接受 “大叔” 这个角色,于是就以 “豆浆大叔” 为基础,再加了其他修饰词作为微博名。

而今,已然是货真价实的大叔一枚,”豆浆大叔“ 就显得非常合适了,哈哈。

终于又开始维护博客了。

以前

多年以前维护过一个博客,当时用 wordpress 搭的。
当时也写过几篇水文,只是后面懒得维护了,域名和服务器都没有续费了,汗 …

消失之后,记得有个哥们还想看下上面的一个文章,只是当时也没有备份,也看不了了 …

现在

这次打算长期维护这个博客,域名直接买了十年 …

也换了现在比较流行的 hexo + markdown 来构建静态网站,markdown 就直接在 github 上来保存了。

内容计划

个人空间而已,主要是自己的技术研究心得,或者一些人生感悟。

希望可以保持一个月两篇的节奏。