そのメモリ不足の通知は完璧？ UnityEngine.Application.lowMemoryを深掘ってみる

こんにちは、グレンジ Advent Calendar 2024の15日目担当のmesshiです。

投稿記事を見ると、ここ数年はAdvent Calederだけは必ず発信してるようです。
とうとうその運用で8年目かぁと思いを馳せながら、相変わらずその時々で興味の出たことをテーマにするスタイルは変わっていません。

ということで、本記事はイマ私が興味のある「Unityの`Application.lowMemory`について、もう一歩深掘ってみよう」という趣旨の記事になります。

# 背景
実は、私はこの関数の存在を知りませんでした。
とあるプロジェクトで利用しているのを見かけ「おや？最近追加された機能かな？」と思ったことが、本稿執筆のきっかけとなりました。

プロジェクトのコードを見ると、この通知を受けて、メモリを解放しているようでした。
なるほど、確かに便利そうです。

一方で、いくつか疑問が湧きました。
- どれぐらいのメモリ使用量でメモリ不足と判定されるのか？
- メモリ不足状態が継続したときは、何度も通知が来るのか？
  - 通知は一度だけ？跨いだときに毎回？常にLowMemoryの場合はどうなる？
- 通知タイミングはいつなのか？
  - メモリ確保が行われたタイミングで都度か？フレームの最後なのか？
- 通知の精度はどの程度か？
  - 連続でメモリ確保と解放を行なえば、理論的にはクラッシュしないのか？

今回はこの疑問について、紐解いていこうと思います。

:::note info
ちなみにこの機能の歴史は古く、Unity5.6から追加されていたようでした。
:::


# 公式情報について
改めて公式ドキュメントから得られる情報をまとめます。

## 概要
デバイスが「low-memory」通知を発行した際に、そのイベントをUnityEngine側が通知する仕組みのようです。
アプリケーションがフォアグラウンドで動作中のみ受け取れるイベントとなっています。

使い方の例としては以下のような記載がありました。

- イベントを受け取った際に、致命的でないリソースをメモリから解放する
  - テクスチャ、オーディオクリップなど
  - 自作LODのような仕組みで、より小さいメモリサイズのものに置換するなど
- アプリが強制終了したときにデータが失われないように、一時データを永続ストレージに書き込むタイミングとするなど


## サポート対象
現時点では、3つのプラットフォームのみです。
- iOS
- Android
- Universal Windows Platform (UWP)
　- HoloLensやXbox Oneなどのメモリ制限されたデバイスのみ
　- デスクトップでは発生しない

<b>モバイル開発者は覚えておいて損はなさそう</b>ですね。

## 通知の仕組み
基本はOSから通知される仕組みなので、Unity側で何かの基準を持っているわけではなさそうです。

- iOSの通知
  - `[UIApplicationDelegate applicationDidReceiveMemoryWarning]`
- Androidの通知
  - `onLowMemory()` と `onTrimMemory(level == TRIM_MEMORY_RUNNING_CRITICAL)`
- UWPの通知 
  - `MemoryManager.AppMemoryUsageIncreased(AppMemoryUsageLevel == OverLimit)` 

# 深掘り編
では、ここから冒頭で記載した疑問点について解消していきましょう。

ただし、私がモバイルゲーム開発者という背景もあり、今回の調査対象はiOSに絞ります。
AndroidはiOSに比べメモリが潤沢であり、デバイスによって固有差が出やすいため調査対象からは外しました。

## 1. どれぐらいのメモリ使用量でメモリ不足と判定されるのか？
結論から言うと、<font color="Red"><b>使用できるメモリ最大量の「-100MB」</b></font>が通知タイミングです。

この計測はXcodeのデバッグビューを基準に計測しました。
デバッグビューはこんな画面のやつです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/f4a4e3cd-8c86-6f59-e5d6-d29906143723.png)

つまり、<b>一度に100MBを超えるメモリ確保には対応できません</b>。
とはいえ、一回のアロケーションで100MB確保することは滅多にないとは思います。

## 2. メモリ不足状態が継続したときは、何度も通知が来るのか？
結論から言うと、<font color="Red"><b>閾値を跨ぐタイミングでのみ</b></font>発行されました。
つまり閾値を跨ぐことを繰り返すと、何度も呼び出されてしまうとも言えます。

