2017年3月6日 星期一

最佳實踐 - 了解Unity效能 - 特別最佳化

作者:Ian 原文

翻譯:Kelvin Lo / 海龜

特別最佳化

雖然上一節說明了所有專案都適用的最佳化,但這節會介紹在還沒有足夠的分析資料之前『不』應該做的最佳化。這些最佳化可能實作成本太高、可能會影響程式可讀性或是可維護性、或者解決的問題只會出現在一定規模的專案上。

多維陣列 vs. 陣列的陣列(Jagged array)

如這篇在 StackOverflow 的文章所描述,陣列的陣列比多維陣列更有效率,因為每次查找多維陣列需要一個函式呼叫。

筆記:
  • Jagged array 即 Arrays of arrays ,用 type[x][y] 宣告而非 type[x,y]。 
  • 多維陣列存取時的的函式呼叫可以用 ILSpy 之類的工具觀察。 
我們在 Unity 5.3 對裡在一個三維 100x100x100 的陣列上執行 100 個完全連續迭代(fully sequential iterations)產生了下面的結果,數值是 10 次測試平均:
Array typeTotal time (100 iterations)
One-Dimensional Array660 ms
Jagged Arrays730 ms
Multidimensional Array3470 ms

額外的函式呼叫成本可以比對多維陣列與一維陣列呼叫的差異看出,迭代非連續的記憶體跟連續記憶體的差別可以從比較陣列的陣列跟一維陣列的差別看出。

如上所述,額外的函式呼叫成本大大超過使用非連續記憶結構所花的代價。

需要注重效能的地方建議使用一維陣列,對於必須用到多維陣列來記錄的地方改用陣列的陣列,多維陣列應該避免使用。

粒子系統物件池

當保留粒子系統到一個物件池時,要小心它們每個至少會消耗 3.5kb 的記憶體。記憶體消耗會根據粒子系統上有多少模組被啟動而往上增加。當粒子系統停用時也不會釋放這些記憶體,只有物件被銷毀時記憶體才會被釋放。

從Unity 5.3開始,大多數的粒子系統設定大多可以在執行期操作,對於有大量不同種類粒子特效的專案來說,把粒子系統的設定參數提出到一個只有設定資料的類別或結構會比較好。

也就是說,當需要一個粒子特效時,從物件池裡拿出泛用的粒子特效。然後將設定參數指到物件實現想要的效果。

這比把每一種特效都做成一個物件池節省記憶體,只是會需要額外的功來完成。

更新管理

在內部,Unity 會追蹤帶有 callback 行為的物件清單,像是 Update、FixedUpdate 和 LateUpdate。這些被用以崁入式連結表(intrusively-linked lists)記錄,以確保增減項目時是常數時間完成。MonoBehaviours 分別在啟用或禁用時會從列表中執行加入或移除。

這種崁入式連結表的特點是 data 就是 node,node 就是 data。使用這種表,我們在獲取 data 時,無需double-dereferencing,並且 intrusive linked list 是一種局部結構。

雖然在 MonoBehaviours 加上 callback 很方便,但隨著 callback 的數量增加效能會越來越低。因為 Unity 從原生引擎程式呼叫非原生的腳本需要透過一段叫做 Trampolining 的程式,這過程會有一定的成本,導致每一幀在呼叫大量的方法時會 Lag,以及在實體化包含大量 MonoBehaviours 的 Prefab 時造成實體化時間拉長。(實體化時間變長是因為要去呼叫 Prefab 實體上面的 Awake 跟 OnEnable)

