觀《Unite ShangHai 2019 高川先生 Unity內存》演講筆記

在這裏插入圖片描述
無意中發現了乾貨滿滿的一期演講視頻,廢話不多說,開始正題:
視頻鏈接

第一節: 在這裏插入圖片描述

既然要講Unity的內存詳解,那麼就先要從什麼是內存講起。高老師從以下三個方面剖析了內存是什麼:

  • 物理內存
  • 虛擬內存
  • 內存尋址範圍(一筆帶過,這裏就不記了)
  • 安卓內存管理

物理內存(Physical Memory):

節選了一下百度百科給出的解釋:

物理內存(Physical memory)是相對於虛擬內存而言的。物理內存指通過物理內存條而獲得的內存空間。內存主要作用是在計算機運行時爲操作系統和各種程序提供臨時儲存。在應用中,自然是顧名思義,物理上,真實存在的插在主板內存槽上的內存條的容量的大小。

而講到物理內存,就要提到CPU對於物理內存的訪問速度,相對於CPU的運算速度,是一個非常緩慢的過程。而硬件生產商爲了優化這一現象,就給CPU主板上加了大面積的板載的Cache(緩存),如圖:
在這裏插入圖片描述
可以看到圖中一大塊的面積都用來放這個Shared L3 Cache去了。說到這裏,也需要稍微講解一下CPU讀取內存的簡單工作機制才方便理解這個Cache是做什麼的。

簡單來講,CPU每次去內存找東西都會先分級去找緩存(緩存分了L1,L2,L3),每一級沒有找到就會有一個cache miss然後繼續往下一級的緩存去找(順序:L1 -> L2 -> L3,往往緩存的大小也是按照這個順序遞增,即 L1 < L2 < L3)。一直到所有緩存都沒有,CPU纔會去主內存去找。

可以想象,每一次內存查找都這麼麻煩的話,那麼每一次的cache miss就會帶來大量的性能損耗。目前Unity針對這一點給出的優化方案就是想辦法減少cache miss,而這個辦法就是ECS & DOTS。其根本就是將程序所使用的數據從不連續變成一個連續的排列狀態(這裏就不展開說了)。

另外有一點特別需要注意的就是,移動設備和臺式設備的內存架構是存在差異的

  • 沒有獨立顯卡,都是板載顯卡
  • 沒有獨立顯存,和數據內存用的是同一塊內存
  • CPU板上面積更小,緩存級數更少,大小更小(例,一臺主流臺式機的CPU的三級CPU約 8 - 16 MB;而主流偏高端的移動端CPU的三級緩存(比如高通845),是2MB)

虛擬內存:

百度百科:

虛擬內存是計算機系統內存管理的一種技術。它使得應用程序認爲它擁有連續的可用的內存(一個連續完整的地址空間),而實際上,它通常是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數據交換。目前,大多數操作系統都使用了虛擬內存,如Windows家族的“虛擬內存”;Linux的“交換空間”等。

說到虛擬內存,其實主要就是聚焦在內存交換這一操作上。將相對閒置的內存空間交換到硬盤上(這裏就涉及到IO操作),將內存釋放出來給更活躍的正在運行的數據。

對於虛擬內存,高老師提到了以下幾點:

  • 移動設備不進行內存交換,原因就是交換的所要求的IO操作代價太大以及內存卡的可擦寫次數也是有差異的
  • iOS會進行內存壓縮:iOS會對不活躍的數據進行一定的壓縮以節約空間
  • 安卓不會對內存進行壓縮

安卓內存管理

- 內存基本單位: Page (默認是4KB/Page)
- 內存管理工具: Low Memory Killer(LMK)
- 內存指標: 各種SS

安卓的應用分爲以下的類別:

  1. Native: 系統內核
  2. System: 系統級應用和服務
  3. Persistent: 用戶級的應用和服務(例:電話,藍牙,WiFi等)
  4. Foreground: 前臺應用;當前正在使用的Activity
  5. Perceptible: 輔助應用和服務
  6. Service: 駐後臺的線程服務(例:雲服務,垃圾回收等)
  7. Home: 桌面
  8. Previous: 上一個使用的應用
  9. Cached: 後臺

當系統內存不夠的時候,LMK就會按照上面的列表從下往上開始殺掉應用程序釋放內存。LMK果真是名副其實的Killer。而LMK沒殺掉一層級的應用,都會有一些特定的表現,如:

  • 當Cache層被殺掉,所有後臺應用的內存都被釋放掉。當你再次通過後臺切換到這些應用的時候,你會發現這個應用重啓了
  • Previous被殺掉與Cached層表現一樣
  • Home層被殺掉會導致主頁表現異常,例如壁紙不見了,應用圖標正在一個一個重新被加載出來等
  • Foreground被殺掉就是常說的當前應用閃退了,崩潰了之類的表現
  • 而當LMK殺到System層,手機就重啓了。殺手自己也被殺掉了

