C#のいろいろな、遅くなる要素のベンチマーク

C#で、速度を徹底的に出したいと思って、いろいろ遅くなる要素を避けます。
・ボクシング
・配列のコピー
・ヒープメモリをなるべく使わない
・仮想呼び出し(継承・インターフェイス)
・文字コードの変換
・Lock
まあ、避けれられる時には避けるわけですが、避けると可読性がすごく悪くなる時とか、
避けられないとか、悩ましい時はあります。

と、ここで、どの要素がどれくらい遅くなるものなのか、正確には知らないことに気づきました。
なので、まずは各要素がどれくらい遅いのか、ベンチマークを取ってみたいと思います。

まずは結論から。


|                                 Method |       Mean |      Error |     StdDev |
|--------------------------------------- |-----------:|-----------:|-----------:|
|                               IntBench |   2.528 ms |  0.0492 ms |  0.0436 ms |
|                            DoubleBench |   7.420 ms |  0.1484 ms |  0.1766 ms |
|                       IntNoInlineBench |  17.373 ms |  0.3421 ms |  0.3940 ms |
|                         IntStructBench |   2.509 ms |  0.0492 ms |  0.0526 ms |
|                          IntClassBench | 105.393 ms |  2.0978 ms |  3.1399 ms |
|                         IntBoxingBench | 105.288 ms |  2.1175 ms |  6.0754 ms |
|                IntSubClassVirtualBench | 104.455 ms |  2.0619 ms |  4.1652 ms |
|               IntSubClassVirtualBench2 |   5.450 ms |  0.0322 ms |  0.0285 ms |
|          IntSubClassSealedVirtualBench | 103.931 ms |  1.3346 ms |  1.2484 ms |
|               IntInterfaceVirtualBench | 108.854 ms |  2.1549 ms |  4.4501 ms |
|                           IntLockBench |  97.660 ms |  1.9243 ms |  2.4336 ms |
|                     AsyncAwaitIntBench | 115.553 ms |  1.8045 ms |  1.6879 ms |
|                AwaitFromResultIntBench |  25.641 ms |  0.3899 ms |  0.3647 ms |
|            AsyncAwaitValueTaskIntBench | 191.696 ms |  3.7660 ms |  4.1859 ms |
|       AwaitFromResultValueTaskIntBench |  84.401 ms |  1.5981 ms |  1.9626 ms |
|              MakeUtf8Abcde_StringBench | 561.458 ms | 11.1289 ms | 13.2481 ms |
|        MakeUtf8Abcde_StaticStringBench | 548.176 ms | 10.7093 ms | 11.9033 ms |
|     MakeUtf8Abcde_StaticByteArrayBench | 358.296 ms |  6.3360 ms |  5.9267 ms |
| MakeUtf8Abcde_StaticByteArraySpanBench | 205.517 ms |  3.0808 ms |  2.8818 ms |

1,000,000回のループを入れてあるので、1回の速度としてはナノ秒単位のはずです。
・Intより、Doubleが遅い 2:7
・メソッドの呼び出し(インライン化を禁止したら)で、+15
・Intを内部に持つstructでは、intと変わらない。
・classを2つnewして、ヒープメモリを使うと、+100
・intをobjectに入れたboxingが起こるケース。+100。classとほぼ同じ。
・仮想呼出(プロパティ)のコストは、+3くらい。
・async awaitを使うメソッド1回で +100
・Task.FromResultを使って、awaitだけなら、+20
・TaskをValueTaskに変えたら、遅くなった。なんでだろう。 +80。理由わかる方いたら、コメントくださるとうれしいです。
・lockを入れたら、+90
次は、utf8文字作成
・stringで結合して、最後にutf8にするのは遅い。580
・stringを、staticに事前にutf8にしておいたら、360で、-220
・結合部分で、byte[]を作らないように、spanで処理したら、200で最速

以下に、各ソースと、簡単な説明を書いていきます。

