この投稿はC#アドベントカレンダー2023の5日目の記事です。
4日目の記事は、nr_ckさんの[.NET 8]素のBlazorWebAppにチョイ足しでPWAを作るでした。
6日目の記事は、algさんの自作キーボードをちょっとだけ便利にするツールをC#で書いた話です。
.NET8.0も出て、新しいutf8の扱い方も増えた様です。
なので、utf8文字列の作り方をいろいろ検討してみます。
今回は、昔からある書き方も含めて、なるべく初級者でもわかる様に、解説を入れていきます。
今回の参考お題は、以下の通り。
次のJSONを、JSONシリアライザーは使わずに作成する。
{“side”:”buy”,”price”:1000000,”size”:0.01}
JSONの文字コードはUTF-8です。
priceの型はlong, sizeの型はdoubleとします。
結果は、byte[]もしくは、Span<byte>で入手します。
まず、VisualStudioでプロジェクトを作成します。
.NET8.0のコンソールアプリケーションです。
NuGetパッケージマネージャにて、BenchmarkDotNetを入れておきます。
初期準備として、以下のプログラムを用意します。
Releaseで実行すれば、ベンチマークが実行できるようになっています。
using BenchmarkDotNet.Running;
using BenchmarkDotNet8;
using BenchmarkDotNet.Attributes;
using System.Runtime.CompilerServices;
public class Program
{
static void Main(string[] args)
{
BenchmarkDotNet.Running.BenchmarkRunner.Run<Utf8Bench>();
}
}
public class Utf8Bench
{
static string _sideString = "buy";
static byte[] _sideUtf8 = "buy"u8.ToArray();
static long _price = 1000000;
static double _size = 0.01;
// ここにベンチマークを記載
[Benchmark]
public int Dummy()
{
return 1 + 1;
}
}
コードの説明の前に、ベンチマークの結果と結論を書いておきます。
| Method | Mean | Error | StdDev |
|--------------------------------- |----------:|---------:|---------:|
| GetBytes_StringSomeAdd | 244.49 ns | 4.616 ns | 4.740 ns |
| GetBytes_StringBuilder | 189.84 ns | 3.673 ns | 3.256 ns |
| GetBytes_StringPlusToUtf8 | 204.04 ns | 2.229 ns | 1.862 ns |
| GetBytes_DollarStringToUtf8 | 190.83 ns | 2.709 ns | 2.262 ns |
| GetBytes_DollarStringToAscii | 180.13 ns | 2.692 ns | 2.518 ns |
| GetByte_CopyToTryFormat | 117.77 ns | 2.296 ns | 2.255 ns |
| GetSpan_CopyToTryFormat | 98.98 ns | 1.099 ns | 0.975 ns |
| GetSpan_Utf8TryWriteDollarString | 108.76 ns | 1.970 ns | 1.843 ns |
| GetSpan_Utf8TryWriteDollarUtf8 | 108.46 ns | 0.765 ns | 0.639 ns |
お勧めの書き方は、GetSpan_Utf8TryWriteDollarUtf8の書き方で、以下です。
public Span<byte> MakeSpan_Utf8TryWriteDollarUtf8(Span<byte> buffer, ReadOnlySpan<byte> side, long price, double size)
{
System.Text.Unicode.Utf8.TryWrite(buffer, $$"""{"side":"{{side}}","price":{{price}},"size":{{size}}}""", out var bytesWritten);
return buffer[0..bytesWritten];
}
さて、まずは初めのコードです。
[Benchmark]
public byte[] GetBytes_StringSomeAdd()
{
return MakeBytes_StringSomeAdd(_sideString, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_StringSomeAdd(string side, long price, double size)
{
string st = "";
st += "{\"side\":\"";
st += side;
st += "\",\"price\":";
st += price;
st += ",\"size\":";
st += size;
st += "}";
return Encoding.UTF8.GetBytes(st.ToString());
}
最初にベンチマークのためのコードを説明しておきます。
ベンチマークを公平に行うために、すべてメソッド2つで定義して、インライン化を禁止しています。
[MethodImpl(MethodImplOptions.NoInlining)]
この部分が、インライン化の禁止ですね。インライン化については、こちらを参照。
さて、メイン部分の説明です。
stringを+で繋いで、stを変えていく形です。
これで動きます。一応動きます。この形は、書いてはいけないコードの代表格ですが・・・。
このコードは、1行毎に、stringオブジェクトを新しく作成します。
しかも、新しいstringを作るたびに、メモリ上で既存の部分をコピーしますので、stringが長くなっていくと、どんどん速度が悪化していきます。
初心者がよくハマるパフォーマンス地雷ですね。
最後に、UTF16→UTF8の変換もしていますので、これも遅くなる要因になりますね。
今回のコードでは、stringオブジェクトが7つ、byte[]オブジェクトが1つ作成されている様です。オブジェクトをたくさん作ると、どんどん遅くなります。パフォーマンスが重要な局面では、なるべくオブジェクトを作らないようにするのが大事です。
さて、教科書通り、上記問題を解決すべく、StringBuilderを使いましょう。
[Benchmark]
public byte[] GetBytes_StringBuilder()
{
return MakeBytes_StringBuilder(_sideString, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_StringBuilder(string side, long price, double size)
{
var stb = new StringBuilder(64);
stb.Append("{\"side\":\"");
stb.Append(side);
stb.Append("\",\"price\":");
stb.Append(price);
stb.Append(",\"size\":");
stb.Append(size);
stb.Append("}");
return Encoding.UTF8.GetBytes(stb.ToString());
}
stringを+で繋いでいくと遅いので、StringBuilderを使います。
初期バッファーサイズ64を指定しておくことで、はじめから64バイトのバッファーが用意され、バッファー不足からのバッファー拡張も発生しません。
パフォーマンスが少し良くなりました。
今回のコードでは、StringBuilderオブジェクトが1つ、stringオブジェクトが1つ、byte[]オブジェクトが1つ作成されている様です。
さて次は、stringで1行で繋いだらどうなるかです。
[Benchmark]
public byte[] GetBytes_StringPlusToUtf8()
{
return MakeBytes_StringPlusToUtf8(_sideString, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_StringPlusToUtf8(string side, long price, double size)
{
var stringJson = "{\"side\":\"" + side + "\",\"price\":" + price.ToString() + ",\"size\":" + size.ToString() + "}";
return Encoding.UTF8.GetBytes(stringJson);
}
このコードでは、まず、price,sizeから、stringが作成されます。
そして、string[7]が作成されます。
そして、string.Concat(array)が呼び出されます。
パフォーマンスは、StringBuilderより悪化しています。
今回のコードでは、Stringオブジェクトが3つ、string[]オブジェクトが1つ、byte[]オブジェクトが1つ作成されている様です。多いですね。
さて、お次のコードです。
[Benchmark]
public byte[] GetBytes_DollarStringToUtf8()
{
return MakeBytes_DollarStringToUtf8(_sideString, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_DollarStringToUtf8(string side, long price, double size)
{
var stringJson = $$"""{"side":"{{side}}","price":{{price}},"size":{{size}}}""";
return Encoding.UTF8.GetBytes(stringJson);
}
$”””…”””を使ったコードの出番です。
通常、$を1つ、”を3つx2で書くわけですが、今回は出力したいコードに、{,}が出てきますので、$$を2つかぶせることで、埋め込む部分を{{}}で書きます。
上記コードで、今までと同じ出力が可能になります。
$””の構文は、StringHandlerとかいうものに変換されていて、かなり速度が出ます。
今回のベンチマークでは、StringBuilderと同程度ですが、書きやすさはかなり良いですね。
さて、お次のコードです。
[Benchmark]
public byte[] GetBytes_DollarStringToAscii()
{
return MakeBytes_DollarStringToAscii(_sideString, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_DollarStringToAscii(string side, long price, double size)
{
var stringJson = $$"""{"side":"{{side}}","price":{{price}},"size":{{size}}}""";
return Encoding.ASCII.GetBytes(stringJson);
}
一つ上と、最後のEncodingを変えました。UTF8ではなく、ASCIIにしています。
半角英数の部分については、UTF8とASCIIではバイトコードは同じです。そのため、2バイト以上の文字を考慮しなくてよくなる分、ASCIIの方が速くなりそうです。
実際に動かしてみた結果でも、ASCIIの方が若干速くなっています。
さて、ここまでは、string(=UTF16)で作った文字列を、UTF8に変換するアプローチを取ってきました。
この変換は無駄な処理なので、これ以降のコードでは、UTF16経由でなく、直接UTF8を作るアプローチで行きたいと思います。
次のコードは、ちょっと長くなります。
public byte[] GetByte_CopyToTryFormat()
{
return MakeBytes_CopyToTryFormat(_sideUtf8, _price, _size);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public byte[] MakeBytes_CopyToTryFormat(ReadOnlySpan<byte> side, long price, double size)
{
Span<byte> buffer = stackalloc byte[128];
int offset = 0;
int written = 0;
"""
{"side":"
"""u8.CopyTo(buffer[offset..]);
offset += 9;
side.CopyTo(buffer[offset..]);
offset += side.Length;
"""
","price":
"""u8.CopyTo(buffer[offset..]);
offset += 10;
price.TryFormat(buffer[offset..], out written);
offset += written;
"""
,"size":
"""u8.CopyTo(buffer[offset..]);
offset += 8;
size.TryFormat(buffer[offset..], out written);
offset += written;
"}"u8.CopyTo(buffer[offset..]);
offset += 1;
return buffer[..offset].ToArray();
}
.NET8.0から、intなどに、直接UTF8で書き出すTryFormatが追加されました。
これらを使って書いてみました。
使用するbufferも、ヒープに作る配列ではなく、スタックメモリにSpanで確保します。
書き込んだ長さを、offsetに持っておいて、最後に、Spanからbyte[]を作成します。
変換処理を無くし、バッファーがスタックメモリになったおかげか、かなり速度は上がります。
今回の記事全体のベストに近づいてきました。
ヒープメモリに作られるオブジェクトは、最後のbyte[]の1つだけです。
ただ、見ての通り、コードの見た目とメンテナンス性はひどいです。
さて、次のコードでは、最後のbyte[]を変えます。
[Benchmark]
public int GetSpan_CopyToTryFormat()
{
Span<byte> buffer = stackalloc byte[128];
var json = MakeSpan_CopyToTryFormat(buffer, _sideUtf8, _price, _size);
return json.Length;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public Span<byte> MakeSpan_CopyToTryFormat(Span<byte> buffer, ReadOnlySpan<byte> side, long price, double size)
{
int offset = 0;
int written;
"""
{"side":"
"""u8.CopyTo(buffer[offset..]);
offset += 9;
side.CopyTo(buffer[offset..]);
offset += side.Length;
"""
","price":
"""u8.CopyTo(buffer[offset..]);
offset += 10;
price.TryFormat(buffer[offset..], out written);
offset += written;
"""
,"size":
"""u8.CopyTo(buffer[offset..]);
offset += 8;
size.TryFormat(buffer[offset..], out written);
offset += written;
"}"u8.CopyTo(buffer[offset..]);
offset += 1;
return buffer[..offset];
}
コードの大部分は一つ前と同じですが、呼出元でバッファーを確保し、byte[]を作らず、Spanを直接返します。
これで、byte[]を1つ作らなくて済む分、速くなります。
今回の記事の中で、速度としてはこれが最速でした。
ただ、メンテナンス性が低いので、よほどのことがなければ、この書き方はしないと思います。
さて、次です。
[Benchmark]
public int GetSpan_Utf8TryWriteDollarString()
{
Span<byte> buffer = stackalloc byte[128];
return MakeSpan_Utf8TryWriteDollarString(buffer, _sideString, _price, _size).Length;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public Span<byte> MakeSpan_Utf8TryWriteDollarString(Span<byte> buffer, string side, long price, double size)
{
System.Text.Unicode.Utf8.TryWrite(buffer, $$"""{"side":"{{side}}","price":{{price}},"size":{{size}}}""", out var bytesWritten);
return buffer[0..bytesWritten];
}
.NET8.0から導入された、$””を使って、直接UTF8を作る方法です。
見ての通り、スマートです。
速度もかなり出ます。
一つもったいない点があって、sideに渡すのはstringとなっています。
つまり、sideの部分だけ、UTF16→UTF8の変換が発生してしまっています。
この点を次のコードで改善します。
[Benchmark]
public int GetSpan_Utf8TryWriteDollarUtf8()
{
Span<byte> buffer = stackalloc byte[128];
return MakeSpan_Utf8TryWriteDollarUtf8(buffer, _sideUtf8, _price, _size).Length;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public Span<byte> MakeSpan_Utf8TryWriteDollarUtf8(Span<byte> buffer, ReadOnlySpan<byte> side, long price, double size)
{
System.Text.Unicode.Utf8.TryWrite(buffer, $$"""{"side":"{{side}}","price":{{price}},"size":{{size}}}""", out var bytesWritten);
return buffer[0..bytesWritten];
}
byte[]は、$””に埋め込むことができないのですが、ReadOnlySpan<byte>に変換すると、埋め込むことができるようになります。
今回の方法が、この記事内での、C#12 .NET8.0時点でのお勧めです。