0x00 前言
工作的過程中,常常會發現有小伙伴對Unity的Profiler提供的內存數據與某些原生平台Profiler工具,例如iOS系統和Xcode,所提供的內存數據有差異而感到好奇。而且大家對如何解讀原生平台工具的數據更加感興趣,同樣例如iOS系統和Xcode。最近正好看了一個來自Unite Copenhagen題為 Developing and optimizing a procedural game | The Elder Scrolls Blades - Unite Copenhagen 的演講,其中就涉及到了一些關於iOS內存的話題(雖然並不是很詳細)。正好也結合工作中的一些經驗,寫一篇文章來討論一下一個Unity開發者如何處理和iOS內存有關的問題。主要內容包括解析iOS系統的內存管理,使用Instrument查看Unity游戲的內存狀況,使用命令行工具深入挖掘Unity游戲的內存問題以及文末小彩蛋。
0x01 iOS的內存管理 - Unity Profiler統計錯了嗎?
首先,我想強調的一點是,Profiler工具所提供的內存數據只是一個(組)數字,而且不同的工具存在有不同統計內存的策略。因此,一個重要的問題是我們看到的數據究竟是如何獲取的?
而根據所使用的工具不同,該工具用於查找數據的策略以及開發人員實際要查找的內容,最后的結果也有可能是不一樣的。因此,如果要尋找一個數字來匯總某個應用或者游戲的所有內存信息,那么可能是把問題想簡單了,或者說忽略了系統的復雜性。例如,不同版本的iOS其對內存開銷的統計都是有區別的——在iOS12上運行的metal app的內存在 Xcode memory gauge的統計是高於iOS11的,這同樣是由於蘋果改變了對內存的統計策略,很多之前沒有被統計的內存如今也被計算到了內存開銷中。而同樣都是iOS,Xcode memory gauge的統計和Instrument中的統計也有可能不完全一致,而早期Instrument的Allocation則主要用來統計heap內存,只能說根據各自工具的統計規則,大家都是正確的。因此,把時間浪費在對比不同工具的數據上還不如以一個工具作為標尺來衡量內存開銷或者是判斷內存的優化是否有效。
The accounting for purgeable, nonvolatile memory changed beginning in iOS 12 and tvOS 12. In iOS 11 and tvOS 11, allocations with this memory storage mode—commonly used by Metal apps to store buffers, textures, and state objects—weren’t counted toward an app’s memory limit and weren’t presented in tools like Xcode memory gauge.
所以,了解操作系統是如何管理內存就變得十分重要,對於如何解讀Profiler工具提供的數據也很有幫助。接下來我們先來討論一下iOS系統對內存的管理機制,之后再來分別看看Xcode抓取的內存數據和Unity抓取的內存數據。
首先,每一個進程都會有一個地址空間。其范圍由指針size支持,比如32bit或64bit。並且地址空間首先會分為多個區域(regions),然后將這些區域細分為4KB(早期版本)或16KB(A7之后)為單位的page,這些page繼承了該region的各種屬性,例如是否是只讀、可讀寫等等。當然,有些page可能存放的數據比這個page的尺寸要小,有的數據可能需要好幾page才能存放,但是系統的內存單位是16kb的page,所以系統統計的內存開銷約等於page的數量 x page的大小。
當然,系統還有真實的物理內存。
Virtual memory vs Resident memory

