問題

開發手機遊戲時,常聽到身邊的人傳授經驗:「CPU和GPU是共享一份內存的」,但這句經驗到底具體指的是什麼,彷彿總得不到細節精確的回答。

因此,本文嘗試以一張貼圖紋理的虛擬內存佔用為例,就以下問題進行分析和解答:

  1. 是否的確主存顯存共享一份貼圖虛擬內存?
  2. 如果問題1證實的確只有一份,紋理虛擬內存的完整流程是怎樣?Unity將該紋理文件在主存載入好紋理數據後:2.1.直接調用圖形API傳遞該主存指針,從而GPU能直接訪問該主存中的紋理數據?2.2. 還是需要調用圖形API將該主存中的紋理數據拷貝到另一份虛擬內存中,以供GPU訪問?拷貝完成後紋理主存部分如何處置?

術語

為清晰表達避免概念混淆,本文採取以下術語:

物理內存(Physical Memory):具體的存儲硬體,各種SDRAM,比如LPDDR是移動設備常用的一種低功耗SDRAM。

虛擬內存(Virtual Memory):對物理內存的一種邏輯映射。主存(Main Memory/Primary Memory):CPU能讀寫的虛擬內存。顯存(Graphics Memory):GPU能讀寫的虛擬內存。

另外,外存(External storage):外部存儲,「硬碟」,在移動設備一般是Flash。

iOS篇

硬體

如下4圖[1][2]所示,iPhone6隻有A8裏擁有一塊物理內存(1GB LPDDR3 RAM),且CPU/GPU晶片中並無物理內存(SDRAM),只有物理內存的介面(SDRAM Interface)。

且A8採取PoP封裝(Package on Package),即將CPU/GPU晶片和物理內存豎直排列於A8晶元中,將CPU/GPU晶片移除後,在下一層露出了它倆共用的一塊物理內存。注,晶片中有高速Cache緩存,類型為SRAM。

iPhone6的物理內存位於Apple A8裏

Apple A8 晶片裏,只有SDRAM的介面,並無SDRAM

A8 GPU PowerVR 6450裏只有System Memory Interface,並無SDRAM

其他iOS設備,iPhone、iPad等,亦如此,硬體層面,它們的物理內存都為統一內存(Unified Memory)架構,即主存和顯存都位於同樣的物理內存硬體中。

而桌面電腦一般是分離物理內存(Discrete Memory)架構。

圖形API

自2013年的AppleA7(iPhone 5s)起iOS設備便支持Metal[3],考慮當下(2018)的市場份額,故只討論支持Metal的情況,而不討論iOS上OpenGLES的情況。

系統層面,Metal支持主存顯存同時訪問同一塊虛擬內存,即MTLBuffer的options為MTLStorageModeShared[4,5,6],此情況已無主存顯存之分,Shared模式是Buffer(比如頂點緩存、索引緩存)的默認創建模式,在iOS中Shared也是紋理緩存的默認創建模式。

Resource storage modes in iOS and tvOS

此時對該虛擬內存的修改,會同時反饋到CPU和GPU上,除非CPU準備好Buffer的內容後不再修改,但一旦CPU對Buffer進行了二次修改,為避免和GPU的訪問衝突,需要有一定的同步機制,比如三重緩衝(Tripple Buffering)[7]。

Pirvate模式為GPU單獨訪問的虛擬內存,主要用於RenderTexture等情況[9],並非當前重點。

分析Unity在iOS的實現

雖然圖形API機制如此,但引擎內部實現大相徑庭,保守起見,具體結論應以引擎具體邏輯為準。

先以紋理為例,Unity在iOS+Metal上從紋理文件存儲到最終紋理顯存,其二進位流的完整流程是怎樣的?人肉閱讀分析Unity源碼是耗時且可能不準確的。結合Profiler等工具進行分析,會省時精確,事半功倍。這樣也可順帶對Profile工具的綜合應用進行介紹。所以下面,先假設我們不知道Metal的機制,試從現象推斷出原因。

GFXMemory測試Demo

