(Unity) 為被 Lua 隔斷的 C# 實現添加 Profiler 支持

  • 問題描述
  • 「正常的」做法
  • AOP
  • 利用 slua 代碼生成的簡明做法
  • 粒度控制

問題描述

Unity 項目在實踐中往往選擇使用 Lua 作為更上層的邏輯腳本。這一方面是由於 Unity 本身對熱更不是很友好,用 Lua 熱更靈活得多,另一方面也是簡化與伺服器共享代碼和數據。目前多種不同的 Unity + Lua 集成方案中,實踐中採用比較多的是 @龐巍偉 同學的 slua 方案。

使用 Lua 的團隊,往往傾向於「較重的」集成,也就是暴露相當大規模的引擎介面給 Lua,這樣邏輯上才能有足夠的自由度對遊戲和引擎做全面的控制。當 C#/Lua 之間的互操作介面迅速增長到成千上萬的數量時,一個重要的問題就會浮現出來:C# 和 Lua 的交互層對引擎是封閉的,很多引擎內建的工具,沒有辦法跨越宿主 (C#) 和腳本 (Lua) 的邊界。這些受到影響的機制里,最重要的就是 Unity Profiler。

Unity Profiler 是 Unity 提供的一個有力的性能分析工具 ,能夠在優化階段有效地幫助定位瓶頸,有時也容易藉機發現一些潛藏的 bug。而當我們定位到某個 Lua 函數有較大的開銷(CPU 或內存 GC Alloc)時,由於跨語言邊界的影響被阻攔,就難以進一步觀察更多的細節。

「正常的」做法

由於 Profiler 的 API 介面也一併暴露給了腳本,正常的做法是:根據 C# 這邊的有問題的調用,翻到對應的 Lua 代碼,把相關的腳本邏輯讀一遍,為那些潛在的開銷大的邏輯添加對應的性能剖析採樣 Profiler.BeginSample()/EndSample(),來定位問題代碼段落,然後再翻回對應 C# 函數,再在裡面加上測試代碼印證我們的想法。

實際上我們知道,大多數情況下(如果不算 bug),與引擎部分相比,邏輯腳本的 CPU 開銷是相對比較低的(邏輯代碼里以 if 判斷居多,遇到需要循環的情況都非常少,一般用不到啥非常複雜的運算——或者說在設計得當的情況下,複雜的運算都會交給底層去整塊整塊地做),而容易造成困擾的託管內存分配導致 GC 卡頓的內存問題,也是完全由腳本調回來的 C# 代碼造成的。

這樣分析下來,往往繞一圈又回到 C# 里,中間付出了大量無謂的在腳本里兜圈子的時間不說,被逼著讀和分析重複性高的腳本邏輯代碼,也大大增加了精力和腦力的負擔。

AOP

既然知道了反正總是要從 Lua 回到 C# 的,那麼有沒有什麼簡單的辦法,一勞永逸地為所有暴露給 Lua 的 C# 介面加上性能剖析採樣呢?

如果能做到這一點,我們就可以無視中間腳本層 (Lua) 的干擾,在 C# 環境內解決所有問題。

俺的目光很自然地投向了 AOP (Aspect Oriented Programming),這種技術能幫我們在不用修改目標函數代碼的情況下,加入我們想執行的代碼(就像 Python 的 decorator 那樣)。

經過一番研究,我成功地得出以下這條結論:

現有的一些針對 C# 的 AOP 方法,在 Unity 的 mono 下,基本都跪了~~

還能不能讓俺過一個快樂的兒童節了~~

在這些嘗試里,最接近成功的是:使用 lambda expression 包一層,添加相關代碼後,再註冊給 slua,然而,slua 需要為註冊進來的函數添加下面的 attribute:

[MonoPInvokeCallbackAttribute (typeof (xxx))]

而 C# 不支持為 lambda expression 添加 attribute,所以 &_& ……

利用 slua 代碼生成的簡明做法

發現難以通過 C# 本身的語言機制解決問題之後,我把目光投向了 slua:既然所有的綁定代碼是 slua 生成的,那麼不如直接修改生成代碼,把採樣代碼生成到介面的綁定函數里~

找到普通函數介面的生成位置

void WriteFunctionImpl( StreamWriter file, MethodInfo m, Type t, BindingFlags bf){ ...}

在一頭一尾添加了對應的生成代碼(BeginSample() 的參數可以直接用 MethodInfo.Name 得到正確的函數名 ),運行 slua 的 Make 生成一下,得到下面的結果(單個函數):

[MonoPInvokeCallbackAttribute(typeof(xxx))]static public int xxx(IntPtr l) { try { Profiler. BeginSample( "xxx"); ... return 1; } catch( Exception e) { return error( l, e); } finally { Profiler. EndSample(); }}

EndSample() 在 finally 內,保證每個出口都能正確配對。

粒度控制

接下來更進一步,我們希望有某種粒度的控制能力,只為某個關心的類生成,甚至只為該類內關心的函數生成。回到前面的函數生成所在的方法,可以看到簽名:

void WriteFunctionImpl( StreamWriter file, MethodInfo m, Type t, BindingFlags bf);

其中第二個參數可以用來篩選我們關心的函數(可以跟 m.Name 比較來過濾字元串),而第三個參數Type t 可以用來篩選對應的類(通過 if ( t == typeof(TargetClass))),這樣就可以只在我們需要的時候,為特定的類和函數生成了。

對應改動在這裡。

[完]

Gu Lu

[2016-06-02]


推薦閱讀:

Unite 2016 針對移動設備端的Unity應用優化
從零開始學基於ARKit的Unity3d遊戲開發系列6
比預想的更複雜——動態斷肢實現
Lua性能優化(一):Lua內存優化
Unity/C++混合編程全攻略!——基礎準備

TAG:Unity游戏引擎 | Lua |