Unity/C#.Net 常用的 Memory Allocation 優化整理
2022-04-14 07:24:00
7k words / 30-minute read
目錄 Table of contents
  1. 前言
  2. Zero Allocation
  3. Why?
  4. When is heap memory allocated?
  5. Cases
    1. 1. 能用struct就不要用class
      1. Solution
    2. 2. Non-constant string
      1. Solution
    3. 3. yield
      1. Solution
    4. 4. StartCoroutine()
      1. Solution
    5. 5. YieldInstruction系列的class
      1. WaitForSeconds
      2. WaitForSecondsRealtime
      3. WaitForEndOfFrame & WaitForFixedUpdate
      4. WaitUntil & WaitWhile
      5. Profiling
    6. 6. Delegate (Lambda expression / delegate operator / anonymous function)
      1. Solution
    7. 7. Captured Variable 陷阱
      1. Solution
    8. 8. Local array/list/stack/queue/hashset/dictionary
    9. 9. Boxing
      1. Solution
    10. 10. params Array as method paramters
      1. Solution
    11. 11. Enum.HasFlag()
      1. 所以Enum.HasFlag()可以放心使用了?
    12. 12. UnityAPI: Any reference type as return value.
  6. Repository
  7. 後話

前言

正式 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

yieldC#的一個語法糖,要使用此關鍵字你必須寫在一個IEnumeratorIEnumerable的方法內,譬如以下例子。

想要了解yieldIEnumerator的原理的建議去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。

Stackoverflow相關討論

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是有兩個因素:

  1. 上面提到的yield語法糖所做的new classobject()開銷
  2. 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 結構stackalloc 運算式 (c # 參考)

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這種情境相關的問題就沒辦法,不過EqualsGetHashCode這兩個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