什么是 JIT?
先看一下 LuaJIT 官方的解释:LuaJIT is a Just-In-Time Compiler for the Lua programming language。
1、 LuaJIT 的组成
LuaJIT 的运行时环境包括一个用 手写汇编实现的 Lua 解释器 和一个可以 直接生成机器代码的 JIT 编译器 。
2、 工作原理
1、Lua 代码在被执行之前总是会先被转换成 LuaJIT 自己定义的 字节码(Byte Code)。 关于 LuaJIT 字节码的文档,可以参见:(这个文档描述的是 LuaJIT 2.0 的字节码,不过 2.1 里面的变化并不算太大)。
2、一开始的时候,Lua 字节码总是被 LuaJIT 的解释器 解释执行。
- LuaJIT 的解释器会在执行字节码的同时记录一些运行时的统计信息,比如每个 Lua 函数调用入口的实际运行次数,还有每个 Lua 循环的实际执行次数。
- 当这些次数超过某个预设的阈值时,便认为对应的 Lua 函数入口或者对应的 Lua 循环 足够的“热”,这时便会 触发 JIT 编译器开始工作。
3、
JIT
编译器会从 热函数 的入口或者 热循环 的某个位置开始尝试编译对应的 Lua 代码路径。 编译的过程是:- 首先,把 LuaJIT 字节码 转换成 LuaJIT 自己定义的 中间码(IR);
- 然后,再生成针对目标体系结构的 机器码(比如 x86_64 指令组成的机器码)。
4、 如果当前 Lua 代码路径上的所有的操作都可以被 JIT 编译器顺利编译,则这条编译过的代码路径便被称为一个
trace
,在物理上对应一个trace
类型的 GC 对象(即参与 Lua GC 的对象)。
1、 查看工具和内容解析
1、 你可以通过
ngx-lj-gc-objs
工具看到指定的 Nginx worker 进程里所有trace
对象的一些基本的统计信息,见 https://github.com/openresty/stapxx#ngx-lj-gc-objs2、 比如下面这一行
ngx-lj-gc-objs
工具的输出:输出内容表明:当前进程内的 LuaJIT VM 里一共有 102 个
trace
类型的 GC 对象,其中最小的trace
占用 160 个字节,最大的占用 928 个字节,平均大小是 337 字节,而所有trace
的总大小是 34468 个字节。
2、 不足之处
LuaJIT 的 JIT
编译器的实现目前还不完整,有一些基本原语它还无法编译,比如:
unpack()
函数string.match()
函数- 基于
lua_CFunction
实现的 Lua C 模块 - FNEW 字节码,等等。
所以当 JIT
编译器在当前代码路径上遇到了它不支持的操作,便会立即终止当前的 trace
编译过程(这被称为 trace abort
),而重新退回到解释器模式。
JIT 编译器不支持的原语被称为 NYI(Not Yet Implemented)原语。比较完整的 NYI 列表在这篇文档里面:http://wiki.luajit.org/NYI
3、如何避坑
- 1、 调整对应的 Lua 代码,避免使用 NYI 原语。
- 2、 增强
JIT
编译器,让越来越多的 NYI 原语能够被编译。
对于第 2 种方式,春哥一直在推动公司(CloudFlare)赞助 Mike Pall 的开发工作。 不过有些原语因为本身的代价过高,而永远不会被编译,比如基于经典的 lua_CFunction
方式实现的 Lua C 模块(所以需要尽量通过 LuaJIT 的 FFI 来调用 C)。
而对于第 1 种方法,我们如何才能知道具体是哪一行 Lua 代码上的哪一个 NYI 原语终止了 trace
编译呢?
答案很简单。就是使用 LuaJIT 安装自带的 jit.v
和 jit.dump
这两个 Lua 模块。这两个 Lua 模块会打印出 JIT
编译器工作的细节过程。
4、实例分析
在 Nginx 的上下文中,我们可以在 nginx.conf
文件中的 http {}
配置块中添加下面这一段:
init_by_lua_block {
local verbose = false
if verbose then
local dump = require("jit.dump")
dump.on(nil, "/tmp/jit.log")
else
local v = require("jit.v")
end
require("resty.core")
}
那一行 require("resty.core")
倒并不是必需的,放在那里的主要目的是为了尽量避免使用 ngx_lua
模块自己的基于 lua_CFunction
的 Lua API,减少 NYI 原语。
在上面这段 Lua 代码中,可以下分为如下两种情况:
- 当
verbose
变量为false
时(默认就为false
哈),我们使用jit.v
模块打印出比较简略的流水信息到/tmp/jit.log
文件中; - 而当
verbose
变量为true
时,我们则使用jit.dump
模块打印所有的细节信息,包括每个trace
内部的字节码、IR 码和最终生成的机器指令。
这里我们主要以 jit.v
模块为例。 在启动 Nginx 之后,应当使用 ab
和 weighttp
这样的工具对相应的服务接口进行预热,以触发 LuaJIT 的 JIT
编译器开始工作(还记得刚才我们说的 “热函数” 和 “热循环” 吗?)。
预热过程一般不用太久,跑个二三百个请求足矣。当然,压更多的请求也没关系。完事后,我们就可以检查 /tmp/jit.log
文件里面的输出了。
jit.v
模块的输出里如果有类似下面这种带编号的 TRACE 行,则表示成功编译了的 trace
对象,例如:
1、单行的
[TRACE 6 shdict.lua:126 return]
解析:这个
trace
对象编号为 6,对应的 Lua 代码路径是从shdict.lua
文件的第 126 行开始的。2、关联的 下面这样的也是成功编译了的
trace
:解析:这个
trace
编号为 16,是从waf-core.lua
文件的第 419 行开始的,同时它和编号为 15 的trace
联接了起来。-
[TRACE --- waf-core.lua:455 -- NYI: FastFunc pairs at waf-core.lua:458]
解析:上面这一行是说,这个
trace
是从waf-core.lua
文件的第 455 行开始编译的,但当编译到waf-core.lua
文件的第 458 行时,遇到了一个 NYI 原语编译不了,即 这个内建函数,于是当前的trace
编译过程被迫终止了。类似的例子还有下面这些:
[TRACE --- waf.lua:321 -- NYI: bytecode 51 at raven.lua:107]
解析:上面第二行是因为操作码 51 的 LuaJIT 字节码也是 NYI 原语,编译不了。
5、 探查字节码的工具
那么我们如何知道 51 字节码究竟是啥呢?我们可以用 nginx-devel-utils
项目中的 ljbc.lua
脚本来取得 51 号字节码的名字:
我们看到原来是用来(动态)创建 Lua 函数的 FNEW 字节码。
ljbc.lua
脚本的位置是:
https://github.com/agentzh/nginx-devel-utils/blob/master/ljbc.lua
非常简单的一个脚本,就几行 Lua 代码。
这里需要提醒的是,不同版本的 LuaJIT 的字节码可能是不相同的,所以一定要使用和你的 Nginx 链接的同一个 LuaJIT 来运行这个 ljbc.lua
工具,否则有可能会得到错误的结果。
6、对比实验
我们实际做个对比实验,看看 JIT
带来的好处:
# cat test.lua
local s = [[aaaaaabbbbbbbcccccccccccddddddddddddeeeeeeeeeeeee
fffffffffffffffffggggggggggggggaaaaaaaaaaabbbbbbbbbbbbbb
ccccccccccclllll]]
for i=1,10000 do
for j=1,10000 do
string.find(s, "ll", 1, true)
end
end
# time luajit test.lua
5.19s user
0.03s system
96% cpu
5.392 total
# time lua test.lua
9.20s user
0.02s system
99% cpu
9.270 total
本例子可以看到效率相差大约 9.2/5.19 ≈ 1.77 倍,换句话说标准 Lua 需要 177% 的时间才能完成同样的工作。估计大家觉得这个还不过瘾,再看下面示例代码:
从这个执行结果中,大致可以总结出下面几点:
- 在标准 Lua 解释器中,使用
ipairs
或pairs
没有区别; - 对于
ipairs
方式,LuaJIT 的性能大约是标准 Lua 的 40 倍。