ref: WWDC 2013
通過虛擬內存使我們能夠建立從該地址空間到真實物理內存的映射,這點我想這些大家應該都知道。而映射其實是一個很有趣的事情。因為從每一個app進程的角度來看,它擁有所有的內存,即虛擬內存,但事實上只有一部分虛擬內存被映射到了真實的物理內存上,這部分被映射到物理內存的部分就是所謂的Resident memory。
就像上面這個圖中描述的一樣,一個app分配了內存,可以看到在虛擬內存上分配了4個region,其中第3個region包括了13個page。 但此時,真正映射到物理內存上的只有6個page。而虛擬內存到真實物理內存的映射發生在對內存的第一次使用時,比如從內存中讀取數據或是向內存中寫數據。Resident memory同樣也是Virtual memory,只不過這部分Virtual memory已經映射到了真實的物理內存。 我想大家可能都通過XCode或者Instrument的統計看到過類似的數據,例如Instrument的VM Tracker中就分別列出了Resident和Virtual Size。
Dirty memory vs Clean memory
page有可能是dirty的也有可能是clean的。要如何區分dirty和clean呢?簡單的說,dirty的頁就是我們的app或者游戲對這個page的內容進行了修改即分配了內存同時也修改了內存的內容,常見的就是malloc在heap上分配的內存。這部分內存是不能被回收的,因為這些數據顯然需要被保存在內存中以保證程序正常的運行。
而clean的頁則是沒有對其內容進行修改,可以被系統收回和重新創建的。例如內存映射文件(Memory-mapped file),如果操作系統需要更多的內存,那么就可以將其丟棄。因為系統總是可以從磁盤中重新加載它,創建內存空間和磁盤上文件的映射關系。clean的內存是可以被釋放和重新創建的。但是可以看到,雖然Memory-mapped file並沒有消耗真實的物理內存,但是它消耗了進程的虛擬內存。
除此之外還有可執行文件的__TEXT段以及一些framework的DATA CONST段,也會歸為clean memory。
在WWDC2018上,iOS的開發人員舉了一個很形象的例子。即分配20,000個integers組成的array,此時會有page被創建,如果只對第一個元素和最后一個元素賦值,則第一個page和最后一個page——即首尾元素所在的page——會變成dirty,但是首尾之間的page仍然是clean,即只分配了內存而沒有修改或寫數據。

ref: WWDC 2018
Compressed memory
當內存吃緊時,會回收clean page。而dirty page是不能被回收的,那么如果dirty memory過多會如何呢?在iOS7之前,如果進程的dirty memory過高則系統會直接終止進程。iOS7之后,引入了Compressed Memory的機制。由於iOS沒有傳統意義上的disk swap 機制(mac OS有),因此我們在蘋果的Profiler工具中看到的Swapped Size指的其實就是Compressed Memory。
iOS7之后,操作系統可以通過內存壓縮器來對dirty內存進行壓縮。首先,針對那些有一段時間沒有被訪問的dirty pages(多個page),內存壓縮器會對其進行壓縮。但是,在這塊內存再次被訪問時,內存壓縮器會對它解壓以正確的訪問。舉個例子,某個Dictionary使用了3個page的內存,如果一段時間沒有被訪問同時內存吃緊,則系統會嘗試對它進行壓縮從3個page壓縮為1個page從而釋放出2個page的內存。但是如果之后需要對它進行訪問,則它占用的page又會變為3個。
Unity Profiler錯了嗎?
可以看到,從操作系統內存管理的角度來看,一個進程的內存其實是十分復雜的。而Unity記錄的內存數據,以“Reserved Total - Unity”為例,則主要來自引擎內MemoryManager的記錄。MemoryManager會根據不同的情況調用對應的Allocator來進行引擎的內存分配。

例如我們可以以Unity 3D Game Kit這個免費項目為例,使用Instrument來查看一下它的內存分配。

可以看到MemoryManager調用了UnityDefaultAllocator。 而下圖的這個分配則使用了IphoneNewLabelAllocator來分配內存。

也就是說Unity的代碼分配的內存,Unity是會進行記錄的。但是我們可以看到除了Unity的代碼本身分配的內存,還有很多framework或者第三方library也會分配內存。但是這部分內存,Unity的Profiler是不會記錄的。
0x02 使用Instrument調試Unity 游戲的內存
這部分我推薦Valentin Simonov的這篇文章Understanding iOS Memory (WiP),對使用Instrument調試內存介紹的十分清晰。
0x03 使用命令行工具深入挖掘內存問題
除了使用Instrument來調查內存問題之外,我們還可以通過很棒的Xcode memory debugger工具來查找內存問題。尤其是將Memgraph導出后,還可以借助各種命令行工具來輔助調查以獲取更多信息。

而且有時大家也會抱怨說在Xcode的Memory Report頁面看到的內存數據有時候不僅和Unity Profiler不一樣,有時甚至和Instrument等蘋果自己的性能工具數值也不一樣。上文已經說過了,不同的工具有不同的數據是正常的。但是我們同樣可以通過Memgraph和命令行工具來查看一下,Memory Report的數據側重什么內容。
還是以Unity 3D Kit這個工程作為演示,測試設備為iPhone X,不過在開始之前我們首先需要開啟Scheme -> Run -> Diagnostics -> Malloc Stack選項。

