2017年1月9日 星期一

詳解Unity WebGL記憶體:Unity Heap

作者:MARCO TRIVELLATO 原文
潤稿:Kelvin Lo

之前的詳解Unity WebGL記憶體一文,我們解釋了WebGL和其它平台記憶體工作的不同之處,我們也給出Unity Heap應越小越好的建議,同時也強調瀏覽器中定會有其他類型的記憶體開銷。

本文將深入探討Unity Heap,並根據實際資料來減少Unity Heap的大小,而不是採用不停除錯和試錯來達到這目的。下面就來看看Unity Heap的定義、原理以及如何進行Unity Heap記憶體分析。

Unity Heap是什麼?


首先要明白,Unity Heap和瀏覽器堆疊是不同的概念。Unity Heap實際上只是瀏覽器堆疊中的一塊記憶體。這方面的講解在
之前的詳解Unity WebGL記憶體已有說明。大致來說,所謂堆疊就是一塊用於動態分配的記憶體,允許應用程式使用malloc/free或new/delete對記憶體進行操作。Unity有自己的記憶體分配系統,以便提高記憶體的利用率,同時可以更方便進行分析與除錯。但在底層仍然使用malloc/free。

在Unity WebGL中,含有所有執行時Unity引擎物件的這塊記憶體稱為Unity Heap。Unity Heap中的記憶體分配是透過dlmalloc完成的。

在遊戲機平台(例如PS,XBOX)上,這個堆疊的大小由硬體規格和作業系統保留記憶體的大小所決定,因此應用程式應保證申請的記憶體不會超過執行時可用記憶體。


在WebGL平台上同理,我們需要預先定義Unity Heap的大小(在Build-time時)。這就是說一旦初始化,Unity Heap的大小就不能再改變。

Unity Heap裡有什麼?


在Unity WebGL平台中,將Unity Heap分配類型如下:
  • 靜態記憶體區域
  • 堆疊(Stack)
  • 動態記憶體
  • 未分配記憶體



被分配的第一塊區域用於堆疊和存放所有靜態物件。堆疊的大小通常是5MB,而靜態區域的大小取決於編譯的程式,不同Unity版本會有所不同。上面這些區域分配好之後,剩餘的所有記憶體即可供執行時動態記憶體分配使用。

當程式開始執行後,動態區域就會佔據越來越多的Unity Heap空間。如果這片區域佔據的空間過多,最終就會導致沒有記憶體供Unity使用。

隨著時間越久,即便會有一些物件被釋放或者其他物件的記憶體再分配,動態記憶體區的大小因為沒有對應的壓縮機制也不會減少。而且這類操作也會使動態記憶體區產生碎塊。



所以要記得記憶體中是會有碎塊產生的。

那託管記憶體(Managed Memory)哪去了?動態區域中有一個或多個運行時託管的Heap,程式建立的所有物件都在裡面。因此託管的Heap是Unity Heap的一部分,而Unity Heap又是瀏覽器JS VM Heap的一部分。聽起來有點複雜,如果看過Inception(全面啟動)或The Matrix(駭客任務),那就理解成一個Heap在另一個Heap中,依此類推...



託管記憶體(Managed Memory)


所有腳本物件都存放於此。之所以叫它"託管"是因為每當一個物件不再被引用時,垃圾回收器(Boehm)就會自動回收這部分記憶體。首先需要瞭解的一個重點是:託管記憶體是從Unity Heap中分配的(或從其他平台的作業系統分配)。其次,這部分記憶體不會再歸還給作業系統,因此託管的Heap大小只增不減。實際上,當一個物件被回收後,它原本佔用的記憶體仍舊被保存在託管的Heap中以供將來使用。

就Unity WebGL而言,當我們說"記憶體不會被歸還給作業系統"時,實際上說的是這部分記憶體不會再歸還給Unity Heap中的可用區塊池。還有一點需要強調的是,與Unity Heap不同(Unity Heap是單個一整塊記憶體),Boehm垃圾回收器有分配多重緩存的能力。另外,每一塊緩存都可以按需被分割為更小的塊。不過當建立新腳本物件時,需要一塊足夠容納這個物件的相鄰記憶體空間,如果Boehm垃圾回收器託管的可用塊不足以滿足需求,則會創建一個新的區塊(從Unity Heap中劃取)。

更多關於託管記憶體的資訊,請查閱Unity手冊


託管記憶體用光後會如何?

如果Boehm垃圾回收器沒能找到用於建立新物件的空閒記憶體,則從Unity heap請求分配失敗,Unity WebGL將停止執行,同時拋出記憶體不足並建議增加WebGLMemorySize的大小
的錯誤

System.GC.Collect無法用於WebGL嗎?

Unity WebGL平台上呼叫GC.Collect()是沒有效果的。因為呼叫堆疊在不為空的時候是無法進行GC操作的。更多有關該限制的內容可查閱Unity手冊

這時Unity WebGL會在每幀開始時嘗試進行一些GC(垃圾回收)操作。之後在載入新場景時,系統會進行一次完全的GC操作。

System.GC.GetTotalMemory具體做了什麼?

在Unity WebGL平台上,這個函數的作用與在其他平台上是相同的,同時也提供了GC回收機制:System.GC.GetTotalMemory() 傳回當前使用的所有託管記憶體,和Profiler.GetMonoUsedSize()一樣。如需瞭解託管Heap的總大小(已使用+空閒),可以使用Profiler.GetMonoHeapSize()

如何在託管Heap中保留一定數量的記憶體?

如果曾用過C++ std容器(例如string,vector等等),應該已經瞭解在向容器中追加或插入新元素時,它們的大小會發生變化。在需要將使用記憶體控制在一定範圍內的遊戲和一些其他應用程式中,這可能是個問題,不過可以使用預留記憶體方法(例如:std::string::reserve, std::vector::reserve)來解決這問題。

