VRアプリ開発における多人数アバター表示の軽量化対策|Array.Reverseの軽量関数実装とBinary -> Vector3変換の最適化

概要

ambrのCTO 藤田です。クライアントサイドのエンジニアをやっています。

ambrでは3月に、メタバースイベントのVRアプリを基盤構築プロダクトxambrを用いて開発しました。 https://prtimes.jp/main/html/rd/p/000000024.000043299.html イベント内ではEスポーツの大会が開催され、VR空間でのリアルなパブリックビューイング体験に近づけるような設計を行いました。 xambrではMagicOnionベースのゲームサーバーを構築し、 チャンネル内で多数のユーザ滞在を実現することができました。

今回はクライアント側で多人数のアバターを表示した際に遭遇したパフォーマンス対策の配列処理の最適化についてご紹介します。

開発環境

  • Unity 2021.1.28f

課題

同一のルーム多人数で多数のユーザ(アバター)が滞在しているときに、一定周期で画面がカクつく現象を確認しました。 滞在人数が増えたことで顕在化したようです。

調査

Profilerで調査すると、カクついているフレームではアバターの同期処理でスパイクが発生していました。

詳しく見たところ受信したbinaryからVector3やQuaternionに変換している処理GCが発生しており、アバターの増加に伴い大きなスパイクになっていたようです。
特にbinaryからfloatへ変換する処理GCを生じさせており頻繁に呼ばれていたため、
このあたりをなんとかすれば改善が見込めそうです。

binary -> floatの変換処理が重たい

修正前のbinary -> floatへ変換するコード

問題が2点ありました。

問題①:配列の要素を反転させるSystem.Array.Reverse()が呼び出されています。 並び替えの処理でヒープを確保してしまっているようで高コストの原因であると考えられました。

問題②:処理のたびに配列を確保しており、GCを生じさせてしまっています。

    /// <summary>
    /// Binaryから float をパース
    /// </summary>
    /// <param name="_binary">対象のbyte 配列</param>
    /// <param name="_offset">読み出し開始位置</param>
    /// <param name="_isLittleEndian">LittleEndianかどうか</param>
    /// <returns></returns>
    public static float ReadFloatFromBinary(in byte[] _binary, ref int _offset, bool _isLittleEndian = false)
    {
        float ret = 0;
        int length = sizeof(float);
        if (_offset + length > _binary.Length)
        {
            Debug.LogError("Out of Range");
            return ret;
        }
        //問題② 都度配列を確保してしまっている
        byte[] floatBinary = new byte[length];
        for (int i = 0; i < floatBinary.Length; i++)
        {
            floatBinary[i] = _binary[_offset + i];
        }
        if (!_isLittleEndian)
        {
            //問題① GCが発生する関数を呼び出してしまっている
            Array.Reverse(floatBinary);
        }
        ret = BitConverter.ToSingle(floatBinary, 0);

        _offset += length;

        return ret;
    }

問題① Array.Reverse()対策

GCを生じさせない軽量の関数を実装して呼び出しを置き換えました。
処理用の一時Listを用意して、同じインスタンスをClear()して使い回します。

ListはAddによって内包している配列以上の要素数が必要になったときはヒープが確保されますが
それを超えない限りはAddしてもGCが生じませんので、以下のようにClear()して使い回す場合はGCの発生を回避することができます。

ただし制約としてマルチスレッドで動かすと一時Listに対して整合性が保てないので、マルチスレッド化したい場合は追加の対応が必要になる点に注意です。

    /// <summary>
    /// Array.Reverse()の高速処理版
    /// 
    /// GCを発生させない代わりに、スレッド間で同時にアクセスしないことが保証される一時処理用のリストを必要とする
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="array">要素の並びを逆順に入れ替えたい配列。本処理実行後、要素が置き換わっていることに注意</param>
    /// <param name="workerList">作業用のリスト。GCを発生させないために繰り返し使うリスト。中身はクリアされることに注意。また、スレッド間で同時に実行すると意図しない結果を生むため実行してはならない</param>
    public static void FastReverse<T>(T[] array, List<T> workerList)
    {
        if (array.IsNullOrEmpty()) return;

        if (workerList == null) workerList = new List<T>();
        workerList.Clear();

        //要素を逆順に作業用リストに詰め込む
        for (int i = array.Length - 1; i >= 0; i--)
        {
            workerList.Add(array[i]);
        }

        //逆順にした要素を元の配列に格納する
        for (int i = 0; i < workerList.Count; i++)
        {
            array[i] = workerList[i];
        }
    }
    //予めListインスタンスを確保
    private List<byte> _workerList = new List<byte>(4);

    //呼び出し元
    //_workerListはClear()して使い回される
    ArrayUtil.FastReverse(floatBinary, _workerList);

