本網站內容使用人工智慧(AI)或機器翻譯技術翻譯,可能存在錯誤。

Skip to content

優化 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)

{ &nbsp;  <br>  &nbsp; &nbsp;    &nbsp;//  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-&gt;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)



&gt;  namecall  0.49229717254639

&gt;  index+call  0.78510332107544

&gt;  call  0.49960780143738

 
第一個迴圈採用新的 __namecall 程式路徑,但由於所有魔法都在底層自動發生,開發者無需修改任何現有程式碼即可受益於此優化。

第二個迴圈模擬舊式的實例方法呼叫方式:先進行查找以定位方法,再進行呼叫。

最後,第三個迴圈展示開發者常用的優化方式:先查詢方法、將其儲存於局部變數中,再呼叫該變數。

這點很棒,因為它顯示了透過 __namecall 優化後,已無需再顯式快取實例函式,因為其執行速度與快取優化效果相當,因此最直觀的程式碼也將具備最佳效能。

既然 __namecall 已部署完成,且我們對目前看到的結果感到滿意,現在是時候將焦點轉向記憶體使用量,並探討如何在該領域進一步改善客戶端效能!