環境は、
・CPU:AMD Ryzen5 3600 6 Core
・Windows11
・VisulaStudio 2022
・.Net6.0

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkDotNet.Running.BenchmarkRunner.Run<BenchTarget>();
    }

}
public class BenchTarget
{
    const int _loopCount = 1_000_0000;

    // ベンチマークの中身
}

枠組み
NuGetで、BenchmarkDotNetを入れてあります。

[Benchmark]
public long IntBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += SumInt(a, b);
    }

    return sum;
}

public int SumInt(int a, int b)
{
    return a + b;
}

標準形というこで、intを2つメソッド内で足しているだけです。
インライン化されて、メソッド呼出のコストは無くなっているはず。

[Benchmark]
public double DoubleBench()
{
    double sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        double a = 1;
        double b = 2;

        sum += SumDouble(a, b);
    }

    return sum;
}

public double SumDouble(double a, double b)
{
    return a + b;
}

同様のをdoubleで。

[Benchmark]
public long IntNoInlineBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += SumIntNoInline(a, b);
    }

    return sum;
}

[MethodImpl(MethodImplOptions.NoInlining)]
public int SumIntNoInline(int a, int b)
{
    return a + b;
}

インライン化を禁止することで、メソッド呼出のコストを
計測してみた。
+15と、ある程度コストがかかる。
オブジェクトを一つ作るよりは軽いみたい。

[Benchmark]
public long IntStructBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        var a = new IntStruct() { Value = 1 };
        var b = new IntStruct() { Value = 2 };

        sum += SumIntStruct(a, b);
    }

    return sum;
}

public int SumIntStruct(IntStruct a, IntStruct b)
{
    return a.Value + b.Value;
}

public struct IntStruct
{
    public int Value { get; set; }
}

intの値をラッピングしたstructを作った場合。
intと速度はほぼ変わらず。

[Benchmark]
public long IntClassBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        var a = new IntClass() { Value = 1 };
        var b = new IntClass() { Value = 2 };

        sum += SumIntClass(a, b);
    }

    return sum;
}

public int SumIntClass(IntClass a, IntClass b)
{
    return a.Value + b.Value;
}

public class IntClass
{
    public int Value { get; set; }
}

intを中に持つclassを使った場合。
都度、ヒープメモリにオブジェクトが作られるので、遅い(おそらくGCが)。

[Benchmark]
public long IntBoxingBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        var a = 1;
        var b = 2;

        sum += SumIntBoxing(a, b);
    }

    return sum;
}

public int SumIntBoxing(object a, object b)
{
    return (int)a + (int)b;
}

引数をobjectにしてあるので、boxingが起こるケース。
classを使った場合と、ほぼ同じ速度。
ただ、今回のシナリオでは同じ回数だけれど、
実際の使用ケースだと、呼出の度にboxingが起こるから、
ダメージが大きい場合の方が多そう。

[Benchmark]
public long IntSubClassVirtualBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        ParentClass1 a = new ChildClass1() { Value = 1 };
        ParentClass1 b = new ChildClass1() { Value = 2 };

        sum += SumIntSubClassVirtual(a, b);
    }

    return sum;
}

public int SumIntSubClassVirtual(ParentClass1 a, ParentClass1 b)
{
    return a.Value + b.Value;
}

public class ParentClass1
{
    public virtual int Value { get; set; }
}

public class ChildClass1 : ParentClass1
{
    public override int Value { get; set; }
}

親子クラスを定義して、virtualで呼び出す。
仮想呼出のコスト<ヒープを使うコスト (100倍くらい?)
みたいで、仮想呼出のコストはよくわからない。

[Benchmark]
public long IntSubClassVirtualBench2()
{
    long sum = 0;

    ParentClass1 a = new ChildClass1();
    ParentClass1 b = new ChildClass1();

    for (int i = 0; i < _loopCount; i++)
    {
        a.Value = 1;
        b.Value = 2;

        sum += SumIntSubClassVirtual(a, b);
    }

    return sum;
}

