高パフォーマンスなStringの作り方

C#でトレードのAPIを呼ぶ時、ハッシュ値をStringにしてから、送ったりします。
こちらのサンプルコードでは、以下のような関数でbyte[]からstringを作っています。

static string ToHexString(byte[] bytes)
{
    var sb = new StringBuilder(bytes.Length * 2);
    foreach (var b in bytes)
    {
        sb.Append(b.ToString("x2"));
    }
    return sb.ToString();
}

インターネットでちょっと調べてみても、このコードは良く見かけます。
動作もわかりやすいですし、パフォーマンスを気にしないでいい場合は、これで問題は無いと思います。

私の記事では、パフォーマンスにこだわっていきたいので、これでは終われません。
計測するためにも、ベンチマークにして、改良を進めていこうと思います。
改造したコードは、.net8より前のバージョンで動作させることは考慮しておりませんので、悪しからず。

まず、ベンチマークの結果を載せておきます。

| Method                        | Mean      | Error    | StdDev   |
|------------------------------ |----------:|---------:|---------:|
| ToHexStringBench              | 533.47 ns | 7.719 ns | 6.843 ns |
| ToHexString_SpanBench         |  47.69 ns | 0.531 ns | 0.470 ns |
| ToHexString_CreateBench       |  37.00 ns | 0.662 ns | 0.587 ns |
| ToHexString_CreateUnsafeBench |  29.65 ns | 0.650 ns | 0.638 ns |

後になるほど速くなっていますね。

では、ひとつずつ説明をしていきます。

byte[] _hash = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();

[Benchmark]
public string ToHexStringBench() => ToHexString(_hash);

// BitFlyer , GmoCoin
static string ToHexString(byte[] bytes)
{
    var sb = new StringBuilder(bytes.Length * 2);
    foreach (var b in bytes)
    {
        sb.Append(b.ToString("x2"));
    }
    return sb.ToString();
}

まず、はじめのサンプルコードと同じものです。
気になる点として、ToStringがループの中に出てきます。
新しいstringオブジェクトをヒープ領域にたくさん作っているはずですので、これは大変です。
今回のメソッドは、長さはあらかじめ分かっていますので、動的配列である、StringBuilderよりも、配列・Spanの方が速そうです。
Spanなら、スタックメモリでできますし。
そこを改造したのがこちら。

[Benchmark]
public string ToHexString_SpanBench() => ToHexString_Span(_hash);

public static string ToHexString_Span(ReadOnlySpan<byte> bytes)
{
    const string HexValues = "0123456789abcdef";

    Span<char> chars = stackalloc char[bytes.Length * 2];

    for (int i = 0; i < bytes.Length; i++)
    {
        var b = bytes[i];

        var i2 = i * 2;
        chars[i2] = HexValues[b >> 4];
        chars[i2 + 1] = HexValues[b & 0xF];
    }

    return new string(chars);
}

どこからか、サンプルコードをもらってきた記憶があるのですが、参考先がわからなくなってしまいました。すみません。
さて、これでかなり速くなったのですが、ちょっと気になる点が残っています。
このロジックでは、charsにデータを作ってから、これをコピーすることで、stringを作っています。
実は、stringには、String.Create<TState>というメソッドが.Net7から追加されています。
これを使うことで、このコピーするという部分を無くし、string用に確保したバッファーに直接書き込むことができます。
長さが事前にわかっていることが条件にはなるのですが。
この改造をしたコードがこちら。

[Benchmark]
public string ToHexString_CreateBench() => ToHexString_Create(_hash);

public static string ToHexString_Create(byte[] hash)
{
    const string HexValues = "0123456789abcdef";

    var st = String.Create<byte[]>(64, hash,
        (Span<char> chars, byte[] state) =>
        {
            for (int i = 0; i < state.Length; i++)
            {
                var b = state[i];
                var i2 = i * 2;
                chars[i2] = HexValues[b >> 4];
                chars[i2 + 1] = HexValues[b & 0xF];
            }
        }
        );

    return st;
}

私の中では、unsafeを使わない場合、現状、これが最速。
さて、次はunsafeも使っていきます。
先にコードを。

[Benchmark]
public string ToHexString_CreateUnsafeBench() => ToHexString_CreateUnsafe(_hash);

static readonly char[] _toHexChars = GetToTexChars();

static char[] GetToTexChars()
{
    const string HexValues = "0123456789abcdef";

    var chars = new char[256 * 2];

    for (int i = 0; i < 256; i++)
    {
        chars[i * 2] = HexValues[i >> 4];
        chars[i * 2 + 1] = HexValues[i & 0xF];
    }

    return chars;
}

public static string ToHexString_CreateUnsafe(byte[] hash)
{
    var st = String.Create<byte[]>(hash.Length * 2, hash,
        (Span<char> chars, byte[] state) =>
        {
            unsafe
            {
                fixed (char* from = _toHexChars)
                fixed (char* to = chars)
                {
                    var to2 = to;
                    for (int i = 0; i < state.Length; i++)
                    {
                        *(int*)(to2) = *(int*)(from + state[i] * 2);
                        to2 += 2;
                    }
                }
            }
        }
        );

    return st;
}

この機能では、1つのbyteから、string内の2文字を作成します。
つまり、1つのbyteから、char2つ分、4byteです。
事前に、byteの取りうる256種類x4byte = 1kbyte分の配列を_toHexCharsとして作っておいて、
hashの1byte毎に、4byteずつコピーしていくと速そうです。4byteなので、intのサイズ分です。
ベンチマークでも、2割ほど速く、最速になっているようです。

コメントする

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