OpenResty Lua 什么是 JIT

2021-08-12 16:54 更新

自從 OpenResty 1.5.8.1 版本之后,默認(rèn)捆綁的 Lua 解釋器就被替換成了 LuaJIT,而不再是標(biāo)準(zhǔn) Lua。單從名字上,我們就可以直接看到這個(gè)新的解釋器多了一個(gè) ?JIT?,接下來我們就一起來聊聊 ?JIT?。

先看一下 LuaJIT 官方的解釋:LuaJIT is a Just-In-Time Compilerfor the Lua programming language。

LuaJIT 的運(yùn)行時(shí)環(huán)境包括一個(gè)用手寫匯編實(shí)現(xiàn)的 Lua 解釋器和一個(gè)可以直接生成機(jī)器代碼的 JIT 編譯器。

Lua 代碼在被執(zhí)行之前總是會先被 lfn 成 LuaJIT 自己定義的字節(jié)碼(Byte Code)。關(guān)于 LuaJIT 字節(jié)碼的文檔,可以參見:http://wiki.luajit.org/Bytecode-2.0(這個(gè)文檔描述的是 LuaJIT 2.0 的字節(jié)碼,不過 2.1 里面的變化并不算太大)。

一開始的時(shí)候,Lua 字節(jié)碼總是被 LuaJIT 的解釋器解釋執(zhí)行。LuaJIT 的解釋器會在執(zhí)行字節(jié)碼時(shí)同時(shí)記錄一些運(yùn)行時(shí)的統(tǒng)計(jì)信息,比如每個(gè) Lua 函數(shù)調(diào)用入口的實(shí)際運(yùn)行次數(shù),還有每個(gè) Lua 循環(huán)的實(shí)際執(zhí)行次數(shù)。當(dāng)這些次數(shù)超過某個(gè)預(yù)設(shè)的閾值時(shí),便認(rèn)為對應(yīng)的 Lua 函數(shù)入口或者對應(yīng)的 Lua 循環(huán)足夠的“熱”,這時(shí)便會觸發(fā) JIT 編譯器開始工作。

JIT 編譯器會從熱函數(shù)的入口或者熱循環(huán)的某個(gè)位置開始嘗試編譯對應(yīng)的 Lua 代碼路徑。編譯的過程是把 LuaJIT 字節(jié)碼先轉(zhuǎn)換成 LuaJIT 自己定義的中間碼(IR),然后再生成針對目標(biāo)體系結(jié)構(gòu)的機(jī)器碼(比如 x86_64 指令組成的機(jī)器碼)。

如果當(dāng)前 Lua 代碼路徑上的所有的操作都可以被 JIT 編譯器順利編譯,則這條編譯過的代碼路徑便被稱為一個(gè)“trace”,在物理上對應(yīng)一個(gè) ?trace ?類型的 GC 對象(即參與 Lua GC 的對象)。

你可以通過 ?ngx-lj-gc-objs? 工具看到指定的 Nginx worker 進(jìn)程里所有 ?trace? 對象的一些基本的統(tǒng)計(jì)信息,見 https://github.com/openresty/stapxx#ngx-lj-gc-objs

比如下面這一行 ?ngx-lj-gc-objs? 工具的輸出

102 trace objects: max=928, avg=337, min=160, sum=34468 (in bytes)

則表明當(dāng)前進(jìn)程內(nèi)的 LuaJIT VM 里一共有 102 個(gè) trace 類型的 GC 對 象,其中最小的 trace 占用 160 個(gè)字節(jié),最大的占用 928 個(gè)字節(jié),平均大小是 337 字節(jié),而所有 trace 的總大小是 34468 個(gè)字節(jié)。

LuaJIT 的 JIT 編譯器的實(shí)現(xiàn)目前還不完整,有一些基本原語它還無法編譯,比如 pairs() 函數(shù)、unpack() 函數(shù)、string.match() 函數(shù)、基于 lua_CFunction 實(shí)現(xiàn)的 Lua C 模塊、FNEW 字節(jié)碼,等等。所以當(dāng) JIT 編譯器在當(dāng)前代碼路徑上遇到了它不支持的操作,便會立即終止當(dāng)前的 trace 編譯過程(這被稱為 trace abort),而重新退回到解釋器模式。

JIT 編譯器不支持的原語被稱為 NYI(Not Yet Implemented)原語。比較完整的 NYI 列表在這篇文檔里面:

http://wiki.luajit.org/NYI

