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がtextdatanativeDataと、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;
}

一方で、nativeDatadataと同様にバイナリデータを返却します。ではdataとの違いは?というと、databyte[]を返すのですがnativeDataは元データをNativeArray<byte>.ReadOnlyとして返却します。

このときnativeDataは、ドキュメントにある通りをアロケーションフリーで元データを返却します。

エンジン側で確保されたbyte配列なのですが、その領域を直接指し示すNativeArray<byte>.RaadOnlyを返却します。C#側で安全に扱える元データのポインターを返す、といったイメージでしょうか。

長々と説明しましたが、まとめるとdatatextは元データをコピーして返却するのに対して、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へ変換する方法(2022.2以前はunsafe, 2022.2以降はAsSpan) - はなちるのマイノート 参考: Unity 2021.2 から新しく使えるようになったC#のクラスを眺める - Qiita

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();

「From UTF-8 Bytes」より「From UTF-8 Bytes With Native Array」のほうがGC Allocを抑えられていることが確認できる

当然ですが、他のシリアライザーでも、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>の変換も簡単になることも説明しました。