WWDC Quick Look 💓 By SwiftGGTeam
Optimize Metal Performance for Apple silicon Macs

Optimize Metal Performance for Apple silicon Macs

观看原视频

Highlight

Apple 讲解了 Apple silicon Mac 上 Metal 的性能调优路径:减少渲染 pass 依赖和带宽、使用 tile shader 保留片上数据,并按 Apple shader core 特性改写 shader。

核心内容

很多 Mac 图形 App 已经有成熟的渲染器。它们在 Intel 或 AMD GPU 上运行多年,常见优化也围绕 Immediate Mode Rendering(立即模式渲染)展开。迁移到 Apple silicon Mac 后,App 可以先通过 Rosetta 运行,再重新编译为原生版本,但这只解决“能跑”的问题。真正影响帧率和功耗的是渲染器是否理解 Apple GPU 的 Tile-Based Deferred Rendering(TBDR,基于 tile 的延迟渲染)架构。

TBDR 的关键变化是 tile memory(片上 tile 内存)。Apple GPU 会先处理一个 render pass 的顶点,把几何体分到屏幕 tile 中,再在片上完成每个 tile 的 fragment 工作。load action 和 store action 决定附件如何进出 tile memory。过去拆成多个 pass、频繁清空、频繁保存中间结果的做法,在这里会直接变成系统内存带宽压力。

这场 session 的叙事很清楚:先找出 GPU 时间线上的空洞,再减少 render pass 带来的 load/store,接着用 hidden surface removal(隐藏面移除)少做无效 shading,最后把 deferred shading、light culling、shader 数据类型都改成适合 Apple GPU 的形状。

对开发者来说,重点不在 API 名称替换。更实际的工作是重排工作负载,合并本来属于同一个逻辑 pass 的渲染,给中间附件选择 .memoryless,再用 Metal System Trace、GPU frame debugger 和 pipeline statistics 验证结果。

详细内容

1. 让 vertex、fragment、compute 尽量重叠

04:17)Apple GPU 有独立硬件通道处理 vertex、fragment 和 compute 工作。Metal 会在没有数据依赖时自动重叠它们。性能问题常出现在“看起来共享资源,实际数据无关”的 false dependency(伪依赖)上。

06:05)如果 Render Pass 0 写入的资源被 Render Pass 1 的 vertex 阶段读取,Metal 会按资源级别建立依赖。即使两个 pass 使用的是同一资源里的不同区域,调度器也无法知道数据无关。解决方式有两类:把数据拆到不同资源;或把资源设为 untracked,再由开发者用 Metal fence 或 event 插入必要同步。

07:23)另一个低成本方法是 pass re-ordering(pass 重排)。独立的 compute 或 render pass 应尽早 encode,用它们填满时间线空洞。Metal 有时会重排独立 pass,但它能看到的信息有限,显式重排更可靠。

这里的可执行边界是:如果相邻 pass 共用同一个 Metal resource,但读写的是互不相关的数据,优先拆成不同 resource。拆不开时,把 resource 标记为 untracked,让 Metal 不再按整个 resource 建立依赖;随后由应用用 Metal fence 或 event 插入真正需要的同步。

关键点:

  • false dependency 来自 resource-level tracking,而不是 API 写错。
  • 拆 resource 是最直接的修复,因为 Metal 可以自动看出两个 pass 没有关联。
  • untracked resource 会移除自动依赖,开发者必须只在真实数据依赖处使用 fence 或 event。
  • pass re-ordering 应放在资源依赖修复之后,用独立 compute/render pass 填满时间线空洞。

2. 用 parallel render command encoder 合并逻辑上相同的 pass

09:08)Apple GPU 的 render pass attachment 在 tile storage 中处理。附件每次 load 和 store 都会消耗系统内存带宽。常见问题是为了 CPU 多线程 encoding,把同一个 G-Buffer 的不同部分拆成多个 command buffer。GPU 看到的是多个 render pass,于是重复 load/store 同一批 attachment。

10:49)Metal 的 parallel render command encoder 解决的是这个场景:CPU 仍然能多线程 encode,GPU 端仍然是一个 render pass。

// Encoding with parallel render commands

let parallelDescriptor = MTLRenderPassDescriptor()
// … setup render pass as usual …

let parallelEncoder = commandBuffer.makeParallelRenderCommandEncoder(descriptor:parallelDescriptor)

let subEncoder0 = parallelEncoder.makeRenderCommandEncoder()
let subEncoder1 = parallelEncoder.makeRenderCommandEncoder()

let syncPoint = DispatchGroup()
DispatchQueue.global(qos: .userInteractive).async(group: syncPoint) {
    /* … encode with subEncoder0 … */ }