先創建一個名為GFXMemory的測試demo,分別有3張解析度足夠大的4096x4096的紋理貼圖,格式分別設為RGBA32、RGB24、ASTC5x5,通過運行時點擊對應的區域,才單獨載入對應貼圖,顯示在屏幕中。

準備做Profile測試先查證以下問題:

由於3張紋理解析度非常大且開啟Mipmaps,其內存佔用理應是期待紋理虛擬內存 = 85.33MB + 64.00MB + 13.65MB = 162.98MB,如果最終內存穩定後,本進程的虛擬內存佔用約為進程內存 ~= 啟動內存 + 已載入紋理內存,即可證實紋理虛擬內存佔用的確只有一份,否則如果進程虛擬內存約為進程內存 ~= 啟動內存 + 2*已載入紋理內存,即可證實主存、顯存各持一份紋理貼圖。

Unity版本為2017.4.8f1、XCode版本為10.1、運行設備為iPhone6s。

先用Unity以Development Build進行XCode工程導出,Development Build僅僅是為了能用Unity Memory Profiler進行Profile。

XCode中對Unity-iPhone工程進行Edit Scheme,並如下圖開啟Malloc Stack,是為了在命令行對memorygraph使用malloc_history命令查看內存創建的堆棧。

開啟Malloc Stack才能對memorygraph方能使用malloc_history命令查看內存創建的堆棧

XCode中構建版本,USB連接iPhone6s並在其上運行,等待幾秒鐘待內存穩定後:

  • 在XCode點擊「Debug Memory Graph」,截取得出XCode的內存統計,並且Export為xcode_empty.memorygraph文件

點擊UI載入上面3張紋理後,等待幾秒鐘待內存穩定後:

  • 在Unity用Memory Profiler點擊Take Snapshot,截取得出Unity的內存統計,並另存為unity.memsnap3文件
  • 在XCode點擊「Capture GPU Frame」,截取得到當前幀的GPU快照,並另存為xcode.gputrace文件
  • 在XCode點擊「Debug Memory Graph」,截取得出XCode的內存統計,並且Export為xcode.memorygraph文件

注意上述操作都確保遊戲是一次運行針對同一進程的4次抓取結果,從而確保內存地址穩定。

我們在命令行執行命令vmmap --summary ./xcode_empty.memgraph,得到載入紋理前的虛擬內存佔用約為111.3MB,如下圖:

載入紋理前,Native虛擬內存佔用約為111.3MB

上圖我們應關心「DIRTY SIZE」和「SWAPPED SIZE」,前者代表已寫虛存大小、後者代表已寫待壓縮虛存大小。iOS和一般OS不一樣,不採取虛存切頁(Paging)的機制,而是採取壓縮內存的機制。而在iOS中所謂的內存佔用(Memory Footprint)事實上是MemoryFootprint = DirtySize + CompressedSize,iOS以MemoryFootprint的大小作為Killapp的依據。注意Swapped Size是待壓縮的大小,壓縮後方為Compressed Size。[8]

Memory Footprint = Dirty Size + Compressed Size

我們再執行命令vmmap --summary ./xcode.memgraph,得到載入紋理後的虛擬內存佔用約為297.8MB,如下圖:

載入紋理後,Native虛擬內存佔用約為297.8MB

從而,載入紋理額外虛擬內存佔用 = 297.9MB - 111.3MB = 186.6MB ~= 期待紋理虛擬內存佔用162.98MB,而186.6MB << 325.96MB,從而幾乎已經證實問題1,的確主存顯存共享一份貼圖虛擬內存。至於為何會多出186.6MB - 162.98MB ~= 23.62MB,我們會在後面證實到。

但僅僅從內存增幅來認定內存共享一份,顯得還不夠精確。

這時有個貌似合理的猜想:「如果GPU裏用到的紋理虛擬內存地址,剛好等於MemoryGraph中對應的紋理虛擬地址,就說明它們必然是共享一份內存了」。

懷著這個想法,我們用XCode打開xcode.gputrace文件,搜索得出4096_rgba32的虛擬內存地址為0x1083f5b80,如下圖:

GPUTrace文件顯示4096_rgba32紋理的虛擬內存地址為0x1083f5b80

