記事

Unity GC 完全攻略 — Boehm GC構造からZero-Allocationパターンまで

Unity GC 完全攻略 — Boehm GC構造からZero-Allocationパターンまで
TL;DR — 要点まとめ
  • Unityは.NETの世代別GCではなくBoehm-Demers-Weiser GCを使用しており、非世代的(non-generational)+ 非移動(non-compacting)構造のためヒープ断片化が蓄積し、収集コストがヒープ全体に比例する
  • GC.Allocはboxing、クロージャキャプチャ、string連結、LINQ、params配列、コルーチンなど「見えない場所」で発生し、ProfilerのGC.Allocマーカーで正確に追跡できる
  • stackalloc + Span\<T\>、ArrayPool、NativeArray、struct基盤設計でmanaged割り当てを0に収束させるZero-Allocationパターンが60fps維持の核心である
Visitors

序論

前回のポストの最後でこう予告した:

NativeContainerを使うべき本当の理由 — GCがゲームに与える影響

このシリーズで我々は「managedの世界を脱せよ」というメッセージを繰り返し目にしてきた。Job SystemはNativeContainerのみ許可し、Burstはmanagedタイプをコンパイルせず、SoAレイアウトはunmanagedメモリでのみ意味がある。

なぜ? その答えの半分はキャッシュ効率にあり、残り半分はGC(Garbage Collector)にある。

GCはC#プログラマに便利さを提供するが、ゲーム開発では60fps(16.6ms予算)の敵だ。1フレームに数msのGCスパイクが発生するとプレイヤーは即座にフレームドロップを体感する。

このポストでは:

  1. UnityのGCが内部的にどう動作するか(Boehm GCの構造)
  2. GC.Allocがどこで発生するか(パターン別総整理)
  3. どう避けるか(Zero-Allocationコーディングパターン)

を扱う。

Job Systemポストでmanaged heap vs unmanaged heapのメモリモデルの違いを扱った。C#配列がGC管轄である理由、NativeArrayがGC-freeである理由の基礎は該当セクションを参照せよ。


Part 1: UnityのGCは何が違うのか

Managed Unmanaged C#コード(Managed領域) class · string · 配列 · LINQ · コルーチン Managed Heap — Boehm GC Mark-Sweep · 非世代的 · 非移動 · 保守的マーキング Unmanaged Heap — Nativeメモリ NativeArray · Burst · Job System · malloc OS / Hardware 物理メモリ · 仮想メモリ · キャッシュ階層

1.1 .NET GC vs Unity GC

多くの開発者が「.NETの世代別GC」を基準にUnityのGCを理解しようとする。しかしUnityのGCは完全に異なる実装体だ。

 .NET (CoreCLR) GCUnity (Boehm) GC
実装体Microsoft’s GCBoehm-Demers-Weiser GC
世代Gen0/1/2(世代別)非世代的(全ヒープスキャン)
Compactionあり(メモリ移動)なし(非移動)
マーキング方式正確(precise)保守的(conservative)
Incremental.NET 5+で部分サポートUnity 2019+でオプション
ConcurrentバックグラウンドGCなし(メインスレッドブロック)

Unity公式ドキュメント:“Unity uses the Boehm-Demers-Weiser garbage collector. It’s a non-generational, non-compacting garbage collector.”

この違いがゲーム性能に与える影響を一つずつ分析する。

.NET GC (CoreCLR)
  • 世代別収集(Gen0/1/2)
  • Compactionで断片化解消
  • 正確な(Precise)マーキング
  • バックグラウンドGC(Concurrent)
  • Gen0収集 ~0.1ms
VS
Unity Boehm GC
  • 非世代的 — 全ヒープスキャン
  • Non-Compacting — 断片化蓄積
  • 保守的(Conservative)マーキング
  • メインスレッドブロック(Stop-the-World)
  • コスト ∝ 全ヒープサイズ

.NETサーバー開発のGC知識がUnityにそのまま適用されない理由

1.2 Boehm GCアーキテクチャ

Mark-Sweepアルゴリズム

Boehm GCはMark-Sweepアルゴリズムの変形だ。2つのフェーズで動作する:

flowchart LR
    subgraph Mark["Markフェーズ"]
        direction TB
        R["ルート(Root)探索"] --> S1["スタック変数"]
        R --> S2["静的フィールド"]
        R --> S3["CPUレジスタ"]
        S1 --> SCAN["ルートから到達可能な\nすべてのオブジェクトを\n再帰探索"]
        S2 --> SCAN
        S3 --> SCAN
        SCAN --> MARKED["到達可能なオブジェクト → Mark"]
    end

    subgraph Sweep["Sweepフェーズ"]
        direction TB
        ALL["全ヒープスキャン"] --> UNMARKED["Markされていないオブジェクト → Free"]
        ALL --> KEEP["Markされたオブジェクト → 維持"]
    end

    Mark --> Sweep

Markフェーズ:

  1. ルート(Root)を見つける — スタック変数、静的フィールド、CPUレジスタにある参照
  2. ルートから到達可能なすべてのオブジェクトを再帰的に訪問し「生存(Mark)」表示
  3. ルートから到達できないオブジェクトはMarkされない → ガベージ

Sweepフェーズ:

  1. ヒープ全体を巡回しMarkされていないオブジェクトのメモリを解放
  2. Markビットを初期化して次のGCサイクルに備える

