UnityWebRequestのDownloadHandler.nativeDataを用いたコピーの回避による最適化について
去年のC#アドベントカレンダーで、System.Text.Jsonのソース生成をUnityで試す | Yucchiy's Noteという記事を書きました。
この記事中で、DownloadHandler.nativeDataを用いてJSONシリアライズ時のコピー回避による最適化について触れていましたが、こちらについてもう少し詳しく触れておきたいと思います。
記事中の実装は、Unity 2021.3.16f1で検証しています。
DownloadHandlerでのデータの取り扱いについて
UnityでHTTP通信を行うためのUnityWebRequestでは、実際にダウンロードされたデータをDownloadHandlerクラスを経由して扱うことができます。
DownloadHandlerダウンロードしたデータを取得するためのAPIがtext・data・nativeDataと、3つあります。
textはダウンロードしたデータをUTF8文字列として返却します。2021.3時点では、textプロパティは内部的にGetTextメソッドを呼び出していますが、このメソッドは下記のような実装になっています。
protected virtual unsafe string GetText()
{
NativeArray<byte> nativeData =
this.GetNativeData();
return nativeData.IsCreated && nativeData.Length > 0 ?
new string(
(sbyte*) nativeData.GetUnsafeReadOnlyPtr<byte>(),
0, nativeData.Length,
this.GetTextEncoder()
) : "";
}unsafeなどがあって若干ややこしいですが、元データ(this.GetNativeData()で取得したNativeArray<byte>)からstringを生成して返却しています。
dataはデータのバイナリデータをbyte[]として返却します。dataプロパティはGetDataメソッドを呼び出していますが、このメソッドは内部的にInternalGetByteArrayを呼び出しています。下記がその実装です。
internal static byte[] InternalGetByteArray(DownloadHandler dh)
{
NativeArray<byte> nativeData = dh.GetNativeData();
return nativeData.IsCreated ?
nativeData.ToArray() :
(byte[]) null;
}ToArray()はNativeArray<T>の内部で保持しているデータをコピーして配列として返却します。下記の実装になっています。
public T[] ToArray()
{
T[] dst = new T[this.Length];
NativeArray<T>.Copy(this, dst, this.Length);
return dst;
}一方で、nativeDataもdataと同様にバイナリデータを返却します。ではdataとの違いは?というと、dataはbyte[]を返すのですがnativeDataは元データをNativeArray<byte>.ReadOnlyとして返却します。
このときnativeDataは、ドキュメントにある通りをアロケーションフリーで元データを返却します。
エンジン側で確保されたbyte配列なのですが、その領域を直接指し示すNativeArray<byte>.RaadOnlyを返却します。C#側で安全に扱える元データのポインターを返す、といったイメージでしょうか。
長々と説明しましたが、まとめるとdataやtextは元データをコピーして返却するのに対して、nativeDataは元データの参照をNativeArray<T>.ReadOnlyを返すことで、元データのコピーを回避できます。
NativeArray<T>からSpan<T>への変換
NativeArray<T>は文字通り配列のため、下記のような処理でSpan<T>として扱えます。
unsafe
{
var data = uwr.nativeData;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(data.GetUnsafeReadOnlyPtr(), data.Length);
}Unity 2022.2以降では、NativeArray<T>にAsSpanメソッドが用意されました。このメソッドはunsafeでないため、下記のようにunsafe外でも普通に利用できます。
var data = uwr.nativeData;
ReadOnlySpan<byte> span = data.AsReadOnlySpan();参考: 【Unity】NativeArray
Span<T>を経由した、シリアライザーのコピー回避
UnityWebRequestでダウンロードしたデータを、nativeDataを用いてコピーフリーでアクセスでき、さらにNativeArray<T>をSpan<T>に変換することで、Span<T>として引き回せることがわかりました。
では、これでなにが嬉しいのかというと、JSONやMessagePackなどのシリアライザーがSpan<T>を受け取る口さえあれば、UnityWebRequestが確保した元データをコピー無しで直接渡すことができる点です。
たとえばSystem.Text.JsonパッケージのJsonSerializerのデシリアライズメソッドは、ReadOnlySpan<byte>を受け取るメソッドがあります。このメソッドに、下記のようにnativeDataを渡すことで、dataと比べて配列コピーのアロケーションコストを抑えることができます。
System.Text.Jsonなどの利用方法については、こちらの記事にて詳細を確認ください。
// handlerはDownloadHandlerとする
// 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();
// 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();
当然ですが、他のシリアライザーでも、Span<byte>を渡すAPIさえあればこのテクニックは利用できます。たとえば、C#向けのシリアライザーであるMemoryPackでは下記のように実装できます。
// 下記クラスがあるとして...
[MemoryPackable]
public partial class Person
{
public int Age { get; set; }
public string Name { get; set; }
}
// APIからMemoryPackバイナリがふってきているとする
// 2022.2からはAsSpanを用いて、とてもシンプルに扱える
var val = MemoryPackSerializer.Deserialize<Person>(handler.nativeData.AsSpan());
Debug.Log($"{val.Age}, {val.Name}");まとめ
UnityWebRequestのDownloadHandler.nativeDataについてdataとの違いを説明しつつ、その実用例として、コピーコストを抑える最適化事例を紹介しました。
2022.2では、NativeArray<T>.AsSpan()が入ることによって、NativeArray<T>とSpan<T>の変換も簡単になることも説明しました。