0%

最近给 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 上来保存了。

内容计划

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

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