2019年1月26日

Unity GC.Collectで処理落ちしたときの対策


メモリ管理を自動で行ってくれるガベージコレクション(GC)。これが原因で重くなってしまったときの最適化方法をメモメモ…

多数のスパイクが発生しているプロファイラ(アニメーション)


※このページの内容の動作確認にはUnity2018.2を使用しています。
突然ですが、ここに非常にマズいスクリプトがあります。

using UnityEngine;

public class ParticleModifier : MonoBehaviour {

 public ParticleSystem target;
 
 void Update ()
 {
  if (target == null) return;

  // 現在のパーティクルを取得する
  int maxParticles = target.main.maxParticles;
  ParticleSystem.Particle[] particles = new ParticleSystem.Particle[maxParticles];
  int particleNum = target.GetParticles(particles);

  for (int i = 0; i < particleNum; i++)
  {
   // ひとつひとつのパーティクルに何らかの変更を加える
  }

  // 変更済みのパーティクルを適用する
  target.SetParticles(particles, particleNum);
 }
}

パーティクルシステムで描画されている粒子のひとつひとつをスクリプト側から制御するプログラムですね。まず13行目でパーティクルのデータを入れるための配列を用意、続いて14行目では GetParticles()メソッド を利用してその配列の中にデータを格納しています。あとは実際に変更を加えて適用というただそれだけなのですが…。

これを実行してみると、プロファイラはこんな感じになります。

多数のスパイクが発生しているプロファイラ
鋭いトゲトゲ 踏むと血が出そう

一定間隔でトゲトゲ(スパイク)になっている、つまり急激な処理落ちが発生している状態です。ヒエラルキーを見てみると上記のスクリプトの Update() 内で GC.Collect が発生し、最も酷い時にはなんと8.76ミリ秒もの処理時間がかかっていました。安定して60fpsを目指すなら常に16.66ミリ秒以下に抑えないといけないので、ここまでの処理落ちになるともはや死活問題となります。

プロファイラの Hierarchy 画面
GC.Collect で 8.76ms かかっている…

原因と対策

GC つまり「ガベージコレクション」とはメモリの割り当てや解放を自動化してくれる機能のことですよね。参照型のオブジェクトや配列が作成されると GC は「ヒープ」と呼ばれる場所にメモリを割り当てます。そして、そのオブジェクトや配列がもう使われていない(参照が切れた)と判断されると、今度は使い終えた場所のメモリを解放するわけです。ガベージコレクションとは、その名の通り使われていないメモリ(ゴミ)を判別して管理してくれる掃除人のような存在と言えるでしょう。(…という説明ももしかしたら間違っている可能性があるのでできればちゃんとしたページで調べてください…)

さて、では今回は一体何が悪かったのかというと、ズバリこの部分です。

int maxParticles = target.main.maxParticles;
ParticleSystem.Particle[] particles = new ParticleSystem.Particle[maxParticles];

Update() 内で使い捨ての配列を作成するということは、その都度新たにメモリが割り当てられることになります。しかも配列のサイズにはパーティクルの最大個数を指定しているので、"Max Particles"の設定しだいで下手すると 1000 とか 2000 じゃすまないような数の要素を割り当てることになるわけです。そしてそれらがすぐさまゴミと化します。ゴミ屋敷の誕生です。さっきの画像をよく見てみると、メモリ割り当て量を示す "GC Alloc" の項目が 0.6MB という尋常じゃない値になっていることがわかります。Update() が実行されるたびに 0.6MB というのはさすがにヤバいですね…。結果的に GC が膨大な量のゴミを仕分けることになり処理落ちが発生するわけです。

というわけで、改善案はいたってシンプルです。毎フレームいちいち配列を作成するのではなく、配列を使いまわせばいいのです。

using UnityEngine;

public class ParticleModifier : MonoBehaviour {

 public ParticleSystem target;
 private ParticleSystem.Particle[] particles;

 void Update ()
 {
  if (target == null) return;

  // 必要な場合のみ配列を作成しなおす
  int maxParticles = target.main.maxParticles;
  if (particles == null || particles.Length < maxParticles)
  {
   particles = new ParticleSystem.Particle[maxParticles];
  }

  // 現在のパーティクルを取得する
  int particleNum = target.GetParticles(particles);

  for (int i = 0; i < particleNum; i++)
  {
   // ひとつひとつのパーティクルに何らかの変更を加える
  }

  // 変更済みのパーティクルを適用する
  target.SetParticles(particles, particleNum);
 }
}

