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メソッドがメモリを消費するわけではありません。以下のメソッドは、今回のテスト条件下でアロケーションが確認されませんでした。
AsEnumerableCastElementAt/ElementAtOrDefaultSingle/SingleOrDefault
これらは単に参照を返したり、既存のインデックスにアクセスしたりするだけであるため、ヒープメモリを汚しません。
少量のメモリを消費するメソッド(約32〜88バイト)
多くの一般的なメソッドは、列挙子(Enumerator)の状態を保持するために少量のメモリを割り当てます。1回あたりのコストは小さいですが、Updateループ内などで頻繁に呼び出すと累積的な影響が出ます。
SelectMin/MaxAny/AllCount/LongCountFirst/LastWhere
高負荷なアロケーションを伴うメソッド
一部のメソッドは、内部でバッファを確保したり、データ構造を再構築したりするため、比較的大きなメモリを消費します。特にソートや変換に関連するメソッドが該当します。
// 実行例とアロケーションの傾向
var sorted = _items.OrderBy(_getScore); // 最大0.5 KB程度
var dict = _items.ToDictionary(_isValid); // 約434バイト
var list = _items.ToList(); // 配列サイズに応じたアロケーション
特に OrderBy は内部でソート用のインデックスやバッファを生成するため、最も重い処理の一つとなります。また、ToDictionary や ToLookup もハッシュテーブルの構築に伴う大きなコストが発生します。
結論と最適化へのアドバイス
計測結果から明らかなように、LINQはその利便性と引き換えに、多かれ少なかれヒープメモリを消費します。パフォーマンスがクリティカルなゲームエンジンや、毎フレーム実行されるUpdate関数内での利用には以下の対策を検討してください。
- 手動ループへの置き換え: 最も確実な方法は、
foreachやforループを使用して手動で集計や抽出を行うことです。 - キャッシュの活用: 変更されないデータセットに対するソート結果やリスト変換は、一度だけ実行して結果をキャッシュしておきます。
- ゼロ・アロケーション・ライブラリの検討: 構造体ベースの列挙子を使用することで、GC負荷をゼロに抑えたLINQライクな代替ライブラリの導入も有効です。