用好Lua+Unity,讓性能飛起來——Lua與C#交互篇

原文地址:用好Lua+Unity,讓性能飛起來——Lua與C#交互篇

整合Lua是目前最強大的Unity熱更新方案,畢竟這是唯一可以支持iOS熱更新的辦法。然而作為一個重度uLua用戶,我們踩過了很多坑才將uLua上升到一個可以在項目中大規模使用的狀態。事實上即使到現在,Lua+Unity的方案仍不能肆意地被使用。要用好,你需要知道很多。在看了UWA之前發布的《Unity項目常見Lua解決方案性能比較》一文後,筆者決定動手寫一下關於Lua+Unity方案的性能優化技巧。

感謝來自深圳遊戲科學的招文勇供稿(QQ: 1490582806,博客:UDD_William - 博客園)。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ群465082844)

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

◆◆

從最早的Lua純反射調用C#,以及雲風團隊嘗試的純C#實現的Lua虛擬機,一直發展到現在的各種Luajit+C#靜態Lua導出方案,Lua+Unity才算達到了性能上實用的級別。但即使這樣,實際使用中我們會發現,比起Cocos2dx時代Luajit的發揚光大,現在Lua+Unity的性能依然存在著相當大的瓶頸。僅從《Unity項目常見Lua解決方案性能比較》的test1就可以看到,iPhone 4S下二十萬次Position賦值就已經需要3000ms,如果是COC這樣類型的遊戲,不處理其他邏輯,一幀僅僅上千次位置賦值(比如數百的單位、特效和血條)就需要15ms,這顯然有些偏高。是什麼導致Lua+Unity的性能並未達到極致,要如何才能更好地使用?我們將結合一些例子逐步挖掘其背後的細節。

由於我們項目主要使用的是uLua(集成了topameng的CsToLua,但是由於持續的性能改進,後面已經做過大量的修改),本文的大部分結論都是基於uLua+CsToLua的測試得出來的,sLua都是基於其源碼來分析(根據我們分析的情況來看,兩者原理上基本一致,僅在實現細節上有一些區別),但沒有做過深入測試,如有問題的話歡迎交流。

既然是Lua+Unity,那性能好不好,基本上要看兩大點:

1、Lua跟C#交互時的性能如何

2、純Lua代碼本身的性能如何

因為這兩部分都有各自需要深入探討的細節,所以我們會分為多篇去探討整個Lua+Unity到底如何進行優化。

◆◆

Lua與C#交互篇

一、 從致命的gameobj.transform.position = pos說起

像gameobj.transform.position = pos這樣的寫法,在Unity中是再常見不過的事情。但是在uLua中,大量使用這種寫法是非常糟糕的。為什麼呢?

因為短短一行代碼,卻發生了非常非常多的事情,為了更直觀一點,我們把這行代碼調用過的關鍵Lua API以及uLua相關的關鍵步驟列出來(以uLua+CsToLua導出為準,gameobj是GameObject類型,pos是Vector3):

就這麼一行代碼,竟然做了這麼一大堆的事情!如果是C++,a.b.c = x這樣經過優化後無非就是拿地址然後內存賦值的事。但是在這裡,頻繁的取值、入棧、C#到Lua的類型轉換,每一步都是滿滿的CPU時間,還不考慮中間產生了各種內存分配和後面的GC!

下面我們會逐步說明,其中有一些東西其實是不必要的,可以省略的。我們可以最終把他優化成:lua_isnumber + lua_tonumber 4次,全部完成。

二、在Lua中引用C#的Object,代價昂貴

從上面的例子可以看到,僅僅想從gameobj拿到一個transform,就已經有很昂貴的代價。C#的Object,不能作為指針直接供c操作(其實可以通過GCHandle進行pinning來做到,不過性能如何未測試,而且被pinning的對象無法用GC管理),因此主流的Lua+Unity都是用一個ID表示C#的對象,在C#中通過dictionary來對應ID和object。同時因為有了這個dictionary的引用,也保證了C#的object在Lua有引用的情況下不會被垃圾回收掉。

因此,每次參數中帶有object,要從Lua中的ID表示轉換回C#的object,就要做一次dictionary查找;每次調用一個object的成員方法,也要先找到這個object,也就要做dictionary查找。

如果之前這個對象在Lua中有用過而且沒被GC,那還就是查下dictionary的事情。但如果發現是一個新的在Lua中沒用過的對象,那就是上面例子中那一大串的準備工作了。