常用內存指標:

  • Resident Set Size (RSS):應用程序自己被分配到的內存和其所調取的公共服務所佔用的內存之和
  • Proportional Set Size (PSS):應用程序自己的內存 + (每一個所調取公告服務/該公共服務調取程序數),即把公共服務所佔用的內存平攤到每一個調取它的APP上面
  • Unique Set Size (USS):只有應用程序被分配到的內存

第二節:Unity 內存管理

在這裏插入圖片描述
參考文檔

首先說一下Editor & Runtime 的不同。舉個例子就是在Runtime的時候,我們的Assets如果不被Load,是不會被加載進內存。但是Editro裏面的東西會在打開Unity的時候就全都被加載進內存。這樣的好處是我們在開發過程中省去來等待加載的過程,開發起來更流暢和連貫。弊端則是如果遇到了超大型的項目,光是打開這個項目可能就要花去3天到一週的時間。

在這裏插入圖片描述
在這裏插入圖片描述
用戶分配的Native內存有哪些呢?比如你導入了一個由使用C++編寫的插件,Unity就無法分析到已經編譯過的C++代碼是如何去分配和使用內存;另外一種情況就是Lua。因爲Lua是自己內部進行管理的,所以Unity無法檢測到Lua對內存的使用情況。

Native Memory

在這裏插入圖片描述

  • Unity重載了C++裏面每一個的分配內存的操作符,如alloc, new等,給這些操作符新增了一個額外的參數,memory label。這個memory label 就是我們在Profiler裏面Memory Snapshot裏面那些欄目的名字。Unity在底層就會根據這個memory label在分配內存的時候把這些一塊塊分配出去的內存放到對應的內存類型池(即,Allocator)裏面,然後由這些池自己去做池子內存的跟蹤。

  • Allocator 是由一個操作符NewAsRoot生成。當我們加載一個新的資源時,就會調取NewAsRoot生成一個Memory Island,然後以這個資源爲Root去管理所有需要的子內存分配。例:當我們加載一個Shader時,會以這個Shader爲Root生成一個Memory Island,然後這個Shader所需要的其他數據,如sub_shader,path, parameter等,就會作爲這個Memory Island的一個成員,也就是這個Root底下的一個成員,去依次分配。最後,當Unity去統級一個Runtime的內存數據時,Unity統計的就是Root而不會去細究到底下的成員分別佔用了多少。

  • Unity Native Memory因爲是在C++層面,所以是會及時返還給os。

下圖便是一個容易導致Native 內存上升的原由
在這裏插入圖片描述

  • Scene:由於Unity是一個C++引擎,它所有的實體都會反應到C++上,而不會反應到託管堆裏面。當我們構建一個GameObject的時候,在Unity的底層會構建一個或多個Object來存儲這一個GameObject的信息,比如它身上的各種Component的信息。所以當我們在一個Scene裏面有過多的GameObject的時候,Native內存就會顯著的上升。這也是最常見的導致Unity Native內存大量上升的原因。

  • Audio - DSP buffer: 當一個聲音需要播放的時候,它需要向CPU發送指令。但是如果聲音數據非常小,就會非常頻繁的向CPU發送指令。會造成過多的IO消耗。因此在向CPU發送指令之前,這些聲音數據都會緩衝在DSP Buffer裏面,只有這個緩衝區域滿了,纔會向CPU發送指令。這個緩衝區也是很多人感知到安卓有聲音延遲的原因,他們把DSP Buffer設置的太大了。

  • Audio - Force to mono: 簡單來講就是把大部分不需要雙聲道的音頻給強制轉換成單聲道以節約內存空間。因爲大部分音頻其實兩個聲道的數據是一模一樣的。

  • Audio - Format:音頻的格式。主要就是不同的平臺對不同的音頻格式有支持。iOS對mp3格式有硬件支持等。詳見Unity Manual。

  • Audio - Compression Format: 壓縮格式,詳見Unity Manual。

  • Code Size: iL2CPP底層編譯展開代碼導致cpp文件過大。一會影響內存佔用,二會影響編譯速度。最常見的源頭是模板泛型的濫用。

