用 Unity 做的遊戲,它的「記憶體使用量」會因為你在哪裡看而不一樣。這次把繪製 20,000 個立方體的示範放在 Windows 上跑起來測了一遍:Framedash 的儀表板上是 167 MB,Windows 的工作管理員上是 739 MB,用 Unity 的 API 讀 C# 堆積則是 8 MB。這三個不是同一時刻的值,也不是同一次執行的值:儀表板和 API 的值各自是一次執行的中位數,工作管理員的 739 MB 是某一時刻的值(怎麼讀後面會談)。數字對不上,是因為它們各自在數不同的東西。這篇文章從同一個建置裡用八種方法同時記錄記憶體數值,把每個數字在數什麼、Framedash 送出的是哪一個理清楚。
「記憶體」從裡到外分成四層
遊戲的記憶體使用量,按數到哪裡為止,可以分成四層。
最裡面一層,是 C# 程式碼建立的物件所在的區域。new 出來的類別和陣列堆在這裡,垃圾回收要清理的也是這裡。
再往外一層,是 Unity 引擎實際在用的量。除了 C# 物件,還包括紋理這類引擎在內部握著的東西。
更外面一層,是 Unity 從 OS 一次借過來、握在自己手裡的量。用完的部分不會馬上還回去,而是留著應付下一次配置,所以這一層比實際在用的量更大。
最外面一層,是從 OS 看到的整個行程的量。執行檔、DLL、圖形驅動程式這些 Unity 的配置器不去追蹤的東西也算在裡面。
這次測量裡,越往外的層,數字越大。不過這四層並不是乾淨的包含關係:工作集數的是目前留在實體記憶體裡的頁,私人(認可)數的是連被換出去的那部分也算在內、行程已經認可的頁。記憶體顯示大多是從這四層裡截取的其中一層。例外是後面出場的圖形驅動程式數值,它不是包住這四層的第五層,而是另立的一個計數器。
把 20,000 個立方體用兩種方式繪製來測量
用的是和上一篇一樣的、繪製 20,000 個立方體的專案。只在繪製方式上做出區別,準備了兩種模式。
- Naive:每個立方體各帶一個遊戲物件和一個 MeshRenderer
- InstancedDirect:不為每個立方體建立遊戲物件,用
Graphics.RenderMeshInstanced繪製
對應上一篇的 NAIVE 和 INSTANCED_DIRECT。環境是 Unity 6000.4.11f1、Built-in Render Pipeline,圖形 API 是 D3D12,GPU 是 RTX 4070 Ti,解析度 1920x1080。測量用的不是 Development 建置,而是 Release 播放器。
先暖機 15 秒,再測量 120 秒。八項讀取都在呼叫 SDK 的 Collect() 之後的同一個影格裡一起取到。不過表裡放的數值,是把 120 秒的影格按每一項讀取取中位數(p50)得到的,並不是照抄某一個影格的數字。
在 Unity 播放器裡取 OS 那一側的值時,System.Diagnostics.Process.WorkingSet64 和 PrivateMemorySize64 用不了。在 Mono 執行階段裡兩者都回傳 0。這次是用 P/Invoke 呼叫 psapi 的 GetProcessMemoryInfo,再和從外面用 Get-Process 同時取到的值做了對照。中位數是 848.5 MB 和 848.7 MB,沒有偏差。
從同一個建置裡讀到的八個值
| 讀取方式 | 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 | - |
八項的組成是:看最裡面那一層的有三項,引擎在用的量和借來的量各一項,OS 那一側兩項,圖形驅動程式一項。帶底色的那一列,就是 Framedash 的 Unity SDK 送出的那一層。影格時間 p50 是 9.25 ms 和 1.44 ms,和上一篇的趨勢一致。上一篇裡寫的 169.0 MB 和這次的 167.2 MB,是用同樣的參數、同樣的建置組態分別跑出來的兩次結果。兩邊的 memory_used_bytes 指的都是這一層已配置記憶體。
GC.GetTotalMemory(false) 在這個位數上和 GetMonoUsedSizeLong 相同,GetAllocatedMemoryForGraphicsDriver 在 Release 播放器裡是 0。深色長條是 Naive,淺色長條是 InstancedDirect,帶底色的那一列就是 Framedash 的 Unity SDK 送出的那一層。不用遊戲物件帶來的效果,從哪一層看,幅度就不一樣。已配置記憶體上是 -66%,C# 堆積的使用量上是 -16%,堆積本身已保留的量上是 -23%。「把記憶體減少了 66%」和「只減少了 16%」,是同一處改動從不同層看到的報告,兩個數字都沒錯。
每一層在數什麼
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%。剩下的都在原生那一側,拿掉 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 工作集是行程此刻放在實體記憶體上的分頁量,和工作管理員裡的「工作集」(Working set)指的是同一個東西。私人(認可)是行程為自己專用而認可的分頁,也包含被換出實體記憶體的部分,對應工作管理員裡的「認可大小」(Commit size)。
Naive 這邊,已配置的 167.2 MB 對上的工作集是 852.4 MB。差出 685.2 MB。不過這是兩個數的東西並不相同的計數器之差:工作集數的是留在實體記憶體裡的頁,已配置記憶體數的是 Unity 的配置器在用的量,所以這 685.2 MB 本身並不是量出來的某一個東西。執行檔和 DLL 的對應、圖形驅動程式、外掛程式的配置,都在行程裡且在 Unity 配置器的追蹤範圍之外,但哪一項各佔多少,這次的測量並沒有拆開。
剩下的 GetAllocatedMemoryForGraphicsDriver,不屬於這四層裡的任何一層,是另立的、專門看圖形驅動程式配置了多少的 API。它在 Release 建置裡回傳 0。這個 API 需要 ENABLE_PROFILER,在 Development 建置裡測的話兩種模式都是 99.0 MB。兩種模式取到同一個值,表示拿掉遊戲物件並沒有讓驅動程式那一側的配置量動起來。這裡的 0 不是「用了 0 位元組」,而是「沒取到」。Framedash 的 Unity SDK 也一樣,值為 0 時連 mem.vram 這個鍵都不送。
工作管理員的「記憶體」資料行和表裡的哪個都不一樣
在工作管理員的 [處理程序] 索引標籤裡,這個執行檔的「記憶體」資料行顯示 738.7 MB。同一時刻從外面取到的工作集是 795.5 MB,私人是 1,091.3 MB。三個都不一樣。[處理程序] 索引標籤的這一行指的是「私人工作集」(Private working set),不是上面表裡那兩個 OS 側數值中的任何一個。
這三個和表裡的 852.4 MB、1,168.3 MB 也對不上。工作管理員上看到的是某一時刻的值,而表裡是 120 秒的中位數。
不要把 Development 建置的數字和 Release 的數字混在一起
同樣的兩種模式也在 Development 建置裡測了一遍。已配置記憶體是 183.6 MB 和 84.6 MB,已保留記憶體是 337.7 MB 和 221.5 MB。和 Release 的已配置 167.2 MB / 56.6 MB、已保留 263.4 MB / 155.2 MB 相比,四個都漲了。因為效能分析器的檢測本身也要佔記憶體。
工作集這邊沒有朝一個方向動。是 792.1 MB 和 469.6 MB,Naive 比 Release 低,InstancedDirect 比 Release 高。就算在已配置和已保留上漲了,工作集也不保證朝同一個方向動。不能把 Development 建置的數字和 Release 的數字並排放著談增減,原因就在這裡。
Framedash 送出的,在不同引擎上是不同的層
Framedash 的上傳 schema 裡,記憶體這一欄只有一個 int64 的 memory_used_bytes。儀表板把它除以 1048576 後以 MB 顯示。欄位只有一個,但填進去的值屬於哪一層,各引擎並不相同。
- Unity:
Profiler.GetTotalAllocatedMemoryLong()。就是本文裡 167.2 MB 和 56.6 MB 的那一層,隨每 10 秒的perf_heartbeat一起送出 - UE5:
FPlatformMemory::GetStats().UsedPhysical。是實體記憶體上的常駐量,所以這一層比 Unity 更靠近 OS 那一側的數字 - Godot(C#):
Performance.Monitor.MemoryStatic。在匯出的 Release 建置裡這個監視器用不了,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 這樣一個組態。已配置記憶體和工作集之間會拉開多大,會隨平台和指令碼後端而變。IL2CPP 建置沒有測。
層與層之間的對應關係,也只是這個組態下的結果。GC.GetTotalMemory(false) 和 GetMonoUsedSizeLong 一致,是這次 Mono 後端上的情況,換成別的後端不一定還是這樣。
改解析度的話工作集也會動。1920x1080 以外的值手上沒有。這還是一個每個影格改寫全部 20,000 個位置的工作負載,往自己的專案上套之前也請先確認這一點。
報數字的時候,也一起說清楚是哪一層
只說「記憶體是 852 MB」,並不能確定在說什麼。同一次測量裡,說 8.2 MB 也行,說 1,168.3 MB 也行。
想知道什麼,決定了該看哪一層。
- 我這處改動有沒有讓引擎握著的量變少:已配置記憶體
- 能不能裝進裝置的記憶體裡:工作集和認可大小
- GC 壓力:受管理堆積
要做比較的時候,把層、建置類型和解析度都對齊。如果在用 Framedash,先確認一下自己的引擎送出的是哪一層。
- 上傳的指標定義整理在資料模型裡。
- SDK 的導入步驟在 Unity SDK 指南裡。