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>
の変換も簡単になることも説明しました。