System.Text.Jsonのソース生成をUnityで試す

この記事はC# Advent Calendar 2022の23日目の記事です。

Unity 2021.2 から Source Generatorが利用できるようになりました。

これによってUnityでも、Microsoftが提供するJsonシリアライザー System.Text.Json のソース生成を利用できるようになりました。

System.Text.Json のソース生成は、C#のSource Generatorを用いてシリアライズおよびデシリアライズのコードをビルド時に生成することで、シリアライザーのパフォーマンスを向上させるものです。

Try the new System.Text.Json source generator - .NET Blog

この記事では、Unityで System.Text.Json のソース生成を利用するための環境構築と、実際にコード生成を用いたJSONシリアライズ・デシリアライズの動作確認、最後にUnityアプリとして実機で動かしてそのパフォーマンスを比較してみます。あわせてUnity公式が提供するJSONシリアライザーの JsonUtility ともパフォーマンスの比較を行ってみます。

検証環境は以下の通りです。

  • macOS Monterey 12.5
  • MacBookPro(2018)、2.9GHz 6-Core Intel Core i9、32GB
  • Unity 2021.3.14f1
  • Rider 2022.2
  • iPhone X

ちなみに、Unity 2022.2.0b16では動作が確認できませんでした。 (おそらく内部のSource Generatorのバージョンが変わった?)

System.Text.JsonをUnityに導入する

前述の通り、Unity 2021.2からSource Generatorが利用できるようになりました。

Unity - Manual: Roslyn analyzers and source generators

Unityでは現状System.Text.Jsonの 6.0.0-previewが利用できます。下記はドキュメントからの抜粋です。

このバージョンから、ソース生成によるシリアライズとデシリアライズがサポートされています。ちなみに6.0.0でのソース生成は、UnityがサポートしているMicrosoft.CodeAnalysis 3.8よりも後のバージョン(3.11)が利用されているため利用できないようです(おそらく)。

UnityでSystem.Text.Jsonを利用するには、System.Text.Json本体とそれが依存するパッケージをすべてnugetから持ってくる必要があります。具体的には下記のパッケージをすべてインストールする必要があります。

それぞれ上記のページに飛んで「Download package」をクリックしてnupkgを落としてきます。

次にnupkgをzipとして解凍します。Macであればファイル名末尾にzipを付けたあとにそのファイルをダブルクリックで解凍できます。解凍後のフォルダをひらくと、lib/netstandard2.0または lib/netstandard2.1フォルダ内にパッケージ名と同じ名前のdllがあるので、Unityプロジェクト配下に配置します(下図はSystem.Text.Jsonパッケージの例)。

09ACD05FCE95B9E3B2D55CB764D9B6D7

上記のDLLをすべてUnityプロジェクトに配置すると、いままでのリフレクションベースな System.Text.Jsonが利用できます。下記コードのようにJsonSerializerSerializeDeserializeを呼び出すことで、クラスとJSONの相互変換ができます。

public class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

var p = new Person
{
    Age = 40,
    Name = "John",
};
// シリアライズ
var json = JsonSerializer.Serialize(p);
// {"Age":40,"Name":"John"}
Debug.Log($"{json}");

// デシリアライズ
var val = JsonSerializer.Deserialize<Person>(json);
// 40, John
Debug.Log($"{val.Age}, {val.Name}");

次に、System.Text.Jsonのソース生成機能をUnityに持ってきます。さきほど解凍したSystem.Text.Jsonのnupkgの中に、下図のようにSystem.Text.Json.SourceGeneration.dllがあるので、これをUnityプロジェクトに配置します。

62E0EC4E0F305E035E1C50CA79BE7EA6

次にプロジェクトビューでSystem.Text.Json.SourceGenerator.dllを選択して、インスペクター上で下記の設定を行います。

  1. 「Select platforms for plugin」ですべてのプラットフォームのチェックを外す
  2. 「Asset Labels」に「RoslynAnalyzer」を設定する

