はなちるのマイノート

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

【Unity】ZStringを導入してマネージドヒープへのアロケーションを削減する

はじめに

今回はZStringという文字列生成におけるGC.Allocをゼロにするライブラリについて紹介したいと思います。

今回、文字列生成におけるメモリアロケーションをゼロにする「ZString」というライブラリを公開しました。

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


github.com

触った感じ特に大きなデメリットもなさそうですし、積極的に導入して良いライブラリかもしれません。

環境

Unity 2022.2.1f1
ZString 2.5.0

対応バージョン

2021.3 or later

Supporting minimum Unity version is 2021.3.

GitHub - Cysharp/ZString: Zero Allocation StringBuilder for .NET Core and Unity.

導入

unitypackageの利用

以下のReleaseページから最新のものをダウンロードしてUnityへインポートしてください。
github.com

ZStringSystem.Runtime.CompilerServices.Unsafe.dllへの依存があります。

ただ.unitypackageの中に同梱されている(Pluginsの中)ので、気にする必要はありません。

packagemanager(manifest.json)

PakcageManagerを利用して以下のコマンドを打ち込みます。

https://github.com/Cysharp/ZString.git?path=src/ZString.Unity/Assets/Scripts/ZString

しかしこの場合はSystem.Runtime.CompilerServices.Unsafe.dllを自分で導入する必要があります。

Nuget for UnityNuGet importer for UnityなどNuGetのパッケージをUnityへ導入するツールを有志の方が開発されていますが、今回はnuget.orgから取ってきてUnityに入れます。

www.nuget.org

Download packageボタンを押し、nupkgファイルをダウンロード。

拡張子を.zipに変換して展開後、lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dllをUnityのPluginsフォルダの中に入れます。

System.Runtime.CompilerServices.Unsafe.dllの導入

基礎的な使い方

まずは簡単な使い方について、readmeを参考にしながら実験結果付きで載せておきます。
GitHub - Cysharp/ZString: Zero Allocation StringBuilder for .NET Core and Unity.

[SerializeField] private TextMeshProUGUI tmp;

private void Start()
{
    int x = 0;
    int y = 1;
    int z = 2;

    // GC.Alloc : 60B + 32B(String.Format) = 92B
    _ = $"{x}+{y}+{z}";

    // x + y + zと同じ意味
    // GC.Alloc : 28B(ZString.Concat)
    // NOTE: String.FastAllocateStringによるGC.Allocみたい
    _ = ZString.Concat(x, y, z);


    // GC.Alloc : 66B(String.Format) + 60B = 126B
    _ = String.Format("x:{0}, y:{1:000}, z:{2:P}", x, y, z);

    // GC.Alloc : 66B(ZString.Format)
    // ZString.Concat同様にString.FastAllocateStringによるGC.Alloc
    _ = ZString.Format("x:{0}, y:{1:000}, z:{2:P}", x, y, z);


    // GC.Alloc : 116B + 104B(String.Join) = 220B
    _ = String.Join(',', x, y, z);

    // GC.Alloc : 44B + 32B(ZString.Join) = 76B
    _ = ZString.Join(',', x, y, z);


    // TMPro名前空間
    // GC.Alloc : 60B + 56B(String.Format) = 116B ??
    tmp.text = String.Format("Position: {0}, {1}, {2}", x, y, z);

    // Cysharp.Text名前空間
    // GCAlloc : 122B ??
    tmp.SetTextFormat("Position: {0}, {1}, {2}", x, y, z);


    // System.Text.StringBuilder.StringBuilder
    // GC.Alloc : 492B
    {
        var sb = new StringBuilder();
        sb.Append("foo");
        sb.AppendLine("42");
        sb.AppendFormat("{0} {1:.###}", "bar", 123.456789);
        var str = sb.ToString();

        // GC.Alloc : 122B
        tmp.SetText(sb);
    }

    // Create StringBuilder
    // GC.Alloc : 178B
    using (var sb = ZString.CreateStringBuilder())
    {
        sb.Append("foo");
        sb.AppendLine(42);
        sb.AppendFormat("{0} {1:.###}", "bar", 123.456789);

        // and build final string
        var str = sb.ToString();

        // for Unity, direct write to TextMeshPro
        // GC.Alloc : 122B
        tmp.SetText(sb);
    }


    // prepare format, return value should store to field(like RegexOptions.Compile)
    // 272B(ZString.PrepareUtf16) + 44B = 316B
    var prepared = ZString.PrepareUtf16<int, int>("x:{0}, y:{1:000}");
    _ = prepared.Format(10, 20);


    // C# 8.0, Using declarations
    // create Utf8 StringBuilder that build Utf8 directly to avoid encoding
    using var sb2 = ZString.CreateUtf8StringBuilder();
    sb2.AppendFormat("foo:{0} bar:{1}", x, y);

    // directly write to steam or dest to avoid allocation
    //await sb2.WriteToAsync(stream);
    //sb2.CopyTo(bufferWritter);
    //sb2.TryCopyTo(dest, out var written);
}

以下のメソッドを利用しましたが、全体的にGC.Allocを抑えることができています。

  • ZString.Concat
  • ZString.Format
  • ZString.Join
  • Cysharp.Text.TextMeshProExtensions.SetTextFormat
  • ZString.CreateStringBuilder
  • ZString.PrepareUtf16
  • ZString.CreateUtf8StringBuilder

ただCysharp.Text.TextMeshProExtensions.SetTextFormatString.Formatを使ったTMP_Text.textへの代入だと、逆になってしまっていました。私の使い方が間違っているのだと思いますが、要調査ですね。

