譯作:窺斑見豹--Peeking inside luaJIT

原文:Peeking inside LuaJIT

luajit是lua的一款高性能虛擬機(jī)。我最近開始搗鼓它,我把目前為止所發(fā)現(xiàn)的記錄到這篇博文里。
luajit是一個(gè)tracing jit,而不是method JIT。它的工作模式并不是發(fā)現(xiàn)并優(yōu)化整個(gè)熱點(diǎn)方法(hot method),而是發(fā)現(xiàn)并優(yōu)化執(zhí)行過程中的熱點(diǎn)軌跡(hot traces)或者路徑。luaJIT也有一個(gè)相當(dāng)有意思的翻譯器,但我不會(huì)主動(dòng)提到它,除非涉及到JIT。
翻譯器的實(shí)現(xiàn)代碼主要分布到vm_<arch>.dasc文件中,其中包含翻譯器(和其它匯編)樁函數(shù)的規(guī)格說明(以類匯編語言描述)。例如,vm_x86.dasc包含如下函數(shù):

// Generate assembly code for a specific Lua bytecode.
static void build_ins(BuildCtx *ctx, BCOp op, int defop) {
  switch (op) {
  case BC_HHGTTG:   // hypothetical bytecode
    | mov eax, 42  // hypothetical implementation
    break;
  }
}

vm_86.dasc首先由dynasm.lua解析,并轉(zhuǎn)換成(lowered by)C程序。以|開頭的行被映射成對(duì)dasm_put的調(diào)用,通過這種方式,生成的C函數(shù)自動(dòng)生成(emit)匯編操作碼(assembly opcodes)。這個(gè)新生成的C程序跟buildvm.c一起鏈接,然后在編譯時(shí)生成所需的匯編樁(assembly stubs)。例如,以BC_HHGTTG(轉(zhuǎn)換后)調(diào)用build_ins,會(huì)生成把42寫到eax的匯編操作碼(不管這個(gè)指令的語義是啥)。這個(gè)匯編指令寫入到lj_vm.s,并且與luaJIT其余部分一起鏈接。

探測(cè)熱點(diǎn)軌跡

現(xiàn)在,有了一點(diǎn)背景知識(shí)后,讓我們看看lua實(shí)際上是如何找到要編譯的軌跡(traces)的。LuaJIT用一張hash表,維護(hù)了相關(guān)指令(跳轉(zhuǎn)和調(diào)用)的熱度,但是奇怪的是,它沒有解決沖突。由于熱度是啟發(fā)式的,而不密集(即是,不太可能程序中所有跳轉(zhuǎn)都是熱點(diǎn)),這個(gè)方法實(shí)際上工作得很好。下面的宏用于訪問熱點(diǎn)計(jì)數(shù)(這個(gè)宏是C語言中一個(gè)特定指令)??纯催@個(gè)宏的實(shí)現(xiàn),理解應(yīng)該會(huì)更清楚些:

// gg is the global state and pc points to the bytecode
// instruction whose hotcount we want.
#define hotcount_get(gg, pc) (gg)->hotcount[(u32ptr(pc)>>2) & (HOTCOUNT_SIZE-1)]

翻譯器執(zhí)行跳轉(zhuǎn)或者調(diào)用時(shí),就會(huì)更新并檢查這些熱點(diǎn)計(jì)數(shù)器。這是通過vm_<arch>.dasc中的hotloop和hotcall宏實(shí)現(xiàn)的(我們將只關(guān)注X86架構(gòu),所以<arch>是x86):

|.macro hotloop, reg
|  mov reg, PC
|  shr reg, 1
|  and reg, HOTCOUNT_PCMASK
|  sub word [DISPATCH+reg+GG_DISP2HOT], HOTCOUNT_LOOP
|  jb ->vm_hotloop
|.endmacro

然后,在各個(gè)地方會(huì)調(diào)用hotloop

case BC_FORL:
  |.if JIT
  |  hotloop RB
  |.endif

dynasm將這個(gè)宏調(diào)用以簡單明了的方式轉(zhuǎn)換,BC_FORL除去HOTCOUT_LOOP的計(jì)數(shù),一旦計(jì)數(shù)小于0,則跳轉(zhuǎn)到vm_hotloop

|->vm_hotloop:            // Hot loop counter underflow.
|.if JIT
|  mov LFUNC:RB, [BASE-8]     // Same as curr_topL(L).
|  mov RB, LFUNC:RB->pc
|  movzx RD, byte [RB+PC2PROTO(framesize)]
|  lea RD, [BASE+RD*8]
|  mov L:RB, SAVE_L
|  mov L:RB->base, BASE
|  mov L:RB->top, RD
|  mov FCARG2, PC
|  lea FCARG1, [DISPATCH+GG_DISP2J]
|  mov aword [DISPATCH+DISPATCH_J(L)], L:RBa
|  mov SAVE_PC, PC
|  call extern lj_trace_hot@8     // (jit_State *J, const BCIns *pc)
|  jmp <3
|.endif