「保守的(Conservative)」マーキングの意味

Boehm GCの最も重要な特性は保守的マーキングだ。

1
2
3
4
5
6
7
8
.NET (正確なGC):
  メタデータで「このフィールドが参照か整数か」正確に分かる
  → 参照のみ辿る → 死んだオブジェクトを100%正確に判別

Boehm (保守的GC):
  スタックやレジスタの値がポインタか整数か確実でない
  → 値がヒープ範囲内の有効なアドレスのように見えれば「参照かもしれない」と仮定
  → 実際には死んだオブジェクトなのに生きていると判断しうる(false retention)

False retentionの結果:

  • 実際にはガベージなオブジェクトが収集されない場合がたまに発生
  • メモリ使用量が理論的最小値よりやや高くなりうる
  • しかし実戦でこれが問題になることは稀だ — 本当の問題は収集コスト

1.3 非世代的(Non-Generational)のコスト

.NETの世代別GCは世代仮説(Generational Hypothesis)を活用する:

「ほとんどのオブジェクトは生成直後に死ぬ」

したがってGen0(最近の割り当て)のみ頻繁に検査し、Gen1/Gen2(古いオブジェクト)は稀に検査する。Gen0収集は非常に速い — 対象が少ないからだ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.NET世代別GC:
┌──── Gen0 ────┐ ┌──── Gen1 ────┐ ┌────── Gen2 ──────┐
│ 新しいオブジェクト │ │ 1回生存       │ │ 古いオブジェクト   │
│ 頻繁に収集     │ │ たまに収集    │ │ 稀に収集         │
│ ~0.1ms        │ │ ~1ms         │ │ ~10ms            │
└──────────────┘ └──────────────┘ └──────────────────┘

Unity Boehm GC:
┌──────────────── 全ヒープ(単一世代) ──────────────────┐
│ 新しいオブジェクト + 古いオブジェクト + すべて           │
│                                                       │
│ 毎回全体をスキャン                                     │
│ コスト ∝ ヒープサイズ(生存オブジェクト数)              │
│                                                       │
│ ヒープが大きくなるほどGC時間が線形に増加                │
└───────────────────────────────────────────────────────┘

核心:UnityのGCは生存オブジェクトの総量に比例するコストが毎回発生する。managedヒープに100MBの生存オブジェクトがあれば、1KBのガベージを収集するためにも100MB全体をスキャンする必要がある。

これがUnityで「managed割り当てを最小化せよ」というアドバイスが.NETサーバー開発よりはるかに重要な理由だ。

1.4 非移動(Non-Compacting)のコスト:ヒープ断片化

.NET GCはCompactionを実行する — 生存オブジェクトをメモリの片側に押し込めて空き領域を連続ブロックにする。

Boehm GCはCompactionをしない。オブジェクトが解放されるとその場所に穴が残り、新しい割り当てはこの穴の中から適切なサイズを探して入る。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
時間の経過とともに発生する断片化:

初期状態(きれい):
┌──────────────────────────────────────────┐
│ [A][B][C][D][E][F][G][H]    空き領域     │
└──────────────────────────────────────────┘

一部オブジェクト解放後:
┌──────────────────────────────────────────┐
│ [A][ ][C][ ][ ][F][ ][H]    空き領域     │
└──────────────────────────────────────────┘
      ↑     ↑  ↑     ↑
      穴(断片化)

新規割り当て試行: 大きな配列(穴3つ分のサイズ)が必要
→ 連続空間がない! → ヒープ拡張が必要
→ 総空き領域は十分なのに割り当て失敗

実戦での影響:

  • ゲームが長時間実行されるほど断片化が蓄積
  • 総空きメモリは十分なのに大きな配列割り当てが失敗してヒープが不必要に拡張
  • 拡張されたヒープは縮小しない → メモリ使用量が増加し続ける(UnityはヒープをOSに返却しない)
  • モバイルでメモリ不足によるOSキルのリスク増加

1.5 Incremental GC

Unity 2019.1からIncremental GCオプションが追加された。

1
2
3
4
5
6
7
8
9
10
11
12
13
通常GC(Stop-the-World):
┌── フレーム ──┐
│ Update       │ ████████ GC (5ms) ████████ │ Render │
│              │         ↑ ここで停止        │        │
└──────────────┴─────────────────────────────┴────────┘
総フレーム時間: 16.6ms + 5ms = 21.6ms → フレームドロップ!

Incremental GC:
┌── フレーム1 ──┐ ┌── フレーム2 ──┐ ┌── フレーム3 ──┐
│ Update │ GC 1ms │ │ Update │ GC 1ms │ │ Update │ GC 1ms │
│        │ (部分) │ │        │ (部分) │ │        │ (部分) │
└────────┴────────┘ └────────┴────────┘ └────────┴────────┘
各フレームに1msずつ分散 → フレームドロップなし

有効化方法

1
2
3
4
5
Project Settings → Player → Other Settings
→ "Use incremental GC" にチェック

またはスクリプト:
GarbageCollector.incrementalTimeSliceNanoseconds = 3_000_000; // 3ms予算

Incremental GCの動作原理

Incremental GCはMarkフェーズを複数フレームにわたって少しずつ実行する。

