mcpField
D
dotnet-linq-optimization

dotnet-linq-optimization

github.com/wshaddix開発8688 回インストール35 週間前に更新
リポジトリ

dotnet-linq-optimization について

.NET 開発者向けの LINQ パフォーマンス最適化スキルです。EF Core のクエリ実行、遅延実行、割り当てを扱い、コンパイル済みクエリや Span などの手法も解説します。

主な機能

  1. 01IQueryable のサーバー実行と IEnumerable のメモリ内実行の区別
  2. 02EF Core のコンパイル済みクエリの活用
  3. 03遅延実行と複数回列挙の問題の特定
  4. 04LINQ の割り当てと手動ループの評価
  5. 05低割り当て処理での Span の利用
  6. 06プロジェクション、ページング、バッチ操作の最適化

活用シーン

  1. 01クエリの早すぎるクライアント実行への切り替え防止
  2. 02高頻度な EF Core クエリの最適化
  3. 03遅延クエリの繰り返し実行の調査
  4. 04ホットパスのコレクション処理での割り当て削減

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-linq-optimization

インストール後、Claude Code で/dotnet-linq-optimizationを入力して読み込まれたか確認します。

詳細

dotnet-linq-optimization

.NET アプリケーション向けの LINQ パフォーマンスパターンです。IQueryable<T> によるサーバー側評価と IEnumerable<T> によるクライアント側の実体化の重要な違い、EF Core の頻繁に実行される処理におけるコンパイル済みクエリ、遅延実行の落とし穴、LINQ to Objects のメモリ割り当てパターンと手動ループに切り替えるタイミング、さらに割り当てゼロの処理を実現する Span ベースの代替手段を扱います。

対象外: EF Core の DbContext のライフサイクル、マイグレーション、インターセプター、接続の回復性については、[skill:dotnet-efcore-patterns] を参照してください。戦略的なデータアーキテクチャ(リポジトリパターン、読み取り/書き込みの分離、N+1 の管理)については、[skill:dotnet-efcore-architecture] を参照してください。Span と Memory の基礎については、[skill:dotnet-performance-patterns] を参照してください。マイクロベンチマークのセットアップについては、[skill:dotnet-benchmarkdotnet] を参照してください。

相互参照:EF Core のコンテキストにおけるコンパイル済みクエリと DbContext の使用方法については [skill:dotnet-efcore-patterns]、Span/Memory の基礎と ArrayPool のパターンについては [skill:dotnet-performance-patterns]、LINQ 最適化の効果測定については [skill:dotnet-benchmarkdotnet] を参照してください。


IQueryable と IEnumerable の実体化

LINQ のパフォーマンスを左右する最も重要な判断は、評価がデータベースサーバー(IQueryable<T>)とアプリケーションのメモリ(IEnumerable<T>)のどちらで行われるかです。

問題
// DANGEROUS: Materializes entire table into memory, then filters in C#
IEnumerable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT * FROM Orders  (no WHERE clause!)

// CORRECT: Filter executes on the database server
IQueryable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT ... FROM Orders WHERE CreatedAt > @cutoff
実体化が発生するタイミング
操作効果
ToList()、ToArray()、ToDictionary()クエリを実行し、結果をメモリに読み込みます
foreach / await foreachクエリを実行し、結果をストリーミングします
AsEnumerable()サーバー側評価からクライアント側評価に切り替えます
Count()、Any()、First()、Single()クエリを実行し、スカラー値を返します
IQueryable に対する Where()、Select()、OrderBy()式ツリーを構築します(実行はしません)
IEnumerable に対する Where()、Select()、OrderBy()メモリ内で遅延評価します
よくある間違い
// MISTAKE 1: AsEnumerable() before filtering
var results = dbContext.Orders
    .AsEnumerable()           // <-- switches to client evaluation
    .Where(o => o.Total > 100)  // runs in memory, not SQL
    .ToList();

// MISTAKE 2: Calling a C# method in IQueryable predicate
var results = dbContext.Orders
    .Where(o => IsHighValue(o))  // Cannot translate to SQL; throws or falls back
    .ToList();

// FIX: Use expression-compatible predicates or call after materialization
var results = dbContext.Orders
    .Where(o => o.Total > 100)   // SQL-translatable
    .AsEnumerable()
    .Where(o => IsHighValue(o))  // C# logic after materialization
    .ToList();

