gitbook/编译原理之美/docs/164017.md
2022-09-03 22:05:03 +08:00

179 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 34 | 运行时优化:即时编译的原理和作用
前面所讲的编译过程,都存在一个明确的编译期,编译成可执行文件后,再执行,这种编译方式**叫做提前编译AOT。** 与之对应的,另一个编译方式**是即时编译JIT**也就是在需要运行某段代码的时候再去编译。其实Java、JavaScript等语言都是通过即时编译来提高性能的。
**那么问题来了:**
* 什么时候用AOT什么时候用JIT呢
* 在讲运行期原理时,我提到程序编译后,会生成二进制的可执行文件,加载到内存以后,目标代码会放到代码区,然后开始执行。那么即时编译时,对应的过程是什么?目标代码会存放到哪里呢?
* 在什么情况下,我们可以利用即时编译技术,获得运行时的优化效果,又该如何实现呢?
本节课,我会带你掌握,即时编译技术的特点,和它的实现机理,并通过一个实际的案例,探讨如何充分利用即时编译技术,让系统获得更好的优化。这样一来,你对即时编译技术的理解会更透彻,也会更清楚怎样利用即时编译技术,来优化自己的软件。
首先,来了解一下,即时编译的特点和原理。
## 了解即时编译的特点及原理
根据计算机程序运行的机制,我们把,不需要编译成机器码的执行方式,**叫做解释执行。**解释执行,通常都会基于虚拟机来实现,比如,基于栈的虚拟机,和基于寄存器的虚拟机(在[32讲](https://time.geekbang.org/column/article/161944)中,我带你了解过)。
与解释执行对应的是编译成目标代码直接在CPU上运行。而根据编译时机的不同又分为AOT和JIT。**那么JIT的特点和使用场景是什么呢**
一般来说一个稍微大点儿的程序静态编译一次花费的时间很长而这个等待时间是很煎熬的。如果采用JIT机制你的程序就可以像解释执行的程序一样马上运行得到反馈结果。
其次JIT能保证代码的可移植性。在某些场景下我们没法提前知道程序运行的目标机器所以也就没有办法提前编译。Java语言先编译成字节码到了具体运行的平台上再即时编译成目标代码来运行这种机制使Java程序具有很好的可移植性。
再比如很多程序会用到GPU的功能加速图像的渲染但针对不同的GPU需要编译成不同的目标代码这里也会用到即时编译功能。
最后JIT是编译成机器码的在这个过程中可以进行深度的优化因此程序的性能要比解释执行高很多。
这样看来JIT非常有优势。
而从实际应用来看原来一些解释执行的语言后来也采用JIT技术优化其运行机制在保留即时运行、可移植的优点的同时又提升了运行效率**JavaScript就是其中的典型代表。**基于谷歌推出的V8开源项目JavaScript的性能获得了极大的提升使基于Web的前端应用的体验越来越好这其中就有JIT的功劳。
而且据我了解R语言也加入了JIT功能从而提升了性能Python的数据计算模块numba也采用了JIT。
在了解JIT的特点和使用场景之后你有必要对JIT和AOT在技术上的不同之处有所了解以便掌握JIT的技术原理。
静态编译的时候首先要把程序编译成二进制目标文件再链接形成可执行文件然后加载到内存中运行。JIT也需要编译成二进制的目标代码但是目标代码的加载和链接过程就不太一样了。
**首先说说目标代码的加载。**
在静态编译的情况下,应用程序会被操作系统加载,目标代码被放到了代码区。从安全角度出发,操作系统给每个内存区域,设置了不同的权限,代码区具备可执行权限,所以我们才能运行程序。
在JIT的情况下我们需要为这种动态生成的目标代码申请内存并给内存设置可执行权限。我写个实际的C语言程序让你直观地理解一下这个过程。
我们在一个数组里存一段小小的机器码只有9个字节。这段代码的功能相当于一个C语言函数的功能也就是把输入的参数加上2并返回。
```
/*
* 机器码,对应下面函数的功能:
* int foo(int a){
* return a + 2;
* }
*/
uint8_t machine_code[] = {
0x55, 0x48, 0x89, 0xe5,
0x8d, 0x47, 0x02, 0x5d, 0xc3
};
```
**你可能问了:**你怎么知道这个函数对应的机器码是这9个字节呢
这不难你把foo.c编译成目标文件然后查看里面的机器码就行了。
```
clang -c -O2 foo.c -o foo.o
或者
gcc -c -O2 foo.c -o foo.o
objdump -d foo.o
```
objdump命令能够反编译一个目标文件并把机器码和对应的汇编码都显示出来
![](https://static001.geekbang.org/resource/image/ac/3b/ac1eee0040fed86b591d5f6962904a3b.png)
另外用“hexdump foo.o”命令显示这个二进制文件的内容也能找到这9个字节图中橙色框中的内容
![](https://static001.geekbang.org/resource/image/64/d4/64bab30e9513fcb24d0ee7b184049ad4.png)
**这里多说一句,**如果你喜欢深入钻研的话那么我建议你研究一下如何从汇编代码生成机器码实际上就是研究汇编器的原理。比如第一行汇编指令“pushq %rbp”为什么对应的机器码只有一个字节如果指令一个字节操作数一个字节应该两个字节才对啊
其实你阅读Intel的手册之后就会知道这个机器码为什么这么设计。因为它要尽量节省空间所以实际上很多指令和操作码会被压缩进一个字节中去表示。在32讲中研究字节码的设计时你应该发现了这个规律。这些设计思路都是相通的如果你要设计自己的字节码也可以借鉴这些思想。
说回我们的话题,既然已经有了机器码,那我们接着再做下面几步:
* 用mmap申请9个字节的一块内存。用这个函数不是malloc函数的好处是可以指定内存的权限。我们先让它的权限是可读可写的。
* 然后用memcp函数把刚才那9个字节的数组拷贝到所申请的内存块中。
* 用mprotect函数把内存的权限修改为可读和可执行。
* 再接着用一个int(\*)(int)型的函数指针指向这块内存的起始地址也就是说该函数有一个int型参数返回值也是int。
* 最后通过这个函数指针调用这段机器码比如fun(1)。你打印它的值,看看是否符合预期。
完整的代码在[jit.cpp](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/33-jit/jit.cpp)里。
借这个例子你可能会知道通过内存溢出来攻击计算机是怎么回事了。因为只要一块内存是可执行的你又通过程序写了一些代码进去就可以攻击系统。是不是有点儿黑客的感觉所以在jit.cpp里我们其实很小心地把内存地址的写权限去掉了。
**如果你愿意深究,**我建议你再看一眼objdump打印的汇编码。你会发现其中开头为0、1和7的三行是没有用的。根据你之前学过的汇编知识你应该知道这三行实际是保存栈指针、设置新的栈指针的。但这个例子中都是用寄存器来操作的没用到栈所以这三行代码对应的机器码可以省掉。
最后只用4个字节的机器码也能完成同样的功能
```
//省略了三行汇编代码的机器码:
uint8_t machine_code1[] = {
0x8d, 0x47, 0x02, 0xc3
};
```
现在,你应该清楚了,动态生成的代码,是如何加载到内存,然后执行了吧?
不过刚刚这个函数比较简单只做了一点儿算术计算。通常情况下你的程序会比较复杂往往在一个函数里要调用另一个函数。比如需要在foo函数里调用bar函数。这个bar函数可能是你自己写的也可能是一个库函数。执行的时候需要能从foo函数跳转到bar函数的地址执行完毕以后再跳转回来。**那么你要如何确定bar函数的地址呢**
**这就涉及目标代码的链接问题了。**
原来编译器生成的二进制对象都是可重定位的。在静态编译的时候链接程序最重要的工作就是重定位Relocatable各个符号的地址包括全局变量、常量的地址和函数的地址这样你就可以访问这些变量或者跳转到函数的入口。
JIT没有静态链接的过程但是也可以运用同样的思路解决地址解析的问题。你编写的程序里的所有全局变量和函数都会被放到一个符号表里在符号表里记录下它们的地址。这样引用它们的函数就知道正确的地址了。
**更进一步,**你写的函数不仅可以引用你自己编写的程序中的对象还可以访问共享库中的对象。比如很多程序都会共享libc库中的标准功能这个库的源代码超过了几百万行像打印输出这样的基础功能都可以用这个库来实现。
**这时候,你可以用到动态链接技术。**动态链接技术运用得很普遍,它是在应用程序加载的时候,来做地址的重定位。
动态链接通常会采用“位置无关代码PIC”的技术使动态库映射进每个应用程序的空间时其地址看上去都不同。这样一来可以让动态库被很多应用共享从而节省内存空间而且可以提升安全性。因为固定的地址有利于恶意的程序去攻击共享库中的代码从而危及整个系统。
到目前为止你已经了解了实现JIT的两个关键技术
* 让代码动态加载和执行。
* 访问自己写的程序和共享库中的对象。
它们是JIT的核心。至于代码优化和目标代码生成与静态编译是一样的。了解这些内容之后你应该更加理解Java、JavaScript等语言即时编译运行的过程了吧
当然LLVM对即时编译提供了很好的支持**它大致的机制是这样的:**
我们编写的任何模块(Module)都以内存IR的形式存在LLVM会把模块中的符号都统一保存到符号表中。当程序要调用模块的方法时这个模块就会被即时编译形成可重定位的目标对象并被调用执行。动态链接库中的方法如printf也能够被重定位并调用。
![](https://static001.geekbang.org/resource/image/e8/fe/e8743ebbc90d04e5c65be16864d878fe.png)
在第一次编译时你可以让LLVM仅运行少量的优化算法这样编译速度比较快马上就可以运行。而对于被多次调用的函数你可以让LLVM执行更多的优化算法生成更优化版本的目标代码。而运行时所收集的某些信息可以作为某些优化算法的输入像Java和JavaScript都带有这种多次生成目标代码的功能。
带你了解JIT的原理之后接下来我再通过一个案例让你对JIT的作用有更加直观的认识。
## 用JIT提升系统性能
著名的开源数据库软件PostgreSQL你可能听说过。它的历史比MySQL久功能也比MySQL多一些。在最近的版本中它添加了基于LLVM的即时编译功能性能大大提高。
看一下下面的SQL语句
```
select count(*) from table_name where (x + y) > 100
```
**这个语句的意思是:**针对某个表统计一下字段x和y的和大于100的记录有多少条。这个SQL在运行时需要遍历所有的行并对每一行计算“(x + y) > 100”这个表达式的值。如果有1000万行这个表达式就要被执行1000万次。
PostgreSQL的团队发现直接基于AST或者某种IR解释执行这个表达式的话所用的时间占到了处理一行记录所需时间的56%。而基于LLVM实现JIT以后所用的时间只占到6%,性能整整提高了一倍。
在这里,我联系[31讲](https://time.geekbang.org/column/article/160990)内存计算的内容,**带你拓展一下。**上面的需求,是典型的基于列进行汇总计算的需求。如果对代码进行向量化处理,再保证数据的局部性,针对这个需求,性能还可以提升很多倍。
**再说回来。**除了针对表达式的计算进行优化PostgreSQL的团队还利用LLVM的JIT功能实现了其他的优化。比如编译SQL执行计划的时间缩短了5.5倍创建b树索引的时间降低了5%~19%。
那么32讲中我提到将一个规则引擎编译成字节码这样在处理大量数据时可以提高性能。这是因为JVM也会针对字节码做即时编译。道理是一样的。
## 课程小结
对现代编程语言来说,编译期和运行期的界限,越来越模糊了,解释型语言和编译型语言的区别,也越来越模糊了。即时编译技术可以生成,最满足你需求的目标代码。那么通过今天的内容,我强调这样几点:
1.为了实现JIT功能你可以动态申请内存加载目标代码到内存并赋予内存可执行的权限。在这个过程中你要注意安全因素。比如向内存写完代码以后要取消写权限。
2.可重定位的目标代码加上动态链接技术让JIT产生的代码可以互相调用以及调用共享库的功能。
3.JIT技术可以让数据库这类基础软件获得性能上的提升如果你有志参与研发这类软件掌握JIT技术会给你加分很多。
## 一课一思
你参与开发的软件特别是支持DSL的软件是否可以用JIT技术提升性能欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多朋友。