Unity Memory Profiler Editor本不支持顯示對象的Native虛擬內存地址,簡單修改其源碼,讓其在面板上顯示Unity Native Object的虛擬內存地址,4096_rgba32紋理的虛擬內存地址為0x1083f53b0紋理,如下圖:

Unity Memory Profiler顯示4096_rgba32紋理的虛擬內存地址為0x1083f53b0

「CPU/GPU訪問的紋理地址不一樣,這證實這張紋理不是CPU/GPU共享的!」但可惜,不能因此得出這個結論。

我們控制檯針對GPUTrace的地址使用命令malloc_history ./xcode.memgraph -fullStacks 0x1083f5b80,有下圖輸出:

GPUTrace紋理對象AGXA9FamilyTexture地址的堆分配函數棧

針對Unity Memory Profiler的地址使用命令malloc_history ./xcode.memgraph -fullStacks 0x1083f53b0,有下圖輸出:

Unity Memory Profiler紋理對象Texture2D地址的堆分配函數棧

使用XCode再次打開xcode.memgraph,搜索地址0x1083f5b80,發現其類型是「AGXA9FamilyTexture」,而且對象大小僅僅只有528位元組,見下圖:

0x1083f5b80地址對應的,僅僅是紋理對象,而並非我們最關心的紋理內容

上面3圖,證實了上面的地址僅僅是紋理對象,而並非我們最關心的紋理內容地址。比如AGXA9FamilyTexture是Metal的紋理對象,Texture2D是Unity的紋理對象,紋理對象內部有指針指向了紋理內容。

如果我們不修改Unity源碼,我們無法得知Texture2D中紋理內容的地址。如何得知紋理內容到底在哪呢?

留意上面vmmap --summary命令顯示載入紋理前後的內存佔用,增幅最大的內存區域(Region)是「IOKit」,我們不妨看看裡面到底是啥,通過vmmap --verbose ./xcode.memgraph | grep "IOKit",有以下結果:

IOKit內存區域裏,有明顯的貼圖內容虛擬內存佔用

上面非常像我們3張紋理貼圖內容的內存佔用大小(下面才解釋為什麼64.0MB變為85.3MB),而左邊就是它們的虛擬內存地址。

我們嘗試用malloc_history ./xcode.memgraph --fullStacks 「上述3個地址」,發現都不能列印出分配它們的棧,說明它們並非使用傳統malloc在堆(Heap)上分配,如下圖。事實上IOKit是iOS的驅動框架,該區域內存是驅動相關的虛擬內存區域,通過額外的實驗可以知道,Metal最重要的MTLBuffer分配,不管Dirty與否,都是在IOKit這個驅動區域進行內存分配。

IOKit區域是驅動相關的虛擬內存地址,並不能通過malloc_history列印出來

但是!當我們在XCode打開xcode.memgraph後,如下圖,搜索地址「0x11c3e0000」得出該85.3MB的IOKit內存,而引用它的,恰好就是我們上面發現的地址為0x1083f5b80的Metal的紋理對象!

至此,我們通過硬體分析、圖形API分析和虛擬內存Profile分析,比較折騰,終於得出以下結論:

  • iOS設備中只有一塊物理內存硬體
  • 主存地址和顯存地址在同一個地址空間(Address Space)中,即虛存地址空間(Virtual Address Space)
  • 虛擬內存中的確只有一份紋理內容,而且該紋理內容的確就是被GPU所用的紋理。

我們接著討論問題2。由於問題2需要回答的是貼圖內存走向,不能通過分析某一時刻的虛擬內存得出結論,而要使用帶有Timeline的Profiler,這裡使用Instruments。

我們進行3種Profiler:Timer Profiler以觀察CPU耗時情況及捕捉函數調用棧,Allocations以觀察堆內存分配釋放情況,VM Tracker以觀察所有虛擬內存的分配釋放情況。針對Time Profiler,我們可以打開其High Frequency選項,以採樣到更精細的函數調用棧。

打開Time Profiler的High Frequency,以捕捉到更精細的函數調用棧