具体的には、下図のように設定します。

B102B22AF45A7E5C6473C0DB73AD26C6

これでソース生成を含めたSystem.Text.Jsonの導入が完了しました。

ソース生成でのJSON

のシリアライズ・デシリアライズを試す

それではSource Generatorでのソース生成とシリアライズ・デシリアライズを試してみます。ソース生成経由でのシリアライズは、下記ドキュメントに詳細が記載されています。

How to use source generation in System.Text.Json | Microsoft Learn

また、下記ブログでも利用方法が説明されています。

Try the new System.Text.Json source generator - .NET Blog

実際に、先程のPersonクラスをソース生成経由でシリアライズとデシリアライズしてみます。大まかには下記の手順です。

  1. JsonSerializeContextを継承したpartialなクラスを作る
  2. 1.で作ったクラスにJsonSerializable属性を指定する
  3. 2.で作成したクラスにSource Generator経由でJsonTypeInfo<T>の実装などが生成されるので、それをシリアライズ・デシリアライズの引数に渡す

具体的には、シリアライズしたいクラスをさきほどのPersonとして、下記のようなクラスを用意します。

[JsonSerializable(typeof(Person))]
internal partial class PersonJsonContext : JsonSerializerContext {}

このクラスを用いて、ソース生成を用いたシリアライズは下記のように行います。

var p = new Person
{
    Age = 40,
    Name = "John",
};

// シリアライズ
var json = JsonSerializer.Serialize(
    p,
    // 生成されたJsonTypeInfoを渡す
    PersonJsonContext.Default.Person
);
// {"Age":40,"Name":"John"}
Debug.Log($"{json}");

// デシリアライズ
var val = JsonSerializer.Deserialize(
    json,
    // こっちも同様
    PersonJsonContext.Default.Person
);
// 40, John
Debug.Log($"{val.Age}, {val.Name}");

シリアライズおよびデシリアライズ時に生成されたJsonTypeInfo<T>(上記だとPersonJsonContext.Default.Person)渡します。それ以外は特に変わりません。簡単ですね。

パフォーマンス比較

ここまででソース生成によるシリアライズとデシリアライズの方法を紹介したので、ソース生成の有無によるパフォーマンスを比較してみます。下記の3種類のクラスをそれぞれシリアライズ・デシリアライズしたときの速度を計測してみます。

// サンプルで取り上げたシンプルなケース
public class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

// 一通りの基本型を持ったクラス
public class Primitives
{
    public short Short { get; set; }
    public int Int { get; set; }
    public long Long { get; set; }
    public byte Byte { get; set; }
    public bool Bool { get; set; }
    public char Char { get; set; }
    public float Float { get; set; }
    public double Double { get; set; }
    public string String { get; set; }
    
    // シリアライズ前に呼ぶ
    public void InitializerPrimitives()
    {
        var rnd = new System.Random();
        Short = (short) rnd.Next();
        Int = (int) rnd.Next();
        Long = (short) rnd.Next();
        Byte = (byte) rnd.Next();
        Char = (char) rnd.Next();
        Float = (float) rnd.NextDouble();
        Double = rnd.NextDouble();
        String = StringUtils.GeneratePassword(100);
    }
}

// クラスがネストするケース
public class NestCase
{
    public class Inner
    {
        public int Int { get; set; }
        public double Double { get; set; }
        public string String { get; set; }
        public Inner()
        {
            var rnd = new System.Random();
            Int = rnd.Next();
            Double = rnd.NextDouble();
            String = StringUtils.GeneratePassword(100);
        }
    }
    
    public Inner A { get; set; }
    public Inner B { get; set; }
    public Inner C { get; set; }
    public Inner D { get; set; }
    public Inner E { get; set; }
    public Inner F { get; set; }
    public Inner G { get; set; }
    public Inner H { get; set; }
    public Inner I { get; set; }