// MISTAKE 3: Projecting too many columns
var names = dbContext.Orders.ToList().Select(o => o.CustomerName);
// Loads ALL columns, then picks one in memory

// FIX: Project before materializing
var names = dbContext.Orders.Select(o => o.CustomerName).ToList();
// SQL: SELECT CustomerName FROM Orders
検出チェックリスト
  • Where/Select の前に AsEnumerable() を呼び出す箇所や、IEnumerable<T> にキャストする箇所は、サーバー側処理を迂回する可能性があります
  • EF Core はクライアント側評価にフォールバックすると、Microsoft.EntityFrameworkCore.Query のログを Warning レベルで出力します
  • 開発中は ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning)) を有効にします

EF Core の頻繁に実行される処理向けコンパイル済みクエリ

コンパイル済みクエリを使うと、呼び出しごとに発生する式ツリーのコンパイルオーバーヘッドをなくせます。毎秒数千回実行されるクエリでは、オーバーヘッドを大幅に削減できる場合があります。

標準的なコンパイル済みクエリ
public sealed class OrderRepository(AppDbContext db)
{
    // Compiled once, reused across all calls
    private static readonly Func<AppDbContext, Guid, Task<Order?>>
        s_findById = EF.CompileAsyncQuery(
            (AppDbContext ctx, Guid id) =>
                ctx.Orders.FirstOrDefault(o => o.Id == id));

    private static readonly Func<AppDbContext, DateTime, IAsyncEnumerable<Order>>
        s_findRecent = EF.CompileAsyncQuery(
            (AppDbContext ctx, DateTime cutoff) =>
                ctx.Orders
                    .Where(o => o.CreatedAt > cutoff)
                    .OrderByDescending(o => o.CreatedAt));

    public Task<Order?> FindByIdAsync(Guid id) =>
        s_findById(db, id);

    public IAsyncEnumerable<Order> FindRecentAsync(DateTime cutoff) =>
        s_findRecent(db, cutoff);
}
コンパイル済みクエリを使用するタイミング
シナリオコンパイル済みクエリを使用しますか?
高頻度のルックアップ(認証、キャッシュ)はい
管理者ダッシュボードのクエリ(低頻度)いいえ -- オーバーヘッドは無視できる程度です
動的な述語を含むクエリ(ユーザー検索)いいえ -- クエリの形状をパラメーター化できません
Include() が変化するクエリいいえ -- Include によって式ツリーの形状が変わります
制限事項
  • コンパイル済みクエリでは、式ツリーの形状が変わる動的な Include() や条件付きの Where() 句を使用できません
  • パラメーターは単純な型である必要があります(複雑なオブジェクトやコレクションは使用できません)
  • EF.CompileAsyncQuery は、単一の結果に対しては Task<T> を、コレクションに対しては IAsyncEnumerable<T> を返します

遅延実行の落とし穴

LINQ は遅延実行を使用します。クエリ演算子は、結果が消費されるまで実行されないパイプラインを構築します。これは強力な仕組みですが、気づきにくいバグの原因にもなります。

複数回の列挙
// BUG: Enumerates the database query twice
IQueryable<Order> query = dbContext.Orders.Where(o => o.Status == Status.Active);

var count = query.Count();         // Executes SQL (1st query)
var items = query.ToList();        // Executes SQL again (2nd query)

// FIX: Materialize once
var items = dbContext.Orders
    .Where(o => o.Status == Status.Active)
    .ToList();

var count = items.Count;           // In-memory, no SQL
ループ内でのクロージャーのキャプチャ
// BUG: All queries capture the same loop variable 'i' by reference
var queries = new List<IQueryable<Order>>();
for (int i = 0; i < statuses.Length; i++)
{
    queries.Add(dbContext.Orders.Where(o => o.Status == statuses[i]));
    // 'i' is captured by reference -- all queries use final value of i
}

// FIX: Copy to a local variable inside the loop body
for (int i = 0; i < statuses.Length; i++)
{
    var localStatus = statuses[i];
    queries.Add(dbContext.Orders.Where(o => o.Status == localStatus));
}