flowchart LR
    subgraph Frame1["フレーム1"]
        M1["Mark一部\n(ルート探索)"]
    end
    subgraph Frame2["フレーム2"]
        M2["Mark継続\n(オブジェクトA~F)"]
    end
    subgraph Frame3["フレーム3"]
        M3["Mark完了\n(オブジェクトG~Z)"]
    end
    subgraph Frame4["フレーム4"]
        SW["Sweep"]
    end
    
    Frame1 --> Frame2 --> Frame3 --> Frame4

しかしライトバリア(Write Barrier)が必要だ。Markが進行中にプログラムが参照を変更すると、すでにスキャンしたオブジェクトに新しい参照が追加される可能性がある。ライトバリアはこのような変更を追跡して再スキャン対象に追加する。

ライトバリアのコスト:

  • すべての参照型フィールド書き込みに~1nsオーバーヘッド追加
  • GCが実行中でなくてもバリアが有効化されている
  • 参照書き込みが多いコードで1~5%の全体性能低下の可能性

Incremental GCの限界

1
2
3
4
5
6
7
8
⚠️ Incremental GCが解決すること:
✅ GCスパイクを分散してフレームドロップを緩和

⚠️ Incremental GCが解決しないこと:
❌ GCの総コスト(同じ量の作業を複数フレームに分けるだけ)
❌ ヒープ断片化(依然としてnon-compacting)
❌ ヒープサイズに比例するスキャンコスト
❌ managed割り当て自体のコスト

Incremental GCは「鎮痛剤」であって「治療」ではない。 根本的な解決策はmanaged割り当て自体を減らすことだ。

1.6 GCコスト公式

Boehm GCのコストを大まかにモデル化すると:

\[T_{GC} \approx \alpha \times N_{alive} + \beta \times N_{dead}\]
  • $T_{GC}$:GC収集1回の所要時間
  • $N_{alive}$:生存managedオブジェクト数(Markコスト)
  • $N_{dead}$:死んだオブジェクト数(Sweepコスト)
  • $\alpha$:オブジェクトあたりMarkコスト(~10-50ns)
  • $\beta$:オブジェクトあたりSweepコスト(~5-20ns)

核心的洞察:MarkコストがGC支配的であるため、GC時間は生存オブジェクトの数に比例する。ガベージ(死んだオブジェクト)が多かろうが少なかろうが、生存オブジェクトが多ければGCは遅い。

これが.NET世代別GCと根本的に異なる点だ。.NET GCはGen0で生き残るオブジェクトのみGen1に昇格するため、「ほとんどすぐ死ぬオブジェクト」のコストが低い。Boehm GCはすべての生存オブジェクトを毎回スキャンする。


Part 2: GC.Alloc発生パターン総整理

Boxing クロージャ String連結 LINQ params配列 コルーチンyield GC Pressure 蓄積 Frame Spike! ⚡ フレームドロップ

GCのコストを減らすにはmanagedヒープ割り当て(GC.Alloc)を減らす必要がある。問題は割り当てが明示的でない場合が多いことだ。

2.1 明示的割り当て:newキーワード

最も明確な割り当て。newで参照型を生成するとmanagedヒープに割り当てられる。

1
2
3
4
5
6
7
8
9
10
// ✅ 割り当て発生 — 参照型 (class)
var enemy = new Enemy();           // GC.Alloc
var list = new List<int>();        // GC.Alloc
var dict = new Dictionary<int, string>();  // GC.Alloc
string name = new string('x', 10); // GC.Alloc

// ❌ 割り当てなし — 値型 (struct)
var pos = new Vector3(1, 2, 3);    // スタックに割り当て、GC無関係
var data = new MyStruct();         // スタックに割り当て
int x = new int();                 // スタックに割り当て (= 0)

ルール:new + class = GC.Alloc、new + struct = スタック割り当て

ただし、structでも配列にするとヒープに割り当てられる:

1
var positions = new Vector3[1000];  // GC.Alloc! 配列自体は参照型

2.2 Boxing:隠れた割り当て #1

Boxingは値型を参照型に変換する過程だ。この時managedヒープに新しいオブジェクトが割り当てられる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ Boxing発生
int hp = 100;
object boxed = hp;              // int → object: ヒープにInt32ボックス生成
IComparable comp = hp;          // int → IComparable: 同様にboxing

// ❌ よく見落とすBoxingパターン
void LogValue(object value) { Debug.Log(value); }
LogValue(42);                   // int → object: boxing!
LogValue(3.14f);                // float → object: boxing!

// ❌ string.FormatのBoxing
string msg = string.Format("HP: {0}, MP: {1}", hp, mp);
// hpとmpがint → objectにboxingされる

// ❌ Dictionary<TKey, TValue>でEnum キーのBoxing
enum EnemyType { Walker, Runner, Boss }
var counts = new Dictionary<EnemyType, int>();
counts[EnemyType.Walker] = 5;
// EqualityComparer<EnemyType>.Defaultが内部的にboxing(Unity 2021以前)

Boxingが危険な理由

Boxingは毎回呼び出しのたびに新しいオブジェクトをヒープに割り当てる。ループ内でboxingが発生すると:

1
2
3
4
5
6
7
// 3,000エージェント × 毎フレーム = フレームあたり3,000回boxing
for (int i = 0; i < agents.Count; i++)
{
    LogValue(agents[i].Health);  // float → object: boxing!
}
// → フレームあたり~72 KB割り当て(24 bytes × 3,000)
// → 数フレームごとにGCトリガー