當每幀 callback 數量到上百甚至上千個時,刪除這些 callback 是有幫助的。將 MonoBehaviours(或是標準 C# 物件)加到一個全域管理的 Singleton。這個 Singleton 再代替 Unity 發出 Update、LateUpdate 之類的 callback。這樣做還有額外的好處,暫時不需要接收 callback 的物件可以直接反註冊事件,進而減少每一幀需要呼叫的函式數量。

最好優化的方法通常是刪除很少用的 callback 來達成目的,像這段程式:

void Update() { if(!someVeryRareCondition) { return; } // … some operation … }

如果有大量的 MonoBehaviours 帶有像上面那樣的 Update callbacks,那麼花費執行 Update callbacks 的大量時間會浪費在 trampolining 到 MonoBehaviours 中然後立即結束。如果這些類別只在 someVeryRareCondition 為 true 時才會登記到全域管理器,然後在後面卸除,那麼在 trampolines 和條件判斷都能省掉。

在 Update Manager 中使用 C# Delegates

直覺上會使用純 C# Delegates 來實現這些 callback,但是 C# 的 delegate 流程是針對清單更新不頻繁登記數量也不多的狀況最佳化的。每次增加或刪除 callback 時,C# delegate 都會執行 callback 清單的完整複製。Delegate 裡面東西太多或同一幀內太多增減都會造成內部的 Delegate.Combine 方法花的時間暴增。

對於頻率高的加入刪除行為,請考慮使用專門為了快速插入/移除而設計的資料結構,而不要用 delegates。

載入執行緒控制

Unity 允許開發人員控制用來載入資料在背景執行緒的優先等級。這在嘗試從背景將 AssetBundles 串流到硬碟時很重要。

主執行緒和圖形執行緒的優先等級都是 ThreadPriority.Normal – 任何具有更高優先等級的執行緒都有可能打斷(Preempt)主執行緒和圖形執行緒執行緒造成卡頓,較低優先等級的執行緒則不會。如果執行緒具有主執行緒相同的優先等級,則 CPU 會嘗試給該執行緒提供相同的時間,如果多個背景執行緒正在執行繁重的運算,例如 AssetBundle 解壓縮就很容易導致 FPS 下降。

目前,這個優先等級可以從三個地方來控制。

首先,Asset 載入的呼叫預設的優先等級(例如Resources.LoadAsync 和AssetBundle.LoadAssetAsync)取自於 Application.backgroundLoadingPriority 設定。如文件所說,這個呼叫同樣限制主執行緒用在整合資源(Integrating Assets)(大部分的資源最後必須要整合到主執行緒,整合時會完成資源初始化然後會執行一些執行緒安全的操作。這包含了呼叫腳本 Callback 像是 Awake ,請詳閱“Resource Management”章節)的時間,好減低資源載入時會降低 FPS 的影響。

其次,每個非同步 Asse t載入行為以及每個 UnityWebRequest 請求都會返回一個 AsyncOperation 物件來監視和管理這個行為。這個 AsyncOperation 物件有一個 priority 的屬性,可以用來獨立調整每個操作的優先等級。

最後,WWW 物件,例如從呼叫 WWW.LoadFromCacheOrDownload 回傳的物件,有一個threadPriority 屬性。必須要提的是,WWW 物件不會自動採用 Application.backgroundLoadingPriority 設定當作它們的預設值 – WWW 物件總是預設為ThreadPriority.Normal。

值得一提的是,這幾個 API 用來解壓縮和載入資料的系統都不一樣。Resources.LoadAsync 和 AssetBundle.LoadAssetAsync 由 Unity 內部的 PreloadManager 系統所管控,這個系統管理自己的載入執行緒並控管自己的速限。UnityWebRequest 使用自己的專用執行緒池(Thread pool)。每次建立 WWW 請求時,WWW 都會產生一個全新的執行緒。

雖然所有其他載入機制都有一個內建的排隊系統,但 WWW 沒有。用 WWW.LoadFromCacheOrDownload 載入大量的 AssetBundles 就會產生等量的執行緒,並和主執行緒競爭 CPU 資源,就可能導致 FPS 下降。

因此,使用 WWW 載入和解壓縮 AssetBundles 時,最好對所建立的每個 WWW 物件的 threadPriority 設定一個適當的等級。

大量物件移動和剔除群組 

如同 Transform Manipulation 那節所述,移動有超大層級結構的 Transform 物件會造成很大的 CPU 消耗。但在現實的環境中,通常不可能將物件結構精簡到最少的 GameObjects。

同時,如果可以最好在玩家不發現的前提下,刪除玩家看不到的行為。例如,在有大量角色的場景時,只針對螢幕可見範圍內的角色計算網格蒙皮(Mesh-skinning)和處理角色動作等等。不需要浪費CPU的資源在計算螢幕外看不到的角色行為。

這兩個問題都可以透過 Unity 5.1 導入的 API 來完美解決:CullingGroups。

與其直接操作場景中一大群的 GameObject,而是改變系統操作 CullingGroup 裡的一組 BoundingSpheres 的Vector3 參數。每個 BoundingSphere 作為這些 GameObject 在遊戲世界中的代表,當 CullingGroup 接近或進入CullingGroup 設定的主鏡頭的錐體範圍內時成員才會收到 callback。然後這些 callback 就可以用來執行啟用/停用的程式碼或元件(例如Animators)讓物體執行在可見範圍內該有的行為。

減少方法呼叫

C# 的字串函式庫提供一個函式呼叫造成效能降低的經典案例。在之前的內建字串 API 介紹 String.StartsWith 和 String.EndsWith 的部分裡,有提到手寫一個比內建的方式快 10-100 倍,即使關掉了不必要的語系轉換還是大敗。

這種效能差異的關鍵在於在迴圈內呼叫方法。每次呼叫都必須在記憶體中定位方法的位址,並將另外一塊記憶體放到記憶體堆疊。這些操作都會耗效能,但一般情況下都少到能被忽略掉。

但是,當在會連續執行的迴圈裡多呼叫方法,方法呼叫的成本可能會累積起來,甚至變成主要開銷。

比較下面兩個範例。

範例 1:

int Accum { get; set; } Accum = 0; for(int i = 0; i < myList.Count; i++) { Accum += myList[i]; }

範例 2:

int accum = 0; int len = myList.Count; for(int i = 0; i < len; i++) { accum += myList[i]; }

兩個方法都只是計算一個 C# List<int> 裡的所有整數加總。第一個例子是比較新的 C# 用法,它用自動產生的屬性來保存資料。

雖然表面上這兩段程式看起來一樣,但是當程式被分解曝露方法呼叫後,就會看出差別。

範例 1:

int Accum { get; set; } Accum = 0; for(int i = 0; i < myList.Count; // call to List::getCount i++) { Accum // call to set_Accum += // call to get_Accum myList[i]; // call to List::get_Value }

因此,每次循環執行時有四個方法呼叫:

  • myList.Count 在 Count 屬性上呼叫 get 方法。 
  • 必須呼叫 Accum 屬性上的 get 和 set 方法:
  • 取得 Accum 當下的值,方便它可以傳遞到加法運算。
  • 設定將加法運算的結果寫回 Accum。 
  • [] 運算符號呼叫列表的 get_Value 方法,以取得特定索引上的值。 

範例 2:

int accum = 0; int len = myList.Count; for(int i = 0; i < len; i++) { accum += myList[i]; // call to List::get_Value }

在第二個範例裡,保留了 get_Value 的呼叫,但所有其他的方法已經被消除或執行一次後就不再循環執行。

  • 由於 accum 現在是一個原始值而非屬性,因此無需進行方法呼叫來設定或檢索他的值。 
  • 假定 myList.Count 在循環內不變,它的存取被移到循環的條件之外,因此它不會再每次循環開始時執行。 
比較這兩個方法實際執行的數據可以看到當我們拿掉四分之三的方法呼叫有什麼收穫,用好一點的電腦來執行 100,000 次時:
  • 範例一用掉324毫秒執行 
  • 範例二用掉128毫秒執行 
這裡的主要問題是 Unity 編譯時很少進行行內(Inlining)操作,就算再 IL2CPP 之下,許多方法也還不能正常行內展開。對於屬性也是如此。虛擬函式和介面函式則根本無法行內展開。

因此,在 C# 原始碼裡宣告的方法呼叫很可能在最後的應用程式裡還是產生一個方法呼叫。

簡單屬性

Unity 為了開發人員方便在數據類型提供許多的常數,但是有鑑於上述所說,要注意這些常數實作成傳回常數值的屬性。

Vector3.zero 的屬性內容如下:

get { return new Vector3(0,0,0); }

Quaternion.identiry 非常像似:

get { return new Quaternion(0,0,0,1); }

雖然存取這些屬性的代價通常比它們周邊的程式還小,但是當它們每幀被執行數千次時,差距就會拉小。

對於基本型別,使用 const 修飾,const 變數在編譯時會行內展開,對 const 變數引用將換為它的值。

注意:因為每次引用一次 const 變數就會行內展開一次,所以不要用 const 在超長字串或是巨大的資料結構上。這會造成最後程式中含有大量重複資料,造成最終應用程式在磁碟上的大小不必要的膨脹。

在不適合用 const 的地方,請改為使用靜態 readonly 變數。在某些專案中,即使 Unity 的內建的簡單屬性也開始改成 static readonly 變數,有小幅改善效能[20] [21]

簡單方法


簡單方法比較棘手,能夠宣告一次功能並重複利用是最好。但是在呼叫很多次的迴圈裡裡有可能需要放棄把程式寫得很好看,而改用手動行內展開的程式方法。

一些方法像是 Quaternion.Set、Transform.Translate 或 Vector3.Scale。這些操作執行非常簡單的操作,可以簡單替換。

對於更複雜的方法,需要衡量手動展開的分析進步幅度和維護一個比較快但比較難讀的程式的人力成本。


Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

沒有留言:

張貼留言

著作人

網誌存檔