テスト

Unity Test Runnerによるユニットテストで 挙動が同一であることを保証するようにしました。

問題② 配列確保対策

問題①解消と同様のアプローチで一時リストを引数に受け取ることで、配列生成コストを回避しました。 配列の要素数はfloatのバイトサイズにすべきであり固定で良いため配列にしています。 もし要素数が想定と異なる場合は生成しています。

    //呼び出し側はfloatのバイトサイズの要素数を持つbyte配列を渡す
    public byte[] workerFloatArray = new byte[sizeof(float)];

    public static float ReadFloatFromBinary(in byte[] _binary, ref int _offset, List<byte> _workerList, ref byte[] _workerFloatArray, bool _isLittleEndian = false)
    {
        ~~省略~~

        //処理用の配列の要素数チェック
        if (_workerFloatArray == null || _workerFloatArray.Length != length)
        {
            Debug.LogError("一時処理用の配列の要素数が指定数: " + length.ToString() + " と異なっていたため生成しました");
            _workerFloatArray = new byte[length];
        }

        //バイナリから読み込み開始位置からfloatのバイト数分読み出して格納
        for (int i = 0; i < _workerFloatArray.Length; i++)
        {
            _workerFloatArray[i] = _binary[_offset + i];
        }

        ~~省略~~
    }

修正後のbinary -> floatへ変換するコード

    /// <summary>
    /// Binaryから float をパース
    /// </summary>
    /// <param name="_binary">対象のbyte 配列</param>
    /// <param name="_offset">読み出し開始位置</param>
    /// <param name="_workerList">作業用のリスト。GCを発生させないために繰り返し使うリスト。実行のたびに中身はクリアされることに注意。また、スレッド間で同時に実行すると意図しない結果を生むため実行してはならない</param>
    /// <param name="_workerFloatArray">作業用の配列。GCを発生させないために繰り返し使う配列。要素はfloatのバイト数と等しい必要がある。new byte[sizeof(float)]で生成可能。また、スレッド間で同時に実行すると意図しない結果を生むため実行してはならない。条件を満たさない場合はnewされる可能性があるため、refで渡される</param>
    /// <param name="_isLittleEndian">LittleEndianかどうか</param>
    /// <returns></returns>
    public static float ReadFloatFromBinary(in byte[] _binary, ref int _offset, List<byte> _workerList, ref byte[] _workerFloatArray, bool _isLittleEndian = false)
    {
        float ret = 0;
        int length = sizeof(float);
        if (_offset + length > _binary.Length)
        {
            Debug.LogError("Out of Range");
            return ret;
        }

        //処理用の配列の要素数チェック
        if (_workerFloatArray == null || _workerFloatArray.Length != length)
        {
            Debug.LogError("一時処理用の配列の要素数が指定数: " + length.ToString() + " と異なっていたため生成しました");
            _workerFloatArray = new byte[length];
        }

        //バイナリから読み込み開始位置からfloatのバイト数分読み出して格納
        for (int i = 0; i < _workerFloatArray.Length; i++)
        {
            _workerFloatArray[i] = _binary[_offset + i];
        }

        if (!_isLittleEndian)
        {
            //リトルエンディアンならビット反転する
            ArrayUtil.FastReverse(_workerFloatArray, _workerList);
        }

        //バイナリからfloatへ変換
        ret = BitConverter.ToSingle(_workerFloatArray, 0);

        //読み出した分をオフセットに加算する
        _offset += length;

        return ret;
    }

改修後のパフォーマンス

修正前:416B
①修正後:144B
①+②修正後:0B ※-88%の処理時間削減

修正後は最大人数で入室した場合でもカクつきが生じなくなりました。

改善前

①修正後

①+②修正後

まとめ

  • System.Array.Reverse()の軽量関数とヒープ確保の回避により、GCを削減してカクつきの原因に対応した
  • マルチスレッドに対応するには別途対応が必要