C# 最適化/パフォーマンス/Peanut Butter
やり方の基本
- できるならビルド時最適化を試してみる
- アプリ全体でプロファイルする
- プロファイルでネックが見つかったら細かく比較計測
- Peanut Butterよりアルゴリズム見直し・並列処理化・非同期化・遅延実行
注意点
- ネック箇所の比較計測には BenchmarkDotNet を使う
- 事実上標準
-
System.Diagnostics.Stopwatch
は辞める- 理由
- めちゃくちゃ時間かかる処理とかはあり
-
StopWatch.GetElapsedTime()
(.NET7+
-
- ランタイム/コンパイラの違いを意識する
- 新しい.NETほど同じソースでも高速
- たとえば、一部の
foreach
がfor
に事前展開されたりしている
- たとえば、一部の
- SharpLab で 展開後のコードやILの違いを確認できる
- Unityのランタイムは標準と違うので注意
- ちょっと古い手法が有効
- 新しい.NETほど同じソースでも高速
-
.NET 8.0以降は計測時に Dynamic PGO のON/OFFに注意
- 良くないコードも Dynamic PGOが効くと最適化されて速くなるから
- 新しい情報収集を忘れずに
- 昔はよかったものももっといい手法・アルゴリズム・ライブラリが出てきたりする
- 内部実装が変わって速くなることがある
- 例
どのやり方をつかう?
重い処理が…
どんな時も
- アルゴリズムの見直し
- 1000倍早くなることもよくある
ネットやファイル読み込みが重い
- 非同期処理 (
async
/await
) - アルゴリズムの見直し
- 一度に全部呼び出さない・読み込まない
CPUが重い
- 一瞬だけ重い
- 遅延実行( LINQ /
Lazy<T>
)
- 遅延実行( LINQ /
- いつでも重い
-
並列処理
- TPL :
ParallelFor
とか - PLINQ :
AsParallel()
で使える並列LINQ - Concurrent Collection : 並列処理用コレクション
- Synchronization Primitives(同期プリミティブ):
semaphoreSlim
とかSpinLock
とか - パーティショナー:TPL/PLINQ用にデータを分割
- System.Threading.Channels : プロデューサー・コンシューマーパターン
- TPL :
- SIMD命令
- GPUに任せる
- ComputeSharp
- Silk.NET (OpenCV)
- ML.NET
- OpenTK
-
並列処理
調べ方:用語
- 最適化
- 高速化
- optimize / optimization
- performance
- peanut butter
情報源
- パフォーマンス向上で知っておくべきコンピューターサイエンスの基礎知識とその実践
- 50 C# Optimization Performance Tips - ByteHide Blog
- 7 Simple (Optimization) Tips in C# - DEV Community
- C# の高速化・最適化関連 #C# - Qiita
- C# Code Optimization: Techniques for Faster Software | by Alexandra Grosu | Medium
- C# Performance tips and tricks · Raygun Blog
- 【C#】ループの最適化手法 ③Span<T>編 ~配列をSpan<T>にするだけで早い~ #C# - Qiita
- .NETのパフォーマンス計測ツール「dotnet-trace」の使い方
- C# パフォーマンス改善に使える新しめの機能たち 7.0〜 #C# - Qiita
- Fast File IO with .NET 6
- Performance Improvements in .NET 8
- Reduce memory allocations using new C# features
- チュートリアル: ref safety を使用してメモリ割り当てを削減する
- 【C#】分岐最適化時代の Compare の書き方 - てくメモ
- CEDEC 2023 モダンハイパフォーマンスC# 2023 Edition
- 静的クラスは遅いことがあるよ - Speaker Deck
- 今日からできる!簡単 .NET 高速化 Tips -2024 edition-
- neue cc - R3のコードから見るC#パフォーマンス最適化技法実例とTimeProviderについて
- C# + LINQ の速度比較(.NET Framework 4.7 ~ .NET 8)
- .NET Memory Performance Analysis
ビルド時最適化
- そもそもコード中で頑張らなくてもビルド設定でも最適化できる
- まず最初に見直す
-
中間コードJITコンパイルのメリットを捨てて最適化する方法、だったり
- メリットとデメリットの比較が大事!
- .NETなのでC#以外でも有効
【前提】最新の.NETむけに最新の.NETでビルドする
- 新しい.NET SDKでコンパイルするとより最適化されたコードが出力される
- 新しい.NET ランタイムで実行するとより高速な処理になる(SIMD/Dynamic PGOとか)
R2R (Ready To Run)
- 事前コンパイルで起動が早くなる
- この意味ではC#は正確にはJITではなかった
- 結構前から使える
- 最新の技術じゃないので使えることが多い
- ファイルサイズがデカくなる
- …けど今なら別にいいんじゃないかなぁ
- 事前コンパイルなのでプラットホーム依存
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
NativeAOT
- ネイティブバイナリへの事前コンパイル
- PublishTrimがデフォでされるらしく容量も小さめになる
- .NET 7.0以降が公式サポート
-
制限がそこそこある
- リフレクションとかと相性悪い
- そりゃそう
- .NET7だとGUIに対応してない[ウソ]
- これはMAUIとかの話。Avaloniaでいいじゃん。
- モバイル系は.NET 8でも実験的サポート
- リフレクションとかと相性悪い
- 事前コンパイルなのでプラットホーム依存
-
クロスコンパイルできない
- まあそりゃそう
- 中間コードのメリットを捨てて最適化する方法だしね
- x64 - arm64はできることもあるっぽい
- まあそりゃそう
OptimizationPreference
- サイズと速度どちら優先かを選べる
<OptimizationPreference>Size</OptimizationPreference>
<OptimizationPreference>Speed</OptimizationPreference>
IlcInstructionSet / IlcMaxVectorTBitWidth
- 追加のCPU限定命令を有効に出来るオプションがある
<PropertyGroup>
<IlcInstructionSet>native</IlcInstructionSet>
<IlcMaxVectorTBitWidth>512</IlcMaxVectorTBitWidth>
</PropertyGroup>
Dynamic PGO
- .NET 8.0以降 そのままで有効
- .NET 7.0
<PropertyGroup>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
変化の大きいC#の文字列の最適化手法
string
はネックになりがち
前提:C#の文字列- C#の
string
はimmutable - 書き換えは新しい文字列が作られる
- 新しく作られる→ヒープメモリ確保→メモリアロケーション→GCが走りやすくなる
- …でネックになりやすいらしい
文字列最適化手法の変化
古い情報だと「とりあえずStringBuilder
」というものが多いけど、最近は色々最適化されるようになってきていて昔の方法が通じないかんじ。
- 昔:
- 「文字列結合は
+
を使わずStringBuilder
で!」
- 「文字列結合は
-
.NET 8時代 :
- ある程度固定されてる文字列なら文字列補完を使う
$"abc{val}"
-
StringBuilder
より文字列補完の中で使われてるDefaultInterpolatedStringHandler
の方が速くなる - 文字列がUTF-8なら(C#内部はUTF-16)、UTF8文字列リテラル(
"あいう"u8
)で扱う-
ReadOnlySpan<byte>
として扱われる
-
-
String.Create()
もある
- ある程度固定されてる文字列なら文字列補完を使う
.NET8時代の考え方
var yatta = "やったぜ!";
var str = $"""
改行OK! ({yatta})
文字列エスケープ不要! ({yatta})
インデントしてもOK! ({yatta})
""";
-
なるべく
string
のままで扱わない- 最初と最後だけにする
-
ToString()
しない - 可能ならそれすらも避ける
-
- (型のサイズ的な意味で)1文字なら
char
- もっと最適化するなら
byte
/Span<byte>
で扱う-
ReadOnlySpan<byte>
で文字列 - UTF-8文字列リテラル →参考 「C#12.0 .NET8.0における、Utf8文字列の作り方とパフォーマンス – 技探」
-
- 最初と最後だけにする
String.Intern
はそのままだと自作キャッシュに負けるがNativeAOTで逆転
.NET 8.0の高速化用新機能・ライブラリ
-
System.Collections.Frozen
- 「コレクションの作成後にキーと値を変更することができません。 この要件により、読み取り操作を高速化できます」
- nugetで導入できる
-
System.Buffers.SearchValues
- 【C#】SearchValues<char> を測ったら桁違いに速かった - てくメモ
- 前のバージョン向けには公開されてない…
-
System.Text.CompositeFormat
- 「コンパイル時に不明な書式指定文字列を最適化する場合に便利」
- 前のバージョン向けには公開されてない…
-
System.IO.Hashing.XxHash3
/System.IO.Hashing.XxHash128
- 高速ハッシュ生成
- nugetで導入化
-
System.Runtime.Intrinsics.Vector512<T>
- AVX-512(SIMD)
- 前のバージョン向けには公開されてない…
-
Parallel.ForAsync
-
Parallel.For
の 非同期版 - ForEachAsyncは前からあった
- 前のバージョン向けに公開されてないっぽい
-
T[]
決して最速じゃない配列- C#の配列
T[]
は利便性ならList<T>
, パフォーマンスならSpan<T>
に負ける(ことがある)- .NET 8.0時代だと、なんでもかんでも配列化、はNG
- C#の配列はプリミティブなものだから速い、というわけでもない
- 要素の追加削除で再割り当て
- コピーが発生(メモリアロケーション)
- .NETの内部では
T[]
->Span<T>
への置き換えが進んでいて高速化が図られている
- バッファで使うなら
new byte[]
じゃなくて- サイズが小さいなら
stackalloc byte[]
でSpan<T>/ReadOnlySpan<T>
で扱う - 大きいorわからないなら
ArrayPool<T>.Shared.Rent()
- サイズが小さいなら
TaskとValueTask, TupleとValueTuple
- Valueがついてる方が構造体
- パフォーマンスを意識した実装
TaskとValueTask
-
Task<T>
の方が古い- クラスなのでムダが…
- awaitしないで抜ける時とか割とよくある
-
ValueTask<T>
- 理由がなければこれで
TupleとValueTuple
-
ValueTuple
は直接使うことない- タプル記法の中身がこれ
-
Tuple
型はクラスでメモリアロケーション- まあつかわないかな(匿名型も)
インライン化阻害防止
反復・例外処理をprivate/local関数化
C# コンパイル結果の IL 命令が32バイトを超える場合、インライン化しない
反復処理を含む場合、インライン化しない
例外処理を含む場合、インライン化しない
ので
反復(for
とか)や例外のところだけstatic
な(privateやローカル)関数で外に出すとインライン化されやすくなる。
インライン化属性
[MethodImpl(MethodImplOptions.AggressiveInlining)]
を付与すると、サイズが小さければインライン化してくれる(こともある)。
インライン化防止属性([MethodImpl(MethodImplOptions.NoInlining)]
)はベンチマーク取る時に大事。
インライン化すれば必ず早くなるわけじゃないのに注意。
DynamicPGOはその辺動的に判断するらしい。
例外処理用属性
DoesNotReturn
DoesNotReturnIf
PolySharpで古い環境にも入れられる
ThrowHelper
/ スローヘルパー 系
System.ThrowHelper
- dotnet内部で使われてる
-
internal
:cry: - コードサイズ削減が目的
ThrowIf~
系
.NET には、特定の条件 (ArgumentNullException.ThrowIfNull と ArgumentException.ThrowIfNullOrEmpty) で例外をスローするヘルパー メソッドも用意されています。
一部はExceptionクラスのメソッドとして利用できる
- .NET Standard 1.0
- .NET 7.0
- .NET 8.0
- ArgumentException.ThrowIfNullOrEmpty
- ArgumentException.ThrowIfNullOrWhiteSpace
-
ArgumentOutOfRangeException
- ThrowIfEqual
- ThrowIfGreaterThan
- ThrowIfGreaterThanOrEqual
- ThrowIfLessThan
- ThrowIfLessThanOrEqual
- ThrowIfNegative
- ThrowIfNegativeOrZero
- ThrowIfNotEqual
- ThrowIfZero
Microsoft.Toolkit.Diagnostics.ThrowHelper
-
.NET Community Toolkit に例外を外部関数化した
ThrowHelper
がライブラリ化されている. - コードサイズも小さくなる。
- Guard APIも
Guard / ガード 系
いわゆる「ガード句」用のライブラリも同じ用途で使える。
CommunityToolkit.Diagnostics.Guard
public static void SampleMethod(int[] array, int index, Span<int> span, string text)
{
Guard.IsNotNull(array);
Guard.HasSizeGreaterThanOrEqualTo(array, 10);
Guard.IsInRangeFor(index, array);
Guard.HasSizeLessThanOrEqualTo(array, span);
Guard.IsNotNullOrEmpty(text);
}
LINQは遅い?
「決して最速じゃない配列T[]」で書いたようにfor文でT[]
を色々するよりLINQが早い場合もある。
新しい.NETを使う
- SIMD化などで高速化するものがある
- .NET 7.0
- int/longの
Min()
/Max()
- int/longの
-
.NET 8.0
- int/long以外の
Min()
/Max()
Sum()
-
Order()
/OrderDescending()
- keyが値型の時の
OrderBy()
/OrderByDescending()
new Dictionary<K, V>(List<KeyValuePair<K, V>>)
- int/long以外の
- .NET 7.0
PLINQで並列処理する
- データ量がある程度多いならforで回すよりPLINQで並列化のほうが効果的
-
Parallel.For
とかでもいいけど…
-
LINQ高速化ライブラリを使う
-
SimdLinq
- SimdLinq - LINQをそのままSIMD対応して超高速化するライブラリ
- .NET 7.0~
- BurstLinq
-
NetFabric.Hyperlinq
- support for
Span<T>
,ReadOnlySpan<T>
,Memory<T>
andReadOnlyMemory<T>
- support for
間違った使い方をしない
-
Enumerable
で長さを確認するのにAny()
を使わない-
Length
/Count
/IsEmpty
プロパティを使う - いつも
Any()
つかうな、ではないのに注意。Count()
の代わりに推奨されるときも。 - CA1860: 'Enumerable.Any()' 拡張メソッドを使用しない
- アナライザーが教えてくれる
-
LINQ 高速化ライブラリ比較
- .NET8.0ではちょくちょく素のLINQが最速というパターンがある
- 単純なfor/foreachより早いケースも
プロファイル
ベンチマーク
BenchmarkDotNet
-
- 事実上の標準
-
BenchmarkDotNet templates
-
dotnet new benchmark
でテンプレ導入できる
-
-
benchly
-
VBench
-
BenchmarkDotNet Analyser (BDNA)
benchmark.net使用時注意
- 複数ランタイムで比較する
- .NET Frameworkと.NETの最新LTS、最新とか
- 内部処理が違うので同じコードでも差がでる
- 可能ならR2R・NativeAOTも比較
- 最新版だけでいいかも
- インライン化を抑制する
- 純粋な処理の差を見るため
[MethodImpl(MethodImplOptions.NoInlining)]
- Dynamic PGOを抑制する
- 純粋な処理の差を見るため
- .NET 8.0~
アプリprofiler
- dotnet-counters
- dotnet-trace
- 公式無料
- dotnet-countersより詳細
- Chromeのプロファイラやspeedscopeで可視化できる
- .NETのパフォーマンス計測ツール「dotnet-trace」の使い方 | Technology | KLablog | KLab株式会社
- dotnet-trace 診断ツール - .NET CLI - .NET | Microsoft Learn
- 公式無料
- Intel VTune
- AMD uProf
- AMD CPUのみ
- AMD μProf | AMD
- PerfCollect
- dotTrace
- 有料
- JetBrains
ReadOnlySpan<T>
+コレクション式でできる
ローカルstaticフィールドはvoid DoSomething(){
//できない
//static int[] arr = {1,2,3};
//staticと同じ扱い
ReadOnlySpan<int> arr = [1,2,3];
}
-
ローカルな
ReadOnlySpan<T>
変数をコレクション式で宣言するとstaticと同等になる -
読み取りが高速化
- メモリは減らない
-
sharplabで調べたら本当だった
- 現時点だと
__StaticArrayInitTypeSize=12_Align=4
っていう自動生成されたクラス内のstaticな構造体になってる
- 現時点だと
Span<T>
のコピーで長さの判定を入れると速くなる(.NET8)
[Peanut Butter]
if(spanA.Length == 10){
spanA.CopyTo(spanB);
}else{
spanA.CopyTo(spanB);
}
PRが通ってるので.NET 9.0では自動で内部的にこれになりそう。
newしない初期値・リセット
- 初期化・リセットのたびに
new
すると遅い - 代わりに空を示すプロパティ・メソッドが用意されてる
- わかりやすくなるメリットも
string.Empty
文字列 -
""
の代わり - 現状だとパフォーマンス面であんまり差はないかも?
- わかりやすさは上
Array.Empty<T>()
配列 - メモリアロケーション防止
Enumerable.Empty<T>()
コレクション - メモリアロケーション防止
- .NET8時点では
EmptyPartition<T>.Instance
っていうキャッシュされたゼロenumberableが返ってくるっぽい
ImmutableArray<T>.Empty
リスト -
List<T>.Empty
はない - けど、
ImmutableArray<T>.Empty
ならある
-
ReadOnlyCollection
にもある- .NET8以降
- FrozenSet / FrozenDictionaryにもある
Span<T>.Empty
Span<T> - Span<T>, ReadOnlySpan<T>, Memory<T>, ReadOnlyMemory<T>にある
[]
コレクション式で初期化 .NET8.0時点では、ケースバイケース。
-
T[]
-
Array.Empty<T>()
が呼ばれる 👍
-
-
List<T>
-
new List<T>()
と同じ
-
-
ImmutableArray<T>
-
ImmutableCollectionsMarshal.AsImmutableArray(Array.Empty<T>())
が呼ばれる
-
-
Span<T>
-
default(Span<T>)
と同じ
-
-
string
- 使用不可
- UTF-8 string
-
default(ReadOnlySpan<byte>)
と同じ
-
int[] array = []; //Array.Empty<int>();
List<int> list = []; //new List<int>();
ImmutableArray<int> im = []; //ImmutableCollectionsMarshal.AsImmutableArray(Array.Empty<int>());
Span<int> span = []; //default(Span<int>);
//string str = []; //error!
ReadOnlySpan<byte> u8str = []; //default(ReadOnlySpan<byte>);
半公式の高速化ライブラリ
.NET Community Toolkit
- コミュニティベースのライブラリだが、MSが開発していてMS Learnにリファレンスがある
- 以下がパフォーマンスに影響
- CommunityToolkit.Diagnostics
ThrowHelper
-
Guard
API - →インライン化阻害防止
- CommunityToolkit.HighPerformance
-
Span2D<T>
/Memory2D<T>
-
MemoryOwner<T>
/SpanOwner<T>
StringPool
ParallelHelper
-
- CommunityToolkit.Diagnostics
.NEXT
.NEXT (dotNext) is the family of powerful libraries aimed to improve development productivity and extend the .NET API with unique features. The project is supported by the .NET Foundation.
- dotnet公式リポジトリで管理されている
Microsoft.IO.RecyclableMemoryStream
System.IO.MemoryStreamに特化したプーリングライブラリ
実行時最適化
Tiered Compilation
P18
- 速度優先(QuickJIT, Tier 0)と最適化優先(Tier 1)に"実行時に"処理が分かれる
- .NET Core 3.0以降
- csproj or runtimeconfig.json or 環境変数で設定変更可能
- R2RでビルドするとTier1に
<TieredCompilation>false</TieredCompilation>
<TieredCompilationQuickJit>false</TieredCompilationQuickJit>
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops>
On-Stack Replacement
情報
.NET 8 で既定で有効になった Dynamic PGO について - Speaker Deck
.NET の最適化の罠? (インライン展開がされなかった理由) #C# - Qiita
GeneratedRegex
正規表現 -
ソースジェネレータを使う
GeneratedRegex
が.NET7以降に追加 -
RegexOptions.Compiled
と同じ用途なら置き換え推奨 -
NativeAOT Readyになる
-
起動が早くなる
-
処理も高速に
-
Trimが効くのでサイズが小さくなる
- ただし、ソース生成なので複雑な正規表現は逆に大きくなる
3rd party performance / optimization libs
Algorythm
- LibOptimization : LibOptimization is numerical optimization algorithm library for .NET Framework. / .NET用の数値計算、最適化ライブラリ
Cache
- LazyCache : An easy to use thread safe in-memory caching service with a simple developer friendly API for c#
SIMD
- HPCsharp : High performance algorithms in C#: SIMD/SSE, multi-core and faster
IL
- DistIL : Post-build IL optimizer and intermediate representation for .NET programs