[.NET] パフォーマンス最適化のよくある間違い

かきかけ

# はじめに
「クソパフォーマンスコードだな、直しちゃろ」と思ってクソパフォーマンスコードを書かないために。
知識は日々劣化していくものです。常に最新のドキュメントを確認して、本当に正しいのかを日々確認して、パフォーマンスチューニングに努めましょう。

## 本記事の展開
本記事は、以下の流れで展開されます。
* 元コード
* 直したクソパフォーマンスコード
* 何故↑のクソパフォーマンスコードがダメなのかの説明
* 最適なコード
* 計測結果

# 開発環境
特に明示されていない場合は、以下の環境です。
* 言語: C# 10
* フレームワーク: .NET 6.0
* OS: Windows 10

# 本題
## ？？？「`StringBuilder` つかおうよー」
### 元コード
```csharp
string Original()
{
    string str =
        "いい感じの文字列1"
        + "いい感じの文字列2"
        + "いい感じの文字列3"
        + "いい感じの文字列4"
        + "いい感じの文字列5"
        + "いい感じの文字列6"
        + "いい感じの文字列7"
        + "いい感じの文字列8"
        + "いい感じの文字列9"
        + "いい感じの文字列10"
        + "いい感じの文字列11"
        + "いい感じの文字列12"
        + "いい感じの文字列13";
    return str;
}
```

### 直したクソパフォーマンスコード
```csharp
using System.Text;

string FuckingFix()
{
    StringBuilder builder = new StringBuilder()
        .Append("いい感じの文字列1")
        .Append("いい感じの文字列2")
        .Append("いい感じの文字列3")
        .Append("いい感じの文字列4")
        .Append("いい感じの文字列5")
        .Append("いい感じの文字列6")
        .Append("いい感じの文字列7")
        .Append("いい感じの文字列8")
        .Append("いい感じの文字列9")
        .Append("いい感じの文字列10")
        .Append("いい感じの文字列11")
        .Append("いい感じの文字列12")
        .Append("いい感じの文字列13");
    return builder.ToString();
}
```


### 何故↑のクソパフォーマンスコードがダメなのかの説明
元コードはコンパイル最適化により、以下のように最適化されます。
```csharp
string Original()
{
    return "いい感じの文字列1いい感じの文字列2いい感じの文字列3いい感じの文字列4いい感じの文字列5いい感じの文字列6いい感じの文字列7いい感じの文字列8いい感じの文字列9いい感じの文字列10いい感じの文字列11いい感じの文字列12いい感じの文字列13";
}
```
つまり、`+` 演算子分の `string.Concat` の呼び出しがなくなり、`ldstr` (文字列のロード) 分だけの実行になります。

一方、`StringBuilder` は最適化されないので、島風ちゃんに「おっそーい」と言われるくらい遅くなります。


### 最適なコード
元コードでも十分適切ですが、もっと最適化するならこうなります。
```csharp
// static field 使うので、一応ちゃんとクラス宣言書く
class Program
{
    static string s_str = 
        "いい感じの文字列1"
        + "いい感じの文字列2"
        + "いい感じの文字列3"
        + "いい感じの文字列4"
        + "いい感じの文字列5"
        + "いい感じの文字列6"
        + "いい感じの文字列7"
        + "いい感じの文字列8"
        + "いい感じの文字列9"
        + "いい感じの文字列10"
        + "いい感じの文字列11"
        + "いい感じの文字列12"
        + "いい感じの文字列13";

    string BestFix()
    {
        return s_str;
    }
}
```

これは `ldfld` (フィールドのロード) のみになり、2421 bytes → 4 or 8 bytes (`sizeof(nint)`) の読み込みになるため、非常に高速になる。
島風もびっくり。


### 計測結果
```
|     Method |        Mean |     Error |    StdDev |      Median |
|----------- |------------:|----------:|----------:|------------:|
|   Original |   0.0066 ns | 0.0160 ns | 0.0142 ns |   0.0000 ns |
| FuckingFix | 131.3555 ns | 1.8521 ns | 1.6419 ns | 131.2134 ns |
|    BestFix |   0.0000 ns | 0.0000 ns | 0.0000 ns |   0.0000 ns |
```

## ？？？「`StringBuilder` つかおうよー」その2
### 元コード
```csharp
public int n;

public string Original()
{
    string str = "";
    for (int i = 0; i < n; i++)
    {
        str = "いい感じの文字列" + i;
    }

    return str;
}
```

### 直したクソパフォーマンスコード
```csharp
using System.Text;

public int n;

public string FuckingFix()
{
    StringBuilder builder = new();
    for (int i = 0; i < n; i++)
    {
        builder.Append("いい感じの文字列" + i);
    }
    return builder.ToString();
}
```


### 何故↑のクソパフォーマンスコードがダメなのかの説明
なんで結合を避けるために `StringBuilder.Append` 使っているのに、`"いい感じの文字列" + i` で結合しているんですか？


