用好Lua+Unity,讓性能飛起來—LuaJIT性能坑詳解

導語:大家都知道LuaJIT比原生Lua快,快在JIT這三個字上。但實際情況是,LuaJIT的行為十分複雜。尤其JIT並不是一個簡單的把代碼翻譯成機器碼的機制,背後有很多會影響性能的因素存在,下面筆者將帶大家一一說明。

這是侑虎科技的原創文章,感謝作者招文勇供稿,歡迎轉發分享,未經作者授權請勿轉載。當然,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群:465082844)

作者博客:UDD_William - 博客園。同時,作者也是U Sparkle活動參與者哦,UWA歡迎更多開發朋友加入 U Sparkle開發者計劃,這個舞台有你更精彩!

一、LuaJIT分為JIT模式和Interpreter模式,首先要弄清楚你使用的模式

同樣的代碼,在PC下可能以不足1ms的速度完成,而到了iOS卻需要幾十ms,是因為PC的CPU更好?是,但要知道頂級iOS設備的CPU單核性能已經是PC級,幾十甚至百倍的差距顯然不在這裡。

這裡要了解LuaJIT的兩種運行模式:JIT、Interpreter

JIT模式:這是LuaJIT高效所在,簡單地說就是直接將代碼編譯成機器碼級別執行,效率大大提升(事實上這個機制沒有說的那麼簡單,下面會提到)。然而不幸的是這個模式在iOS下是無法開啟的,因為iOS為了安全,從系統設計上禁止了用戶進程自行申請有執行許可權的內存空間,因此你沒有辦法在運行時編譯出一段代碼到內存然後執行,所以JIT模式在iOS以及其他有許可權管制的平台(例如PS4,XBox)都不能使用。

Interpreter模式:那麼沒有JIT的時候怎麼辦呢?還有一個Interpreter模式。事實上這個模式跟原生Lua的原理是一樣的,就是並不直接編譯成機器碼,而是編譯成中間態的位元組碼(bytecode),然後每執行下一條位元組碼指令,都相當於switch到一個對應的function中執行,相比之下當然比JIT慢。但好處是這個模式不需要運行時生成可執行機器碼(位元組碼是不需要申請可執行內存空間的),所以任何平台任何時候都能用,跟原生Lua一樣。這個模式可以運行在任何LuaJIT已經支持的平台,而且你可以手動關閉JIT,強制運行在Interpreter模式下。

我們經常說的將Lua編譯成bytecode可以防止破解,這個bytecode是Interpreter模式的bytecode,並不是JIT編譯出的機器碼(事實上還有一個在bytecode向機器碼轉換過程中的中間碼SSA IR,有興趣可以看LuaJIT官方WIKI),比較坑的是可供32位版本和64位版本執行的bytecode還不一樣,這樣才有了著名的2.0.x版本在iOS加密不能的坑。

二、JIT模式一定更快?不一定!

iOS不能用JIT,那麼安卓下應該就可以飛起來用了吧?用腳本語言獲得飛一般的性能,讓我大紅米也能對杠iPhone!然而,並不是安卓不能開啟JIT,而是JIT的行為極其複雜,對平台高度依賴,導致它在以arm為主的安卓平台下,未必能發揮出在PC上的威力,要知道LuaJIT最初只是考慮PC平台的。

首先我們要知道,JIT到底怎麼運作的。LuaJIT使用了一個很特殊的機制(也是其大坑),叫做trace compiler的方式,來將代碼進行JIT編譯的。什麼意思呢?它不是簡單的像C++編譯器那樣直接把整套代碼翻譯成機器碼就完事了,因為這麼做有三個問題:

  1. 編譯時間長,這點比較好理解。
  2. 更關鍵的是,作為動態語言,難以優化。例如對於一個function foo(a),這個a到底是什麼類型,並不知道,對這個a的任何操作,都要檢查類型,然後根據類型做相應處理,哪怕就是一個簡單的a+b都必須這樣(a和b完全有可能是兩個表,實現的__add元方法),實際上跟Interpreter模式就沒什麼區別了,根本起不到高效運行的作用;
  3. 很多動態類型無法提前知道類型信息,也就很難做鏈接(知道某個function的地址、知道某個成員變數的地址)。

