C#12.0 .NET8.0における、Utf8文字列の作り方とパフォーマンス

この投稿は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時点でのお勧めです。

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

コメントする

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