2.3 クロージャキャプチャ:隠れた割り当て #2

ラムダ/匿名メソッドが外部変数をキャプチャ(capture)すると、コンパイラがキャプチャクラスを生成してヒープに割り当てる。

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ クロージャキャプチャ → 割り当て発生
float threshold = 0.5f;
var filtered = enemies.Where(e => e.Health > threshold);  
// thresholdをキャプチャするクラスがヒープに割り当てられる

// コンパイラが実際に生成するコード:
class DisplayClass_0
{
    public float threshold;
    public bool Lambda(Enemy e) => e.Health > threshold;
}
var closure = new DisplayClass_0 { threshold = threshold };  // GC.Alloc!
var filtered = enemies.Where(closure.Lambda);
1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ キャプチャなしラムダ → 割り当てなし(C# 9+ static ラムダ)
var filtered = enemies.Where(static e => e.Health > 0.5f);
// 定数のみ使用 → キャプチャなし → 割り当てなし

// ✅ キャプチャを避けるパターン
void FilterEnemies(List<Enemy> enemies, float threshold)
{
    for (int i = enemies.Count - 1; i >= 0; i--)
    {
        if (enemies[i].Health <= threshold)
            enemies.RemoveAtSwapBack(i);
    }
}

2.4 String演算:隠れた割り当て #3

C#のstringは不変(immutable)参照型だ。すべてのstring変形演算は新しいstringオブジェクトをヒープに割り当てる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ❌ 毎回新しいstring割り当て
string status = "HP: " + hp + "/" + maxHp;
// "HP: " + hp → boxing + 新しいstring
// + "/" → 新しいstring
// + maxHp → boxing + 新しいstring
// 計5~6回ヒープ割り当て!

// ❌ Update()で毎フレームstring生成
void Update()
{
    fpsText.text = $"FPS: {(1f / Time.deltaTime):F0}";
    // 毎フレーム新しいstring割り当て → GC圧力
}

// ✅ StringBuilder再利用
private StringBuilder _sb = new StringBuilder(64);

void Update()
{
    if (_frameCount++ % 30 == 0)  // 30フレームごとにのみ更新
    {
        _sb.Clear();
        _sb.Append("FPS: ");
        _sb.Append((int)(1f / Time.smoothDeltaTime));
        fpsText.text = _sb.ToString();  // ToString()は依然として割り当て
    }
}

// ✅✅ 最善:TextMeshProのSetText(割り当てなし)
void Update()
{
    if (_frameCount++ % 30 == 0)
    {
        tmpText.SetText("FPS: {0}", (int)(1f / Time.smoothDeltaTime));
        // SetTextは内部charバッファを再利用 → GC.Alloc 0
    }
}

2.5 LINQ:隠れた割り当て #4

LINQのほぼすべての演算がイテレータオブジェクトをヒープに割り当てる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ❌ LINQチェーン = 割り当てチェーン
var targets = enemies
    .Where(e => e.IsAlive)           // WhereEnumerableIterator割り当て + クロージャ
    .OrderBy(e => e.Distance)        // OrderedEnumerable割り当て + クロージャ
    .Take(5)                         // TakeIterator割り当て
    .ToList();                       // List割り当て + 内部配列割り当て
// 最低5~7回ヒープ割り当て

// ✅ forループで代替
void FindClosest5Alive(List<Enemy> enemies, List<Enemy> result)
{
    result.Clear();
    
    // 単純選択ソート(Nが小さければ十分速い)
    for (int pick = 0; pick < 5 && pick < enemies.Count; pick++)
    {
        float minDist = float.MaxValue;
        int minIdx = -1;
        
        for (int i = 0; i < enemies.Count; i++)
        {
            if (!enemies[i].IsAlive) continue;
            if (result.Contains(enemies[i])) continue;
            if (enemies[i].Distance < minDist)
            {
                minDist = enemies[i].Distance;
                minIdx = i;
            }
        }
        
        if (minIdx >= 0) result.Add(enemies[minIdx]);
    }
    // 割り当て0回(resultを事前に作っておいて再利用)
}

2.6 params配列:隠れた割り当て #5

paramsキーワードで可変引数を受け取ると、呼び出しのたびに配列が割り当てられる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// メソッド定義
void SetValues(params int[] values) { /* ... */ }

// ❌ 呼び出しのたびにint[]配列割り当て
SetValues(1, 2, 3);        // new int[] { 1, 2, 3 } 割り当て
SetValues(10, 20);          // new int[] { 10, 20 } 割り当て

// ✅ C# 13 paramsコレクション(Unity 6+ / .NET 8+)
void SetValues(params ReadOnlySpan<int> values) { /* ... */ }
SetValues(1, 2, 3);  // スタック割り当て、GC.Alloc 0

// ✅ オーバーロードで回避
void SetValues(int a) { /* ... */ }
void SetValues(int a, int b) { /* ... */ }
void SetValues(int a, int b, int c) { /* ... */ }
// 最も一般的な引数数に対してオーバーロード → params配列回避

2.7 コルーチン:隠れた割り当て #6

Unityコルーチン(StartCoroutine)は複数の地点で割り当てが発生する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ コルーチンの割り当て地点
IEnumerator SpawnWave()
{
    // 1. StartCoroutine()呼び出し時にCoroutineオブジェクト割り当て
    // 2. IEnumeratorステートマシンオブジェクト割り当て
    
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        yield return new WaitForSeconds(0.5f);  // 3. 毎回WaitForSeconds割り当て!
    }
}

