原文連接:UDD.William @ http://www.gamesci.com.cn/
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 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)。
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#的函數了html
void gse_ffi_register_csharp(int id, void* func)
{
s_reg_funcs[id] = func;
}android
這裏,id是一個你自由分配給c#函數的id,lua經過這個id來決定調用哪一個函數。ios
而後在c#中將c#函數註冊到c中c++
[DllImport(LUADLL, CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
public static extern void gse_ffi_register_csharp(int funcid, IntPtr func);c#
public static void gse_ffi_register_v_i1f3(int funcid, f_v_i1f3 func)
{
gse_ffi_register_csharp(funcid, Marshal.GetFunctionPointerForDelegate(func));
}api
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);
]]iphone
local funcid = 1
ffi.C.gse_ffi_i_f3(funcid, objID, posx, posy, posz)ide
就能夠從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如下的內存佔用,這點並不用太擔憂,除非你有很大的跨平臺打算,或者面向將來兩年後的主流手機設備開發。