項目後期Lua接入筆記10--Lua代碼優化1

csdn的手機驗證終於可以跳過了,不知道產品怎麼想的,客服板塊都要炸了。


本文轉自用好Lua+Unity,讓性能飛起來——Lua與C#交互篇,感謝原作者提供的好文章

從最早的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互轉)。大家可以參考https://msdn.microsoft.com/zh-cn/library/ms998551.aspx,這裏有更詳細的關於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集成方案背後的性能原理分析,應該就很容易去考慮怎麼做了。
方式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;
    }

}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章