如果你返回的對象只是臨時在Lua中用一下,情況更糟糕!剛分配的userdata和dictionary索引可能會因為Lua的引用被GC而刪除掉,然後下次你用到這個對象又得再次做各種準備工作,導致反覆的分配和GC,性能很差。

例子中的gameobj.transform就是一個巨大的陷阱,因為.transform只是臨時返回一下,但是你後面根本沒引用,又會很快被Lua釋放掉,導致你後面每次.transform一次,都可能意味著一次分配和GC。

三、在Lua和C#間傳遞Unity獨有的值類型(Vector3/Quaternion等)更加昂貴

既然前面說了Lua調用C#對象緩慢,如果每次vector3.x都要經過C#,那性能基本上就處於崩潰了,所以主流的方案都將Vector3等類型實現為純Lua代碼,Vector3就是一個{x,y,z}的table,這樣在Lua中使用就快了。

但是這樣做之後,C#和Lua中對Vector3的表示就完全是兩個東西了,所以傳參就涉及到Lua類型和C#類型的轉換,例如C#將Vector3傳給Lua,整個流程如下:

1. C#中拿到Vector3的x、y、z三個值;

2. Push這3個float給Lua棧;

3. 然後構造一個表,將表的x,y,z賦值;

4. 將這個表push到返回值里。

一個簡單的傳參就要完成3次push參數、表內存分配、3次表插入,性能可想而知。那麼如何優化呢?

我們的測試表明,直接在函數中傳遞三個float,要比傳遞Vector3要更快。例如void SetPos(GameObject obj, Vector3 pos)改為void SetPos(GameObject obj, float x, float y, float z)。具體效果可以看後面的測試數據,提升十分明顯。

四、Lua和C#之間傳參、返回時,儘可能不要傳遞以下類型:

嚴重類: Vector3/Quaternion等Unity值類型,數組

次嚴重類:bool string 各種object

建議傳遞:int float double

雖然是Lua和C#的傳參,但是從傳參這個角度講,Lua和C#中間其實還夾著一層C(畢竟Lua本身也是C實現的),Lua、C、C#由於在很多數據類型的表示以及內存分配策略都不同,因此這些數據在三者間傳遞,往往需要進行轉換(術語parameter mashalling),這個轉換消耗根據不同的類型會有很大的不同。

先說次嚴重類中的 bool和 string類型,涉及到C和C#的交互性能消耗,根據微軟官方文檔,在數據類型的處理上,C#定義了Blittable Types和Non-Blittable Types,其中bool和string屬於Non-Blittable Types,意思是他們在C和C#中的內存表示不一樣,意味著從C傳遞到C#時需要進行類型轉換,降低性能,而string還要考慮內存分配(將string的內存複製到託管堆,以及utf8和utf16互轉)。大家可以參考Chapter 7 - Improving Interop Performance,這裡有更詳細的關於C和C#交互的性能優化指引。

而嚴重類,基本上是uLua等方案在嘗試Lua對象與C#對象對應時的瓶頸所致。

Vector3等值類型的消耗,前面已經有所提及。

而數組則更甚,因為Lua中的數組只能以table表示,這和C#下完全是兩碼事,沒有直接的對應關係,因此從C#的數組轉換為Lua table只能逐個複製,如果涉及object/string等,更是要逐個轉換。

五、頻繁調用的函數,參數的數量要控制

無論是Lua的pushint/checkint,還是C到C#的參數傳遞,參數轉換都是最主要的消耗,而且是逐個參數進行的,因此,Lua調用C#的性能,除了跟參數類型相關外,也跟參數個數有很大關係。一般而言,頻繁調用的函數不要超過4個參數,而動輒十幾個參數的函數如果頻繁調用,你會看到很明顯的性能下降,手機上可能一幀調用數百次就可以看到10ms級別的時間。

六、優先使用static函數導出,減少使用成員方法導出

前面提到,一個object要訪問成員方法或者成員變數,都需要查找Lua userdata和C#對象的引用,或者查找metatable,耗時甚多。直接導出static函數,可以減少這樣的消耗。

像obj.transform.position = pos。我們建議的方法是,寫成靜態導出函數,類似

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),這樣的性能會好非常多,因為省掉了transform的頻繁返回,而且還避免了transform經常臨時返回引起Lua的GC。

七、注意Lua拿著C#對象的引用時會造成C#對象無法釋放,這是內存泄漏常見的起因

前面說到,C# object返回給Lua,是通過dictionary將Lua的userdata和C# object關聯起來,只要Lua中的userdata沒回收,C# object也就會被這個dictionary拿著引用,導致無法回收。最常見的就是gameobject和component,如果Lua裡頭引用了他們,即使你進行了Destroy,也會發現他們還殘留在mono堆里。不過,因為這個dictionary是Lua跟C#的唯一關聯,所以要發現這個問題也並不難,遍歷一下這個dictionary就很容易發現。uLua下這個dictionary在ObjectTranslator類、SLua則在ObjectCache類。

八、考慮在Lua中只使用自己管理的ID,而不直接引用C#的Object

想避免Lua引用C# Object帶來的各種性能問題的其中一個方法就是自己分配ID去索引Object,同時相關C#導出函數不再傳遞Object做參數,而是傳遞int。這帶來幾個好處:

1. 函數調用的性能更好;

2. 明確地管理這些Object的生命周期,避免讓ULua自動管理這些對象的引用,如果在Lua中錯誤地引用了這些對象會導致對象無法釋放,從而內存泄露;

3. C#Object返回到Lua中,如果Lua沒有引用,又會很容易馬上GC,並且刪除ObjectTranslator對Object的引用。自行管理這個引用關係,就不會頻繁發生這樣的GC行為和分配行為。

例如,上面的LuaUtil.SetPos(GameObject obj, float x, float y, float z)可以進一步優化為LuaUtil.SetPos(int objID, float x, float y, float z)。然後我們在自己的代碼裡頭記錄objID跟GameObject的對應關係,如果可以,用數組來記錄而不是dictionary,則會有更快的查找效率。如此下來可以進一步省掉Lua調用C#的時間,並且對象的管理也會更高效。

九、合理利用out關鍵字返回複雜的返回值

在C#向Lua返回各種類型的東西跟傳參類似,也是有各種消耗的。比如 Vector3 GetPos(GameObject obj) 可以寫成 void GetPos(GameObject obj, out float x, out float y, out float z)。表面上參數個數增多了,但是根據生成出來的導出代碼(我們以uLua為準),會從:LuaDLL.tolua_getfloat3(內含get_field + tonumber 3次) 變成 isnumber + tonumber 3次。get_field本質上是表查找,肯定比isnumber訪問棧更慢,因此這樣做會有更好的性能。

◆◆◆

實測

好了,說了這麼多,不拿點數據來看還是太晦澀,為了更真實地看到純語言本身的消耗,我們直接沒有使用例子中的gameobj.transform.position,因為這裡頭有一部分時間是浪費在Unity內部的。

我們重寫了一個簡化版的GameObject2和Transform2。

class Transform2{ public Vector3 position = new Vector3();}class GameObject2{ public Transform2 transform = new Transform2();}

然後我們用幾個不同的調用方式來設置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)

分別進行100萬次,結果如下(測試環境是Windows版本,CPU是i7-4770,luajit的jit模式關閉,手機上會因為luajit架構、IL2CPP等因素干擾有所不同,但這點我們會再進一步闡述):

方式1:903ms

方式2:539ms

方式3:343ms

方式4:559ms

方式5:470ms

方式6:304ms

可以看到,每一步優化,都是提升明顯的,尤其是移除.transform獲取以及Vector3轉換提升更是巨大,我們僅僅只是改變了對外導出的方式,並不需要付出很高成本,就已經可以節省66%的時間。

實際上能不能再進一步呢?還能!在方式6的基礎上,我們可以再做到只有200ms!這裡賣個關子,我們將在luajit集成中進行進一步講解。一般來說,我們推薦做到方式6的水平已經足夠。

這只是一個最簡單的案例,有很多各種各樣的常用導出(例如GetComponentsInChildren這種性能大坑,或者一個函數傳遞十幾個參數的情況)都需要大家根據自己使用的情況來進行優化,有了我們提供的Lua集成方案背後的性能原理分析,應該就很容易去考慮怎麼做了。

附測試用例的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; } }

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

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

推薦閱讀:

Unity什麼時候應該手動進行視域Culling?
Billboards 技術在Unity 中的幾種使用方法
300行代碼實現Minecraft(我的世界)大地圖生成
【Unity】工具類系列教程—— 代碼自動化生成!
幻影坦克架構指南(二)

TAG:Unity游戏引擎 | 手机游戏开发 | 性能优化 |