PONOS Advent Calendar 2021の1日目の記事です。
はじめに
ご存知の通りUnityにおけるメモリ管理は基本的にGCにおまかせになっています。
詳しい説明は省略しますが、一般的にこうした環境下でのメモリの領域は、大きくわけてヒープ領域とスタック領域という二つに分類されます。
ローカル変数として宣言された値型やstructはスタック領域にメモリが確保され、スコープを抜けたタイミングで解放されるわけですが、ヒープ領域に確保されたメモリは特定のタイミングでGCによって解放されます(世代別GCなどの話は省略)。
このヒープ解放はそれなりに負荷がかり、時にはスパイクとなってしまうことがあり注意が必要です。
Unityのコルーチンで用いられるWaitForSeconds
はClassインスタンスを生成するため、ヒープ領域にメモリが割り当てられます。
つまり、ループ毎に new WaitForSeconds(xxx)
を行ってしまうと、理論上はその度にヒープ領域にゴミのようなメモリ割り当てを発生させてしまうことになります。
今回はProfilerを使用して、実際にnew WaitForSeconds(xxx)
がどの程度メモリ割り当てを発生させているかを確認してみたいと思います。
検証内容
検証する環境は以下の通りです。
- Unity: 2020.3.23f1
- フレームレート: 60FPS
- Scene上にはデフォルトのカメラとライト以外、テスト用のGameObjectのみとする
- 極力他の影響を避けるため、デバッグログの出力なども含めた余計なコードは一切かかない
- 動作はUnityエディタ実行時で確認する
そして以下の動作を確認します。
- 何もしない状態で毎フレームどの程度のメモリー割り当てが発生しているかを確認します。
-
new WaitForSeconds(xxx)
を行うコルーチンを100個生成し、同様の確認を行います。 -
new WaitForEndOfFrame()
を行うコルーチンを100個生成し、同様の確認を行います。 -
WaitForSeconds
を再利用する形に変更し、同様の確認を行います。
検証する
1. プレーンな状態
テスタースクリプトを作る
のちのちWaitForSeconds
を使用するテスタースクリプトを作ります。
ここではプレーンな状態を想定しているので実際にはそれを使用しませんが、FPSだけ固定しておきます。
public class Tester : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
Application.targetFrameRate = 60;
}
}
このスクリプトをヒエラルキーのGameObjectにアタッチしておきます。
結果を見る
Window > Analysis
を開き、この状態でエディタ実行してしばらく様子をみます。
Memory
の項目のGC Allocated In Frame
という項目を見ると、1フレームあたりに割り当てられた個数とデータ量が確認できます。
この結果から、プレーンな状態では0/0B
であることがわかりました。
2. new WaitForSeconds(xxx)
を行うコルーチンを100個生成
スクリプトを編集する
0.01f秒のwaitを返すコルーチンを100個生成するようにしてみました。
public class Tester : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
Application.targetFrameRate = 60;
for (var i = 0; i < 100; i ++) {
StartCoroutine(Hoge());
}
}
private IEnumerator Hoge()
{
while (true) {
yield return new WaitForSeconds(0.01f);
}
}
}
結果を見る
Memory
の項目のGC Allocated In Frame
という項目を見ると、毎フレーム100個/2.0KBのメモリ割り当てが発生していることが確認できます。
60FPSなので1秒間で120KB, 1分間に約7MBの割り当てを行なっている計算です。
また、WaitForSeconds
一つあたり、20Bのメモリが割り当てられるようです。
3. new WaitForEndOfFrame()
を行うコルーチンを100個生成
スクリプトを編集する
次はWaitForEndOfFrame
の場合をみてみます。
コルーチンを書き換えます。
private IEnumerator Hoge()
{
while (true) {
yield return new WaitForEndOfFrame();
}
}
結果を見る
Memory
の項目のGC Allocated In Frame
という項目を見ると、毎フレーム100個/1.6KBのメモリ割り当てが発生していることが確認できます。
60FPSなので1秒間で96KB, 1分間に約5.6MBの割り当てを行なっている計算です。
また、WaitForSeconds
一つあたり、16Bのメモリが割り当てられるようです。
4. WaitForSeconds
を再利用する
ここまでのことでわかる通り、このループのコードでは常にメモリ割り当てが発生してしまっています。
これを発生させないために再利用するコードに変えてみます。
スクリプトを編集する
コルーチンの処理を書き換えます。
private IEnumerator Hoge()
{
var wait = new WaitForSeconds(0.01f);
while (true) {
yield return wait;
}
}
結果を見る
Memory
の項目のGC Allocated In Frame
という項目を見ると、0/0Bになっており、メモリ割り当てが発生していないことが確認できます。
まとめ
このように、よく使うWaitForSeconds
も、ヒープ領域が割り当てられることが確認できました。
1インスタンスあたりの割り当てられるメモリは決して多くありませんが、同様のオブジェクトが多数あったり、1秒間あたりの実行回数が多い場合は無視できないデータ量になることもあり、それによってGCのためのスパイクを発生させる要因の一つとなりえます。
ここではインスタンスの再利用という簡単なコードでそれを回避しました。
WaitForSeconds
に限らず、小さいデータであったとしてもヒープ領域へのアロケーションが必要なコードをループに記述する場合、極力その回数を減らせられるように、注意が必要です。
明日は@e73ryoさんです!