2017年3月6日 星期一

最佳實踐 - 了解Unity效能 - 字串和Text

作者:Ian 原文

翻譯:Kelvin Lo / 海龜

字串與 Text

字串和 Text 是 Unity 專案裡常見到影響效能的原因之一。在 C# 裡所有的字串都是不可變(Immutable)的。任何對字串的操作都會導致配置一個全新的字串,這其實還蠻貴的。如果重複地串接大字串、或是串接數量很多的字串、或是在執行很多次的迴圈裡串接都會造成效能問題。

此外,由於 N 個字串的串接需要配置 N-1 個中介字串,因此這種字串串接容易造成記憶體壓力。

對於必須要在每幀或是在迴圈內處裡這種串接字串的專案,請使用 StringBuilder 來執行串接字串,實體化的 StringBuilder 可以被重複使用,以最小化記憶體開銷。

微軟有份 C# 字串最佳實踐,可以在這裡找到。

強制語系轉化和序數比較法 

很多字串相關的程式的效率問題來自誤用預設較慢的字串 API。這些 API 是為了商業應用而建立的,它們會嘗試分析處理來自各種不同文化和語言規則的字串。

舉例來說,這段程式碼在 US-English 語言環境下執行時會回傳 true,但對許多歐洲語言環境會回傳 false。

注意,從 Unity 5.3 和 5.4 開始,Unity 腳本執行時都會預設用 US English (en-US) 的環境來執行。
String.Equals("encyclopedia", “encyclopædia”);

對於大多數 Unity 專案,這是完全沒必要的。使用序數比較(Ordinal)會大約快 10 倍,它採用 C 和 C++ 程式設計師比較慣用的比對方式,即逐位元比對而非找出位元構成的字元再判斷兩個字元在目前語系是否等價。

改用序數字串比對方法只要像原本 String.Equals 一樣寫法,後面加上 StringComparison.Ordinal 參數即可。
myString.Equals(otherString, StringComparison.Ordinal);


效能低落的內建字串 API

除了改用序數比對法之外,有些 C# 內建的字串 API 已知效率不好。其中包含 String.Format、String.StartsWith 和 String.EndsWith。String.Format 很難被替換,但其他兩個效率不好的比對方法比較容易最佳化。

雖然微軟的建議是一樣將 StringComparison.Ordinal 用在這些不需要考慮語系的字串比對上,但從 Unity 效能分析結果來看會發現有沒有用序數比對的進步跟跟自己手寫一個比對比起來算是九牛一毛。

MethodTime (ms) for 100k short strings
String.StartsWith, default culture137
String.EndsWith, default culture542
String.StartsWith, ordinal115
String.EndsWith, ordinal34
Custom StartsWith replacement4.5
Custom EndsWith replacement4.5

String.StartsWith 和 String.EndsWith 都可以手動換成像是下方的簡單範例。

public static bool CustomEndsWith(string a, string b) { int ap = a.Length - 1; int bp = b.Length - 1; while (ap >= 0 && bp >= 0 && a [ap] == b [bp]) { ap--; bp--; } return (bp < 0 && a.Length >= b.Length) || (ap < 0 && b.Length >= a.Length); } public static bool CustomStartsWith(string a, string b) { int aLen = a.Length; int bLen = b.Length; int ap = 0; int bp = 0; while (ap < aLen && bp < bLen && a [ap] == b [bp]) { ap++; bp++; } return (bp == bLen && aLen >= bLen) || (ap == aLen && bLen >= aLen); }

正規表示法 

雖然這種表示法是一種強大的比對和操作字串的方法,但效能代價可能也很高。此外,由於 C# 函式庫的正規表示法的關係,即使是很簡單的 isMatch 查詢也會在幕後配置很多暫用的資料結構。除了在初始化期間,這種短暫的記憶體爆發應該被認為不可接受的。

所以如果需要正規表示法,強烈建議不要使用接受正規表示法字串作為參數的靜態 Regex.Match 或 Regex.Replace 方法。這些方法都是當場編譯正規表示法後用過即丟。

底下這個範例程式看起來人畜無害。

Regex.Match(myString, "foo");

但每次執行都會產生 5k 的垃圾。可以簡單修改一下來解決:

var myRegExp = new Regex("foo"); myRegExp.Match(myString);

這個範例每次呼叫 myRegExp.Match“只會”產生 320byte 的垃圾,雖然還是有相當代價,但比起 5k 是好多了。

因此如果正規表示法是不變的字串文字,把它們作為 Regex 物件的建構子的第一個參數來預編譯。這些預編譯的正規表示法應該要重複使用。

XML、JSON 和其他大型文字的解析

分析文字通常是載入時最耗效能的操作之一,在某些情況下,分析文字所花費的時間可能超過載入和實體化資源所花的時間。

背後的原因得看用甚麼解析器(parser),C# 的內建 XML 解析器是很靈活的,也因為如此,它並沒有對特定資料結構最佳化。

許多第三方解析器都是架構在反射(Reflection)上,雖說反射是開發中一個很好的解決方案(因為它能讓解析器快速適應資料架構變化),但是它也是眾所皆知的慢。

Unity 導入了一個內建 JSONUtility API 的解決方案,它為 Unity 序列化系統(Serialization system)提供了一個讀寫 JSON 的介面。從各方面的效能分析來看,它甚至比純 C# JSON 解析器更快,但它和 Unity 的序列化系統其他介面有著相同的限制:在使用者改寫之前無法序列化許多複雜的資料結構,像是 Dictionary。(參考 ISerializationCallbackReceiver 介面的說明來了解如何在 Unity 的序列化系統上處理這些資料結構)

如果你遇到這裡提到的資料解析所產生的效能問題,可以考慮三種替代方案。

方案一:在打包時解析

要避免過長的文字解析時間成本,最好的方法就是在執行時不要有文字解析的操作。一般來說就是透過某些流程將文件資料先“烘焙”成二進制格式。

大多數選擇這條路的開發者會將數據資料移到某種繼承 ScriptableObject 的類別,然後用 AssetBundles 來打包。關於 ScriptableObjects 的詳細討論,可以參考 Richard Fine 在 Unite 2016 的演講。

這個方案能提供最好的效能,但只適合用在不需要動態產生的資料。比如說它很適合用在遊戲設計參數等不會變動的內容。

方案二:分解(Split)和延遲(Lazy)載入

第二種可能性是將需要分析的資料分成小塊,分成小塊之後,解析的效能成本可以分攤在幾個幀上。甚至可以判斷並只解析客戶端需要顯示的特定部分。只載入那些部分。

一個簡單的例子,如果專案是一個闖關遊戲,並不需要把所有的關卡資料序列化一股腦的全載入。如果關卡資料被分割為一關一個獨立的資源檔,甚至每關再分割成不同區域打包,就可以在玩家接近時才解析下一區域。

這聽起來很容易,但實際上要花很多心思在建構工具上,而且還可能需要重新定義你的資料結構。

方案三:執行緒

對於完全解析為純 C# 物件,不需要和 Unity API 有任何互動的資料,可以將解析操作移到工作執行緒(Worker threads)。

這個方案針對擁有多核的平台很有優勢(目前 iOS 裝置最多兩核,Android 大多二到四核,這項技術最適合用在更多資源的電腦和家用主機上),但是它需要很小心編寫程式以免發生 Deadlocks 和 Race conditions 的問題。

選擇要和執行緒奮鬥的勇者可以使用內建的 C# Thread 和 ThreadPool 類別來管理工作執行緒,還有標準 C# 同步(Synchronization)類別。

Unity最佳化 - 目錄


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

沒有留言:

張貼留言

著作人

網誌存檔