用好lua+unity,讓性能飛起來——luajit集成篇/平台相關篇


luajit集成篇

大家都知道luajit比原生lua快,快在jit這三個字上。
但實際情況是,luajit的行為十分復雜。尤其jit並不是一個簡單的把代碼翻譯成機器碼的機制,背后有很多會影響性能的因素存在。
 

1.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),然后每執行下一條字節碼指令,都相當於swtich到一個對應的function中執行,相比之下當然比jit慢。但好處是這個模式不需要運行時生成可執行機器碼(字節碼是不需要申請可執行內存空間的),所以任何平台任何時候都能用,跟原生lua一樣。這個模式可以運行在任何luajit已經支持的平台,而且你可以手動關閉jit,強制運行在interpreter模式下。
我們經常說的將lua編譯成bytecode可以防止破解,這個bytecode是interpreter模式的bytecode,並不是jit編譯出的機器碼(事實上還有一個在bytecode向機器碼轉換過程中的中間碼SSA IR,有興趣可以看luajit官方wiki),比較坑的是可供32位版本和64位版本執行的bytecode還不一樣,這樣才有了著名的2.0.x版本在ios加密不能的坑。
 

2.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執行,僅僅是部分代碼會這樣,並且是運行過程中決定的。
 
 

3.要在安卓下發揮jit的威力,必須要解決掉jit模式下的坑:jit失敗

