はじめに
C#を使う際に、今日から使えそうなパフォーマンス改善につながる小ネタを10個まとめてみました。
BenchmarkDotnetを使ってベンチマーク測定したコードは下記においてあります。
※他にもこんなのあるぜっていうのは是非教えて頂けると幸いです🙇♂️
1. Capacityを設定する
System.Collections.Generic
に定義されているListなどのクラスはコンストラクタを呼び出す際にCapacityを設定することで無駄なアロケーションを削減できる可能性があります。
Listなどは内部に配列を持っており、Addなどの操作によってその配列サイズに収まらない要素数になった際に新しく配列を確保します。(現状の配列サイズ x 2サイズの配列を確保)
必要な要素数が明確な場合はCapacityを設定して無駄なアロケーションを避けるようにすることでパフォーマンス改善が見込めます。
サンプルコード
// default_capacityが4なので初回のAdd時に4, 次超えたら8と配列を確保していくので無駄が多い
var list = new List<int>();
// 配列の初期サイズが10となるので、10個までの要素追加に対しては追加のアロケーションなしで動く
var list = new List<int>(10);
CapacityBenchmark.csの測定結果
Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
WithCapacity | 244.1 μs | 95.06 μs | 5.21 μs | 124.7559 | 124.7559 | 124.7559 | 391 KB |
WithoutCapacity | 355.0 μs | 16.99 μs | 0.93 μs | 285.6445 | 285.6445 | 285.6445 | 1,024 KB |
2. StringBuilderを使う
文字列連結にはStringBuilderを使用した方が効率が良い場合があります。
C#のStringはreadonlyなので、文字列を連結した際には新しい文字列分のヒープアロケーションが実行されます。
そのため、複数回文字列を連結する際には、StringBuilderを使用することでパフォーマンス改善が見込めます。
StringBuilderの内部実装を詳しく解説された「A deep dive on StringBuilder」の記事がすごくわかりやすいので参考にしてください。
サンプルコード
var stringBuilder = new StringBuilder(10); // これもcapacity設定できるので文字列サイズがわかっている場合は設定するとより良い
stringBuilder.Append("Hello");
stringBuilder.Append(", ");
stringBuilder.Append("World");
stringBuilder.Append("!");
StringBuilder.csの測定結果
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
UseString | 8,125.0 ns | 3,294.22 ns | 180.57 ns | 29.5258 | 121 KB |
UseStringBuilder | 832.4 ns | 84.44 ns | 4.63 ns | 0.5903 | 2 KB |
3. structを使う
小さいデータサイズのものを表現する際にはstructを使うことでヒープアロケーションを避けることができます。
下記のリンクの「構造体のデザイン」でMicrosoft様がDO, DONTを明記してくれているので、構造体の使い方あまりわからんって方はこの辺りはチラッと見てみるのおすすめです。
また、Cysharpの河合さんによる「Understanding C# Struct All Things」も非常に参考になるのでおすすめです。
StructBenchmark
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
UserDataClassBenchmark | 5.2877 ns | 0.7380 ns | 0.0405 ns | 0.0077 | 32 B |
UserDataValueBenchmark | 0.0035 ns | 0.0166 ns | 0.0009 ns | - | - |
4. structをreadnlyにする
可能な限りstructをreadonlyにすることで、防衛的コピーを避けることができます。
防衛的コピーについては次のブログなどを参考にしてください。
readonlyで保持された構造体のメソッドを呼び出した際に、普通の構造体のままでは内部で値が書き換えられない保証ができません。そのため、構造体をまるまるコピーしてそのコピーしたものに対してメソッドを呼び出すことで、保持していた構造体の値が変わらないことを保証する仕組みのことです。
ただし、この丸々コピーにコストが発生するので、readonlyにすることでこの防衛的コピーが発生しなくなります。
防衛的コピーが行われている様子をILの世界から解説してくださっている記事を見つけたので気になる方はそちらを参考にしてください。
防衛的コピーのサンプルコード
public struct Huga {
public void Say();
}
public class Hoge {
private readonly Huga readonlyHuga;
private Huga huga;
public void Say() {
// HogeからしたらreadonlyなreadonlyHugaがHuga.Sayの中で書き換えられない保証ができない
// そのためreadonlyHugaを複製して、複製した方に対してSayを呼び出す(これならreadonlyHugaは書き変わらないよね!)
readonlyHuga.Say();
huga.Say();
}
}
ReadonlyStructBenchmark.cs
Method | Mean | Error | StdDev | Median | Allocated |
---|---|---|---|---|---|
SimpleStructCall | 0.0064 ns | 0.1138 ns | 0.0062 ns | 0.0067 ns | - |
ReadonlyStructCall | 0.0029 ns | 0.0631 ns | 0.0035 ns | 0.0019 ns | - |
5. IEquatableを実装する
等価性判定のためのメソッドを提供するIEquatableを実装することで、特に構造体だとパフォーマンス改善が期待できます。
C#の構造体は何もオーバーライドしない状態だと内部的にはリフレクションを使用して全てのフィールド変数の等価性を判定しています。
そのため、結構遅い。
IEquatableを実装することで、等価性判定のメソッドをオーバーライドすることができ、一部の箇所でパフォーマンス改善が見込めます。
IEquatableも下記のサイトがすごくわかりやすく解説してくださっているので参考にしてください。
EquatableForStruct.cs
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
SimpleStruct | 397.019 ns | 29.1086 ns | 1.5955 ns | 0.0496 | 208 B |
EquatableStruct | 2.867 ns | 0.1398 ns | 0.0077 ns | - | - |
6. boxingを避ける
boxing(値型を参照型でラップすること)を避けるというのは当たり前かもしれませんが、意外な箇所でboxingされていたりします。
例えば文字列補完を使った時など。
※余談ですが、String Interpolationに関してはC#10.0、.NET6.0でパフォーマンス改善が入っているのでそちらも読んでみると面白いです。
サンプルコード
int number = 10;
string message = $"number: {number}"
// 下記のようにコンパイルされる
// string.Format("number: {0}", number);
// string.Format(string, object);とされるので、引数のnumberはbox化される
こういう場合はintとして渡すのではなくstringとして渡すことでbox化を避けることができます。
int number = 10;
string message = $"number: {number.ToString()}";
BoxingBenchmark.cs
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
BoxingToString | 50.95 ns | 2.131 ns | 0.117 ns | 0.0134 | 56 B |
NotBoxingToString | 13.59 ns | 0.679 ns | 0.037 ns | 0.0076 | 32 B |
7. ラムダ式をローカル関数に
ラムダ式はActionなどのクラスを内部的に生成しますが、ローカル関数は単にそのクラスのstaticメソッドとして扱われます。
そのため、可能であればローカル関数を使用することで無駄なアロケーションを避けることができ、パフォーマンス改善が見込めます。
※下記は2021/10/31追記
ただし、ローカル関数をさらにActionとして引数に渡す場合などは、むしろパフォーマンスの低下に繋がるので注意が必要です。
LocalFunctionBenchmark.cs
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
LambdaExpression | 1.3475 ns | 5.4290 ns | 0.2976 ns | - | - |
LocalFunction | 0.0000 ns | 0.0000 ns | 0.0000 ns | - | - |
LambdaToAction | 2.5312 ns | 2.6375 ns | 0.1446 ns | - | - |
LocalFunctionToAction | 13.4936 ns | 17.6236 ns | 0.9660 ns | 0.0153 | 64 B |
8. IEqualityComparerを渡す
DictionaryなどのコンストラクタはIEqualityComparerを受け取ることができます。これを渡すことで、内部で等価性を判定する際の処理をカスタマイズすることができます。
例えばユーザ情報を管理している際にユーザ固有のIdが存在し、その比較のみでユーザ情報の等価性が判定できる場合はその処理を定義したIEqualityComparerを実装しコンストラクタに渡すことで、無駄な等価性のための処理を省くことができパフォーマンスの改善に繋がる場合があります。
EqualityComparer.cs
Method | Mean | Error | StdDev | Allocated |
---|---|---|---|---|
GetAddress | 16.381 ns | 0.3868 ns | 0.0212 ns | - |
GetBetterAddress | 9.408 ns | 1.0257 ns | 0.0562 ns | - |
9. Conditionalを使って本番ビルドに含めない
開発中のログ出力は重要ですが、本番ビルド時にはログ出力は含めたくないと思います。(セキュリティ的な懸念や、パフォーマンス的な懸念などにより)
その際に#if-#endifで囲うのも方法ですが、Conditional属性を使うことでコードの見通しを保ちつつ本番ビルドから除外することができます。
// DEBUG_MODEが定義されているときだけこのメソッドは呼び出される
[Conditinal("DEBUG_MODE")]
public static void Log(string message) {
Console.WriteLine(message);
}
10. LINQ使わない
※LINQを否定するものではありません(LINQの機能や書き心地は大好きです)
LINQはあるシーケンスに対する処理を書くことができる機能として優れていますが、内部的にはIterator用のクラスを生成していたり、IEnumerableに対するオーバーロードしかなくT[]などをわざわざIEnumerableとして扱って操作する箇所があるので普通に書くよりはどうしても遅くなってしまいます。
結論としてケースバイケースかと思いますが、究極にパフォーマンスを求める必要がある箇所では「使わない」という選択肢もありかと思います。
※2021/10/31追記
また、LINQにはToArrayやToListというメソッドが用意されており、これを呼び出すことで即時評価を行なった結果が取得できます。
ただし、foreachなどの処理を行うためだけなら、ToArrayやToListを呼び出す必要はない(LINQのメソッドの返り値は基本的にIEnumerable<T>
)ので、遅延評価or即時評価の話も理解した上で必要な箇所だけ呼び出すようにしましょう。
LinqVsPureBenchmark.cs
Method | Mean | Error | StdDev | Allocated |
---|---|---|---|---|
Pure | 4.777 μs | 0.0661 μs | 0.0036 μs | - |
ForEach | 4.524 μs | 0.1400 μs | 0.0077 μs | - |
Linq | 38.462 μs | 1.4306 μs | 0.0784 μs | 32 B |
11. 2の累乗が除数となる剰余計算の高速化
こちらのコメントで教えて頂いた「2の累乗が除数」の場合の剰余計算の高速化についてです。
ALUの剰余計算の仕組み上、計算時間がかかってしまうのは仕方ありませんが、逆にいうと剰余計算をせず剰余の結果を取得できる方法があればそちらに置き換えることで高速化が図れるということになります。
例えば、2の累乗が除数の場合に、(除数-1)をした数値でAND演算子を使った計算を行うことで剰余を求めることができます。
ModuleBenchmark.cs
Method | Mean | Error | StdDev | Allocated |
---|---|---|---|---|
ModuleNormalBenchmark | 49.60 ns | 2.140 ns | 0.117 ns | - |
ModuleAndBenchmark | 49.37 ns | 0.534 ns | 0.029 ns | - |
ModuleNormalNotConstBenchmark | 252.95 ns | 7.604 ns | 0.417 ns | - |
ModuleAndNotConstBenchmark | 50.52 ns | 2.932 ns | 0.161 ns | - |
※この計測結果から、除数が定数の場合は何らかの最適化がかかっている気がします。(が、ILを見ただけだと%を使った場合は全てrem命令を呼び出していたので、詳細は追えていないです…)