與C++ std容器不同,Unity中沒有為託管Heap提供類似的記憶體保留API。不過此前也曾提到,可以走偏門來達成這一目的。

假設已經預先知道程式內容的託管Heap佔用大小,就可以預先創建一個大小相同的陣列,然後手動執行GC。這樣能為託管Heap保留一塊記憶體,託管Heap也不再需要擴充了。

聽起來不錯,但如我們之前提到的,呼叫GC.Collect()函數不會有任何效果,且完整的GC機制只在場景載入時被啟動。當然,這個問題還是比較容易解決的,可以設一個預載入場景,其中僅有一個遊戲物件,將分配陣列的腳本附加在物件上。

然後將這個場景設定為專案的第一個場景。現在只剩一件事要處理,我們需要知道預分配託管Heap整個遊戲週期大概會用多少,然後使用Profiler.GetMonoHeapSize()函數獲取保留記憶體的總大小。最後記住一點,使用這個方法的代價是程式的託管記憶體永遠都是最大值。

設定Unity Heap大小


解釋過Unity Heap與記憶體管理腳本之後,回到最開始的問題:選擇Unity Heap大小的最佳策略是什麼?

基本思路是要知道執行內容所需的最大記憶體占用量,然後將WebGLMemorySize設定為一個大一點的值(大於16的倍數)。具體做法是完整測試WebGL程式的所有內容,記錄所占記憶體的高峰。然後將最終大小再稍微加大以防萬一,將它調整為16MB的倍數。

好在Profiler API提供了獲取總記憶體大小(Total Reserved Memory)的函數。Profiler.GetTotalReservedMemory(),對應Unity Profiler視窗中的ReservedTotal:


但是這種方法存在兩個問題。第一個問題和記憶體峰值有關:如果臨時記憶體的分配和釋放發生在同一幀時,這部分開銷就不會被計算在ReservedTotal中。第二個問題是Unity Profiler不會記錄所有的記憶體分配。

沒跟蹤到的記憶體分配

從Profiler中獲取的資訊可以發揮很大的作用,然而還需要考慮到一些事情:Profiler會告訴您它知道的一切,但它無法告訴您它不知道的東西!

Unity之所以能夠追蹤記憶體的使用情況,是因為記憶體的分配都是通過MemoryManager::Allocate()完成的,而該函數會存儲有關記憶體分配名稱和大小的額外資訊。不過出於某些原因,還有一些其他的記憶體分配操作不會被追蹤,因此如果想要確切地知道Unity Heap裡佔用了多少記憶體就有問題。

通常是因為某些內部子系統和第三方廠商的函式庫在存取記憶體時使用的是malloc/free函數,而不是Unity自身的MemoryManager。除此之外,開發者的plug-in也有可能產生這樣的情況,例如C和C++代碼中的mallc()函數,或JavaScript中的_malloc()函數(例如JS樣例外掛程式中的StringReturnValueFunction函數),或者是檔案的寫入操作(這也會導致記憶體的寫入)。

專案需要考慮的未追蹤記憶體究竟有多大?我們拿Unity 5.5一個簡單的專案來說約7mb以下。好消息是,我們正在著手解決這個問題,將來這個數字只會少不會多。

如何知道記憶體的準確用量

實際上還是有方法的。我們再次回顧最開始的圖片,很容易就能發現總記憶體佔用(Total Reserved Memory) = 靜態記憶體 + 堆疊 + 動態記憶體。



很幸運的,我們能夠即時取得這些記憶體區域的大小,使用emscripten產生的變數和常數即可。之後僅需存入jslib檔並存儲在專案裡,然後建立對應的C#程式即可。
只要把這些程式存成一個新的jslib檔案放道專案裡,然後建立對應的C#綁定。
使用這種方式還有一個好處,與Profiler API不同,這個Plugin可以在最後發佈的版本中使用。需要注意的是,上述的jslib程式依賴于emscripten產生的JS程式,因此在將來的Unity版本中,這個Plugin可能會需要更新。不過既然已經發現了這個問題,我們可能會為它加上Unity WebGL專用API來避免這樣的問題。

如何分析Unity Heap的資料


首先可以使用Unity Memory Profiler記憶體分析器,該分析器可以提供記憶體資料的總覽,以及所有記憶體分配類型的詳細資訊。如果需要排查記憶體洩露,可以參考CPU分析器中的GC Alloc一欄。這一欄可以清楚表明在某一幀分配了多少記憶體。


順便一提,如果在使用分析器的過程中遇到問題,有可能是5.3中的bug。我們已經在5.3.6 Patch 8中修復了這個問題。

如果想試試更新的東西,可以試試新的記憶體分析器(支援Unity 5.3):


Memory Profiler是個非常實用的工具,但還是要注意一點:它只適用於il2cpp(當然這不是問題,因為我們只在Unity WebGL上使用),它也還只是一個體驗版,因此可能在使用時會產生各式各樣的問題。

總結


這篇文章是否解決了您記憶體方面的疑問?這是一個非常寬泛卻又非常重要的課題,我們將來還會圍繞這方面分享更多文章。重要的是,現在已經有了查看Unity WebGL內容運行所需記憶體大小的工具。如果想瞭解更多關於分析和優化的內容,可以看看這裡,或看看Unite Europe 2016的主題演講"優化手遊APP"。

任何問題,也歡迎加入論壇一起討論

沒有留言:

張貼留言

關於我自己

我的相片
Unity台灣官方部落格 請上Facebook搜尋Unity Taiwan取得Unity中文的最新資訊