导语| 本文简述了 GPU 的渲染管线和硬件架构,对一些常见问题进行了讨论和分析。有以下几点核心内容:(1)移动平台渲染管线 TBDR 的介绍;(2)GPU 缓存体系的介绍;(3)Warp 的执行机制;(4)常见的如 AlphaTest 或者分支对性能的影响。
GPU 渲染管线
一、渲染管线简述
-
应用程序阶段
-
顶点处理阶段
-
光栅化阶段
-
逐片元操作
-
优势:
1.其优势是渲染管线没有中断,有利于提高 GPU 的最大吞吐量,最大化的利用 GPU 性能。同时从 vertex 到 raster 的处理都是在 GPU 内部的 on-chip buffer 上进行的,这意味着只需要很少的带宽(bandwidth),就可以存取(storing and retrieving)处理过程中的图元数据。
2.所以桌面 GPU 天然就可以处理大量的 DrawCall 和海量的顶点。而移动端 GPU 则对这两者异常敏感。这不仅仅是 GPU 性能差异,架构差异也至关重要。
-
劣势:
三、TBR: Tile-based Rendering
-
为什么要使用 TBR 架构
1.对移动端设备而言,控制功耗是非常重要的。功耗高意味着耗电、发热、降频,这会导致我们的游戏出现严重的卡顿或者帧率降低。带宽是功耗的第一杀手,大量的带宽开销会带来明显的耗电和发热。
-
TBR 架构的原理
-
优势
-
劣势
四、TBDR: Tile-Based Deferred Rendering
TBDR 和 TBR 模式基本类似,唯一的区别在于,多了一个 隐面剔除(Hidden Surface Removal) 的过程。就是上图中 HSR & Depth Test 这个步骤。通过 HSR,无论以什么顺序提交 drawcall,最终只有对屏幕产生贡献的像素会执行像素着色器。被遮挡的片元会被直接丢弃掉。
不同的 GPU 有自己的隐面剔除技术,比如 PowerVR 就是 Hidden Surface Removal (HSR),Adreno 就是 Low Resolution Z (LRZ),Mali 就是 Forward Pixel Kill (FPK)。其原理和实现各不相同,不过最终目的都是为避免执行无效的像素着色器。
五、总结
GPU 硬件架构
一、GPU 和 CPU 的差异
这张图展示了 CPU 和 GPU 的硬件差异。
CPU 和 GPU 的差异可以描述在下面表格中:
二、CPU 的缓存体系和指令执行过程
虽然本文主要讲的是 GPU 架构,不过 CPU 和 GPU 有很多地方是相通的,同时又有一些设计方向是相反的。了解 CPU 可以帮助我们更好的理解 GPU 的执行过程。
-
内存的硬件类型
-
CPU 的缓存体系
CPU 的缓存有 L1/L2/L3 三级缓存。L1 缓存和 L2 缓存是在 CPU 核心内部的(每个核心都配有独立的 L1/L2 缓存),L3 缓存是所有核心共享的。缓存是 SRAM,速度比系统内存(DRAM)要快非常多。
L1/L2 缓存是片上缓存,速度很快,但是通常比较小。比如 L1 cache 通常在 32KB~256KB 这个级别。而 L3 cache 可以达到 8MB~32MB 这个级别。像苹果的 M1 芯片(CPU 和 GPU 等单元在一个硬件上,SoC),L3 缓存是给所有硬件单元使用的,所以也被称为 System Level Cache。
L1 缓存分为指令缓存(I-Cache)和数据缓存(D-Cache),CPU 针对指令和数据有不同的缓存策略。
L1 缓存不可能设计的很大。因为增大 L1 缓存虽然会减少 L1 cache missing,但是会增加访问的时钟周期,也就是说降低了 L1 cache 的性能。
CPU 的 L1/L2 缓存需要处理缓存一致性问题。即不同核心之间的 L1 缓存之间的数据应该是一致的。当一个核心的 L1 中的数据发生变化,其他核心的 L1 中的相应数据需要标记无效。而 GPU 的缓存不需要处理这个问题。
CPU 查找数据的时候按照 L1-->L2-->L3-->DRAM 的顺序进行。当数据不在缓存中时,需要从主存中加载,就会有很大的延迟。
缓存对提高 CPU 的执行性能有着非常重要的意义。如上面的 Intel i7 die shot 所示,很多时候缓存会占据芯片中一半以上的晶体管和面积。苹果的 A14/A15/M1 芯片性能上碾压同档次的其他 SoC,跟超大缓存密不可分,其 L2/L3 缓存一般是其他 SoC 的两三倍。
-
CPU 指令的执行过程
三、GPU 渲染过程
具体渲染过程,其实就是经典的渲染管线的执行过程。可以跟上一部分的渲染管线流程图对照阅读。推荐阅读 Life of a triangle - NVIDIA's logical pipeline 一文。
四、桌面端 GPU 硬件架构
上图展示的是 NVIDIA Fermi 架构的示意图。
五、Shader Core 的主要构成单元
六、GPU 的内存结构
-
UMA (Unified Memory Architeture)
-
GPU 缓存的分类
GPU 缓存结构
L1 缓存是片上缓存(On-Chip),每个 Shader 核心都有独立的 L1 缓存,访问速度很快。移动 GPU 还会有 TileMemory,也就是片上内存(On-Chip Memory)。
内存访问速度
内存的存取速度从寄存器到系统内存依次变慢:
存储类型 | Register | Shared Memory | L1 Cache | L2 Cache | Texture/Const Memory | System Memory |
---|---|---|---|---|---|---|
访问周期 | 1 | 1~32 | 1~32 | 32~64 | 400~600 | 400~600 |
NVIDIA 的内存分类
查资料的时候经常会看到这些概念,但是 NVIDIA 的内存分类是为 CUDA 开发服务的,与游戏开发或者移动 GPU 还是有一些差异的。所以这里只需要简单了解即可。
-
Cache line
-
Memory Bank 和 Bank Conflict
七、GPU 的运算系统
-
SIMD (Single Instruction Multiple Data) 和 SIMT (Single Instruction Multiple Thread)
在游戏引擎内,我们常会使用 SSE 加速计算(比如视锥体裁剪的计算)。这里利用的就是 SIMD,单个指令计算多个数据。
而 GPU 的设计是为了满足大规模并行的计算(其处理的任务就是天然并行的)。因此 GPU 是典型的 SIMD/SIMT 执行模式。在其内部,若干相同运算的输入会被打包成一组并行执行。
在介绍 SIMT 之前,我们需要先介绍下Vector processor和Scalar processor的概念。早期 GPU 是Vector processor(对应 SIMD)。因为早期 GPU 处理的都是颜色值,就是 rgba 四个分量。在此架构下,编译器会尽可能把数据打包成 vec4 来进行计算。但是随着图形渲染以及 GPGPU 的发展,计算变得越来越复杂,数据并不一定能够打包成 vec4,这就可能会导致性能的浪费。所以现代 GPU 都改进为Scalar processor(对应 SIMT)。后面介绍 Mali 的架构演进的时候还会提到这一点。
现代 GPU 都是 SIMT 的执行架构。传统 SIMD 是一个线程调用向量处理单元(Vector ALU)操作向量寄存器完成运算,而 SIMT 往往由一组标量处理单元(Scalar ALU)构成,每个处理单元对应一个像素线程。所有 ALU 共享控制单元,比如取指令/译码模块。它们接收同一指令共同完成运算,每个线程,可以有自己的寄存器,独立的内存访问寻址以及执行分支。
传统的 SIMD 是数据级并行,DLP (Data Level Parallelism)。而 SIMT 是线程级并行,TLP (Thread Level Parallelism)。更进一步的超标量(Super Scalar)是指令级并行,**ILP (Instruction Level Parallelism)**。
Mali 的 Midgard 是 VLIM(超长指令字,Very long instruction word)设计。它可以通过 128bit-wide 的计算单元并行计算 4 个 FP32 或者 8 个 FP16 等类型的数据。编译器和 GPU 会合并指令以充分利用处理器资源。这也是一种指令级并行(ILP)。
PowerVR、Adreno 的 GPU,以及 Mali 最新的 Valhall 架构的 GPU 都支持Super Scalar。可以同时发射多个指令,由空闲的 ALU 执行。也就是说,同一个 Pipeline 内的多个 ALU 元件是可以并行执行指令序列中的指令的。
无论使用哪种架构,GPU 的计算单元都是并行处理大量数据,所以有的文章也会直接把 GPU 的计算单元称作 SIMD 引擎,或者简称为 SIMD。
如上图所示,左侧是 Vector 处理器,而右侧是 Scalar 处理器。对于 Vector 处理器而言,它是在一个 cycle 内计算(x,y,z)三个值,如果没有填满的话,就会产生浪费。如果是 Scalar 处理器,它是在 3 个时钟周期内分别计算(x,y,z)三个值,不过它可以 4 个线程同时计算,这样就不会浪费处理器性能。
合并单个计算为向量计算,在 Scalar processor 上没有优化效果。因为处理器计算的时候还是会把向量拆散。之前是一个 vec4 在一个 cycle 内完成计算。现在是一个 vec4 在 4 个 cycle 内完成计算,每个 cycle 计算一个单位。如果是 vec3 的话,就是 3 个 cycle。
-
Warp 线程束
-
Stall 和 Latency Hiding (延迟隐藏)
指令从开始到结束消耗的 clock cycle 称为指令的 latency。延迟通常是由对主存的访问产生的。比如纹理采样、读取顶点数据、读取 Varying 等。像纹理采样,如果 cache missing 的情况下可能需要消耗几百个时钟周期。
CPU 通过分支预测、乱序执行、大容量缓存等技术来隐藏延迟。而 GPU 则是通过 warp 切换来隐藏延迟。
对 CPU 而言,上线文切换是一个有明显开销的行为。所以 CPU 是尽可能避免频繁的线程切换的。而 GPU 在 Warp 之间切换几乎是无开销的,所以当一个 Warp stall 了,GPU 就切换到下一个 Warp。等之前的 Warp 获得需要的数据了,再切换回来继续执行。
关于 GPU 的执行过程,知乎上洛城的这篇回答非常有趣,生动形象的展示了 GPU 的硬件构成和常见概念。如果对其还不了解的同学,强烈推荐阅读。
-
Warp Divergence
由于 Warp 是锁步执行的,Warp 中的 32 个线程执行的是同样的指令。当我们的 shader 中有 if-else 的时候,如果 Warp 内有的线程需要走 if 分支,有的线程需要走 else 分支,就会出现Warp divergence。GPU 对此的处理方式是,两个分支都走一遍,通过 Mask 遮蔽掉不要的结果。
如果 Warp 内所有线程都走的是分支的一侧,则没有太大影响。所以动态分支就相当于两条分支都走一遍,对性能影响较大,而静态分支则还好。当然,实际情况可能还会更加复杂一些,后面会再详细讨论。
八、其他重要概念
-
Pixel quad
光栅化阶段,栅格离散化的粒度虽然最终是像素级,但是离散化模块输出的单位却不是单个像素,而是 Pixel Quad(2x2 像素)。其中原因可能是单个像素无法计算 ddx、ddy,从而在 PS 当中判断选用贴图的 mipmap 层级会发生困难。进行 EarlyZ 判定的最小单位也是 Pixel Quad。
如上图所示,可以看出像素点网格被划分成了 2X2 的组,这样的组就是 Quad。一个三角形,即使只覆盖了一个 Quad 中的一个像素,整个 Quad 中的四个像素都需要执行像素着色器。Quad 中未被覆盖的像素被称为"辅助像素"。比较小的三角形在渲染时,辅助像素的比例会更高,从而造成性能浪费。
请注意,辅助像素其实依然在管线内参与整个 PS 计算,只不过计算结果被丢弃而已。而又因为 GPU 和内存之间有 cache line 的存在,cache line 一次交换的数据大小是固定的,所以这些被丢弃的像素很多时候也不会节省带宽。他们会原样读入原样写出,带宽消耗还是那么多。所以,尽可能避免大量小图元的绘制,可以更有效的利用 Warp。
-
EarlyZS
Depth test 和 Stencil test 是一个硬件单元(ROP 中的硬件单元)。Early depth test 的阶段同样是可以做 Early stencil test 的。所以很多文档会描述这个阶段为 Early ZS。Early-Z 技术可以将很多无效的像素提前剔除,避免它们进入耗时严重的像素着色器。Early-Z 剔除的最小单位不是 1 像素,而是像素块(Pixel Quad)。
传统的渲染管线中,depth test 在像素着色器之后进行。进行深度测试,发现自己被遮挡了,然后丢弃掉。这显然会出现大量的无用计算,因为 overdraw 是不可避免的。因此现代 GPU 中运用了 Early-Z 的技术,在像素着色器执行之前,先进行一次深度测试,如果深度测试失败,就不必进行像素着色器的计算了,因此在性能上会有很大的提升。
AlphaTest 会影响 EarlyZ 的执行。一方面是自身不能执行 EarlyZ write 操作,因为只有当像素着色器执行完毕之后才知道自己要不要丢弃,如果提前写入深度会有错误的执行结果。另外一方面只有当自己执行完像素着色器,写入深度之后,相同位置的后续片元才能继续执行,否则就必须阻塞等待其返回结果,这会阻塞管线。关于这一点后面还会再做详细分析。
其他如在像素着色器里面修改深度,或者使用 Alpha to coverage 等,也会影响 EarlyZ 的执行。
-
Hierarchical-z 和 Tile-based Rasteration
这两个是硬件提供的优化。
Hierarchical Z-culling,也称为 Z-cull。是 NVIDIA 硬件支持的粗粒度的裁剪方案。有点像 Adreno 的 LRZ 技术,通过低分辨率的 Z-buffer 来做剔除。不过它只精确到 8x8 的像素块,而非像 LRZ 一样可以精确到 Quad(2x2)。移动平台 GPU 有其他技术做裁剪剔除,所以猜想是没有使用这个技术的。另外,不要把它和 EarlyZ 弄混,也不要把它和我们引擎实现的 Hi-Z GPU Occlusion Culling 弄混。
Tile-based Rasteration 技术。光栅化也是可以 Tile-based,这同样是硬件厂商的优化技术。光栅化阶段通常不会成为性能瓶颈。不过游戏性能优化杂谈中介绍了一个有趣的案例,原神中对树叶使用 Stencil,期望通过抠图实现半透明效果来提高性能。但是却因为影响了 Tile-based Rasteration 的优化,反而导致性能下降。在 PC 平台原本会有一些优化习惯,比如通过 discard 或者其他手段剔除掉像素,避免其进入到像素着色器(减少计算)或者 ROP(减少访问主存)阶段,以此来提高性能。不过这些习惯在移动平台通常都是负优化。
大量小三角形绘制是 GPU 非常不擅长的工作情景。GPU 对顶点着色器和光栅化的优化手段有限,又因为光栅化的输出是 PixelQuad,那么大量像素级的小三角形就必然会导致 warp 中的有效像素大大减少。所以 UE5 的 Nanite 会使用 ComputeShader 自己实现软光栅,来替代硬件光栅化处理这些像素级的小三角形,以此获得几倍的性能提升。相关细节可以参考UE5 渲染技术简介这篇文章。
-
Register Spilling 和 Active Warp
GPU 核心的寄存器虽然很多,但是数量还是有限的。GPU 核心执行一个 Warp 的时候,会在一开始就把寄存器分配给每条线程。如果 Shader 占用的寄存器过多,那么能够分配到 GPU 核心来执行的 Warp 就更少。也就是Active Warp降低。这会降低 GPU 隐藏延迟的能力,进而影响 GPU 的性能。比如,原本在一个 Warp 加载纹理产生 Stall 的时候,会切换到下一个 Warp,如果 Active Warp 过少,就可能所有 Warp 都在等待纹理加载,那么此时 GPU 核心就真的产生 Stall 了,只能空置等待结果返回。
寄存器文件会用多少,在 shader 编译完就确定了。每个变量、临时变量、部分符合条件的 uniform 变量,都会占用寄存器文件。如果 Shader 使用的寄存器文件过多,比如超过 64 或者 128,会产生更加严重的性能问题,就是Register Spilling。GPU 会将寄存器文件存储到 Local Memory 上,之前我们介绍过,LocalMemory 就是主存的一块儿区域,访问速度是很慢的,所以 Register Spilling 会大大降低 Shader 的执行性能。
Shader 占用的寄存器文件多少,指令数多少,是否发生 Spilling,都可以使用 Mali offline compiler 查看。
-
Mipmap
我们传递给 GPU 一个带 Mipmap 的纹理,GPU 会在运行时通过(ddx, ddy)偏导选取合适的 Mipmap Level 的纹理。
Mipmap 有利于节省带宽,并不是说我们传递给 GPU 的纹理数据变小的(相反是增加了)。而是最终渲染的时候相邻的像素更有可能在一个 CacheLine 里面,这就提高了 Texture cache 的命中率。因为减少了对主存的交互,所以减少带宽。
前面我们介绍 GPU 内存的时候有提到,当需要访问主存的时候,需要消耗几百个时钟周期。这会产生严重的 Stall。提升 Texture Cache 命中率就可以减少这种情况的出现。我们通过一些 GPU 性能分析工具优化游戏性能的时候,Texture L1/L2 Cache Missing 是一个非常重要的指标,通常要控制在一个很低的数值才是合理的。
Mipmap 本身是会多消耗 1/3 的内存的(多了低级别的 mipmap 图),不过我们是可以决定纹理 Upload 给 GPU 的最高 mipmap level。我们通过引擎动态控制纹理的最高 mipmap level,反而可以有效的控制纹理的内存用量,这就是 Unity 引擎的 Texture Streaming 机制。基于 Texture Streaming,纹理的内存总量是固定的,把不重要的纹理换出成高 level 的 mipmap 就可以减少纹理的内存占用。当然如果重新切换到 mipmap0,可能会有纹理加载的过程,不过这个是引擎内部实现的,上层开发者是无感知的。我们看到很多 3D 游戏图片会有从模糊到清晰的过程,有可能就是 Texture Streaming 在起作用。
关于纹理的内存占用这里可以再做补充说明。前面介绍移动平台 GPU 内存的时候我们有提到,虽然 CPU 和 GPU 是共用一块儿物理内存,但是其内存空间是分离的。所以纹理提交给 GPU 是需要 Upload 的。当纹理 Upload 给 GPU 之后,CPU 端的纹理内存就会被释放掉。这种情况下,我们将显存中的纹理的内存释放掉,也就相当于释放掉纹理内存。
在 Unity 中,还有一部分纹理是需要 CPU 端读写数据的,或者编辑器下某个纹理导入选项中勾选了 Read/Write Enabled。对这些纹理而言,CPU 端的内存就不会被释放掉。此时该纹理就占用了两份内存,CPU 一份,GPU 一份。
-
纹理采样和纹理过滤
N 倍各向异性就是 N 倍开销。
九、从硬件角度理解 GPU 的执行逻辑
-
GPU 中的可编程元件和固定管线元件
-
从硬件角度看 EarlyZ
-
GPU 核心的乱序执行和保序
移动平台 GPU 架构
一、PowerVR 架构
-
PowerVR GPU 管线
-
PowerVR GPU 硬件架构
PowerVR Rouge 架构的 GPU 包含了 N 个Unified Shading Cluster,这个 USC 就是 GPU 的核心。每个 USC 包含 16 条 Pipeline。每个 Pipeline 包含 N 个 ALU。ALU 就是真正执行指令的地方。ALU 的数目是 GPU 性能的重要指标。
二、Mali 架构
-
Mali GPU 管线
Mali GPU 中有两条并行的管线,Non-Fragment(处理 Vertex Shader、Compute Shader)和 Fragment(处理 Fragment Shader)。
-
Mali GPU 四代架构演变
Mali GPU 的架构演变非常直观的展示了移动 GPU 的进化过程。再加上 Mali 的开发资料比较多,所以这里分别介绍了 Mali 的四代架构。这里可以和上文介绍的 GPU 管线和硬件架构的理论形成参考和对照。
Utgard (2007)
Midgard (2012)
Midgard 是 Mali 的第二代 GPU 架构,见于 Mali-T8xx, Mali-T7xx 和 Mali-T6xx。市面上并不多见了,可能在智能电视芯片中还可见到。
Midgard 的 Shader 核心已经是 Unified shader core。指令执行单元叫做 Tripipe,内部包含三个单元:
1.ALU(s) -- Arithmetic Pipeline,执行指令的地方。可能有 2~3 个。
2.Texture Unit,配有 L1 缓存。
3.Load/Store Unit,配有 L1 缓存。
Midgard 是 Vector processor,通过 SIMD 实现并行计算的。此时并没有 Warp 机制。使用 128-bit wide 的 ALU 进行计算。可以混合处理不同类型的数据,比如 4 个 FP32,8 个 FP16 或者 16 个 INT8。
网上可能会见到的在 shader 中做 vector 处理合并数据来提高性能,对应的就是 Madgard 这种 Vector 处理器。这种优化措施对后面的 Scalar 处理器已经不再适用。
Midgard 的 Shader Core 是以指令级并行(ILP,Instruction Level Parallelism)为主的设计,采用的是超长指令字(VLIW)指令格式。为了最大程度地利用 Midgard 的 Shader Core,需要提取尽可能多的指令(4 条 FP32 并发指令),以便填充 Shader Core 中的所有槽。这种设计非常适合基本的图形渲染工作,因为 4 种颜色分量 RGBA 非常适合 VLIW-4 设计的 4 条通道。
随着移动 GPU 技术的发展,解决方案逐渐向标量处理转移,即以线程级并行(TLP,Thread Level Parallelism)为中心的体系结构设计。这也正是其下一代 Bifrost 架构的发展方向。指令向量化不一定能够完美执行,可能有的标量无法向量化,导致时钟周期的浪费。新的设计不会从单个线程中提取 4 条指令,而是将 4 个线程组合在一起并从每个线程中执行一条指令。以 TLP 为中心设计的优势在于它无需花费大量精力即可从线程中提取指令级并行性。同时对编译器也更加友好,编译器可以实现的更加简单。
Bifrost (2016)
Bifrost 是 Mali 的第三代架构 GPU,见于 Mali-G71、G72、G76 和 Mali-G5x。
Mali 的着色器核心数量是可变的。从上面的 die shot 可以看到,Mali-G76MP10 包含 10 个 Shader core,MP 代表了核心数量。不同核心数量性能差异非常大。所以同样是 Mali-G72 架构,Mali-G72MP12 能跑标准画质,而 Mali-G72MP3 就只能跑流畅画质了,其性能甚至还不如 Mali-G71MP8。
Bifrost 每 Shader core 包含 3 个 Execution Engine(指令执行单元),中低端的 Mali-G5x 可能每个 Shader core 只包含 2 个 EE。
Bifrost 的执行核心不再是 Tripipe 结构。Bifrost 把 TextureUnit 和 L/S Unit 从 Execute Engine 中拆分开了。变成 Shader core 中的独立单元。这样可以避免负载不均衡导致 TU 的能力被浪费,同时也更容易扩展 ALU,增强 GPU 的计算能力。
从这一代开始,Mali GPU 从 Vector 处理器转变为 Scalar 处理器。对应的也加入了 Warp 机制。一个 Warp 是 4 个线程,Mali 称其为 Quad。相比于 NVIDIA 或者 PowerVR 的 32 线程 Warp,Bifrost 的 Warp 要小很多。Warp 小,那么出现上文介绍的Warp Divergence的时候就可以避免浪费,也就是说 if-else 分支对其影响较小。不过 Warp 小,意味着需要更多的控制单元,比如 32 线程的 Warp 只需要 1 个控制单元,而到 Mali 这边就需要 8 个控制单元。控制单元过多,会占用更多的晶体管和芯片面积,限制了 ALU 的数量。同时也意味着更多的功耗。
Valhall (2019)
这是 Mali 最新的 GPU 架构,Mali-G77、G78 以及最新推出的 G710 都是这个架构。对应的中低端架构为 G5XX。
从这一代开始,不再是每个 Shader core 三个 Execution Engine 了。而是一个 Execution Engine。不过 EE 改进为两个 16-wide 的结构。也就是说从 Mali-G77 开始 Warp 大小修改为 16 了。同时这代开始的 GPU,都是 Super Scalar 设计,可以更好的利用 ALU 空闲单元,提升流水线性能。
前面有提到,G78 这一代,重写了 FMA 引擎,其 ALU 也变为 FP32 和 FP16 独立元件了。
现在衡量 GPU 性能的一个重要指标是 Floating-point Operations 的能力。结合 GPU 核心的时钟频率就可以得到 FLOPS(Floating-point Operations Per Second),也就是我们在跑分软件里面看到的 GFLOPS。需要注意的是,FMA(Fused-Multiply-Add,a x b + c)或者叫 MAD,也就是乘加,一次执行记做两个 FLOP。
下面列举不同架构,单核心每个时钟周期的 FP32 operations 数量(FP32 operations/clock)。
1.Mali-G72 是 3 x 4 x 2 = 24
2.Mali-G76 是 3 x 8 x 2 = 48
3.Mali-G77 是 16 x 2 x 2 = 64
4.Mali-G710 是 16 x 2 x 2 x 2 = 128
可以看到,Mali-G77 虽然只有一个 EE,但是计算能力相比 G72 和 G76 却大幅提升。同时由于控制单元就更少,其控制单元的 overhead 就更少。执行相同的运算的功耗就更低。最新的 Mali-G710,架构不变,EE 扩展为两个,性能再次大幅提升。
-
Mali GPU 其他技术
Forward Pixel Kill
IDVS: Index-Driven Vertex Shading
Vertex shading 被拆成两个部分,Position Shading 和 Varying Shading。计算完 position shading 就可以进行裁剪,只有通过裁剪的图元才会执行 varying shading。这样就被裁掉的图元就不用 fetch 各种属性甚至纹理了。
所以对于 Mali GPU 而言,把 Mesh 的 position 单独拆分一个 stream 可以有效节省带宽。其他 GPU 应该也有类似的技术。而对高通的 Adreno 而言,因为 LRZ 需要先跑一遍 VertexShader 中的 Position 部分,得到低分辨率深度图,所以对其而言拆分 position 可以获得更大的收益。
AFBC: Arm Frame Buffer Compression
AFBC 是 FrameBuffer 的快速无损压缩。可以节省带宽,也可以降低显存占用。这个对开发者是无感知的。其他平台也都有类似的压缩技术。
Transaction Elimination
Transaction Elimination 也是一种很有效的降低带宽的方法。在有些情况下,只有部分 Tile 中的内容会变化(例如摄像机不动,一个 Tile 中只有静态物体)。此时通过比较该 Tile 前一次和本次的渲染结果的 CRC 值,可得到 Tile 是否变化,如果不变,那么就没有必要执行 Tile 到 System Memory 的写回操作了,有效地降低了带宽占用。
Hierarchical Tiling
根据图元的大小选择合适的 Hierarchy Level 的 Tile。降低 Tiling 阶段对主存的读取和写入开销。
Shared Memory
三、Adreno 架构
Adreno3xx, 4xx, 5xx, 6xx 是市面上常见的型号。都是 Scalar 架构。
1.3xx 在一些非常低配的手机上还可以见到。
2.5xx 一般见于中低配手机。这一代开始加入了 LRZ 技术。
3.6xx 是近几年新出的型号。630~660 是高配,如骁龙 888 配备是 Adreno660 芯片。
Adreno 一个非常显著的特点是它的核心数量很少,但是每个核心配备一个非常大的 GMEM,这个 GMEM 是 On-chip 的,大小可以达到 256k~1M。比如只有 Adreno630 只有 2 核,GMEM 大小为 1MB。
Adreno 上的 Bin(也就是 Tile)并不是固定大小。而是根据 GMEM 大小和 RenderTarget 格式决定。其大小通常比 Mali 的 16x16 要大非常多。因此,如果渲染目标如果开启 HDR+MSAA 的话,Bin size 会小很多,也就意味着更多的与主存的交互,明显增加功耗。
Flexable Render
Low Resolution Z
四、总结
常见问题的分析与讨论
一、DrawCall 对性能的影响
GPU 工作在内核空间(Kernel Space),我们只能通过驱动与其打交道。所以我们应用层设置一个渲染命令或者给 GPU 传输数据,需要经过图形 API 和驱动的中转,才能最终到达 GPU。而且驱动调用会有用户空间(User Space)到内核空间的转换。当 DrawCall 非常大的时候,这里的 overhead 就会很高。
二、AlphaTest 和 AlphaBlend 对性能的影响
-
桌面平台
-
移动平台
比较有参考价值的是下面两个知乎上的讨论
1.再议移动平台的 AlphaTest 效率问题
2.试说 PowerVR 家的 TBDR。文中摘引是Alpha Test VS Alpha Blend这里的讨论,算是比较官方的回答。
Alpha tested primitives will do the following: ISP HSR: Depth and and stencil tests (no writes) Shading: Colours are calculated for fragments that pass the tests Visibility feedback to ISP: After the shader has executed, the GPU knows which fragments were discarded and which where kept. Visibility information is fed back to the ISP so depth and stencil writes can be performed for the fragments that passed the alpha test When discard is used, pixel visibility isn’t known until the corresponding fragment shader executes. Because of this, depth and stencil writes must be deferred until pixel visibility is known. This reduces performance as the pixel visibility information has to be fed back to the ISP unit after shader execution to determine which depth/stencil positions need to be written to. The cost of this can vary, but in the worse case the entire fragment processing pipeline will stall until the deferred depth/stencil writes complete.
以 PowerVR 的 HSR 为例。不透明物体片元是在 HSR 检测通过就写入深度。而 AlphaTest 片元在 ISP 中做 HSR 检测的时候是不能写入深度的。因为只有像素着色器执行完毕之后它才知道自己会不会被丢弃,如果被丢弃则不能写入深度。而如果没有被丢弃,则会将深度信息回写到 ISP 的 on-chip depth buffer 中。在深度回写完毕之前,相同像素位置的后续片元都不能被处理。这就导致阻塞了管线的执行。
EarlyZ 也是类似的问题。可能早期 EarlyZ 和 LateZ 是共用硬件单元,读和写必须是原子操作。AlphaTest 导致不能进行 EarlyZ write,也就不能进行 EarlyZ test。所以早期一些文档会描述为 AlphaTest 导致 EarlyZ 失效,直到 Flush。现代 GPU 不存在这个问题。AlphaTest 物体不能做 EarlyZ write,但是依然可以做 EarlyZ test。当然因为深度回读导致卡管线,是不可避免的。
单独一个 AlphaTest 和 AlphaBlend 比较,AlphaBlend 可能会比较快。因为它不存在的深度回读的过程,也不会阻塞后续图元绘制。不过这个影响很有可能只在特定情况下才会比较明显。而更加常见的情况是多层半透明叠加的情况。此时 AlphaBlend 由于不写深度,完全无法做剔除,会导致 overdraw 很高,在移动平台上很容易出现性能问题。而 AlphaTest 虽然会因为写深度而阻塞管线,但是也因为会写深度,后续被遮挡的图元(无论是不透明还是半透明)是可以被剔除掉的。所以这种情况下 AlphaTest 可能性能会更好一些。而如果加入 Prez,AlphaTest 性能优势会更加明显。所以草地渲染使用 PreZ + AlphaTest +(alpha to coverage)是比较合理的选择。通常会比使用 AlphaBlend 有更好的性能表现。
不同的测试用例可能会得到不同的测试结果,而一般我们的测试用例很有可能是利好 AlphaTest,所以会得出 AlphaTest 性能比 AlphaBlend 好的结论。当然,我们过于深究 AlphaTest 和 AlphaBlend 的性能差异并没有太大意义。因为多数情况下这两者效果不同,不能互相替换。下面做一些总结。
1.无论是 AlphaTest 还是 AlphaBlend,都不会影响其自身被不透明物体遮挡剔除。RenderPass 中有 AlphaTest 物体,也不会导致后面不透明物体之间的遮挡剔除。
2.对于比较小的特效,不要尝试用 AlphaTest 替代 AlphaBlend,这很有可能是负优化,可能会阻塞管线。
3.对于草地、树叶等穿插遮挡严重的情景,使用 AlphaBlend 性能很低,应该使用 PreZ+AlphaTest。
4.Opaque-->AlphaTest-->Transparent 是合理的渲染顺序,打乱这个顺序可能会造成明显性能问题。
三、不透明物体是否需要排序
按上面的介绍。Opaque-->AlphaTest-->Transparent 是合理的绘制顺序。Opaque 和 AlphaTest 都是不透明物体队列,Transparent 是半透明物体队列。
尤其要注意,AlphaTest 物体不能频繁的和 Opaque 物体穿插绘制(指的是渲染顺序上,而不是物体坐标上),否则会严重阻塞渲染管线。半透明物体不能提到不透明物体队列里面,即半透明物体不能穿插到 Opaque 物体绘制,同样会导致严重的性能问题,比如写深度的半透明物体如果在不透明物体之前绘制,会导致 LRZ 整体失效。
对半透明物体而言,因为要进行混合,所以需要从远到近来绘制(画家算法),否则会得到错误的绘制结果。
对不透明物体而言,在没有隐面剔除功能的芯片上(Adreno3xx),需要保证物体是从近到远进行绘制,可以更好的利用 EarlyZ 优化,也就是说需要进行排序。而有隐面剔除功能的芯片上(PowerVR、Areno5xx、Mali 大部分芯片),不关心物体的绘制顺序,不需要排序,不透明物体不会有 overdraw。
前文介绍 Mali 的 FPK 的时候有提到,FPK 并不能像 HSR 或者 LRZ 一样,对屏幕无贡献的像素肯定会被剔除。FPK 可能存在没有即时 kill 掉的情况。所以对于 Mali 芯片,推荐还是在引擎层做排序。Unity 引擎中判定是否需要排序的代码:
bool hasAdrenoHSR = caps->gles.isAdrenoGpu && !isAdreno2 && !isAdreno3 && !isAdreno4;caps->hasHiddenSurfaceRemovalGPU = caps->gles.isPvrGpu || hasAdrenoHSR;
这里还需要注意,所谓排序,对半透明物体而言,就是根据物体与相机的距离排序的。这是为了得到正确的渲染结果。当然即便基于物体排序,也还是会有半透明物体渲染顺序错误导致冲突的情况,比如较大物体互相穿插,或者物体自身部件之间互相穿插等等。
而对于不透明物体,则是分区块排序。在一个区块儿内部,物体绘制顺序跟与相机的距离无关。这么做主要因为严格按照距离排序,不利于合批,合批需要优先考虑材质、模型是否一致,而不是与相机距离的远近。
四、PreZ pass/Depth prepass 是否有必要
五、Shader 中的分支对性能的影响
-
分支对性能的影响
同一个 warp 内执行的是相同的指令,当出现分支(if-else)的时候,如果所有线程都走分支的一侧,则分支对影响很小。但是如果有些线程走 if 分支,有些线程走 else 分支。那么 GPU 的处理方式是,两条分支都走一遍,然后通过执行掩码(execution mask)丢弃不要的执行结果。这就带来了很多无意义的开销。这种情况就是我们前面介绍的Warp Divergence。
常量做分支条件,编译器会做优化,几乎不会影响性能。
uniform 做判定条件,多数时候可以保证不会出现 Warp Divergence,对性能也不会有太大影响。注意,并不能将不会有太大影响当做没有影响。使用分支的性能隐患有很多,下文还会详细说明。
动态分支,如使用纹理采样的值做判断条件,大概率会产生 Warp Divergence,会严重影响性能,尽可能避免。
-
编译器对 shader 的优化
-
分支的性能隐患
-
multi_compile 的副作用
如果不使用 if-else,那么另外一个选择就是 multi_compile。遗憾的是,使用 multi_compile 同样会有明显的副作用。
-
关于分支的建议
六、Load/Store Action 和 Memoryless
-
Load/Store Action
-
Memoryless
-
Render Target 切换
上图可以看到,RenderTarget 的切换是非常慢的。在我们游戏的渲染流程中,应该尽可能的避免频繁的 RT 切换。
-
避免 CPU 回读 GPU 数据
-
Pixel local storage
-
移动平台延迟渲染优化
七、MSAA 对性能的影响
-
MSAA
移动平台的 MSAA 可以在 TileMemory 上实现 Multisampling,不会带来大量的访问主存的开销,也不会大幅增加显存占用。所以移动平台 MSAA 是比较高效的。通过指定 FrameBuffer 格式就可以开启 MSAA,不需要通过后处理等方案来自己实现 MSAA。
但是这并不是说 MSAA 在移动平台就是免费的了。它依然是有一定开销的,所以也只能在高配手机开启 MSAA。
在 Adreno 上,GMEM 大小是固定的(256k~1M),而 Tile 大小跟 RenderTarget 的格式有关。如果开了 MSAA,Tile 会对应缩小,这就导致产生更多的与主存的交互。开启 HDR 会有更大开销也在于此。具体可以参考移动端 GPU 的运作特性与 UE4 半透特效性能优化方案。
Mali 和 PowerVR 由于 TileMemory 有限,打开 HDR 与 MSAA 需要更多空间来保存渲染结果,GPU 只能够通过缩小 Tile 的尺寸来适应 On-Chip Memory 的固定大小。进行渲染的 Tile 数量会因此而增加。比如 PowerVR 原本 Tile 是 32x32,如果开启 MSAA 可能就变为 32x16 或者 16x16。下面的表格显示了 Mali Bifrost GPU,bits/pixel 和 Tile 大小的关系。
-
Alpha to coverage
Family | 16x16 Tile | 16x8 Tile | 8x8 Tile |
---|---|---|---|
<= Bifrost Gen 1 | 128 bpp | 256 bpp | 512 bpp |
>= Bifrost Gen 2 | 256 bpp | 512 bpp | 1024 bpp |
-
Shader 的优化建议
结语
了解 GPU 硬件架构和运行机制对我们的性能优化工作有指导意义,可以帮助我们更快的分析出游戏的性能瓶颈。比如下图是 SnapdragonProfiler 中的 Trace 截图。如果用 Mali 的 Streamline 的话可以看到更加详细(复杂)的参数指标。如果对 GPU 不够了解的话,这些参数就毫无意义。
本文来自微信公众号“腾讯云开发者”(ID:QcloudCommunity)。大作社经授权转载,该文观点仅代表作者本人,大作社平台仅提供信息存储空间服务。