この記事は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から持ってくる必要があります。具体的には下記のパッケージをすべてインストールする必要があります。
- System.Text.Json
- System.Text.Encodings.Web
- System.Runtime.CompilerServices.Unsafe
- Microsoft.Bcl.AsyncInterfaces
それぞれ上記のページに飛んで「Download package」をクリックしてnupkgを落としてきます。
次にnupkgをzipとして解凍します。Macであればファイル名末尾にzipを付けたあとにそのファイルをダブルクリックで解凍できます。解凍後のフォルダをひらくと、lib/netstandard2.0
または lib/netstandard2.1
フォルダ内にパッケージ名と同じ名前のdllがあるので、Unityプロジェクト配下に配置します(下図はSystem.Text.Json
パッケージの例)。
上記のDLLをすべてUnityプロジェクトに配置すると、いままでのリフレクションベースな System.Text.Json
が利用できます。下記コードのようにJsonSerializer
のSerialize
とDeserialize
を呼び出すことで、クラスと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プロジェクトに配置します。
次にプロジェクトビューでSystem.Text.Json.SourceGenerator.dllを選択して、インスペクター上で下記の設定を行います。
- 「Select platforms for plugin」ですべてのプラットフォームのチェックを外す
- 「Asset Labels」に「RoslynAnalyzer」を設定する
具体的には、下図のように設定します。
これでソース生成を含めた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
クラスをソース生成経由でシリアライズとデシリアライズしてみます。大まかには下記の手順です。
JsonSerializeContext
を継承したpartialなクラスを作る- 1.で作ったクラスに
JsonSerializable
属性を指定する - 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
に渡したメソッドの処理を、複数回実行して計測し、その平均や分散などを下図のように報告してくれます。
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
と比べると、たとえばシリアライズの対象がシリアライズ可能なフィールドのみなどと自由度が低いというデメリットはありますが、高速にシリアライズ・デシリアライズが行えるなどのメリットがあります。
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バイト配列を受け取ることで無駄なエンコーディングを省くことができます。
ちなみにこれはデシリアライズ時にも同じことが言えます。 UnityWebRequest
のDownloadHandler
は、レスポンスをdata
とtext
とというメソッド経由で取得でき、それぞれレスポンスのコピーとレスポンス文字列に変換したデータが受け取れます。それぞれ 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
を参照することでコピーを避ける事ができます。またJsonSerializer
の Deserialize
はReadOnlySpan<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が小さくなっているのが確認できます。
余談ですが、 Unity 2022.2では NativeArray<T>
からSpan<T>
を生成するAPIが用意されました。
Unity - Scripting API: Unity.Collections.NativeArray_1.AsSpan
【Unity】NativeArray
こちらを用いることで、下記のように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つのシリアライズ、デシリアライズのパフォーマンスについて、 Person
・ Primitives
・ NestCase
の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で導入することを検討しておられる方の参考になれば幸いです。