運行游戲后從主菜單點擊開始游戲加載第一個場景,我們可以在Memory Report中看到此時的內存已經達到了1.48G。但是Memory Report中它的內存刻度仍然在綠色部分,所以實事求是的講Memory Report的刻度並不是一個好的優化建議,因為這個內存開銷在iphone7上就直接會導致游戲被系統中止。

Animation Leak?
我們直接進入到Xcode memory debugger,如果想要在這里檢查是否有內存leak的問題,可以點擊Filter中的選項。這里有一個常見的假“leak”情況。

如果我們看一下它的堆棧信息的話,大多是和Animation有關的。這里我咨詢了一下這個功能的開發者,確認這是一個蘋果的誤報,Unity還是會正常釋放這部分內存的。當然如果大家遇到其他奇怪的和引擎有關的leak,可以按照這篇文章的介紹給Unity提交Bug Report。
之后我們可以將此時的數據導出為.memgraph文件。接下來就可以使用一些命令行工具來處理這些數據了。

VMMAP Summary
第一個命令行工具是vmmap,使用它我們可以查看當前的虛擬內存的數據。
首先拿到一個memgraph文件時,我們可以考慮使用這個指令同時加上--summary標記來輸出當前虛擬內存的一個總覽。
vmmap --summary Unity3DKit_ipx.memgraph
終端的輸出如下圖所示:

我們可以發現一些有趣的地方。首先有前4列是我們之前討論過的內容:VIRTUAL SIZE、RESIDENT SIZE、DIRTY SIZE、SWAPPED SIZE。分別表示虛擬內存的大小,映射到物理內存的大小,Dirty內存的大小以及Compressed內存的大小。 我們可以看到TOTAL的部分,這個游戲進程分配了2.7G的虛擬內存其中有1.6G映射到了物理內存上,而DIRTY SIZE的值是1.4G——這個值很接近Memory Report中的數值,而SWAPPED SIZE的數值為52mb,根據蘋果工程師在WWDC2018上的演講,這個值是壓縮前的內存而不是壓縮后的內存。因此我們主要來關注DIRTY SIZE這一項。
IOKit
其次我們可以看到IOKit的開銷最大,它的虛擬內存不僅達到了832.5mb,而且實際映射到物理內存上的空間也達到了750.4mb。這部分主要是一些和GPU相關的一些內存,例如render targets, textures, meshes, compiled shaders等等。而這個測試項目也的確是mesh、texture的內存占用很大。
MALLOC 和 Heap
再次,我們可以看到MALLOC_**分配了很多內存。這部分內存主要是調用Malloc進行分配的,其中即包括Unity的原生也就是C++代碼的分配也包括第三方庫和系統使用Malloc分配的內存,這部分內存在所謂的Heap上,在這幾行的后面可以看到“see MALLOC ZONE table below”,也就是可以在下面看到各個heap zone的一個歸類。在這里我們可以利用第二個命令行工具heap來檢查一下Heap內存的內容。
heap --sortBySize Unity3DKit_ipx.memgraph
使用heap指令,我們還可以添加--sortBySize標志來對數據進行排序(默認按照類型實例的數量進行排序)。

可以看到Heap的絕大部分內存都被non-object占用了,達到了近700mb,而實際的object的內存分配其實都是很小的,比如類GpuProgramMetal的實例有573個,但是內存其實只占用了223kb。此時大家一定對non-object的內容很感興趣,不過在這個頁面里似乎也看不到太多的內容。所以接下來我們可以添加--showSize標志,將合並在一起的數據按照size進行分組。
heap --showSize --sortBySize Unity3DKit_ipx.memgraph
這樣就清晰多了。

可以看到non-object這一類中,排名最高的幾塊內存分配的尺寸分別是1個31mb、3個10mb以及1個8.4mb,這樣我們就確定了這個時候的調查方向。
當然,heap指令還提供了更多的功能,比如那些有Class Name的對象分配,我們可以通過ClassName匹配的方式獲取每一個該類型實例的內存地址。此時需要-addresses標簽即可。比如我們輸出Unity的GpuProgramMetal類的所有實例的地址信息,可以看到其實這個類的實例本身並不大,但是它引用的真正的shader資源則可能是內存開銷的大戶之一。
heap -addresses GpuProgramMetal Unity3DKit_ipx.memgraph

