28
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

エンジニア作業飲み集会Advent Calendar 2022

Day 12

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

Last updated at Posted at 2022-12-11

かきかけ

はじめに

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

本記事の展開

本記事は、以下の流れで展開されます。

  • 元コード
  • 直したクソパフォーマンスコード
  • 何故↑のクソパフォーマンスコードがダメなのかの説明
  • 最適なコード
  • 計測結果

開発環境

特に明示されていない場合は、以下の環境です。

  • 言語: C# 10
  • フレームワーク: .NET 6.0
  • OS: Windows 10

本題

???「StringBuilder つかおうよー」

元コード

string Original()
{
    string str =
        "いい感じの文字列1"
        + "いい感じの文字列2"
        + "いい感じの文字列3"
        + "いい感じの文字列4"
        + "いい感じの文字列5"
        + "いい感じの文字列6"
        + "いい感じの文字列7"
        + "いい感じの文字列8"
        + "いい感じの文字列9"
        + "いい感じの文字列10"
        + "いい感じの文字列11"
        + "いい感じの文字列12"
        + "いい感じの文字列13";
    return str;
}

直したクソパフォーマンスコード

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();
}

何故↑のクソパフォーマンスコードがダメなのかの説明

元コードはコンパイル最適化により、以下のように最適化されます。

string Original()
{
    return "いい感じの文字列1いい感じの文字列2いい感じの文字列3いい感じの文字列4いい感じの文字列5いい感じの文字列6いい感じの文字列7いい感じの文字列8いい感じの文字列9いい感じの文字列10いい感じの文字列11いい感じの文字列12いい感じの文字列13";
}

つまり、+ 演算子分の string.Concat の呼び出しがなくなり、ldstr (文字列のロード) 分だけの実行になります。

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

最適なコード

元コードでも十分適切ですが、もっと最適化するならこうなります。

// 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

元コード

public int n;

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

    return str;
}

直したクソパフォーマンスコード

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 で結合しているんですか?

最適なコード

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 |

???「二分探索の方が速いよ!」

元コード

string[] _items;

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

直したクソパフォーマンスコード

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 |
計測に用いたソース
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>();
    }
}
28
17
0

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
28
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?