5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Unity】 GC のスパイクを劇的に下げるコツ

Last updated at Posted at 2023-11-06

更新履歴: 浮動小数点のエンディアン反転&書き込みの効率化
 

主にネットワーク処理とかでバイト配列を扱う人向けです。そうじゃない人も思い当ったら試してみてください。

その1: BitConverter.GetBytes

GC Alloc の元凶はバイト配列(参照型)を返すこのメソッド。

浮動小数点が BinaryPrimitives クラスで扱えないから BitConverter 使うかー、て感じだったけど、これを使うのをやめただけで GC Alloc が 20KB -> 10KB になってすべて解決した。

Note
※メモリ消費量は大したものじゃないけど、10KB / 4B = 2,500 x フレームレート = 秒間数十万ものオブジェクトが GC 待ちになってスパイクが発生していたって事ですね

BitConverter.GetBytes(floatValue);  // <-- 呼び出し毎に4バイトの配列を確保&即GC待機列へ

// 👇 に変える

static byte[] FLOAT_BYTES = new byte[sizeof(float)];
...
UnsafeUtility.As<byte, float>(ref FLOAT_BYTES[0]) = value;
// As() メソッドは ref 戻り値で、そこに値を直接代入しているという分かり辛さ
// C ネイティブで書くとたしかこんな感じ? --> *(float*)FLOAT_BYTES = value;

// ref var ... = ref ... でも良い。ちょっと分かりやすくなる。
ref var like_a_ptr = ref UnsafeUtility.As<byte, float>(ref FLOAT_BYTES[0]);
like_a_ptr = value;

// ※ 配列と聞くとついついクラスの静的メンバーとして一度だけ確保して、みたいにやってしまうが
// stackalloc でもマネージドヒープを回避できる。まだ慣れないなー。
Span<byte> tmp = stackalloc byte[sizeof(float)];

UnsafeUtility.As() は Unity.Burst に入ってる。System.Runtime.CompilerServices.Unsafe があるならそっちでも良い。中身は同じで間接呼び出しか否かの違いだけ。

メソッド(の ref 戻り値)に代入するか ref var ... する、ってのが分かり辛いけど unsafe コンテキストじゃなくても使えて便利。

効率的な浮動小数点のエンディアン反転と書き込み

BitConverter に引っ張られて byte[] を使っているが、そもそも4バイトなら何でもいい。

書き込み目的の反転なら BinaryPrimitives が扱える Int32 を受け皿とするのが一番だろう。

int tmp = 0;
UnsafeUtility.As<int, float>(ref tmp) = 12.345f;
BinaryPrimitives.WriteInt32BigEndian(stream, tmp);

これでクラスメンバーへのアクセスが無くなるので一千万回の試行でなんと 0.0x 秒も早くなる。

整数のエンディアン反転

浮動小数点は上記の通り。整数の場合は Unity 2021 なら BinaryPrimitives が使えるから Span<byte> ReadOnlySpan<byte> とコレ使うだけでおっけ。

BinaryPrimitives.ReverseEndianness(...);
BinaryPrimitives.ReadInt32BigEndian(...);
BinaryPrimitives.WriteInt32LittleEndian(...);

// バニラ Unity だと使えない。なので BitConverter を使いがちで問題が起きる
BinaryPrimitives.ReadSingleBigEndian(...)

その2: Memory<T>

ネットワーク越しにデータを受信すると、受信したデータのバイト列が確保されるのは避けられない。

それは構わないとして、受信したバイト列を Span<T> ReadOnlySpan<T> を通して走査していくわけだけど、

void OnReceived(ReadOnlySpan<byte> bytes)
{
    ...
    var processed = bytes.Slice(start, len).ToArray();  // <-- ココで新しい配列を確保
}

ToArray() で内容の一部のコピーを確保することになったりして、せっかくのスパンが台無しになる。

すぐにデータを使うとは限らない

