記事一覧へ戻る

Unity の「メモリ使用量」はどれを見ればいい? 8 通りの読み方と Framedash が送る値

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.WorkingSet64PrivateMemorySize64 は使えません。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 も、確保済みメモリの層を指しています。

6 つの読み取りを、Naive と InstancedDirect の値を並べて示した横棒グラフ。上から GetMonoUsedSizeLong 8.2 / 6.9 MB、GetMonoHeapSizeLong 10.3 / 7.9 MB、網掛けの GetTotalAllocatedMemoryLong 167.2 / 56.6 MB、GetTotalReservedMemoryLong 263.4 / 155.2 MB、OS ワーキングセット 852.4 / 412.0 MB、OS プライベート(コミット)1,168.3 / 681.9 MB
目盛りはリニアです。8 つの読み取りのうち 6 つを載せています。GC.GetTotalMemory(false) はこの桁では GetMonoUsedSizeLong と同じ値になり、GetAllocatedMemoryForGraphicsDriver はリリースのプレイヤーでは 0 だからです。濃い棒が Naive、淡い棒が InstancedDirect で、網掛けの行が Framedash の Unity SDK が送っている層です。

GameObject をやめた効果は、どの層から見るかで大きさが変わります。 確保済みメモリでは -66%、C# のヒープの使用量では -16%、ヒープとして確保してある量では -23% です。 「メモリを 66% 減らした」と「16% しか減らなかった」は、同じ 1 つの変更を別の層から見た報告で、どちらも正しい数字です。

層ごとに何を数えているか

GetMonoUsedSizeLongGetMonoHeapSizeLong が見ているのは、いちばん内側の層です。 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 ビルドの数字とリリースの数字を並べて増減を語れないのは、このためです。

リリースビルドと Development ビルドを比べた 2 枚のグラフ。左は確保済みメモリで、リリースが 167.2 と 56.6 MB、Development が 183.6 と 84.6 MB。右は OS ワーキングセットで、リリースが 852.4 と 412.0 MB、Development が 792.1 と 469.6 MB
左右で縦軸の目盛りが違うので、比べるのはパネルの中だけです。確保済みメモリは Development ビルドで両モードとも増えますが(+16.4 MB と +28.0 MB)、ワーキングセットは同じようには動かず、Naive が -60.3 MB、InstancedDirect が +57.6 MB でした。

Framedash が送るのは、エンジンごとに別の層

Framedash の送信スキーマにあるメモリの欄は、int64 の memory_used_bytes ひとつです。 ダッシュボードはこれを 1048576 で割って MB として表示します。 欄が 1 つでも、そこに入る値がどの層かはエンジンごとに違います。

  • UnityProfiler.GetTotalAllocatedMemoryLong()。この記事の 167.2 MB と 56.6 MB の層で、10 秒ごとの perf_heartbeat に載ります
  • UE5FPlatformMemory::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.heapGetMonoUsedSizeLongmem.vramGetAllocatedMemoryForGraphicsDriver です。 どちらも 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 を叩けば、リリース前にビルド間の差をこの粒度で確認できます。

Framedash のビルド比較画面。build id 20260828-Naive-memlayers-tel と 20260828-InstancedDirect-memlayers-tel が並び、メモリの p50 と p95 の列が表示されている
2 つの build id を並べた比較画面。テレメトリーを送りながら測った実行のもので、メモリの p50 は 167.0 MB から 56.6 MB です。この画面の百分位は、各ビルドが送った 133 件のイベントに対するもので、上の表のようなフレーム単位ではありません。

どこまでが今回の話か

ここまでの値は、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 を使っているなら、自分のエンジンがどの層を送っているかを先に確かめておきます。