### 最適なコード
```csharp
public int n;

public string BestFix()
{
    StringBuilder builder = new();
    for (int i = 0; i < n; i++)
    {
        builder.Append("いい感じの文字列");
        builder.Append(i);
    }
    return builder.ToString();
}

// C# 10.0 以降なら、文字列補間 ($始まり) で書いた文字列は
// StringBuilder.AppendInterpolatedStringHandler に暗黙的に変換されるので、こっちで書いた方がパフォーマンスと可読性を両立できるのでおすすめ
public string BestFix2()
{
    StringBuilder builder = new();
    for (int i = 0; i < n; i++)
    {
        builder.Append($"いい感じの文字列{i}");
    }
    return builder.ToString();
}
```


### 計測結果
```
|     Method |     n |          Mean |         Error |        StdDev |
|----------- |------ |--------------:|--------------:|--------------:|
|   Original |   100 |      4.914 us |     0.0270 us |     0.0253 us |
| FuckingFix |   100 |      1.933 us |     0.0266 us |     0.0249 us |
|    BestFix |   100 |      1.067 us |     0.0162 us |     0.0151 us |
|   BestFix2 |   100 |      1.180 us |     0.0093 us |     0.0078 us |
|   Original | 10000 | 80,812.917 us | 1,609.4959 us | 4,211.7703 us |
| FuckingFix | 10000 |    288.914 us |     4.8126 us |     4.5017 us |
|    BestFix | 10000 |    110.361 us |     0.6834 us |     0.6393 us |
|   BestFix2 | 10000 |    125.578 us |     0.4204 us |     0.3726 us |
```


## ？？？「二分探索の方が速いよ！」
### 元コード
```csharp
string[] _items;

public bool Original()
{
    string[] items = _items;
    foreach (string str in items)
    {
        if (str == "abc")
        {
            return true;
        }
    }
    return false;
}
```

### 直したクソパフォーマンスコード
```csharp
using System.Linq;

string[] _items;

public bool FuckingFix()
{
    var sortedList = _items.ToList();
    sortedList.Sort();

    return sortedList.BinarySearch("abc") >= 0;
}
```


### 何故↑のクソパフォーマンスコードがダメなのかの説明
二分探索は高速ですが、ソートしてまでやるほどではありません


### 最適なコード
`Original` で十分です。(`string.Length` を確認すれば、場合によっては少し高速になりますが、特筆するほど高速になる訳ではありません。)
個人的には、可読性を優先して、`_items.Contains()` でも良いと思います。


### 計測結果
```
|     Method | itemCount | maxLength |            Mean |         Error |        StdDev |
|----------- |---------- |---------- |----------------:|--------------:|--------------:|
|   Original |       100 |       100 |        50.49 ns |      0.507 ns |      0.474 ns |
| FuckingFix |       100 |       100 |    25,761.58 ns |    311.147 ns |    275.823 ns |
|   Original |       100 |     10000 |        59.19 ns |      0.839 ns |      0.743 ns |
| FuckingFix |       100 |     10000 |    27,000.79 ns |    269.980 ns |    252.539 ns |
|   Original |     10000 |       100 |    11,226.87 ns |     37.011 ns |     30.906 ns |
| FuckingFix |     10000 |       100 | 7,121,160.37 ns | 34,551.173 ns | 28,851.794 ns |
|   Original |     10000 |     10000 |    16,586.33 ns |     71.628 ns |     59.813 ns |
| FuckingFix |     10000 |     10000 | 7,457,502.29 ns | 58,746.371 ns | 52,077.128 ns |
```

<details><summary>計測に用いたソース</summary>

```cs
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Linq;
using System.Text;

public class Test
{
    [Params(100, 10000)]
    public int itemCount;
    [Params(100, 10000)]
    public int maxLength;

    private string[] _items = null!;

    [GlobalSetup]
    public void Setup()
    {
        Random rand = new(42);

        string GetString()
        {
            byte[] buffer = new byte[rand.Next(maxLength)];
            rand.NextBytes(buffer);
            unsafe
            {
                fixed (sbyte* p = (sbyte[])(object)buffer)
                {
                    return new string(p, 0, buffer.Length, Encoding.ASCII);
                }
            }
        }
        
        _items = Enumerable.Repeat(0, itemCount)
            .Select(s => GetString())
            .ToArray();
    }


    [Benchmark]
    public bool Original()
    {
        string[] items = _items;
        foreach (string str in items)
        {
            if (str == "abc")
            {
                return true;
            }
        }
        return false;
    }

    [Benchmark]
    public bool FuckingFix()
    {
        var sortedList = _items.ToList();
        sortedList.Sort();

        return sortedList.BinarySearch("abc") >= 0;
    }

    static void Main(string[] args)
    {
        BenchmarkRunner.Run<Test>();
    }
}
```
</details>