Unity の「メモリ使用量」は、ダッシュボード、タスクマネージャー、C# のヒープで違う数字になります。 20,000 個のキューブを描くデモを Windows で測ると、Framedash では 167 MB、タスクマネージャーでは 739 MB、Unity の API で読んだ C# のヒープは 8 MB でした。 ただし、これらは別々の観測値です。 ダッシュボードとヒープの値はそれぞれ別の実行の中央値で、タスクマネージャーの 739 MB はある瞬間の値です。
数字を比べる前に、何を数えているかを確かめる必要があります。 Framedash の Unity SDK が送るのは、Unity のアロケーターが使っている量です。 管理ヒープの使用量とも、プロセスが物理メモリに載せている量とも異なります。 この違いを同じ条件で確かめるため、同じビルドから毎フレーム 8 種類の値を記録しました。
メモリ使用量を 4 つの範囲に分ける
今回の読み取りは、次の 4 つの範囲に分けると整理できます。 「層」と考えると把握しやすくなりますが、きれいな入れ子ではありません。
- 管理ヒープ:C# のクラスのインスタンスや配列などを置く、ガベージコレクターが管理する領域
- Unity の確保済みメモリ:Unity のアロケーターのプール内で使っている量。管理ヒープ以外に、テクスチャーデータなどエンジン側の確保も含む
- Unity の予約済みメモリ:Unity が OS から確保しているプール全体。再利用に備えて残した空き容量も含む
- プロセスのメモリ:OS から見た量。実行ファイルや DLL のマッピングなど、Unity のアロケーターが追跡しない分も対象になる
OS の値も 1 種類ではありません。 ワーキングセットは物理メモリに載っているページを数え、プライベートコミットはページアウトした分も含めたプロセス専用のコミット量を数えます。 今回の計測では OS の値のほうが大きくなりましたが、範囲の異なる値を引き算しても、正確な内訳は得られません。 グラフィックスドライバーの値も独立したカウンターで、これらを包むもう 1 つの層ではありません。
20,000 個のキューブを 2 通りに描いて測る
前回の記事と同じプロジェクトで、20,000 個のキューブを 2 通りに描きました。
- Naive:キューブ 1 個につき GameObject と MeshRenderer を 1 つずつ持つ
- InstancedDirect:キューブごとの GameObject を作らず、スクリプトから
Graphics.RenderMeshInstancedで描く
前回の記事の NAIVE と INSTANCED_DIRECT にあたります。 環境は Unity 6000.4.11f1、Built-in Render Pipeline、D3D12、Mono バックエンド、RTX 4070 Ti、1920x1080 です。 主な比較表には、テレメトリーの送信を止めたリリースプレイヤーの値を載せています。
各モードで 15 秒のウォームアップの後、120 秒間を計測しました。
この実験では SDK の Collect() を呼んだ直後、同じフレーム内で 8 つの値を読み取っています。
表の各値は、読み取りごとに求めた計測期間中の中央値(p50)です。
表全体が、ある 1 フレームの状態を表しているわけではありません。
今回の Mono プレイヤーでは、System.Diagnostics.Process.WorkingSet64 と PrivateMemorySize64 がともに 0 を返しました。そこで psapi の GetProcessMemoryInfo を P/Invoke で呼びました。プレイヤーの外から実行した Get-Process との照合では、中央値が 848.5 MB と 848.7 MB で、差は 0.2 MB でした。
8 つの読み取りを 120 秒間の中央値で比べる
| 読み取り | Naive | InstancedDirect | 差 |
|---|---|---|---|
GetMonoUsedSizeLong |
8.2 MB | 6.9 MB | -16% |
GC.GetTotalMemory(false) |
8.2 MB | 6.9 MB | -16% |
GetMonoHeapSizeLong |
10.3 MB | 7.9 MB | -23% |
GetTotalAllocatedMemoryLong |
167.2 MB | 56.6 MB | -66% |
GetTotalReservedMemoryLong |
263.4 MB | 155.2 MB | -41% |
| OS ワーキングセット | 852.4 MB | 412.0 MB | -52% |
| OS プライベート(コミット) | 1,168.3 MB | 681.9 MB | -42% |
GetAllocatedMemoryForGraphicsDriver |
0 | 0 | - |
網掛けの行が、Framedash の Unity SDK が送る値です。 フレームタイムの p50 は Naive が 9.25 ms、InstancedDirect が 1.44 ms で、前回と同じ傾向でした。 前回の 169.0 MB と今回の 167.2 MB は、同じビルド構成を同じ引数で別々に走らせた結果です。 どちらも確保済みメモリを数えています。
GC.GetTotalMemory(false) はこの桁では GetMonoUsedSizeLong と同じ値になり、GetAllocatedMemoryForGraphicsDriver はリリースのプレイヤーでは 0 だからです。濃い棒が Naive、淡い棒が InstancedDirect で、網掛けの行が Framedash の Unity SDK が送っている層です。Naive から InstancedDirect に切り替えると、Unity の確保済みメモリは 66%、管理ヒープの使用量は 16%、管理ヒープの容量は 23% 減りました。 どれも同じ変更の結果です。 「メモリが 66% 減った」と報告するなら、何を測った数字かも添える必要があります。
各カウンターから何がわかるか
管理ヒープの使用量と容量
GetMonoUsedSizeLong は、GC の回収待ちのオブジェクトも含めた管理ヒープの使用量を返します。
GetMonoHeapSizeLong はヒープの容量です。
Naive の中央値は 8.2 MB と 10.3 MB で、その差は 2.1 MB でした。
ヒープにはオブジェクトが使っていない領域もありますが、この引き算は中央値同士の差であり、空き容量を別途測った値ではありません。
GC.GetTotalMemory(false) は、今回の Mono バックエンドでは使用量の値と表示桁まで一致しました。
別のバックエンドでも一致するとは限りません。
また、ヒープの使用量だけでは、新たな割り当ての頻度や GC の処理時間はわかりません。
GC によるフレームタイムの悪化を調べるなら、Profiler でフレームごとの割り当てと GC の実行状況も確認します。
Unity が使う量と予約している量
GetTotalAllocatedMemoryLong は、Unity のアロケーターのプール内で使っている量を返します。
管理ヒープの 8.2 MB は、確保済みの 167.2 MB と比べて約 5% の大きさです。
管理ヒープの減少だけでは、確保済みメモリの 110.6 MB の減少を説明できません。
このモード変更による減少は主にネイティブ側に出たと読めますが、今回の計測ではオブジェクトごとの内訳までは分けていません。
GetTotalReservedMemoryLong には、プール内の空き容量も含まれます。
Naive の予約済みは 263.4 MB、確保済みは 167.2 MB で、中央値の差は 96.2 MB でした。
InstancedDirect では 98.6 MB と、ほぼ変わりません。
このため、減少率は予約済みの 41% と確保済みの 66% で離れていても、減った量は 108.2 MB と 110.6 MB で近くなります。
ここでの引き算も中央値同士の差で、未使用量を独立に計測したものではありません。
プロセスのワーキングセットとプライベートコミット
OS のワーキングセットは、共有ページも含め、現在物理メモリに載っているページを数えます。 タスクマネージャーの ワーキング セット にあたります。 プライベートコミットはプロセス専用のコミット量で、ページアウトした分も含み、コミット サイズ に対応します。
Naive では、ワーキングセットの中央値 852.4 MB と確保済みの中央値 167.2 MB の間に、685.2 MB の差がありました。 これは定義の異なるカウンターの差であり、特定の種類のメモリを測った量ではありません。 実行ファイルや DLL のマッピング、グラフィックスドライバーやプラグインの確保には、Unity のアロケーターが追跡しないものがあります。 それぞれがどれだけを占めるかは、今回の計測からはわかりません。
グラフィックスドライバーの値と、取得できない場合の 0
GetAllocatedMemoryForGraphicsDriver は Editor と Development プレイヤーで値を取得できる API です。
ネイティブ側の計測には ENABLE_PROFILER が必要です。
今回のリリースプレイヤーでは 0、Development ビルドでは両モードとも 99.0 MB を返しました。
この表示桁では、モード間の差は見られません。
リリースでの 0 は、ドライバーがメモリを使っていないという意味ではなく、値を取得できなかったという意味です。
Framedash は、この API が正の値を返さないときは mem.vram を送りません。
タスクマネージャーが別の数字を表示する理由
タスクマネージャーの プロセス タブでは、この実行ファイルの メモリ 列が 738.7 MB でした。 同じ時刻に外から取ったワーキングセットは 795.5 MB、プライベートコミットは 1,091.3 MB です。 表示されていたのは、物理メモリに載っているプロセス専用のページを数える「プライベート ワーキング セット」でした。 共有ページとページアウトした専用ページは含まないため、表の 2 つの OS カウンターとは異なります。
これらは 1 時点の観測値なので、120 秒間の中央値である表の 852.4 MB や 1,168.3 MB とも一致しません。 比べるときには、カウンターの定義と計測期間の両方をそろえます。
変更を比べるときはビルドの種類をそろえる
同じ 2 モードを Development ビルドでも測りました。 確保済みは 183.6 MB と 84.6 MB、予約済みは 337.7 MB と 221.5 MB です。 リリースの確保済み 167.2 MB / 56.6 MB、予約済み 263.4 MB / 155.2 MB と比べると、4 つとも増えています。 Development ビルドにはプロファイラーの計装が入りますが、この比較だけでは、増加分のうち計装が占める量までは特定できません。
ワーキングセットは逆方向に動きました。 Naive の 792.1 MB はリリースより低く、InstancedDirect の 469.6 MB は高くなっています。 アロケーターが使う量が増えても、物理メモリに載る量が同じように増えるとは限りません。 最適化の効果を見るなら、リリース同士、または Development 同士で比較します。 ビルドの種類まで変えると、差の原因がもう 1 つ増えてしまいます。
Framedash のメモリ欄に入る値
送信スキーマのメモリ欄は int64 の memory_used_bytes ひとつですが、値の取得元はエンジンごとに異なります。
ダッシュボードではバイト数を 1048576 で割り、MB と表示します。
この除数での単位は厳密には MiB です。
- Unity:
Profiler.GetTotalAllocatedMemoryLong()。この記事で 167.2 MB と 56.6 MB だったカウンターで、10 秒ごとのperf_heartbeatに載ります - UE5:
FPlatformMemory::GetStats().UsedPhysical。Unity のアロケーターの値とは異なり、プラットフォームごとのプロセスの物理メモリ統計です - Godot(C#):
Performance.Monitor.MemoryStaticが正の値を返せばそれを使い、取得できない場合はSystem.GC.GetTotalMemory(false)にフォールバックします。エクスポートしたリリースビルドは、多くの場合この管理ヒープの値になります
Godot では、ビルド構成によって同じ欄の意味が変わり得ます。 取得元が異なるビルドの値を、そのまま比べることはできません。 Unity の管理ヒープ 8.2 MB と確保済み 167.2 MB は、この区別を示す例であり、Godot に使える換算比率ではありません。
Unity SDK は、GetMonoUsedSizeLong を mem.heap、GetAllocatedMemoryForGraphicsDriver を mem.vram としても送ります。
各 API が正の値を返した場合だけキーが付くため、キーがないことと 0 バイトであることは区別します。
テレメトリーを送った実行との比較
SDK からテレメトリーを送った別の実行では、GetMonoUsedSizeLong の p50 が増えました。
Naive は 8.2 MB から 9.8 MB、InstancedDirect は 6.9 MB から 8.8 MB です。
同じ実行の確保済みは 167.0 MB と 56.6 MB、ワーキングセットは 857.3 MB と 415.3 MB でした。
これら確保済みとワーキングセットの値は、送信を止めた実行との差がいずれも 5 MB 以内です。
管理ヒープの増加分 1.6 MB と 1.9 MB は、この構成で観測した差です。 一般的な SDK のオーバーヘッドを表す値ではありません。 イベントバッファー、シリアライズ、SDK のログの内訳は分けておらず、実行記録からはログの設定が一致していたかも確認できません。
Framedash のビルド比較では、p50 と p95 を並べて確認できます。
p95 は中央値があまり動かないときにも大きい側の値の変化を見る手がかりになりますが、最大値ではありません。
CI で framedash perf-diff を実行すれば、リリース前のビルド比較にも使えます。
フレーム単位の計測結果と比べる前に、何を集計した百分位なのかを確かめます。
今回の結果が当てはまる範囲
測ったのは Windows、D3D12、Mono、デスクトップ GPU、1920x1080 の構成です。 IL2CPP や別の解像度では計測していません。 また、このワークロードでは毎フレーム、20,000 個すべての位置を書き換えています。 別のプラットフォームやバックエンド、解像度、ワークロードでは、改めて計測が必要です。 今回の大小関係や減少率を、Unity 全般の比率として使うことはできません。
調べたいことに合うカウンターを選ぶ
「メモリが 852 MB」だけでは、測った範囲がわかりません。 同じ実行から 8.2 MB も 1,168.3 MB も得られます。 調べたいことに合わせて、次のように値を選びます。
- Unity のアロケーターが使う量の変化を見るなら、確保済みメモリ。プールの容量も確認するなら、予約済みメモリも見る
- 端末でのメモリ需要を調べるなら、対象プラットフォームのワーキングセットとコミット量を、ピークも含めて確認する。120 秒間の中央値だけで「収まる」とは判断しない
- GC の問題を調べるなら、管理ヒープの使用量に加えて、割り当ての頻度と GC の実行状況を見る
変更前後の比較では、カウンター、プラットフォーム、スクリプティングバックエンド、ビルドの種類、解像度、ワークロード、テレメトリーの設定をそろえます。 ウォームアップ、記録の間隔、集計期間も同じにします。 タスクマネージャーの 1 時点の値、毎フレームの p50、テレメトリーイベントの百分位は、同じ MB 表示でも違う問いに答える数字です。
- データモデル:SDK が送るメトリクスの定義
- Unity SDK ガイド:導入と収集の手順