実はこれは GetParticles()メソッド の公式ドキュメントページで紹介されている方法です。まず、パーティクル用の配列はメンバ変数にすることで参照が切れないようにしています。さらに配列を new するのは「最初の1回」と「配列サイズが小さくて入りきらない場合」のみに限定することでメモリの割り当てを必要最小限に抑えています。

今回の例では配列でしたが、もちろん他の参照型オブジェクトでもこのような方法で最適化が行えます。

ガベージコレクション最適化いろいろ

GC.Collect など GC 関連の基本的な考え方や最適化については以下の公式ページが参考になります。


ついでに、最後のチュートリアルのページで挙げられている最適化方法を簡単に書き留めておきます。対象バージョンは5.5なのでちょっと古いかもしれませんが、まあ概ね変わりないと思います。
  • キャッシュする
    基本だが最も重要な考え方。変数を保管して使いまわすようにする
  • 頻繁に呼び出される関数内でオブジェクトや配列を作成しない
    できるだけStart()内やAwake()内で作成する。どうしてもUpdate()関数内で処理をしなければいけないときは「条件をつけて必要な場合のみ作成する」「タイマーで一定時間ごとに作成する」など、メモリ割り当てが毎フレーム起きないように工夫する
  • コレクションは new ではなく Clear() する
    Listなどのコレクションはキャッシュしておき、中身を初期化したい場合は新たに作成しなおすのではなくClear()メソッドを使う
  • オブジェクトプールを実装する
    たとえばシューティングゲームの弾のように、頻繁に生成と破棄を繰り返すようなオブジェクトは、その都度使い捨てるのではなく常に一定数を貯めて(プールして)おき、使い終えたものを再利用できるような設計にする
  • その他 落とし穴を避ける
    不必要なゴミの発生をできるだけ抑えるよう、以下のことも知っておく
    • C#ではstringも参照型で、実は文字列を連結させるたびに新しいデータが作成されている(つまり古い文字列はゴミになる)
    • Unityの組み込み関数の中には呼び出すたびにメモリ割り当てが発生するものがある。特にアクセサの場合(Mesh.normalsやGameObject.tagなど)は意識せずループ処理中に使ってしまいがちなので注意
    • ボックス化(値型から参照型への変換)を避ける。たとえばコルーチン内で"yield return 0"を実行すると0という値がボックス化されてしまうので"yield return null"と書くようにする、など。…とはいえUnity内部の処理で勝手にSystem.Object型に変換されているパターンも往々にしてあるのであまり深く考えすぎないこと
    • StartCoroutine()を呼び出すとわずかだがゴミが発生する
    • Unity5.5より前のバージョンでは、配列以外をforeachで回すとボックス化が発生していた(5.5以降では修正済み)
    • メソッド参照も参照型なのでゴミが発生する。また、匿名メソッドをクロージャに変換すると割り当てに必要なメモリ量が大幅に増加する
    • LINQや正規表現などでもボックス化によりゴミが発生するため、パフォーマンスが重要な場面では多用しない

塵も積もればゴミ屋敷。コードを書くときは頭の片隅に置いておきたいです。

あと個人的にひっかかったのは可変長引数(params)。結局は新しい配列を自動で作成しているにすぎないので、呼び出すごとにゴミが増えます。いくら便利だからといっても濫用は控えましょう。引数の数を固定したメソッドも併せてオーバーロードしておくと、引数の数が一致した場合にそちらが優先されるようになります。




あ、それから、メモリの割り当て量を確認するにはプロファイラの "GC Alloc" の項目を見ればいいですが、その際、スクリプトの一部を Profiler.BeginSample と Profiler.EndSample で囲むとその部分だけを切り出してプロファイラに表示させることができます。どの処理でどのくらい割り当てがなされているかをピンポイントで調べることができるので便利です。

using UnityEngine.Profiling;

// (中略)

Profiler.BeginSample("Create New Array!");
ParticleSystem.Particle[] particles = new ParticleSystem.Particle[maxParticles];
Profiler.EndSample();

今まで全然知りませんでした… 恥ずかし~…

0 件のコメント:

コメントを投稿