注:C# 5 以降では、foreach ループ変数は反復ごとにスコープが分かれるため、このバグは発生しません。一方、for ループのインデックス変数は反復間で共有されるため、ループ内で遅延実行される LINQ クエリを構築するときによくある落とし穴です。

メソッドの戻り値における遅延実行
// DANGEROUS: Returns an unevaluated query -- caller may not realize
// the DbContext could be disposed before enumeration
public IEnumerable<Order> GetActiveOrders()
{
    return dbContext.Orders.Where(o => o.Status == Status.Active);
    // Not evaluated yet -- DbContext may be disposed when caller iterates
}

// SAFE: Materialize before returning
public async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct)
{
    return await dbContext.Orders
        .Where(o => o.Status == Status.Active)
        .ToListAsync(ct);
}

LINQ-to-Objects の割り当てパターン

メモリ内コレクションに対する LINQ 演算子は、イテレーター、デリゲート、中間コレクションを割り当てます。1 秒あたり数千件を処理するホットパスでは、これらの割り当てが GC の負荷を引き起こすことがあります。

割り当ての発生源
操作割り当て
Where()、Select()イテレーターオブジェクト + デリゲート
ToList()、ToArray()新しいコレクション + サイズ変更が発生する場合あり
OrderBy()並べ替え用の全コピー
GroupBy()ディクショナリ + グループ化オブジェクト
SelectMany()イテレーター + 内部イテレーター
ローカル変数をキャプチャするラムダキャプチャしたスコープごとにクロージャオブジェクト
LINQ の割り当てが問題になる場合

ほとんどのコードでは、LINQ の割り当ては無視できる程度です。最適化するのは、次の場合に限ってください。

  • ホットパスで処理される(1 秒あたり数千回呼び出される)
  • BenchmarkDotNet で Allocated バイト数が大きいことが示される
  • GC メトリクス(Gen0 コレクション数/秒)が負荷を示している
手動ループによる代替
// LINQ: Allocates iterator + delegate + List<T>
var result = items
    .Where(x => x.IsActive)
    .Select(x => x.Name)
    .ToList();

// Manual loop: Single List<T> allocation, no iterator/delegate overhead
var result = new List<string>(items.Count);
foreach (var item in items)
{
    if (item.IsActive)
    {
        result.Add(item.Name);
    }
}
// LINQ: Allocates iterator + delegate + bool boxing (Any)
var hasActive = items.Any(x => x.IsActive);

// Manual loop: Zero allocations beyond the enumerator
var hasActive = false;
foreach (var item in items)
{
    if (item.IsActive)
    {
        hasActive = true;
        break;
    }
}
LINQ を使い続けながら割り当てを減らす

手動ループに切り替える前に、次の中間的な方法を検討してください。

// 1. Use Array.Find / Array.Exists for arrays (no iterator allocation)
var first = Array.Find(items, x => x.IsActive);
var exists = Array.Exists(items, x => x.IsActive);

// 2. Pre-size collections when count is known
var result = new List<string>(items.Length);
result.AddRange(items.Where(x => x.IsActive).Select(x => x.Name));

// 3. Use static lambdas to avoid delegate allocation (C# 9+)
var result = items.Where(static x => x.IsActive).ToList();
// Note: static lambdas prevent accidental closure capture
// but the delegate is already cached by the compiler for
// non-capturing lambdas; the main benefit is enforcement

コレクション処理における Span ベースの代替手段

最高のパフォーマンスが求められる場面では、Span<T> と ReadOnlySpan<T> により、スタックベースで割り当てのない処理が可能になります。これらの API は LINQ とは互換性がありませんが、よくあるパターンをカバーします。

Span による検索とフィルター処理
// Zero-allocation contains check on an array
ReadOnlySpan<int> values = stackalloc int[] { 1, 2, 3, 4, 5 };
bool found = values.Contains(3);

// Zero-allocation index search
int index = values.IndexOf(4);
文字列処理のための MemoryExtensions
// Zero-allocation split and iterate
ReadOnlySpan<char> csv = "alice,bob,charlie";
foreach (var segment in csv.Split(','))
{
    ReadOnlySpan<char> value = csv[segment];
    // Process each value without allocating strings
}