DispatchQueue.global(qos: .userInteractive).async(group: syncPoint) {
    /* … encode with subEncoder1 … */ }

syncPoint.wait()
parallelEncoder.end()

关键点:

  • MTLRenderPassDescriptor() 和普通 render pass 使用同样的附件配置,所有 sub-encoder 共享这份配置。
  • makeParallelRenderCommandEncoder 创建一个并行 encoder,Metal 会把其中的 sub-encoder 合成为单个 render pass 执行。
  • makeRenderCommandEncoder() 的创建顺序决定 sub-encoder 命令的执行顺序,所以要先按预期顺序创建。
  • 两个 DispatchQueue.global block 把 encoding 分发到不同 worker thread,保留 CPU 并行度。
  • syncPoint.wait() 保证所有子任务完成后再调用 parallelEncoder.end(),否则 parallel encoder 还没有完整命令流。

3. 减少 attachment ping-pong 和多余 store

12:12)另一个带宽陷阱是只因为 load/store action 变化就拆 pass。例如 forward renderer 先画 opaque,再画 translucent,如果附件集合相同,可以把两段 draw 合在同一个 pass 中,只在最后 store 一次 color。深度附件如果 pass 外不再使用,store action 应设为 .dontCare,纹理也可以设为 .memoryless

13:29)Attachment ping-pong 更隐蔽。比如每盏灯都先生成 screen-space attenuation texture,再 composite 到主场景。每盏灯一个 pass 会让主场景附件反复 load/store。Metal 每个 pass 最多支持 8 个 color attachment,可以把主场景和 attenuation 放进同一个 pass。

// Multiple render target setup

let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(…)
let lightingTexture = device.makeTexture(descriptor: textureDescriptor)

textureDescriptor.storageMode = .memoryless
let attenuationTexture = device.makeTexture(descriptor: textureDescriptor)

let renderPassDesc = MTLRenderPassDescriptor()
renderPassDesc.colorAttachments[0].texture      = lightingTexture
renderPassDesc.colorAttachments[0].loadAction   = .clear
renderPassDesc.colorAttachments[0].storeAction  = .store
renderPassDesc.colorAttachments[1].texture      = attenuationTexture
renderPassDesc.colorAttachments[1].loadAction   = .clear
renderPassDesc.colorAttachments[1].storeAction  = .dontCare

let renderPass = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDesc);

关键点:

  • lightingTexture 是主场景输出,pass 结束后仍需要,所以 storeAction.store
  • textureDescriptor.storageMode = .memoryless 用在中间 attenuation texture 上,避免给只在 pass 内使用的数据分配系统内存 backing。
  • colorAttachments[0]colorAttachments[1] 同时出现在一个 render pass descriptor 中,消除 ping-pong。
  • attenuationTexturestoreAction.dontCare,因为它不在 pass 外被读取。
  • makeRenderCommandEncoder 用这份 descriptor 启动单个 pass,减少中间 load/store。

15:17)清屏也应该折叠进真正需要它的 render pass。独立 clear pass 只为了清空附件,会产生额外内存流量。MSAA resolve 也类似,sample data 通常只在 pass 内有用,应在前一个 pass 内 resolve,multi-sampled attachment 可以设为 memoryless。

4. 让 hidden surface removal 少做无效 fragment 工作

17:09)Apple GPU 的 hidden surface removal(HSR,隐藏面移除)目标是只 shade 可见像素。draw 顺序会影响效率:先 opaque,再 feedback,最后 translucent。Opaque draw 内部顺序通常由材质系统决定;feedback 和 translucent 需要关心自己的顺序。

19:01)fragment function 写 buffer 或非 render pass texture 时,Metal 默认要执行这些 fragment 来保证 memory write 确定性。如果被遮挡 fragment 的写入对结果没有意义,可以给 fragment function 添加 early fragment test attribute,让深度测试失败的 fragment 不再执行。

19:47)Write mask(写掩码)也会降低 HSR 效率。被保留的通道需要从下层 fragment 生成,造成额外 shading。

let descriptor = MTLRenderPipelineDescriptor();
// ...
descriptor.colorAttachments[0].writeMask = .red | .green;

关键点:

  • MTLRenderPipelineDescriptor 控制 pipeline 的 color attachment 行为。
  • writeMask = .red | .green 表示 fragment function 只更新红、绿通道。
  • 蓝色和 alpha 通道要保留旧值,Apple GPU 可能需要 shade underlapping fragment 来得到这些值。
  • 如果确实需要写掩码,可以使用;如果只是 shader 漏写某个附件,应改为写满全部附件。

