Unity で作ったゲームの「メモリ使用量」は、どこで見るかで数字が変わります。 今回、20,000 個のキューブを描くデモを Windows で走らせて測ったところ、Framedash のダッシュボードでは 167 MB、Windows のタスクマネージャーでは 739 MB、Unity の API で C# のヒープを読むと 8 MB でした。 3 つは同じ瞬間の値でも、同じ実行の値でもありません。 ダッシュボードと API の値はそれぞれ別の実行の中央値で、タスクマネージャーの 739 MB はある瞬間の値です(読み方は後半で扱います)。 数字が食い違うのは、それぞれが別のものを数えているからです。 この記事では、同じビルドから 8 通りの方法でメモリ値を同時に記録し、それぞれが何を数えているのか、Framedash が送っているのはどれかを整理します。
「メモリ」は内側から外側まで 4 つの層に分かれる
ゲームのメモリ使用量は、どこまでを数えるかで 4 つの層に分かれます。
いちばん内側は、C# のコードが作ったオブジェクトが載る領域です。
new したクラスや配列がここに積まれ、ガベージコレクションが片付けるのもここです。
その外側は、Unity のエンジンが実際に使っている量です。 C# のオブジェクトのほかに、テクスチャーのようにエンジンが内部で抱えているものも入ります。
さらに外側は、Unity が OS からまとめて借りてきて、手元に置いている量です。 使い終わった分もすぐには返さず、次の確保に備えて持ち続けます。 そのぶん、実際に使っている量より大きくなります。
いちばん外側は、OS から見たプロセス全体の量です。 実行ファイルや DLL、グラフィックスドライバーなど、Unity のアロケーターが追っていないものまで入ります。
今回の計測では、外側の層ほど大きな数字になりました。 ただし、4 つはきれいな入れ子ではありません。 ワーキングセットが数えるのは物理メモリに載っているページで、プライベート(コミット)が数えるのは、ページアウトした分も含めてプロセスが確保しているページです。 メモリの表示はたいてい、この 4 つのどこかを切り取ったものです。 例外は後で出てくるグラフィックスドライバーの値で、これは 4 つを包む 5 つめの層ではなく、別立てのカウンターです。
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、グラフィックス API は D3D12、GPU は RTX 4070 Ti、解像度は 1920x1080 です。 Development ビルドではなく、リリースのプレイヤーで測りました。
15 秒のウォームアップののち、120 秒を計測しました。
8 つの読み取りは、SDK の Collect() を呼んだ直後の同じフレームの中でまとめて取っています。
ただし表に載せる値は、120 秒ぶんのフレームを読み取りごとに中央値(p50)にしたものです。
どれか 1 フレームをそのまま書き写した数字ではありません。
OS 側の値を Unity のプレイヤー内から取るとき、System.Diagnostics.Process.WorkingSet64 と PrivateMemorySize64 は使えません。Mono ランタイムではどちらも 0 を返します。今回は psapi の GetProcessMemoryInfo を P/Invoke で呼び、外から Get-Process で同時に取った値と突き合わせました。中央値は 848.5 MB と 848.7 MB で、ずれていません。
同じビルドから読んだ 8 つの値
| 読み取り | 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 | - |
8 つの内訳は、いちばん内側の層を見るものが 3 つ、エンジンが使っている量と借りている量が 1 つずつ、OS 側が 2 つ、グラフィックスドライバーが 1 つです。
網掛けの行が、Framedash の Unity SDK が送っている層です。
フレームタイムの p50 は 9.25 ms と 1.44 ms で、前回と同じ傾向でした。
前回の記事に載せた 169.0 MB と今回の 167.2 MB は、同じ引数で同じビルド構成を別々に走らせた結果です。
どちらの memory_used_bytes も、確保済みメモリの層を指しています。
GC.GetTotalMemory(false) はこの桁では GetMonoUsedSizeLong と同じ値になり、GetAllocatedMemoryForGraphicsDriver はリリースのプレイヤーでは 0 だからです。濃い棒が Naive、淡い棒が InstancedDirect で、網掛けの行が Framedash の Unity SDK が送っている層です。GameObject をやめた効果は、どの層から見るかで大きさが変わります。 確保済みメモリでは -66%、C# のヒープの使用量では -16%、ヒープとして確保してある量では -23% です。 「メモリを 66% 減らした」と「16% しか減らなかった」は、同じ 1 つの変更を別の層から見た報告で、どちらも正しい数字です。
層ごとに何を数えているか
GetMonoUsedSizeLong と GetMonoHeapSizeLong が見ているのは、いちばん内側の層です。
C# のオブジェクトが載るこの領域を、管理ヒープと呼びます。
使っている量が 8.2 MB、確保してある量が 10.3 MB でした。
差の 2.1 MB は、ヒープとして押さえてあるだけの空き領域です。
GC.GetTotalMemory(false) が表示の桁まで一致したのは、これも同じ管理ヒープの使用量を返すからです。
GC の圧力を追うなら、見るのはこの層になります。
GetTotalAllocatedMemoryLong は、Unity のアロケーターが実際に使っている量の合計です。
167.2 MB のうち、管理ヒープは 8.2 MB しかありません。
全体の 5% です。
残りはネイティブ側で、GameObject を 20,000 個やめて 110.6 MB 減ったのも、ほぼこの分でした。
GetTotalReservedMemoryLong は、Unity が OS から取ってきて手元に抱えているプールの大きさです。
確保済みメモリは、そのプールのうち実際に使われている分にあたります。
Naive では 263.4 MB を予約して 167.2 MB を使っていたので、96.2 MB が使われないまま残っていました。
InstancedDirect でも余りは 98.6 MB で、ほとんど変わりません。
割合で見ると予約済みの -41% と確保済みの -66% は離れていますが、減った量は 108.2 MB と 110.6 MB でほぼ同じです。
OS ワーキングセットは、プロセスが今、物理メモリ上に持っているページの量です。 タスクマネージャーの「ワーキング セット」と同じものを指します。 プライベート(コミット)は、プロセスが自分専用に確保したページの量で、物理メモリから追い出された分も含みます。 タスクマネージャーの「コミット サイズ」がこれにあたります。
Naive では、確保済みの 167.2 MB に対してワーキングセットが 852.4 MB でした。 差は 685.2 MB あります。 ただしこれは、測っているものが違う 2 つのカウンターの差です。 ワーキングセットは物理メモリに載っているページを、確保済みメモリは Unity のアロケーターが使っている量を数えていて、この 685.2 MB 自体が何かを測った量ではありません。 実行ファイルと DLL のマッピング、グラフィックスドライバー、プラグインの確保は、いずれもプロセスの中にあって Unity のアロケーターの外側ですが、内訳までは今回の計測では分けられません。 何がいくら分を占めているかまでは、今回の計測では分解していません。
残る GetAllocatedMemoryForGraphicsDriver は、4 つの層とは別に、グラフィックスドライバーが確保した量を見る API です。
リリースビルドでは 0 を返しました。
この API は ENABLE_PROFILER を必要とし、Development ビルドで測ると両モードとも 99.0 MB です。
両モードで同じ値だったので、GameObject をやめてもドライバー側の確保量は動いていません。
ここでの 0 は「0 バイト使っている」ではなく「値を取れなかった」を意味します。
Framedash の Unity SDK も、0 のときは mem.vram のキーごと送りません。
タスクマネージャーの「メモリ」列は、表のどれとも違う
タスクマネージャーの [プロセス] タブで、この実行ファイルの「メモリ」列は 738.7 MB でした。 同じ時刻に外から取ったワーキングセットは 795.5 MB、プライベートは 1,091.3 MB です。 3 つとも違います。 [プロセス] タブのこの列が指しているのは「プライベート ワーキング セット」で、表に載せた 2 つの OS 側の値のどちらでもありません。
この 3 つは、表の 852.4 MB や 1,168.3 MB とも合いません。 タスクマネージャーで見えるのは 1 時点の値で、表は 120 秒間の中央値だからです。
Development ビルドの数字とリリースの数字を混ぜない
同じ 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 つとも増えています。 プロファイラーの計装がそのぶんメモリを使うためです。
ワーキングセットのほうは、同じ向きには動きませんでした。 792.1 MB と 469.6 MB で、Naive はリリースより低く、InstancedDirect は高くなっています。 確保済みと予約済みが増えていても、ワーキングセットが同じ向きに動くとは限りません。 Development ビルドの数字とリリースの数字を並べて増減を語れないのは、このためです。
Framedash が送るのは、エンジンごとに別の層
Framedash の送信スキーマにあるメモリの欄は、int64 の memory_used_bytes ひとつです。
ダッシュボードはこれを 1048576 で割って MB として表示します。
欄が 1 つでも、そこに入る値がどの層かはエンジンごとに違います。
- Unity:
Profiler.GetTotalAllocatedMemoryLong()。この記事の 167.2 MB と 56.6 MB の層で、10 秒ごとのperf_heartbeatに載ります - UE5:
FPlatformMemory::GetStats().UsedPhysical。物理メモリ上の常駐量なので、Unity よりも OS 側に近い層です - Godot(C#):
Performance.Monitor.MemoryStatic。エクスポートしたリリースビルドではこのモニターが使えず、SDK はSystem.GC.GetTotalMemoryにフォールバックします
Godot(C#)では、同じ列の意味がビルドの種類で変わります。 管理ヒープだけを数えた値がどれくらい小さく出るかは、上の表で見当がつきます。 Unity では 8.2 MB と 167.2 MB でした。
Unity SDK では、管理ヒープとドライバー側を別のキーとして追加できます。
mem.heap が GetMonoUsedSizeLong、mem.vram が GetAllocatedMemoryForGraphicsDriver です。
どちらも 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 自身のログのうち、どれがどれだけかは、この実行では分けていません。
ビルド間の比較では、p50 と p95 が並んで出ます。
中央値が動かないまま裾だけが伸びた変化も、そこに現れます。
CI から framedash perf-diff を叩けば、リリース前にビルド間の差をこの粒度で確認できます。
どこまでが今回の話か
ここまでの値は、Windows、D3D12、Mono バックエンド、デスクトップ GPU という 1 つの構成で測ったものです。 確保済みとワーキングセットの間がどれくらい開くかは、プラットフォームとスクリプティングバックエンドで変わります。 IL2CPP ビルドでは測っていません。
層どうしの対応も、この構成での結果です。
GC.GetTotalMemory(false) が GetMonoUsedSizeLong と一致したのは今回の Mono バックエンドでの話で、どのバックエンドでもそうなるとは限りません。
解像度を変えればワーキングセットも動きます。 1920x1080 以外の値は持っていません。 20,000 個すべての位置を毎フレーム書き換えるワークロードである点も、自分のプロジェクトに当てはめる前に確認してください。
数字を出すときは、層の名前もいっしょに言う
「メモリが 852 MB」とだけ言われても、何の話かは決まりません。 同じ計測から、8.2 MB とも 1,168.3 MB とも言えるからです。
何を知りたいかで、見る層が決まります。
- 自分の変更でエンジンが抱える量が減ったか:確保済みメモリ
- 端末のメモリに収まるか:ワーキングセットとコミットサイズ
- GC の圧力:管理ヒープ
比べるときは、層とビルドの種類と解像度を揃えます。 そして Framedash を使っているなら、自分のエンジンがどの層を送っているかを先に確かめておきます。
- 送信されるメトリクスの定義はデータモデルにまとまっています。
- SDK の導入手順は Unity SDK ガイドにあります。