所謂“讓更多的 Lua 代碼被 JIT 編譯”,其實(shí)就是幫助更多的 Lua 代碼路徑能為 JIT 編譯器所接受。這一般通過兩種途徑來實(shí)現(xiàn):

  1. 調(diào)整對應(yīng)的 Lua 代碼,避免使用 NYI 原語。
  2. 增強(qiáng) JIT 編譯器,讓越來越多的 NYI 原語能夠被編譯。

對于第 2 種方式,春哥一直在推動公司(CloudFlare)贊助 Mike Pall 的開發(fā)工作。不過有些原語因?yàn)楸旧淼拇鷥r(jià)過高,而永遠(yuǎn)不會被編譯,比如基于經(jīng)典的 lua_CFunction 方式實(shí)現(xiàn)的 Lua C 模塊(所以需要盡量通過 LuaJIT 的 FFI 來調(diào)用 C)。

而對于第 1 種方法,我們?nèi)绾尾拍苤谰唧w是哪一行 Lua 代碼上的哪一個(gè) NYI 原語終止了 trace 編譯呢?答案很簡單。就是使用 LuaJIT 安裝自帶的 jit.v 和 jit.dump 這兩個(gè) Lua 模塊。這兩個(gè) Lua 模塊會打印出 JIT 編譯器工作的細(xì)節(jié)過程。

在 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"
        v.on("/tmp/jit.log")
    end

    require "resty.core"
}

那一行 require "resty.core" 倒并不是必需的,放在那里的主要目的是為了盡量避免使用 ngx_lua 模塊自己的基于 lua_CFunction 的 Lua API,減少 NYI 原語。

在上面這段 Lua 代碼中,當(dāng) verbose 變量為 false 時(shí)(默認(rèn)就為 false 哈),我們使用 jit.v 模塊打印出比較簡略的流水信息到 /tmp/jit.log 文件中;而當(dāng) verbose 變量為 true 時(shí),我們則使用 jit.dump 模塊打印所有的細(xì)節(jié)信息,包括每個(gè) trace 內(nèi)部的字節(jié)碼、IR 碼和最終生成的機(jī)器指令。

這里我們主要以 jit.v 模塊為例。在啟動 Nginx 之后,應(yīng)當(dāng)使用 ab 和 weighttp 這樣的工具對相應(yīng)的服務(wù)接口進(jìn)行預(yù)熱,以觸發(fā) LuaJIT 的 JIT 編譯器開始工作(還記得剛才我們說的“熱函數(shù)”和“熱循環(huán)”嗎?)。預(yù)熱過程一般不用太久,跑個(gè)二三百個(gè)請求足矣。當(dāng)然,壓更多的請求也沒關(guān)系。完事后,我們就可以檢查 /tmp/jit.log 文件里面的輸出了。

jit.v 模塊的輸出里如果有類似下面這種帶編號的 TRACE 行,則指示成功編譯了的 trace 對象,例如

[TRACE 6 shdict.lua:126 return]

這個(gè) trace 對象編號為 6,對應(yīng)的 Lua 代碼路徑是從 shdict.lua 文件的第 126 行開始的。

下面這樣的也是成功編譯了的 trace:

[TRACE  16 (15/1) waf-core.lua:419 -> 15]

這個(gè) trace 編號為 16,是從 waf-core.lua 文件的第 419 行開始的,同時(shí)它和編號為 15 的 trace 聯(lián)接了起來。

而下面這個(gè)例子則是被中斷的 trace:

[TRACE --- waf-core.lua:455 -- NYI: FastFunc pairs at waf-core.lua:458]

上面這一行是說,這個(gè) trace 是從 waf-core.lua 文件的第 455 行開始編譯的,但當(dāng)編譯到 waf-core.lua 文件的第 458 行時(shí),遇到了一個(gè) NYI 原語編譯不了,即 pairs() 這個(gè)內(nèi)建函數(shù),于是當(dāng)前的 trace 編譯過程被迫終止了。

類似的例子還有下面這些:

[TRACE --- exit.lua:27 -- NYI: FastFunc coroutine.yield at waf-core.lua:439]
[TRACE --- waf.lua:321 -- NYI: bytecode 51 at raven.lua:107]

上面第二行是因?yàn)椴僮鞔a 51 的 LuaJIT 字節(jié)碼也是 NYI 原語,編譯不了。

那么我們?nèi)绾沃?51 字節(jié)碼究竟是啥呢?我們可以用 nginx-devel-utils 項(xiàng)目中的 ljbc.lua 腳本來取得 51 號字節(jié)碼的名字:

$ /usr/local/openresty/luajit/bin/luajit-2.1.0-alpha ljbc.lua 51
opcode 51:
FNEW

我們看到原來是用來(動態(tài))創(chuàng)建 Lua 函數(shù)的 FNEW 字節(jié)碼。ljbc.lua 腳本的位置是

