C#のLINQメソッドにおけるGCアロケーションの徹底調査

LINQ(Language Integrated Query)はC#開発において非常に強力なツールですが、パフォーマンス、特にメモリの割り当て(GCアロケーション)に関しては注意が必要です。実行時にどれだけの「ゴミ」が生成され、ガベージコレクションに負荷をかけるのか。主要なLINQメソッドのメモリ消費量を検証しました。

検証にあたり、ラムダ式のキャプチャやデリゲートの生成によるノイズを排除するため、以下のようなテスト用クラスと事前にキャッシュされたデリゲートを用意しました。これにより、LINQメソッド内部で発生する純粋なアロケーションのみを計測します。

public class DataItem
{
    public float Score;
}

public class LinqPerformanceTest : MonoBehaviour
{
    private DataItem[] _items;
    private DataItem _target;
    private IOrderedEnumerable<DataItem> _sortedItems;

    // デリゲートのキャッシュ
    private Func<DataItem, bool> _isValid;
    private Func<DataItem, float> _getScore;
    private Func<DataItem, DataItem, DataItem> _selector;

    void Awake()
    {
        _items = new[] { new DataItem { Score = 10.0f } };
        _target = new DataItem { Score = 20.0f };

        // アロケーションを避けるため事前に定義
        _isValid = x => true;
        _getScore = x => x.Score;
        _selector = (a, b) => b;
    }
}

Unityのプロファイラーを使用して、各メソッドを個別に実行した際のメモリ割り当てを確認しました。テスト環境は、Unity 2018.2、.NET 4.x、IL2CPPビルドです。

アロケーションが発生しないメソッド

驚くべきことに、全てのLINQメソッドがメモリを消費するわけではありません。以下のメソッドは、今回のテスト条件下でアロケーションが確認されませんでした。

  • AsEnumerable
  • Cast
  • ElementAt / ElementAtOrDefault
  • Single / SingleOrDefault

これらは単に参照を返したり、既存のインデックスにアクセスしたりするだけであるため、ヒープメモリを汚しません。

少量のメモリを消費するメソッド(約32〜88バイト)

多くの一般的なメソッドは、列挙子(Enumerator)の状態を保持するために少量のメモリを割り当てます。1回あたりのコストは小さいですが、Updateループ内などで頻繁に呼び出すと累積的な影響が出ます。

  • Select
  • Min / Max
  • Any / All
  • Count / LongCount
  • First / Last
  • Where

高負荷なアロケーションを伴うメソッド

一部のメソッドは、内部でバッファを確保したり、データ構造を再構築したりするため、比較的大きなメモリを消費します。特にソートや変換に関連するメソッドが該当します。

// 実行例とアロケーションの傾向
var sorted = _items.OrderBy(_getScore); // 最大0.5 KB程度
var dict = _items.ToDictionary(_isValid); // 約434バイト
var list = _items.ToList(); // 配列サイズに応じたアロケーション

特に OrderBy は内部でソート用のインデックスやバッファを生成するため、最も重い処理の一つとなります。また、ToDictionaryToLookup もハッシュテーブルの構築に伴う大きなコストが発生します。

結論と最適化へのアドバイス

計測結果から明らかなように、LINQはその利便性と引き換えに、多かれ少なかれヒープメモリを消費します。パフォーマンスがクリティカルなゲームエンジンや、毎フレーム実行されるUpdate関数内での利用には以下の対策を検討してください。

  • 手動ループへの置き換え: 最も確実な方法は、foreachfor ループを使用して手動で集計や抽出を行うことです。
  • キャッシュの活用: 変更されないデータセットに対するソート結果やリスト変換は、一度だけ実行して結果をキャッシュしておきます。
  • ゼロ・アロケーション・ライブラリの検討: 構造体ベースの列挙子を使用することで、GC負荷をゼロに抑えたLINQライクな代替ライブラリの導入も有効です。

タグ: C# LINQ Unity MemoryManagement GarbageCollection

7月3日 16:20 投稿