那怎麼辦呢?這個解決方案可以另寫一篇文章了。這裡只是簡單說一下LuaJIT採用的trace compiler方案:首先所有的Lua都會被編譯成bytecode,在Interpreter模式下執行,當Interpreter發現某段代碼經常被執行,比如for循環代碼(是的,大部分性能瓶頸其實都跟循環有關),那麼LuaJIT會開啟一個記錄模式,記錄這段代碼實際運行每一步的細節(比如裡頭的變數是什麼類型,猜測是數值還是table)。有了這些信息,LuaJIT就可以做優化了:如果a+b發現就是兩個數字相加,那就可以優化成數值相加;如果a.xxx就是訪問a下面某個固定的欄位,那就可以優化成固定的內存訪問,不用再走表查詢。最後就可以將這段經常執行的代碼JIT化。

這裡可以看到,第一,Interpreter模式是必須的,無論平台是否允許JIT,都必須先使用Interpreter執行;第二,並非所有代碼都會JIT執行,僅僅是部分代碼會這樣,並且是運行過程中決定的。

三、要在安卓下發揮JIT的威力,必須要解決掉JIT模式下的坑:JIT失敗

那麼說了JIT怎麼運作的,看起來沒什麼問題呀,為何說不一定更快呢?這裡就有另一個大坑:LuaJIT無法保證所有代碼都可以JIT化,並且這點只能在嘗試編譯的過程中才知道。