21:15)G-Buffer 生成阶段经常无意中漏写某个 attachment。session 给出的修复方式是给所有 render pass attachment 都写值,即使某个阶段只需要初始化为 0。

struct FragInput { ... };
struct FragOutput { float3 albedo; float3 normals; float3 lighting; };

fragment FragOutput GenerateGbuffer(
                         FragInput in [[stage_in]]) {
    FragOutput out;
    out.albedo = sampleAlbedo(in);
    out.normals = interpolateNormals(in);
    out.lighting = float3(0, 0, 0);
    return out;
}

关键点:

  • FragOutput 明确列出 albedo、normals、lighting 三个输出,和 G-Buffer attachment 对齐。
  • sampleAlbedo(in) 写入材质颜色。
  • interpolateNormals(in) 写入法线。
  • out.lighting = float3(0, 0, 0) 主动初始化 lighting attachment,避免因未写入导致写掩码行为。
  • return out 一次返回完整输出,让 HSR 更容易处理 opaque fragment。

5. 用 tile shader 把 deferred shading 留在片上

23:22)Deferred shading 通常先生成 G-Buffer,再读取 G-Buffer 算 lighting。传统两 pass 写法会把 albedo、normal、roughness 等附件写到系统内存,再马上读回来。Apple GPU 上更好的方式是用 programmable blending 和 memoryless render target,让 G-Buffer 数据留在 tile memory。

28:29)当 renderer 混合 render 和 compute,例如 tile-based light culling,普通 compute encoder 会把流程拆成多个 pass。Apple GPU 从 A11 Bionic 起支持 tile-based compute dispatch。Tile shader 可以在 render command encoder 内按 tile 执行,读取 imageblock、threadgroup memory 和 device memory。

30:19)下面的 render pass descriptor 把 G-Buffer 设为 memoryless 和 .dontCare store,只把最终 lighting texture store 出去。

let renderPassDesc = MTLRenderPassDescriptor()

renderPassDesc.tileWidth = 32
renderPassDesc.tileHeight = 32

renderPassDesc.threadgroupMemoryLength = MemoryLayout<LightInfo>.size * 8

renderPassDesc.colorAttachments[0].texture      = albedoMemorylessTexture
renderPassDesc.colorAttachments[0].loadAction   = .clear
renderPassDesc.colorAttachments[0].storeAction  = .dontCare
renderPassDesc.colorAttachments[1].texture      = normalsMemorylessTexture
renderPassDesc.colorAttachments[1].loadAction   = .clear
renderPassDesc.colorAttachments[1].storeAction  = .dontCare
renderPassDesc.colorAttachments[2].texture      = roughnessMemorylessTexture
renderPassDesc.colorAttachments[2].loadAction   = .clear
renderPassDesc.colorAttachments[2].storeAction  = .dontCare
renderPassDesc.colorAttachments[3].texture      = lightingTexture
renderPassDesc.colorAttachments[3].loadAction   = .clear
renderPassDesc.colorAttachments[3].storeAction  = .store

关键点:

  • tileWidthtileHeight 定义片上 tile 尺寸,示例使用 32×32,和 light culling scheme 对齐。
  • threadgroupMemoryLength 为每个 tile 的 light list 分配 threadgroup memory,示例按 8 个 LightInfo 计算。
  • albedo、normals、roughness 三个 attachment 使用 memoryless texture,pass 结束不写回系统内存。
  • 这些中间 attachment 的 storeAction 都是 .dontCare,因为后续阶段在同一个 render pass 内消费它们。
  • lightingTexture 是最终输出,所以它的 storeAction.store

32:20)Tile shader 还可以重排 imageblock layout。例子从 deferred shading 的 imageblock 转成 multi-layer alpha blending 需要的 layout,把 lighting 和 depth 保存到新的结构中。

// Transitioning from deferred rendering to multi-layer alpha blending layout
struct DeferredShadingFragment {
    rgba8unorm<half4> albedo;
    rg11b10f<half3>   normal;
    float             depth;
    rgb9e5<half3>     lighting;
};
struct MultiLayerAlphaBlendFragments {
    half4 color_and_transmittence[3];
    float depth[3];
};
struct FragmentOutput {
    MultiLayerAlphaBlendFragments v [[imageblock_data]];
};
fragment FragmentOutput my_tile_shader(DeferredShadingFragment input [[imageblock_data]]) {
    FragmentOutput output;
    output.v.color_and_transmittence[0] = half4(input.lighting, 0.0h);
    output.v.depth[0]                   = input.depth;
    return output;
}