用意されているAPI

readmeに一覧でまとめてあります。
github.com

まだZString歴が短すぎる私が、独断と偏見でいくつかセレクションしてみました。

[SerializeField] private TextMeshProUGUI tmp;

private void Start()
{
    // ZString.CreateStringBuilder
    using (Utf16ValueStringBuilder sb = ZString.CreateStringBuilder())
    {
        // Utf16ValueStringBuilder.Append
        sb.Append("a");

        // Utf16ValueStringBuilder.AppendJoin
        sb.AppendJoin(',', 1, 2);

        // Utf16ValueStringBuilder.AppendFormat
        sb.AppendFormat("x:{0}", 1);

        // Utf16ValueStringBuilder.Length
        Debug.Log(sb.Length); // 7

        // Utf16ValueStringBuilder.AsSpan
        ReadOnlySpan<char> span = sb.AsSpan();

        // Utf16ValueStringBuilder.AsMemory
        // 補足 : Spanと違ってclassのフィールドにしたりasync/awaitで使える奴
        ReadOnlyMemory<char> memory = sb.AsMemory();

        // Utf16ValueStringBuilder.TryCopyTo
        // お試しでNativeArrayを使ってみたり
        NativeArray<char> nativeArray =
            new NativeArray<char>(sb.Length, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        sb.TryCopyTo(nativeArray, out var charsWritten);
        Debug.Log(charsWritten); // 7
        nativeArray.Dispose();

        // Utf16ValueStringBuilder.ToString
        Debug.Log(sb.ToString()); // a1,2x:1
    }

    // ZString.CreateStringBuilder(bool notNested)
    // ネストしない場合はnotNestedをtrueにすると高速に動作する
    using (Utf16ValueStringBuilder sb = ZString.CreateStringBuilder(notNested: true))
    {
        sb.Append("aaa");
        sb.Append("bbb");
        Debug.Log(sb.ToString()); //aaabbb
    }

    // ZString.Join
    Debug.Log(ZString.Join(',', 1, 2, 3)); // 1,2,3

    // ZString.Concat
    Debug.Log(ZString.Concat(1, 2, 3)); // 123

    // ZString.Format
    Debug.Log(ZString.Format("x:{0}", 1)); //x:1

    // ZString.PrepareUtf16
    // Utf16PreparedFormat.Format
    var prepared = ZString.PrepareUtf16<int, int>("x:{0}, y:{1:000}");
    Debug.Log(prepared.Format(1, 2)); // x:1, y:002
    Debug.Log(prepared.Format(3, 4)); // x:3, y:004
    Debug.Log(prepared.Format(5, 6)); // x:5, y:006

    // TextMeshProExtensions.SetText
    using (var sb = ZString.CreateStringBuilder(notNested: true))
    {
        // notNestedがtrueのときはZString.Concat/Join/Formatを使ってはダメなことに注意
        sb.Append("aaa");
        sb.Append("bbb");

        tmp.SetText(sb); // aaabbb
    }

    // TextMeshProExtensions.SetTextFormat
    tmp.SetTextFormat("Position: {0}, {1}, {2}", 1, 2, 3); // Position: 1, 2, 3
}

ネットワークやファイル入出力等においてEncoding.UTF8.GetBytes(stringBuilder.ToString())をするくらいならこちらを。

using(var sb = ZString.CreateUtf8StringBuilder())
using(var fs = File.Open("foo.txt", FileMode.OpenOrCreate))
{
    sb.Append("foo");
    sb.AppendLine(42);
    sb.AppendFormat("{0} {1:.###}", "bar", 123.456789);
 
    // write inner Utf8 buffer to stream
    await sb.WriteToAsync(fs);
 
    // or get inner buffer
    // .AsSpan(), .AsMemory(), TryCopyTo
}

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

StringBuilderのネストについて

ZString.CreateStringBuilder(notNested:true)というものがあり、ネストをしない場合はより高速に実行できるとのこと。

// OK, return buffer immediately.
using(var sb = ZString.CreateStringBuilder(true))
{
    sb.Append("foo");
    return sb.ToString();
}
// NG
using(var sb = ZString.CreateStringBuilder(true))
{
    sb.Append("foo");

    using var sb2 = ZString.CreateStringBuilder(true); // NG, nested stringbuilder uses conflicted same buffer
    var str = ZString.Concat("x", 100); // NG, ZString.Concat/Join/Format uses threadstatic buffer
}

GitHub - Cysharp/ZString: Zero Allocation StringBuilder for .NET Core and Unity.

Utf16ValueStringBuilderやUtf8ValueStringBuilderを引数で渡す

Utf16ValueStringBuilderUtf8ValueStringBuildermutableな構造体なので、参照渡しをしなければいけません。

void Build()
{
    var sb = ZString.CreateStringBuilder();
    try
    {
        BuildHeader(ref sb);
        BuildMessage(ref sb);
    }
    finally
    {
        // when use with `ref`, can not use `using`.
        sb.Dispose();
    }
}


void BuildHeader(ref Utf16ValueStringBuilder builder)
{
    //..
}

void BuildMessage(ref Utf16ValueStringBuilder builder)
{
    //..
}

GitHub - Cysharp/ZString: Zero Allocation StringBuilder for .NET Core and Unity.

文字列結合の注意点

// NG : 54B
// OK : 0B
using (var sb = ZString.CreateStringBuilder())
{
    // NG
    sb.Append("aaa" + 2);
            
    // OK
    sb.Append("aaa");
    sb.Append(2);
}

極力+の利用を抑える。

仕組み

作者の河合さんが記事を書いています。
tech.cygames.co.jp