    // シリアライズ前に呼ぶ
    public void InitializeNestCase()
    {
        A = new Inner();
        B = new Inner();
        C = new Inner();
        D = new Inner();
        E = new Inner();
        F = new Inner();
        G = new Inner();
        H = new Inner();
        I = new Inner();
    }
}

ただし、JsonUtilityではフィールドのみシリアライズの対象のため、上記のクラスをシリアライズできません。

今回の計測では、できるだけ同様なJSONをシリアライズ・デシリアライズするような下記の型を用意して、 JsonUtilityの計測ではこちらを利用して計測を行いました。

[Serializable]
public class PersonForJsonUtility
{
    public int Age;
    public string Name;
}

[Serializable]
public class PrimitivesForJsonUtility
{
    public short Short;
    public int Int;
    public long Long;
    public byte Byte;
    public bool Bool;
    public char Char;
    public float Float;
    public double Double;
    public string String;

    public void InitializePrimitives()
    {
        var rnd = new System.Random();
        Short = (short) rnd.Next();
        Int = (int) rnd.Next();
        Long = (short) rnd.Next();
        Byte = (byte) rnd.Next();
        Char = (char) rnd.Next();
        Float = (float) rnd.NextDouble();
        Double = rnd.NextDouble();
        String = StringUtils.GeneratePassword(100);
    }
}

[Serializable]
public class NestCaseForJsonUtility
{
    [Serializable]
    public class Inner
    {
        public int Int;
        public double Double;
        public string String;
        public void SetInner()
        {
            var rnd = new System.Random();
            Int = rnd.Next();
            Double = rnd.NextDouble();
            String = StringUtils.GeneratePassword(100);
        }
    }
    
    public Inner A;
    public Inner B;
    public Inner C;
    public Inner D;
    public Inner E;
    public Inner F;
    public Inner G;
    public Inner H;
    public Inner I;

    public void InitializeNestCase()
    {
        A = new Inner();
        A.SetInner();
        B = new Inner();
        B.SetInner();
        C = new Inner();
        C.SetInner();
        D = new Inner();
        D.SetInner();
        E = new Inner();
        E.SetInner();
        F = new Inner();
        F.SetInner();
        G = new Inner();
        G.SetInner();
        H = new Inner();
        H.SetInner();
        I = new Inner();
        I.SetInner();
    }
}

実機上でのパフォーマンス計測について

計測はiOSの実機上で行います。実機上でのパフォーマンス計測にはPerformance Testing Extensionという、Unity Test Runner上でパフォーマンス計測を行ってくれるツールを用いました。

たとえば下記のようなコードを記述して、

[Test, Performance]
public void SimplePerson_Serialize_SourceGenerator_Check()
{
    Measure.Method(() =>
    {
        for (int i = 0; i < 1000; i++)
        {
            JsonSerializer.Serialize(
                PersonTests[i], PersonJsonContext.Default.Person);
        }
    }).Run();
}

Test Runnerを実行することで、上記のMeasure.Methodに渡したメソッドの処理を、複数回実行して計測し、その平均や分散などを下図のように報告してくれます。

8C9B13DD254F5B30CAA532C92A7F95E6

Performance Testing Extensionについては下記の記事にてインストール方法や利用方法を記載していますので、興味があればご確認ください。

Performance Testing Extensionを用いてUnityで実機でパフォーマンス計測をおこなう | Yucchiy's Note

計測対象について

今回は下記の4つでパフォーマンスを比較しました。

  • ソースコード生成なしのSystem.Text.Jsonでの文字列へのシリアライズとデシリアライズ
  • ソースコード生成ありのSystem.Text.Jsonでの文字列へのシリアライズとデシリアライズ
  • ソースコード生成ありのSystem.Text.JsonでのUTF-8 バイト配列へのシリアライズとデシリアライズ
  • JsonUtilityでの文字列へのシリアライズとデシリアライズ