关键点:

  • DeferredShadingFragment 描述旧 imageblock layout,包含 albedo、normal、depth、lighting。
  • MultiLayerAlphaBlendFragments 描述新 layout,为多层透明混合准备 color/transmittence 和 depth 数组。
  • [[imageblock_data]] 标记 tile memory 中的数据输入和输出。
  • my_tile_shader 从旧 layout 读取 lighting 和 depth,写入新 layout 的第 0 层。
  • 0.0h 使用 half literal,和后面 shader core 章节的 16-bit 建议一致。

6. 按 Apple shader core 改写 shader 数据访问

34:11)Metal Shading Language 的 address space 会影响性能。device 适合读写、大量、大小不固定、每个线程访问不同元素的数据。constant 适合只读、大小有限、同一 draw 或 dispatch 中多个线程反复读取的数据。

36:03)要让 compiler 更容易把 constant buffer preload 到 uniform register,应把常量组织成单个 struct,通过 constant address space 传引用;数组大小最好在编译期已知。这段重点是数据组织方式,而不是新的 shader 入口。

关键点:

  • device address space 适合大小不固定、每个线程读取不同元素的数据。
  • constant address space 适合只读、大小有限、多个线程反复读取的数据。
  • 常量数组大小在编译期已知时,compiler 更容易把数据 preload 到 uniform register。
  • 常量数据作为单个 struct 按引用传入,比从 device address space 读取动态数组更符合这段优化目标。

37:43)Apple GPU 对 16-bit 类型优化更好。能用 halfshort 时,不要默认使用 floatint。16-bit 类型减少寄存器压力,提高 shader core occupancy,也常有更好的 ALU 利用率。

40:04)还有三个容易被忽略的内存访问问题:避免动态索引的 stack-allocated array;内存索引尽量用 signed type,让 compiler 不必保留 unsigned wrapping 语义;结构体中一起读取的字段尽量相邻,或直接改成 vector type,帮助 compiler 合并 load。

核心启发

1. 做一个 render pass 带宽审计面板

  • 做什么:在引擎调试菜单中列出每帧 render pass 数量、每个 attachment 的 load/store action、memoryless 使用情况。
  • 为什么值得做:session 把 load/store traffic 定义为 Apple GPU 上主要带宽来源,拆 pass、独立 clear、MSAA sample store 都会放大成本。
  • 怎么开始:从 frame graph 或 render pass descriptor 收集 attachment 配置,把 .store.load、非 memoryless 中间纹理高亮,再用 Metal System Trace 对照验证。

2. 给 G-Buffer pipeline 增加 single-pass Apple GPU 路径

  • 做什么:为 deferred renderer 增加 Apple GPU 专用路径,把 G-Buffer、light culling 和 lighting 尽量放进一个 render pass。
  • 为什么值得做:programmable blending、memoryless render target 和 tile shader 可以让中间 G-Buffer 留在 tile memory,减少系统内存读写。
  • 怎么开始:先把 albedo、normal、roughness 改成 memoryless attachment,再用 tile shader 在 render command encoder 内构建 per-tile light list,只 store 最终 lighting texture。

3. 做一个 HSR 友好的 draw 排序检查器

  • 做什么:在 renderer 的调试构建中标记 opaque、feedback、translucent draw,并检查是否按这个顺序提交。
  • 为什么值得做:Apple GPU 的 hidden surface removal 能省掉被遮挡 fragment 的 shading;错误顺序、无意 write mask、fragment resource write 都会降低效率。
  • 怎么开始:在 material 或 pipeline metadata 中记录 blending、discard、depth output、write mask 等状态,发现 opaque 阶段漏写 attachment 时提示补零输出。

4. 增加 shader address space 和 16-bit 类型 lint

  • 做什么:为 Metal shader 增加静态检查,提示可改成 constant 的只读数据、可改成 half/short 的局部计算、缺少 h 后缀的 half literal。
  • 为什么值得做:session 指出 constant preload、16-bit ALU 和较低寄存器压力会影响 Apple shader core 性能。
  • 怎么开始:先扫描 shader 中的 buffer 参数、数组大小、float literal 和 unsigned loop index,把候选点输出为 warning,再用 GPU frame debugger 看 pipeline statistics。

5. 为移植后的 Mac 游戏做 Rosetta 到原生的性能对照

  • 做什么:同一场景分别记录 Rosetta、原生未优化、原生 Apple GPU 优化后的 GPU 时间线和带宽指标。
  • 为什么值得做:session 开头说明 Rosetta 和 consistency feature 会带来性能成本,原生编译后仍需要面向 TBDR 调整 pass 和 shader。
  • 怎么开始:用固定 camera path 捕获 Metal System Trace,逐项应用 pass merge、memoryless、HSR 排序和 shader 类型优化,保留每一步数据。

关联 Session

评论

GitHub Issues · utterances