https://github.com/agentzh/nginx-devel-utils/blob/master/ljbc.lua

非常簡單的一個(gè)腳本,就幾行 Lua 代碼。

這里需要提醒的是,不同版本的 LuaJIT 的字節(jié)碼可能是不相同的,所以一定要使用和你的 Nginx 鏈接的同一個(gè) LuaJIT 來運(yùn)行這個(gè) ljbc.lua 工具,否則有可能會得到錯(cuò)誤的結(jié)果。

我們實(shí)際做個(gè)對比實(shí)驗(yàn),看看 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 倍,換句話說標(biāo)準(zhǔn) Lua 需要 177% 的時(shí)間才能完成同樣的工作。估計(jì)大家覺得這個(gè)還不過癮,再看下面示例代碼:

文件 test.lua:

local loop_count = tonumber(arg[1])
local fun_pair = "ipairs" == arg[2] and ipairs or pairs

local t = {}
for i=1,100 do
    t[i] = i
end

for i=1,loop_count do
    for j=1,1000 do
        for k,v in fun_pair(t) do
            --
        end
    end
end
執(zhí)行參數(shù)執(zhí)行結(jié)果
time lua test.lua 1000 ipairs3.96s user 0.02s system 98% cpu 4.039 total
time lua test.lua 1000 pairs3.97s user 0.01s system 99% cpu 3.992 total
time luajit test.lua 1000 ipairs0.10s user 0.00s system 95% cpu 0.113 total
time luajit test.lua 10000 ipairs0.98s user 0.00s system 99% cpu 0.991 total
time luajit test.lua 1000 pairs1.54s user 0.01s system 99% cpu 1.559 total

從這個(gè)執(zhí)行結(jié)果中,大致可以總結(jié)出下面幾個(gè)觀點(diǎn):

  • 在標(biāo)準(zhǔn) Lua 解釋器中,使用 ipairs 或 pairs 沒有區(qū)別;
  • 對于 pairs 方式,LuaJIT 的性能大約是標(biāo)準(zhǔn) Lua 的 4 倍;
  • 對于 ipairs 方式,LuaJIT 的性能大約是標(biāo)準(zhǔn) Lua 的 40 倍。

可以被 JIT 編譯的元操作

下面給大家列一下截止到目前已經(jīng)可以被 JIT 編譯的元操作。 其他還有 IO、Bit、FFI、Coroutine、OS、Package、Debug、JIT 等分類,使用頻率相對較低,這里就不羅列了,可以參考官網(wǎng):http://wiki.luajit.org/NYI。

基礎(chǔ)庫的支持情況

函數(shù)編譯?備注
assertyes
collectgarbageno
dofilenever
errornever
getfenv2.1 partial只有 getfenv(0) 能編譯
getmetatableyes
ipairsyes
loadnever
loadfilenever
loadstringnever
nextno
pairsno
pcallyes
printno
rawequalyes
rawgetyes
rawlen (5.2)yes
rawsetyes
selectpartial第一個(gè)參數(shù)是靜態(tài)變量的時(shí)候可以編譯
setfenvno
setmetatableyes
tonumberpartial不能編譯非10進(jìn)制,非預(yù)期的異常輸入
tostringpartial只能編譯:字符串、數(shù)字、布爾、nil 以及支持 __tostring元方法的類型
typeyes
unpackno
xpcallyes

字符串庫

函數(shù)編譯?備注
string.byteyes
string.char2.1
string.dumpnever
string.find2.1 partial只有字符串樣式查找(沒有樣式)
string.format2.1 partial不支持 %p 或 非字符串參數(shù)的 %s
string.gmatchno
string.gsubno
string.lenyes
string.lower2.1
string.matchno
string.rep2.1
string.reverse2.1
string.subyes
string.upper2.1

函數(shù)編譯?備注
table.concat2.1
table.foreachno2.1: 內(nèi)部編譯,但還沒有外放
table.foreachi2.1
table.getnyes
table.insertpartial只有 push 操作
table.maxnno
table.pack (5.2)no
table.remove2.1部分,只有 pop 操作
table.sortno
table.unpack (5.2)no

math 庫

函數(shù)編譯?備注
math.absyes
math.acosyes
math.asinyes
math.atanyes
math.atan2yes
math.ceilyes
math.cosyes
math.coshyes
math.degyes
math.expyes
math.flooryes
math.fmodno
math.frexpno
math.ldexpyes
math.logyes
math.log10yes
math.maxyes
math.minyes
math.modfyes
math.powyes
math.radyes
math.randomyes
math.randomseedno
math.sinyes
math.sinhyes
math.sqrtyes
math.tanyes
math.tanhyes


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號