同時,有了各個對象所在的內存地址,我們就可以通過下面要提到的malloc_history命令來查找它們是怎么來的。但是現在我們還是把目光轉向內存分配比較大的目標吧。
此時返回終端,繼續輸出虛擬內存的信息,不過這次我們只關注MALLOC_LARGE的分配,所以我們可以借助grep來過濾出我們的目標。
vmmap -verbose Unity3DKit_ipx.memgraph | grep "MALLOC_LARGE"
這次輸出了MALLOC_LARGE類型下的內存信息,包括它的地址、尺寸以及所在Heap Zone等等信息。我們可以在這里找到我們的目標,一個30mb、3個10mb以及一個8mb的內存分配。

接下來我們就來看一下分配它們的堆棧調用吧。這里我們會使用malloc_history命令,同時加上--fullStacks標志來輸出堆棧信息。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000127c60000
可以看到這30mb的分配是為了給FMOD分配內存池。

另外3個10mb的分配,同樣也是做類似的事情。可見這個項目使用的聲音資源很多。最后我們來看一下這個8mb的分配是從哪里來的。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000113400000

可以看到是開啟多線程渲染時,Unity創建CommandQueue時分配的內存。
VM_ALLOC == Mono Size?
接下來,我們可以看到vmmap –summary輸出的結果中,有一項叫做VM_ALLOC。根據Valentin Simonov的說法,VM_ALLOC對應的是Mono內存也就是托管內存的大小。究竟是否如此呢?我們同樣可以通過上面的方式,來查看一下VM_ALLOC部分的內存分配堆棧。 首先我們還是通過vmmap和grep來過濾出VM_ALLOC部分的內存信息。
vmmap -verbose Unity3DKit_ipx.memgraph | grep "VM_ALLOC"
可以看到這部的內存分配並不多,我們同樣選擇2塊分配最大的內存下手。

我們首先使用malloc_history來查看一下3m部分的調用堆棧。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000152bd4000

我們可以看到這3m的內存是C#腳本中調用了SimplFXSynth的RenderAudio方法而觸發了GC分配,托管堆進行了擴容。針對腳本中的方法定位,我們可以通過RuntimeInvoker這個符號來定位它在堆棧中的位置。

有趣,接下來我們再來看看1mb的這塊內存是怎么分配的。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000150084000
這次是由於Unity的ScriptingGCHandle::Acquire方法在托管堆上進行了內存分配。

可見,VM_ALLOC這部分內存主要對應了Unity的Mono托管堆的內存而且這個項目的Mono內存並不大。而具體是哪個函數觸發了GC分配,則可以通過malloc_history來查看。
Command Summary
至此,使用命令行調試和查找iOS平台上內存問題就介紹完了。簡單來個小結,拿到一個Unity游戲的內存.Memgraph文件之后,可以先通過vmmap --summary來查看一下內存的全景圖。對於heap也就是malloc分配的內存,可以進一步通過heap指令來進一步分析。 而一旦獲取了目標對象的內存地址之后,就可以使用malloc_history指令來獲取分配這塊內存的堆棧信息了。當然前提是要開啟Malloc Stack的選項。之后,可以做一個自動化的分析工具,對數據進行處理和輸出來定位內存問題。
0x04 小彩蛋
-
Unity 3D Game Kit是一個很棒的Unity的學習工程。它的教學頁面可以查看這里:https://learn.unity.com/tutorial/3d-game-kit-reference-guide#5c7f8528edbc2a002053b73f

-
iOS13之后提供了一個新的API-os_proc_available_memory,利用這個API我們可以獲取當前這個進程還能獲取多少內存的預估值。嗯,怪不得我的iphone7跑不動這個項目。

Useful Link:
-
https://connect.unity.com
-
https://developer.apple.com/documentation/metal/reducing_the_memory_footprint_of_metal_apps
-
https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemAdvancedPT/MappingFilesIntoMemory/MappingFilesIntoMemory.html
-
https://docs.google.com/document/d/1J5wbf0Q2KWEoerfFzVcwXk09dV_l1AXJHREIKUjnh3c/edit
-
https://developer.apple.com/videos/play/wwdc2013/704/
-
https://developer.apple.com/videos/play/wwdc2018/416/
https://docs.microsoft.com/zh-cn/learn/?WT.mc_id=DT-MVP-5001664