はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】.NET 6で追加されたstring.Create(internalであるstring.FastAllocateStringを利用できるメソッド)を用いて高速にstringを生成する

はじめに

今回はstring.Createを用いて高速にstringを生成する方法を紹介したいと思います。

learn.microsoft.com

概要

string.FastAllocateStringというinternalなメソッドが.NETにはあり、これを利用することで高速に文字列領域を確保することができます。

通常のオブジェクトと同じようにオブジェクトヘッダを持ちヒープ領域に確保され、同様に、原則new stringのみで生成可能です。StringBuilder.ToStringやEncoding.GetStringなども、最終的にnew stringを呼んで、新たな文字列を確保しています。

正確には、一部の.NET Framework内部のメソッドは String.FastAllocateString(int length)というinternalメソッドで確保された文字列領域に直接書き込みを行っています。このメソッドは外部に公開されていませんが、.NET Standard 2.1 ではString.Create(int length, TState state, SpanActionaction)メソッドが追加され、これを呼ぶことで、新規の文字列領域に直接書き込みができます。

ZString – Unity/.NET CoreにおけるゼロアロケーションのC#文字列生成 | Cygames Engineers' Blog

string.FastAllocateStringをpublicにしてくれないかというIssueもあったりするのですが、string.Createを通して利用してくれとのことでした。

Making this public would encourage mutating an immutable type. It's internal for a good reason. I would not want to make this public. string.Create was exposed an alternative.

Make string.FastAllocateString public · Issue #36989 · dotnet/runtime · GitHub

今回はそのstring.Createの使い方について紹介します。

使い方

public static string Create<TState> (int length, TState state, System.Buffers.SpanAction<char,TState> action);

String.Create メソッド (System) | Microsoft Learn

引数で指定した長さの新しい文字列を確保し、作成後に指定したコールバックを使用してそれを初期化します。

// length: 最初に文字列の長さを固定する(↓だとSpan<char> bufferの長さを10に固定)
// state: ローカル変数をキャプチャしないように、ローカル変数を利用したい場合はここで渡す(↓だとstateが"Add Char : {0}"になる)
// action: 確保したメモリ領域を指すSpan<char> と 上記stateで確保した値 を利用した文字列の初期化処理
string text = string.Create(10, "Add Char : {0}", (buffer, state) =>
{
    for (var i = 0; i < 10; i++)
    {
        // int -> charにする
        buffer[i] = (char)('0' + i);

        // stateには"Add Char : "が格納されている
        // Add Char : 0
        // ...
        // Add Char : 9
        Console.WriteLine(state, i);
    }
});

// 0123456789
Console.WriteLine(text);

string.Createの内部実装を見るとより処理の流れが掴めるかもしれません。

/// <summary>Creates a new string with a specific length and initializes it after creation by using the specified callback.</summary>
/// <param name="length">The length of the string to create.</param>
/// <param name="state">The element to pass to <paramref name="action" />.</param>
/// <param name="action">A callback to initialize the string.</param>
/// <typeparam name="TState">The type of the element to pass to <paramref name="action" />.</typeparam>
/// <returns>The created string.</returns>
public static string Create<TState>(int length, TState state, SpanAction<char, TState> action)
{
    if (action == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.action);
    if (length <= 0)
    {
        if (length == 0)
            return string.Empty;
        ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.length);
    }

    string str = string.FastAllocateString(length);
    action(new Span<char>(ref str.GetRawStringData(), length), state);
    return str;
}

ちなみに

string.Createには以下のオーバーロードがありますが、こちらはDefaultInterpolatedStringHandlerにカルチャー指定するのに利用したりします。

public static string Create (IFormatProvider? provider, Span<char> initialBuffer, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler);
public static string Create (IFormatProvider? provider, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler);

C# の補間文字列はカルチャー依存で、何も指定しないと CurrentCulture が使われます。 その結果、手元の環境で実行すると日本式のフォーマットになるけど、 サーバー上で実行すると米国式のフォーマットになったりすることがあります。

ufcpp.net

var en = CultureInfo.GetCultureInfo("en-US");
var jp = CultureInfo.GetCultureInfo("ja-JP");
        
// Data: 5/12/2024 8:42:38 PM
_ = string.Create(en, $"Data: {DateTime.Now}");

// Data: 2024/05/12 20:42:39
_ = string.Create(jp, $"Data: {DateTime.Now}");

実験

例えばstackallocでメモリを確保して、そこに書き込み、最後にnew stringをしてみます。

[MemoryDiagnoser]
public class SimpleTest
{
    [Benchmark]
    public string StringCreate()
    {
        // length: 最初に文字列の長さを固定する(↓だとSpan<char>の長さを10に固定)
        // state: ローカル変数をキャプチャしないように、ローカル変数を利用したい場合はここで渡す(↓だと特にローカル変数をキャプチャしないので利用しない)
        // action: 確保したメモリ領域を指すSpan<char> と 上記stateで確保した変数の値 を利用した文字列の初期化処理
        return string.Create(10, 0, (buffer, _) =>
        {
            for (var i = 0; i < 10; i++)
            {
                // int -> charにする
                buffer[i] =  (char)('0' + i);
            }
        });
    }

    [Benchmark]
    public string StackAlloc()
    {
        Span<char> text = stackalloc char[10];

        for (var i = 0; i < 10; i++)
        {
            text[i] = (char)('0' + i);
        }
        
        return new string(text);
    }
}
Method Mean Error StdDev Gen0 Allocated
StringCreate 6.930 ns 0.0766 ns 0.0717 ns 0.0057 48 B
StackAlloc 8.844 ns 0.0674 ns 0.0630 ns 0.0057 48 B

stackallocした方が一見早そうに思えますが、string.Createの方が高速です。

追記

そういえばキャッシュしたchar[]new stringした方が早いんじゃないかみたいな説もありますが、それでもstring.Createの方が早いです。

private readonly char[] _cache = new char[10];

[Benchmark]
public string Cache()
{
    for (var i = 0; i < 10; i++)
    {
        // int -> charにする
        _cache[i] = (char)('0' + i);
    }

    return new string(_cache);
}
Method Mean Error StdDev Gen0 Allocated
Cache 8.659 ns 0.0324 ns 0.0253 ns 0.0057 48 B
StringCreate 7.088 ns 0.1444 ns 0.1280 ns 0.0057 48 B
StackAlloc 9.228 ns 0.1559 ns 0.1302 ns 0.0057 48 B

注意点

string.Createで確保したメモリ領域はゼロ埋めはされておらず、ランダムな文字列になっているので必ず全て代入するようにしてください。

The initial content of the destination span passed to action is undefined. Therefore, it is the delegate's responsibility to ensure that every element of the span is assigned. Otherwise, the resulting string could contain random characters.

// DeepL翻訳
アクションに渡されるデスティネーション・スパンの初期コンテンツは未定義です。従って、スパンの全ての要素が代入されることを確実にするのは、デリゲートの責任です。さもなければ、結果の文字列にはランダムな文字が含まれる可能性があります。

String.Create Method (System) | Microsoft Learn