在看了uwa之前發(fā)布的《Unity項(xiàng)目常見Lua解決方案性能比較》,決定動(dòng)手寫一篇關(guān)于lua+unity方案的性能優(yōu)化文。
整合lua是目前最強(qiáng)大的unity熱更新方案,畢竟這是唯一可以支持ios熱更新的辦法。然而作為一個(gè)重度ulua用戶,我們踩過了很多的坑才將ulua上升到一個(gè)可以在項(xiàng)目中大規(guī)模使用的狀態(tài)。事實(shí)上即使到現(xiàn)在lua+unity的方案仍不能輕易的說可以肆意使用,要用好,你需要知道很多。
因此,這篇文章是從一堆簡(jiǎn)單的優(yōu)化建議里頭,逐步挖掘出背后的原因。只有理解了原因,才能很清楚自己做的優(yōu)化,到底是為了什么,有多大的效果。
從最早的lua純反射調(diào)用c#,以及云風(fēng)團(tuán)隊(duì)嘗試的純c#實(shí)現(xiàn)的lua虛擬機(jī),一直發(fā)展到現(xiàn)在的各種luajit+c#靜態(tài)lua導(dǎo)出方案,lua+unity才算達(dá)到了性能上實(shí)用的級(jí)別。
但即使這樣,實(shí)際使用中我們會(huì)發(fā)現(xiàn),比起cocos2dx時(shí)代luajit的發(fā)揚(yáng)光大,現(xiàn)在lua+unity的性能依然存在著相當(dāng)?shù)钠款i。僅從《性能比較》的test1就可以看到,iphone4s下二十萬次position賦值就已經(jīng)需要3000ms,如果是coc這樣類型的游戲,不處理其他邏輯,一幀僅僅上千次位置賦值(比如數(shù)百的單位、特效和血條)就需要15ms,這顯然有些偏高。
是什么導(dǎo)致lua+unity的性能并未達(dá)到極致,要如何才能更好的使用?我們會(huì)一些例子開始,逐步挖掘背后的細(xì)節(jié)。
由于我們項(xiàng)目主要使用的是ulua(集成了topameng的cstolua,但是由于持續(xù)的性能改進(jìn),后面已經(jīng)做過大量的修改),本文的大部分結(jié)論都是基于ulua+cstolua的測(cè)試得出來的,slua都是基于其源碼來分析(根據(jù)我們分析的情況來看,兩者原理上基本一致,僅在實(shí)現(xiàn)細(xì)節(jié)上有一些區(qū)別),但沒有做過深入測(cè)試,如有問題的話歡迎交流。
既然是lua+unity,那性能好不好,基本上要看兩大點(diǎn):
lua跟c#交互時(shí)的性能如何
純lua代碼本身的性能如何
因?yàn)檫@兩部分都各有自己需要深入探討的地方,所以我們會(huì)分為多篇去探討整個(gè)lua+unity到底如何進(jìn)行優(yōu)化。
lua與c#交互篇
1.從致命的gameobj.transform.position = pos開始說起
像gameobj.transform.position = pos這樣的寫法,在unity中是再常見不過的事情
但是在ulua中,大量使用這種寫法是非常糟糕的。為什么呢?
因?yàn)槎潭桃恍写a,卻發(fā)生了非常非常多的事情,為了更直觀一點(diǎn),我們把這行代碼調(diào)用過的關(guān)鍵luaapi以及ulua相關(guān)的關(guān)鍵步驟列出來(以u(píng)lua+cstolua導(dǎo)出為準(zhǔn),gameobj是GameObject類型,pos是Vector3):
第一步:
GameObjectWrap.get_transform lua想從gameobj拿到transform,對(duì)應(yīng)gameobj.transform
LuaDLL.luanet_rawnetobj 把lua中的gameobj變成c#可以辨認(rèn)的id
ObjectTranslator.TryGetValue 用這個(gè)id,從ObjectTranslator中獲取c#的gameobject對(duì)象
gameobject.transform 準(zhǔn)備這么多,這里終于真正執(zhí)行c#獲取gameobject.transform了
ObjectTranslator.AddObject 給transform分配一個(gè)id,這個(gè)id會(huì)在lua中用來代表這個(gè)transform,transform要保存到ObjectTranslator供未來查找
LuaDLL.luanet_newudata 在lua分配一個(gè)userdata,把id存進(jìn)去,用來表示即將返回給lua的transform
LuaDLL.lua_setmetatable 給這個(gè)userdata附上metatable,讓你可以transform.position這樣使用它
LuaDLL.lua_pushvalue 返回transform,后面做些收尾
LuaDLL.lua_rawseti
LuaDLL.lua_remove
第二步:
TransformWrap.set_position lua想把pos設(shè)置到transform.position
LuaDLL.luanet_rawnetobj 把lua中的transform變成c#可以辨認(rèn)的id
ObjectTranslator.TryGetValue 用這個(gè)id,從ObjectTranslator中獲取c#的transform對(duì)象
LuaDLL.tolua_getfloat3 從lua中拿到Vector3的3個(gè)float值返回給c#
lua_getfield + lua_tonumber 3次 拿xyz的值,退棧
lua_pop
transform.position = new Vector3(x,y,z) 準(zhǔn)備了這么多,終于執(zhí)行transform.position = pos賦值了
就這么一行代碼,竟然做了這么一大堆的事情!如果是c++,a.b.c = x這樣經(jīng)過優(yōu)化后無非就是拿地址然后內(nèi)存賦值的事。但是在這里,頻繁的取值、入棧、c#到lua的類型轉(zhuǎn)換,每一步都是滿滿的cpu時(shí)間,還不考慮中間產(chǎn)生了各種內(nèi)存分配和后面的GC!
下面我們會(huì)逐步說明,其中有一些東西其實(shí)是不必要的,可以省略的。我們可以最終把他優(yōu)化成:
lua_isnumber + lua_tonumber 4次,全部完成
2.在lua中引用c#的object,代價(jià)昂貴
從上面的例子可以看到,僅僅想從gameobj拿到一個(gè)transform,就已經(jīng)有很昂貴的代價(jià)
c#的object,不能作為指針直接供c操作(其實(shí)可以通過GCHandle進(jìn)行pinning來做到,不過性能如何未測(cè)試,而且被pinning的對(duì)象無法用gc管理),因此主流的lua+unity都是用一個(gè)id表示c#的對(duì)象,在c#中通過dictionary來對(duì)應(yīng)id和object。同時(shí)因?yàn)橛辛诉@個(gè)dictionary的引用,也保證了c#的object在lua有引用的情況下不會(huì)被垃圾回收掉。
因此,每次參數(shù)中帶有object,要從lua中的id表示轉(zhuǎn)換回c#的object,就要做一次dictionary查找;每次調(diào)用一個(gè)object的成員方法,也要先找到這個(gè)object,也就要做dictionary查找。
如果之前這個(gè)對(duì)象在lua中有用過而且沒被gc,那還就是查下dictionary的事情。但如果發(fā)現(xiàn)是一個(gè)新的在lua中沒用過的對(duì)象,那就是上面例子中那一大串的準(zhǔn)備工作了。
如果你返回的對(duì)象只是臨時(shí)在lua中用一下,情況更糟糕!剛分配的userdata和dictionary索引可能會(huì)因?yàn)閘ua的引用被gc而刪除掉,然后下次你用到這個(gè)對(duì)象又得再次做各種準(zhǔn)備工作,導(dǎo)致反復(fù)的分配和gc,性能很差。
例子中的gameobj.transform就是一個(gè)巨大的陷阱,因?yàn)?transform只是臨時(shí)返回一下,但是你后面根本沒引用,又會(huì)很快被lua釋放掉,導(dǎo)致你后面每次.transform一次,都可能意味著一次分配和gc。
3.在lua和c#間傳遞unity獨(dú)有的值類型(Vector3/Quaternion等)更加昂貴
既然前面說了lua調(diào)用c#對(duì)象緩慢,如果每次vector3.x都要經(jīng)過c#,那性能基本上就處于崩潰了,所以主流的方案都將Vector3等類型實(shí)現(xiàn)為純lua代碼,Vector3就是一個(gè){x,y,z}的table,這樣在lua中使用就快了。
但是這樣做之后,c#和lua中對(duì)Vector3的表示就完全是兩個(gè)東西了,所以傳參就涉及到lua類型和c#類型的轉(zhuǎn)換,例如c#將Vector3傳給lua,整個(gè)流程如下:
1.c#中拿到Vector3的x,y,z三個(gè)值
2.push這3個(gè)float給lua棧
3.然后構(gòu)造一個(gè)表,將表的x,y,z賦值
4.將這個(gè)表push到返回值里
一個(gè)簡(jiǎn)單的傳參就要完成3次push參數(shù)、表內(nèi)存分配、3次表插入,性能可想而知。
那么如何優(yōu)化呢?我們的測(cè)試表明,直接在函數(shù)中傳遞三個(gè)float,要比傳遞Vector3要更快。
例如void SetPos(GameObject obj, Vector3 pos)改為void SetPos(GameObject obj, float x, float y, float z)
具體效果可以看后面的測(cè)試數(shù)據(jù),提升十分明顯。
4.lua和c#之間傳參、返回時(shí),盡可能不要傳遞以下類型:
嚴(yán)重類: Vector3/Quaternion等unity值類型,數(shù)組
次嚴(yán)重類:bool string 各種object
建議傳遞:int float double
雖然是lua和c#的傳參,但是從傳參這個(gè)角度講,lua和c#中間其實(shí)還夾著一層c(畢竟lua本身也是c實(shí)現(xiàn)的),lua、c、c#由于在很多數(shù)據(jù)類型的表示以及內(nèi)存分配策略都不同,因此這些數(shù)據(jù)在三者間傳遞,往往需要進(jìn)行轉(zhuǎn)換(術(shù)語parameter mashalling),這個(gè)轉(zhuǎn)換消耗根據(jù)不同的類型會(huì)有很大的不同。
先說次嚴(yán)重類中的bool string類型,涉及到c和c#的交互性能消耗,根據(jù)微軟官方文檔,在數(shù)據(jù)類型的處理上,c#定義了Blittable Types和Non-Blittable Types,其中bool和string屬于Non-Blittable Types,意思是他們?cè)赾和c#中的內(nèi)存表示不一樣,意味著從c傳遞到c#時(shí)需要進(jìn)行類型轉(zhuǎn)換,降低性能,而string還要考慮內(nèi)存分配(將string的內(nèi)存復(fù)制到托管堆,以及utf8和utf16互轉(zhuǎn))。
而嚴(yán)重類,基本上是ulua等方案在嘗試lua對(duì)象與c#對(duì)象對(duì)應(yīng)時(shí)的瓶頸所致。
Vector3等值類型的消耗,前面已經(jīng)有所提及。
而數(shù)組則更甚,因?yàn)閘ua中的數(shù)組只能以table表示,這和c#下完全是兩碼事,沒有直接的對(duì)應(yīng)關(guān)系,因此從c#的數(shù)組轉(zhuǎn)換為lua table只能逐個(gè)復(fù)制,如果涉及object/string等,更是要逐個(gè)轉(zhuǎn)換。
5.頻繁調(diào)用的函數(shù),參數(shù)的數(shù)量要控制
無論是lua的pushint/checkint,還是c到c#的參數(shù)傳遞,參數(shù)轉(zhuǎn)換都是最主要的消耗,而且是逐個(gè)參數(shù)進(jìn)行的,因此,lua調(diào)用c#的性能,除了跟參數(shù)類型相關(guān)外,也跟參數(shù)個(gè)數(shù)有很大關(guān)系。一般而言,頻繁調(diào)用的函數(shù)不要超過4個(gè)參數(shù),而動(dòng)輒十幾個(gè)參數(shù)的函數(shù)如果頻繁調(diào)用,你會(huì)看到很明顯的性能下降,手機(jī)上可能一幀調(diào)用數(shù)百次就可以看到10ms級(jí)別的時(shí)間。
6.優(yōu)先使用static函數(shù)導(dǎo)出,減少使用成員方法導(dǎo)出
前面提到,一個(gè)object要訪問成員方法或者成員變量,都需要查找lua userdata和c#對(duì)象的引用,或者查找metatable,耗時(shí)甚多。直接導(dǎo)出static函數(shù),可以減少這樣的消耗。
像obj.transform.position = pos。
我們建議的方法是,寫成靜態(tài)導(dǎo)出函數(shù),類似
class LuaUtil{
static void SetPos(GameObject obj, float x, float y, float z){obj.transform.position = new Vector3(x, y, z); }
}
然后在lua中LuaUtil.SetPos(obj, pos.x, pos.y, pos.z),這樣的性能會(huì)好非常多,因?yàn)槭〉袅藅ransform的頻繁返回,而且還避免了transform經(jīng)常臨時(shí)返回引起lua的gc。
7.注意lua拿著c#對(duì)象的引用時(shí)會(huì)造成c#對(duì)象無法釋放,這是內(nèi)存泄漏常見的起因
前面說到,c# object返回給lua,是通過dictionary將lua的userdata和c# object關(guān)聯(lián)起來,只要lua中的userdata沒回收,c# object也就會(huì)被這個(gè)dictionary拿著引用,導(dǎo)致無法回收。
最常見的就是gameobject和component,如果lua里頭引用了他們,即使你進(jìn)行了Destroy,也會(huì)發(fā)現(xiàn)他們還殘留在mono堆里。
不過,因?yàn)檫@個(gè)dictionary是lua跟c#的唯一關(guān)聯(lián),所以要發(fā)現(xiàn)這個(gè)問題也并不難,遍歷一下這個(gè)dictionary就很容易發(fā)現(xiàn)。ulua下這個(gè)dictionary在ObjectTranslator類、slua則在ObjectCache類
8.考慮在lua中只使用自己管理的id,而不直接引用c#的object
想避免lua引用c# object帶來的各種性能問題的其中一個(gè)方法就是自己分配id去索引object,同時(shí)相關(guān)c#導(dǎo)出函數(shù)不再傳遞object做參數(shù),而是傳遞int。
這帶來幾個(gè)好處:
1.函數(shù)調(diào)用的性能更好;
2.明確地管理這些object的生命周期,避免讓ulua自動(dòng)管理這些對(duì)象的引用,如果在lua中錯(cuò)誤地引用了這些對(duì)象會(huì)導(dǎo)致對(duì)象無法釋放,從而內(nèi)存泄露
3.c#object返回到lua中,如果lua沒有引用,又會(huì)很容易馬上gc,并且刪除ObjectTranslator對(duì)object的引用。自行管理這個(gè)引用關(guān)系,就不會(huì)頻繁發(fā)生這樣的gc行為和分配行為。
例如,上面的LuaUtil.SetPos(GameObject obj, float x, float y, float z)可以進(jìn)一步優(yōu)化為L(zhǎng)uaUtil.SetPos(int objID, float x, float y, float z)。然后我們?cè)谧约旱拇a里頭記錄objID跟GameObject的對(duì)應(yīng)關(guān)系,如果可以,用數(shù)組來記錄而不是dictionary,則會(huì)有更快的查找效率。如此下來可以進(jìn)一步省掉lua調(diào)用c#的時(shí)間,并且對(duì)象的管理也會(huì)更高效。
9.合理利用out關(guān)鍵字返回復(fù)雜的返回值
在c#向lua返回各種類型的東西跟傳參類似,也是有各種消耗的。
比如
Vector3 GetPos(GameObject obj)
可以寫成
void GetPos(GameObject obj, out float x, out float y, out float z)
表面上參數(shù)個(gè)數(shù)增多了,但是根據(jù)生成出來的導(dǎo)出代碼(我們以u(píng)lua為準(zhǔn)),會(huì)從:
LuaDLL.tolua_getfloat3(內(nèi)含get_field + tonumber 3次)
變成
isnumber + tonumber 3次
get_field本質(zhì)上是表查找,肯定比isnumber訪問棧更慢,因此這樣做會(huì)有更好的性能。
實(shí)測(cè)
好了,說了這么多,不拿點(diǎn)數(shù)據(jù)來看還是太晦澀
為了更真實(shí)地看到純語言本身的消耗,我們直接沒有使用例子中的gameobj.transform.position,因?yàn)檫@里頭有一部分時(shí)間是浪費(fèi)在unity內(nèi)部的。
我們重寫了一個(gè)簡(jiǎn)化版的GameObject2和Transform2。
class Transform2{
public Vector3 position = new Vector3();
}
class GameObject2{
public Transform2 transform = new Transform2();
}
然后我們用幾個(gè)不同的調(diào)用方式來設(shè)置transform的position
方式1:gameobject.transform.position = Vector3.New(1,2,3)
方式2:gameobject:SetPos(Vector3.New(1,2,3))
方式3:gameobject:SetPos2(1,2,3)
方式4:GOUtil.SetPos(gameobject, Vector3.New(1,2,3))
方式5:GOUtil.SetPos2(gameobjectid, Vector3.New(1,2,3))
方式6:GOUtil.SetPos3(gameobjectid, 1,2,3)
分別進(jìn)行1000000次,結(jié)果如下(測(cè)試環(huán)境是windows版本,cpu是i7-4770,luajit的jit模式關(guān)閉,手機(jī)上會(huì)因?yàn)閘uajit架構(gòu)、il2cpp等因素干擾有所不同,但這點(diǎn)我們會(huì)在下一篇進(jìn)一步闡述):
方式1:903ms
方式2:539ms
方式3:343ms
方式4:559ms
方式5:470ms
方式6:304ms
可以看到,每一步優(yōu)化,都是提升明顯的,尤其是移除.transform獲取以及Vector3轉(zhuǎn)換提升更是巨大,我們僅僅只是改變了對(duì)外導(dǎo)出的方式,并不需要付出很高成本,就已經(jīng)可以節(jié)省66%的時(shí)間。
實(shí)際上能不能再進(jìn)一步呢?還能!在方式6的基礎(chǔ)上,我們可以再做到只有200ms!
這里賣個(gè)關(guān)子,下一篇luajit集成中我們進(jìn)一步講解。一般來說,我們推薦做到方式6的水平已經(jīng)足夠。
這只是一個(gè)最簡(jiǎn)單的案例,有很多各種各樣的常用導(dǎo)出(例如GetComponentsInChildren這種性能大坑,或者一個(gè)函數(shù)傳遞十幾個(gè)參數(shù)的情況)都需要大家根據(jù)自己使用的情況來進(jìn)行優(yōu)化,有了我們提供的lua集成方案背后的性能原理分析,應(yīng)該就很容易去考慮怎么做了。
下一篇將會(huì)寫lua+unity性能優(yōu)化的第二部分,luajit集成的性能坑
相比起第一部分這種看導(dǎo)出代碼就能大概知道性能消耗的問題,luajit集成的問題要復(fù)雜晦澀得多。
附測(cè)試用例的c#代碼:
public class Transform2{ public Vector3 position = new Vector3();}public class GameObject2{ public Transform2 transform = new Transform2(); public void SetPos(Vector3 pos) { transform.position = pos; } public void SetPos2(float x, float y, float z) { transform.position.x = x; transform.position.y = y; transform.position.z = z; }} public class GOUtil{ private static List<GameObject2> mObjs = new List<GameObject2>(); public static GameObject2 GetByID(int id) { if(mObjs.Count == 0) { for (int i = 0; i < 1000; i++ ) { mObjs.Add(new GameObject2()); } } return mObjs[id]; } public static void SetPos(GameObject2 go, Vector3 pos) { go.transform.position = pos; } public static void SetPos2(int id, Vector3 pos) { mObjs[id].transform.position = pos; } public static void SetPos3(int id, float x, float y ,float z) { var t = mObjs[id].transform; t.position.x = x; t.position.y = y; t.position.z = z; }}