最悪なケースを考えてみましょう。
たとえば、バトル画面でLowMemoryコールバックが呼び出されたとします。
その際、バトルで確保したエフェクトプールを全解放する処理を呼び出したとします。
しかし、バトルは継続しているため、再度エフェクトが生成され、プールに積まれていきます。
場合によっては、数秒単位で「解放」と「生成」のループに陥り、とんでもない処理負荷になってしまうでしょう。

そのため「何を解放するべきか？」はとても大事な戦略です。

:::note warn
`Resources.UnloadUnusedAssets`, `GC.Collect`などを盲目的に呼び出す場合も同様の理由で注意が必要です。  
その場合は、この掃除を行う処理にインターバルを設ける必要があるかもしれません。
:::


## 3. 通知のタイミングはいつなのか？
まず、LowMemoryの直前までメモリが確保されたアプリケーションを用意します。
また、LowMemory通知を受け取った際は、生成したテクスチャを破棄し、メモリを解放するプログラムを組みます。

さて、この状態で「2Kテクスチャを同フレームで4枚」生成するとどうなるでしょう？

:::note info
2Kテクスチャのメモリ使用量は16.7MBです。
ただし、Read/WriteはTrueの設定なので、1枚あたりは32.4MBとなります。
つまり4枚で合計約130MBほどの使用量になります。
:::

都度のメモリ確保で通知が発行される場合、割り込みでメモリが確保されるためクラッシュはしないでしょう。
フレームの最後などで通知がまとめられる場合は、クラッシュするでしょう。

結果としては、<font color="Red"><b>通知が来る前にクラッシュしました。</b></font color>

<b>同一フレームで100MB確保するケースは、とくにシーン遷移などの生成タイミングではありえるでしょう</b>。
そのため、この点については要注意ポイントです。


## 4. この通知はどの程度の精度なのか？
次に、この通知がどの程度の精度なのか検証してみましょう。

この検証ではUpdateで毎フレーム2kテクスチャを1枚生成してみましょう。
また、LowMemory通知では、2Kテクスチャを3枚解放するとします。
理論上は無限にアプリケーションが動くはずでしょう。

解放処理としては以下の流れで行なっています。
1. Destroyでテクスチャを破棄
2. Resources.UnloadUnusedAssets
3. GC.Collect

:::note info
Resources.UnloadUnusedAssetsは非同期処理なので、コルーチンで処理待ちします。
その間、Update関数ではテクスチャを生成しません。
:::

結果としては、1~2分ほど継続したが最終的にクラッシュしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/b87862d6-2cb5-d5bc-d5e0-7997396d2c37.png)

しかし、プロファイラーデータを分析した結果、テクスチャの数は減っていましたが、閾値の100MBを下回らなかったため、以降のLowMemoryが呼び出されず、クラッシュしてしまったということが読み取れました。

結論としては、<font color="Red"><b>精度としては必ず呼ばれる</b></font color>と思っても良さそうです。
ただし、Heap Allocationや、その他のアロケーションなどによって、閾値を下回らないこともケースによってはありえそうです。

# まとめ
Application.lowMemoryの詳細な仕様としては、現時点では以下になります。

- iOSの場合、クラッシュ閾値の-100MBで通知される
- 通知は、この閾値を超えた際に、毎回発生する
- アロケーションの度にチェックされているわけではないため、1フレーム内でのメモリ確保量が100MBを超えると、コールバックの前にクラッシュする
- 精度としては1フレーム単位でも必ず呼ばれる

これらを受けて、注意すべき点は以下でしょう
- メモリの解放内容によっては、解放と通知が短時間で何度も発生してしまう可能性がある
- メモリを解放した結果、閾値を下回ったかどうかは分からない (自分でメモリ量をチェックする必要がある)

このコールバックが発生しているタイミングは、アプリケーションにとってかなりメモリ状況が厳しいです。
そのため、何かしらの解放コードを仕込んでおくこと自体は有用だと思います。
一方でもう少し細やかに対応するなら、自身でメモリを把握しながら似たような仕組みを作るのも良いかもしれません。

個人的な所感としては、開発中のバイナリで、このコールバックを受け取った際に分かりやすい通知をするのは、早期発見という点では非常に有用かなと思います。
たとえば、個性的なSEを鳴らす、ダイアログを出すなど、ぜひ検討してみてください。

[Grenge](https://www.grenge.co.jp)で一緒に働くメンバーも募集していますので、興味を持って頂けた方は是非弊社のサイトもご覧ください。

それでは、最後まで読んで頂き、ありがとうございました。