えんじにあ雑記!

開発していて学んだことをまとめていきます!

C#でパフォーマンスの良いコードを書く

f:id:flat-M_M:20200407212936j:plain

for/foreach, string/StringBuilder, try-catch/エラー処理, 多次元配列など色々な点から高パフォーマンスの書き方を調査してみました。

はじめに

C#でパフォーマンスを意識してコーディングするために、色々リサーチして検証結果をまとめました。

テスト環境

for/foreach

IEnumerableを継承しているコレクションを扱う際に用いるfor/foreachについてです。

結論から述べると、forの方が速いという結果になりました。

理由として、後でILからデコンパイルして生成したC#コードを掲載しているのでそちらをみてもらえればわかるのですが、foreachの場合にはGetEnumerator()メソッドを使ってIEnumeratorを取得しています。また、MoveNext()やCurrentプロパティにアクセスなどを実行しているためオーバーヘッドがforよりも大きくなり、結果としてforの方が速くなるということでした。

例外としてArrayの場合はforeachもコンパイラによってforと同等に展開されるため速度差はあまり見られません。参考としてそちらの検証コードと検証結果も記載しておきます。

検証コード(Listの比較)

public class ListIterationTest
{
    private List<int> nums = Enumerable.Range(0, 100_000).ToList();

    [Benchmark]
    public int UseFor()
    {
        var sum = 0;
        for (int i = 0; i < nums.Count; i++)
        {
            sum += nums[i];
        }
        return 0;
    }

    [Benchmark]
    public int UseForEach()
    {
        var sum = 0;
        foreach (var num in nums)
        {
            sum += num;
        }
        return 0;
    }
}

検証結果(Listの比較結果)

Method Mean Error StdDev
UseFor 86.97 us 1.625 us 1.520 us
UseForEach 188.20 us 3.731 us 4.441 us

forの元コードとデコンパイルしたC#コード

forループを使った場合のC#コードは下記の通りです。

public int UseFor()
{
    var sum = 0;
    for (int i = 0; i < nums.Count; i++)
    {
        sum += nums[i];
    }
    return 0;
}

これを一旦ビルドしてILを出力してからもう一度C#コードへデコンパイルした結果が下記のコードです。

public int UseFor()
{
    int num = 0;
    for (int i = 0; i < nums.Count; i++)
    {
        num += nums[i];
    }
    return 0;
}

forEachの元コードとデコンパイルしたC#コード

foreachループを使った場合のC#コードは下記の通りです。

public int UseForEach()
{
    var sum = 0;
    foreach (var num in nums)
    {
        sum += num;
    }
    return 0;
}

そしてこれを一旦ビルドしてILを出力してからもう一度C#コードへデコンパイルした結果が下記のコードです。

  • GetEnumerator()メソッドを呼び出している
  • MoveNext()で検証している
  • Currentにアクセスして値を取り出している

ことなどが分かります。この辺りがforループとの速度差が生まれた原因です。

public int UseForEach()
{
    int num = 0;
    List<int>.Enumerator enumerator = nums.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            int current = enumerator.Current;
            num += current;
        }
    }
    finally
    {
        ((IDisposable)enumerator).Dispose();
    }
    return 0;
}

検証コード(Array[int]の場合)

public class ArrayIterationTest
{
    private int[] nums = new int[100_000];

    [Benchmark]
    public int UseFor()
    {
        var sum = 0;
        for (int i = 0; i < nums.Length; i++)
        {
            sum += nums[i];
        }
        return 0;
    }

    [Benchmark]
    public int UseForEach()
    {
        var sum = 0;
        foreach (var num in nums)
        {
            sum += num;
        }
        return 0;
    }
}

検証結果(Array[int]の場合)

Method Mean Error StdDev
UseFor 48.70 us 0.058 us 0.051 us
UseForEach 34.19 us 0.112 us 0.099 us

ほぼ同じ速度となっています。

forか、foreachか

forの方が速いから必ずforを使わないといけないというわけではなく、可読性なども考慮してケースバイケースだと思います。ただ、知識としてfor/foreachでは速度差があるということを知っておくことは無駄にはならないと思います。

String/StringBuilder

C#ではStringはImmutable(変更不可能)なものとして扱われるため、変更するときにはヒープにコピーされます。これは連結するたびに発生するのでループ内で毎回文字列を連結するような処理だとかなりパフォーマンスに影響が出るかもしれません。

一方、StringBuilderはインスタンスの生成時にバッファを確保しておき、そこに追加していくのでメモリコピーが発生しにくく、+による文字列連結よりもパフォーマンス面で有効な場合があります。

Microsoft公式ドキュメントより

文字列リテラルや、定数の文字列はコンパイル時に連結が起こるので問題ないが、文字列変数については実行中に文字列連結が発生するためパフォーマンス上の問題が生じます。

なので下記のような可読性を高めるためのコードはコンパイル時に文字列連結が実行されるのでパフォーマンス面で問題はありません。

// Concatenation of literals is performed at compile time, not run time.
string text = "Historically, the world of data and the world of objects " +
"have not been well integrated. Programmers work in C# or Visual Basic " +
"and also in SQL or XQuery. On the one side are concepts such as classes, " +
"objects, fields, inheritance, and .NET Framework APIs. On the other side " +
"are tables, columns, rows, nodes, and separate languages for dealing with " +
"them. Data types often require translation between the two worlds; there are " +
"different standard functions. Because the object world has no notion of query, a " +
"query can only be represented as a string without compile-time type checking or " +
"IntelliSense support in the IDE. Transferring data from SQL tables or XML trees to " +
"objects in memory is often tedious and error-prone.";

System.Console.WriteLine(text);

また、一行に複数の+演算子があったとしても文字列コピーが発生するのは一度だけです。

string userName = "<Type your name here>";
string dateString = DateTime.Today.ToShortDateString();

// Use the + and += operators for one-time concatenations.
string str = "Hello " + userName + ". Today is " + dateString + ".";
System.Console.WriteLine(str);

そして公式ドキュメントには以下のような記載もありました。

簡単に訳すと、「何でもかんでもStringBuilderに置き換えれば良いというものではありません。どこがパフォーマンスネックな部分になっているのかを分析した上で、必要ならば置き換える方が良いでしょう。」ということが記載されています。

Important
Although the StringBuilder class generally offers better performance than the String class, you should not automatically replace String with StringBuilder whenever you want to manipulate strings. Performance depends on the size of the string, the amount of memory to be allocated for the new string, the system on which your app is executing, and the type of operation. You should be prepared to test your app to determine whether StringBuilder actually offers a significant performance improvement.

検証コード

public class StringAndStringBuilderTest
{
    private const int ITERATION = 100_000;

    [Benchmark]
    public int StringConcatenate()
    {
        string result = string.Empty;
        for (var i = 0; i < ITERATION; i++)
        {
            result += '*';
        }
        return 0;
    }

    [Benchmark]
    public int StringBuilderConcatenate()
    {
        StringBuilder builder = new StringBuilder();
        for (var i = 0; i < ITERATION; i++)
        {
            builder.Append('*');
        }
        return 0;
    }
}

検証結果

Method Mean Error StdDev
StringConcatenate 777,308.0 us 14,954.42 us 13,988.38 us
StringBuilderConcatenate 247.6 us 3.96 us 3.31 us

エラー処理と例外処理

例外処理か、返り値を見てエラーの有無を判定するかについてですが、まず例外にはスタックトレースやエラー発生時のコンテキストが保存されています。そしてスタックトレースやコンテキストを保存するオーバーヘッドは大きいので、結論から言うと例外処理はパフォーマンス面では劣ります。

今回の検証では、ParseとTryParseを使って検証しました。

検証コード

public class ExceptionAndError
{
    private char[] digits = new char[] { '1', '2', '3', '4', '5', '6', '7', '8', '9', 'x' };
    private const int ITERATION = 10_000;
    private const int LIST_SIZE = 1000;
    private const int NUMBER_SIZE = 5;
    private List<string> numbers = new List<string>();

    [GlobalSetup]
    public void BenchmarkSetup()
    {
        Random random = new Random();
        for (var i = 0; i < LIST_SIZE; i++)
        {
            StringBuilder builder = new StringBuilder();
            for (var j = 0; j < NUMBER_SIZE; j++)
            {
                int index = random.Next(digits.Length);
                builder.Append(digits[index]);
            }
            numbers.Add(builder.ToString());
        }
    }

    [Benchmark]
    public int ErrorTest()
    {
        for (var i = 0; i < LIST_SIZE; i++)
        {
            var success = int.TryParse(numbers[i], out int number);
        }
        return 0;
    }

    [Benchmark]
    public int ExceptionTest()
    {
        for (var i = 0; i < LIST_SIZE; i++)
        {
            try
            {
                int.Parse(numbers[i]);
            } catch (FormatException)
            {

            }
        }
        return 0;
    }
}

検証結果

Method Mean Error StdDev
ErrorTest 18.92 us 0.019 us 0.016 us
ExceptionTest 5,271.35 us 9.659 us 9.035 us

次元数の違う配列へのアクセス

  • [i, j, k] slowest! Arrayメソッドが走っている(遅い)
  • [i + ITERATION * (j + k * ITERATION)] ldelema(load element address)がILでは走っている(最適化)
  • [index++] fastest! 次元数の違いや配列のアクセス方法によっても速度に差が生まれます。今回は以下の三通りについてパフォーマンスを検証しました。

  • 3次元配列

  • indexを3つのパラメータから計算してアクセスする1次元配列
  • indexをインクリメントしてアクセスする1次元配列

検証コード

public class ArrayDimention
{
    private const int SIZE = 100;
    private int[,,] threeDim = new int[SIZE, SIZE, SIZE];
    private int[] oneDim = new int[SIZE * SIZE * SIZE];
    private int[] directAccess = new int[SIZE * SIZE * SIZE];

    [Benchmark]
    public int ThreeDimTest()
    {
        for (var i = 0; i < SIZE; i++)
        {
            for (var j = 0; j < SIZE; j++)
            {
                for (var k = 0; k < SIZE; k++)
                {
                    threeDim[i, j, k]++;
                }
            }
        }
        return 0;
    }

    [Benchmark]
    public int OneDimTest()
    {
        for (var i = 0; i < SIZE; i++)
        {
            for (var j = 0; j < SIZE; j++)
            {
                for (var k = 0; k < SIZE; k++)
                {
                    oneDim[i + (SIZE * (j + SIZE * k))]++;
                }
            }
        }
        return 0;
    }

    [Benchmark]
    public int DirectAccessTest()
    {
        int index = 0;
        for (var i = 0; i < SIZE * SIZE * SIZE; i++)
        {
            directAccess[index]++;
            index++;
        }
        return 0;
    }
}

検証結果

Method Mean Error StdDev
ThreeDimTest 1,894.5 us 1.67 us 1.56 us
OneDimTest 1,602.5 us 4.35 us 4.07 us
DirectAccessTest 708.7 us 13.21 us 13.57 us