目錄 Table of contents
- 前言
- Zero Allocation
- Why?
- When is heap memory allocated?
- Cases
- 1. 能用struct就不要用class
- 2. Non-constant string
- 3. yield
- 4. StartCoroutine()
- 5. YieldInstruction系列的class
- 6. Delegate (Lambda expression / delegate operator / anonymous function)
- 7. Captured Variable 陷阱
- 8. Local array/list/stack/queue/hashset/dictionary
- 9. Boxing
- 10. params Array as method paramters
- 11. Enum.HasFlag()
- 12. UnityAPI: Any reference type as return value.
- Repository
- 後話
前言
正式 full-time 開發目前這個 2D 遊戲專案前後也有快 2 年半左右,前前後後時常在優化性能,這篇文想單純整理一下我優化記憶體來盡量達成 Zero Allocation 的方式。
Zero Allocation
Zero Allocation 嚴格定義是指不做任何 dynamic/heap memory allocation,不過在 Unity/C#.Net 這裡我會比較喜歡解釋成「不做任何重複的heap allocation」,所以使用 object pooling 時第一次必要的 allocation 可以被接受,之後要二次使用時會從 pool 中拿回之前使用過的 memory 以避免第二次 allocate。
Why?
首先 memory allocation 本身算是一個大開銷,再者 C# 語言有 Garbage Collection 這個大坑,GC 讓你不需要也沒辦法主動釋放你申請使用的 heap memory,更會無預警的執行 GC.Collect()
導致那個瞬間的遊戲畫面卡頓。
可能有人會說你可以主動執行
GC.Collect()
來主動釋放記憶體,但執行這個方法的代價很高,它會掃一遍所有的記憶體來尋找 unused,這個搜尋行為本身就貴到不能隨便使用了。
所以優化方向當然是盡可能地減少 memory allocation ,並大幅地重複利用它們。
特別是Unity的那些 Update
, FixedUpdate
, LateUpdate
等幾乎每個frame都會執行的方法絕對要是Zero Allocation,這算是我自己對專案的最基本優化要求。
因為我們的環境是在 unity 底下,所以以下我會直接把 Heap Memory Allocation 稱為 GC.Alloc,這是在 Unity Profiler 中使用的名詞,也方便我們直接觀察。
When is heap memory allocated?
首先我們要知道哪些行為會導致GC.Alloc,其實就是: 實例化一個類別(class)物件。
最直接的方法就是用new
關鍵字:Class a = new Class();
,這樣就會發生allocation。要注意只有class/reference type才會GC.Alloc,而struct/value type就不會有。
所以要了解這件事你要先理解struct和class的實質差別在哪裡,我之前有一篇文有講到這個: More-Effective-C-2nd-讀書筆記。
其他比較間接的方法之所以會產生GC.Alloc都是它們方法內部有偷偷幫你做了這個動作但你不知道而已,反正最後在Unity Profiler都可以很清楚地發現問題。
總之我接下來會整理一些我自己很常見的案例以及改善方法,以下的順序和出現頻率無關,單純只是我撰寫的時候想到什麼就寫什麼。
Cases
1. 能用struct就不要用class
new class是最直接的發生原因,而有些人會濫用class,即使它明明該是個value type的結構也通通寫成class,如下面案例。
public class Transform2DValue {
public Vector2 position;
public float rotation;
public Vector2 scale;
public Transform2DValue(Transform transform) {
this.position = transform.position;
this.rotation = transform.eulerAngles.z;
this.scale = transform.localScale;
}
public void Apply(Transform destination) {
destination.position = this.position;
destination.eulerAngles = new Vector3(0, 0, this.rotation);
destination.localScale = this.scale;
}
}
public void CopyTransformTo(Transform source, Transform[] destinations) {
Transform2DValue transformValue = new Transform2DValue(source); // GC.Alloc!
foreach (var item in destinations) {
transformValue.Apply(item);
}
}
這個案例的Transform2DValue
是用來儲存2D物件的transform資訊的值,之後可以把這個值Apply到傳入參數的目標上來達成複製transform的功能。
Solution
很顯然這個方法會因為new而造成GC.Alloc,但我們其實根本不需要把Transform2DValue
定義成class阿。首先它本來就是拿來做為Value儲存使用,那它應該是Value Type才符合定義,改成struct就解決了。
而且struct在各種使用上通常都會比class有效率,除非你是一個成員欄位龐大或需要長期使用的型別,否則大多時候能用struct就用,特別是案例這種僅在method scope內暫時使用的local variable。
延伸: What Is Faster In C#: A Struct Or A Class?
2. Non-constant string
string是一個特殊基礎型別,它是一個盡量實作成Value Type的Reference Type Class,一般的Assign operator不會有問題,但一些需要copy/new操作的方法就幾乎避免不了GC.Alloc,最常見的是string.Concat
或以operator+
表示stringA + stringB
操作,以及任何object都有的ToString()
方法。
這邊有特別強調是Non-constant string,因為constant string會從一個內部的pool申請出來,兩個constant string的reference是一樣的,這點你可以用
ReferenceEquals()
方法來驗證。
這邊舉幾個例子:
public void Process() {
Profiler.BeginSample("Constant strings"); // These cases are allocation-free.
const string constant = "constant";
StringParameter("constant");
StringParameter(constant);
string constantStringConcat = "string" + "concat"; // Compiler optimize it as "stringconcat" directly.
string constantStringConcat2 = "string" + constant; // Compiler optimize it as "stringconcat" directly.
Profiler.EndSample();
Profiler.BeginSample("Non-constant string concat");
string nonConstant = "nonConstant";
string nonConstantConcat = "string" + nonConstant; // GC.Alloc
Profiler.EndSample();
Profiler.BeginSample("string.Concat");
string stringConcat = string.Concat("string", "concat"); // GC.Alloc
Profiler.EndSample();
Profiler.BeginSample("MethodConcat");
string methodConcat = MethodConcat("string", "concat"); // GC.Alloc
Profiler.EndSample();
Profiler.BeginSample("int.ToString()");
string intString = 1.ToString(); // GC.Alloc
Profiler.EndSample();
}
private void StringParameter(string nonConstant) {
return;
}
private string MethodConcat(string lhs, string rhs) {
return lhs + rhs;
}
打開Profiler驗證無誤:
Solution
string其實還挺無解的,平常就盡量避免使用,某些情形可以改用enum
取代。不過最終還是一定有需要用string的地方,這時候我就只能推這個大神寫的library了: ZString - Zero Allocation StringBuilder for .NET Core and Unity.。
ZString主要是它自己重新定義自己的string型別,然後把原先.net內建的StringBuilder class改成struct結構,最後還自己管理一個inner buffer(allocated from ArrayPool)使得多數資源可以重複回收利用。當然你最後還是會有ToString()
流程來取得最後的string
才能把它當成原本的string
使用,不過它可以大幅減少內部運算複製來複製去的GC.Alloc開銷。總之是一個很強的lib,細節可以去看作者這篇文。
3. yield
yield
是C#的一個語法糖,要使用此關鍵字你必須寫在一個IEnumerator
或IEnumerable
的方法內,譬如以下例子。
想要了解
yield
與IEnumerator
的原理的建議去google一下,這邊不會花太多的篇幅解釋。
public void Process() {
Profiler.BeginSample("SimpleEnumerator");
IEnumerator simpleEnumerator = SimpleEnumerator(); // GC.Alloc
Profiler.EndSample();
}
private IEnumerator SimpleEnumerator() {
yield return null;
}
使用yield
會使compiler把這個方法包裝成一個實作IEnumerator的class,而這個class會在第一次MoveNext()
後取得null的值便迭代結束(return false),我們假設這個內部class命名為SimpleEnumertor_Class
。之後這個SimpleEnumertor()
會被改成類似下面這樣:
private IEnumerator SimpleEnumerator() {
return new SimpleEnumerator_Class();
}
發現了嗎? 他會new一個class出來,這就是導致當我們呼叫SimpleEnumerator()
的時候會有GC.Alloc的原因。
你可能會想那為什麼compiler要把它做成class而不是struct呢?
換個方向想,假設它會自動被做成struct然後return,注意你的回傳型別仍然是
IEnumerator
,這代表它會發生boxing,那始終還是會有GC.Alloc。
Solution
說真的我直到現在都對這個情況沒有一個好的解法,只要你想使用這個語法糖就必然要承受這個負擔。因為這個自動包裝好的IEnumerator
甚至沒有實作Reset()
方法,所以連想要重複利用都沒辦法。總而言之只能注意不要在重複呼叫的程式碼區塊中使用到它吧。
真要解決的話就不能使用yield
語法糖來製作IEnumerator
物件,而是要自己定義一個實作IEnumerator
的struct然後想辦法重現一樣的運行邏輯,但這樣寫起來就完全失去語法糖提供的便利性了。
public struct SimpleEnumerator_Struct : IEnumerator {
// ...implementation
}
不過這裡有個特定情形的優化方法,假設你有一個長這樣的class關係:
public abstract class ClassBase {
public abstract IEnumerator SimpleEnumerator();
}
public class ClassA : ClassBase {
public override IEnumerator SimpleEnumerator() {
//...
}
}
ClassA
必須實作這個SimpleEnumerator()
方法,但你希望它甚麼都不做,這時候你可能會這樣寫:
public override IEnumerator SimpleEnumerator() {
yield return null;
}
或
public override IEnumerator SimpleEnumerator() {
yield break;
}
但這兩種寫法都還是會在呼叫時造成GC.Alloc,所以你其實可以這樣寫:
public override IEnumerator SimpleEnumerator() {
return null;
}
這樣就不會造成任何GC.Alloc了,因為你沒有使用yield
語法糖所以它就只是一個會回傳null的普通方法。
前兩種寫法你拿到的回傳值仍是一個實體的
IEnumerator
物件,第三種回傳值是真的null,回傳後的處理要注意這點。
4. StartCoroutine()
前一個提到IEnumerator
,它主要在Unity中就是給Coroutine使用的。不過我想已經網路上有很多文章提及過StartCoroutine(DoSomething())
會有GC.Alloc所以要注意使用,這邊我要強調一下之所以會造成GC.Alloc是有兩個因素:
- 上面提到的
yield
語法糖所做的new classobject()開銷 StartCoroutine
內部會new一個Coroutine
物件並回傳出來,可參考官方doc
Solution
第一點請參考上面一個案例,第二點無解。所以還是一樣,能少用就少用。
另一個改善方案是改用同是CySharp大神寫的library: UniTask, which provides an efficient allocation free async/await integration for Unity.
不過換成UniTask的寫法與原理完全不同,它是使用C#7.0的新功能Async Task Types in C#來實作的,更換過去需要適應一段時間。我目前看下來UniTask要全面汰換Coroutine應該完全沒問題,甚至能做出來的效果也遠比Coroutine多很多。
作者花了不少功夫在Zero Allocation:
- 透過以struct實作的
UniTask
來取代內建的Task
class - 透過以struct實作的
AsyncMethodBuilder
來取代內建的Builder - Task Pool可以重複回收利用
- Compiler內部自動生成的
AsyncStateMachine
在debug(developement) mode下會是class,在release mode下會優化成struct(所以在unity profiling要注意這點,詳情可見這裡)
其他細節可以看看作者的文
這裡放個比較範例:
// I'll run this method 1000 times.
public void Process() {
Profiler.BeginSample("StartCoroutine");
coroutineProxy.StartCoroutine(Coroutine());
Profiler.EndSample();
Profiler.BeginSample("TaskAsync().Forget()");
TaskAsync().Forget();
Profiler.EndSample();
}
private IEnumerator Coroutine() {
yield return null;
}
private async UniTaskVoid TaskAsync() {
await UniTask.Yield();
}
在unity2020 editor改成release mode後查看profiler:
這裡有提前多跑幾次後才profiling,因為pooling的特性需要多次使用才能顯現
5. YieldInstruction系列的class
這裡指的是WaitForSeconds
, WaitForSecondsRealtime
, WaitForEndOfFrame
, WaitForFixedUpdate
這些會用在Coroutine內部的類別。很多文章大概都講過這部分就是要cache它們重複使用,避免直接在內部new一個出來。我同意這個說法,但其實針對各個不同的類別有不同的改善方法。
WaitForSeconds
直接看案例5.a
// Case 5.a
StartCoroutine(DelayWaitForSeconds());
private IEnumerator DelayWaitForSeconds() {
yield return new WaitForSeconds(1f); // GC.Alloc
// Do something..
}
這個案例的確可以事前存好WaitForSeconds
物件然後重複使用,像這樣:
// Case 5.a
WaitForSeconds wait1sec = new WaitForSeconds(1f); // cached.
StartCoroutine(DelayWaitForSeconds());
private IEnumerator DelayWaitForSeconds() {
yield return wait1sec; // free
// Do something..
}
但這種改善法有個問題,如果秒數本身是傳入參數要怎麼處理,如案例5.b
// Case 5.b
StartCoroutine(DelayWaitForSeconds(1f));
private IEnumerator DelayWaitForSeconds(float seconds) {
yield return new WaitForSeconds(seconds); // GC.Alloc
// Do something..
}
因為WaitForSeconds
並不能在constructor以外的地方修改等待的秒數,上面的方法就很難用了。
所以比起用cache法,我更推薦以下這樣改善:
// Case 5.b
StartCoroutine(DelayWaitForSeconds(1f));
private IEnumerator DelayWaitForSeconds(float seconds) {
for (float time = 0; time < seconds; time += Time.deltaTime) {
yield return null; // free.
}
// Do something..
}
這樣的好處是:
- 更改方便快速,你不用在method scope外增加任何東西。
- 萬一之後需要progress動畫之類的東西就可以直接在while裡面處理
- 在Unity order of execution中,yield null和yield WaitForSeconds的位置在同一區,多數時候不會發生改變執行順序產生的問題。
- 若傳入參數
seconds
是非正數,那它根本不會進去for迴圈也不會多等一個frame,反觀用WaitForSecond
就必然要等至少一個frame。
缺點是有些人可能看不懂這在寫什麼,可讀性較低一點。但我自己覺得只要團隊有介紹過這種寫法就不會有看不懂的問題。
WaitForSecondsRealtime
這個與WaitForSeconds
性質一樣,把Time.deltaTime
換成Time.unscaledDeltaTime
就可以了。
WaitForEndOfFrame & WaitForFixedUpdate
這兩個的性質一樣,就是要等到下一個執行迴圈的指定時間點繼續,看以下案例5.c
// Case 5.c
StartCoroutine(DelayToDoSomething());
private IEnumerator DelayToDoSomething() {
// wait for 3 end of frames
yield return new WaitForEndOfFrame(); // GC.Alloc
yield return new WaitForEndOfFrame(); // GC.Alloc
yield return new WaitForEndOfFrame(); // GC.Alloc
// Do something..
// wait for 3 fixed updates.
yield return new WaitForFixedUpdate(); // GC.Alloc
yield return new WaitForFixedUpdate(); // GC.Alloc
yield return new WaitForFixedUpdate(); // GC.Alloc
// Do something..
}
其實這兩個類別的實體毫無意義,內部實作是類似於
if (obj.GetType() == typeof(WaitForFixedUpdate))
來判定的。所以說我們根本不需要每次使用都new一個實體使用,最方便的方式是開一個static shared variable供所有人使用就好了。
// Case 5.c
StartCoroutine(DelayToDoSomething());
private IEnumerator DelayToDoSomething() {
// wait for 3 end of frames
yield return WaitForInstances.WaitForEndOfFrame; // Free
yield return WaitForInstances.WaitForEndOfFrame; // Free
yield return WaitForInstances.WaitForEndOfFrame; // Free
// Do something..
// wait for 3 fixed updates.
yield return WaitForInstances.WaitForFixedUpdate; // Free
yield return WaitForInstances.WaitForFixedUpdate; // Free
yield return WaitForInstances.WaitForFixedUpdate; // Free
// Do something..
}
public static class WaitForInstances {
public static WaitForFixedUpdate WaitForFixedUpdate { get; } = new WaitForFixedUpdate();
public static WaitForEndOfFrame WaitForEndOfFrame { get; } = new WaitForEndOfFrame();
}
WaitUntil & WaitWhile
這兩個完全沒有理由要去使用,只要你知道yield return null
的用法基本上就不需要這兩個類別了。
而且它們兩個還需要傳入Func<bool>
,一般會用到Lambda expression,反而造成更多GC.Alloc。
Profiling
字尾加Fix的是修正後的結果
6. Delegate (Lambda expression / delegate operator / anonymous function)
名詞就不解釋了,直接看以下程式碼:
public void Process() {
Profiler.BeginSample("6.a delegate implicit operator");
ActionParameter(DoSomething); // 6.a
Profiler.EndSample();
Profiler.BeginSample("6.b delegate explicit operator");
ActionParameter((System.Action)DoSomething); // 6.b
Profiler.EndSample();
Profiler.BeginSample("6.c lambda expression");
ActionParameter( // 6.c
() => {
DoSomething();
});
Profiler.EndSample();
Profiler.BeginSample("6.d delegate operator");
ActionParameter(delegate { DoSomething(); }); // 6.d
Profiler.EndSample();
}
private void ActionParameter(System.Action action) {
action?.Invoke();
}
private void DoSomething() {
// do something.
}
以上 abcd 四種寫法都會有 GC.Alloc ,理由如下:
- a. 他需要把
this.DoSomething()
隱式轉換成System.Action
,也就是生成一個新的System.Action
的實體。 - b. 顯式轉換,結果同 a.。
- c. Lambda expression 是一個直接建立一個匿名函數的語法糖,建立本身是編譯期執行,但是
this.DoSomething()
包含了this
這個實體,所以這會有一個 captured variable 用來捕捉執行當下this
的參考,所以必然每次呼叫都會生成新的System.Action
實體(以確保 captured variable 正確)。 - d. 跟 Lambda expression 差不多意思,換個寫法而已。
所以有 GC 的理由不外乎就兩件事:型別轉換和 Captured Variable 。
有些人可能會有個誤解 ─ 以為直接把一個方法當作 delegate 不會有 GC.Alloc (第一種寫法),因為你可能會覺得方法本身是唯一的、是一個常數,那我拿到這個唯一的方法的 delegate 不就是 allocation free 嗎?
當然不是。這邊需要有一個認知,delegate 也是 object,想一下 delegate 本身是可以重組合併其他 delegate 的(
operator+=
和operator-=
),所以它當然不是唯一的常數。
我們再來試試把 this.DoSomething()
換成 static function 。
public void Process() {
Profiler.BeginSample("6.e delegate implicit operator");
ActionParameter(DoSomethingStatic); // 6.e
Profiler.EndSample();
Profiler.BeginSample("6.f delegate explicit operator");
ActionParameter((System.Action)DoSomethingStatic); // 6.f
Profiler.EndSample();
Profiler.BeginSample("6.g lambda expression");
ActionParameter( // 6.g
() => {
DoSomethingStatic();
});
Profiler.EndSample();
Profiler.BeginSample("6.h delegate operator");
ActionParameter(delegate { DoSomethingStatic(); }); // 6.h
Profiler.EndSample();
}
private void ActionParameter(System.Action action) {
action?.Invoke();
}
private static void DoSomethingStatic() {
// do something.
}
以上方法的結果是 e, f 有 GC ,而 g, h 沒有 GC。
因為 e, f 的情況還是會需要把 static function 型別轉換成 System.Action
物件。
而 g, h 建立匿名函數的過程在編譯期,調用的時候因為省了 capture this
這件事情,所以完全不需要生成新的 System.Action
物件了。
關於 Lambda expression without captured variables 為什麼沒有 GC ,可以直接參考編譯後的程式碼,看看 C# 背後到底幫你做了甚麼事情:
-> 直接用 SharpLab 編譯給你看
Solution
Lambda 匿名函式寫法可能會跟外部的 captured variable 有關聯所以我沒辦法提供通用解法,不過你如果只是像範例 6.a 和 6.b 的寫法,我一般來說會 cache 起來優化:
System.Action m_ActionDoSomething;
private void Update() {
if (m_ActionDoSomething == null) {
m_ActionDoSomething = DoSomething;
}
Profiler.BeginSample("Cached Action (Fix)");
ActionParameter(m_ActionDoSomething);
Profiler.EndSample();
}
當然免不了第一次使用時還是會有GC.Alloc,但至少可以重複利用。
缺點是你要開很多 System.Action
成員在類別內,可能會造成撰寫便利性和可讀性降低。
附上 profiler:
7. Captured Variable 陷阱
使用lambda匿名函式時有時候會用到 captured variable
的 C# 語言特性,簡單來說就是當你的匿名函式內部有使用到外面的變數時,compiler 會自動幫你把指定的變數的 reference 儲存起來放入匿名函數內,且因為他是儲存 reference ,所以有時候會有不直覺的結果,可以參考案例: Captured variable in a loop in C#。
但我這篇不是要講邏輯上的陷阱,而是 GC.Alloc 的陷阱,直接看範例:
private void CapturedVariable() {
int var1 = 100;
ActionParameter(
() => {
int var2 = var1; // <--- captured variable.
DoSomething();
});
}
這單純是一個簡單的 captured variable 使用例子,沒甚麼問題,但是我使用上有時候會有下面這種情況:
CapturedVariablePitfall(false); // GC.Alloc!
private void CapturedVariablePitfall(bool condition) {
int var1 = 100; // GC.Alloc at this line for captured variable below.
if (condition) { // Even if condition==false, there is still GC.Alloc
ActionParameter(
() => {
int var2 = var1; // <--- because of this captured variable.
DoSomething();
});
}
}
上面這種寫法會有GC.Alloc!
上面這種寫法會有GC.Alloc!
上面這種寫法會有GC.Alloc!
很不直覺的一個坑,第一次遇到的時候花了好一陣子才搞懂。
這段code的CapturedVariablePitfall
方法本身是根據傳入參數來決定是否執行ActionParameter(),而我第一行使用會傳入false
,直覺上code根本不會進到if
內部所以也根本不會建立匿名函數,那怎麼會有GC.Alloc?
以結果來說,在方法的第一行就會產生GC.Alloc了,這點你可以用Profiler抓抓看就能驗證。至於為什麼,要看看Compiler到底編譯了甚麼東西出來才清楚,我直接用SharpLab這個網站來看看編譯結果:
C#原始碼編譯前:
因為IL語言比較難看懂,這邊直接看編譯後的C#表示法就行了,編譯後C#:
有對照組很明顯看得出問題點吧,Captured Variable實際上是在那個變數宣告的地方就馬上capture成一個object了,這也就是為什麼會有GC.Alloc。
Solution
理解GC.Alloc的原因後要修正就很簡單了,因為原本那個變數可能有其他人要使用,一般來說沒辦法直接移到if內部,所以多數情況都是在if內部再創一個變數使用即可。
private void CapturedVariablePitfallFix(bool condition) {
int var1 = 100;
if (condition) {
int var1Copy = var1; // GC.Alloc at this line for captured variable below.
ActionParameter(
() => {
int var2 = var1Copy; // <--- captured variable.
DoSomething();
});
}
}
打開Profiler驗證一下:
8. Local array/list/stack/queue/hashset/dictionary
有時候會需要在一個方法內部創建一個暫時的容器使用,如以下程式碼:
private void TemporaryArray() {
int[] numbers = new int[10];
for (int i = 0; i < numbers.Length; i++) {
numbers[i] = i + 1;
}
// do something with numbers array.
}
建立容器避免不了GC.Alloc,不過你可以用Pooling解決這件事。這時候我會推薦導入一些unity沒有使用到的.net內建dll檔,你可以從ZString的專案找到它們,這樣你就可以使用System.Buffers.ArrayPool
這個類別,改成以下這樣:
private void TemporaryArrayFix1() {
int[] numbers = System.Buffers.ArrayPool<int>.Shared.Rent(10); // Rent an array from shared pool.
for (int i = 0; i < numbers.Length; i++) {
numbers[i] = i + 1;
}
System.Buffers.ArrayPool<int>.Shared.Return(numbers); // Return an array to the pool.
}
一般來說這些dll應該可以正常使用(至少有ZString背書),不過如果還是有專案沒辦法導入這些dll的話,你也可以自己寫一個
ArrayPool
。
除了Array以外的容器你也希望pooling的話你就得自己寫個類別來用,以下是一個未經驗證優化的簡單實作:
public class ListPool<T> {
public static ListPool<T> Shared = new ListPool<T>();
private Stack<List<T>> m_Inactived = new Stack<List<T>>();
public List<T> Rent() {
while(m_Inactived.Count > 0) {
List<T> pop = m_Inactived.Pop();
if (pop != null) {
return pop;
}
}
return new List<T>();
}
public void Return(List<T> list) {
if (list == null) { return; }
list.Clear();
m_Inactived.Push(list);
}
}
Expand capacity規則什麼的還有優化空間,不過上面這樣就能解決GC.Alloc問題了,其他容器pool也依樣畫葫蘆。
另外暫用的小型Array我個人會更喜歡下面這種寫法,這也需要仰賴上面提到的dll檔:
private void TemporaryArrayFix2() {
System.Span<int> numbers = stackalloc int[10];
for (int i = 0; i < numbers.Length; i++) {
numbers[i] = i + 1;
}
}
System.Span
是C# 7.2的新類別,可以用來表示一個記憶體區塊。
stackalloc
是C# 8.0的新功能,可以在stack建立一個連續的記憶體區塊,其實就相當於一堆local variables,所以他會在方法return時自動捨棄掉。
這才是名符其實的Zero Allocation寫法,這是我在讀ZString原始碼的時候學到的,非常實用。
缺點是Stack記憶體有限制大小,超出範圍會throw StackOverflowException要注意。還有它不支援不確定實際長度的Managed class,所以基本上幾乎所有class都不能用。
延伸閱讀: Span
9. Boxing
Boxing的定義和原理網路上很多資料查一下就有,這邊只討論發生的時機點和如何避免。
boxing會在一個Value Type物件被轉型成Reference Type時發生,具體來說的boxing是會發生在:
- Value Type物件被轉型成
object
- Value Type物件被轉型成interface
- struct物件被轉型成
System.ValueType
(這是一個內部的abstract class)
範例程式碼:
public void Process() {
KindOfStruct kindOfStructA = new KindOfStruct();
KindOfStruct kindOfStructB = new KindOfStruct();
Profiler.BeginSample("Object boxing: Parameter");
ObjectParameter(kindOfStructA); // boxing: KindOfStruct -> object
Profiler.EndSample();
Profiler.BeginSample("Object boxing: Equals");
kindOfStructA.Equals(kindOfStructB); // double boxing: KindOfStruct -> System.ValueType and KindOfStruct -> object
Profiler.EndSample();
Profiler.BeginSample("Object boxing: GetHashCode");
kindOfStructA.GetHashCode(); // boxing: KindOfStruct -> System.ValueType
Profiler.EndSample();
Profiler.BeginSample("Interface boxing: Parameter");
InterfaceParameter(kindOfStructA); // boxing: KindOfStruct -> IKindOfInterface
Profiler.EndSample();
}
private void ObjectParameter(object obj) {
return;
}
private void InterfaceParameter(IKindOfInterface kindOfInterface) {
kindOfInterface.DoSomething();
}
private interface IKindOfInterface {
void DoSomething();
}
private struct KindOfStruct : IKindOfInterface {
public int value;
public void DoSomething() { }
}
以上是很常見會發生boxing的方式。
Solution
傳入參數轉型成object
/interface這種情境相關的問題就沒辦法,不過Equals
和GetHashCode
這兩個boxing是可以完全解決的,只要你乖乖地在struct內實作這些方法就行了:
KindOfStructFix kindOfStructFixA = new KindOfStructFix();
KindOfStructFix kindOfStructFixB = new KindOfStructFix();
Profiler.BeginSample("Object boxing: Equals (Fix)");
kindOfStructFixA.Equals(kindOfStructFixB); // No boxing as it's implemented method.
Profiler.EndSample();
Profiler.BeginSample("Object boxing: GetHashCode (Fix)");
kindOfStructFixA.GetHashCode(); // No boxing as it's implemented method.
Profiler.EndSample();
private struct KindOfStructFix : IKindOfInterface, System.IEquatable<KindOfStructFix> {
public int value;
public void DoSomething() { }
public bool Equals(KindOfStructFix other) {
return value == other.value;
}
public override int GetHashCode() {
return value.GetHashCode();
}
}
正確地撰寫一個struct應該要實作哪些interface和方法,關於這個問題我之前的一篇文章 More Effective C# 筆記 #1 資料型別 有寫到相關內容。
10. params
Array as method paramters
public void Process() {
Profiler.BeginSample("params int[] as method paramters");
Max(0, 1); // GC.Alloc for int array.
Max(0, 1, 4564); // GC.Alloc for int array.
Max(0, 1, 4564, 12); // GC.Alloc for int array.
Profiler.EndSample();
}
private int Max(params int[] values) { // GC.Alloc for int array.
int max = values[0];
for (int i = 1; i < values.Length; i++) {
if (values[i] > max) {
max = values[i];
}
}
return max;
}
這個蠻好懂的吧,雖然用了語法糖params
但傳入的參數仍然會轉成int[]
,既然是陣列那自然免不了GC.Alloc。
Solution
public void Process() {
Profiler.BeginSample("overload method paramters");
MaxFix(0, 1);
MaxFix(0, 1, 4564);
MaxFix(0, 1, 4564, 12);
Profiler.EndSample();
}
private int MaxFix(int v1, int v2) {
return v1 > v2 ? v1 : v2;
}
private int MaxFix(int v1, int v2, int v3) {
return v1 > v2 ? (v1 > v3 ? v1 : v3) : (v2 > v3 ? v2 : v3);
}
private int MaxFix(int v1, int v2, int v3, int v4) {
return v1 > v2 ?
(v1 > v3 ? (v1 > v4 ? v1 : v4) : (v3 > v4 ? v3 : v4)) :
(v2 > v3 ? (v2 > v4 ? v2 : v4) : (v3 > v4 ? v3 : v4));
}
最直接的方法就是overload,寫起來麻煩但也沒辦法。
其實這種overload方法很容易在別人寫的lib裡面看到,常見的會搭配Template使用。一般來說
params
版本的overload也會留著以防使用者真的不幸用超過了上限數量。
11. Enum.HasFlag()
[System.Flags]
private enum AttackAttribute {
None = 0,
Poison = 1 << 0,
Burning = 1 << 1,
Death = 1 << 2,
}
private void Attack1(AttackAttribute attackAttribute) {
if (attackAttribute.HasFlag(AttackAttribute.Poison)) {
// poison.
}
if (attackAttribute.HasFlag(AttackAttribute.Burning)) {
// burning.
}
if (attackAttribute.HasFlag(AttackAttribute.Death)) {
// death.
}
}
如上的寫法。由於HasFlag
的定義是System.Enum.HasFlag(System.Enum flag)
,理論上這樣呼叫會有兩個boxing ─ 一個是caller的一個是param的。
但是!
但是!
但是!
你如果用Unity Profiler看,會發現它竟然沒有GC.Alloc! (on Unity2019LTS and 2020LTS)
這個疑惑我拿去餵google也沒有找到一個明確的解答,我也用了SharpLab和dnSpy看編譯後的IL也都顯示會有boxing才對。
所以只好不負責任地用猜的了:
就是Compiler有針對這種情況優化,可能類似Array和List的foreach沒有GC.Alloc那樣的優化。
另外有查到Mono 4.0 2015年有針對HasFlag方法優化快了60倍,猜測可能是unity有同步這次更新,文章在這。
所以Enum.HasFlag()
可以放心使用了?
不,這次看看以下的寫法:
private void Attack2(AttackAttribute attackAttribute) {
if (HandleFlag(attackAttribute, AttackAttribute.Poison)) {
// poison.
}
if (HandleFlag(attackAttribute, AttackAttribute.Burning)) {
// burning.
}
if (HandleFlag(attackAttribute, AttackAttribute.Death)) {
// death.
}
}
private bool HandleFlag(AttackAttribute attackAttribute, AttackAttribute flag) {
if (attackAttribute.HasFlag(flag)) {
return true;
}
return false;
}
這樣寫就冒出6次的GC.Alloc了:
也就是變回Compiler沒有優化了的情形。
總之就是,當HasFlag
的輸入參數flag
不是一個常數時,GC.Alloc就會出現了。
所以扯了一圈,我還是建議完全不要使用HasFlag()
,改用位元運算子:
private void Attack2Fix(AttackAttribute attackAttribute) {
if (HandleFlagFix(attackAttribute, AttackAttribute.Poison)) {
// poison.
}
if (HandleFlagFix(attackAttribute, AttackAttribute.Burning)) {
// burning.
}
if (HandleFlagFix(attackAttribute, AttackAttribute.Death)) {
// death.
}
}
private bool HandleFlagFix(AttackAttribute attackAttribute, AttackAttribute flag) {
if ((attackAttribute & flag) != 0) {
return true;
}
return false;
}
執行效率絕對比HasFlag
高且絕對不會有GC.Alloc,唯一的缺點是可讀性比較差一點而已。
如果有一個工程師跟我說他看不懂位元運算子,那我也沒什麼話好跟他說了。
12. UnityAPI: Any reference type as return value.
其實不只unity API,照理來說任何安全的library提供的API在回傳reference type的東西的時候都會額外copy一份出來以免被使用者搞壞,這也導致了GC.Alloc發生。
並且要注意property getter基本上跟function return是一樣的行為,所以通常也都會做copy的動作。
一般來說UnityAPI都有提供Zero Allocation的API來代替,但還是有些功能就是沒提供,下面就直接列出來我自己常用到的一些APIs。
GC.Alloc | Zero Allocation |
---|---|
GameObject.tag == "Player" |
GameObject.CompareTag("Player") |
Physics2D.RaycastAll() and other CastAll methods |
Physics2D.RaycastNonAlloc() and other CastNonAlloc and single Cast methods |
Physics2D.OverlapCircleAll() and other OverlapAll methods |
Physics2D.OverlapCircleNonAlloc() and other OverlapNonAlloc and single Overlap methods |
Collision2D.contacts |
Collision2D.GetContacts() and Collision2D.GetContact(i) |
Renderer.sharedMaterials |
Renderer.GetSharedMaterials () |
Mesh.vertices , Mesh.normals , Mesh.tangents , Mesh.uv , Mesh.colors , Mesh.triangles and Mesh.boneWeights |
Mesh.GetVertices() ,Mesh.GetNormals() , Mesh.GetTangents() , Mesh.GetUVs() , Mesh.GetColors() , Mesh.GetTriangles() and Mesh.GetBoneWeights() |
一時之間想不到還有哪些常見的API,之後有想到會陸陸續續補上來。
Repository
在撰寫這篇文時我也順便開了一個unity project來驗證,本篇文提及的所有原始碼和Profiler結果都包含在裡面。
https://github.com/qwe321qwe321qwe321/Unity-Avoid-GC-Alloc-Cases
測試環境: Unity2020.3.31f1 Win10
後話
這篇文前前後後花了4天以上才寫完,比起初預想的時間還要久很多很多。主要是一些知識在撰寫的途中才發現自己好像還沒有十足的把握,才花了更多的時間去驗證它們。
不過這樣也好,寫文章的目的一直以來都是為了自己:O