翻譯:Kelvin Lo / 海龜
了解 Managed Heap
另一個常見的 Unity 開發者會遇到的問題是 Managed Heap 非預期地擴展,在 Unity 裡 Managed Heap 展開比縮回容易得多。此外,Unity 使用的垃圾回收機制會讓記憶體容易破碎化,這會妨礙 Heap 縮減。
技術細節:Managed Heap 怎麼運作的以及為何他會擴大
Managed Heap 是專案程式執行時由執行層(Mono 或 IL2CPP)的記憶體管理器(Memory manager)自動管理的記憶體,在 Managed 程式中建立的物件都必須配置在 Managed Heap 上。嚴格來說,所有非 null 參照型別物件(Reference-typed objects)和所有被封箱的數值型別物件(Boxed value-typed objects),必須配置在Managed Heap 上。上圖白色區域表示分配給 Managed Heap 的記憶體大小,其中有顏色的區塊表示存在 Managed Heap 記憶體裡面的資料。當需要額外加入數值資料時,會從 Managed Heap 裡分配更多空間出來。
GC 是週期性地在執行(確切的週期依平台而定),它會掃描堆積上所有的物件,標記所有不再被任何其他物件引用的物件。然後被標記的物件會被刪除,把記憶體釋放出來。
很重要的是,Unity 的 GC(Garbage Collection)使用 Boehm GC 演算法,是一種 Non-generational和 Non-compacting 的 GC 演算法。”Non-generational”意思是 GC 在執行清理檢查時必須掃描整個堆積,代表隨著堆積的擴展會讓它的效能降低。”Non-compacting”代表清理完畢後,不會重新排序記憶體裡的物件位址來消滅物件之間的空隙。
上圖是記憶體破碎化的範例。當一個物件被釋放時它在記憶體裡的空間也會被釋放,但是釋放出的記憶體空間並不會和其他大塊空白記憶體合在一起。釋放空間的前後兩側都還有使用中的記憶體區塊。因此它就會產生記憶體碎片(紅色圈圈表示),這個空間最後只能存放相等或小於此空間的數據資料。
記得,物件在配置記憶體時必須占用整塊連續的空間。
這會導致記憶體破碎的主要問題:就帳面上來說可能還有很多記憶體空間,但實際上這些空間分散在其他物件之間的小縫隙裡。這種情況下,可能導致 Managed Heap 有足夠的總空間但卻找不到夠大的連續空間給比較大塊的物件。
一旦遇到了上面的狀況,一個較大的物件在無法找到足夠的連續記憶體位置來存放,Unity 會執行兩個動作。
首先,如果沒有跑過 GC 它會觸發 GC 試圖釋放空間來存放這個物件。
如果在 GC 執行完畢後仍然沒有足夠空間,就必須要拓展這個堆積。拓展的大小會因為不同平台而有所不同,一般來說是拓展成現有的 Managed Heap 的兩倍。
堆積的關鍵問題
- Unity 不太會釋放已分配給 Managed Heap 的記憶體分頁,就算大部分是空的也會保留下來。主要是怕收縮後不久又要再拓展。
- 在多數平台上,Unity 最終還是會把 Managed Heap 記憶體分頁的空白部分釋放回 OS,但時間很不固定所以不能太依賴這個行為。
- Managed Heap 使用的虛擬記憶體位址(Address speace)不會還給 OS 。
- 對於 32 位元的程式來說,如果 Managed Heap 這樣拓展又縮回很多次可能會導致位址空間耗盡。如果程式可用的記憶體位置空間耗盡可能會造成程式被作業系統終止。
- 對於64位元的程式來說,位址空間絕對夠大的,除非你的程式要執行一百年否則很難發生位址空間不足。
臨時配置
我們發現許多 Unity 專案每幀會在 Managed Heap 上配置幾十 kb 或幾百 kb 的臨時資料,這通常對效能很不好。以下是簡單的估算:如果程式每幀分配 1kb 的臨時記憶體,且專案以 60FPS 的速度執行。代表每秒會有 60kb 的臨時記憶體產生。一分鐘會有 3.6mb 的垃圾產生。因為效能考量我們不可能每秒呼叫 GC 一次,但在記憶體本來就不多的設備執行每分鐘配置 3.6mb 會有很大的問題。
此外,考慮到載入的動作。假如載入一個資源時產生大量的臨時物件 ,這些物件會一直被引用直到資源載入完畢,這樣會造成 GC 處裡器無法在載入過程中回收這些臨時物件,導致 Managed Heap 擴展,就算這些物件在載入完成後很快就被標成可回收也無法挽回。
要追蹤 Managed 記憶體相較之下比較簡單,可以從 Unity 的 CPU Profiler 找到 GC Alloc 這一欄,它能顯示出在特定幀配置了多少 Managed Heap 記憶體(這跟這幀配置了多少「暫用的」記憶體不同,之後還會使用的記憶體也會被加到這項),開啟 Deepprofiling 可以追蹤到產生這些配置的方法。
要注意有些腳本方法在編輯器執行時會導致記憶體配置,但在輸出專案後並不會產生配置問題。像是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(…)
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最佳化 - 目錄
沒有留言:
張貼留言