問題描述
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 那樣)。
python-deco
經過一番研究,我成功地得出以下這條結論:
現有的一些針對 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))),這樣就可以只在我們需要的時候,為特定的類和函數產生了。