要注意有些腳本方法在編輯器執行時會導致記憶體配置,但在輸出專案後並不會產生配置問題。像是GetComponent 是最常見的例子,它會在編輯模式執行記憶體分配,但建置專案後就不會有問題。
一般來說,我們建議所有開發者在玩家操作階段時儘量避免 Managed heap 配置行為。在非操作階段,比如場景載入時來執行配置比較不會有問題。
基本記憶體節約
有些簡單的方法可以減少 Managed Heap 分配。
重複使用集合(Collection)和陣列(Array)
當使用 C# 的集合類別或陣列時,盡可能考慮重複利用或物件池化(Pooling)配置的集合或陣列。Collection 類別有個 Clear 方法能不釋放記憶體但清除集合內的值。
void Update() {
List<float> nearestNeighbors = new
List<float>();
findDistancesToNearestNeighbors(nearestNeighbors);
nearestNeighbors.Sort();
// … use the sorted list somehow …
}
一個簡單的範例,當你需要不斷配置暫用的集合來幫助你運算的時候,利用 Clear 可以解決你的配置問題。
上面的程式中每幀會配置一次 nearestNeighbors 這個 List 來暫存一組資料。可以將這個宣告從 Update 方法提出到外面的類別定義裡,就能避免每幀配置一個新的 List。
List<float>
m_NearestNeighbors = new List<float>();
void Update() {
m_NearestNeighbors.Clear();
findDistancesToNearestNeighbors(NearestNeighbors);
m_NearestNeighbors.Sort();
// … use the sorted list somehow …
}
用這個修改過的版本,List 的記憶體會保留並在不同的幀被拿來重複利用。除非這個 List 需要擴展才會配置新的記憶體。
閉包(Closures)和匿名方法
使用這兩個語言提供的功能要考慮到以下兩點。
首先,C# 裡的所有方法的參考都是參考型別(Reference type)所以都是在堆積上配置。將方法的參考當成參數傳遞就會造成記憶體配置,不管正在傳遞的方法是匿名方法還是預先定義的方法。
其次,將匿名方法轉換成閉包會增加傳遞閉包給接收它作為引數的方法所需要的記憶體大小。
看看這段程式碼:
List<float>
listOfNumbers = createListOfRandomNumbers();
listOfNumbers.Sort(
(x, y) =>
(int)x.CompareTo((int)(y/2))
);
這段程式用一個簡單的匿名方法來排序第一行宣告的清單列表。但是如果有個程式設計師希望重複利用這段程式,他可能會想把常數 2 換成一個區域變數,像是這樣:
List<float>
listOfNumbers = createListOfRandomNumbers();
int
desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort(
(x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);
結果現在這個匿名方法需要存取自己的範疇(Scope)以外的變數所以要變成閉包,desiredDivisor 變數必須以某種方式傳遞給閉包,方便它可以被閉包內的程式取用。
為了達成這目的,C# 會產生一個匿名的類別用來保存閉包所需要的外部變數,當這個閉包傳遞給 Sort方法時,會實體化一個匿名類別的物件,並用 desiredDivisor 整數的值來初始化這個物件。
因為執行閉包需要實體化產生的類別,且所有的類別的實體都是 C# 中的參考型別,所以執行閉包需要在 Managed Heap 上進行配置。
一般來說,C# 中最好避免閉包,儘量少用匿名方法和方法參考(Method references)在和效能相關的程式碼裡,特別是那種每一幀都會執行的程式碼。
IL2CPP 底下的匿名方法
目前,查看由 IL2CPP 產生的程式碼會你發現對 System.Function 類型的變數做簡單的宣告或是賦值都會產生一個新物件。無論是變數是顯性宣告(在方法或類別定義宣告)或隱性的(宣告在另一個方法的參數)都是如此。
因此在 IL2CPP 環境下任何使用匿名方法都會配置 Managed 的記憶體。使用 Mono 則不會發生。
此外,IL2CPP 會因為方法的指向另一個方法的參數宣告方法不同而會有不同的 Managed 記憶體配置量。如預期般地用閉包來呼叫會配置最多的記憶體。
但違反一般人的直覺地,預先定義的方法在 IL2CPP 環境下作為參數傳遞時,配置的記憶體幾乎和閉包一樣多。而匿名方法在堆積上產生的臨時垃圾最少,少了一個以上的數量級。
因此,如果專案打算用 IL2CPP 做為執行環境,有三點建議:
- 建議程式風格避免傳遞方法作為參數。
- 真的無法避免的話,採用匿名方法而非預先定義方法。
- 不管 Mono 或 IL2CPP 都避免使用閉包。
Boxing - 封箱
封箱(Boxing)是 Unity 專案中發現非預期的記憶體配置最常見的問題來源,當一個數值型別被當成參考型別使用時就會發生,通常是將基本(Primitive)的數值型別(int 或 float)傳給接收 object 類型作為參數方法時。
在這個例子裡,x 這個整數被封箱後傳遞給 object.Equals 方法,因為 object 上的 Equals 方法接收的參數型別是object。
int x = 1;
object y = new
object();
y.Equals(x);
C# 的 IDE 介面和編譯器通常不會警告這種封箱行為,即使它造成一些意外的記憶體配置。這是因為 C# 語言設計時假設 GC 處理器和依照配置大小分類的記憶池能有效地處理這種較小的臨時分配。
雖然 Unity 的配置器(Allocator)會針對不同大小的配置使用不同的記憶池,但 Unity 的 GC 回收器如上面所提是“non-generational”類型的,因此對於封箱所產生頻繁的記憶體碎片無法有效的清理。
在 Unity 底下寫 C# 程式應該極力避免造成封箱。
找出封箱問題所在
發生封箱時會以底下其中之一的方法呼叫出現在 CPU 追蹤上,使用 Mono 或是 IL2CPP 會造成變化,其中的<some class> 是類別名稱,…代表一些參數。
- <some class>::Box(…)
- Box(…)
- <some class>_Box(…)
你也可以搜索反編譯器或 IL 檢視器(Intermediate Language Viewer)所輸出的資料,比如ReSharper 裡內建的 IL 檢視工具或是 dotPeek 反編譯器。裝箱的 IL 指令是“box”。
Dictionaries 和 Enums
發生封箱一個常見的原因是使用 enum 類型作為 Dictionary 的 Key,宣告一個 enum 會建立一個新的數值類別其運作方式像是普通的整數,但在編譯時會進行類別檢查(Type-safety)。
在預設的情況下,呼叫 Dictionary.add(key, value) 會呼叫到 Object.getHashCode(Object)。這個方法用來取得 Dictionary key 的雜湊值,它也會在 Dictionary.tryGetValue、Dictionary.remove 等等方法執行中呼叫到。
Object.getHashCode 方法是傳參考類型的,但 enum 值永遠是數值類型。因此使用 enum 作為 key 類型的 Dictionary,每次的方法呼叫會導致 key 被封箱一次以上。
以下程式碼說明這個封箱問題
enum
MyEnum { a, b, c };
var
myDictionary =
new Dictionary<MyEnum,
object>();
myDictionary.Add(MyEnum.a,
new object());
要解決這個問題,需要編寫一個自訂類別來實做 IEqualityComparer 介面,並將該類別的實體設定作為 Dictionary 的比較器(註10)。這種物件通常不需要保存狀態,因此不同的 Dictionary 可以共用 IEqualityComparer 物件以節省記憶體。
以下是簡單的 IEqualityComparer 範例。
public
class MyEnumComparer : IEqualityComparer<MyEnum> {
public bool Equals(MyEnum x, MyEnum y) {
return x == y;
}
public int GetHashCode(MyEnum x) {
return (int)x;
}
}
上面類別的實體可以傳遞給 Dictionary 的建構子(Constructor)。
Foreach 迴圈
在 Unity 使用的舊版 Mono C# 編譯器上使用 foeach 迴圈會迫使 Unity 每次迴圈結束時都封箱一次(每次迴圈完成時,這個值就會被封箱。並非在迴圈每次迭代封箱,所以迴圈不管是跑 2 次還是 200 次封箱記憶體用量都是一樣的。)這是因為 Unity 的 C# 編譯器產生的 IL 會建構一個數值類別的泛型Enumerator 來迭代這個集合。
這個 Enumerator 實做了 IDisposable 介面,當迴圈終止時必定會呼叫 IDisposable 上的 Dispose。但是,透過介面呼叫一個數值類型物件(例如 struct 和 Enumerator)就必須要封箱它們。
一個簡單的範例
int accum = 0;
foreach(int x in myList)
{
accum += x;
}
經過 Unity 的 C# 編譯器會變成以下的 IL 程式:
.method private hidebysig
instance void
ILForeach() cil managed
{
.maxstack 8
.locals init (
[0] int32 num,
[1] int32 current,
[2] valuetype
[mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
)
// [67 5 - 67 16]
IL_0000: ldc.i4.0
IL_0001: stloc.0 // num
// [68 5 - 68 74]
IL_0002: ldarg.0 // this
IL_0003: ldfld class
[mscorlib]System.Collections.Generic.List`1<int32> test::myList
IL_0008: callvirt instance valuetype
[mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class
[mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_000d: stloc.2 // V_2
.try
{
IL_000e: br IL_001f
// [72 9 - 72 41]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype
[mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
IL_001a: stloc.1 // current
// [73 9 - 73 23]
IL_001b: ldloc.0 // num
IL_001c: ldloc.1 // current
IL_001d: add
IL_001e: stloc.0 // num
// [70 7 - 70 36]
IL_001f: ldloca.s V_2
IL_0021: call instance bool valuetype
[mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
IL_0026: brtrue IL_0013
IL_002b: leave IL_003c
} // end of .try
finally
{
IL_0030: ldloc.2 // V_2
IL_0031: box valuetype
[mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
IL_0036: callvirt instance void
[mscorlib]System.IDisposable::Dispose()
IL_003b: endfinally
} // end of finally
IL_003c: ret
} // end of method test::ILForeach
}
// end of class test
關鍵的部分在接近尾端的 __finally { … }__ ,callvirt 指令在呼叫方法之前在記憶體中發現IDisposable.Dispose 方法的位置,並要求 Enumerator 要封箱。
一般來說,Unity 裡應該儘量避免用 foreach 迴圈,不只是會產生封箱的問題,這種透過 Enumerator迭代集合的方式呼叫成本通常比用 for 或 while 迴圈等手動迭代慢得多。
請注意,Unity 5.5 的 C# 編譯器升級明顯提升了 Unity 產生 IL 的能力。在 foreach 迴圈中所產生的封箱問題已經消除。當然就解決了 foreach 迴圈相關的記憶體消耗。但是,相較採用基於 Array 的程式相比,CPU 效能還是因為涉及方法呼叫而差一截。
回傳陣列的 Unity API
有一種比 foreach 還要惡毒的坑是不小心去持續呼叫回傳陣列的 Unity API。所有的 Unity API 在回傳陣列時都會建立一個新的陣列副本。所以非必要建議少用會回傳陣列的 Unity API。
這段範例程式在每個循環迭代中建立了四個 vertices 陣列的副本,每次取用 .vertices 就會創造一個。
for(int
i = 0; i < mesh.vertices.Length;
i++)
{
float x, y, z;
x = mesh.vertices[i].x;
y = mesh.vertices[i].y;
z = mesh.vertices[i].z;
// ...
DoSomething(x, y, z);
}
透過進入迴圈之前先把頂點陣列暫存起來,就能將陣列配置限制到只有一個。
var
vertices = mesh.vertices;
for(int
i = 0; i < vertices.Length; i++)
{
float x, y, z;
x = vertices[i].x;
y = vertices[i].y;
z = vertices[i].z;
// ...
DoSomething(x, y, z);
}
雖然存取屬性一次花的 CPU 成本沒有很高,但在一個迴圈循環內重複的存取還是會影響 CPU 效能,此外,這樣的行為也會造成不必要的 Managed heap 記憶體擴展。
這個問題很常出現在手機設備,因為 Input.touches API 的行為就和上面所說的 .vertices 很像,常常會看到專案有這樣的程式碼,每次 .touch 被存取時就會有配置:
for
( int i = 0; i < Input.touches.Length;
i++ )
{
Touch touch = Input.touches[i];
// …
}
這問題當然是可以把陣列宣告跟存取拉出迴圈來解決。
Touch[]
touches = Input.touches;
for
( int i = 0; i < touches.Length; i++ )
{
Touch touch = touches[i];
// …
}
但是現在許多新版 Unity API 呼叫時不會配置陣列,當有新 API 可用時通常建議直接使用它們:
int
touchCount = Input.touchCount;
for
( int i = 0; i < touchCount; i++ )
{
Touch touch = Input.GetTouch(i);
// …
}
像上面範例裡的轉換應該不會太複雜。
注意,存取 Input.touchCount 屬性仍然保持在迴圈之外,以便節省呼叫屬性的 get 方法造成的 CPU 消耗。
空陣列重複利用
當一個回傳陣列的方法需要回傳一個空集合時,比起回傳 null 值,有些團隊更喜歡傳回空陣列。這種寫作方式在許多語言還蠻常見的,特別是 C# 和 Java。
通常,當從方法回傳一個 0 長度的陣列時,可以回傳一個在 Singleton 上預先配置好的長度為 0 的陣列,比重複建立空陣列好得多。當然,當這個空陣列回傳後被改變大小應該要丟出異常警告。
Unity最佳化 - 目錄
- 分析
- 記憶體
- 協同
- 資源審查
- GC和Managed Heap
- 字串和Text
- Resources目錄和一般最佳化
- 特別最佳化