StartCoroutine(SpawnWave());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ WaitForSecondsキャッシュ
private static readonly WaitForSeconds _wait05 = new WaitForSeconds(0.5f);

IEnumerator SpawnWave()
{
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        yield return _wait05;  // キャッシュされたオブジェクト再利用 → 割り当て0
    }
}

// ✅✅ 最善:コルーチンの代わりにasync/await(UniTask)
// UniTaskはstruct基盤なのでGC.Alloc 0
async UniTaskVoid SpawnWave(CancellationToken ct)
{
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        await UniTask.Delay(500, cancellationToken: ct);  // 割り当て0
    }
}

2.8 Unity APIの隠れた割り当て

Unityの一部APIは内部的に配列を割り当てて返す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ❌ 毎回呼び出しのたびに新しい配列割り当て
Collider[] hits = Physics.OverlapSphere(pos, radius);     // 配列割り当て
RaycastHit[] results = Physics.RaycastAll(ray);            // 配列割り当て
GameObject[] objects = GameObject.FindGameObjectsWithTag("Enemy");  // 配列割り当て
Renderer[] renderers = GetComponentsInChildren<Renderer>();         // 配列割り当て

// ✅ NonAllocバージョン使用
private Collider[] _hitBuffer = new Collider[32];         // 事前割り当て

void Update()
{
    int count = Physics.OverlapSphereNonAlloc(pos, radius, _hitBuffer);
    for (int i = 0; i < count; i++)
    {
        ProcessHit(_hitBuffer[i]);
    }
}

// ✅ GetComponentsInChildren — Listバージョン(再利用)
private List<Renderer> _rendererBuffer = new List<Renderer>(16);

void CacheRenderers()
{
    _rendererBuffer.Clear();
    GetComponentsInChildren(_rendererBuffer);  // 既存Listに追加 → 配列再割り当て最小化
}

2.9 GC.Alloc発生パターン総整理

パターン原因コスト(概算)解決策
new class()参照型生成24B+オブジェクトオブジェクトプーリング、struct
Boxing値→参照変換12~24Bジェネリック、具象型使用
クロージャキャプチャラムダの外部変数32B+staticラムダ、forループ
String連結不変string生成可変StringBuilder、TMP SetText
LINQイテレータチェーン32B+ × Nforループ
params配列可変引数12B + N×4Bオーバーロード、Span
コルーチンyieldWaitForX生成20~40Bキャッシュ、UniTask
Unity API配列返却可変NonAllocバージョン
配列生成new T[n]12B + N×sizeof(T)ArrayPool、NativeArray
Dictionary EnumキーEqualityComparer boxing12~24Bカスタムcomparer
foreach on struct IEnumeratorインターフェースディスパッチboxing32B+for + indexer

Part 3: Zero-Allocationコーディングパターン

3.1 stackalloc + Span<T>

stackallocはmanagedヒープではなくスタックにメモリを割り当てる。GCとは完全に無関係だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ スタックに割り当て — GC.Alloc 0
void CalculateDistances(Vector3 myPos, Vector3[] targets, int count)
{
    // スタックにfloat配列割り当て(関数終了時に自動解放)
    Span<float> distances = stackalloc float[count];
    
    for (int i = 0; i < count; i++)
    {
        Vector3 diff = targets[i] - myPos;
        distances[i] = diff.magnitude;
    }
    
    // distancesを使用...
    float minDist = float.MaxValue;
    for (int i = 0; i < count; i++)
        minDist = Mathf.Min(minDist, distances[i]);
}
// 関数が終わればdistancesメモリがスタックから自動解放

Span<T>は連続メモリ領域に対する「ビュー(view)」だ。配列、stackalloc、ネイティブメモリどこでも指すことができる。

1
2
3
4
5
6
7
8
9
// Spanは様々なメモリソースを統合するビュー
Span<int> fromArray = new int[] { 1, 2, 3 };     // 配列のビュー
Span<int> fromStack = stackalloc int[3];           // スタックのビュー
Span<int> slice = fromArray.Slice(1, 2);           // 部分ビュー(コピーなし!)

// Spanを受けるメソッドはメモリソースに無関係に動作
void Process(Span<int> data) { /* ... */ }
Process(fromArray);    // ✅ 配列
Process(fromStack);    // ✅ スタック

stackalloc注意事項

1
2
3
4
5
6
7
8
9
10
11
12
13
// ⚠️ スタックサイズ制限(~1MB)
// 大きな配列をstackallocするとStackOverflowException!
Span<float> bad = stackalloc float[1_000_000];  // 💥 ~4MB → スタックオーバーフロー

// ✅ 安全パターン:小さければスタック、大きければプール
void Process(int count)
{
    Span<float> buffer = count <= 256
        ? stackalloc float[count]              // 小さければスタック(~1KB)
        : new float[count];                     // 大きければヒープ(またはArrayPool)
    
    // bufferを使用...
}

3.2 ArrayPool<T>

ArrayPoolは配列インスタンスをプーリングして再利用する。managedヒープ割り当ては最初の1回のみ発生し、以降はプールから借りて使う。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Buffers;

