【WWDC 2020】在 Apple Silicon 上优化你的 Metal 应用程序

发布于:2020-08-31 17:04,阅读数:83,点赞数:0


> 本文[首发](https://xiaozhuanlan.com/topic/7641820539)于小专栏《WWDC20 内参》,更多文章见[《WWDC20 内参》](https://xiaozhuanlan.com/wwdc20)。
>
> Apple Silicon 是今年苹果推出的新的 Mac 处理器架构。
>
> 如果读者对这个概念不熟悉,请先查看 Apple Silicon 相关 Session。
>

# 概述

本文主要讨论今年苹果推出 Apple Silicon 处理器之后如何针对其架构优化现有的 Metal 应用程序。本文假设读者已经拥有一定的 Metal 开发经验(iOS 或者 macOS 平台),并且对常见的渲染优化技术熟悉。

推荐首先阅读前置 Session:[使你的 Metal 应用程序更好地运行在 Apple Silicon 架构上](https://blog.yuusann.com/posts/article/20001)

# GPU 架构

Apple Silicon 架构采用了 TBDR 技术进行优化。关于 TBDR 的具体内容在前置 Session 的文章中已有提及,请查看前置 Session 的相关文章,在这里不在赘述。

# 性能优化

## 时序

### 资源依赖

Apple 的 GPU 有三个执行通道:`顶点`、`片元`和`计算`,这三者是并行执行的。在没有相互依赖关系的理想情况下,这三个通道的执行状态应该如下图所示:

![](//cdn.yuusann.com/img/posts/20002_1.png)

三个通道各自服务于不同管线,互不干扰。但有些情况下,管线之间可能有数据依赖的,三个通道就会发生互相等待同步的过程。

![](//cdn.yuusann.com/img/posts/20002_2.png)

但实际情况往往不是如此,例如帧渲染依赖计算着色器的结果时,而计算着色器又依赖于上一帧的渲染结果时,管线的执行顺序就会变成这样:

![](//cdn.yuusann.com/img/posts/20002_3.png)

如何最大程度避免这种情况?

首先需要隔离资源。Metal 以 Resource 作为单位来判断是否互相依赖,而不是 Resource 中数据,即使数据毫不相关,也会被判定为有依赖关系。

![](//cdn.yuusann.com/img/posts/20002_4.png)

解决这个问题的方法就是在 Resource 层就把两份数据拆开。

![](//cdn.yuusann.com/img/posts/20002_5.png)

这样 Metal 就会判定为两个管线使用的资源并不依赖,两个渲染管线得以并行工作。

但在实际情况中,工程上并不允许这么做或者这样做成本很高,那就可以选择把这个资源标记为`Untracked Resource`。

![](//cdn.yuusann.com/img/posts/20002_6.png)

这种情况下,Metal 就不会再去判定这个资源的依赖关系。内存冲突方面的工作将会交给开发者来处理,通过使用`Fence`和`Event`来手动管理这份资源。

关于`Fence`和`Event`可以查看往年的 Session:

- [Wath's New in Metal, Part 1 -- WWDC16](https://developer.apple.com/videos/play/wwdc2016/604/)
- [Metal for Game Developers -- WWDC18](https://developer.apple.com/videos/play/wwdc2018/607/)

### 重排任务

任务顺序可能会造成管线的无效等待,例如以下这个例子:

![](//cdn.yuusann.com/img/posts/20002_7.png)

- `计算管线1`依赖`渲染管线0`
- `渲染管线1`依赖`计算管线1`
- `渲染管线2`依赖`计算管线2`

此时时序如上图所示。

那么在这种情况下,开发者可以通过:

- 把`计算管线2`提前,因为它并不依赖`计算管线1`的结果。

![](//cdn.yuusann.com/img/posts/20002_8.png)

- 把`渲染管线2`提前,因为它只依赖`计算管线2`。

![](//cdn.yuusann.com/img/posts/20002_9.png)

重排之后,把`渲染管线2`的工作放到了`渲染管线0`的后面从而从整体上缩短了 GPU 等待的时间。

Metal 会在一定程度上帮助开发者来排列顺序,但 Metal 的能力是有限的,开发者能够从代码层面控制逻辑是最佳选择。

### 如何发现时序问题

`Metal System Trace`可以帮助开发者发现时序上的问题。

![](//cdn.yuusann.com/img/posts/20002_10.png)

在经过优化后,能够在时序图上直接看到结果。

![](//cdn.yuusann.com/img/posts/20002_11.png)

## 节省内存带宽

### 正确使用加载和存储选项

正确使用管线的使用`loadAction`和`storeAction`能够节省内存带宽。

看下面这个例子:

![](//cdn.yuusann.com/img/posts/20002_14.png)

第一个管线存储了 Color 和 Depth,交给第二个管线使用。第二个管线读写了 Color,而 Depth 只是 load。

可以看出本次渲染的结果经过了两个管线,最终结果只有一个 Color buffer,Depth buffer 最终并没有 Store,所以是可以丢弃的。

![](//cdn.yuusann.com/img/posts/20002_16.png)

Apple 建议只有在`loadAction`有变化的时候拆封管线,这种情况下不应该拆分管线,这样就不用保存中间结果了,可以节约带宽以及节省内存占用。

同时,Depth buffer 在这里并不需要,所以 storeAction 可以设置为`DontCare`,并设置深度缓冲区的`storeMode`为`memoryless`来减少内存占用。

另外,建议只在需要的时候把`loadAction`设为`clear`。在渲染时,Metal 会直接覆盖之前的像素内容。例如本次渲染会对整个贴图内容都进行渲染,那么本身新的像素就会覆盖旧的像素,因此在 load 的时候 clear 是一个多余的操作。

### 并行渲染

Metal 允许开发者并行对各个管线编码来减少 CPU 和 GPU 的互相依赖,所以大多数开发者可能都在这样使用。在其他图形引擎中,这可能是唯一的并行渲染的方法。

![](//cdn.yuusann.com/img/posts/20002_12.png)

三个渲染命令都在使用 G-buffer,因此它需要写回系统内存,三个管线渲染的时候都在重复读取和储存 G-buffer 导致内存带宽占用。

为了解决这个问题,鼓励开发者使用`Metal Parallel Render Command`。

![](//cdn.yuusann.com/img/posts/20002_13.png)

使用并行渲染命令,在编码时仍然可以并行进行,但在最终执行时,Metal 会把子命令合并进一个管线进行执行从而解决内存带宽。

在 Metal API 层面,使用并行命令非常容易。

![](//cdn.yuusann.com/img/posts/20002_15.png)

编码子命令时,和编码普通的命令并没有区别,只是在头尾加上并行命令的 API 即可。

### 多目标渲染

有时开发者需要对多个贴图进行渲染。在这种场景下,中间结果被多次读取、写入系统内存,导致内存带宽占用。

在下面这个例子中,照明和衰减相互依赖,导致不必要的内存带宽占用。

![](//cdn.yuusann.com/img/posts/20002_17.png)

这时最佳的方法是把多个要渲染的目标同时作为附件交给渲染管线。Metal 最多允许管线一次最多设置 8 个 color 附件。

![](//cdn.yuusann.com/img/posts/20002_18.png)

在这个例子中可以只使用两个贴图,照明贴图和用于显示的贴图。照明贴图用于多次光照和衰减,因为它并不在管线之外使用,所以光照贴图可以设置为 memoryless,而只储存显示用的贴图。

Metal API 层面,在编码时直接挂载多个附件即可。

![](//cdn.yuusann.com/img/posts/20002_19.png)

## 最小化负荷

### 隐藏面移除(HSR)

面剔除技术就是通过剔除被遮挡的面来减少图元着色器的负荷。不透明物体在被渲染之前都有可能被剔除。

推荐的场景中的物体的绘制顺序:

![](//cdn.yuusann.com/img/posts/20002_20.png)

- 不透明物体(任意顺序)
- 反馈物体(从前到后顺序)
- 半透明物体(从后到前)

![](//cdn.yuusann.com/img/posts/20002_21.png)

如图中,黄色和绿色三角形中,被蓝色三角形遮挡的部分会被剔除,不会被渲染。

在默认情况下,片元着色器会渲染所有的图元。``[[early_fragment_tests]]``标记可以让 Metal 忽略在深度测试中失败的图元不渲染,从而减少负荷。

### 写入遮罩

Metal 支持只对部分颜色通道进行渲染。

![](//cdn.yuusann.com/img/posts/20002_22.png)

使用写入遮罩可以减少不必要的计算,有时候开发者并不想渲染所有颜色。

Apple GPU 要求保留未经修改的通道以保证正确性,即使这个通道没有作用。所以在需要遮罩的时候请尽量使用。

### 写满所有通道

在生成 G-buffer 时,G-buffer 通常的三个通道只会用到两个,因为光照并不是在这里处理的。

![](//cdn.yuusann.com/img/posts/20002_23.png)

在这里推荐把三个通道都写满,否则会影响 HSR 性能。

![](//cdn.yuusann.com/img/posts/20002_24.png)

### 提前深度测试

提前深度测试指的是先渲染一遍场景获得深度贴图来进行隐藏面剔除,然后再绘制一遍场景来得到最终结果。

![](//cdn.yuusann.com/img/posts/20002_26.png)

HSR 已经实现了同样的效果,如果应用程序做提前深度测试仅仅是为了剔除隐藏面,没有其他用途,那么现在可以移除了。

![](//cdn.yuusann.com/img/posts/20002_25.png)

有关`z-fighting`的内容请查看前置 Session 的相关文章。

# Apple TBDR 的新功能

## 优化延迟渲染

延迟渲染是一项沿用已久的优化技术,在许多游戏引擎中都早有应用。简单概括分为两步:

- 生成 G-buffer

![](//cdn.yuusann.com/img/posts/20002_27.png)

- 利用 G-buffer 渲染

![](//cdn.yuusann.com/img/posts/20002_28.png)

许多开发者还会在两个管线之间用 barrier 隔开,这会严重影响性能。

![](//cdn.yuusann.com/img/posts/20002_29.png)

可编程混合能够帮助开发者优化这种情况,关于可编程混合的细节可以查看笔者去年的专题文章中关于可编程混合的内容:[基于 Metal 的现代渲染技术 —— 小专栏《WWDC19 内参》](https://xiaozhuanlan.com/topic/6927418053)

![](//cdn.yuusann.com/img/posts/20002_30.png)

其优化结果就是不再需要把结果写回系统内存,使用 memoryless 的缓冲区来减少内存和带宽使用。

在更复杂的场景下,如游戏引擎,一次渲染会有很多渲染管线的参与:

![](//cdn.yuusann.com/img/posts/20002_31.png)

同样的,可编程混合也可以优化这种情况。

![](//cdn.yuusann.com/img/posts/20002_32.png)

## 打包渲染和计算

现代渲染引擎通常会引入计算着色器来帮助画面渲染,例如分块光照剔除:

![](//cdn.yuusann.com/img/posts/20002_33.png)

计算着色器会计算所有光源对分块的影响,最终只渲染对这个分块有效的光源。

iOS 上从 A11 Bionic 处理器开始,Apple GPU 就支持了针对分块的计算着色器。现如今,Apple Silicon 把它带到了 macOS 上。且支持使用计算着色器进行渲染命令分发。

![](//cdn.yuusann.com/img/posts/20002_34.png)

得益于计算着色器可以分发任务,那么现在就可以把渲染任务和计算任务合在一起了。

## 分块着色器

现在再让我们回过头来优化延迟渲染。

在这之前,由于光照剔除需要在计算着色器中进行,那么光照剔除的计算着色器将会把整个渲染过程分割成三个管线。

![](//cdn.yuusann.com/img/posts/20002_35.png)

经过优化之后,计算着色器可以承担命令分发的作用,因此三个阶段就可以合在一起,不再有系统内存在于,直接在 Tail 内存中处理完全部过程。

![](//cdn.yuusann.com/img/posts/20002_36.png)

要完成这一步优化,在 Metal API 方面需要有一些工作:

![](//cdn.yuusann.com/img/posts/20002_37.png)

- 设置分块大小。这个分块大小并不是任意的,Apple GPU 只支持一些特定的数值。
- 分配 threadgroup 内存用于存放光源信息。
- 设置 G-buffer 的三个纹理。这三个纹理都是`DontCare`的,因为它们并不会被写回系统内存。这几个贴图的`storeMode`也应该是`memoryless`的。
- 设置最终的渲染结果。

今天在这里只是简单描述一下块着色器的概念,让大家有一个简单的了解。在未来,Apple 会有更多资源来介绍这一技术。

更多信息请查看:

- [Metal 2 on A11 - Tail Shading](https://developer.apple.com/videos/play/tech-talks/604/)
- [Modern Rendering with Metal - WWDC19](https://developer.apple.com/videos/play/wwdc2019/601/)

笔者去年的 Session[《基于 Metal 的现代渲染技术》 —— 小专栏《WWDC19 内参》](https://xiaozhuanlan.com/topic/6927418053)就是针对`Modern Rendering with Metal`而写,有兴趣的读者可以自行查看。

## 重新调整块内存

在实际开发过程中,有一些场景会需要重新调整 Tail 内存的布局,如下图就是一个 G-buffer 使用的内存布局调整为 3 个半透明层数据布局。

![](//cdn.yuusann.com/img/posts/20002_38.png)

这个过程类似于 map 函数,将一个结构的数据调整为另一个结构的数据。

为了完成这样的操作,需要一个基于块的着色器管线来处理,像这样:

![](//cdn.yuusann.com/img/posts/20002_39.png)

这个片元着色器的工作就是一个 map 函数的工作。

更多信息请查看:[Metal 2 on A11 - Image Blocks](https://developer.apple.com/videos/play/tech-talks/603/)

# 优化着色器

## 地址空间

在着色器函数里定义变量时,着色器要求开发者指定变量的存储空间。

![](//cdn.yuusann.com/img/posts/20002_40.png)

- `Device`:可读写,无大小限制。
- `Constant`:只读,共享,大小有限,对可重用的常量进行了优化。

如何选择存储空间只需要问自己两个问题:

- 需要存储多大的数据?如果数据量在编译时不确定,则选择 Device 空间。
- 如果数据是固定的,这个数据会被读取多少次?如果很少,使用 Device 空间,否则使用 Constant 空间。

## 常量区预加载

![](//cdn.yuusann.com/img/posts/20002_41.png)

在着色器运行之前,常量会被加载进 uniform 寄存器。但并不是所有的常量都能够被预加载。

![](//cdn.yuusann.com/img/posts/20002_42.png)

必须是编译时大小确定的 buffer,且没有别标记存储在 Device 空间的常量才会被预加载。

## 数据类型优化

Apple GPU 针对 16-bit 进行了优化。如果使用大于 16-bit 的数据结构,会造成额外的寄存器占用。

![](//cdn.yuusann.com/img/posts/20002_43.png)

使用 16-bit 的数据结构不仅能够节约寄存器,还能使 GPU 更好地并发。因此,使用`half`和`short`会比`float`和`int`性能更佳。

需要额外注意一下情况:

![](//cdn.yuusann.com/img/posts/20002_44.png)

函数调用中,虽然传入的参数`a`和`b`被定义为了半精度,但参数中的`-2.0`和`5.0`是单精度,MSL 会以最高精度的为准。因此在使用常量的时候记得使用`h`标记将常量降到半精度。

## 内存访问

在着色器中尽可能减少栈的访问。

![](//cdn.yuusann.com/img/posts/20002_45.png)

在左边的例子中,由于`index`是传入参数,在编译时是未知的,因此编译器没办法进行优化,只能在运行时对栈进行操作。而右边的例子中,`index`作为循环自变量,且循环的长度在编译时是固定的,因此编译器可以对其进行优化,直接把循环展开,运行时就不用再对栈进行操作了。

该类访问可以在调试时从 Xcode 中看到相关警告。

![](//cdn.yuusann.com/img/posts/20002_46.png)

## 索引时使用有符号数

在 MSL 中,对无符号数的溢出做了封装,这会造成额外的性能损失。

![](//cdn.yuusann.com/img/posts/20002_47.png)

在 MSL 中,如果此时`start`比`end`大,那么 MSL 会让这个循环循环到溢出。在作为索引时,通常不需要这么做,也不会出现溢出的情况,因此使用有符号数更佳。

需要注意的是,有符号数的溢出在 MSL 是未定义的行为。

## 打包访问内存

来看下面这个例子:

![](//cdn.yuusann.com/img/posts/20002_48.png)

在左边的例子中,最后一行读取了`a`和`c`两个参数,但在结构体定义中,`b`夹在中间。性能更好的写法是`a`和`c`写在一起,或定义成一个向量。

# 总结

![](//cdn.yuusann.com/img/posts/20002_49.png)

以上就是本 Session 的所有内容。

Apple 为开发者能够顺利迁移到 Apple Silicon 并改善性能准备了大量资料,下一步可以继续查看其他相关的 Session。

- [Gain Insights into Your Metal App with Xcode 12 - WWDC20](https://developer.apple.com/videos/play/wwdc2020/10605/)
- [Delivering Optimized Metal Apps and Games - WWDC19](https://developer.apple.com/videos/play/wwdc2019/606/)
- [Modern Rendering with Metal - WWDC19](https://developer.apple.com/videos/play/wwdc2019/601/)

# 参考资料

- [基于 Metal 的现代渲染技术 —— 小专栏《WWDC19 内参》](https://xiaozhuanlan.com/topic/6927418053)
- [使你的 Metal 应用程序更好地运行在 Apple Silicon 架构上 —— 小专栏《WWDC20 内参》](https://xiaozhuanlan.com/topic/7904235681)
- [Optimize Metal Performance for Apple Silicon Macs - WWDC20](https://developer.apple.com/videos/play/wwdc2020/10632/)
- [Hidden Surface Removal - Learn WebGL](http://learnwebgl.brown37.net/11_advanced_rendering/hidden_surface_removal.html)


评论:0条


返回列表

返回归档

返回主页