優化 Lua 與 C++ 之間的互通性

簡介
Roblox 引擎採用 C++ 與 Lua 混合編寫,其中執行運算密集型操作的程式碼採用優化過的 C++ 編寫,而遊戲邏輯與腳本則採用 Lua 編寫,以利開發。要讓此架構發揮效能,必須確保 Lua 與 C++ 之間的轉換盡可能快速,因為在這片「無人區」所耗費的任何時間,本質上都只是白白浪費的毫秒。
過去幾個月來,我們針對系統的這部分陸續推出了各項改進。其中一項——從 Lua 實際呼叫 C++ 方法——尤其值得玩味,因為它帶來了顯著的速度提升,且需要深入探究 Lua 的內部運作機制,才能理解其底層運作原理。
最終我們修改了 Lua 虛擬機本身,但在深入探討之前,我們需要先打好基礎。
編譯器、虛擬機器與位元組碼
當 Lua 原始碼被編譯時,會轉譯為 Lua 位元組碼,隨後由 Lua 虛擬機器執行。Lua 位元組碼總共包含約 35 條指令,涵蓋讀寫表、呼叫函式、執行二進位運算、跳轉與條件判斷等操作。 Lua 虛擬機器採用寄存器架構,有別於許多其他虛擬機器的堆疊架構,因此編譯器在產生位元組碼時,部分工作在於決定每條指令應使用哪些寄存器。
每條指令的格式為「OP_CODE A B」或「OP_CODE A B C」,其中「OP_CODE」是操作碼(例如,CALL 代表呼叫函式),而 A/B/C 則是操作碼的參數。 這些參數(或寄存器)並非實際數值,而是指向兩個表格之一的索引:常數表(寫作 Kst(..))或寄存器表(寫作 R(..))。
關於 Lua 位元組碼的詳細說明,請參閱《Lua 5.1 VM 指令的簡明入門》。這比聽起來要有趣得多,我保證!
為了讓您了解 Lua 位元組碼的樣貌,我們將先檢視一些簡單的程式,然後逐步過渡到更相關的範例。
透過 Chunkspy 工具,我們可以將 Lua 位元組碼反編譯為 Lua 組語,並取得程式碼清單以及常數表,如此一來,我們基本上就能看到針對任何給定的 Lua 原始碼所產生的位元組碼。
基礎字節碼範例
像「x = 10」這樣的簡單程式,編譯後會變成:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x 前兩行顯示常數表(第 0 槽位為字串值「x」,第 1 槽位為整數值 10),後兩行則是反編譯出的指令碼。
[第 1 行] 查閱「No Frills」中的 LOADK 指令碼,我們可以看到其形式為「LOADK A Bx --- R(A) := Kst(Bx)」。 因此,LOADK 有兩個參數(寄存器 A 和 B),其運作方式是將常數表中由第二個參數指定的位置所對應的值,Kst(Bx),賦予給由第一個參數指定的位置,R(A)。這裡的「Bx」僅表示由於指令碼僅有兩個參數,因此 B 寄存器被擴展並賦予更多位元。
[第 2 行] SETGLOBAL 的形式為「SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)。」 它使用第二個參數所指位置的常數表中的鍵值,將一個值賦予全域表。由於第二個參數是 0,而常數表在 0 處的值是「x」,因此它會使用鍵值「x」將某個值寫入全域表。寫入的內容正是第一個參數所指位置的寄存器表中的值,而前一條指令已將該位置載入為 10。
讓我們來看一個稍微複雜一點的例子:「x = 10; y = x」。我將手動執行這段程式碼的任務留給讀者作為練習。 :)
.const "x"; 0
.const 10; 1
.const"y"; 2
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x
[3] getglobal 0 0 ; x
[4] setglobal 0 2 ; y
函式呼叫的位元組碼
讓我們來看看為「foo(10):」生成的程式碼
.const "foo"; 0
.const 10; 1
[1] getglobal 0 0 ; foo // R(A) := Gbl[Kst(Bx)]
[2] loadk 1 1 ; 10 // R(A) := Kst(Bx)
[3] call 0 2 1
要執行函式呼叫,必須將函式載入第一個登錄檔,並將參數載入後續的登錄檔中。 「CALL A B C」的語義定義如下:A 存放函式,B 代表參數個數(實際上是參數個數 +1,這是因為「...」的實作方式),而 C 代表回傳值個數(同樣地,也是回傳值個數 +1,用以處理多個回傳值)。
我們對前兩行已很熟悉;它們將一個值載入寄存器表槽位 0,並將值 10 載入寄存器表槽位 1。 第三行執行函式呼叫,使用 A 寄存器(即載入「foo」的寄存器表槽位 0)中的值,其中 B 指定參數數量,C 指定回傳值數量(請記住,B 和 C 的值都應加 1)。在呼叫函式之前,虛擬機器還會驗證 R(A) 中的值是否確實可呼叫。
Lua 提供了一種機制,允許使用者透過將元表與現有表關聯,來擴展表的功能。元表包含備用方法,當主表無法執行特定方法或操作時,這些備用方法便會被調用(詳見 https://www.lua.org/pil/13.html 中的完整說明)。
就我們的目的而言,元表中最具相關性的條目是「__index」和「__call」字段。__index 用於在表中查找元素,因此程式碼「local x = my_table[10]」會首先呼叫 my_table 上的 __index 方法。 若該操作失敗,系統將改為嘗試呼叫 my_table 元表上的 __index 方法。__call 的用法類似,例如當您試圖將某個物件視為函式並呼叫它時,例如「local x = foo(42)」
為了讓 Lua 和 C++ 能夠互通,它們需要某種方式來共享函式和資料。Lua 透過提供一種稱為 UserData 的資料型別來實現這一點。UserData 物件可以在 C++ 環境中建立,而且因為它們是原生的 Lua 資料型別,因此可以附加元資料表,讓 Lua 程式碼能夠像對待一般 Lua 物件一樣與它們互動。
成員函式呼叫
好,讓我們回到字節碼的探討!接下來的範例會更有趣,因為它展示了當你遇到類似「foo:bar(10),」這類程式碼時會發生什麼事——這段程式碼是在對實例 foo(類別 Foo 的實例)呼叫 bar 方法。
foo:bar(10)
.const "foo"; 0
.const "bar"; 1
.const 10; 2
[1] getglobal 0 0 ; foo
[2] self 0 0 257 ; "bar"
[3] loadk 2 2 ; 10
[4] call 0 3 1 這裡的新
東西是 self 指令 [第 2 行],這是我們之前未曾見過的。Self 的語法是「SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)」,讓我們來分解看看。 在寄存器表的 R(A) 槽位中,它會將使用 RK(C) 槽位中的鍵值,從 R(B) 槽位中查詢到的結果存入該處。同時,它會將 R(B) 槽位中的內容複製到 R(A+1) 槽位,但這部分我們稍後再談。你可能會注意到 C 寄存器的值是 257。 這在邏輯上是成立的,因為 Lua 正使用 RK(C) 來查詢值,而 RK 會根據第 9 位元的值,選擇使用寄存器表或常數表。若該位元為 1(本例中確實如此),則使用常數表;否則,查詢將轉向寄存器表(在移除最高位元後)。
第 3 行將 10 存入第 2 個槽位,最後第 4 行將執行函式呼叫。
SELF 指令有兩個用途。首先,它會在 Foo 類別中搜尋「bar」方法,並將其存入 R(A)。 其次,由於 foo 是一個實例方法,且在呼叫時需要該類別的實例,因此會將此實例存入 R(A+1)。若您熟悉 Python 中的類別,應該能理解這個概念:方法通常寫成「def my_method(self, arg1, arg2..)」,其中 self 即為類別實例。
我們需要更深入探討這一點,並觀察當 foo 實例是一個 C++ 物件(在 Lua 中以 UserData 物件表示)時會發生什麼情況。
SELF 呼叫可視為表查閱,即 Foo[“bar”](大寫的 Foo 代表 Foo 類別,有別於實例 foo),而我們知道查閱會使用 __index 方法。 當 foo 實例在 C++ 環境中建立時,會與該實例關聯一個元表,且該元表的 __index 欄位被設定為一段 C++ 程式碼,這段程式碼會在 __index 被呼叫時被執行。
當從 Lua 呼叫 C/C++ 時,唯一傳遞的資料是一個 lua_State 物件。此物件包含與當前執行中的 Lua 執行緒相關的一切資訊。狀態物件中最關鍵的資訊是 Lua 堆疊,其中包含函式參數(可透過 lua_tointeger/tostring 等函式群組存取),同時也用於將值傳回 Lua。
以偽 C++ 語法來看,我們的 __index 函式大致如下:
int metaIndex(lua_State* L)
{
// first argument is the userdata object
UserData* userdata = lua_touserdata(L, 1);
// get some kind of descriptor, that contains information
// about what methods the class exposes
ClassDescriptor* desc = getDescriptorForUserData(userdata);
// See if the class has the requested method
const char* methodName = lua_tostring(L, 2);
MemberFunctionPtr method = desc->hasMethod(methodName);
if (method)
{
// Upvalues are values that are available when a C
// function is invoked.
lua_pushupvalue(L, method);
lua_pushcfunction(L, methodInvoker);
return 1;
}
else
{
lua_pushnil(L);
return 0;
}
}
雖然省略了許多內部細節,但核心原理如下。基於 Lua 堆疊中作為第一個參數傳入的 UserData 物件,我們能夠找到描述實際 C++ 類別的描述符,並透過該描述符判斷此類別是否具有指定名稱的方法。若存在,則將指向方法呼叫器的函式指標壓入 Lua 堆疊,並回傳成功。
此呼叫完成後,Lua 虛擬機器會將其餘參數放置於寄存器表中,接著呼叫我們從 metaIndex 方法返回的函式,該函式將再次呼叫 C++ 程式碼,並進入 invoker 函式:
int methodInvoker(lua_State* L)
{ <br> // Get the userdata and the class descriptor
UserData* userdata = lua_touserdata(L, 1);
ClassDescriptor* desc = getDescriptorForUserData(userdata);
Class* instance = (Class*)userdata;
// Using Lua's upvalue mechanism, get the 'method'
// that was stored in metaIndex.
MemberFunctionPtr method = lua_getupvalue(L, 1);
// This is hand-wavey, but we have some mechanism of being
// able to invoke a member function via the class descriptor,
// and also pop arguments from the Lua stack, and push return values
return desc->invokeFunction(instance, method, L);
}
methodInvoker 同樣使用 ClassDescriptor,但這次它能夠呼叫成員函式,並從堆疊中彈出正確的參數。
最後一哩路!
既然我們已清楚看到 Lua 與 C++ 之間的兩次往返,現在可以試著思考如何優化這個流程。
我們的最終目標是實現從 Lua 到 C++ 的單次函式呼叫,並讓 Lua 堆疊中包含所有必要的元素,以便能一次完成方法查找與呼叫。 問題似乎在於我們少了一個寄存器。當我們呼叫這個結合了查找與呼叫功能的函式時,希望 Lua 堆疊的結構是 [self, 方法名稱, arg1, arg2, ...],但觀察 SELF 結構,我們發現它將第一個槽位用於存放方法函式的查找結果,而第二個槽位則用於存放實例。
當我們研究 __call 元方法的運作方式時,有了關鍵的領悟。如果物件具有 __call 元方法,那麼在 _call 函式被呼叫之前,物件本身會被壓入堆疊,而所有參數則會向上推移。藉由利用這項功能,我們找到了一種方法,可以在不需明確將其儲存於登錄器的情況下,將「self」放入堆疊中。
第二部分則涉及將方法名稱也放入堆疊中。為此,我們必須耍點小聰明,並修改 SELF 指令碼的運作方式。
請記住,在預設情況下,SELF 會嘗試查找成員函式,並將其與實例一同儲存於 R(A+1) 中。我們最終完全跳過了這個查找過程,而是將實際物件儲存於 R(A),並將方法名稱儲存於 R(A+1)。
若此時確保 R(A) 中的物件具備 __call 元方法,我們便能將 self 一併壓入堆疊。如此一來,堆疊結構將呈現為 [self, 方法名稱, 參數…],且僅需進行單次 C++ 呼叫。完美!嗯,幾乎是完美。 :)
在宣告完成之前,我們還想對其進行最後的潤飾。我們不希望重載 __call 元方法的語義,因此改為為此類呼叫新增了一個專用的元方法——稱為 __namecall——且僅在 UserData 物件上可用。我們也修改了 SELF 指令碼,使其僅在物件具有 __namecall 元方法時才使用新的語義。
我們做的第二件事,主要是讓新路徑和舊路徑能輕鬆共用程式碼。我們將方法名稱從第二個參數移到了最後一個參數。因此,在用它查詢方法指標之後,可以輕鬆地將其從堆疊中彈出,此時堆疊的狀態就如同該函式是透過舊路徑調用時一樣。
結論
這項優化究竟有多大影響?嗯,就像程式設計中的多數事情一樣,答案是「視情況而定」。對於那些佔用資源較多——且你不太常呼叫——的函式,你不會看到太大的改善。但對於那些你經常呼叫的小型函式,節省的資源可能相當可觀。
開發者論壇上的使用者很快便注意到這個奇怪的新元方法(metamethod)的出現,並提出了一張表格,比較了 __namecall 的速度與舊式實例方法呼叫方式,以及開發者先前用來優化方法呼叫的變通方案:
local part = workspace.Baseplate
local count = 1000000
local start0 = tick()
for i=1,count do
part:IsA("BasePart")
end
local end0 = tick()
local start1 = tick()
for i=1,count do
local isa = part.IsA
isa(part, "BasePart")
end
local end1 = tick()
local start2 = tick()
local isa = part.IsA
for i=1,count do
isa(part, "Basepart")
end
local end2 = tick()
print("namecall", end0 - start0)
print("index+call", end1 - start1)
print("call", end2 - start2)
> namecall 0.49229717254639
> index+call 0.78510332107544
> call 0.49960780143738
第一個迴圈採用新的 __namecall 程式路徑,但由於所有魔法都在底層自動發生,開發者無需修改任何現有程式碼即可受益於此優化。
第二個迴圈模擬舊式的實例方法呼叫方式:先進行查找以定位方法,再進行呼叫。
最後,第三個迴圈展示開發者常用的優化方式:先查詢方法、將其儲存於局部變數中,再呼叫該變數。
這點很棒,因為它顯示了透過 __namecall 優化後,已無需再顯式快取實例函式,因為其執行速度與快取優化效果相當,因此最直觀的程式碼也將具備最佳效能。
既然 __namecall 已部署完成,且我們對目前看到的結果感到滿意,現在是時候將焦點轉向記憶體使用量,並探討如何在該領域進一步改善客戶端效能!