void ProcessFrame()
{
    // プールから配列を借りる(割り当てなし、プールが空なら最初の1回のみ割り当て)
    float[] buffer = ArrayPool<float>.Shared.Rent(1024);
    // 注意:Rent(1024)が正確に1024サイズを返すとは限らない
    // 2の累乗に切り上げられたサイズ(例:1024)が返される
    
    try
    {
        // buffer[0..1023]を使用
        for (int i = 0; i < 1024; i++)
            buffer[i] = ComputeValue(i);
        
        ApplyResults(buffer, 1024);
    }
    finally
    {
        // 必ず返却!しないとプールが枯渇して新規割り当てが発生
        ArrayPool<float>.Shared.Return(buffer);
    }
}

ArrayPool vs stackalloc vs NativeArray

 stackallocArrayPoolNativeArray
メモリ位置スタックmanagedヒープ(プーリング)unmanagedヒープ
GC影響なし最初の割り当て時のみなし
サイズ制限~1KB推奨数MBOSメモリ限度
Job使用不可不可可能
Burst互換不可不可可能
寿命関数スコープReturnまでDisposeまで
最適用途小さな一時バッファ中サイズ一時配列Job/Burstデータ
メモリ割り当て戦略比較

3.3 オブジェクトプーリング

参照型オブジェクト(class)を毎回new/GCせずにプールから借りて使い返却するパターン。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Unity 2021+で提供されるObjectPool
using UnityEngine.Pool;

public class ProjectilePool : MonoBehaviour
{
    private ObjectPool<Projectile> _pool;
    
    void Awake()
    {
        _pool = new ObjectPool<Projectile>(
            createFunc: () => Instantiate(projectilePrefab).GetComponent<Projectile>(),
            actionOnGet: p => p.gameObject.SetActive(true),
            actionOnRelease: p => p.gameObject.SetActive(false),
            actionOnDestroy: p => Destroy(p.gameObject),
            defaultCapacity: 50,
            maxSize: 200
        );
    }
    
    public Projectile Spawn(Vector3 pos, Vector3 dir)
    {
        var p = _pool.Get();        // プールから取得(割り当てなし)
        p.transform.position = pos;
        p.Initialize(dir);
        return p;
    }
    
    public void Despawn(Projectile p)
    {
        _pool.Release(p);           // プールに返却(GCなし)
    }
}

3.4 struct基盤設計

GC.Allocを根本的に避ける最も確実な方法は値型(struct)で設計することだ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ class — ヒープ割り当て、GC対象
class DamageEvent
{
    public int TargetId;
    public float Amount;
    public DamageType Type;
}

// ✅ struct — スタック割り当てまたは配列内インライン、GC無関係
struct DamageEvent
{
    public int TargetId;
    public float Amount;
    public DamageType Type;
}

structを選ぶべき基準

基準struct適合class適合
サイズ≤ 64 bytes> 64 bytes
寿命短い(1-2フレーム)長い(複数フレーム)
共有コピーしてもOK参照共有が必要
継承不要必要
用途データ伝達、計算中間値複雑な状態、多態性

64バイト基準はコピーコストのためだ。structは値コピーされるので大きすぎるとコピーコストがヒープ割り当てコストより大きくなりうる。一般的にキャッシュラインサイズ(64B)以下なら安全だ。

参照共有または継承が必要か? はい いいえ classを使用 サイズが64バイト以下か? はい いいえ structを使用 Zero Allocation classを使用 コピーコスト > ヒープ割り当て

3.5 ジェネリックでBoxing除去

1
2
3
4
5
6
7
8
9
// ❌ objectを受けると値型がboxingされる
void Log(object value) => Debug.Log(value);
Log(42);        // boxing!
Log(3.14f);     // boxing!

// ✅ ジェネリックは型ごとに特殊化されboxingなし
void Log<T>(T value) => Debug.Log(value.ToString());
Log(42);        // Log<int> — boxingなし
Log(3.14f);     // Log<float> — boxingなし
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ DictionaryでEnumキーのboxing(Unity 2021以前)
var dict = new Dictionary<MyEnum, int>();
// 内部的にEqualityComparer<MyEnum>.Defaultがboxing発生

// ✅ カスタムcomparerでboxing除去
struct MyEnumComparer : IEqualityComparer<MyEnum>
{
    public bool Equals(MyEnum x, MyEnum y) => x == y;
    public int GetHashCode(MyEnum obj) => (int)obj;
}

var dict = new Dictionary<MyEnum, int>(new MyEnumComparer());
// boxingなし

3.6 Zero-Allocation Update()パターン

