この記事は「C# Advent Calendar 2024」に参加しています。シリーズ2の12月13日の内容です。
C# のパフォーマンス高速化に関することを学習した内容です。下記のような Tips があったので、個人的にサンプルコードを書いたり調べたりして学習した内容になります。(内容は加筆あり未記載もあり)
ポイントは「ソフトウェアは速くて困ることはない」
- ヒープアロケーション(割り当て)を(避けれるところは)避ける
- メモリの再利用とスタック領域の活用を徹底
- 非同期 I/O を使う
- CPU は高価なリソースなので、待ち時間を与えず使い切る
- 計算効率を上げる
- 高効率アルゴリズムで単位時間あたりの計算回数を増やす
C# におけるメモリ管理方法は値型「スタック」と参照型「ヒープ」の2種類があります。一般的にヒープの領域の "確保" はスタックと比べると重たい処理になります。
別の言い方でのポイント:
- 実装時からやることやって損しない
- よく通るコードパス/共通ライブラリは特に目を向ける
- 速いコードを書く癖をつける
- 練習と思って取り組むほうがいい
読むときのための用語メモ:
値型の利点は、スタックに値を置く(ヒープを使わない)ことによる性能向上です。ボックス化(ヒープ領域の確保)が起きると、値型の利点は失われます。
パフォーマンスに影響の大きい絶対に守るべきこと
コレクションの型あわせ
List 型を配列型に変換する。不可避な理由がないなら無駄なメモリコピーが発生するため適切ではない。
List<int> values = [1, 2, 3, 4]; DoSomething(values.ToArray()); void DoSomething(int[] values) { ... }
ただし、後述の AsSpan()
(Span<T>
)は、逆に最適化になることがあります。
int[] array = { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; foreach (var i in array.AsSpan()[2..6])) { ... }
ループ内の線形探索
コレクションのデータ数を考えて、たくさんデータがあるときはどうすれば取得が速いのか。First は線形探索なので効率とかじゃないよね。
foreach (var sample in samples) { // BAD var bad = sample.First(p => p.id == id); // GOOD var good = sample[id]; }
線形探索……先頭から順番に比較を行い、見つかれば終了する。
コレクションの初期容量を指定
動的なバッファ拡張は、なるべく避ける。
初期容量 (capacity) を指定しなければ、最後の要素を追加するときに2倍の内部バッファ確保と全要素のコピーが発生するため非効率。
パフォーマンスに悪影響があるのは、 Count 3 のタイミング。コレクションの容量を上げるために、既存コレクションから新しいコレクションにコピーが発生する。
int[] values = [1, 2, 3, 4]; var list = new List<int>(capacity: values.Length / 2); foreach (var x in values) { list.Add(x); Console.WriteLine($"Capacity: {list.Capacity}, Count: {list.Count}"); }
Capacity: 2, Count: 1 Capacity: 2, Count: 2 Capacity: 4, Count: 3 Capacity: 4, Count: 4
Result と Wait はやめる
スレッドの処理をブロックするので、スレッドが占有される。想像しているよりもデッドロックしやすい。
結果的にリソース効率が低下する。
public int DoBadSomething() { var result = CallAsync().Result; return result + 1; } public async Task<int> DoGoodSomething() { var result = await CallAsync(); return result + 1; }
本質的ではないけど、デッドロックを避ける対策の一例。処理するスレッドが変えて待つけど、スレッドが効率的に利用されているとは言えない。
public int DoBadSomethingSafe() { var result = CallAsync().ConfigureAwait(false).GetAwaiter().GetResult(); return result + 1; }
環境
最速の開発/実行環境
最新の .NET を利用しておくこと。
.NET Framework よりも何倍も速い。C# 7.x 以降は、パフォーマンスを意識した改善が多い。基本的には後方互換があるので更新して最新の言語機能を使う。
- MemoryPack
- MessagePack for C#
- FastEnum
構造体
構造体の優位性
ガベージコレクションの頻度を下げる
- GC の実行時はパフォーマンスが瞬間的に大きく低下する
- 特に Unity のようなフレームベースのアプリは気をつける
メンバーへのアクセスが高速になる
- 値型:スタック領域にあるので、そのまま直にアクセス可能
- 参照型:ヒープ領域から値をたどってアクセスする
思ったよりも構造体の挙動は、言語仕様の知識が必要なところがあります。いくつかの作法を知っていることで、効率化される感じです。
構造体の欠点
高頻度で値のコピーが発生する
- 引数/戻り値/変数への代入/関数呼び出し
- サイズの大きい構造体の場合は、逆にコストになる(16 bytes 目途)
継承/多態ができない
- 現状は interface + 拡張メソッドを駆使する
インターフェース以外の継承ができない理由を考えると、struct は「メモリをマッピングした構造だから」という考え方がわかりやすいと思います。裏付けとして
StructLayout
はデフォルトだと Sequential です。(class のように)データの並びをアライメントにあわせた最適化はされません。値型として、固定サイズを守ることが優先されると考えることができます。
インターフェースを付与できるのは、メモリ構造に影響を与えずに多様性を追加するだけだからです。もともと、C言語の構造体は完全な値型でメソッドを作ることができませんでした。しかし、C#の構造体は最初から、値型としての特性を持ちつつメソッドを持てます。同じ理由だと考えています。
参照渡し
性能劣化に直結するコピーを抑制する。構造体のサイズが大きいときは検討する。
具体的にすると以下の例だと a
は PassThrough
メソッドに参照を渡していて a
のメモリ上のアドレスが共有される動きになります。
さらに ref var b
は a
を参照しているから a
と b
は同じ値であり、b
を変更すると a
の値も書き換わる。
private void TestRef() { var a = 1; ref var b = ref PassThrough(ref a); // b を変更すると a にも影響する b = 2; Console.WriteLine(a); // output 2 } private ref int PassThrough(ref int b) { ref var c = ref b; return ref c; }
ref 戻り値は C# 7.2 から利用可能になったので 2017 年末からの機能です。また ref readonly
のような戻り値の書き方もできる。(が、後述のようにパフォーマンスの観点では注意が必要)
このほかにも「参照渡しだけど読み取り専用」なら in
を使えるので、大きい構造体を渡すときはパフォーマンス改善につながる引数の指定になる。
従来の out
は「参照渡し」。あるメソッドの中で、メソッド外の変数の値を書き換えることができるのに対して in
は「入力用」を明示します。
補足として、構造体ではなくクラスの場合はあまりパフォーマンスの改善に効果がない。理由は参照型なのでポインタのような動きをするヒープ領域を利用しているため、データそのものがコピーされることがない。
ref readonly/in の難しさ
Defensive Copy が発生してしまう。
引数の場合は in
で、戻り値の場合は ref readonly
として、この問題がどちらの場合でも発生してしまう。(後述の Generics は別)
プログラムの都合として、呼び出し側は、メソッドの内部で値が書き換わっていないことを保証する方法を持たないので、あらかじめメソッドを呼んだ時点で無条件にコピーを作ってしまう動きのことを Defensive Copy という。
対策:
- 構造体を readonly にしておく
- 関数内でフィールドの書き換えがないことを保証しておく
readonly struct ReadOnly { public readonly int X; } struct Foo { .. public readonly int Add() => X + Y; // GOOD public int Sub() => X - Y; // BAD コピー発生 .. }
コピー抑制
構造体の拡張メソッドを作るといは、参照渡しにする。Generics における in 引数はできない。
これはコンパイラが型は値型か参照型かを判断できていないので、仕様として in 引数にできない。(参照型のときは参照渡しである必要がない)
static void Process<T>(in this T data) // エラー { } static void Process<T>(ref this T data) : where T : struct // OK { }
in を使った overload をするときも、struct 自体を readonly にしておく。
readonly struct Complex { public double R { get; } public double I { get; } // in 引数が認められるようになった public static Complex operator +(in Complex x, in Complex y) => new Complex(x.R + y.R, x.I + y.I); }
というより、基本的には in は readonly な struct に使うもの、という考え方になるはず。
ValueTuple
匿名型の歴史みたいなもの。LINQ といった局所的なところで使うとよいもの。ValueTuple は、値を変更できる構造体として考えておく。
コレクションからコレクションを選択する動作なうえ、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));
Span
連続したメモリ領域を直接参照できる。unsafe じゃない managed な状態でアクセスできる。配列や文字列から、メモリの再確保をしない部分的な参照ができる。
const string text = “ab123cd”; var span = text.AsSpan(2, 3); var sub = text.Substring(2, 3); // 123
中身を read するだけなら span を上手く活用することで、無駄なコピーを避けて高速化させることができる。
でも、内部的にコピーが発生しているかどうかなんて、言語の理解が深まらないと最適化できない。
string.Create
string.Create は Span
以下は byte
型の値を文字列に変換する(例えば、文字列 "01101100" にする)もので buffer が span なので StringBuilder のような中間変数が存在しない。とてもテクニカルな技術だと思った。
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'; } });
ボックス化を回避する
古い産廃を使ってはいけない
- System.Collections
- ダメ絶対
- System.Collections.Generics
- 変更先
非 generics コレクションなので使わないほうがいい。名前空間が出ていたら注意したほうがいい。
System.Enum の問題点
値を代入するとボックス化してしまう。
// ボックス化する static void Foo(Enum value) {} // Generics 制約をするとボックス化しない static void Foo<T>(T value) where T : struct, Enum {}
構造体を interface 型として扱う
正直、構造体の高速化テクニックはマニアックだと思う。
struct は object するポイントとしてインターフェースを経由すると Box 化が発生してしまう。が、そこで generics を使うと脱仮想化になる。
// 引数で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();
一番よく言われるようなのが、比較の equal 関係なので、そういったところは record を使うことでも回避できると思います。
知らない間に作られるインスタンスに気を配る
クロージャの変数生成
変数のキャプチャー = 隠しクラスの生成ということになります。なので、ヒープ確保と利便性はトレードオフの関係です。
具体的には、ラムダ式や匿名メソッド内で外部スコープの変数を参照するとき、変数がクロージャにキャプチャされることを指しています。この「クロージャ」はヒープ上にオブジェクトとして生成されることになります。
以下は id がキャプチャされる例です。Visual Studio では "=>" の部分にカーソルを合わせると「キャプチャされているかどうか」を知ることができます。
static async Task<Person> GetAsync(int id) { var people = await QueryFromDbAsync(); return people.FirstOrDefault(x => x.Id == id); }
なので、フィルタリング部分条件をラムダ式の外に置く、拡張メソッドにして自作をしたりすることになる。
static async Task<Person> GetAsync(int id) { var people = await QueryFromDbAsync(); foreach (var person in people) { if (person.Id == id) return person; } return null; }
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; }
また、ラムダ式や匿名メソッドは外の変数をキャプチャできてしまうため、変数名として x や y や i を使ったつもりが、外の変数をキャプチャしてしまってバグになることがあります。そういうときは、静的ラムダ式を使うことで、外部スコープの変数が入り込まないように(ある種)明示することになります。
var x = 10; var result = Enumerable.Range(0, 10) .Where(static x => x % 2 is 0) .ToDictionary(static x => x, static y => x * x); // 警告になる
時間とリソースを有効に使う
非同期 IO を使う
await している間にできることがある。
// これなら通信待ちを別処理に有効活用できる var url = "...."; var client = new HttpClient(); var rss = await client.GetStringAsync(url); var node = XElement.Parse(rss);
並列処理
それぞれの処理が独立しているなら、Parallel にすると処理時間を短縮できるかも。
var t1 = LoadFile1Async(); var t2 = LoadFile2Async(); var t3 = LoadFile3Async(); await Task.WhenAll(t1, t2, t3);
キャッシング
まず考え方として Enum.GetValues はリフレクションを内部的に利用しているため、比較的コストが大きい操作になっています。
なので、この処理を繰り返し実行することは避けたい。そこで、キャッシングを利用することで、一度だけ値を取得して、以降は保存された値を使うことでパフォーマンスを上げる。
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)); } }
ただし、キャッシュするサイズが大きくなるとメモリ消費が大きくなる。キャッシュされた値が変更される場合があると、キャッシュの無効化や更新が必要になるのでよくない。
List の高速イテレーション
コレクションのデータを Add/Remove/Clear などの操作をせずに、大量のデータにアクセスする場面では Span が有効なことがある。リストを変更しないことは重要。
List<int> list = [0, 1, 2, 3, 4]; var span = CollectionsMarshal.AsSpan(list); foreach (var x in span) { // Do something with x }
Frozen Collections
Frozen Collection はコレクションを作成した後は変更ができません。スレッドセーフでマルチスレッド環境で安全に利用できるのも特徴。
データが固定化されたことで、検索が最適化されています。Contains なども高速化。
単純なことなので、Frozen できることを覚えていれば OK だと思います。
var data = new Dictionary<int, string> { { 1, "One" }, { 2, "Two" }, { 3, "Three" }, }; var frozenData = data.ToFrozenDictionary();
構造体は yield return しない
これもおそらく構造体を利用することでヒープアロケーションを回避するテクニックだと思います。値型なので、キャッシュ効率が挙がって、頻繁なイテレーション部分で性能を向上させるテクニックのはずです。
foreach や LINQ は GetEnumerator を使ってイテレーションを開始するので、構造体が返されるときは、ここまでの例のようにインターフェース型 IEnumerator<T>
として扱われると、暗黙的にボクシングが発生する効率の悪さもある。
yield return は状態マシンなので言わんとするところは同じはず。
class Program { static void Main() { var range = new RangeEnumerable(1, 10); foreach (var num in range) { Console.WriteLine(num); } } }
public struct RangeEnumerable : IEnumerable<int> { private readonly int _start; private readonly int _end; public RangeEnumerable(int start, int end) { _start = start; _end = end; } public Enumerator GetEnumerator() => new Enumerator(_start, _end); // 明示的に IEnumerator<int> を使いたい場合 IEnumerator<int> IEnumerable<int>.GetEnumerator() => GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); // 内部構造体で IEnumerator<int> を実装 public struct Enumerator : IEnumerator<int> { private readonly int _end; private int _current; public Enumerator(int start, int end) { _current = start - 1; _end = end; } public int Current => _current; object IEnumerator.Current => Current; public bool MoveNext() { if (_current < _end) { _current++; return true; } return false; } public void Reset() => throw new NotSupportedException(); public void Dispose(){} } }
補足:C# のコンパイラは、特定のインターフェース型を直接指定しなくても、対応するメソッドやプロパティが存在していれば、コードを正しく動作させます。これは型が厳密にインターフェースを実装している必要が無いという点で "duck typing" と呼ばれるものです。