dynasm通過.type指示符來識(shí)別C語言的結(jié)構(gòu)體和其內(nèi)部字段的偏移位置。在vm_hotloop的實(shí)現(xiàn)體內(nèi)部,LFUNC:RB告訴dynasm把RB的值當(dāng)作一個(gè)LFUNC來處理。RB和LFUNC在前面定義了:
譯注: LFUNC其實(shí)是GCfuncL,這是luajit的lua函數(shù)類型。與之對(duì)應(yīng)lua還有函數(shù)原型。兩者的差別類似C++的類與對(duì)象的關(guān)系,不太像js的原型跟對(duì)象的關(guān)系。詳細(xì)分析可看這篇

|.define RB,      ebp
// ...
|.type LFUNC,     GCfuncL

當(dāng)然,在LFUC:RB中把RB當(dāng)作一個(gè)LFUNC并不會(huì)做什么事,它只是標(biāo)注。但是,在下一條指令中,這個(gè)標(biāo)注讓我們可以在代碼中寫出LFUNC:RB->pc,而dynasm會(huì)自動(dòng)找到pc正確的偏移位置。讓我們逐行看一遍vm_hotloop,注意BASE指向lua求值棧的棧頂,RB和RD則是寄存器,F(xiàn)CARG1和FCARG2也是寄存器,它倆是lua調(diào)用C函數(shù)時(shí)調(diào)用規(guī)范中的前兩個(gè)函數(shù)參數(shù),SAVE_L和SAVE_PC是棧上的槽位。
首先,我們從棧頂彈出GCFuncL,并取出pc,其指向這個(gè)閉包字節(jié)碼的開頭:

|  mov LFUNC:RB, [BASE-8]
|  mov RB, LFUNC:RB->pc

luaJIT遵守一個(gè)通用慣例,函數(shù)字面量或者原型跟函數(shù)閉包分開保存。這樣,VM可以在相同函數(shù)原型的兩個(gè)閉包之間共享字節(jié)碼,從而節(jié)省空間。例如,在V8中,你用SharedFunctionInfo來保存一個(gè)函數(shù)字面量的特有信息,用 Function來表示實(shí)際可執(zhí)行的閉包。
在luaJIT中,函數(shù)字面量用GCproto來表示。在內(nèi)存中,一個(gè)GCproto對(duì)象后面跟著函數(shù)字面量(所有閉包共享,用GCfuncL來表示)的字節(jié)碼。因此,給定一個(gè)GCfuncL,我們能夠?qū)⒅赶蜃止?jié)碼數(shù)組開頭的指針減去sizeof(GCproto)來得到對(duì)應(yīng)的GCproto。PC2PROTO用這個(gè)技術(shù)來訪問GCproto結(jié)構(gòu)體的framesize字段,用它來計(jì)算棧上第一個(gè)可用槽。

|  movzx RD, byte [RB+PC2PROTO(framesize)]
|  lea RD, [BASE+RD*8]

然后,我們填好lua_state的各個(gè)字段(在lj_obj.h中定義)

|  mov L:RB, SAVE_L
|  mov L:RB->base, BASE
|  mov L:RB->top, RD

設(shè)置參數(shù)寄存器,保存一點(diǎn)東西到棧槽中,然后調(diào)用lj_trace_hot

|  mov FCARG2, PC
|  lea FCARG1, [DISPATCH+GG_DISP2J]
|  mov aword [DISPATCH+DISPATCH_J(L)], L:RBa
|  mov SAVE_PC, PC
|  call extern lj_trace_hot@8
|  jmp <3

luaJIT進(jìn)入記錄模式。

記錄軌跡

軌跡是執(zhí)行一段特定代碼路徑的字節(jié)碼線性序列。軌跡由翻譯器協(xié)助記錄(記錄始于lj_trace_hot調(diào)用之時(shí))。翻譯器使用一個(gè)指針數(shù)組,其元素指向匯編樁,并以匯編樁實(shí)現(xiàn)的字節(jié)碼指令為數(shù)組索引--原則上,翻譯一個(gè)lua程序則是把字節(jié)碼一個(gè)接一個(gè)解碼并跳轉(zhuǎn)到對(duì)應(yīng)的匯編樁上。把這個(gè)調(diào)度數(shù)組中各個(gè)元素都指向lj_vm_record(dynasm加上了lj_前綴,在vm_x86.dasc中符號(hào)其實(shí)是vm_record)從而記錄軌跡。一個(gè)簡化的vm_record如下所示:

|->vm_record:
|  mov L:RB, SAVE_L
|  mov L:RB->base, BASE
|  mov FCARG2, PC
|  mov FCARG1, L:RB
|  call extern lj_dispatch_ins@8
|  mov BASE, L:RB->base
|  movzx RA, PC_RA
|  movzx OP, PC_OP
|  movzx RD, PC_RD
|  jmp aword [DISPATCH+OP*8+GG_DISP2STATIC]
|.endif

可以看到這段代碼基本上是先調(diào)用lj_dispatch_ins,然后跳轉(zhuǎn)到 [DISPATCH+OP*8+GG_DISP2STATIC] 指向的地址。luajit會(huì)在相距調(diào)度數(shù)組GG_DISP2STATIC的地方保存調(diào)度數(shù)組的副本。調(diào)度數(shù)組當(dāng)下只包含指向的lj_vm_record的指針。
Lj_vm_record用這個(gè)副本數(shù)組來跳轉(zhuǎn)到真正的匯編樁。
我們不要老是紙上談兵,現(xiàn)在我們看看實(shí)實(shí)在在的代碼,理解代碼的精妙之處。
當(dāng)一次循環(huán)或者調(diào)用被度量為熱點(diǎn)后,翻譯器調(diào)用lj_trace_hot,其為記錄,編譯和安裝軌跡的入口。lj_trace_hot設(shè)置重要對(duì)象jit_State的狀態(tài)為LJ_TRACE_START,并且把CPU控制權(quán)移交給lj_trace_ins。
luajit的跟蹤子系統(tǒng)(tracing subsystem)有五個(gè)狀態(tài):START,RECORD, END, ASM和ERR。lj_trace_ins是一個(gè)有限自動(dòng)機(jī)(finite automaton),其基于從執(zhí)行軌跡上讀出的字節(jié)碼指令來進(jìn)行狀態(tài)之間的遷移。總體方案很簡單:

START   -------------------> RECORD ---> ERR
          set up dispatch      |             
              vector           |         [IDLE]
                               v           /
                              END -----> ASM

當(dāng)然,這些狀態(tài)遷移不會(huì)同時(shí)發(fā)生--隨著調(diào)用lj_dispatch_ins解析當(dāng)前執(zhí)行軌跡的越來越多字節(jié)碼,狀態(tài)被記錄下來,狀態(tài)遷移(或者不遷移)。跟蹤器看到的字節(jié)碼流被譯作SSA形式的中間表示(intermediate SSA representation),然后再優(yōu)化和編譯成匯編。

跟蹤只會(huì)給我們一個(gè)代碼的線性路徑,而不管其它已得到的相同的有效路徑。跟蹤JIT通常通過安裝守衛(wèi)(guards)來處理這種情況。守衛(wèi)會(huì)檢查各種假設(shè)條件,當(dāng)違反假設(shè)時(shí)跳出軌跡(就luajit而言,會(huì)返回翻譯器)。例如,考慮下面這個(gè)簡單的lua程序:

total = 0
function test()
  if total < 500 then
      total = total + 1
   end
end
for i=1,501 do
   test()
end

這段代碼會(huì)編譯為如下的字節(jié)碼(去掉一些對(duì)我們的理解不重要的部分)

;; function test()
0001    GGET     0   0
0002    KSHORT   1 500
0003    ISGE     0   1
0004    JMP      0 => 0008
0005    GGET     0   0
0006    ADDVN    0   0   0
0007    GSET     0   0
0008 => RET0     0   1
;; body (instructions 0001 to 0007 removed)
0008    FORI     0 => 0012
0009 => GGET     4   2      ; "test"
0010    CALL     4   1   1
0011    FORL     0 => 0009
0012 => RET0     0   1

(用-b -l可以讓luajit以上面的形式輸出字節(jié)碼,用-jdump輸出全部的軌跡信息)

沒啥意外的--有一個(gè)for循環(huán)(以FORI和FORL來定界),調(diào)用了501次test。luajit提取下面這段SSA格式的軌跡(也刪減了代碼)

;; Instructions 0001 to 0013 removed
0014 >  p32 HREFK  0013  "total" @11
0015 >  num HLOAD  0014
0016 >  num LT     0015  +500
0017  + num ADD    0015  +1
0018    num HSTORE 0014  0017
0019  + int ADD    0001  +1
0020 >  int LE     0019  +501
0021 ------ LOOP ------------
0022 >  num LT     0017  +500
0023  + num ADD    0017  +1
0024    num HSTORE 0014  0023
0025  + int ADD    0019  +1
0026 >  int LE     0025  +501
0027    int PHI    0019  0025
0028    num PHI    0017  0023