実際のゲームループで適用する総合パターン:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class AgentManager : MonoBehaviour
{
    // Persistent割り当て — 1回のみ、Awakeで
    private NativeArray<float3> _positions;
    private NativeArray<float3> _velocities;
    private NativeArray<byte> _isAlive;
    
    // キャッシュされた配列 — 1回のみ割り当てて再利用
    private Collider[] _overlapBuffer = new Collider[64];
    private readonly StringBuilder _debugSb = new StringBuilder(128);
    
    // キャッシュされたYieldInstruction
    private static readonly WaitForSeconds _spawnDelay = new WaitForSeconds(1f);
    
    void Awake()
    {
        int maxAgents = 3000;
        _positions = new NativeArray<float3>(maxAgents, Allocator.Persistent);
        _velocities = new NativeArray<float3>(maxAgents, Allocator.Persistent);
        _isAlive = new NativeArray<byte>(maxAgents, Allocator.Persistent);
    }
    
    void Update()
    {
        // ✅ Job + NativeArray → GC.Alloc 0
        var moveJob = new AgentMoveJob
        {
            Positions = _positions,
            Velocities = _velocities,
            IsAlive = _isAlive,
            DeltaTime = Time.deltaTime
        };
        moveJob.Schedule(_positions.Length, 64).Complete();
        
        // ✅ NonAlloc物理クエリ → GC.Alloc 0
        int hitCount = Physics.OverlapSphereNonAlloc(
            transform.position, 10f, _overlapBuffer);
        
        // ✅ 条件付きstring生成(毎フレームではない)
        #if UNITY_EDITOR
        if (Time.frameCount % 60 == 0)
        {
            _debugSb.Clear();
            _debugSb.Append("Agents: ").Append(_aliveCount);
            Debug.Log(_debugSb);
        }
        #endif
    }
    
    void OnDestroy()
    {
        if (_positions.IsCreated) _positions.Dispose();
        if (_velocities.IsCreated) _velocities.Dispose();
        if (_isAlive.IsCreated) _isAlive.Dispose();
    }
    
    [BurstCompile]
    struct AgentMoveJob : IJobParallelFor
    {
        public NativeArray<float3> Positions;
        [ReadOnly] public NativeArray<float3> Velocities;
        [ReadOnly] public NativeArray<byte> IsAlive;
        [ReadOnly] public float DeltaTime;
        
        public void Execute(int index)
        {
            if (IsAlive[index] == 0) return;
            Positions[index] += Velocities[index] * DeltaTime;
        }
    }
}

このパターンのUpdate()内GC.Alloc = 0 bytes。すべての割り当てはAwake()で1回発生し、ランタイムでは既存メモリを再利用する。


Part 4: ProfilerでGCスパイクを追跡

4.1 Unity Profiler:GC.Allocマーカー

Unity ProfilerでGC割り当てを追跡する方法:

1
2
3
4
5
Window → Analysis → Profiler
→ CPU Usageモジュール選択
→ 下部Hierarchyビューで「GC.Alloc」カラム確認
→ GC.Allocが0でないフレームをクリック
→ どのメソッドがどれだけ割り当てたか確認

Deep Profile vs Normal Profile

モード精度オーバーヘッド用途
Normalメソッド単位低い常時プロファイリング
Deep Profileすべての関数呼び出し高い(5~10×)GC.Alloc原因の精密追跡

Normal Profileで「PlayerLoop → Update.ScriptRunBehaviourUpdate → GC.Alloc: 1.2 KB」が見えたら、Deep Profileに切り替えてどの行で割り当てが発生しているか追跡する。

4.2 GC.Allocが0であることを確認する方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// エディタスクリプト:特定コードブロックのGC.Allocを測定
using Unity.Profiling;

void MeasureAllocation()
{
    // 方法1:Profiler.GetTotalAllocatedMemoryLong()
    long before = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
    
    // --- 測定対象コード ---
    MyHotFunction();
    // ----------------------
    
    long after = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
    long allocated = after - before;
    Debug.Log($"GC.Alloc: {allocated} bytes");
}

// 方法2:ProfilerRecorder(Unity 2021+)
using Unity.Profiling;

ProfilerRecorder _gcAllocRecorder;

void OnEnable()
{
    _gcAllocRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc.In.Frame");
}

void Update()
{
    // 現在フレームの総GC.Alloc
    if (_gcAllocRecorder.Valid)
        Debug.Log($"Frame GC.Alloc: {_gcAllocRecorder.LastValue} bytes");
}

void OnDisable()
{
    _gcAllocRecorder.Dispose();
}

4.3 GCスパイク分析実践ワークフロー

flowchart TD
    START["フレームドロップ発見"] --> PROF["Profilerを開く\nCPU Usage確認"]
    PROF --> CHECK{"GC.Collect\nマーカーがあるか?"}
    CHECK -->|Yes| GCSPIKE["GCスパイク!\n→ managed割り当てが原因"]
    CHECK -->|No| OTHER["他のボトルネック\n(レンダリング、物理など)"]
    
    GCSPIKE --> DEEP["Deep Profile有効化"]
    DEEP --> FIND["GC.Allocが高い\nメソッドを探す"]
    FIND --> ANALYZE{"割り当て原因は?"}
    
    ANALYZE -->|Boxing| FIX1["ジェネリックで置き換え"]
    ANALYZE -->|String| FIX2["StringBuilder/TMP SetText"]
    ANALYZE -->|配列生成| FIX3["ArrayPool/NativeArray"]
    ANALYZE -->|LINQ| FIX4["forループで置き換え"]
    ANALYZE -->|Unity API| FIX5["NonAllocバージョン"]
    ANALYZE -->|コルーチン| FIX6["キャッシュ/UniTask"]
    
    FIX1 --> VERIFY["修正後Profiler再確認\nGC.Alloc = 0を検証"]
    FIX2 --> VERIFY
    FIX3 --> VERIFY
    FIX4 --> VERIFY
    FIX5 --> VERIFY
    FIX6 --> VERIFY

