LoginSignup
294
334

More than 1 year has passed since last update.

今日からできるC#のパフォーマンス改善小ネタ11選

Last updated at Posted at 2021-10-24

はじめに

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命令を呼び出していたので、詳細は追えていないです…)

参考リンク集

294
334
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
294
334