Share
Embed
今日からできる!簡単 .NET 高速化 Tips -2024 edition-
C# / .NET における、パフォーマンス改善の Tips をお届けします。
これを見れば、効率良く 80 点を取ることができるようになるはずです!
Takaaki Suzuki
April 27, 2024
Transcript
63 slides
-
C# パフォーマンス勉強会 2024/4/27 (土) 鈴木 孝明 今日からできる! 簡単 .NET 高速化
Tips - 2024 edition - -
Name 鈴木 孝明 a.k.a @xin9le Work Application Engineer Award Microsoft
MVP (2012/7 -) Web Site https://about.xin9le.net About -
顧客への価値提供が最優先 「まず MVP (Minimum Viable Product) から始めよ」は真理 Done is better
than perfect. そのために見限るものは多い 仕様変更 / 機能削減はソフトウェア開発では茶飯事 その中でパフォーマンスは最も後回しにされがちなものの代表 よくある光景 -
1. ヒープアロケーションを避ける メモリの再利用とスタック領域の活用を徹底しよう 2. 非同期 I/O を使う CPU は高価なリソースなので、待ち時間を与えず使い切ろう 3.
計算効率を上げる 高効率アルゴリズムで単位時間あたりの計算回数を増やそう 高速化のポイント 3 選 -
.First() 好き過ぎ問題 運が悪いと 100 万要素の末尾にあるかもしれない 「インデックスの張り忘れ」と並ぶタイムアウト理由 (当社比 ループの中での線形探索 foreach (var
student in students) { // O(n) になるこのパターンが本当に多い…! // ラムダ式で変数キャプチャもされるし、良いことがない var score = scores.First(x => x.StudentId == student.Id); // 事前に Dictionary を作っておくだけで O(1) 検索になって爆速 var score = scoreByStudentId[student.Id]; } -
不必要に具象型を要求しない 高速化都合など、メソッド内に不可避な理由がある場合を除く ライブラリ層でもない限り、IE<T> など抽象的な型を使うのが吉 コレクションの型合わせ // 型合わせのための .ToArray() でメモリコピーを無駄に発生させる List<int>
values = [0, 1, 2, 3, 4]; DoSomething(values.ToArray()); // 型遊びしてないで引数を直せ! // 特段の理由がないのに具象型を要求する引数を作ってしまう static void DoSomething(int[] values) { foreach (var x in values) { } } -
動的なバッファ拡張を極力避ける .Add() しただけで「2 倍のメモリ確保 + 全要素コピー」が発生 もはや下手なコードより LINQ の方がずっと効率的 コレクションの初期容量を指定
int[] values = [0, 1, 2, 3, 4]; var list = new List<int>(capacity: values.Length); foreach (var x in values) { // 初期容量を指定しない場合、最後の要素を追加するときに // 2 倍の内部バッファ確保と全要素コピーが発生 list.Add(x * x); } 超大事! -
.Result / .Wait() はダメ。ゼッタイ。 本質的に 1 スレッドで実行できる処理に 2 スレッド使うことになる 開発フレームワークによってはデッドロックする可能性も
Sync over async public int DoSomething() { var result = CallDependencyAsync().Result; return result + 1; } public async Task<int> DoSomethingAsync() { var result = await CallDependencyAsync(); return result + 1; } -
.NET 8 何年にも渡る地道な改善で .NET Framework より何倍も速い 新規プロジェクトでは完全に一択 C# 12 C#
7.x 以降はパフォーマンスを意識した改善がたくさん 後方互換があるのでサッサと更新して最新の言語機能を使おう 最速の開発/実行環境 -
MemoryPack MessagePack for C# より速いバイナリシリアライザ C# のメモリ構造をほぼそのままバイナリデータに変換している MessagePack for C#
.NET 界最速の MessagePack シリアライザ SignalR の標準シリアライザとして採用されるくらい爆速 最速のシリアライザ -
ガベージコレクションの発動頻度を低減 GC の発動はパフォーマンスを瞬間的に大きく低下させる 特に Frame ベースのアプリ (Unity など) では気を配ると良い メンバーアクセスが高速
値型 : スタック領域にあるのでそのまま直アクセス 参照型 : ヒープ領域まで辿ってアクセス 構造体の優位性 -
高頻度で値のコピーが発生 引数 / 戻り値 / 変数への代入、関数呼び出し サイズの大きい構造体の場合、逆にコストになる 継承 / 多態ができない
現状 interface + 拡張メソッドを駆使するしかない 構造体の欠点 16 bytes 程度を目途に Defensive Copy -
性能劣化に直結するコピーを抑止 構造体のサイズが大きいときに検討したい 参照渡し (ref / in / out) 常に 気を配る
static void Main() { var a = 1; ref var d = ref PassThrough(ref a); d = 2; } static ref int PassThrough(ref int b) { ref var c = ref b; return ref c; } -
Defensive Copy の発生 Readonly 保証のためにコピーを作って関数 call をする場合がある コピー回数削減どころか、逆にコピー回数が増えることも in (ref
readonly) 引数の難しさ // Bar() がフィールドを // 書き換えていない保証がない struct Foo { public readonly int X; public void Bar(){} } // せっかく参照渡ししたけど… void Call(in Foo x) { var a = x.X; // コピーなし x.Bar(); // 防衛的コピー x.Bar(); // 防衛的コピー } -
Defensive Copy の抑止 全フィールドが書き換えられないことを保証 readonly struct (C# 7.2) 可能な限り つける
// 書き換えていない保証をする readonly struct Foo { public readonly int X; public void Bar(){} } // 読み取り専用の参照渡しが効果を発揮 void Call(in Foo x) { var a = x.X; // コピーなし x.Bar(); // コピーなし x.Bar(); // コピーなし } -
Defensive Copy の抑止 関数内でフィールドの書き換えがないことを保証 struct Foo { public int X;
public int Y; public readonly int Add() => X + Y; public int Sub() => X - Y; } readonly 関数メンバー (C# 8.0) // 関数単位で挙動が決まる void Call(in Foo x) { x.Add(); // コピーなし x.Sub(); // 防衛的コピー } 可能な限り つける struct でのみ 適用可能 -
コピー抑止 構造体の拡張メソッドを作るときには積極的に参照渡しにしたい 防衛的コピー周りの理由で Generics における in 引数にはできない 参照渡しの拡張メソッド // OK
public static void Foo(ref this int value){} public static void Foo2(in this int value){} // struct 制約があれば ref 引数は OK public static void Foo3<T>(ref this T value) where T : struct {} -
参照渡しの演算子 overload コピー抑止 in 引数のみ認めらているので防衛的コピーに注意 readonly struct Complex { public
double R { get; } public double I { get; } public Complex(double r, double i) => (this.R, this.I) = (r, i); // in 引数が認められるようになった public static Complex operator +(in Complex x, in Complex y) => new Complex(x.R + y.R, x.I + y.I); } -
Good-bye 匿名型 / Tuple LINQ みたいな局所的な利用 / 多値戻り値には最適 言語機能にも統合されているので書き心地も最高 ValueTuple
// ValueTuple : スタック利用 var q1 = collection.Select(x => (value: x, power: x * x)); // 匿名型 : ヒープ利用 var q2 = collection.Select(x => new { Value = x, Power = x * x }); // Tuple : ヒープ利用 (名前を付けられない) var q3 = collection.Select(x => Tuple.Create(x, x * x)); -
連続したメモリ領域を直参照 ザックリ言うと、ポインタアクセスを managed に行う仕組み 配列や文字列をメモリ再確保なしに一部分だけ参照できる Span<T> const string text =
“ab123cd”; var span = text.AsSpan(2, 3); var sub = text.Substring(2, 3); a b 1 2 3 c d 1 2 3 text sub span -
ゼロ初期化されたメモリ領域に直書き これまで unsafe を使わないとできなかった最適化 string 初期化時のメモリコピーを削減できるので高速 String.Create static string ToBitString(byte
value) => string.Create(8, value, (buffer, state) => { const byte on = 0b_0000_0001; for (var i = 0; i < buffer.Length; i++) { buffer[buffer.Length - 1 - i] = ((state >> i & on) == on) ? '1' : '0'; } }); static void Main() { byte b = 0b_0110_1011; var s = ToBitString(b); // s : "01101011" } -
スタック領域に配列を安全に確保 利用期間が短く、小さなサイズの配列を扱いたいときに活躍 unmanage 型限定 / 非同期メソッドの中では利用不可 (※ 式中では OK) stackalloc
// byte 配列に乱数を格納 Span<byte> buffer = stackalloc byte[64]; Random.Shared.NextBytes(buffer); // ファイルに書き込み using var file = File.OpenWrite(path); file.Write(buffer); -
Box 化の雨あられ Generics のない C# 1.0 時代の黒歴史 値型を扱うと特に遅くて全く使い物にならない (10 倍以上遅い)
今すぐ殲滅せよ! 変更先は System.Collections.Generics もう使っている人はいないと信じたい 産廃 : System.Collections -
enum : 値型 / System.Enum : 参照型 System.Enum に値を代入すると box
化する C# 7.3 以降では Generics の Enum 制約を使うことで回避できる System.Enum 型の罠 // 引数に渡したときに box 化 static void Foo(Enum value) {} // Generics 制約を使うと box 化しない static void Foo<T>(T value) where T : struct, Enum {} -
interface に型変換すると Box 化 ちょっと気を抜くとすぐにヒープ送りにされる Generics を使う or 脱仮想化で box
化を回避 構造体を interface 型として使う // 引数で box 化が発生して遅い static void Interface(IDisposable x) => x.Dispose(); // .NET Core 2.1 以降の場合 // 脱仮想化という最適化がかかる static void NonGeneric(X x) => ((IDisposable)x).Dispose(); // 安定して高速 static void Generic<T>(T x) where T : IDisposable => x.Dispose(); -
ヒープ確保と利便性とのトレードオフ LINQ やイベントコールバックなどで高頻度で利用 よく通るコードパスでは敢えてキャプチャしない書き方も検討 変数キャプチャ = 隠しクラス // 変数 id
が FirstOrDefault にキャプチャされている static async Task<Person> GetAsync(int id) { var people = await QueryFromDbAsync(); return people.FirstOrDefault(x => x.Id == id); } -
拡張メソッドを自作すると便利 // ラムダ式の中で利用したい変数を state として別途渡すことでキャプチャを回避 public static T? FirstOrDefault<T, TState>(
this IEnumerable<T> source, TState state, Func<T, TState, bool> predicate) { foreach (var x in source) { if (predicate(x, state)) return x; } return default; } // こんな感じで使う .FirstOrDefault(id, static (x, state) => x.Id == state); -
意図しない変数キャプチャを防止 ローカル関数のキャプチャは高速だけど若干のペナルティはある 静的ローカル関数 (C# 8.0) // ローカル関数 (C# 7.0 以降)
static void Main() { var a = 3; var b = 4; var result = LocalFunction(a); int LocalFunction(int x) => x + b; // b をキャプチャしてる } // 静的ローカル関数 (C# 8.0 以降) static void Main() { var a = 3; var b = 4; var result = LocalFunction(a); static int LocalFunction(int x) => x + b; // コンパイルエラー } -
キャプチャ防止 + デリゲートキャッシュ static がないと不安になるくらい溺愛すべし! 静的ラムダ式 (C# 9.0) var x
= 10; var result = Enumerable.Range(0, 10) .Where(x => x % 2 is 0) .ToDictionary(x => x, y => x * x); var x = 10; var result = Enumerable.Range(0, 10) .Where(static x => x % 2 is 0) .ToDictionary(static x => x, static y => x * x); static の加護 でエラーに 気付き難い バグ -
変数キャプチャの外出し static void Main() { // 途中で return していても //
関数の最初で隠しインスタンスが // 生成されてしまう var id = 0; if (true) return; // このコードは通らないのに理不尽! Foo(x => x == id); } static void Main() { // 隠しインスタンスの生成なし var id = 0; if (true) return; CallFoo(id); } // 変数キャプチャされるコードパスを // 別関数にすることで効率化 static void CallFoo(int id) => Foo(x => x == id); -
async の正体は State Machine コンパイラがひっそりと class / struct を作って実現している await
する必要がないなら async を積極的に消す 不要な async 修飾子 // 隠れインスタンスが生成される static async Task DoAsync() => await Task.Delay(300); // 推奨 : これは全く無駄がない static Task DoAsync() => Task.Delay(300); // async だけ付いてるのも無駄 // 警告 (CS1998) を無視しない static async Task DoAsync() { // await しなくても動くけど } -
I/O 待ち = CPU が暇してる CPU は最も高価な計算リソース 余裕を持たせ過ぎるではなく十分に「使い切る」ことが重要 非同期 I/O
を利用 // RSS を取得している間 CPU が暇 var url = "http://blog.xin9le.net/rss"; var node = XElement.Load(url); // これなら通信待ちを別処理に有効活用できる var url = "http://blog.xin9le.net/rss"; var client = new HttpClient(); var rss = await client.GetStringAsync(url); var node = XElement.Parse(rss); -
処理時間を大幅に短縮できる それぞれの処理が独立していることが前提 並列処理を積極的に検討 // 直列 await HogeAsync(); await FugaAsync(); await
MogeAsync(); // 並列 var t1 = HogeAsync(); var t2 = FugaAsync(); var t3 = MogeAsync(); await Task.WhenAll(t1, t2, t3); t t Parallel 型の 利用も検討 -
Task / IValueTaskSource をラップした構造体 await を跨がない限り内部に持つ Task 型を生成しない 原則 ValueTask
で統一するくらいの気持ちで積極的に使う await を通らない可能性を考慮 他人に主導権があるコードはどう実装されるか分からない ex.) abstract / virtual / interface メソッド ValueTask -
CoreFx は ValueTask の API が乏しい .WhenAll / .WhenAny /
.Lazy など、必須級の不足 API を補う .AsTask() するよりもヒープアロケーションを抑えられて高速 ValueTaskSupplement (by Cysharp) // .WhenAll をこんな感じでエレガントに書ける var (foo, bar) = await (FooAsync(), BarAsync()); // 遅延実行もできる (AsyncLazy とか不要) var lazy = ValueTaskEx.Lazy(async () => await Task.Delay(300)); await lazy; https://github.com/Cysharp/ValueTaskSupplement -
Static Type Caching public static class FastEnum { public static
IReadOnlyList<T> GetValues<T>() where T : struct, Enum => Cache<T>.Values; // キャッシュを直接参照 // 静的 Generics 型のフィールドにキャッシュを持つ private static class Cache<T> where T : struct, Enum { public static readonly T[] Values; static Cache() => Values = (T[])Enum.GetValues(typeof(T)); } } T 型ごとに 別の型扱い -
呼び出しコスト ≒ 0 Dictionary<Type, Xxx> から Lookup するよりもずっと速い BenchmarkDotNet が「計測できない」と音を上げるレベル
静的コンストラクタで事前計算 静的コンストラクタはスレッドセーフが保証されている ロックフリーにできるので、呼び出しコスト低減に大きく寄与 Static Type Caching -
.NET Core 時代は配列も再利用 頻繁に発生するメモリの確保と破棄はパフォーマンス悪化のもと .NET Core / .NET Standard (2.1
以降) では積極的に活用すべし ArrayPool<T> var array = ArrayPool<int>.Shared.Rent(50); try { // レンタルした配列を使って何かする } finally { ArrayPool<int>.Shared.Return(array); } -
DefaultInterpolatedStringHandler の活用 Append しかできないが、アロケーションなしで高効率 構造体版の StringBuilder string result; var builder
= new DefaultInterpolatedStringHandler(0, 0); try { builder.AppendLiteral("現在時刻 : "); builder.AppendFormatted(DateTimeOffset.Now); } finally { result = builder.ToStringAndClear(); } // 現在時刻 : 2024/04/14 23:17:25 +09:00 -
内部バッファを Span<T> で直接触る virtual call や Enumerator を介したループを排除 境界値チェックも外れる List<T>
の高速なイテレーション List<int> list = [0, 1, 2, 3, 4]; var span = CollectionsMarshal.AsSpan(list); foreach (var x in span) { // Do something with x } Span<T> を 取り出し -
Frozen Collection 初速を犠牲にして Read 処理を高速化した特化コレクション NuGet で Backport 版が提供されているので .NET
Fx でも利用可能 NuGet Gallery | System.Collections.Immutable NO MORE yield return; IEnumerator<T> を構造体で自作することでアロケーション削減 C# がダックタイピングを採用していることを利用したハック 他にもまだまだ色々… -
1. ヒープアロケーションを避ける メモリの再利用とスタック領域の活用を徹底しよう 2. 非同期 I/O を使う CPU は高価なリソースなので、待ち時間を与えず使い切ろう 3.
計算効率を上げる 高効率アルゴリズムで単位時間あたりの計算回数を増やそう 高速化のポイント 3 選 (再掲)