4.4 GC関連ランタイムAPI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// GC状態確認
long totalMemory = GC.GetTotalMemory(forceFullCollection: false);
int collectionCount = GC.CollectionCount(generation: 0);  // Unityでは常に0

// GC手動トリガー(ロード画面で)
System.GC.Collect();
// 注意:ゲームプレイ中に呼び出すとスパイク発生
// シーン遷移、ロード画面など「少し止まっても良い時点」でのみ呼び出す

// Incremental GC制御
GarbageCollector.GCMode = GarbageCollector.Mode.Enabled;    // デフォルト
GarbageCollector.GCMode = GarbageCollector.Mode.Disabled;   // 一時無効化

// ⚠️ GC無効化中でも割り当ては可能だが収集されない
// → メモリが増加し続ける → 最終的にOOM
// → 短い時間(ボス戦カットシーンなど)でのみ使用

ロード画面でのGC戦略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IEnumerator LoadScene(string sceneName)
{
    // ロード画面表示
    loadingScreen.SetActive(true);
    
    var op = SceneManager.LoadSceneAsync(sceneName);
    
    while (!op.isDone)
    {
        loadingBar.value = op.progress;
        yield return null;
    }
    
    // 新シーンロード後 → 前のシーンのガベージが大量発生
    // ロード画面が表示されている間にGCを強制実行
    System.GC.Collect();
    
    // 追加:メモリプールウォームアップ
    PrewarmPools();
    
    loadingScreen.SetActive(false);
    // → ゲームプレイ開始時にクリーンな状態
}

Part 5: 実践チェックリスト

5.1 フレームあたりGC.Alloc予算

プラットフォーム目標FPSフレーム予算推奨GC.Alloc/フレーム
PC(ハイエンド)6016.6ms< 1 KB
モバイル(ミッドレンジ)3033.3ms< 0.5 KB
VR9011.1ms0 bytes
競技ゲーム144+6.9ms0 bytes
プラットフォーム別推奨GC.Alloc/フレーム

VRと競技ゲームではUpdate()内のGC.Allocが文字通り0でなければならない。

5.2 ホットパス vs コールドパス

すべてのコードをZero-Allocationにする必要はない。ホットパス(毎フレーム実行されるコード)コールドパス(たまに実行されるコード)を区別せよ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 🔴 ホットパス(毎フレーム) — Zero-Allocation必須
void Update()
{
    MoveAgents();          // NativeArray + Job
    UpdatePhysics();       // NonAllocクエリ
    RenderUI();            // TMP SetText、キャッシュ
}

// 🟡 ぬるいパス(毎秒1~2回) — 注意が必要
void OnEnemyKilled()
{
    UpdateScore();         // 少量の割り当ては許容
    PlayEffect();          // オブジェクトプール使用推奨
}

// 🟢 コールドパス(1回または稀に) — 自由に割り当て
void Start()
{
    LoadConfig();          // Dictionary、Listなど自由に使用
    BuildNavMesh();        // LINQもOK
    InitializePools();     // 初期割り当ては問題なし
}

5.3 コードレビューチェックリスト

Update()、FixedUpdate()、LateUpdate()内部で:

  • newキーワードでclassを生成していないか?
  • string連結(+$""Format)をしていないか?
  • LINQ(WhereSelectOrderByなど)を使用していないか?
  • params引数を受けるメソッドを呼び出していないか?
  • 配列を返すUnity APIの代わりにNonAllocバージョンを使用しているか?
  • 値型がobjectにキャスト(boxing)されていないか?
  • ラムダが外部変数をキャプチャしていないか?
  • コルーチンのyield return new ...をキャッシュしているか?

まとめ

シリーズ要約

ポスト核心質問答え
Job System + Burstマルチスレッドを安全に?Job Schedule/Complete + Safety System
SoA vs AoSメモリをどう配置?データ指向設計 + キャッシュ最適化
Burst深掘りコンパイラが内部で何をするか?LLVMパイプライン + 自動ベクトル化
NativeContainer深掘り何にデータを入れるか?コンテナエコシステム + Allocator + Custom
Unity GC完全攻略(この記事)なぜmanagedを避けるか?Boehm GCコスト + Zero-Allocationパターン

核心要約

  1. UnityのBoehm GCは.NET GCと異なる — 非世代的、非移動、保守的マーキング。生存オブジェクト全体を毎回スキャンするのでヒープが大きいほどコストが大きくなる
  2. Incremental GCは鎮痛剤であって治療ではない — スパイクを分散するだけで、総コストと断片化は解決しない
  3. GC.Allocは「見えない場所」で発生する — boxing、クロージャ、string、LINQ、params、コルーチン、Unity API
  4. ホットパスのGC.Alloc = 0が目標 — stackalloc/Span、ArrayPool、NativeArray、オブジェクトプール、struct設計
  5. ProfilerのGC.AllocマーカーとDeep Profileで正確に追跡し、修正後必ず再検証せよ

このシリーズを通じて我々はUnityで高性能コードを書くための全体像を完成させた:

マルチスレッド(Job) + 高性能コンパイル(Burst) + キャッシュ効率的メモリ(SoA) + 正しいコンテナ(NativeContainer) + GC回避(Zero-Allocation) = 60fpsで数千エージェント


参考資料

この記事は著者の CC BY 4.0 ライセンスの下で提供されています。