以下文章来源于腾讯GWB游戏无界 ,作者Donald
腾讯独立游戏孵化器是腾讯游戏学院在2020年底新成立的组织,由GWB负责协调腾讯游戏各相关部门的孵化资源,实现高潜独立游戏的全程孵化。帮助独立游戏团队解决难题、提升品质、获得成功,促进游戏行业多元生态的整体发展。
开发手机游戏时,常听到身边的人传授经验:“CPU和GPU是共享一份内存的”,但这句经验到底具体指的是什么,仿佛总得不到细节精确的回答。
因此,本文尝试以一张贴图纹理的虚拟内存占用为例,就以下问题进行分析和解答:
1)是否的确主存显存共享一份贴图虚拟内存?
为清晰表达避免概念混淆,本文采取以下术语:
物理内存(Physical Memory):具体的存储硬件,各种SDRAM,比如LPDDR是移动设备常用的一种低功耗SDRAM。
虚拟内存(Virtual Memory):对物理内存的一种逻辑映射。
主存(Main Memory/Primary Memory):CPU能读写的虚拟内存。
显存(Graphics Memory):GPU能读写的虚拟内存。
另外,外存(External storage):外部存储,“硬盘”,在移动设备一般是Flash。
2.1 硬件
且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
A8 SoC CPU/GPU晶片 和 物理内存采取PoP封装。将CPU/GPU晶片从SoC移除后,露出下一层的DRAM物理内存
其他iOS设备,iPhone、iPad等,亦如此,硬件层面,它们的物理内存都为统一内存(Unified Memory)架构,即主存和显存都位于同样的物理内存硬件中。
而桌面电脑一般是分离物理内存(Discrete Memory)架构。
2.2 图形API
自2013年的AppleA7(iPhone 5s)起iOS设备便支持Metal[3],考虑当下(2018)的市场份额,故只讨论支持Metal的情况,而不讨论iOS上OpenGLES的情况。
此时对该虚拟内存的修改,会同时反馈到CPU和GPU上,除非CPU准备好Buffer的内容后不再修改,但一旦CPU对Buffer进行了二次修改,为避免和GPU的访问冲突,需要有一定的同步机制,比如三重缓冲(Tripple Buffering)[7]。
Pirvate模式为GPU单独访问的虚拟内存,主要用于RenderTexture等情况[9],并非当前重点。
2.3 分析Unity在iOS的实现
虽然图形API机制如此,但引擎内部实现大相径庭,保守起见,具体结论应以引擎具体逻辑为准。
GFXMemory测试Demo
开启Malloc Stack才能对memorygraph方能使用malloc_history命令查看内存创建的堆栈
XCode中构建版本,USB连接iPhone6s并在其上运行,等待几秒钟待内存稳定后:
点击UI加载上面3张纹理后,等待几秒钟待内存稳定后:
在Unity用Memory Profiler点击Take Snapshot,截取得出Unity的内存统计,并另存为unity.memsnap3文件
在XCode点击“Capture GPU Frame”,截取得到当前帧的GPU快照,并另存为xcode.gputrace文件
注意上述操作都确保游戏是一次运行针对同一进程的4次抓取结果,从而确保内存地址稳定。
我们在命令行执行命令vmmap --summary ./xcode_empty.memgraph,得到加载纹理前的虚拟内存占用约为111.3MB,如下图:
上图我们应关心“DIRTY SIZE”和“SWAPPED SIZE”,前者代表已写虚存大小、后者代表已写待压缩虚存大小。iOS和一般OS不一样,不采取虚存切页(Paging)的机制,而是采取压缩内存的机制。而在iOS中所谓的内存占用(Memory Footprint)事实上是MemoryFootprint = DirtySize + CompressedSize,iOS以MemoryFootprint的大小作为Killapp的依据。注意Swapped Size是待压缩的大小,压缩后方为Compressed Size。[8]
我们再执行命令vmmap --summary ./xcode.memgraph,得到加载纹理后的虚拟内存占用约为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纹理对象AGXA9FamilyTexture地址的堆分配函数栈
0x1083f5b80地址对应的,仅仅是纹理对象,而并非我们最关心的纹理内容
上面3图,证实了上面的地址仅仅是纹理对象,而并非我们最关心的纹理内容地址。比如AGXA9FamilyTexture是Metal的纹理对象,Texture2D是Unity的纹理对象,纹理对象内部有指针指向了纹理内容。
如果我们不修改Unity源码,我们无法得知Texture2D中纹理内容的地址。如何得知纹理内容到底在哪呢?
留意上面vmmap --summary命令显示加载纹理前后的内存占用,增幅最大的内存区域(Region)是“IOKit”,我们不妨看看里面到底是啥,通过vmmap --verbose ./xcode.memgraph | grep "IOKit",有以下结果:
上面非常像我们3张纹理贴图内容的内存占用大小(下面才解释为什么64.0MB变为85.3MB),而左边就是它们的虚拟内存地址。
我们尝试用malloc_history ./xcode.memgraph --fullStacks “上述3个地址”,发现都不能打印出分配它们的栈,说明它们并非使用传统malloc在堆(Heap)上分配,如下图。事实上IOKit是iOS的驱动框架,该区域内存是驱动相关的虚拟内存区域,通过额外的实验可以知道,Metal最重要的MTLBuffer分配,不管Dirty与否,都是在IOKit这个驱动区域进行内存分配。
但是!当我们在XCode打开xcode.memgraph后,如下图,搜索地址“0x11c3e0000”得出该85.3MB的IOKit内存,而引用它的,恰好就是我们上面发现的地址为0x1083f5b80的Metal的纹理对象!
iOS设备中只有一块物理内存硬件。
主存地址和显存地址在同一个地址空间(Address Space)中,即虚存地址空间(Virtual Address Space)。
虚拟内存中的确只有一份纹理内容,而且该纹理内容的确就是被GPU所用的纹理。
我们接着讨论问题2。由于问题2需要回答的是贴图内存走向,不能通过分析某一时刻的虚拟内存得出结论,而要使用带有Timeline的Profiler,这里使用Instruments。
Profile结果如下图。其中3个红框左到右分别表示加载RGBA32、RGB24、ASTC5x5时的情况。
进行Time Profiler、Allocations、VM Tracker的Profiler,图中3个红框分别是加载RGBA_32、RGB24、ASTC5x5时的情况
虚拟内存消耗则整体呈现持续增长
我们先看最左边RGBA32的CPU消耗情况,如下两图,分别为加载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是从哪来的?但先不急,先把这个栈看完。接着把uploadBuffer和ftuInstr里几乎所有关于纹理的数据,传递给Texture.cpp的static UploadTexture2DData()。
Texture.cpp文件中,static UploadTexture2DData()调用gfxDevice.UploadTexture2D(),通知GfxDeviceMetal进行纹理上传。
TextureMetal.mm文件中,static UploadTexture()通过[MTLDevice newTextureWithDescriptor]创建Metal的纹理对象,指定了纹理分辨率、格式、mipmap层数等,并且在IOKit区域里已为该对象分配了用于存放纹理内容的内存区域。
AsyncReadManagerThreaded::ThreadEntry()不断从m_Requests里拿出AsyncReadCommand类型的实例command,
并且打开纹理文件对象指针:File* file = m_OpenFilesCache.OpenCached(command->fileName);
由于内存分配的CPU消耗可能很小,就算是高精度的Sampler也可能在Time Profiler里找不到,这里我们明显要求救于Allocation,如下图,我们选择“Call Trees”分类,框选在加载纹理时,内存飙升时的时段,发现132.03MB内存是在AsyncUploadManager::ManageTextureUploadRingBufferMemory()中分配给m_DataRingBuffer。
纹理上传过程中,最大的堆内存分配是分配给了`AyncUploadManager.m_DataRingBuffer`
至此,回答了问题2。
Metal不支持RGB24,交给GPU使用前需要转换为RGBA32,需要消耗CPU进行一次BlitImage
2.4 结论
通过Profile结果和源码,我们证实了:iOS设备中只有一块物理内存硬件,主存地址和显存地址在同一块虚存地址空间中,虚存最终的确只有一份纹理内容位于IOKit区域中,而且该纹理内容的确就是被GPU所用的纹理。
过程中,我们展示了在iOS中各种Profile工具的实际使用方法。
也介绍了一些基础的内存知识和概念。
[1]ifixit - iPhone 6 Teardown
[2]Chipworks Disassembles Apple’s A8 SoC
[3]Metal_(API)#Supported_GPUs#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
关于腾讯游戏学院专家团
如果你的游戏也富有想法充满创意,如果你的团队现在也遇到了一些开发瓶颈,那么欢迎你来联系我们。腾讯游戏学院聚集了腾讯及行业内策划、美术、程序等领域的游戏专家,我们将为全世界的创意游戏团队提供专业的技术指导和游戏调优建议,解决团队在开发过程中遇到的一系列问题。
项目指导合作请联系微信:18698874612
Imagination和腾讯WeTest开展深度合作,助力开发者获取GPU关键报告
END