2019年WWDC的《 Session 606 - Delivering Optimized Metal Apps and Games 》 主要内容速览:
- 通用性能优化
- 内存带宽
- 内存占用
这个 session 的主要内容是Metal Best Practices,不是OK,不是Acceptable,也不是Trick。而是为大家提供 Metal 程序优化的最佳实践。
主要有三大方面(即通用性能,内存带宽,内存占用),共 18 个具体措施和建议
通用性能优化,包括
- 选择正确的分辨率
- 最小化透明的过度绘制(overdraw)
- 尽可能早的向 GPU 提交任务
- 有效的传输资料
- 做好持续性能的设计
下面逐一讲解这五条。
选择正确的分辨率
为知道,游戏中的每个效果,可能有不同的分辨率。最佳策略就是:
- 考虑图像质量和性能之间的平衡
- 以原生分辨率(至少应尽量接近原生分辨率)混合游戏中的 UI
我们可以用 Metal Frame Debugger 工具来检查分辨率的问题,其中的 Dependency Viewer(依赖关系查看器)最常用,它能展示每个渲染通道中的顺序图。
上图中,各个不同的效果使用了不同的分辨率,但最终生成的 UI 则是原生分辨率的。
最小化透明的过度绘制(overdraw)
处理多个逐像素的片段就会引起过度绘制。iOS 的 GPU 在减少不透明的过度绘制时非常高效,无需我们过多插手,但是那些透明的就需要我们程序员来帮忙处理了。最佳策略有:
- 首先渲染不透明网格(mesh),然后再渲染透明的网格(mesh)
- 不要渲染不可见的(即完全透明的)网格
检查工具还是 Metal Frame Debugger ,不过这次我们用 GPU Counters 仪表来检验指定通道的过度绘制情况
上图中,我们只关注主光照渲染通道。为了计算过度绘制情况,我们需要关注片段着色器的调用数量,除以储存像素数量。上面例子中,是几乎不存在透明场景的,所以看到没有过度绘制。
尽可能早的向 GPU 提交任务
尽早提交任务非常重要,因为它可以:
- 改善延迟和响应性
- 允许系统根据负载进行调整
最佳策略有:
- 尽早向 GPU 提交所有离屏任务
- 尽可能晚的拿到帧中的 drawable
这样可能不太好理解,我们可以看下面的 game performance template 的例子,里面有非常多的stutter(口吃,即掉帧),按住Option键选中,可以将其放大观看。
放大后,再点击左侧的
A11可以展开查看详情。我们可以看到 Main Thread 中的少量任务(Main Thread 行的黄色部分)完成后,由于 Fragment 中的任务(蓝色部分)处理时间过长,黄色任务提交太晚,错过了当前帧的显示时机,所以掉帧了。
修复上面的问题后,再看一下。我们可以看到,GPU 已经提前拿到了任务并开始处理,Wait for Next Drawable 的时间也缩短了。
修复这个问题的主要代码如下:
// Off-screen command buffer
let offscreenCb = commandQueue.makeCommandBuffer()!
// ... Encode off-screen work ...
offscreenCb.commit() //这里提交了离屏任务
let drawable = caMetalLayer.nextDrawable()! //等待 drawable,会阻塞
// On-screen command buffer
let onscreenCb = commandQueue.makeCommandBuffer()!
// .. Encode on-screen work ...
onscreenCb.present(drawable, afterMinimumDuration: 33.0 / 1000)
onscreenCb.commit() //提交其他 on-screen 任务
有效的传输资料
我们知道,分配资源需要花费时间。将资源素材从渲染线程传输过去可能会引起卡顿。最佳策略有:
- 考虑内存和性能的平衡
- 在启动时就分配并加载 GPU 素材资源
- 在专用的线程分配并传输新的素材资源
这个问题可以用 Metal System Trace 来追踪解决,Allocation行显示了相关信息
做好持续性能的设计
持续性能,主要就是指游戏运行一段时间后,手机发热或低电量等情况下的性能稳定问题。包括改善热状态,改善稳定性和响应性。 最佳策略有:
- 在
serious热状态下测试你的游戏 - 考虑将你的游戏在运行中切换为
serious热状态
现在,可以在 xcode 中进行设置,强制开启热节流状态
整体性能则可以在 Xcode Energy Gauge 中查看
内存带宽
为什么要优化内存?因为内存的转移代价昂贵。
iOS 设备上有
- CPU 和 GPU 之间的共享内存(Shared memory)
- GPU 的专用内存(Dedicated memory)
Metal 可以帮助你同时提升这两者的效果
主要措施有:
- 压缩纹理素材
- 优化,以便 GPU 能更快访问
- 选择正确的像素格式
- 优化加载和储存动作
- 优化多重采样纹理(用于 MSAA 的纹理)
- 提升块内存(tile memory)
下面来逐一讲解
压缩纹理素材
采样很大的纹理有可能非常低效率。最佳策略:
- 压缩所有纹理素材----ASTC,PVRTC,等
- 为纹理产生可缩小的 mipmap
比如下面的图,完整的纹理需要 16MB,而改用 PVRTC 压缩并启用 mipmap 后,只需要 2.7MB。这里之所有使用了 PVRTC 是因为我们的 Demo游戏需要支持 A7(iPhone 5s) 及以上设备,如果你需要支持的设备更新,则可以使用 STC 压缩格式,可以有更高压缩的同时保留更好的质量。
如何检查游戏中的纹理格式呢?我们可以使用 Metal Memory Viewer,只需双击就能查看是否压缩,是否启用 mipmap。
但是,如果是那些不能预先压缩的纹理呢?比如 render target 或者运行时产生纹理呢?
最新的 iOS GPU 支持无损的纹理压缩,它允许 GPU 压缩纹理以供快速访问。就是下一条优化措施。
优化纹理,以便 GPU 快速访问
正确的配置纹理,可以让 GPU 能更快访问。最佳策略:
- 使用
private存储模式 - 不要设置
unknown使用标识 - 不要设置不必要的使用标识(例如,
shaderWrite或者pixelView) - 对于
shared纹理,在 CPU 更新后再明确优化
private:只能被 GPU 访问;shared:可以被 CPU 和 GPU 访问
比如下面的代码,该纹理只需要被 GPU 访问,所以存储模式设置为private,使用标识设置为shaderRead和renderTarget
// Create a texture with optimal GPU access
// ...
textureDescriptor.storageMode = .private
textureDescriptor.usage = [ .shaderRead, .renderTarget ]
let texture = device.makeTexture(descriptor: textureDescriptor)
但是,对于那些需要被 CPU 和 GPU 共同使用的纹理来说,就稍微复杂一些
// Optimize Shared texture after CPU update
// ...
textureDescriptor.storageMode = .shared
textureDescriptor.usage = .shaderRead
let texture = device.makeTexture(descriptor: textureDescriptor)!
// ... CPU 处理后,再优化纹理,以供 GPU 快速访问
texture.replace(region: region, mipmapLevel: 0, withBytes: bytes, bytesPerRow: bytesPerRow)
let blitCommandEncoder = commandBuffer.makeBlitCommandEncoder()!
blitCommandEncoder.optimizeContentsForGPUAccess(texture: texture)
blitCommandEncoder.endEncoding()
纹理的查看和优化工具,仍然是 Metal Memory Viewer
选择正确的像素格式
纹理优化还有最后一步,就是选择正确的像素格式。大的像素格式会使用更多的内存带宽。同时采样率也是和像素格式有关。最佳策略:
- 避免像素格式带有不必要的通道
- 例如,使用 RGBA16 来存储 2-分量的数据
- 尽可能的使用低精度数据
下图中我们可以看到在 A12 及更新设备上,常规的 32-bit 格式速度最快,而 128-bit 格式(如 RGBA 32-bit float)则只有四分之一的速度。
通常,这些高精度格式被用于噪声纹理或者后置处理效果(post-process effect)的查找表(lookup table)。仍然是用 Metal Memory Viewer 来查看。下图中的示例 demo 中,SSAO 效果用到的是 16-bit 格式数据。
还有就是,本例中的大部分纹理其实是 render target。当游戏变得越来越复杂时,这些 render target 会消耗大量带宽。
所以,下面要讲讲渲染通道的加载和储存动作,仔细了解一下 MSAA,并讲一点关于 Tile Memory 的知识
优化加载和储存动作
加载或储存 render target 会消耗带宽。不合适的配置项可能会创建假性依赖关系。最佳策略:
- 只在必须时,再加载或储存 render target
代码如下:
// Configure color attachment 1 as transient 只需瞬态使用的纹理,并不需要从中加载或向其储存任何东西
// ...
renderPassDescriptor.colorAttachments[1].texture = texture
renderPassDescriptor.colorAttachments[1].loadAction = .clear
renderPassDescriptor.colorAttachments[1].storeAction = .dontCare
当 loadAction 是clear时,GPU 就不会在加载后纹理进行转换操作。当 storeAction 为dontCare时,意味着在渲染通道的最后不会写入任何数据。
对加载和储存的查看,可以在 Dependency Viewer 中进行
优化多重采样纹理(用于 MSAA 的纹理)
iOS 设备有非常高效的 MSAA
- 从 tile memory 中解析(无需消耗内存带宽)
- 明确的颜色覆盖控制,自定义解析等
最佳策略有:
- 考虑在原生分辨率下进行 MSAA
- 不要加载或储存多重采样纹理
- 设置多重采样纹理的存储模式为
memoryless
关键代码如下:
// Create a multisample texture
// ...
textureDescriptor.textureType = .type2DMultisample
textureDescriptor.sampleCount = 4
textureDescriptor.storageMode = .memoryless
let msaaTexture = device.makeTexture(descriptor: textureDescriptor)!
// Configure MSAA render pass descriptor
// ...
renderPassDescriptor.colorAttachments[0].texture = msaaTexture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .multisampleResolve
检查仍然是使用 Dependency Viewer。看到,优化后,内存带宽消耗从 97MB 变为 11MB,内存占用也从 97MB 变为 11MB,效果明显
提升块内存(tile memory)
Metal 提供了访问 tile memory 的方式
- 程序性混合(programmable blending)
- 图像块(Image blocks)
- 块着色器(Tile shaders)
最佳策略:
- 明确的利用块内存 更多详情可以参考 Modern Rendering with Metal
现在我们先来看一下延迟渲染(Deferred Shading)。传统的延迟渲染非常消耗内存,这是因为传统做法要求应用程序将几何信息(G-Buffer)储存起来,然后在第二个光照通道中采样纹理,并在 render target 中汇集出最终的颜色。这就意味着,我们将数据从 G-Buffer 中先储存,然后又加载出来,这就是为什么带宽消耗严重。
而 iOS 则允许你以更有效的方式处理。iOS 中,我们可以升级程序性混合模式,这个特性让片段着色器能直接从块内存中访问像素数据。这就意味着,G-Buffer 数据可以被储存在块内存中,并能被光照着色器在同一渲染通道内访问。
下图是 Demo 中改进后的示例,非常简洁高效
Dependency Viewer 是一个非常好用的工具,如下图,双击CommandBuffer打开依赖关系图
就能看到 Xcode 给出的提示,右下角还有汇总展示区。可以看到这些无用却被保存下来的纹理,占用了 14MB 的系统内存。
修复后,不再占用系统内存
今年还引入了一个新的纹理格式:Depth16,它主要用于优化深度贴图,将原来的 Depth32 替换,可以节省一半内存
内存占用
在 iOS 系统中,强制限制了 app 的内存使用,主要是为了:
- 允许更多的 app 保留在内存中
- 允许系统保持响应
- 在 app 间切换时能更快
同时,iOS 12 更新了内存统计方式,更加严格地限制了 app 的内存使用,苹果要求如果不能降低内存使用,需要联系苹果请求 iOS 11-style 的内存统计方式。
iOS 12 的统计方式变更主要有:Metal buffer 和 Metal texture。用于游戏素材(网格,材质等)和后置处理效果(阴影,模糊等)
检查内存占用的可用工具很多,Xcode Memory Gauge,Memory Viewer(已被添加到 Metal Frame Debugger 中),还有 Metal Resources Allocations
考虑到有人会有特殊需要,所以今年推出了一个 C 语言的方法,可以用代码获取可用内存大小
#include <os/proc.h>
size_t os_proc_available_memory(void)
另一个新特性是,允许捕捉手机上的 GPU 信息,可通过代码自动触发 GPU 捕捉:先添加MetalCaptureEnabled到 info.plist 中,然后添加下面代码
if os_proc_available_memory() < 150 * 1024 * 1024 {
let captureDescriptor = MTLCaptureDescriptor()
captureDescriptor.captureObject = _device
captureDescriptor.destination = .gpuTraceDocument
captureDescriptor.outputURL = _outputURL
do {
try _captureManager.startCapture(with: captureDescriptor)
} catch {
// ... Handle the error ...
}
}
// ... Render next frame ...
if _captureManager.isCapturing {
_captureManager.stopCapture()
// ... Handle the GPU trace ...
}
控制内存占用的方式主要有:
- 使用无内存渲染目标(Memoryless render target)
- 避免加载无用素材
- 使用更小的素材
- 简化内存敏感效应
- (高级)使用 Metal 资源栈
- (高级)将资料标记为volatile
- (高级)管理 Metal PSO
下面逐一介绍各项措施
使用无内存渲染目标
这个选项我们在前面讲内存带宽优化时用过。瞬态渲染目标(transient render target):
- 不被加载或储存在系统内存中
- 不需要内存分配
最佳策略:
- 使用
memoryless储存模式 - 将所有的多重采样附件(multisampled attachment)设置为该模式
代码如下:
// Configure the G-Buffer textures as memoryless render targets
// ...
gBufferTextureDescriptor.storageMode = .memoryless
gBufferTextureDescriptor.usage = [ .shaderRead, .renderTarget ]
// Configure the deferred shading render pass
// ...
for i in 1..<3
{
gBufferTextureDescriptor.pixelFormat = gBufferPixelFormats[i]
gBufferTextures[i] = device.makeTexture(descriptor: gBufferTextureDescriptor)!
renderPassDescritptor.colorAttachments[i].texture = gBufferTextures[i]
renderPassDescritptor.colorAttachments[i].loadAction = .clear
renderPassDescritptor.colorAttachments[i].storeAction = .dontCare
}
下图是 demo 中的对比,节省了相当多的内存
避免加载无用素材
将所有素材加载进内存,会增大内存占用,最佳策略是:
- 考虑内存和性能的平衡
- 只加载需要用的素材
- 释放所有临时性资源,如闪屏图片或引导 UI
这里可以用 Metal Memory Viewer 来检查未使用过的素材资源
使用更小的素材
在开发中,我们要根据需要来确定素材的最大尺寸。最佳策略:
- 考虑图片质量和内存的平衡
- 压缩纹理
- 压缩网格(顶点数据)
- 考虑只加载最小的 mipmap 级别
- 考虑加载低精度的 3D 模型
简化内存敏感效果
某些视觉效果要求很大的离屏缓冲:阴影贴图,SSAO 等。最佳策略有:
- 考虑图片质量和内存的平衡
- 降低离屏缓冲的分辨率
- 当内存紧张时,禁用内存敏感效果
(高级)使用 Metal 资源栈
渲染一帧画面,可能需要大量的中间内存。使用资源栈可以让我们:
- 从单次分配中创建多个资源
- 混叠(Aliasing)资源
译者注:Alias 在计算机中有多个中文含义:走样(Aliasing),在采样中出现连续不可辨信号。别名 (数据位置)(Aliasing computing),同样的数据位置有多个名称。这里的含义是指把多个资源放在同一个地方,个人认为翻译为:
混叠比较好
最佳策略有:
- 混叠(Alias)没有依赖性的中间资源(如 SSAO和 DOF)
下图为一次性分配一个大的栈空间,同时储存 ABC 三个纹理。
混叠则可以节省更多的空间,它的原理是:ABC 三个纹理不是同时出现并使用的,所以其实可以只用一份空间,就相继保存了三个纹理。这样你的游戏就可以使用更多更复杂的后置处理效果,又不用担心消耗太多内存了。
(高级)将资料标记为volatile
下面讲一下可清除内存状态:
- Non-volatile --- 不会被丢弃的数据
- Volatile --- 数据可以被丢弃,即使资源可能仍然会被需要
- Empty --- 数据已经被丢弃 其中 Volatile 和 Empty 分配的内存不会被统计在内存占用中
临时资源可能成为游戏占用的重要组成部分,因此,可以明确设置可清除状态的Metal资源,以优化占用。最佳策略:
- 明确管理资源的可清除状态
- 将所有缓存的资源标记为 volatile
代码示例如下:
// Mark all the textures in the cache as volatile
for i in 0. . <cacheSize
{
texturePool[i] . setPurgeableState( .volatile)
}
//...
if (texturePool[ idx] .setPurgeableState( .nonVolatile) == . empty )
//.. Regenerate texture data . . .
//... Use texture
(高级)管理 Metal PSO
Metal允许应用程序预先加载大部分渲染状态(PSO)。最佳策略有:
- 考虑性能和内存的平衡
- 不要持有不必要的 PSO 引用
- 不要在 PSO 创建后持有 Metal 函数的引用
如下图所示,PSO 囊括了大部分的 Metal 渲染状态。而 PSO 本身是由一个 descriptor 创建的,这个 descriptor 包含了顶点函数,片段函数及其他各种用于混合状态和顶点布局的 descriptor。所有的这些编译到最终的 Metal PSO 中。实际上,我们渲染时需要的只有这个最终的 Metal PSO
因为 Metal 允许你提前加载大部分的渲染状态,你就可以用提前加载来提升性能。但是,同时还要考虑一个内存开销的平衡。如果你的内存不足,就不需要再持有 PSO 的引用了,因为它已经没用了。最重要的是,不要在你创建了 PSO 缓存后再持有 Metal 函数的引用,因为它们只是用于创建新的 PSO 的。所有最佳策略就是,在创建 PSO 之后,释放顶点函数和片段函数的引用。
最后,当你的内存不足,如果你知道 PSO 已经没用了,也要把 PSO 给释放掉。
Demo演示
捕捉 GPU 中的数据后,点击左侧的 Memory 使用情况,右侧画面通过图形和列表展示内存状态。主要原因是纹理占用空间太大。
选中
Unused(指当前渲染帧的最终输出时,未用到该纹理),并按大小排列查看。这里展示都是未被 GPU 使用的纹理,其中第一个有个提示,点击后显示该纹理也从未被 CPU 访问过,所以完全可以优化掉。
再看下面另一个纹理的问题。它从未被 CPU 访问过,同时已经 47 秒没有被 GPU 使用过了,所以应被标识为
Volatile以便被清理掉。这里可以看到,最右侧的Time since Last Bound统计也是非常有用的信息
下面,我们再选中Used栏,查看其中的可优化项。这里看到给出了两条不同的建议:无损压缩,和储存模式。实际上我们只能根据需要选择其中一种,这是因为 Memory Viewer 是基于当前帧收集到的数据进行的提示,而真正懂得真实使用情况的是你自己,毕竟程序员不局限于这一帧。
所以,如果我们确定这个纹理就是瞬态的,那就将储存模式设置为Memoryless,这样能减少 18MB 的内存占用。另一方面,如果我们确定这个纹理不是瞬态的,那就应该考虑设置为无损压缩,这样就能减少带宽压力。
总结
至此三大方面(即通用性能,内存带宽,内存占用),共 18 个具体措施和建议已经介绍完了。
参考资料
相关视频,WWDC2019