仮想呼出のコストだけを調査してみた。
+3という結果がでたけど、インライン阻害のケースより速いのが謎。
virtual化したのが、プロパティだからか。
もっと、調査をしてみたいところ。

[Benchmark]
public long IntSubClassSealedVirtualBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        ParentClass2 a = new ChildClassSealed2() { Value = 1 };
        ParentClass2 b = new ChildClassSealed2() { Value = 2 };

        sum += SumIntSubClassSealedVirtual(a, b);
    }

    return sum;
}

public int SumIntSubClassSealedVirtual(ParentClass2 a, ParentClass2 b)
{
    return a.Value + b.Value;
}

public class ParentClass2
{
    public virtual int Value { get; set; }
}

public sealed class ChildClassSealed2 : ParentClass2
{
    public override int Value { get; set; }
}

子クラスをsealedしてみたけど、今回のケースだと速度はほぼ変わらず。

[Benchmark]
public long IntInterfaceVirtualBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        IHasValue a = new ChildClassWithInterface() { Value = 1 };
        IHasValue b = new ChildClassWithInterface() { Value = 2 };

        sum += SumIntInterfaceVirtual(a, b);
    }

    return sum;
}

public int SumIntInterfaceVirtual(IHasValue a, IHasValue b)
{
    return a.Value + b.Value;
}

public interface IHasValue
{
    public int Value { get; set; }
}

public class ChildClassWithInterface : IHasValue
{
    public int Value { get; set; }
}

インターフェース経由いした場合。
こちらも、継承とあまり変わらない。

[Benchmark]
public long IntLockBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        lock (lockObject)
        {
            sum += SumInt(a, b);
        }
    }

    return sum;
}

readonly object lockObject = new();

sumを呼び出すときに、lockをかけた場合。
new2つと同じくらいの、結構大きなコストがかかる。

[Benchmark]
public async Task<long> AsyncAwaitIntBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += await SumAsyncInt1(a, b);
    }

    return sum;
}

public async Task<int> SumAsyncInt1(int a, int b)
{
    return a + b;
}

async,awaitを使って呼び出した場合。
実際には同期実行されるので、本当はasync,awaitはいらない
プラグラムだが。
内部で、Taskオブジェクトがnewされるからか、new2つと同じくらい
コストがかかっている。

[Benchmark]
public async Task<long> AwaitFromResultIntBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += await SumFromResultInt(a, b);
    }

    return sum;
}

public Task<int> SumFromResultInt(int a, int b)
{
    return Task.FromResult(a + b);
}

Task.FromResultに書き換えたケース。
かなり速くなる。
理由は、Task.FromResult内でキャッシュが効いているようで、
Taskオブジェクトが新しく作られないと思う。

[Benchmark]
public async Task<long> AsyncAwaitValueTaskIntBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += await SumAsyncAwaitValueTaskInt(a, b);
    }

    return sum;
}

public async ValueTask<int> SumAsyncAwaitValueTaskInt(int a, int b)
{
    return a + b;
}

Sumを、Taskではなく、ValueTaskにしたケース。
速くなるかと思ったのに、なぜか逆に遅くなった。理由は良くわからない。

[Benchmark]
public async Task<long> AwaitFromResultValueTaskIntBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        int a = 1;
        int b = 2;

        sum += await SumFromResultValueTaskInt(a, b);
    }

    return sum;
}

public ValueTask<int> SumFromResultValueTaskInt(int a, int b)
{
    return ValueTask.FromResult(a + b);
}

ValueTask.FromResult版。
こちらもTaskより遅くなった。

[Benchmark]
public long MakeUtf8Abcde_StringBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        string a = "a";
        string b = "b";
        string c = "c";
        string d = "d";
        string e = "e";

        var result = MakeUtf8Abcde_String(a, b, c, d, e);

        sum += result.Length;
    }

    return sum;
}

public byte[] MakeUtf8Abcde_String(string a, string b, string c, string d, string e)
{
    return Encoding.UTF8.GetBytes(a + b + c + d + e);
}

static string _a = "a";
static string _b = "b";
static string _c = "c";
static string _d = "d";
static string _e = "e";

string5つを、渡してくっつけて、utf8のbyte[]にして返す。
結合の時と、byte[]を作る2回で、ヒープメモリが使われる点、
変換が1回とコストがかかっている。

[Benchmark]
public long MakeUtf8Abcde_StaticStringBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {

        var result = MakeUtf8Abcde_String(_a, _b, _c, _d, _e);

        sum += result.Length;
    }

    return sum;
}

public byte[] MakeUtf8Abcde_StaticString(string a, string b, string c, string d, string e)
{
    return Encoding.UTF8.GetBytes(a + b + c + d + e);
}

stringの値を、メソッド内に持たず、staticで定義するように置き換えたもの。
速度は変わらないと想定したが、想定通りほぼ同じ速度だった。

[Benchmark]
public long MakeUtf8Abcde_StaticByteArrayBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {

        var result = MakeUtf8Abcde_StaticByteArray(b_a, b_b, b_c, b_d, b_e);

        sum += result.Length;
    }

    return sum;
}

static byte[] b_a = Encoding.UTF8.GetBytes("a");
static byte[] b_b = Encoding.UTF8.GetBytes("b");
static byte[] b_c = Encoding.UTF8.GetBytes("c");
static byte[] b_d = Encoding.UTF8.GetBytes("d");
static byte[] b_e = Encoding.UTF8.GetBytes("e");

public byte[] MakeUtf8Abcde_StaticByteArray(byte[] a, byte[] b, byte[] c, byte[] d, byte[] e)
{
    byte[] result = new byte[a.Length + b.Length + c.Length + d.Length + e.Length];
    int index = 0;
    a.CopyTo(result, index);
    index += a.Length;
    b.CopyTo(result, index);
    index += a.Length;
    c.CopyTo(result, index);
    index += a.Length;
    d.CopyTo(result, index);
    index += a.Length;
    e.CopyTo(result, index);
    index += a.Length;

    return result;
}

staticで、各要素をはじめからbyte[]で持ち、byte[]に結合した。
文字コード変換は無しで、byte[]のヒープ確保が1回発生する。

[Benchmark]
public long MakeUtf8Abcde_StaticByteArraySpanBench()
{
    long sum = 0;

    for (int i = 0; i < _loopCount; i++)
    {
        sum += MakeUtf8Abcde_StaticByteArraySpan1();
    }

    return sum;
}

public int MakeUtf8Abcde_StaticByteArraySpan1()
{
    Span<byte> buffer = stackalloc byte[128];
    var result = MakeUtf8Abcde_StaticByteArraySpan2(buffer, b_a, b_b, b_c, b_d, b_e);

    return result.Length;
}

public Span<byte> MakeUtf8Abcde_StaticByteArraySpan2(Span<byte> buffer,byte[] a, byte[] b, byte[] c, byte[] d, byte[] e)
{
    int index = 0;
    a.AsSpan().CopyTo(buffer[index..]);
    index += a.Length;
    b.AsSpan().CopyTo(buffer[index..]);
    index += b.Length;
    c.AsSpan().CopyTo(buffer[index..]);
    index += c.Length;
    d.AsSpan().CopyTo(buffer[index..]);
    index += d.Length;
    e.AsSpan().CopyTo(buffer[index..]);
    index += e.Length;

    return buffer[..index];
}

staticで、各要素をはじめからbyte[]で持ち、Spanで結合した。
文字コード変換、ヒープメモリ共に使用を回避した。
結果、文字結合では最速になった。

仮想呼出については、結果がしっくりこなかったので、
後日、また調査をしてみたいと思います。

投稿日:
カテゴリー: C#

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です