LOOP之后的指令不足為奇:一個(gè)典型的循環(huán)加上一個(gè)尋常的SSA phi節(jié)點(diǎn)。注意(在跟蹤JIT(tracing JITs)中很尋常),test的一個(gè)版本被完全內(nèi)聯(lián)到一個(gè)緊湊循環(huán)中。指令0016相當(dāng)有意思:>表示這是一條特殊的指令,它是一個(gè)守衛(wèi)(guard),即如果這條指令對(duì)應(yīng)的條件不滿足則這個(gè)軌跡會(huì)退出,并返回到翻譯器。在這里,如果0015(為總的值,看看0014和0015指令你就明白了)大于等于500,則會(huì)退出。有意思的原因是跟蹤編譯器沒有自作聰明,而是當(dāng)總值大于等于500時(shí)啥也沒做,這也是正確的行為。它只知道當(dāng)總值小于500時(shí),正確的作為是總值加1,因?yàn)檫@正是它在軌跡中所遵從的(what it has observed in the trace)。特別的是,如果總值大于等于500,路徑已經(jīng)走了足夠多次,這條路徑會(huì)被標(biāo)記為熱點(diǎn),然后將編譯成一個(gè)副軌跡(side-trace,相對(duì)root trace而言),你應(yīng)該自己試試分析這個(gè)過程。

Lj_record.c中的lj_record_ins在字節(jié)碼執(zhí)行前,以SSA格式記錄每條字節(jié)碼指令。一個(gè)構(gòu)架上的精妙之處在于知道要生成哪個(gè)守衛(wèi)(guard)-- 假設(shè)一個(gè)條件:
IF A < B THEN {0} ELSE {1} END
是要生成A < B的守衛(wèi)還是生成NOT (A < B)的守衛(wèi)呢?這只有在條件求值之后才知道。如果在軌跡記錄階段,A < B為真,那么我們需要一個(gè)守衛(wèi)來保證A < B,反之亦然。luajit沒有在跟蹤器中實(shí)現(xiàn)一個(gè)偏翻譯器(partial interpreter),而是把守衛(wèi)指令的插入推遲到調(diào)用下一個(gè)字節(jié)碼的lj_record_ins之前。這時(shí)它就知道條件計(jì)算的結(jié)果了。

另外一個(gè)精妙之處涉及代碼路徑包含已經(jīng)編譯的軌跡。luajit通過替換正常的循環(huán)或者調(diào)用字節(jié)碼為帶有特殊標(biāo)記的字節(jié)碼來跟蹤已編譯的軌跡。這些特殊的字節(jié)碼表示軌跡的存在,并提供開始于這點(diǎn)的已編譯軌跡的位置。然而,當(dāng)跟蹤時(shí),我們想要能看到函數(shù)字節(jié)碼。解決方案是:先臨時(shí)把特殊的JIT標(biāo)記字節(jié)碼(例如BC_JFUNCF或者BC_JLOOP)打補(bǔ)丁成原始的字節(jié)碼,再讓跟蹤器走完原始字節(jié)碼,在跟蹤結(jié)束后再把補(bǔ)丁去掉,JIT標(biāo)記字節(jié)又重新生效。這段處理在rec_func_jit(在lj_record.c中)和trace_pendpatch(在lj_trace.c中)。

快照

涉及在兩個(gè)(或者多個(gè)?。﹫?zhí)行模式轉(zhuǎn)換的VM都會(huì)面臨一個(gè)在未優(yōu)化和已優(yōu)化的堆棧布局之間的切換的問題。對(duì)luajit來說,也是這樣。luajit的棧的含義稍有不同。我們希望同一個(gè)軌跡的翻譯版本和編譯版本之間是觀測(cè)性等價(jià)(observational equivalence)的。而且編譯版本有相同的副作用,操作數(shù)棧的映射方式也要跟翻譯版本一致。然而,這種等價(jià)性不需要在已編譯的軌跡內(nèi)部保持,只需要在軌跡出口(用守衛(wèi)方式或者不用)中保持即可。
luajit解決方案是:維護(hù)一個(gè)從棧位置到SSA IR指令的映射。這些映射在luajit代碼庫中被稱為快照(snapshots)。使用一個(gè)快照,它能重新構(gòu)建操作數(shù)棧就好像軌跡被翻譯的那樣(對(duì)應(yīng)的代碼在lj_snap.c中的snap_restoreval和ln_snap_restore)。
這很慢,但套用Mike Pall的話來說:『使用數(shù)據(jù)驅(qū)動(dòng)方式的狀態(tài)恢復(fù)當(dāng)然會(huì)很慢。但是反復(fù)調(diào)用副出口(side exits)會(huì)很快觸發(fā)副軌跡(side traces)的生成??煺沼糜诔跏蓟避壽E的IR。初始化需要必要的狀態(tài),而狀態(tài)要用似載荷(pseudo-loads)。這些能一起用副軌跡的余下部分來優(yōu)化。通過后端讓似載荷與父軌跡的機(jī)器狀態(tài)相統(tǒng)一,從來能夠零成本的把似載荷鏈接到副軌跡中?!?/p>

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容