dotnet-performance-patterns について
.NET 8 以降のアプリケーション向けに、性能パターンを解説するガイドです。ホットパスの改善や割り当て削減を目指す開発者に向け、Span、バッファープール、構造体、文字列処理の理由と測定の重要性を扱います。
主な機能
- 01Span と Memory による低割り当て処理
- 02ArrayPool によるバッファー再利用
- 03構造体と in パラメーターの性能上の考慮
- 04sealed クラスと JIT のデバーチャライズ
- 05stackalloc の使い方と安全上限
- 06文字列比較と構築の手法
活用シーン
- 01高スループット解析での GC 負荷軽減
- 02大きなバッファーの反復割り当て回避
- 03ホットパスでの構造体受け渡しの最適化
- 04ベンチマーク測定後の変更評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-performance-patternsインストール後、Claude Code で/dotnet-performance-patternsを入力して読み込まれたか確認します。
詳細
dotnet-performance-patterns
.NET アプリケーション向けのパフォーマンス重視のアーキテクチャパターンです。Span<T> と Memory<T> による割り当てゼロのコーディング、ArrayPool<T> によるバッファープーリング、パフォーマンスを考慮した構造体の設計(readonly struct、ref struct、in パラメーター)、JIT による sealed class の非仮想化、stackalloc によるスタックベースの割り当て、文字列処理のパフォーマンスを扱います。方法(言語構文)ではなく、理由(パフォーマンス上の根拠と測定)に重点を置いています。
バージョンの前提: .NET 8.0 以降をベースラインとします。Span<T> と Memory<T> は .NET Core 2.1 以降で利用できますが、このスキルは .NET 8 以降の最新の使用パターンを対象としています。
対象外: Span、レコード、パターンマッチング、コレクション式の C# 言語構文については、[skill:dotnet-csharp-modern-patterns] を参照してください。コーディング標準と命名規則(sealed class のスタイルに関するガイダンスを含む)については、[skill:dotnet-csharp-coding-standards] を参照してください。マイクロベンチマークの設定と測定は、このエピックの関連スキルが担当します。[skill:dotnet-benchmarkdotnet] を参照してください。Native AOT のコンパイルパイプラインとトリミングについては、[skill:dotnet-native-aot] を参照してください。シリアル化形式のパフォーマンス上のトレードオフについては、[skill:dotnet-serialization] を参照してください。アーキテクチャパターン(キャッシュ、回復性、DI)については、[skill:dotnet-architecture-patterns] を参照してください。EF Core のクエリ最適化については、[skill:dotnet-efcore-patterns] を参照してください。
相互参照:これらのパターンの影響を測定するには [skill:dotnet-benchmarkdotnet]、Span/Memory の構文の基礎については [skill:dotnet-csharp-modern-patterns]、sealed class のスタイル規約については [skill:dotnet-csharp-coding-standards]、AOT のパフォーマンス特性とパターンの選択に対するトリミングの影響については [skill:dotnet-native-aot]、シリアル化のパフォーマンスに関する背景については [skill:dotnet-serialization] を参照してください。
割り当てゼロのシナリオにおける Span<T> と Memory<T>
Span<T> がパフォーマンスに重要な理由
Span<T> は、割り当てを行わずに、連続したメモリを安全かつ境界チェック付きで参照できます。配列、文字列、スタックメモリをコピーせずにスライスできます。構文の詳細については [skill:dotnet-csharp-modern-patterns] を参照してください。このセクションでは、パフォーマンス上の根拠に焦点を当てます。
割り当てゼロの文字列処理
// BAD: Substring allocates a new string on each call
public static (string Key, string Value) ParseHeader_Allocating(string header)
{
var colonIndex = header.IndexOf(':');
return (header.Substring(0, colonIndex), header.Substring(colonIndex + 1).Trim());
}
// GOOD: ReadOnlySpan<char> slicing avoids all allocations
public static (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) ParseHeader_ZeroAlloc(
ReadOnlySpan<char> header)
{
var colonIndex = header.IndexOf(':');
return (header[..colonIndex], header[(colonIndex + 1)..].Trim());
}
パフォーマンスへの影響:高スループットの解析(HTTP ヘッダー、ログ行、CSV 行)では、Span ベースの解析によって GC の負荷を完全に解消できます。[skill:dotnet-benchmarkdotnet] の [MemoryDiagnoser] で測定してください。Allocated 列が 0 B になっているはずです。
非同期処理とストレージのシナリオにおける Memory<T>
Span<T> は非同期メソッドで使用したり、ヒープに格納したりできません(ref struct であるため)。次の用途では Memory<T> を使用してください。
- バッファーを非同期 I/O メソッドに渡す
- スライス参照をフィールドまたはコレクションに格納する
- 後で使用するメモリー領域をメソッドから返す
public async Task<int> ReadAndProcessAsync(Stream stream, Memory<byte> buffer)
{
var bytesRead = await stream.ReadAsync(buffer);
var data = buffer[..bytesRead]; // Memory<T> slicing -- no allocation
return ProcessData(data.Span); // .Span for synchronous processing
}
private int ProcessData(ReadOnlySpan<byte> data)
{
var sum = 0;
foreach (var b in data)
sum += b;
return sum;
}
バッファープーリングにおける ArrayPool<T>
バッファーをプールする理由
大きな配列の割り当て(>= 85,000 バイト)は直接 Large Object Heap(LOH)に配置されます。LOH は Gen 2 GC でのみ収集されるため、コストが高く、一時停止の原因になります。小さな配列であっても、ホットパスでは GC の負荷が増加します。ArrayPool<T> はバッファーを貸し出して返却することで、割り当ての繰り返しを防ぎます。
使用パターン
using System.Buffers;
public int ProcessLargeData(Stream source)
{
var buffer = ArrayPool<byte>.Shared.Rent(minimumLength: 81920);
try
{
var bytesRead = source.Read(buffer, 0, buffer.Length);
// IMPORTANT: Rent may return a larger buffer than requested.
// Always use bytesRead or the requested length, never buffer.Length.
return ProcessChunk(buffer.AsSpan(0, bytesRead));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
// clearArray: true zeroes the buffer -- use when buffer held sensitive data
}
}
よくある間違い
| 間違い | 影響 | 修正方法 |
|---|---|---|
要求サイズではなく buffer.Length を使用する | 実際のデータを超えた未初期化バイトまで処理する | 要求サイズと実際のサイズを別々に記録する |
| バッファーを返却し忘れる | プールが枯渇し、割り当てにフォールバックする | try/finally または using ラッパーを使用する |
| バッファーを二重に返却する | プールの状態が破損する | 返却後に参照を null にする |
| 機密データをクリアしない | プールされたバッファーから情報が漏えいする | Return に clearArray: true を渡す |
readonly struct、ref struct、in パラメーター
readonly struct -- 防御的コピーの排除
JIT は、変更を防ぐため、in、readonly フィールド、または readonly メソッドを介してアクセスされる非 readonly 構造体を防御的にコピーする必要があります。構造体を readonly として宣言すると、不変性が保証され、こうしたコピーがなくなります:
// GOOD: readonly eliminates defensive copies on every access
public readonly struct Point3D
{
public double X { get; }
public double Y { get; }
public double Z { get; }
public Point3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
// readonly struct: JIT knows this cannot mutate, no defensive copy needed
public double DistanceTo(in Point3D other)
{
var dx = X - other.X;
var dy = Y - other.Y;
var dz = Z - other.Z;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
}
readonly を付けない場合、in パラメーターを介して構造体のメソッドを呼び出すと、変更を防ぐために JIT は構造体全体をコピーします。大きな構造体をタイトなループ内で扱う場合、このコピーによる大きなオーバーヘッドを排除できます。
ref struct -- スタック専用型
ref struct 型はスタック上に限定されます。ボックス化したり、フィールドに格納したり、非同期メソッドで使用したりすることはできません。これにより、Span<T> を安全にラップできます:
public ref struct SpanLineEnumerator
{
private ReadOnlySpan<char> _remaining;
public SpanLineEnumerator(ReadOnlySpan<char> text) => _remaining = text;
public ReadOnlySpan<char> Current { get; private set; }
public bool MoveNext()
{
if (_remaining.IsEmpty)
return false;
var newlineIndex = _remaining.IndexOf('\n');
if (newlineIndex == -1)
{
Current = _remaining;
_remaining = default;
}
else
{
Current = _remaining[..newlineIndex];
_remaining = _remaining[(newlineIndex + 1)..];
}
return true;
}
}
in パラメーター -- 変更せずに参照渡し
メソッドに渡す大きな読み取り専用構造体には in を使用します。in 修飾子は参照渡し(コピーを回避)を行い、変更を防止します:
// in parameter: pass by reference, no copy, no mutation allowed
public static double CalculateDistance(in Point3D a, in Point3D b)
=> a.DistanceTo(in b);
in を使用する場合:
| 構造体のサイズ | 推奨事項 |
|---|---|
| <= 16 bytes | 値渡しにします(レジスターに適しており、間接参照のオーバーヘッドがありません) |
| > 16 bytes | コピーのオーバーヘッドを避けるために in を使用します |
| 任意のサイズ、readonly 構造体 | in は安全です(防御的コピーが発生しません) |
| 任意のサイズ、非 readonly 構造体 | in は避けます(防御的コピーによって利点が失われます) |
sealed クラスのパフォーマンス上の根拠
JIT によるディ仮想化
クラスが sealed の場合、サブクラスによるオーバーライドが不可能なため、JIT は仮想メソッド呼び出しを直接呼び出し(ディ仮想化)に置き換えられます。これにより、さらなるインライン化が可能になります:
// Without sealed: virtual dispatch through vtable
public class OpenService : IProcessor
{
public virtual int Process(int x) => x * 2;
}
// With sealed: JIT devirtualizes + inlines Process call
public sealed class SealedService : IProcessor
{
public int Process(int x) => x * 2;
}
public interface IProcessor { int Process(int x); }
[skill:dotnet-benchmarkdotnet] の [DisassemblyDiagnoser] を使ってディ仮想化を確認してください。クラスをデフォルトで sealed にするプロジェクト規約については、[skill:dotnet-csharp-coding-standards] を参照してください。
パフォーマンスへの影響
ディ仮想化とインライン化により、次の要素が排除されます:
- vtable の検索 -- メソッドポインターを見つけるための間接的なメモリアクセス
- 呼び出しのオーバーヘッド -- 実際の間接呼び出し命令
- インライン化の障壁 -- 仮想呼び出しはインライン化できませんが、sealed メソッドは可能です
タイトなループやホットパスでは、累積的な効果を測定できます。拡張を目的として設計されていないフレームワークやライブラリの型では、常に sealed を優先してください。
小さなスタックベースの割り当てに対する stackalloc
stackalloc を使用する場合
stackalloc はスタック上にメモリを割り当てるため、GC を完全に回避できます。ホットパスで小さな固定サイズのバッファーを扱う場合に使用します:
public static string FormatGuid(Guid guid)
{
// 68 bytes on the stack -- well within safe limits
Span<char> buffer = stackalloc char[68];
guid.TryFormat(buffer, out var charsWritten, "D");
return new string(buffer[..charsWritten]);
}
安全性に関するガイドライン
| ガイドライン | 根拠 |
|---|---|
| 割り当てサイズは小さく保ちます(通常は < 1 KB、絶対最大でも < 4 KB) | スタック領域には制限があります(Windows ではデフォルトで約 1 MB)。オーバーフローするとプロセスがクラッシュします |
| 定数または上限が定められたサイズのみを使用します | 実行時に変化するサイズは、悪意のある入力や予期しない入力によるスタックオーバーフローのリスクがあります |
生ポインターより Span<T> への代入を優先します | Span は境界チェックを行いますが、生ポインターは行いません |
| 大きなサイズや可変サイズには ArrayPool を使用します | スタックの予算を超えるケースにも適切に対処できます |
ハイブリッドパターン:stackalloc と ArrayPool によるフォールバック
public static string ProcessData(ReadOnlySpan<byte> input)
{
const int stackThreshold = 256;
char[]? rented = null;
Span<char> buffer = input.Length <= stackThreshold
? stackalloc char[stackThreshold]
: (rented = ArrayPool<char>.Shared.Rent(input.Length));
try
{
var written = Encoding.UTF8.GetChars(input, buffer);
return new string(buffer[..written]);
}
finally
{
if (rented is not null)
ArrayPool<char>.Shared.Return(rented);
}
}
このパターンは .NET ランタイムライブラリ全体で使用されており、小さな入力と大きな入力の両方を処理するメソッドに推奨される方法です。
文字列インターン化と StringComparison のパフォーマンス
文字列比較のパフォーマンス
序数比較は Unicode 正規化を回避するため、カルチャを考慮した比較より大幅に高速です:
// FAST: ordinal comparison (byte-by-byte)
bool isMatch = str.Equals("expected", StringComparison.Ordinal);
bool containsKey = dict.ContainsKey(key); // Dictionary<string, T> uses ordinal by default
// FAST: case-insensitive ordinal (no culture overhead)
bool isMatchIgnoreCase = str.Equals("expected", StringComparison.OrdinalIgnoreCase);
// SLOW: culture-aware comparison (Unicode normalization, linguistic rules)
bool isMatchCulture = str.Equals("expected", StringComparison.CurrentCulture);
デフォルトの指針: 内部識別子、ディクショナリキー、ファイルパス、プロトコル文字列には StringComparison.Ordinal または StringComparison.OrdinalIgnoreCase を使用します。カルチャを考慮した比較は、ユーザーに表示するテキストの並べ替えと表示に限定します。
文字列インターン化
CLR はコンパイル時の文字列リテラルを自動的にインターン化します。string.Intern() を使うと、繰り返し頻繁に現れる実行時文字列のメモリ使用量を削減できます:
// Intern frequently-repeated runtime strings to share a single instance
var normalized = string.Intern(headerName.ToLowerInvariant());
注意: インターン化された文字列はガベージコレクションされません。HTTP ヘッダーや XML 要素名など、範囲が限定された既知の集合の文字列のみをインターン化してください。ユーザー入力や範囲が限定されないデータは決してインターン化しないでください。
効率的な文字列の構築
| シナリオ | 推奨される方法 | 理由 |
|---|---|---|
| 2~3 回の連結 | 文字列補間 $"{a}{b}" | コンパイラーが string.Concat に最適化します |
| ループ内での連結 | StringBuilder | 二次的なメモリ割り当てを回避できます |
| 固定部分が既知の場合 | string.Create | 単一の割り当てで、Span ベースの書き込みができます |
| 高スループットの書式設定 | Span<char> + TryFormat | メモリ割り当てなしで書式設定できます |
// string.Create for single-allocation building
public static string FormatId(int category, int item)
{
return string.Create(11, (category, item), static (span, state) =>
{
state.category.TryFormat(span, out var catWritten);
span[catWritten] = '-';
state.item.TryFormat(span[(catWritten + 1)..], out _);
});
}
パフォーマンス測定チェックリスト
最適化パターンを適用する前に、まず測定してください。データのない早すぎる最適化は、測定可能な効果のない複雑なコードにつながります。
- ホットパスを特定する -- [skill:dotnet-benchmarkdotnet] を使ってベースラインを確立します
- メモリ割り当てを測定する --
[MemoryDiagnoser]を有効にし、Allocated列を確認します - 一度に 1 つのパターンを適用する -- 1 つ変更するたびに再測定し、ベースラインと比較します
- AOT への影響を確認する -- Native AOT をターゲットにする場合([skill:dotnet-native-aot])、パターンがトリミングに安全か確認します
- 本番環境に近いデータで検証する -- 合成ベンチマークでは、実環境でのメモリ割り当てパターンを見落とすことがあります
- トレードオフを記録する -- 最適化では、速度と引き換えに可読性や柔軟性が損なわれます。測定した効果を記録します
エージェントが注意すべき点
- 最適化の前に測定する -- メモリ割り当てやレイテンシの問題がベンチマークで確認されていない限り、Span/ArrayPool/stackalloc を適用しないでください。早すぎる最適化は、測定可能な効果がないまま、読みにくいコードを生み出します。
- 信頼できない入力から得た可変サイズに対して stackalloc を使用しない -- スタックオーバーフローが発生すると、例外ハンドラーなしでプロセスがクラッシュします。必ず範囲を検証するか、stackalloc と ArrayPool を組み合わせたパターンを使用してください。
- 不変の値型には必ず
readonly structを指定する --readonlyがないと、JIT はinパラメーターやreadonlyフィールドにアクセスするたびに防御的コピーを生成し、構造体を使うパフォーマンス上の利点がひそかに損なわれます。 - ArrayPool から借りたバッファーは finally ブロックで返す -- 返し忘れるとプールが枯渇し、代替のメモリ割り当てが発生して効果が損なわれます。
- 内部比較には
StringComparison.Ordinalを使用する -- 比較パラメーターを省略すると、カルチャを考慮した比較が既定で使用され、処理が遅くなるうえ、技術的な文字列(ファイルパス、識別子)で予期しない結果が生じます。 - sealed クラスがパフォーマンス向上に役立つのは、JIT が具体的な型を確認できる場合に限られる -- 非仮想化できない呼び出し箇所で、オブジェクトがインターフェイス型の変数を介してアクセスされる場合、sealed にしても効果はありません。
[DisassemblyDiagnoser]で確認してください。 - 言語構文を再度説明しない -- Span/Memory の構文の詳細については、[skill:dotnet-csharp-modern-patterns] を参照してください。このスキルでは、パフォーマンス向上のためにこれらのパターンをいつ、なぜ使うのかに重点を置いています。
知識の出典
このスキルのパフォーマンスパターンは、以下の情報に基づいています。
- Stephen Toub -- .NET パフォーマンスに関するブログシリーズ(devblogs.microsoft.com/dotnet/author/toub)。Span<T>、ValueTask、ArrayPool、async の内部実装、ランタイムのパフォーマンス特性に関する権威ある情報源です。
- Stephen Cleary -- 非同期処理のベストプラクティスと並行コレクションに関するガイダンス。『Concurrency in C# Cookbook』の著者です。
- Nick Chapsas -- 最新の .NET パフォーマンスパターンとベンチマーク手法。
上記の情報源は、ここに示したパターンと根拠の参考になっています。このスキルは、いかなる個人を代表したり、その個人を代弁したりするものではありません。