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割ほど速く、最速になっているようです。