Profile結果如下圖。其中3個紅框左到右分別表示載入RGBA32、RGB24、ASTC5x5時的情況。

進行Time Profiler、Allocations、VM Tracker的Profiler,圖中3個紅框分別是載入RGBA_32、RGB24、ASTC5x5時的情況

大致觀察上圖可以發現:

  • CPU消耗尖刺(Spike):RGB24 > RGBA32 >> ASTC5x5
  • 堆內存消耗尖刺:RGB24 > RGB32 >> ASTC5x5
  • 虛擬內存消耗則整體呈現持續增長

我們先看最左邊RGBA32的CPU消耗情況,如下兩圖,分別為載入RGB24紋理時CPU消耗Spike的前期和後期

載入RGB24紋理時CPU消耗Spike的前期

載入RGB24紋理時CPU消耗Spike的後期

不需無頭緒地辛苦閱讀海量引擎代碼,有的放矢,立刻可精確看出Unity在載入紋理時主要工作分兩部分:文件載入(File::Read())和紋理上傳(UploadTexture2DData())。

而且發現將時間線在前後期中間不管如何細分,都只出現了上面2個主要消耗,說明瞭只有這兩個工作線程在工作,我們只需分析它們相信已足夠找出紋理載入的流程。我們也發現在整個紋理載入過程中,主線程只有非常少的Update空轉佔用,證實紋理載入幾乎是脫離主線程工作的。

文件載入函數棧看起來比較通用,先從紋理上傳的函數棧看起應該會更快解決問題。

閱讀源碼,發現其關鍵流程如下:
  • AsyncUploadManager.cpp中,AsyncUploadManager.AsyncResourceUpload()m_UploadQueue不斷Dequeue出FileAssetUploadInstruction類型的對象ftuInstr,其非常重要,描述了這次紋理上傳的所有關鍵數據。根據紋理類型,調用了2D紋理函數static Upload2DTexture()
  • AsyncUploadManager.cpp中,static Upload2DTexture()ftuInstr->buffer直接賦值給UInt8* uploadBuffer,至此,首次顯式出現了紋理內容的指針,可以看出,非常關鍵的問題是,到底FileAssetUploadInstruction::buffer是從哪來的?但先不急,先把這個棧看完。接著把uploadBufferftuInstr裏幾乎所有關於紋理的數據,傳遞給Texture.cpp的static UploadTexture2DData()
  • Texture.cpp文件中,static UploadTexture2DData()調用gfxDevice.UploadTexture2D(),通知GfxDeviceMetal進行紋理上傳
  • TextureMetal.mm文件中,static UploadTexture()通過[MTLDevice newTextureWithDescriptor]創建Metal的紋理對象,指定了紋理解析度、格式、mipmap層數等,並且在IOKit區域裏已為該對象分配了用於存放紋理內容的內存區域
  • TextureMetal.mm文件中,static UploadMipPyramid()為各個mipmap層算出解析度,最終調用了Metal API[MTLTexture replaceRegion],將對應的紋理數據最終拷貝到了MTLTexture對象中。注本介面名叫「替換replace」,事實上是進行了紋理內容數據進行了「拷貝copy」操作。

通過以上比較囉嗦的分析,可以看出就算是在Metal進行紋理上傳,也難免有紋理內容拷貝的過程。用[MTLDevice newTextureWithDescriptor]創建紋理對象及其指向的紋理內容空間,把FileAssetUploadInstructionbuffer數據,加以一定處理(Crunch、紋理格式轉換等),最終通過[MTLTexture replaceRegion]將紋理內容數據拷貝到了驅動虛擬內存IOKit區域裏。

那到底這個buffer數據到底從哪來的?當然,從上文和類名包含「File」,已經可以猜出是從外存讀取得來,但不精確證實不服氣,我們將注意力回到上面的文件載入調用棧。堆棧協助代碼閱讀,發現很簡單:

