dotnet-linq-optimization について
.NET 開発者向けの LINQ パフォーマンス最適化スキルです。EF Core のクエリ実行、遅延実行、割り当てを扱い、コンパイル済みクエリや Span などの手法も解説します。
主な機能
- 01IQueryable のサーバー実行と IEnumerable のメモリ内実行の区別
- 02EF Core のコンパイル済みクエリの活用
- 03遅延実行と複数回列挙の問題の特定
- 04LINQ の割り当てと手動ループの評価
- 05低割り当て処理での Span の利用
- 06プロジェクション、ページング、バッチ操作の最適化
活用シーン
- 01クエリの早すぎるクライアント実行への切り替え防止
- 02高頻度な EF Core クエリの最適化
- 03遅延クエリの繰り返し実行の調査
- 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);
エージェントが注意すべき点
- フィルター処理の前に IQueryable を IEnumerable にキャストしないでください -- これにより、サーバー側の SQL 評価からクライアント側のメモリ内評価へ暗黙に切り替わり、テーブル全体が読み込まれる可能性があります。
AsEnumerable()、明示的なキャスト、またはIEnumerable<T>を受け取るメソッドシグネチャがないか確認してください。 - リポジトリメソッドから IQueryable を返さないでください -- 呼び出し元は演算子を追加できますが、列挙の前に DbContext が破棄される可能性があります。マテリアライズ済みのコレクション(
List<T>)を返すか、IAsyncEnumerable<T>を使用してください。 - ベンチマークなしに LINQ の割り当てを最適化しないでください -- ほとんどのビジネスロジックでは、LINQ イテレーターのオーバーヘッドは無視できる程度です。LINQ を手動ループに置き換える前に、[skill:dotnet-benchmarkdotnet] の
[MemoryDiagnoser]を使って、割り当てが問題であることを確認してください。 - 動的な述語とコンパイル済みクエリを併用しないでください -- コンパイル済みクエリは式ツリーの形状をキャッシュします。呼び出しごとにクエリの形状が変わる場合(条件付きの include、動的なフィルター)、コンパイル済みクエリは例外をスローするか、誤った結果を返します。
- 遅延クエリを複数回列挙しないでください -- 列挙するたびに、基となるソース(データベースクエリ、ネットワーク呼び出し)が再実行されます。結果を複数回使用する場合は、
ToList()でマテリアライズしてください。 - 深いページネーションに
Skip()/Take()を使用しないでください -- オフセットページネーションはデータベース上で O(N) です。ページの深さにかかわらず一貫したパフォーマンスを得るには、最後に確認したキーを指定するWhere句によるキーセット(シーク)ページネーションを使用してください。