JsonUtilityについて

JsonUtilityはUnityが標準で用意するJSONシリアライザーです。System.Text.Jsonと比べると、たとえばシリアライズの対象がシリアライズ可能なフィールドのみなどと自由度が低いというデメリットはありますが、高速にシリアライズ・デシリアライズが行えるなどのメリットがあります。

JSON 形式へのシリアル化 - Unity マニュアル

JsonUtilityの使い方については下記の記事にて紹介していますので、こちらも興味があればご確認ください。

JsonUtilityでオブジェクトをシリアライズしたりデシリアライズしたりする | Yucchiy's Note

SerializeToUtf8BytesによるUTF-8バイト配列のシリアライズ

C# を使用して JSON をシリアル化および逆シリアル化する方法 - .NET | Microsoft Learn

今回System.Text.Jsonが用意する JsonSerializerで、UTF-8 バイト配列へのシリアライズとUTF-8 バイト配列からのデシリアライズもパフォーマンス計測の対象に入れました。

C#の文字列はその内部フォーマットがUTF-16なため、たとえば JsonSerializer.Serializeを呼び出して結果をstringで受け取る場合は、一度UTF-8としてシリアライズした後にstringに変換するため際にUTF-16へのエンコーディングが行われます。

JSONは文字コードがUTF-8を想定しているため、たとえばJSONフォーマットなAPI通信をUnity標準のHTTPクライアントUnityWebRequestを用いると、下記のようなコードを書くことになります。

// requestはAPIのリクエストを表すインスタンスだとする
// シリアライズしてUTF-8バイト配列から文字列(UTF-16)に変換
var json = JsonSerializer.Serialize(request);
// 文字列(UTF-16)からUTF-8にバイト配列に再度変換
var data = System.Text.Encoding.UTF8.GetBytes(json);

// ここからUnityWebRequest
var uwr = UnityWebRequest.Post(url);
uwr.uploadHandler = (UploadHandler)new UploadHandlerRaw(data);
uwr.SetRequestHandler("Content-Type", "application/json");
// リクエスト結果待ち
yeld return uwr.SendWebRequest();

シリアライズ時にUTF-8バイト配列からUTF-16に変換したのち、さらにUTF-8バイト配列にエンコードしなおしてリクエストする必要があります。つまり文字列を挟むと2度余計な変換をしていることになります。

これをSerializeToUtf8Bytesを用いると下記のように記述できます。

// シリアライズ結果をそのままUTF-8バイト配列を受け取る
var data = JsonSerializer.SerializeToUtf8Bytes(request);

// UnityWebRequest
var uwr = UnityWebRequest.Post(url);
// そのままUTF-8バイト配列を渡せる
uwr.uploadHandler = (UploadHandler)new UploadHandlerRaw(data);
uwr.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
uwr.SetRequestHandler("Content-Type", "application/json");
// リクエスト結果待ち
uwr.SendWebRequest();

このように直接UTF-8バイト配列を受け取ることで無駄なエンコーディングを省くことができます。

ちなみにこれはデシリアライズ時にも同じことが言えます。 UnityWebRequestDownloadHandlerは、レスポンスをdatatextとというメソッド経由で取得でき、それぞれレスポンスのコピーとレスポンス文字列に変換したデータが受け取れます。それぞれ JsonSerizlier でデシリアライズでの下記のコードで行えます。

// 文字列(UTF-16)をデシリアライズ
Profiler.BeginSample("From Text");
var text = handler.text;
var p = JsonSerializer.Deserialize<Product>(
    text, ProductJsonContext.Default.Product);
Profiler.EndSample();

// UTF-8バイト配列をデシリアライズ
// JSONの場合はUTF-8という前提で
Profiler.BeginSample("From UTF-8 Bytes");
var utf8Bytes = handler.data;
p = JsonSerializer.Deserialize<Product>(
    utf8Bytes,
    ProductJsonContext.Default.Product);
Profiler.EndSample();

textをデシリアライズすると、元データ(UTF-8バイト配列)→ 文字列(UTF-16)→ UTF-8バイト配列に変換 → デシリアライズとなりますが、dataをデシリアライズすると、UTF-8バイト配列を直接デシリアライズできるため、無駄な処理を省くことができます。

ちなみに DownloadHandlerにはnativeDataという、元データをNativeArray<byte>をリードオンリーで参照できるプロパティが存在します。 dataは呼び出しごとにこのnativeData をコピーしてしまうのですが、 nativeDataを参照することでコピーを避ける事ができます。またJsonSerializerDeserializeReadOnlySpan<byte>を直接みてデシリアライズできます。

これを組み合わせると、UnsafeなコードにはなりますがReadOnlySpan<byte>を介して、元データのコピーを行わずそのままデシリアライズが可能です。具体的には、下記のようにNativeArray<T>からReadOnlySpan<T>を引き抜いて、それをDeserializeに渡すことで実現できます。

// UTF-8バイト配列を、元のデータを直接参照してデシリアライズ
// このとき元配列をコピーしないので、その分アロケーションコストが抑えられる
Profiler.BeginSample("From UTF-8 Bytes With Native Array");
unsafe
{
    var utf8BytesSpan = new ReadOnlySpan<byte>(
        handler.nativeData.GetUnsafeReadOnlyPtr(),
        handler.nativeData.Length);
    p = JsonSerializer.Deserialize<Product>(
        utf8BytesSpan, ProductJsonContext.Default.Product);
}
Profiler.EndSample();

上の3つのデシリアライズのプロファイリングを取ると下図のようになりました。nativeData経由だと、特にGC Allocが小さくなっているのが確認できます。

ED3DEAB5B24E6396C6D5ECE01FB5237B

余談ですが、 Unity 2022.2では NativeArray<T>からSpan<T>を生成するAPIが用意されました。

Unity - Scripting API: Unity.Collections.NativeArray_1.AsSpan

【Unity】NativeArrayからSpanへ変換する方法(2022.2以前はunsafe, 2022.2以降はAsSpan) - はなちるのマイノート

こちらを用いることで、下記のようにUnsafeなしに、さらにシンプルにコードが記述できるようになります。

// UTF-8バイト配列を、元のデータを直接参照してデシリアライズ
// このとき元配列をコピーしないので、その分アロケーションコストが抑えられる
Profiler.BeginSample("From UTF-8 Bytes With Native Array2");
// unsafeも外せるしシンプル
var utf8BytesSpan = handler.nativeData.AsSpan();
var p = JsonSerializer.Deserialize<Product>(
    utf8BytesSpan, ProductJsonContext.Default.Product);
Profiler.EndSample();

パフォーマンス計測結果

改めて、下記4つのシリアライズ、デシリアライズのパフォーマンスについて、 PersonPrimitivesNestCase の3つのクラスでのパフォーマンスの計測結果を比較します。

  • ソースコード生成なしのSystem.Text.Jsonでの文字列へのシリアライズとデシリアライズ
    • JsonSerializerと表記
  • ソースコード生成ありのSystem.Text.Jsonでの文字列へのシリアライズとデシリアライズ
    • JsonSerializerSrcGenと表記
  • ソースコード生成ありのSystem.Text.JsonでのUTF-8 バイト配列へのシリアライズとデシリアライズ
    • JsonSerializerSrcGenUtf8と表記
  • JsonUtilityでの文字列へのシリアライズとデシリアライズ
    • JsonUtilityと表記

上記のクラスのインスタンスをそれぞれ1000回シリアライズ・デシリアライズを1計測として、それを10回繰り返し、その平均を算出しました。例えばPersonクラスJsonSerializerSrcGenの計測コードは下記の通りです。