在AsyncReadManagerThreaded.cpp裏,
  • AsyncReadManagerThreaded::ThreadEntry()不斷從m_Requests裏拿出AsyncReadCommand類型的實例command
  • 並且打開紋理文件對象指針:File* file = m_OpenFilesCache.OpenCached(command->fileName);
  • file的內容,全都讀取到command->buffer裏:bool readOk = file->Read(command->offset, command->buffer, command->size) == command->size;,說明command->buffer指向的內存已經分配好了內存以供紋理文件讀入。

那麼command->buffer的內存哪裡分配而來呢?

由於內存分配的CPU消耗可能很小,就算是高精度的Sampler也可能在Time Profiler裏找不到,這裡我們明顯要求救於Allocation,如下圖,我們選擇「Call Trees」分類,框選在載入紋理時,內存飆升時的時段,發現132.03MB內存是在AsyncUploadManager::ManageTextureUploadRingBufferMemory()中分配給m_DataRingBuffer

文件讀取的緩存應該是在堆上分配

紋理上傳過程中,最大的堆內存分配是分配給了`AyncUploadManager.m_DataRingBuffer`

通過以上種種分析,已經掌握了不少信息和關鍵字,找出答案已是臨門一腳了:

AsyncUploadManager::ScheduleAsyncRead()m_DataRingBuffer申請紋理內容大小的內存空間,同時將指針賦值給asyncReadCommand->bufferftuInstr->buffer,從而文件讀取線程將紋理文件內容寫到asyncReadCommand->buffer指向的堆內存,渲染線程在通過ftuInstr->buffer將紋理內容從同一堆內存獲取到。

至此,回答了問題2。

最後的最後,上面提到的RGB24紋理的特殊情況,為什麼其虛擬內存佔用大小不是64MB,而是和RGBA32一樣,都是85.3MB?結合上面已知流程,分析可知,原因是Metal並不支持RGB24,在運行時都會轉為RGBA32,如下:

Metal不支持RGB24,交給GPU使用前需要轉換為RGBA32,

這能從以下Time Profiler以及Allocation棧輕易證實:

Metal不支持RGB24,交給GPU使用前需要轉換為RGBA32,需要消耗CPU進行一次BlitImage

Metal不支持RGB24,交給GPU使用前需要轉換為RGBA32,需要在堆內存申請臨時內存進行一次BlitImage

結論

通過Profile結果和源碼,我們證實了:iOS設備中只有一塊物理內存硬體,主存地址和顯存地址在同一塊虛存地址空間中,虛存最終的確只有一份紋理內容位於IOKit區域中,而且該紋理內容的確就是被GPU所用的紋理。

在紋理上傳過程中,Unity先在堆內存申請緩存,然後將紋理文件內容讀進緩存裏,然後調用圖形API將該該紋理內容數據拷貝到IOKit虛存中,供GPU訪問。拷貝完成後緩存視乎情況從堆內存釋放。過程中,我們展示了在iOS中各種Profile工具的實際使用方法。也介紹了一些基礎的內存知識和概念。

下載實驗工程及數據

見Github:MobileGFXMemoryTest

Android篇

打算未來才做Android的Profile實驗和分析報告,但通過上面的分析看來,可以大膽預測:

  1. Android設備也是基於ARM架構,想必各種Vendor的設備也是隻有一塊物理內存硬體;
  2. 上面的函數棧大多平臺無關,而且Vulkan和Metal是同一代的圖形框架,所以Unity在Vulkan上的實現內存流程應該和Metal非常類似;
  3. 由於GLES是較老的框架,所以其內存流程可能和Metal類似,但要留意GLES具體情況,和其在驅動內部gralloc的使用情況,有沒有額外的拷貝

關鍵字

手機,GPU,顯存,移動設備,iPhone,iPad,iOS,安卓,Android,Mobile Device,內存,共享內存,物理內存

引用

[1]ifixit - iPhone 6 Teardown

[2]Chipworks Disassembles Apples A8 SoC[3]Metal_(API)#Supported_GPUs[4]Metal Best Practices Guide - Resource Options[5]Metal - Resource Storage Mode[6]MTLBuffer[7]Triple Buffering[8]iOS Memory Deep Dive[9]Choosing a Resource Storage Mode in iOS and tvOS[10]MTLBuffer makeTexture
推薦閱讀:
相關文章