じゃあ ToArray() しなければ良いじゃない。という話なんだけど、

  • C# 別スレッドで可能な限り前処理を済ませておく(Unity のメインスレッドを消費したくない
    • → その後間をおいて Unity メインスレッドで残りの処理を行う

としたい場合、短命が前提で制約の多い Span<byte> ReadOnlySpan<byte> だと無理なわけで。

で、表題の Memory<T> の登場となるわけだが、、、どのくらい雑に使っていいのか。

元になったバイト配列の扱い

受信したバイト配列を Memory<byte> にするのは良いとして、元になったデータの扱いはどうすればいいのかという話。例えば以下のような場合。

...OnReceived(originalByteArray);  // バイト列がどこかで確保されてメソッドに渡される

void OnReceived(Memory<byte> bytes)
{
    ...
    _memSlices.Add(bytes.Slice(start, len));  // <-- クラスのフィールドに切り出した結果を追加
}


List<Memory<byte>> _memSlices;  // <-- Memory<T> だけが残る。元になった byte[] は忘れて良いの??

...
_memSlices.Clear();  // どこかにある元の配列は消えるの??

どこかにある匿名のバイト配列を参照している Memory<byte> だけが残る状態ですね。

大丈夫なの? どうなんの? っていう。

※ こんなこともあったので 👉 Unity 特有の不必要なヒープ拡張とその対処法

結果

まあ大丈夫そうです。unsafe じゃないんだからそれはそう。

なにかしら参照を残しておいて Memory<byte> が無くなったらオリジナルを削除して、なんてことしないでも C# が全部やってくれます。良いですね!!

※ GC で回収されるタイミングはまちまち

class MemoryAndByteArray : MonoBehaviour
{
    [SerializeField] bool ForceGCCollect = false;

    // おまじない
    void OnValidate()
    {
        if (ForceGCCollect)
        {
            ForceGCCollect = false;
            GC.Collect();
        }
    }

    // プロファイラーのスピード調整
    void Awake()
    {
        Application.targetFrameRate = 30;
    }

    // メモリー確保
    void Alloc()
    {
        var bytes = new byte[2000000000];
        var mem = bytes.AsMemory();

        for (int i = 0; i < 100; i++)
        {
            _memSlices.Add(mem.Slice(i * 200 + i, i * 100 + i));
        }
    }


    List<Memory<byte>> _memSlices = new();
    int _frameCount = 0;

    void Update()
    {
        _frameCount++;

        Debug.Log($"Frame {_frameCount:D3}:\t{GC.GetTotalMemory(false) * 0.000001:F1} MB");

        if (_frameCount == 30)
        {
            Alloc();

            // Memory<T>.Span を書き換えれば Memory<T> の内容が変わるか確認
            _memSlices[0].Span.Fill((byte)'_');
            _memSlices[0].Span[..10].Fill((byte)'A');
            _memSlices[0].Span[20..25].Fill((byte)'B');
            _memSlices[0].Span[50..^20].Fill((byte)'C');
            Debug.Log(string.Join("", _memSlices[0].ToArray().Select(static x => (char)x)));
        }
        else if (_frameCount is > 90)
        {
            for (int i = 0; i < 5; i++)
            {
                if (_memSlices.Count == 0)
                    break;
                _memSlices.RemoveAt(0);
            }
        }

    }
}

Span<T>Memory<T> 変換を行うには、新規に配列を確保しないといけないの面倒っすね。.Pin() とかあるからなんとか出来るか?

Span<T> ReadOnlySpan<T> の強い制約は変なコードの防止になるから Memory<T> はなるべく使いたくないんだ。

おわりに

メモリ使用量は多くないし、作られる配列の数がフレームレートに依存するから気付きにくい。でも直したら信じられないレベルでスパイクが無くなる。他社製のライブラリが使ってたら呼び出し頻度によっては怒りが有頂天。

今の時代にビッグエンディアンなんか使うなって話なのかもしれないけど。

--

以上です。お疲れ様でした。

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?