// Zero-allocation trim and compare
ReadOnlySpan<char> input = "  hello  ";
bool match = input.Trim().SequenceEqual("hello");
LINQ より Span を使う場合
シナリオ方法
タイトなループでの CSV/ログ行の解析ReadOnlySpan<char> + Split
ソート済み配列の検索Span<T>.BinarySearch
I/O からのバイトバッファー処理ReadOnlySpan<byte> のスライス
コレクションに対する一般的なビジネスロジックLINQ(マイクロ最適化より読みやすさを優先)

包括的な Span/Memory パターンと ArrayPool の使用方法については、[skill:dotnet-performance-patterns] を参照してください。


クエリ最適化パターン

マテリアライズ前にプロジェクションする

必要な列だけを常に選択してください。

// BAD: Loads entire entity graph
var orders = await dbContext.Orders
    .Include(o => o.Lines)
    .Include(o => o.Customer)
    .ToListAsync(ct);

var summaries = orders.Select(o => new
{
    o.Id,
    o.Customer.Name,
    Total = o.Lines.Sum(l => l.Price * l.Quantity)
});

// GOOD: Project in the query -- single SQL with computed columns
var summaries = await dbContext.Orders
    .Select(o => new
    {
        o.Id,
        CustomerName = o.Customer.Name,
        Total = o.Lines.Sum(l => l.Price * l.Quantity)
    })
    .ToListAsync(ct);
キーセット(シーク)方式によるページネーション
// Offset pagination: O(N) -- server must skip rows
var page = await dbContext.Orders
    .OrderBy(o => o.Id)
    .Skip(pageSize * pageNumber)
    .Take(pageSize)
    .ToListAsync(ct);

// Keyset pagination: O(1) -- index seek
var page = await dbContext.Orders
    .Where(o => o.Id > lastSeenId)
    .OrderBy(o => o.Id)
    .Take(pageSize)
    .ToListAsync(ct);
バッチ処理
// BAD: N UPDATE statements (one per tracked entity change)
foreach (var order in orders)
{
    order.Status = OrderStatus.Archived;
}
await dbContext.SaveChangesAsync(ct);
// Generates N individual UPDATE statements in a single round-trip

// GOOD: EF Core 7+ ExecuteUpdateAsync (single SQL statement)
await dbContext.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(
        s => s.SetProperty(o => o.Status, OrderStatus.Archived),
        ct);

エージェントが注意すべき点

  1. フィルター処理の前に IQueryable を IEnumerable にキャストしないでください -- これにより、サーバー側の SQL 評価からクライアント側のメモリ内評価へ暗黙に切り替わり、テーブル全体が読み込まれる可能性があります。AsEnumerable()、明示的なキャスト、または IEnumerable<T> を受け取るメソッドシグネチャがないか確認してください。
  2. リポジトリメソッドから IQueryable を返さないでください -- 呼び出し元は演算子を追加できますが、列挙の前に DbContext が破棄される可能性があります。マテリアライズ済みのコレクション(List<T>)を返すか、IAsyncEnumerable<T> を使用してください。
  3. ベンチマークなしに LINQ の割り当てを最適化しないでください -- ほとんどのビジネスロジックでは、LINQ イテレーターのオーバーヘッドは無視できる程度です。LINQ を手動ループに置き換える前に、[skill:dotnet-benchmarkdotnet] の [MemoryDiagnoser] を使って、割り当てが問題であることを確認してください。
  4. 動的な述語とコンパイル済みクエリを併用しないでください -- コンパイル済みクエリは式ツリーの形状をキャッシュします。呼び出しごとにクエリの形状が変わる場合(条件付きの include、動的なフィルター)、コンパイル済みクエリは例外をスローするか、誤った結果を返します。
  5. 遅延クエリを複数回列挙しないでください -- 列挙するたびに、基となるソース(データベースクエリ、ネットワーク呼び出し)が再実行されます。結果を複数回使用する場合は、ToList() でマテリアライズしてください。
  6. 深いページネーションに Skip()/Take() を使用しないでください -- オフセットページネーションはデータベース上で O(N) です。ページの深さにかかわらず一貫したパフォーマンスを得るには、最後に確認したキーを指定する Where 句によるキーセット(シーク)ページネーションを使用してください。

参考資料

コメント