作者:潘名扬,iOS 音视频研制,目前就职于腾讯,从事音视频修正器研制。
审阅:莲叔 ,任职于阿里uc事业部,负责uc主端短视频,直播等业务。关于音视频,端智能等技能领域有必定经历。
本文来源于 WWDC 2021 – 10153 「Create image processing apps powered by Apple Silicon」。
简介
这篇 WWDC 的同享首要介绍了怎样针对苹果芯片对图画处理运用进行优化,探索了怎样充分发挥 Metal 烘托指令编码器、切片烘托、一致内存架构,以及 memoryless attachments 的优势。在文中的示例里,工程师为咱们展现了怎样针对苹果 GPU 的 TBDR 架构来降低运用的内存占用,削减能耗。经过本篇同享,你还能够了解到将运用中的运算从独立显卡迁移到苹果芯片的最佳实践。
本文首要由两部分组成。前半部分,工程师根据过去一年开发者的一些反应,同享了针对 M1 芯片优化图画处理的最佳实践和经历教训。后半部分是详细的示例,苹果工程师手把手辅导咱们怎样设计图画处理管线,才干发挥 M1 芯片的最大功用。
苹果 GPU 的优化之道
想要针对 M1 芯片进行优化,就先要了解苹果芯片的体系架构,以及它的优势地点。市面上许多图画处理和视频修正的运用在设计时都针对独立显卡进行了优化,咱们重点着重一下苹果 GPU 的不同之处。
首要,全部的苹果芯片都运用一致内存架构(Unified Memory Architecture,简称 UMA)。也便是说,包括 CPU、GPU、神经网络和多媒体引擎在内的全部单元,都能够运用一致的内存接口,拜访同一个体系内存。说白了便是没有独立的显存,CPU、GPU 等处理器都是直接拜访体系内存。
其次,苹果的 GPU 选用根据切片的延迟烘托(Tile Based Deferred Renderers,简称TBDR)。TBDR 有两个首要阶段:切片和烘托。切片是指将完整的烘托画面拆分为一个个的小图块,然后分别进行几何处理。烘托是指对每个图块上的全部像素进行处理。
因而,咱们的运用要充分运用这两个特色,才干取得最高的功率:
- 因为苹果芯片没有独立的显存,咱们应该防止以前的各种复制操作。
- 因为苹果芯片选用 TBDR 架构,咱们要充分运用切片内存和 Local Image Block。
想了解苹果 TBDR 底层的工作原理,以及苹果上色器中心的更多信息,能够观看 2020 年的相关议题:
- Bring your Metal app to Apple silicon Macs
- Optimize Metal Performance for Apple silicon Macs
下面,咱们详细地介绍一下,针对苹果芯片优化图画处理运算的六个最有价值的技巧。
1、防止不必要的内存复制( blits)。
首要咱们要防止进行不必要的内存复制,也便是 blits。鉴于咱们现在处理的图画的分辨率或许高达 8K,这一点十分重要。
市面上大多数图画处理运用都是环绕独立显卡设计的。在运用独立显卡的时分,体系内存和显卡的内存是相互独立的。为了让 GPU 能够拜访每帧图画,有必要对图画进行显式的复制。而且一般需求两次复制,一次传入 GPU 处理,一次从 GPU 取回成果。
比方,以往咱们假如想解码一个 8K 视频,对它进行一些图画处理,然后保存到磁盘。
如上图所示,首要咱们会在一个 CPU 线程上进行解码。然后将解码的帧复制到 GPU VRAM 中。接着在 GPU 中运用各种效果和滤镜。终究咱们还要将处理后的图画复制回体系内存,在 CPU 上进行编码操作。
在高级图画处理运用中,往往需求进行深化的并发优化,或许用一些巧妙的方法来填满这些处理器的空闲间隙。值得幸亏的是,在苹果的 GPU 上,咱们不再需求为了传递图画而进行内存复制。因为内存是同享的,CPU 和 GPU 都能够直接拜访它。
let hasUMA = device.hasUnifiedMemory()
在 UMA 架构的体系上,内存复制往往是剩余的。咱们能够经过上面这个简略的查看来判断体系是否支撑 UMA,假如体系支撑,那咱们就尽量防止不必要的内存复制。
这将大大节约内存,一起咱们也完全防止了内存复制的耗时,咱们的运算就能够无缝衔接。这样也使得 CPU 和 GPU 的运算流水线更加的合理。如下图所示,消除了内存复制的耗时后,CPU 和 GPU 能够无缝衔接,不仅提高功率,也大大简化了使命调度的杂乱度。
此外,Xcode 里的 GPU Frame Capture 工具能够帮咱们查看是否有内存复制的存在。
2、多用烘托而非核算
下面,咱们来谈谈怎样充分运用苹果 GPU 的 TBDR 架构进行图画处理。
在以往,大多数图画处理运用是经过派发一系列核算内核(compute kernel)对图画缓冲区进行操作。当咱们用默许的串行形式去派发核算内核时,Metal 会保证全部后续派发都能看到上一次的全部内存写入,如下图所示。
这保证了全部上色器的内存一致性,因而在下一次派发开始时,全部其他上色器都能够看到每次内存写入。但这也意味着内存读写的流量会十分高,因为每次都有必要读取和写入整幅图画。
有了 M1 之后,苹果的 GPU 能够在 MacOS 上启用切片派发(Tile Dispatches)。和之前的运算不同的是,切片派发调度的是切片内存,也只要切片同步点(Tile sync point)。卷积之类的滤镜,没法映射到切片形式,所以不能从中获益,可是其他的大部分滤镜都能够。
咱们将体系内存的刷新时机推迟到整个编码器(encoder)完毕的时分,能够大大提高功率。这样一来,没有了体系内存带宽的瓶颈,咱们就能够履行更多的 GPU 运算。
更进一步,咱们能够发现许多滤镜其实是逐像素运算的,并不需求拜访相邻像素,因而连切片同步点都不需求。这种状况就能够用到片元函数(fragment functions)。片元函数能够在没有隐式的切片同步的状况下履行,只需求在编码器的边界进行同步,或许在片元上色器之后串行派发切片上色器时才需求同步。
上面说了苹果 GPU 支撑片元函数和切片上色器,能够完成更高效的图画处理。那下面就让咱们看看详细怎样运用。
简略来说,便是把缓冲区上进行的惯例核算派发,转化为纹路上进行的 MTLRenderCommandEncoder
。根据上面所说的,规则如下:
- 没有像素间依靠的逐像素运算改用片元函数来完成。
- 触及线程组内操作的滤镜都改用切片上色器完成,因为需求拜访切片内的相邻像素。
- Scatter-gather 和卷积滤镜需求随机拜访,因而它们仍保存核算派发。
MTLRenderCommandEncoder
还运用了苹果一项共同的 GPU 功用:纹路和烘托方针的无损带宽紧缩。这能够十分好地节约带宽,特别是图画烘托管线。
但需求留意的是,以下几种状况无法敞开无损紧缩
- 已经紧缩的纹路格局无法从无损紧缩中获益。
- 运用了三个纹路标志之一
MTLTextureUsagePixelFormatView | MTLTextureUsageShaderWrite | MTLTextureUsageUnknown
- 线性纹路,或许由 MTLBuffer 回写缓存了。
非私有纹路也需求一些特别处理,例如 MTLStorageModeShared
或许 MTLStorageModeManaged
。咱们需求调用 optimizeContentsForGPUAccess()
来保证能够快速拜访。
GPU Frame Capture 调试界面能够显现无损紧缩正告,并显现纹路不支撑的原因。
3、正确的 load
/store
操作
接下来,咱们看看怎样样正确运用切片内存。
切片内存 TBDR 的一些概念关于桌面国际来说是全新的,例如 load
/store
操作,以及无内存附件(Memoryless Attachments)。所以咱们需求特别留意运用它们的正确方式。
咱们先看看 load
/store
操作。正如咱们上面提到的,整个烘托方针被分割成一个个的切片。 load
/store
操作是批量对每个切片进行的,它会保证在内存层次结构中选用最佳的途径。它在一次 render pass 开始时履行,咱们会告知 GPU 怎样初始化切片内存,并在 render pass 完毕时通知 GPU 哪些附件需求写回。
这里的关键是防止加载咱们不需求的切片。
假如咱们要直接覆写整个图画,或许资源是暂时的,能够将 load
操作设置为 MTLLoadActionDontCare
。运用烘托编码器的时分,不需求铲除输出的内容或暂时数据,只需求设置 MTLLoadActionClear
,就能够有效地铲除指定值。store
操作也是相同,保证只存储需求的数据,而且不要暂时存储任何东西。
除了显式的 load
/store
操作外,苹果 GPU 还可经过无内存附件节约内存占用。
咱们能够显式地将附件(Attachment)界说为具有无内存存储形式。这会启用“仅切片内存分配”,这意味着咱们的资源只会在编码器的生命周期内,为每个切片保存。这能够大大削减内存占用,尤其是 6K/8K 图画,每帧的占用就能够到达几百 MB。详细运用方法如下所示:
4、Uber-shaders 和函数常量
现在,让咱们谈谈 Uber-shaders(超级上色器)。 Uber-shaders 或 Uber-kernels 是一种十分流行的方式,能够让开发人员的工作更轻松。主体代码便是很多的操控结构,上色器只是循环地履行一系列 if/else 语句,比方说,是否启用色彩映射,或许输入格局为 HDR 或 SDR。这种方法也称为 “Ubers-shader“,这么做能够有效地削减管线状况对象的数量。
然而,它也有缺点。它最首要的一个问题,便是增加了寄存器的压力,因为它带来了更杂乱的操控流。运用很多寄存器会在上色器运转时,约束 GPU 的最大运用率。
以下面的上色器为例。
咱们在上面的上色器中运用两个变量,来操控对应的功用。全部看起来都很正常,然而,因为咱们无法在编译时推断出任何内容,因而,关于每个条件判断,咱们都有必要假设两条途径都或许走到,比方 HDR 和非 HDR。然后进行组合,根据输入标志,屏蔽或启用某个途径。
这里的最大的问题是寄存器。每个操控流途径都需求实时寄存器。这便是超级上色器不太好的当地。因为在并发对各个像素履行上色器的时分,寄存器是共用的,假如一路上色器就占用了许多寄存器,那 GPU 的并发量也就会降低,运用率也就降低。假如咱们只能运转只需求的逻辑,那将完成更高的 simdgroup 并发性和 GPU 运用率,如下图所示。
下面就谈谈怎样解决这个问题。
在 Metal 的 API 里有能够解决这个问题的工具,它被称为 函数常量(function_constants)。
咱们将两个操控参数界说为函数常量,并修正代码,如下所示。这样就能够解决上面的问题。
5、充分运用低精度数据类型
另一个削减寄存器压力的好方法是在上色器中运用 16 位的数据类型。Apple GPU 原生支撑 16 位数据类型。因而,运用更小的数据类型时,上色器也只需求更少的寄存器,从而提高 GPU 运用率。运用 half 和 short 类型的能耗也更低,而且或许完成更高的峰值速率。因而,咱们应该尽或许运用 half 和 short 类型而不是 float 和 int,因为类型转化一般是没有功用损耗的。
例如上面的示例中,上色器的线程组参数 thread_position_in_threadgroup
咱们运用的是 unsigned int,但 Metal 支撑的最大线程组并不会超过 unsigned short 的数据规模。另一个参数 threadgroup_position_in_grid
数值或许会比较大。可是即使是 8K 或 16K 图画 unsigned short 也足够了。假如咱们都改用 16 位类型,则生成的代码就只会运用较少数量的寄存器,这样一来,GPU 运用率很或许就会有所提升。
Xcode 13 中的 GPU Frame Capture 能够取得寄存器相关的全部信息,如下所示。
6、MTLPixelFormat 最佳实践
评论了寄存器问题后,咱们来谈谈纹路格局。
首要,咱们要知道不同的像素格局或许有不同的采样率。根据硬件和通道数量,更大的浮点类型或许会降低点采样率。特别是 RGBA32F 等浮点格局在采样时会比 FP16 之类的慢。比较小的数据类型也削减了内存、带宽和缓存的空间占用。所以咱们要尽或许运用最小的类型。
但还有一个原因,那便是纹路存储的耗费。这实践上是咱们开发图画处理的 3D LUT 时很常遇到一种状况。咱们运用的大多数的 3D LUT 都启用了双线性滤波,运用的往往是浮点 RGBA。咱们能够考虑是否能够改为 half 精度就足够了。假如是的话,那就赶紧切换到 FP16 来取得最高的采样率。
假如半精度不行,咱们发现 fixed-point unsigned short 供给了十分均匀的值规模。因而以 unit scale 来编码 LUT,并向上色器传入 LUT 规模,是取得最高采样率和准确性的好办法。
实践
现在,让咱们根据上面的最佳实践,为 Apple 芯片从头设计图画处理管道。实时图画处理十分占用GPU 核算和内存带宽,所以咱们首要了解它一般是怎样设计的,然后咱们看看怎样针对 Apple 芯片对它进行优化。
咱们以 ProRes 编码的输入文件为例。
首要咱们从磁盘或外部存储中读取 ProRes 编码的帧,然后咱们在 CPU 上解码帧。然后,在图画处理阶段在 GPU 上对解码的帧履行烘托,终究输出帧。
烘托管线
接下来,让咱们看一下示例中图画处理管线的组成。
如上图所示,咱们首要将源图画 RGB 的不同通道解包到独自的缓冲区中。后续能够在图画处理管线中对这些通道一同或独自处理。接下来,进行色彩空间转化。然后咱们运用 3D LUT;履行色彩校对;然后运用降噪、卷积、模糊和其他效果。终究,咱们将独自处理的通道打包在一同进行终究输出。
这些过程有什么共同点呢?它们都是点像素滤镜,仅在单个像素上运转,没有像素间依靠性。这很适宜用片段上色器来完成。空间和卷积操作需求拜访大半径的像素,咱们也有离散的读写拜访形式,这些十分适宜核算内核。咱们稍后会用到这些知识。
因为内存有限,咱们常经过拓扑排序来线性化滤镜链。这样做是为了尽或许削减中心资源的总数,一起防止竞赛条件。示例中的这个简略的滤镜链需求两个缓冲区才干在没有竞赛条件的状况下运转并输出成果。下面线性化的图也粗略地表明了 GPU 指令缓冲区发生的工作。
咱们更深化地看看为什么这个滤镜链十分占用设备内存带宽。每个过滤操作都有必要将整个图画从设备内存加载到寄存器中,并将成果写回设备内存,这是相当多的内存流量。
拿 4K 图画来举例。一帧 4K 图画解码,假如选用 FP16 精度就需求 67 MB 的内存,假如选用 FP32 精度就需求 135 MB 的内存。关于专业的图画处理运用来说,必定需求 32 位的精度。这样一来,用 32 位精度来过这个滤镜链,去处理一个 4K 的图画帧,就会发生超过 2GB 的设备内存读写流量。一起还会影响其它的烘托管线,发生缓存动摇。
惯例的核算内核无法主动从片上的切片内存(Tile Memory)中获益。内核能够显式分配线程组规模的内存,这便是在片上的切片内存分配的,可是该内存在核算编码器内的一次派发中不是持续存在的。相比之下,切片内存在一个 MTLRenderCommandEncoder
内的制作过程中是持续存在的。
下面让咱们看看怎样从头设计这个具有代表性的图画处理管线以运用切片内存。咱们将经过以下三个过程来解决这个问题。
第一步,咱们将核算管线更改为烘托管线,并将全部中心缓冲区更改为纹路。
第二步,咱们将没有像素间依靠的操作,编码为一个 MTLRenderCommandEncoder
中的片元上色器调用,这过程要保证全部中心成果正确并设置恰当的 load
/store
操作。
然后咱们评论下更杂乱的问题。刚刚咱们的第一步是运用独自的 MTLRenderCommandEncoder
来编码所需的上色器。在这个滤镜链中,Unpack、色彩空间转化、LUT 和色彩校对滤镜都是单像素点的滤镜,所以咱们能够将它们转化为片元上色器,并兼并起来运用一个MTLRenderCommandEncoder
对其进行编码。相似的,烘托链结尾的 Mixer 和 Pack 上色器也能够转化为片段上色器,并运用另一个 MTLRenderCommandEncoder
进行编码。
然后咱们能够在它们各自的 render pass 中调用这些上色器。创立 render pass 时,附加到该 render pass 中 color attachment 的全部资源都会被隐式切片。一个片元上色器只能写入该片元地点切片的图画数据块。同一 render pass 中的下一个上色器能够直接从切片内存中获取前一个上色器的输出。咱们看看详细的代码完成。
在这里,我将输出图画作为纹路附加到 render pass descriptor 的 color attachment 0;将保存中心成果的纹路附加到 color attachment 1。这两个都会被隐式切片。咱们需求按照前面说的,正确设置 load
/store
特点。
接下来咱们看看怎样在片元上色器中运用这个结构。咱们只需运用咱们之前界说的结构拜访片元上色器中的输出和中心纹路。这些纹路会写入到与片元对应的切片内存中。
Unpack 上色器发生的输出被色彩空间转化(CSC)上色器用作输入,输入的格局便是咱们之前界说的结构。这个片元上色器能够进行自己的处理,并更新输出和中心纹路,更新相应的切片内存的数据。后续便是对同一 render encoder pass 中的全部其他片元上色器持续履行相同的过程。
第三步,让咱们看看离散随机拜访形式的滤镜。
此类滤镜的上色器能够直接对设备内存中的数据进行操作。卷积过滤器十分适宜核算内核中根据图块的操作。咱们能够经过声明一个线程组内的内存来表达运用切片内存的目的。然后将像素块与全部必要的卷积算子的像素一同放入切片内存中,详细取决于卷积的半径,并直接在切片内存上履行卷积运算。要记住的是,切片内存在核算内核的派发中不是持续存活的。因而,在履行 Filter1 之后,有必要显式地将切片内存的内容刷新到设备内存。这样,Filter2 就能够消费到 Filter1 的输出啦。
经过上述的修正,整个内存读写的带宽从 2.16 GB 下降到仅 810 MB,这意味着到设备内存的内存流量削减了 62%。咱们也不再需求两个中心缓冲区,每帧可节约 270 MB 的内存。终究,一起咱们削减了缓存动摇,这是因为该 render pass 中的全部片元上色器都直接在切片内存上运转。
UMA
Apple 芯片的首要特性之一是其 UMA(一致内存架构)。在接下来的部分中,咱们将经过一个示例,介绍 GPU 输出成果帧进行 HEVC 编码时,怎样用最有效的方式来设置管线。
首要咱们将运用 CoreVideo API 创立一个由 IOSurfaces
的像素缓冲池。然后,运用 Metal API,咱们将帧画面烘托为由咱们刚刚创立的缓存池中 IOSurfaces
的 Metal 纹路。终究,咱们将这些像素缓冲区直接派发到媒体引擎进行编码。因为 UMA 一致内存架构的存在,咱们能够在 GPU 和媒体引擎单元之间无缝移动数据,无需进行任何内存复制。
终究,记得在每一帧完毕之后释放 CVPixelBuffer
和 CVMetalTexture
的引用。释放 CVPixelBuffer
能够收回此缓冲区,以便后续的帧能够运用。
总结
终究,咱们总结一下以上本文介绍的几个最重要的实践。
- 首要运用一致内存架构
- 在适用时运用
MTLRenderCommandEncoder
而不是核算管线 - 在单个
MTLRenderCommandEncoder
中兼并全部符合条件的 render pass - 设置适宜的
load
/store
特点 - 对暂时资源运用 memoryless
- 尽量运用切片上色
- 运用缓冲池等 API 完成零内存复制
重视咱们
咱们是「老司机技能周报」,一个持续寻求精品 iOS 内容的技能大众号。欢迎重视。
重视有礼,重视【老司机技能周报】,回复「2021」,收取 2017/2018/2019/2020 内参
支撑作者
在这里给大家推荐一下 《WWDC21 内参》 这个专栏,一共有 102 篇关于 WWDC21 的内容,本文的内容也来源于此。假如对其他内容感兴趣,欢迎戳链接阅览更多 ~
WWDC 内参 系列是由老司机牵头安排的精品原创内容系列。 已经做了几年了,口碑一向不错。 首要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实践开发经历、苹果文档和视频内容做二次创作。