那么說了jit怎么運作的,看起來沒什么問題呀,為何說不一定更快呢?
這里就有另一個大坑: luajit無法保證所有代碼都可以jit化,並且這點只能在嘗試編譯的過程中才知道。
聽起來好像沒什么概念。事實上,這種情況的出現,有時是毀滅性的, 可以讓你的運行速度下降百倍
對,你沒看錯,是百倍,幾ms的代碼突然飆到幾百ms。
具體的感受,可以看看uwa那篇《Unity項目常見Lua解決方案性能比較》中S3的測試數據,一個純lua代碼的用例(Vector3.Normalize沒有經過c#),卻出現了巨大的性能差異。
而jit失敗的原因非常多,而當你理解背后的原理后會知道,在安卓下jit失敗的可能要比pc上高得多。
根據我們在安卓下的使用來看,最常見的有以下幾種,並且后面寫上了應對方案。
 

3.1可供代碼執行的內存空間被耗盡->要么放棄jit,要么修改luajit的代碼

要jit,就要編譯出機器碼,放到特定的內存空間。但是arm有一個限制,就是跳轉指令只能跳轉前后32MB的空間,這導致了一個巨大的問題:luajit生成的代碼要保證在一個連續的64MB空間內,如果這個空間被其他東西占用了,luajit就會分配不出用於jit的內存,而目前luajit會瘋狂重復嘗試編譯,最后導致性能處於癱瘓的狀態。
雖然網上有一些不修改luajit的方案( http://www.freelists.org/post/luajit/Performance-degraded-significantly-when-enabling-JIT,9),在lua中調用luajit的jit.opt的api嘗試將內存空間分配給luajit,但根據我們的測試,在unity上這樣做仍然無法保證所有機器上能夠不出問題,因為這些方案的原理要搶在這些內存空間被用於其他用途前全部先分配給luajit,但是ulua可以運行的時候已經是程序初始化非常后期的階段,這個時候眾多的unity初始化流程可能早已耗光了這塊內存空間。相反cocos2dx這個問題並不多見,因為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.4jit遇到不支持的字節碼->少用for in pairs,少用字符串連接

有非常多bytecode或者內部庫調用是無法jit化的,最典型就是for in pairs,以及字符串連接符(2.1.0開始支持jit)。
具體可以看 http://wiki.luajit.org/NYI,只要不是標記yes或者2.1的代碼,就不要過多使用。
 
 
 

4.怎么知道自己的代碼有沒有jit失敗?使用v.lua

完整的luajit的exe版本都會帶一個jit目錄,下面有大量luajit的工具,其中有一個v.lua,這是luajit verbose mode(另外還有一個很重要的叫p.lua,luajit profiler,后面會提到),可以追蹤luajit運行過程中的一些細節,其中就可以幫你追蹤jit失敗的情況。
local verbo = require("jit.v")
verbo.start()
當你看到以下錯誤的時候,說明你遇到了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里頭。
 
 

5.照着luajit的偏好來寫lua代碼

最后,趟完luajit本身的深坑,還有一些相對輕松的坑,也就是你如何在寫lua的時候,根據luajit的特性,按照其喜好的方式來寫,獲得更好的性能
這里可以看我們的另一篇文章《luajit官方性能優化指南和注解》,里頭比較詳細的說明如何寫出適合luajit的lua代碼。
 
 

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

由於cocos2dx時代的推廣,目前主流的lua面向對象實現(例如cocos2dx以及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風格的寫法。
 

7.不要過度使用c#回調lua,這非常慢

目前luajit官方文檔(ffi的文檔)中建議優先進行lua調用c,而盡可能避免c回調lua。當然常用的ui回調因為頻次不高所以一般可以放心使用,但是如果是每幀觸發的邏輯,那么直接在lua中完成,比反復從lua->c->lua的調用要更快。這里有一篇blog分析,可以參考:
 
 

8.借助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)
{
  s_reg_funcs[id] = func;
}

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

 

然后在c#中將c#函數注冊到c中

[DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void gse_ffi_register_csharp(int funcid, IntPtr func);

public static void gse_ffi_register_v_i1f3(int funcid, f_v_i1f3 func)
{
  gse_ffi_register_csharp(funcid, Marshal.GetFunctionPointerForDelegate(func));
}

gse_ffi_register_v_i1f3(1, GObjSetPositionAddTerrainHeight);//將GObjSetPositionAddTerrainHeight注冊為id1的函數

 

然后lua中使用的時候,這么調用

local ffi = require("ffi")
ffi.cdef[[
int gse_ffi_i_f3(int funcid, float f1, float f2, float f3);
]]

local funcid = 1
ffi.C.gse_ffi_i_f3(funcid, objID, posx, posy, posz)

就可以從lua中利用ffi調用c#的函數了
可以類似tolua,將這個注冊流程的代碼自動生成。
 
 

9.既然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 then

  jit.off();jit.flush()

end

 

平台相關篇

1.精簡你的lua導出,否則IL2CPP會是你的噩夢

網上已經有非常多IL2CPP導致包體積激增的抱怨,而基於lua靜態導出后,由於生成了大量的導出代碼。這個問題又更加嚴重
鑒於目前ios必須使用IL2CPP發布64bit版本,所以這個問題必須要重視,否則不但你的包體積會激增,binary是要加載到內存的,你的內存也會因為大量可能用不上的lua導出而變得吃緊。
移除你不必要的導出,尤其是unityengine的導出。
如果只是為了導出整個類的一兩個函數或者字段,重新寫一個util類來導出這些函數,而不是整個類進行導出。
如果有把握,可以修改自動導出的實現,自動或者手動過濾掉不必要導出的東西。
 
 

2.ios在沒有jit的加持下,luajit的性能特性與原生lua基本一致

注意,這里說的不是“性能”一致,是“性能特性”一致。luajit不開啟jit依然是要比原生lua快很多的。這里說的性能特性一致是指你可以按照原生lua的優化思路來優化luajit的非jit環境。
因為ios下無法開啟jit,只能使用interpreter,因為原生lua的優化方案基本都適用於ios下使用。這時,每一個a.b都意味着一次表查找,寫代碼的時候一定要考慮清楚,該cache的cache,該省的省。
 
 
 

3.luajit在沒有開啟GC64宏的情況下,不能使用超過1G的內存空間

隨着現在游戲越來越大,對內存的消耗也越來越高。但是luajit有一個坑也是很多人並不知道的,就是luajit的gc不支持使用1G以上的內存空間。如果你的游戲使用了1G以上的內存,luajit很可能就會分配不出內存然后crash掉。
有沒有解呢?目前有一個折中解,就是開啟LUAJIT_ENABLE_GC64宏再編譯luajit(這也是目前支持arm64 bytecode必須的),但是這個方法有一個大問題,就是開了這個宏就不能開啟jit,目前官方並沒有給出解決這個問題的時間表,所以可以認為很長一段時間內這個問題都會存在(除非哪位大牛出來拯救一下)。
當然考慮到現在ios的游戲都普遍要壓在300M以下的內存占用,這點並不用太擔心,除非你有很大的跨平台打算,或者面向未來兩年后的主流手機設備開發。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM