(C#)10のn乗するベンチマーク

Jsonのデシリアライズ関係で、
“1.2345678E+15” → 1234567800000000
といった変換を、なるべくパフォーマンス良く実行したいと思いました。
1.2345678と、+15 を抜きだし、
1.2345678 * 1000000000000000 を実行したい。
この実装を考察します。
上記の場合、double * doubleとなりますが、私の使用の都合で、調べるのは、long * long の評価とします。
(固定小数点の実装を調べていて、浮動小数点は、最初から最後まで出現させたくない)

さて、まずは結果を載せておきます。

|                  Method |       Mean |     Error |    StdDev |
|------------------------ |-----------:|----------:|----------:|
|            MathPowBench | 25.5353 ns | 0.3383 ns | 0.2641 ns |
|         ForPower10Bench |  3.8944 ns | 0.0926 ns | 0.0773 ns |
|  WhileMinusPower10Bench |  3.3517 ns | 0.0751 ns | 0.0703 ns |
| WhileMinus4Power10Bench |  1.2583 ns | 0.0561 ns | 0.0768 ns |
|    SwitchStatementBench |  0.7492 ns | 0.0407 ns | 0.0399 ns |
|   SwitchExpressionBench |  0.7310 ns | 0.0127 ns | 0.0106 ns |
|              ArrayBench |  0.1696 ns | 0.0091 ns | 0.0076 ns |
|                RosBench | 11.3009 ns | 0.2496 ns | 0.3245 ns |

結論として、最も速いのは、static long[]を事前に作っておいて、それを使う方法でした。
ベンチマークでは、123,11 → 123 * 100000000000 の速度を計測しました。
環境は、Win11,.net7.0,CPU:AMD Ryzen5-3600です。
共通のコードとして、以下を用意しました。

static long sourceValue = 123;
static int powerValue = 11;

では、一つずつ見ていこうと思います。

まずは、MathPowBench

[Benchmark]
public long MathPowBench()
{
    return Power0(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power0(long baseNumber, int power)
{
    return (long)(baseNumber * Math.Pow(10, power));
}

シンプルに、Mathにある、Powを使った実装です。
実は余談がありまして、はじめ調べていた時、Math.Powが一番速かったのです。
Power0(123,11)と書いていたのですが、どうもコンパイルかJIT最適化のどちらかわかりませんが、最適化がされていて、Math.Powが実際に実行されていな様な感じでした。
リテラルを入れるのをやめて、sourceValue,powerValueという2つの変数を入れるようにしたところ、普通に遅くなったので安心しました。
遅い理由は、まあ、doubleのdouble乗なんて、普通に考えたら大変ですもんね。
longのint乗の方が速いのは当然だと思います。

さて次は、ForPower10Benchです。

[Benchmark]
public long ForPower10Bench()
{
    return Power1(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power1(long baseNumber, int power)
{
    var value = baseNumber;
    for (int i = 0; i < power; i++)
    {
        value *= 10;
    }
    return value;
}

n乗の回数分、forで回して10倍を繰り返します。
Math.Powよりはかなり速いですが、遅めです。コードはシンプルですが。

次は、WhileMinusPower10Benchです。

[Benchmark]
public long WhileMinusPower10Bench()
{
    return Power2(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power2(long baseNumber, int power)
{
    var value = baseNumber;
    int i = power;
    while (i > 0)
    {
        value *= 10;
        i--;
    }
    return value;
}

whileで引き算で書いてみました。パフォーマンスはforとあまり変わりません。

次は、WhileMinus4Power10Benchです。

[Benchmark]
public long WhileMinus4Power10Bench()
{
    return Power3(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power3(long baseNumber, int power)
{
    var value = baseNumber;
    int i = power;
    while (i > 3)
    {
        value *= 10000;
        i -= 4;
    }
    while (i > 0)
    {
        value *= 10;
        i--;
    }
    return value;
}

n乗が大きい時、ループ回数が大きくなるのを減らす目的で、4ずつ処理するようにしました。
普通に3倍くらい速くなっていますね。効果があるようです。

次は、SwitchStatementBenchです。

[Benchmark]
public long SwitchStatementBench()
{
    return Power4(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power4(long baseNumber, int power)
{
    switch (power)
    {
        case 1:
            return baseNumber * 10;
        case 2:
            return baseNumber * 100;
        case 3:
            return baseNumber * 1000;
        case 4:
            return baseNumber * 10_000;
        case 5:
            return baseNumber * 100_000;
        case 6:
            return baseNumber * 1_000_000;
        case 7:
            return baseNumber * 10_000_000;
        case 8:
            return baseNumber * 100_000_000;
        case 9:
            return baseNumber * 1_000_000_000;
        case 10:
            return baseNumber * 10_000_000_000;
        case 11:
            return baseNumber * 100_000_000_000;
        case 12:
            return baseNumber * 1_000_000_000_000;
        case 13:
            return baseNumber * 10_000_000_000_000;
        case 14:
            return baseNumber * 100_000_000_000_000;
        case 15:
            return baseNumber * 1_000_000_000_000_000;
        case 16:
            return baseNumber * 10_000_000_000_000_000;
        case 17:
            return baseNumber * 100_000_000_000_000_000;
        case 18:
            return baseNumber * 1_000_000_000_000_000_000;
        default:
            return -1;
    }
}

swithステートメントです。
ちょっと見た目に難ありですが、かなり速いです。
どうも、連番のswitchは、内部でジャンプテーブル的に分岐してくれるようで、効率が良いみたいです。

次は、SwitchExpressionBenchです。

[Benchmark]
public long SwitchExpressionBench()
{
    return Power5(sourceValue, powerValue);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power5(long baseNumber, int power)
{
    return baseNumber * (power switch
    {
        1 => 10,
        2 => 100,
        3 => 1000,
        4 => 10_000,
        5 => 100_000,
        6 => 1000_000,
        7 => 10_000_000,
        8 => 100_000_000,
        9 => 1000_000_000,
        10 => 10_000_000_000,
        11 => 100_000_000_000,
        12 => 1000_000_000_000,
        13 => 10_000_000_000_000,
        14 => 100_000_000_000_000,
        15 => 1000_000_000_000_000,
        16 => 10_000_000_000_000_000,
        17 => 100_000_000_000_000_000,
        18 => 1000_000_000_000_000_000,
        _ => -1
    });
}

switch式に変えてみました。短くなりましたw
パフォーマンスは、switchステートメントと大差がないですね。

次は、ArrayBenchです。

[Benchmark]
public long ArrayBench()
{
    return Power6(sourceValue, powerValue);
}

static long[] powerArray = new long[] { 
    0,
    10, 
    100, 
    1_000, 
    10_000, 
    100_000,
    1_000_000,
    10_000_000,
    100_000_000,
    1_000_000_000,
    10_000_000_000,
    100_000_000_000,
    1_000_000_000_000,
    10_000_000_000_000,
    100_000_000_000_000,
    1_000_000_000_000_000,
    10_000_000_000_000_000,
    100_000_000_000_000_0000,
    1_000_000_000_000_000_000 
};

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power6(long baseNumber, int power)
{
    return baseNumber * powerArray[power];
}

今回の最速方法です。
ちょっと、開始時にメモリにlong[]を作ることになりますが、それを除けば最速です。
今までの最速のswitchより4倍以上速いので、これが良いと思います。
調査に、Utf8JsonのReadDoubleの実装も読んだのですが、上記と似たdouble[]を用意する実装になっていました。

最後に、RosBenchです。

[Benchmark]
public long RosBench()
{
    return Power7(sourceValue, powerValue);
}


[MethodImpl(MethodImplOptions.AggressiveInlining)]
public long Power7(long baseNumber, int power)
{
    ReadOnlySpan<long> powerSpan = new long[] {
        0,
        10,
        100,
        1_000,
        10_000,
        100_000,
        1_000_000,
        10_000_000,
        100_000_000,
        1_000_000_000,
        10_000_000_000,
        100_000_000_000,
        1_000_000_000_000,
        10_000_000_000_000,
        100_000_000_000_000,
        1_000_000_000_000_000,
        10_000_000_000_000_000,
        100_000_000_000_000_0000,
        1_000_000_000_000_000_000
    };

    return baseNumber * powerSpan[power];
}

Arrayを事前に作るのではなく、ReadOnlySpanを実行時に作るようにしました。
ReadOnlySpan<byte>だと、このような実装で最適化がかかって、すごく速いのですが、ReadOnlySpan<int>等では最適化がかからず、実行時に普通にコピーが走るようです。
そのため、Math.Powよりはましなものの、すごく遅い結果となりました。
どうも、.net8.0ではこれが最速になるかもしれないようです。期待したいですね。

今回はこんなところです。ではまた。

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

コメントする

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