在這裏插入圖片描述

  • AssetBundle - TypeTree:版本類型兼容數據結構。因爲Unity不同版本可能有的類型會有所改變,而TypeTree則會在序列化時記錄下來,反序列時保證能夠找到對應版本不同的數據應該反序列化成哪種類型,從而儘可能實現了版本兼容的特性。Unity中提供一個開關的接口可以關掉TypeTree,所以當你確認遊戲版本唯一時,可以關掉它以減少內存,縮小包體,build變快。

  • AssetBundle - Lz4 vs Lzma: Lz4 爲目前主流壓縮方式,優點是速度快Lzma10倍,而且是trunk base所以可以一次一次解壓,即可以複用內存,減少內存峯值;缺點是包體大小會比Lzma大30%。Lzma是stream base,只能一次解壓,速度慢,內存佔用大,已經不推薦使用。

  • Size & Count: 包體大小和包體數量的均衡。因爲AssetBundle是分包頭(包頭即包內數據共用的一些索引數據)和包內數據。如果包體過多,包內數據過少,可能導致包頭比包體數據還要大。則會導致得不償失。每個項目應該按需決定自己包體大小和包的數量。

  • Resource: 和AssetBundle包有一個包頭去儲存索引數據,Resource會在被打進包的時候做一個紅黑樹來幫助Resource去檢索它所需要的資源的位置的。這個紅黑樹會在遊戲剛剛加載就存進內存中,並且不可卸載。因此會造成一個持續的內存壓力。當我們Resource文件夾過大時,這個紅黑樹也會跟着增大,帶來更多的內存壓力並且拖慢遊戲的啓動時間。建議:使用AssetBundle代替Resource。

  • Texture - upload buffer: 同Audio的DSP Buffer,緩存池。滿了才向GPU推送一次。大小也可以在Unity中設置

  • Texture - r/w:Texture內存優化經典項目。當Unity檢測到你把這個選項勾選上,會把這份Texture在顯存和緩存中各備份一份以方便後續的修改。如果你的Texture加載之後再也不會有修改的操作,請把這個選項去掉,節省大量內存空間。

  • Mip Maps: 一些UI等資源,不需要的也請把這個選項去掉。

Managed Memory:

在這裏插入圖片描述

VM內存池(Mono 虛擬機內存池):

  • VM內存池在滿足一定條件的時候,是會內存返回給OS的。這個條件是:當一個Block連續6此GC被訪問到,這個block就會被返還給OS(然而,這種情況基本上看不到 😛)。

在這裏插入圖片描述
Unity 使用的GC回收算法是Boehm,他有以下兩個特點,從而導致了內存碎片化的問題:

  • Non-Generational 非分代式:分代式指的是對內存按照block的大小,訪問的頻率來進行分區的操作。而非分代式則沒有這些操作,全部內存block混雜在一起,優點是快。

  • Non-Compacting 非壓縮式:壓縮式指的是內存被回收的時候,回根據大小重新排布這些block。而Unity是非壓縮式的,即不會重新排布內存。

  • 下一代GC: Incremental GC
    當前的GC回收需要主線程停下來,遍歷一遍所有的Memory Island來決定那些資源可以被回收,會造成主線程卡頓。而新一代的Incremental GC把這個遍歷的過程分開好幾幀來完成,從而減少了CPU峯值。

Managed Memory Best Practices:

在這裏插入圖片描述

  • 只有顯式的調用Destroy才能真正把一個東西摧毀,光是把引用設成null並不行
  • Class vs Struct:簡單來說Struct因爲不是引用類型,內存的佔用比Class要優
  • Pool in Pool: 這裏我的理解就是Pooling,將高頻重用的東西放到池子裏重用,而不是經常性的創建和摧毀
  • 閉包和匿名函數和協程:在底層iL把C#編譯出來的代碼裏面,所有的閉包和匿名函數都會被new成一個Class。所以當這些匿名函數和閉包包括協程沒有被釋放,這些函數裏面的變量(即是是local字段)也會一直holding在內存裏面不會被釋放。所以官方對於協程的使用建議即是使用的時候創建,不用的時候釋放掉。不要把協程當線程來用。
  • Configurations:縮小配置表大小。進來把大配置表拆分開。
  • Singleton:慎用單例因爲它會一直存在內存裏。

Q & A:

  1. SetActive在項目裏面佔用了調用了很大的GC,請問SetActive裏面到底爲什麼這麼損耗性能?

SetActive實際上在背後會做很多設置,尤其是當我們在用UI的時候,他還會進行一個子UI遞歸的初始化操作。所以一般建議如果你項目的UI調用SetActive時有很大的消耗,可以只把UI移除屏幕外而不需要開關一次。

  1. 異步 vs 協程的使用哪個更好:

這兩個其實是不衝突的兩個東西。異步更多的可以應用在IO操作的時候,異步的進行可以讓你的主線程不用停下來等待IO的返回;協程其實更多的是一個輪循的過程,每一個都會循環分時的被調用,而不是因爲某一個自己需要等待而讓別的協程先進行。這裏面是一個主動等待和被等待的區別。當然他們很多時候也可以互相替代給開發者一種兩者選一的感覺,其實只是看你當前需要完成的工作更看重什麼。

  1. 好的我承認這道題我連他在問什麼都不是很清楚,感覺這個觀衆想問很多導致說了一大堆沒有重點。

高先生的回答總結就是Unity的mono源碼是開源在Github上的,如果有需要是可以自己修改編譯。然後因爲Unity已經停止了對mono的支持,所以他這邊不清楚以後會不會對mono有更新。


啊,寫完了。以後常回來複習看看。真是乾貨滿滿的一期視頻,希望下次有機會親臨Unite現場!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章