[Test, Performance]
public void SimplePerson_Serialize_SourceGenerator_Check()
{
    Measure.Method(() =>
    {
        for (int i = 0; i < 1000; i++)
        {
            JsonSerializer.Serialize(
                // PersonTests[i]は事前に生成したものを利用
                // すべてランダムな値を入れている。
                // 文字列は1~9、a-zA-Zでランダムな文字列を利用
                // (本当はユニコードも入れるべき...)
                PersonTests[i], PersonJsonContext.Default.Person);
        }
    })
        // 計測は10回繰り返す
        .MeasurementCount(10)
        .Run();
}

まずシリアライズについてです。単位はミリ秒です。それぞれのクラスで最も速かったシリアライズに対して太字を付けました。

Person
JsonSerializer 5.09
JsonSerializerSrcGen 2.29
JsonSerializerSrcGenUtf8 2.18
JsonUtility 3.27
Primitives
JsonSerializer 24.38
JsonSerializerSrcGen 9.38
JsonSerializerSrcGenUtf8 8.83
JsonUtility 5.77
NestCase
JsonSerializer 74.22
JsonSerializerSrcGen 29.92
JsonSerializerSrcGenUtf8 28.48
JsonUtility 28.93

次にデシリアライズです。

Person
JsonSerializer 4.49
JsonSerializerSrcGen 1.56
JsonSerializerSrcGenUtf8 1.36
JsonUtility 3.03
Primitives
JsonSerializer 19.23
JsonSerializerSrcGen 5.48
JsonSerializerSrcGenUtf8 4.99
JsonUtility 5.90
NestCase
JsonSerializer 71.76
JsonSerializerSrcGen 44.34
JsonSerializerSrcGenUtf8 42.88
JsonUtility 24.45

上記の結果から、下記のことが言えそうです。

  • System.Text.Jsonではソース生成を行うことで、速度改善が見られた。
    • シリアライズ処理に対しては概ね2〜2.5倍程度の速度向上が見られた。
    • デシリアライズは〜2倍程度。シリアライズ処理よりは大きな速度改善は見られなかった。
  • UTF-8バイト配列にシリアライズ、またはUTF-8バイト配列をデシリアライズすることで若干の速度改善は見られた。
    • こちらはGC Allocも合わせて改善した。
  • System.Text.Jsonのソース生成&UTF-8バイト配列による処理とJsonUtilityによる処理については、ケースによって結果が前後したが、特にデシリアライズはJsonUtilityが高速な傾向あった。
    • とくにNestCaseが顕著だった。

まとめ

Unity2021.2でSource Generatorが利用できるようになったので、Source Generatorによる最適化が入ったSystem.Text.Jsonの紹介とその導入方法、シリアライズとデシリアライズ方法、パフォーマンスについて比較しました。

また、System.Text.JsonでUTF-8バイト配列を直接シリアライザーに渡す・受け取ることで、UTF-16へのエンコーディング処理を省く最適化と、UnityWebRequestのNativeArray<T>Span<T>を用いたアロケーション削減についても触れました。

テストケースが少ないのであまりはっきりとしたことは言いづらいですが、Unityで利用できるJsonUtilityは、高速な反面カスタマイズや、UTF-8バイト配列を直接受け取る方法がないなどのデメリットがあるため、とくに通信APIなどでのJSONシリアライザーとしてSystem.Text.Jsonの利用は、カスタマイズ製やGC削減などの最適化の観点で、ありなのかなという感じがしました。

書いてみて(C# Advent Calendarの記事なのに)C#成分が少なくて、どっちかというとUnity Advent Calendarネタ感があり申し訳ない感じもしますが、UnityへのSystem.Text.JsonのSource Generator導入について扱っているブログが少なかった(調べた感じではなかった)ので、記事にさせていただきました。

System.Text.JsonをUnityで導入することを検討しておられる方の参考になれば幸いです。