聽起來好像沒什麼概念。事實上,這種情況的出現,有時是毀滅性的,可以讓你的運行速度下降百倍。對,你沒看錯,是百倍,幾ms的代碼突然飆到幾百ms。具體的感受,可以看看我們之前的技術推文《Unity項目常見Lua解決方案性能比較》中S3的測試數據,一個純Lua代碼的用例(Vector3.Normalize沒有經過c#),卻出現了巨大的性能差異。而JIT失敗的原因非常多,而當你理解背後的原理後會知道,在安卓下JIT失敗的可能要比PC上高得多。

根據我們在安卓下的使用來看,最常見的有以下幾種,並且後面寫上了應對方案。

3.1 可供代碼執行的內存空間被耗盡->要麼放棄JIT,要麼修改LuaJIT的代碼

要JIT,就要編譯出機器碼,放到特定的內存空間。但是arm有一個限制,就是跳轉指令只能跳轉前後32MB的空間,這導致了一個巨大的問題:LuaJIT生成的代碼要保證在一個連續的64MB空間內,如果這個空間被其他東西佔用了,LuaJIT就會分配不出用於jit的內存,而目前LuaJIT會瘋狂重複嘗試編譯,最後導致性能處於癱瘓的狀態。

雖然網上有一些不修改LuaJIT的方案,在Lua中調用LuaJIT的jit.opt的api嘗試將內存空間分配給LuaJIT,但根據我們的測試,在Unity上這樣做仍然無法保證所有機器上能夠不出問題,因為這些方案的原理要搶在這些內存空間被用於其他用途前全部先分配給LuaJIT,但是uLua可以運行的時候已經是程序初始化非常後期的階段,這個時候眾多的Unity初始化流程可能早已耗光了這塊內存空間。相反Cocos-2dx這個問題並不多見,因為LuaJIT運行早,有很大的機會提前搶佔內存空間。

無論從代碼看還是根據我們的測試以及LuaJIT maillist的反饋來看,這個問題早在2.0.x就存在,更換2.1.0依然無法解決,我們建議,如果項目想要使用jit模式,需要在android工程的Activity入口中就載入LuaJIT,做好內存分配,然後將這個luasate傳遞給Unity使用。如果不願意趟這個麻煩,那可以根據項目實際測試的情況,考慮禁用jit模式(見文章第9點)。一般來說,Lua代碼越少,遇到這個問題的可能性越低。

3.2 寄存器分配失敗->減少local變數、避免過深的調用層次

很不幸的一點是,arm中可用的寄存器比x86少。LuaJIT為了速度,會儘可能用寄存器存儲local變數,但是如果local變數太多,寄存器不夠用,目前JIT的做法是:放棄治療(有興趣可以看看源碼中asm_head_side函數的注釋)。因此,我們能做的,只有按照官方優化指引說的,避免過多的local變數,或者通過do end來限制local變數的生命周期。

3.3 調用c函數的代碼無法JIT->使用ffi,或者使用2.1.0beta2

這裡要提醒一點,調用c#,本質也是調用c,所以只要調用c#導出,都是一樣的。而這些代碼是無法JIT化的,但是LuaJIT有一個利器,叫ffi,使用了ffi導出的c函數在調用的時候是可以JIT化的。

另外,2.1.0beta2開始正式引入了trace stitch,可以將調用c的lua代碼獨立起來,將其他可以jit的代碼jit掉,不過根據作者的說法,這個優化效果依然有限。

3.4 JIT遇到不支持的位元組碼->少用for in pairs,少用字元串連接

有非常多bytecode或者內部庫調用是無法JIT化的,最典型就是for in pairs,以及字元串連接符(2.1.0開始支持JIT)。

具體可以看Not Yet Implemented,只要不是標記yes或者2.1的代碼,就不要過多使用。

四、怎麼知道自己的代碼有沒有JIT失敗?使用v.lua

完整的LuaJIT的exe版本都會帶一個JIT目錄,下面有大量LuaJIT的工具,其中有一個v.lua,這是LuaJIT Verbose Mode(另外還有一個很重要的叫p.lua,luajit profiler,後面會提到),可以追蹤LuaJIT運行過程中的一些細節,其中就可以幫你追蹤JIT失敗的情況。

local verbo = require("jit.v")nverbo.start()n

當你看到以下錯誤的時候,說明你遇到了JIT失敗:

failed to allocate mcode memory,對應錯誤3.1

NYI: register coalescing too complex,對應錯誤3.2

NYI: C function,對應錯誤3.3(這個錯誤在2.1.0beta2中已經移除,因為有trace stitch)

NYI: bytecode,對應錯誤3.4

這在LuaJIT.exe下使用會很正常,但要在Unity下用上需要修改v.lua的代碼,把所有out:write輸出導向到Debug.Log里。

五、照著LuaJIT的偏好來寫Lua代碼

最後,趟完LuaJIT本身的深坑,還有一些相對輕鬆的坑,也就是你如何在寫Lua的時候,根據LuaJIT的特性,按照其喜好的方式來寫,獲得更好的性能

這裡可以看我們的另一篇文章《LuaJIT官方性能優化指南和註解》,這裡比較詳細地說明如何寫出適合LuaJIT的Lua代碼。

六、如果可以,用傳統的local function而非class的方式來寫代碼

由於cocos2dx時代的推廣,目前主流的Lua面向對象實現(例如Cocos-2dx以及uLua的simpleframework集成的)都依賴metatable來調用成員函數,深入讀過LuaJIT後就會知道,在Interpreter模式下,查找metatable會產生多一次表查找,而且self:Func()這種寫法的性能也遠不如先cache再調用的寫法:local f = Class.Func; f(self),因為local cache可以省去表查找的流程,根據我們的測試,Interpreter模式下,結合local cache和移除metatable流程,可以有2~3倍的性能差。

而LuaJIT官方也建議儘可能只調用local function,省去全局查找的時間。比較典型的就是Vector3的主流Lua實現都是基於metatable做的,雖然代碼更優雅,更接近面向對象的風格(va:Add(vb)對比Vector3.Add(va, vb))但是性能會差一些。當然,這點可以根據項目的實際情況來定,不必強求,畢竟要在代碼可讀性和性能間權衡。我們建議在高頻使用的對象中(例如Vector3)使用function風格的寫法,而主要的代碼可以繼續保持class風格的寫法。

七、不要過度使用C#回調Lua,這非常慢

目前LuaJIT官方文檔(ffi的文檔)中建議優先進行Lua調用c,而儘可能避免c回調Lua。當然常用的UI回調因為頻次不高所以一般可以放心使用,但是如果是每幀觸發的邏輯,那麼直接在Lua中完成,比反覆從Lua->C->Lua的調用要更快。這裡有一篇blog分析,可以參考:LuaJIT之callback大坑繞路記 - 天地之靈 - C++博客

八、藉助ffi,進一步提升LuaJIT與C/C#交互的性能

ffi是LuaJIT獨有的一個神器,用於進行高效的LuaJIT與C交互。其原理是向LuaJIT提供C代碼的原型聲明,這樣LuaJIT就可以直接生成機器碼級別的優化代碼來與C交互,不再需要傳統的Lua API來做交互。

我們進行過簡單的測試,利用ffi的交互效率可以有數倍甚至10倍級別的提升(當然具體要視乎參數列表而定),真可謂飛翔的速度。而藉助ffi也是可以提高LuaJIT與C#交互的性能。原理是利用ffi調用自己定義的C函數,再從C函數調用C#,從而優化掉LuaJIT到c這一層的性能消耗,而主要留下C到C#的交互消耗。在上一篇中我們提到的300ms優化到200ms,就是利用這個技巧達到的。

必須要注意的是,ffi只有在JIT開啟下才能發揮其性能,如果是在iOS下,ffi反而會拖慢性能。所以使用的時候必須要做好快關。

首先,我們在c中定義一個方法,用於將C#的函數註冊到c中,以便在c中可以直接調用C#的函數,這樣只要LuaJIT可以ffi調用c,也就自然可以調用C#的函數了

void gse_ffi_register_csharp(int id, void* func)n{n s_reg_funcs[id] = func;n}n

這裡,id是一個你自由分配給C#函數的id,lua通過這個id來決定調用哪個函數。

然後在C#中將C#函數註冊到c中

[DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]npublic static extern void gse_ffi_register_csharp(int funcid, IntPtr func);nnpublic static void gse_ffi_register_v_i1f3(int funcid, f_v_i1f3 func)n{n gse_ffi_register_csharp(funcid, Marshal.GetFunctionPointerForDelegate(func));n}nngse_ffi_register_v_i1f3(1, GObjSetPositionAddTerrainHeight);//將GObjSetPositionAddTerrainHeight註冊為id1的函數n

然後Lua中使用的時候,這麼調用

local ffi = require("ffi")nffi.cdef[[nint gse_ffi_i_f3(int funcid, float f1, float f2, float f3);n]]nnlocal funcid = 1nffi.C.gse_ffi_i_f3(funcid, objID, posx, posy, posz)n

就可以從Lua中利用ffi調用C#的函數了

可以類似ToLua,將這個註冊流程的代碼自動生成。

九、既然LuaJIT坑那麼多那麼複雜,為什麼不用原生Lua?

無法否認,LuaJIT的JIT模式非常難以駕馭,尤其是其在移動平台上的性能表現不穩定導致在大型工程中很難保證其性能可靠。那是不是乾脆轉用原生Lua呢?

我們的建議是,繼續使用LuaJIT,但是對於一般的團隊而言,使用Interpreter模式。

目前根據我們的測試情況來看,LuaJIT的Interpreter模式誇平台穩定性足夠,性能行為也基本接近原生Lua(不會像JIT模式有各種trace compiler帶來的坑),但是性能依然比原生Lua有絕對優勢(平均可以快3~8倍,雖然不及JIT模式極限幾十倍的提升),所以在遊戲這種性能敏感的場合下面,我們依然推薦使用LuaJIT,至少使用Interpreter模式。這樣項目既可以享受一個相對ok的語言性能,同時又不需要過度投入精力進行Lua語言的優化。

此外,LuaJIT原生提供的profiler也非常有用,更複雜的位元組碼也更有利於反破解。如果團隊有能力解決好LuaJIT的編譯以及代碼修改維護,LuaJIT還是非常值得推薦的。

不過,LuaJIT目前的更新頻率確實在減緩,最新的LuaJIT2.1.0 beta2已經有一年沒有新的beta更新(但這個版本目前看也足夠穩定),在標準上也基本停留在Lua5.1上,沒有5.3里int64/utf8的原生支持,此外由於LuaJIT的平台相關性極強,一旦希望支持的平台存在兼容性問題的話,很可能需要自行解決甚至只能轉用原生Lua。所以開發團隊需要自己權衡。但從我們的實踐情況來看,LuaJIT使用5.1的標準再集成一些外部的int64/utf解決方法就能很好地適應跨平台、國際化的需求,並沒有實質的障礙,同時繼續享受這個版本的性能優勢。

我們的項目,在戰鬥時同屏規模可達100+角色,在這樣的情況下Interpreter的性能依然有相當的壓力。所以團隊如果決定使用Lua開發,仍然要注意Lua和C #代碼的合理分配,高頻率的代碼盡量由C#完成,Lua負責組裝這些功能模塊以及編寫經常需要熱更的代碼。

最後,怎麼打開Interpreter模式?非常簡單,最你執行第一行Lua前面加上。

if jit thennn jit.off();jit.flush()nnendn

文末,再次感謝招文勇的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群:465082844)。

也歡迎大家來積极參与U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!


推薦閱讀:

Lua程序逆向之Luac文件格式分析
用LuaStudio調試Unity中SLua里的Lua5.3代碼
維基百科中模板和模塊有什麼區別?
公式計算機的另一種實現思路
Lua性能優化—Lua內存優化

TAG:Lua | luajit | Unity游戏引擎 |