GPU渲染管线和硬件架构 [转]

作者:landonwang,腾讯IEG游戏客户端开发工程师

序言

  • 联发科的工程师团队在对我们游戏进行了性能分析之后,建议我们将草地的PreZ移除掉试试,或许可以提高游戏性能。这与我们的传统认知有出入。但是印象中,确实有某些机器开不开PreZ差别不大。这个矛盾点促使我们对PreZ做了进一步的研究和测试。
  • 在测试过程中我发现,如果对GPU的渲染管线不够了解的话,很有可能连测试用例都是错误的。所以后面又花了大量时间查阅了GPU硬件架构的资料。过去一些模糊的概念也变得清晰起来。
  • 本文简述了GPU的渲染管线和硬件架构,对一些常见问题进行了讨论和分析。特此分享出来,与君共勉。当然,由于本人并未从事过硬件开发的工作,文中有错漏之处在所难免,欢迎批评指正。
  • 本文内容量很大,总结下来有以下核心内容:
    • 移动平台渲染管线TBDR的介绍
    • GPU缓存体系的介绍
    • Warp的执行机制
    • 常见的如AlphaTest或者分支对性能的影响

一、GPU渲染管线

1.1 渲染管线简述 所谓渲染管线,就是CPU传送给GPU一堆数据(顶点、纹理等),经过一系列处理,最后渲染得出来一副二维图像。有以下几个阶段。

1.1.1 应用程序阶段

粗粒度剔除、渲染状态设置、准备数据。

我们在游戏引擎中所做的视锥剔除、遮挡剔除等,都是粗粒度剔除,是基于模型级别的。

这一步是在CPU进行的,后面的步骤都是在GPU内部进行的。

1.1.2 顶点处理阶段

顶点着色器、曲面细分、几何着色器、顶点裁剪、屏幕映射。

这里会做背面剔除等裁剪,确保只有真正需要绘制的图元才会进入光栅化。

顶点处理是可编程的(Vertex Shader,Geometry Shader和Compute Shader)。

1.1.3 光栅化阶段

三角形设置、三角形遍历、片元着色器。

光栅化引擎会将图元(Primitive)映射为与屏幕像素对应的片元(Fragment)。片元包含每个像素的坐标、颜色、深度、法线、导数、纹理坐标等信息。这个数据经过片元着色器的计算得到最终的颜色值。

像素处理是可编程的(OpenGL中叫做片元着色器,Fragment Shader,DirectX中叫做像素着色器,Pixel Shader)。这里通常是性能瓶颈所在,所以现代GPU做了很多的优化来尽可能避免执行无效的像素处理,比如EarlyZ、隐面剔除等。

1.1.4 逐片元操作

裁剪测试、深度测试、模板测试、混合。

光栅化阶段得到的颜色值通过一些列的测试、混合,最终写入到FrameBuffer中。

ROP (Raster Operations),是由一个独立的硬件单元来完成的。这个硬件单元的数量和性能限制了GPU每秒写入FrameBuffer的数据量。在一些低端机这个阶段也非常有可能成为性能瓶颈,即每秒ROP处理数据量。即便使用最简单的全屏半透明shader,多叠加一两层就会因overdraw而严重降帧,很有可能就是ROP瓶颈。

1.2 IMR: Immediate Mode Rendering

上图展示了一个非常经典的IMR渲染管线,也是桌面端最常见的GPU架构。GPU会在每个drawcall提交中,按照管线的顺序处理每个图元。

1.2.1 优势:

其优势是渲染管线没有中断,有利于提高GPU的最大吞吐量,最大化的利用GPU性能。同时从vertex到raster的处理都是在GPU内部的on-chip buffer上进行的,这意味着只需要很少的带宽(bandwidth),就可以存取(storing and retrieving)处理过程中的图元数据。

所以桌面GPU天然就可以处理大量的DrawCall和海量的顶点。而移动端GPU则对这两者异常敏感。这不仅仅是GPU性能差异,架构差异也至关重要。

1.2.2 劣势:

IMR是全屏绘制的。当前绘制的图元可能存在于屏幕的任何位置。这意味着我们需要一个全屏的FrameBuffer。这个buffer的内存很大(比如30MB,大小跟屏幕分辨率和渲染目标的格式有关),所以只能放在系统内存(DRAM)中。而在光栅化之后的fragment shading, depth testing, stencil testing, blending等阶段,都会大量的与系统内存进行交互,消耗大量的带宽。GPU的L1/L2缓存可以缓解这个问题,但是对于移动端,依然是不可接受的开销。

1.3 TBR: Tile-based Rendering 1.3.1 为什么要使用TBR架构

对移动端设备而言,控制功耗是非常重要的。功耗高意味着耗电、发热、降频,这会导致我们的游戏出现严重的卡顿或者帧率降低。带宽是功耗的第一杀手,大量的带宽开销会带来明显的耗电和发热。

移动端GPU的带宽本来就跟桌面端GPU不是一个量级,又无法像独立显卡一样独占大量带宽,所以减少带宽开销变得异常重要。因此移动端GPU普遍使用的是TBR/TBDR架构。

1.3.2 TBR架构的原理

TBR跟IMR不同之处在于,它并不是基于全屏直接绘制。而是把屏幕分成一个一个的Tile,GPU一次只绘制一个Tile。绘制完毕再将绘制结果写入到系统内存的FrameBuffer中。

TBR架构的绘制过程分成两个部分

第一步,处理所有顶点,生成一个 tile list 的中间数据(FrameData)。这个数据保存了每个图元归属于哪个屏幕上的Tile。PowerVR一个Tile是32×32大小,而Mali则是16×16大小(即将发布的Mali-G710也修改为32×32大小了)。

第二步,针对每个Tile执行像素处理过程(光栅化、片元着色器等),每个Tile处理完毕,将结果一起写入到系统内存中。

1.3.3 优势

TBR架构最大的优势就是减少了对主存的访问,也即减少了带宽开销。每个Tile足够小,其framebuffer是可以做到on-chip memory上的。on-chip memory紧挨着GPU的shader core,访问速度极快。

不仅仅是fragment shader,depth testing、blending等操作也是在on-chip memory进行的。大幅减少像素处理阶段对系统内存的访问。

还有一个明显优势是,depth buffer和stencil buffer只在Tile处理内部有用。它们是不需要写回系统内存的,这进一步节省了带宽开销。

TBR架构里,一些原本非常昂贵的,消耗大量的带宽的算法,会变得高效起来。比如MSAA (Multi-Sample Anti-Aliasing)。

1.3.4 劣势

GPU要处理所有的顶点,生成tile list,然后才可以进行光栅化。跟IMR相比,这里会有明显的“延迟(latency)”。

生成的这个tile list数据是要存到系统内存中的。这同样会有带宽开销。顶点越多,计算的压力就越大,带宽消耗也会越多。像曲面细分(tessellation),在TBR架构下就异常的昂贵。

所以移动端游戏对顶点数量更加敏感。如果顶点数量过大的话,会导致性能严重下降。

1.4 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)。其原理和实现各不相同,不过最终目的都是为避免执行无效的像素着色器。

1.5 总结

IMR是桌面端GPU的主流架构。NVIDIA较新的显卡也部分支持了Tile based的特性。不过这个Tile是较大的Tile,而不是像Mali芯片这样16×16的小Tile。

TBR是移动端GPU的主流架构,通过拆分成一个个Tile绘制,减少与主存的交互,进而减少带宽开销。

TBDR一开始专指PowerVR,其光栅化之后并不是立即渲染,而是多了隐面剔除的过程。后来Adreno和Mali也分别提出了自己的隐面剔除方案。所以认为现在的移动端GPU都是TBDR架构也不为过。

推荐阅读 GPU Framebuffer Memory: Understanding Tiling 这篇文章,更加直观的了解不同架构的绘制过程。

二、GPU硬件架构 2.1 GPU和CPU的差异

这张图展示了CPU和GPU的硬件差异。

CPU核心数量少(计算单元少),每个核心都有控制单元。内存设计上是大缓存、低延迟。

而GPU则恰好相反,计算单元非常多,多个计算单元共享一个控制单元。内存设计上追求高带宽,可以接受较高延迟。

所以CPU中习以为常的分支控制,逻辑运算,在GPU中成了奢侈品。而面对海量数据并发计算的场景,GPU则比CPU快好几个数量级。

CPU和GPU的差异可以描述在下面表格中:

CPUGPU
延迟容忍度
并行目标任务(Task)数据(Data)
核心架构多线程核心SIMT核心
线程数量级别1010000
吞吐量
缓存需求量
线程独立性

2.2 CPU的缓存体系和指令执行过程

虽然本文主要讲的是GPU架构,不过CPU和GPU有很多地方是相通的,同时又有一些设计方向是相反的。了解CPU可以帮助我们更好的理解GPU的执行过程。

2.2.1 内存的硬件类型

  • SRAM(Static Random Access Memory,静态随机存取内存)具有静止存取数据的作用,但是断电后数据会消失,不需要刷新电路就能够保存数据,速度较DRAM快很多。一般用作片内缓存(On-chip Cache),例如L1 Cache、L2 Cache。
  • DRAM(Dynamic Random Access Memory,动态随机存取内存)需要不停地刷新电路,否则内部的数据将会消失,因此被称为“动态”存储器。常用于内存,容量较SRAM大。一般用作系统内存(System Memory)。现在桌面端的内存都是DDR (DDR SDRAM,Double Data Rate Synchronous Dynamic Random-Access Memory,简称DDR)。
  • GDDR(Graphic DDR),用作显存。时钟频率更高,耗电量更少。早期显存也是使用DDR的,不过后面独立发展为GDDR。DDR现在还处于DDR4标准,而GDDR已经发展到GDDR6了。
  • LPDDR SDRAM,简称LPDDR(Low Power Double Data Rate)。是移动设备常用的一种低功耗SDRAM,以低功耗和小体积著称。FrameBuffer便存放于此。芯片参数中说的LPDDR4/LPDDR5,说的便是这个,代表了系统内存的性能。
  • UFS(Universal Flash Storage)。通用闪存存储,如ufs2.1/ufs3.1等。代表了移动设备“磁盘”的性能。

2.2.2 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–&gtL2–&gtL3–&gtDRAM的顺序进行。当数据不在缓存中时,需要从主存中加载,就会有很大的延迟。
  • 缓存对提高CPU的执行性能有着非常重要的意义。如上面的Intel i7 die shot所示,很多时候缓存会占据芯片中一半以上的晶体管和面积。苹果的A14/A15/M1芯片性能上碾压同档次的其他SoC,跟超大缓存密不可分,其L2/L3缓存一般是其他SoC的两三倍。

2.2.3 CPU指令的执行过程

  • 经典的指令流水线有下面五个阶段
    • Instruction Fetch,取指令。从指令缓存(I-Cache)中取出指令,放在指令寄存器中。
    • Instruction Decode,指令解码。这里还会通过寄存器文件(Register File)取到指令的源操作数。
    • Execute,执行指令。
    • Memory Access,如果需要存储器取数据(load/store指令),则通过数据缓存(D-Cache)取数据。不访问存储器的指令此阶段不做任何事情。
    • Register Write Back,将指令执行结果写入目的寄存器。
  • 访问主存,无论是GPU还是CPU都会出现较大的延迟。面对延迟,CPU和GPU的解决方案不一样。CPU是通过大容量高速缓存,分支预测,乱序执行(Out-of-Order)等手段来遮掩延迟。CPU缓存容量比GPU要大,比如GPU就没有L3缓存。而且CPU的缓存是以低延迟为目标设计的。GPU是另外一种思路,通过切换线程Warp来规避指令延迟带来处理单元的停顿,下文介绍GPU的Warp机制的时候还会提到这一点。
  • 如果出现分支,CPU是通过分支预测,来提高流水线的执行性能。现代CPU的分支预测准确率很高,可以达到90%以上。当然一些糟糕的代码影响了分支预测,就会出现性能问题。GPU没有分支预测单元,所以并不擅长执行分支。
  • CPU可以同时发射多条指令,让指令能够并行计算,而不是一条流水线串行计算,这样可以更好的利用计算单元。这个就是超标量设计(Super Scalar)。现代CPU基本都是超标量的。
  • 如果按照原本的指令顺序执行,可能指令之间有依赖无法并行执行,或者频繁出现高延迟指令。所以CPU会在保证执行结果正确性的基础上,修改指令的执行顺序,让指令能够更加高效的执行,从而减少执行等待,提升管线性能。这个就是乱序执行
  • CPU的线程切换会有明显的上下文(Context)切换开销。因为切换到其他线程需要将寄存器和程序计数器保存起来,等切换回来的时候还需要恢复寄存器和程序计数器。所以CPU会尽可能避免频繁的线程切换。而GPU因为寄存器数量很多,线程切换时不需要保存上下文,所以就可以通过零成本的切换线程来遮掩延迟。
  • Intel CPU也有使用GPU的思路,准备两套寄存器,CPU核心在两个线程之间切换的成本就非常低。这样一个核心就可以当做两个核心来使用。这就是超线程技术。不过CPU毕竟不像GPU有大量寄存器,核心在两个线程之间切换,并不一定能够保证降低延迟,同时不能准确控制每个线程的执行时间。所以很多游戏以及高性能计算程序都是关闭超线程的。

2.3 GPU渲染过程

具体渲染过程,其实就是经典的渲染管线的执行过程。可以跟上一部分的渲染管线流程图对照阅读。推荐阅读 Life of a triangle – NVIDIA&aposs logical pipeline 一文。

  • 应用程序通过图形API(DirectX、OpenGL、Vulkan、Metal)发出渲染命令,通过驱动传输数据给GPU。GPU通过主机接口(Host Interface)接受这些命令,并通过前端(Front End)处理这些命令。
  • SM (Streaming Multiprocessor) 负责处理执行顶点着色器。现代GPU都是统一着色器架构(Unified Shader Architecture),顶点着色器和像素着色器使用相同的处理核心执行。这样GPU可以更好的做负载均衡,以适应顶点任务重或者像素任务重的不同工作情景。
  • 处理过的三角形会被裁剪,然后分配给光栅化引擎。在光栅化阶段,会把三角形离散为与屏幕对应的栅格信息。
  • 光栅化后的片元,经过EarlyZ剔除,生成像素线程。32个线程为一个线程束(Warp)。这是GPU计算核心的最小工作单元。
  • 接下来是在SM中执行像素着色器。一个Warp中执行的指令是一样的,但是数据不一样(SIMD/SIMT)。
  • 执行完像素着色器之后,数据会交给ROP (Raster Operations)。因为像素着色器执行有快有慢,所以这里会有排序过程,保证执行ROP的顺序和原始API的调用顺序是一致的。一个ROP内部有很多ROP单元,在ROP单元中处理深度测试,和Framebuffer的混合。深度测试和颜色写入必须是原子操作,否则两个不同的三角形在同一个像素点就有可能会有冲突和错误。

2.4 桌面端GPU硬件架构

上图展示的是NVIDIA Fermi架构的示意图。不同的GPU,架构差异较大,但是大体都包含下列核心组件:

  • SM、SMX、SMM (Streaming Multiprocessor)。GPU的核心,执行Shader指令的地方。一个GPU由多个SM构成。Mali中类似的单元叫做Shader Core,PowerVR中叫做Unified Shading Cluster (USC)。
  • Core。真正执行指令的地方。NVIDIA叫做CUDA Core。Mali中叫做Execution Engine或者Execution Core。PowerVR中叫做Pipeline。当然由于硬件结构差异,Execution Engine、Pipeline和CUDA Core并不等价。后面还会对此再做分析。
  • Raster Engine。光栅化引擎。
  • ROP (Raster Operations)。depth testing, blending等操作都是在这里完成的。
  • Register File, L1 Cache, L2 Cache。寄存器和各级缓存。

2.5 Shader Core的主要构成单元

  • 32个运算核心 (CUDA Core,也叫流处理器Stream Processor)
  • 16个LD/ST(Load/Store)模块来加载和存储数据
  • 4个SFU(Special function units)执行特殊数学运算(sin、cos、log等)
  • 128KB寄存器(Register File)3万个32-bit的寄存器,大寄存器设计
  • 64KB L1缓存 (On-Chip memory)
  • 纹理读取单元 (Texture Unit)
  • 指令缓存(Instruction Cache)
  • Warp Schedulers:这个模块负责warp调度,一个warp由32个线程组成,warp调度器的指令通过Dispatch Unit送到Core执行。

2.6 GPU的内存结构

2.6.1 UMA (Unified Memory Architeture)

  • 这张图展示了桌面端和移动端的内存结构差异。桌面端独立显卡是左边的分离式架构,CPU和GPU使用独立的物理内存。而移动端是右侧的统一内存架构,CPU和GPU共用一个物理内存。桌面端的集成显卡也是UMA架构。
  • UMA并不是说CPU和GPU的内存就在一起了,实际上它们所使用的内存区域并不一样。物理内存中有一块儿专有区域由GPU自己管理。CPU到GPU的数据通信依然是有拷贝过程的。如果狭义上说,像主机平台或者苹果M1芯片这样可以实现CPU和GPU的零拷贝数据传输的架构才是真正的UMA,移动端这种架构只能算是共享物理内存。
  • 移动芯片都是SoC(System on Chip),CPU和GPU等元件都在一个芯片上,芯片面积(die size)寸土寸金。自然不可能像桌面端一样给显卡配备GDDR显存,通过独立的北桥(PCI-e)进行通信。在移动端CPU和GPU使用同一个物理内存也更加灵活一些,操作系统可以决定分配给GPU的显存大小。当然副作用就是CPU和GPU很多时候会抢占带宽,这会进一步限制GPU能使用的带宽。
  • GPU使用独立的显存空间的好处是,GPU可以对Buffer或者Texture做进一步优化,比如对GPU更加友好的内存排布。显存中存储的数据可能并不是我们实际Upload的数据。所以即便在手机上,CPU和GPU共用的是一块儿物理内存,我们依然需要通过MapBuffer的形式来完成数据的拷贝。反过来说,如果CPU和GPU直接使用相同的数据,那么GPU就无法对数据做优化,可能会降低性能。
  • CUDA后面有推出统一虚拟地址(Unified Virtual Address,UVA)和统一内存(Unified Memory,UM)的技术,将内存和显存的虚拟地址统一。不过这个跟物理内存是合并的还是分离的没有关系。其目的是为了减化开发者写CUDA程序的内存管理的负担。

2.6.2 GPU缓存的分类 GPU缓存结构

  • L1缓存是片上缓存(On-Chip),每个Shader核心都有独立的L1缓存,访问速度很快。移动GPU还会有TileMemory,也就是片上内存(On-Chip Memory)。
  • L2缓存是所有的Shader核心共享的。属于片外缓存,离Shader核心略远,所以访问速度较L1缓存要慢。
  • DRAM是主存(系统内存,可以叫做System Memory,Global Memory或Device Memory),访问速度是最慢的。FrameBuffer是放在主存上的。

内存访问速度

内存的存取速度从寄存器到系统内存依次变慢:

存储类型RegisterShared MemoryL1 CacheL2 CacheTexture/Const MemorySystem Memory
访问周期11~321~3232~64400~600400~600
  • 寄存器访问速度是最快的,GPU的寄存器数量很多。
  • Shared Memory和L1 Cache是同一个硬件单元,Shared Memory是可以由开发者控制的片上内存,而L1缓存是GPU控制的,开发者无法访问。部分移动芯片如Mali,是没有Shared Memory的,这个主要影响OpenCL开发。
  • Local Memory和Texture/Const Memory都是主存上的一块儿内存区域,所以访问速度很慢。

NVIDIA的内存分类

查资料的时候经常会看到这些概念,但是NVIDIA的内存分类是为CUDA开发服务的,与游戏开发或者移动GPU还是有一些差异的。所以这里只需要简单了解即可。

  • 全局内存(Global memory)。主存,Device Memory。
  • 本地内存(Local memory)。Local Memory是Global Memory中的一部分。是每个线程私有的。主要用于处理寄存器溢出(Register spilling,寄存器不够用了),或者超大的uniform数组。访问速度很慢。
  • 共享内存(Shared memory)。Shared Memory是片上内存,访问速度很快。是一个Shader核心内的所有线程共享的。
  • 寄存器内存(Register memory)。访问速度最快。
  • 常量内存(Constant memory)。Constant Memory和Local Memory类似,都是Global Memory中的一块儿区域,所以访问速度很慢。部分GPU会有专门的Constant cache。
  • 纹理内存(Texture memory)。与Constant Memory类似,也在主存上。部分GPU有Texture cache。

2.6.3 Cache line

  • GPU和缓存之间的内存交换不是以字节为单位,而是以Cache line为单位的。Cache line是固定的大小,比如CPU的Cache line是64字节,GPU是128字节。
  • Cache line不仅仅是为了字节对齐。也有现实意义。想要知道是否缓存命中,是否写入主存,肯定要有标记位。所以一个Cache line就是标记位+地址偏移+实际数据。
  • 缓存命中与否性能差异巨大。对一块儿内存进行顺序访问比随机访问,性能可能要好很多。我们纹理使用Mipmap可以是提高纹理的缓存命中率进而提升性能。Unity的ECS系统也是期望通过Cache友好的数据布局来提升性能。

2.6.4 Memory Bank和Bank Conflict

  • 为了提高对内存的访问性能,获得更高带宽,Shared Memory/L1 Cache被设计为一个个的Memory Bank(L2 cache可能也有类似设计)。bank数量一般与warp大小或者CUDA core数量对应,例如32个core就把SMEM划分为32个bank,每个bank包含多个cache line。Bank可以理解为Memory的对外窗口,有10个窗口可以访问,肯定要比只有1个窗口要高效。
  • 如果同一个warp中的不同线程,访问的是不同的bank,那么就可以并行执行,最大化利用带宽,性能最高。
  • 如果访问的是一个bank中的同一个cache line,那么可以通过广播机制同步到其他线程。一次访问即可取得数据,也不会有性能问题。
  • 如果访问的是同一个bank中的不同的cache line,那么就必须阻塞等待,串行访问。这个会严重阻碍GPU的并行性,产生明显的性能下降。这个阻塞等待的情况,被称为Bank Conflict
  • 如果不同的线程,对同一个cacheline有写操作,那么也必须要阻塞等待。必须等上一个线程写完毕,才能执行后面的读取或者写入操作。

2.7 GPU的运算系统

2.7.1 SIMD (Single Instruction Multiple Data) 和 SIMT (Single Instruction Multiple Thread)

  • 在游戏引擎内,我们常会使用SSE加速计算(比如视锥体裁剪的计算)。这里利用的就是SIMD,单个指令计算多个数据。
  • 而GPU的设计是为了满足大规模并行的计算(其处理的任务就是天然并行的)。因此GPU是典型的SIMD/SIMT执行模式。在其内部,若干相同运算的输入会被打包成一组并行执行。
  • 在介绍SIMT之前,我们需要先介绍下Vector processorScalar 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。

2.7.2 Warp 线程束

  • Warp是典型的单指令多线程(SIMT)的实现。32个线程为一组线程束(Warp)。这32个线程同时执行一样的指令,只是线程数据不一样,这就是锁步(lock step)执行。这样的好处就是一个Warp只需要一个套控制单元对指令进行解码和执行就可以了,芯片可以做的更小更快。Mali早期的GPU并不是基于warp的,性能表现不佳。因为每个核心都有控制单元,占用了过多的晶体管,产生了很多的overhead,进而导致功耗的增加。
  • Warp Scheduler会将数据存入寄存器,然后将着色器代码存入指令缓存,并要求指令分派单元(DispatchUnit)从指令缓存中读取指令分派给计算核心(ALU)执行。
  • Warp中的所有线程执行的是相同的指令,如果遇到分支,那么就可能会出现线程不激活执行的情况(例如当前的指令是true的分支,但是当前线程数据的条件是false),此时线程会被遮掩(masked out),其执行结果会被丢弃掉。shader中的分支会显著增加时间消耗,在一个warp中的分支除非32个线程都走到if或者else里面,否则相当于所有的分支都走了一遍。
  • 线程不能独立执行指令而是以warp为单位,而这些warp之间才是独立的。warp是GPU执行的最小单位。如果一个shader对应的像素数量填不满32个线程,它也会占用一个warp来执行。这种是明显warp利用率低的情况,部分核心在工作,部分核心在陪跑。
  • 一个Warp中的像素线程可以来自不同的图元。只不过其shader指令是一致的。
  • Warp中的线程数量和SM中的CUDA core数量并不一定是一致的。完全可以Warp为32,但是CUDA core只有16个。这种情况下,每个core,两个cycle完成一个warp的计算就行了。PowerVR就是类似的设计。

2.7.3 Stall 和 Latency Hiding (延迟隐藏)

  • 指令从开始到结束消耗的clock cycle称为指令的latency。延迟通常是由对主存的访问产生的。比如纹理采样、读取顶点数据、读取Varying等。像纹理采样,如果cache missing的情况下可能需要消耗几百个时钟周期。
  • CPU通过分支预测、乱序执行、大容量缓存等技术来隐藏延迟。而GPU则是通过warp切换来隐藏延迟。
  • 对CPU而言,上线文切换是一个有明显开销的行为。所以CPU是尽可能避免频繁的线程切换的。而GPU在Warp之间切换几乎是无开销的,所以当一个Warp stall了,GPU就切换到下一个Warp。等之前的Warp获得需要的数据了,再切换回来继续执行。
  • 之所以能够实现这个机制,得益于GPU的大寄存器设计。GPU中的寄存器数量远超CPU。比如前文提到的费米架构的GPU,每个SM的寄存器是3万个。而每个线程能够使用的最大寄存器数量限制在255。所以即便每个线程都占满寄存器,也只消耗了总寄存器数量的四分之一。
  • 每个SM会被同时分配多个warp来执行,warp一旦进入SM内就不会再离开,直到执行完毕。每个线程会在一开始就分配好所需的寄存器和Local Memory。当一个Warp产生了Stall,GPU的Core会直接切换到另外的warp来执行。因为不需要保存和恢复寄存器状态,所以这个切换几乎没有成本,可以在一个cycle内完成。
  • SM中warp调度器每个cycle会挑选Active warp送去执行,一个被选中的warp称为Selected warp。没被选中,但是已经做好准备被执行的称为Eligible warp,没准备好要执行的称为Stalled warp。warp适合执行需要满足两个条件:32个CUDA core有空以及所有当前指令的参数都准备就绪。
  • 如果Shader里面使用的变量越多(Shader写的很长),占用的寄存器数量就越多,留给Warp切换的寄存器就会变少。分配给SM的Warp数量就减少了,也就是Active warp降低了。这会降低GPU隐藏延迟的能力,会降低GPU的利用率。
  • 关于GPU的执行过程,知乎上洛城的这篇回答非常有趣,生动形象的展示了GPU的硬件构成和常见概念。如果对其还不了解的同学,强烈推荐阅读。

2.7.4 Warp Divergence

  • 由于Warp是锁步执行的,Warp中的32个线程执行的是同样的指令。当我们的shader中有if-else的时候,如果Warp内有的线程需要走if分支,有的线程需要走else分支,就会出现Warp divergence。GPU对此的处理方式是,两个分支都走一遍,通过Mask遮蔽掉不要的结果。
  • 如果Warp内所有线程都走的是分支的一侧,则没有太大影响。所以动态分支就相当于两条分支都走一遍,对性能影响较大,而静态分支则还好。当然,实际情况可能还会更加复杂一些,后面会再详细讨论。

2.8 其他重要概念

2.8.1 Pixel quad

  • 光栅化阶段,栅格离散化的粒度虽然最终是像素级,但是离散化模块输出的单位却不是单个像素,而是Pixel Quad(2×2像素)。其中原因可能是单个像素无法计算ddx、ddy,从而在PS当中判断选用贴图的mipmap层级会发生困难。进行EarlyZ判定的最小单位也是Pixel Quad。
  • 如上图所示,可以看出像素点网格被划分成了2X2的组,这样的组就是 Quad。一个三角形,即使只覆盖了一个Quad中的一个像素,整个Quad中的四个像素都需要执行像素着色器。Quad中未被覆盖的像素被称为”辅助像素”。比较小的三角形在渲染时,辅助像素的比例会更高,从而造成性能浪费。
  • 请注意,辅助像素其实依然在管线内参与整个PS计算,只不过计算结果被丢弃而已。而又因为GPU和内存之间有cache line的存在,cache line一次交换的数据大小是固定的,所以这些被丢弃的像素很多时候也不会节省带宽。他们会原样读入原样写出,带宽消耗还是那么多。所以,尽可能避免大量小图元的绘制,可以更有效的利用Warp。

2.8.2 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的执行。

2.8.3 Hierarchical-z和Tile-based Rasteration

这两个是硬件提供的优化。

  • Hierarchical Z-culling,也称为Z-cull。是NVIDIA硬件支持的粗粒度的裁剪方案。有点像Adreno的LRZ技术,通过低分辨率的Z-buffer来做剔除。不过它只精确到8×8的像素块,而非像LRZ一样可以精确到Quad(2×2)。移动平台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渲染技术简介这篇文章。

2.8.4 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查看。

2.8.5 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一份。

2.8.6 纹理采样和纹理过滤

  • 纹理过滤有几种模式:
    • 临近点采样 Nearest Point Sampling
    • 双线性插值 Bilinear Interpolation
    • 三线性插值 Trilinear Interpolation
    • 各向异性过滤 Anisotropic Filter
  • 现代GPU都支持一个cycle内完成一个Bilinear的采样。从性能上说,Point Sampling和Bilinear Filtering是一样的。较新的高端GPU,如Mali-G78可以在0.25个cycle完成一个Bilinear的采样。也就是说它可以在一个cycle内完成一个Quad的采样。
  • Trilinear Filtering需要采样两层mipmap做插值,所以消耗是Bilinear的两倍,也就是两个cycle一个采样。
  • N倍各向异性就是N倍开销。

2.9 从硬件角度理解GPU的执行逻辑 2.9.1 GPU中的可编程元件和固定管线元件

  • 顶点和像素处理是可编程,在Shader Core中执行着色器指令。
  • 光栅化是不可编程的,由光栅化引擎负责。
  • EarlyZ、LateZ、Blend,是固定管线,由ROP单元负责。
  • 固定管线的单元负责特定工作,硬件制作更加简单,性能更好,功耗更低。

2.9.2 从硬件角度看EarlyZ

  • 我们在游戏引擎级别看,每个drawcall有它对应的RenderState,以此来决定是否是AlphaBlend、是否要写深度、是否是AlphaTest等等。但是对于硬件而言,每个图元并不知道自己是不是AlphaBlend。当前RenderState是AlphaBlend的话,那么图元就按照AlphaBlend绘制。当前的ZWrite是Off的,那么LateZ就不写深度。
  • 执行EarlyZ的是硬件单元(ROP),所以不应该用代码的思维去理解EarlyZ的执行过程,更恰当的比喻应该是流水线上的阀门,它可以控制片元是否通过。有一些我们用软件实现起来显而易见的算法,在硬件上却是非常昂贵,难以实现的方案。

2.9.3 GPU核心的乱序执行和保序

  • GPU的计算核心是乱序执行的,不同Warp执行耗时不一致。受分支、cache missing等因素影响。GPU会尽可能填充任务到计算核心。
  • 但是同一个像素的写入顺序是可以得到保证的。先执行的drawcall的像素一定是先写入到Framebuffer中的。不同像素的写入顺序通常也是有序的。
  • GPU在每个阶段的输出结果其实都是有序的。不同阶段之间,通过FIFO队列,保证顺序。随着技术的发展,可能使用的技术不限于FIFO,但是最终目的都是保序。
  • 这个机制是有现实意义的。对于半透明物体,如果ROP是乱序的,那么得到的是错误的结果。而对于不透明物体,虽然有Depth Test的机制,乱序也可以保证结果正确,但是保序对性能有好处,且可以缓解Z-fighting。

三、移动平台GPU架构

3.1 PowerVR架构

3.1.1 PowerVR GPU管线

  • A10之前(iPhone7),都是Imagination PowerVR的GPU,GPU架构可以参考Imagination的文档。A11 (iPhone8/iPhoneXR)开始使用的是苹果自研GPU,苹果应该是得到了Imagination的授权,所以HSR等特性依然是保留的。苹果自研的GPU相关资料较少,暂时理解为是PowerVR的增强衍生版本。
  • TBDR的第一步Tiling的结果存放在Parameter Buffer中。Parameter Buffer是System memory上的一块儿数据区。它的大小是有限的。所以可能会出现PB已经被填充满,但是还有drawcall未执行的情况。当这种情况出现的时候,硬件会进行Flush,以便后续的渲染能够继续执行下去。这带来的问题是Flush前和Flush后的对象是两次HSR处理,即便存在遮挡也无法合理的进行剔除,导致overdraw增加。当PB填充满的时候,可能会导致性能急剧下降。所以应该简化场景,避免出现这种情况。
  • GPU通过ISP单元进行HSR (Hidden Surface Removal,隐面剔除)处理。ISP同样会处理深度回读(Visibility feedback)的情况。HSR是EarlyZ的完全替代品。可以像素级的剔除被遮挡的片元。HSR处理结果存放在TagBuffer中,TagBuffer在片上缓存里,通过TagBuffer就可以得到最终需要绘制的片元。只有最终对屏幕产生贡献的像素才会被绘制。
  • 关于AlphaTest对HSR的影响可以参考后面专门对AlphaTest的讨论。简单说就是AlphaTest不会影响自身的剔除判定,但是会卡管线。它会打断ISP处理覆盖同一像素的几何体。ISP要得到PS执行后的结果才能正确进行HSR,在这一过程中,所有覆盖了带有discard操作像素的几何体全部都要等待。

3.1.2 PowerVR GPU硬件架构

  • PowerVR Rouge架构的GPU包含了N个Unified Shading Cluster,这个USC就是GPU的核心。每个USC包含16条Pipeline。每个Pipeline包含N个ALU。ALU就是真正执行指令的地方。ALU的数目是GPU性能的重要指标。
  • 从上图我们可以看到PowerVR是两个USC共享一个Texture Unit。
  • 图中的MCU就是L2缓存。每个USC和TU都配备独立的L1缓存。每个USC中还都有一块儿Tile Memory也就是我们之前说的On-chip memory。
  • Rouge架构中,每个Pipeline包含4个FP16的ALU,2个FP32的ALU和1个SFU。可以看到FP16和FP32的ALU是分离的。虽然这会占用更多的芯片面积,但是可以大幅减少功耗。FP16速度更快,占用带宽更小,功耗更小。
  • PowerVR是Scalar ALU。支持超标量(Super Scalar),可以同时发射多条指令,让空闲的ALU来执行。所以一个Pipeline内部的多个ALU是可以被充分利用起来的。
  • 每个Pipeline内多个ALU的设计跟其他GPU也有些不一样。比如 NVIDIA的CUDA Core就只有两个 ALU,一个 FP32 ALU 和一个INT ALU。所以虽然PowerVR GPU的核心(USC)数量虽然不多,但是ALU数量反而会比同档次GPU要多。
  • PowerVR的Warp是32大小。但是它的Pipeline是16。所以它是两个cycle处理一个Warp。

3.2 Mali架构

3.2.1 Mali GPU管线

  • Mali GPU中有两条并行的管线,Non-Fragment(处理Vertex Shader、Compute Shader)和Fragment(处理Fragment Shader)。
  • 上面的图中可以确认,FPK (Mali的隐面剔除) 是在EarlyZ之后的。
  • Execution Core中,有WarpManager负责Warp的调度。指令执行单元有FMA(fused multiply-accumulate,混合乘加,基础浮点计算)、CVT(convert,类型转换)、SFU(special functions unit,特殊函数计算)三个元件。在Valhall架构下支持SuperScalar,这三个元件是可以并行执行指令的。

3.2.2 Mali GPU四代架构演变

Mali GPU的架构演变非常直观的展示了移动GPU的进化过程。再加上Mali的开发资料比较多,所以这里分别介绍了Mali的四代架构。这里可以和上文介绍的GPU管线和硬件架构的理论形成参考和对照。

Utgard (2007)

  • 这是Mali的第一代架构。对应 Mali-4xx 系列的GPU。
  • 这代GPU并非Unified shader core,Vertex和Pixel使用不同的计算单元。在市面上几乎看不到了。

Midgard (2012)

  • Midgard是Mali的第二代GPU架构,见于Mali-T8xx, Mali-T7xx和Mali-T6xx。市面上并不多见了,可能在智能电视芯片中还可见到。
  • Midgard的Shader核心已经是Unified shader core。指令执行单元叫做Tripipe,内部包含三个单元:
    • ALU(s) — Arithmetic Pipeline,执行指令的地方。可能有2~3个。
    • Texture Unit,配有L1缓存
    • 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的数量。同时也意味着更多的功耗。
  • Mali-G76在此架构基础上增加了一条通路,每个EE可以同时处理8条线程,也就是说Warp大小扩展为8了。架构没有改变,把Warp提高一倍,就带来了大幅的性能提升。可见之前4-wide warp的设计并不是正确的选择。
  • Mali GPU的ALU是不区分FP32和FP16的。像Midgard一样,GPU会做指令的分解和融合。其ALU每个时钟周期可以处理1个INT32、2个INT16或4个INT8。像PowerVR,FP32和FP16 ALU是独立的,这也是在空间和功率效率之间进行权衡的选择。独立ALU会占用更多的芯片面积,但是会减少功耗。从结果上看,PowerVR的设计更加合理,后面会介绍的Mali-G78重写的FMA(Fused-Multiply-Add)也修改为FP32和FP16独立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)。
    • Mali-G72是 3 x 4 x 2 = 24
    • Mali-G76是 3 x 8 x 2 = 48
    • Mali-G77是 16 x 2 x 2 = 64
    • Mali-G710是 16 x 2 x 2 x 2 = 128
  • 可以看到,Mali-G77虽然只有一个EE,但是计算能力相比G72和G76却大幅提升。同时由于控制单元就更少,其控制单元的overhead就更少。执行相同的运算的功耗就更低。最新的Mali-G710,架构不变,EE扩展为两个,性能再次大幅提升。
  • 上述数据可以在这里 Arm Mali GPU datasheet 下载到。这个文档包含Mali每个GPU的详细数据,比如Warp大小,L1 cache大小等等。

3.2.3 Mali GPU其他技术 Forward Pixel Kill

  • FPK是Mali的隐面剔除技术。通过EarlyZ的片元会进入到一个FIFO的FPK队列。如果后面的片元发现前面的片元被遮挡住了,那么就可以将其终止掉。正常情况下一个片元通过EarlyZ判定之后,就产生了pixel thread,会提交给Shader core来执行,这个thread是无法终止的。不过Mali的FPK技术却可以终止掉不需要绘制的thread,从而避免overdraw。
  • 相比PowerVR的HSR,FPK更像是一个EarlyZ的硬件补丁,来弥补EarlyZ的不足。当EarlyZ失效的时候,FPK肯定也是失效的。FPK并不能保证被遮挡的不透明像素一定不会被绘制(比如队列中的Fragment已经被处理了)。所以Mali推荐还是使用排序的方式以充分利用EarlyZ进行剔除。这篇官方文档的优化建议中也提到了排序的问题。虽然有FPK,但是不要过分依赖它,该排序还是要排序的。

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

  • Mali GPU没有Shared Memory。所有对Shared Memory的操作其实都是寄存器或者主存。不过这个影响比较大的是OpenCL计算或者ComputeShader。普通的shader也操作不了Shared Memory。
  • Adreno GPU有Shared Memory。

3.3 Adreno架构

  • Adreno3xx, 4xx, 5xx, 6xx 是市面上常见的型号。都是Scalar架构。
    • 3xx在一些非常低配的手机上还可以见到。
    • 5xx一般见于中低配手机。这一代开始加入了LRZ技术。
    • 6xx是近几年新出的型号。630~660是高配,如骁龙888配备是Adreno660芯片。
  • Adreno一个非常显著的特点是它的核心数量很少,但是每个核心配备一个非常大的GMEM,这个GMEM是On-chip的,大小可以达到256k~1M。比如只有Adreno630只有2核,GMEM大小为1MB。
  • Adreno上的Bin(也就是Tile)并不是固定大小。而是根据GMEM大小和RenderTarget格式决定。其大小通常比Mali的16×16要大非常多。因此,如果渲染目标如果开启HDR+MSAA的话,Bin size会小很多,也就意味着更多的与主存的交互,明显增加功耗。

Flexable Render

  • Adreno GPU同时支持IMR和TBR两种模式,并且可以根据画面的复杂度,在两者之间动态切换,这就是Flexable Render技术。当然,在移动平台TBR依然是更为高效的方式。

Low Resolution Z

  • LRZ是Adreno5xx系列开始加入的隐面剔除技术。通过先跑一遍Vertex Shader的position部分,生成低精度的深度图,进行裁剪剔除。相当于硬件级别做了Hi-z。这个剔除是可以精确到Pixel Quad的。

3.4 总结

  • Mali的Warp是16,PowerVR是32。Adreno没有找到相关资料,大概率是32。
  • PowerVR、Adreno和Mali最新的Valhall都是Scalar架构,支持SuperScalar,可以更好的利用ALU并行计算。
  • 隐面剔除技术,PowerVR是HSR,Adreno是LRZ,Mali是FPK。
  • 把Mesh的position单独拆分一个stream,有利于节省带宽。
  • Shader中使用mediump,半精度浮点数,可以更好的利用FP16的ALU,性能更好。
  • Tile大小跟RenderTarget格式有关。Adreno的GMEM很大,Tile也要比Mali和PowerVR要大很多。

四、常见问题的分析与讨论

4.1 DrawCall对性能的影响

  • GPU工作在内核空间(Kernel Space),我们只能通过驱动与其打交道。所以我们应用层设置一个渲染命令或者给GPU传输数据,需要经过图形API和驱动的中转,才能最终到达GPU。而且驱动调用会有用户空间(User Space)到内核空间的转换。当DrawCall非常大的时候,这里的overhead就会很高。
  • 以DX为例,程序提交一个DrawCall,数据需要经过 App-&gtDX runtime-&gtUser mode driver-&gtdxgkrnl-&gtKernel mode driver-&gtGPU,然后才能到达GPU。在到达GPU之前,全部是在CPU上执行的。这也是新的DX12试图降低的开销。
  • 主机平台的硬件是固定的,可以对其硬件和驱动做专门的优化。没有了驱动的层层中转,CPU和GPU交互的开销是很低的。所以即便主机的硬件性能不如PC,但是实际游戏性能却远超PC。
  • 当然,单纯的DrawCall命令(比如DrawPrimitive)开销也不会很大。更大的开销在于DrawCall附带的绑定数据(buffer、texture、shader),设置渲染状态的开销。在RenderDoc可以看到一个DrawCall实际上可能会有十几条命令。
  • 在Unity中,绑定Vertex buffer记做一个Batch。CPU和GPU的交互模式,更加擅长一次传输大量数据,而不是多次传输少量数据。Unity的StaticBatch和DynamicBatch的目的就在于此。现在Unity都是以Batches数目代指DrawCall数目。
  • 材质切换记做一个SetPassCalls。材质切换会面临大量的属性同步、shader的编译和绑定、纹理绑定等等,无论在引擎层面还是GPU交互层面都是巨大开销。现代引擎通过排序,让相同渲染状态的物体连续绘制,目的就是减少这一部分的开销。
  • 如果绘制大量小物体,很有可能大量时间消耗在CPU和GPU的交互上,而实际GPU本身的负载并不高。所以我们通常认为DrawCall过高可能会导致CPU出现瓶颈

4.2 AlphaTest和AlphaBlend对性能的影响

  • 事实上,本文出现的初衷就是解答这个问题。因为要测试PreZ对性能的影响(PreZ就是针对AlphaTest和AlphaBlend的优化方案),连带着要测试下AlphaTest和AlphaBlend对不同平台的性能影响。看了网上的一些讨论和文章,结果变得更加困惑了。很多疑问无法解答,比如EarlyZ的实现机制,什么时候EarlyZ会失效等等。最终促使我花了很多的时间精力去学习GPU的硬件架构,这才有了这篇文章。

4.2.1 桌面平台

  • 桌面平台的IMR架构上,AlphaBlend操作的是DRAM(读+写),如果使用过多,会有明显的overdraw和带宽开销。相比起来,AlphaTest如果discard了,PS中不会有后续计算,也可以避免对FrameBuffer的写操作。如果绘制顺序合理(从前往后绘制),未被discard掉的部分也可以有效遮挡住后续部分,可以减轻overdraw。
  • 所以在桌面平台,很多时候会建议使用AlphaTest替代AlphaBlend,这可能会带来性能的提升。
  • 当然如果考虑到其对EarlyZ或者Hierarchical Z的影响(阻塞甚至失效,与具体硬件实现有关)。节省的带宽开销是否比上述优化带来的价值更大,是需要实际测试才能得出结论的。这可能会因GPU不同而产生不同的结果。

4.2.2 移动平台

  • 比较有参考价值的是下面两个知乎上的讨论
    • 再议移动平台的AlphaTest效率问题
    • 试说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 testsVisibility 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 testWhen 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的性能差异并没有太大意义。因为多数情况下这两者效果不同,不能互相替换。下面做一些总结。
    • 无论是AlphaTest还是AlphaBlend,都不会影响其自身被不透明物体遮挡剔除。RenderPass中有AlphaTest物体,也不会导致后面不透明物体之间的遮挡剔除。
    • 对于比较小的特效,不要尝试用AlphaTest替代AlphaBlend,这很有可能是负优化,可能会阻塞管线。
    • 对于草地、树叶等穿插遮挡严重的情景,使用AlphaBlend性能很低,应该使用PreZ+AlphaTest。
    • Opaque–&gtAlphaTest–&gtTransparent 是合理的渲染顺序,打乱这个顺序可能会造成明显性能问题。

4.3 不透明物体是否需要排序

  • 按上面的介绍。Opaque–&gtAlphaTest–&gtTransparent 是合理的绘制顺序。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-&gtgles.isAdrenoGpu && !isAdreno2 && !isAdreno3 && !isAdreno4caps-&gthasHiddenSurfaceRemovalGPU = caps-&gtgles.isPvrGpu || hasAdrenoHSR

  • 这里还需要注意,所谓排序,对半透明物体而言,就是根据物体与相机的距离排序的。这是为了得到正确的渲染结果。当然即便基于物体排序,也还是会有半透明物体渲染顺序错误导致冲突的情况,比如较大物体互相穿插,或者物体自身部件之间互相穿插等等。
  • 而对于不透明物体,则是分区块排序。在一个区块儿内部,物体绘制顺序跟与相机的距离无关。这么做主要因为严格按照距离排序,不利于合批,合批需要优先考虑材质、模型是否一致,而不是与相机距离的远近。

4.4 PreZ pass/Depth prepass是否有必要

  • PreZ pass就是预先使用非常简单的shader(开启ZWrite,关闭颜色写入)画一遍场景,得到最终的Depth Buffer。然后再使用正常Shader(关闭ZWrite,ZTest修改为EQUAL,不执行clip),来进行绘制。这样只有最终显示在最上面的像素会绘制出来,其他像素都会因EarlyZ被剔除掉。
  • PreZ的好处是降低overdraw。坏处是多画了一遍场景(虽然使用的是最简单的shader),DrawCall翻倍,顶点翻倍(顶点着色器执行两遍)。由于通常我们游戏的瓶颈都在于像素着色器,所以大多数情况下PreZ都是有优化效果的。
  • PC平台使用PreZ pass或许是个很好的选择。一方面因为它没有移动平台的各种隐面剔除技术,另外基于IMR渲染,对DrawCall和顶点数量不那么敏感。所以PC平台使用PreZ pass是很好的降低overdraw的手段。
  • 移动平台则恰好相反。移动平台GPU都有各种隐面剔除技术,不透明物体本身就不存在overdraw。而TBR架构对DrawCall和顶点数量异常敏感,大量顶点会导致更多的主存访问,甚至会出现主存ParameterBuffer放不下,产生Flush的情况。所以移动平台不需要PreZ pass
  • 如果实际测试发现,游戏的性能瓶颈在于DrawCall和顶点着色器,那么就不要使用PreZ,这会进一步增大顶点压力。
  • 回到我们序言中提到的问题,我们是否有必要对草地这样的物体使用PreZ呢?答案是有必要。草地是AlphaTest,且有大量的穿插,不可避免的会有大量的overdraw。通过PreZ可以很好的降低草地的overdraw。同时,由于使用PreZ最后绘制草地的时候是不写深度的,也没有clip,那么就可以当做不透明物体来绘制,不会像普通AlphaTest一样影响渲染管线的执行。
  • 后面跟联发科工程师团队进一步沟通了解到,他们建议去掉PreZ pass的原因是在测试我们游戏的过程中发现,在特定场景下vs很容易出现瓶颈,而ps反而留有余地。我们上面提到,PreZ会增加顶点数量,更加容易出现vs的瓶颈。当然,实际游戏运行过程会非常复杂,可能稍微换个视角或者换个手机就又是ps瓶颈了。如何取舍还是要以真机测试结果为准。
  • 网上有一些文章提到,某团队使用AlphaTest替代AlphaBlend绘制草地,又或者某2D游戏,使用AlphaTest绘制角色,都获得了性能的大幅提升。其原因就在于我们上面所分析的,当游戏的overdraw很重,或者ps是瓶颈的时候,使用AlphaTest可以利用EarlyZ做剔除,提升性能。当然,这里要再次重申,一定要确保不透明、AlphaTest、半透明,这样的绘制顺序,如果AlphaTest或者半透明物体穿插到不透明物体之间绘制的话,会严重影响性能。

4.5 Shader中的分支对性能的影响

4.5.1 分支对性能的影响

  • 同一个warp内执行的是相同的指令,当出现分支(if-else)的时候,如果所有线程都走分支的一侧,则分支对影响很小。但是如果有些线程走if分支,有些线程走else分支。那么GPU的处理方式是,两条分支都走一遍,然后通过执行掩码(execution mask)丢弃不要的执行结果。这就带来了很多无意义的开销。这种情况就是我们前面介绍的Warp Divergence
  • 常量做分支条件,编译器会做优化,几乎不会影响性能。
  • uniform做判定条件,多数时候可以保证不会出现Warp Divergence,对性能也不会有太大影响。注意,并不能将不会有太大影响当做没有影响。使用分支的性能隐患有很多,下文还会详细说明。
  • 动态分支,如使用纹理采样的值做判断条件,大概率会产生Warp Divergence,会严重影响性能,尽可能避免。

4.5.2 编译器对shader的优化

  • Unity会使用glsl optimizer对shader做优化。多数时候会明显提高shader的执行性能。不过对于if-else,它的默认处理方式是将分支展开,全部计算一遍,根据判断条件取其中一个结果。除非分支中的指令很复杂,或者有大量纹理采样,它才会保留分支代码。这个行为是可以通过branch和flatten关键字来控制的。Unity中的UNITY_BRANCH和UNITY_FLATTEN就对应这两个关键字。
    • branch,shader会根据判断语句只执行当前情况的代码。
    • flatten,shader会执行全部情况的分支代码,然后再根据判断条件获得结果。
    • unroll,for循环是展开的,直到循环条件终止。
    • loop,for循环不展开。

4.5.3 分支的性能隐患

  • 大量if-else会导致shader指令数比较多,占用的寄存器就更多。这会导致GPU的Active warp数量降低,降低GPU隐藏延迟的能力。也有可能因为寄存器占用过多,产生Register spilling(将寄存器写入主存)。
  • 还有可能因为shader指令比较多,导致编译后的bin文件比较大,不利于缓存。
  • 另外有的时候,我们有一些计算或者纹理采样是无意中写在分支之外的,而计算结果只有分支中才会用得上。这种情况如果是multi_compile,则编译器会自动做优化,精简掉不必要的代码。但是如果是if分支,则编译器不会优化这块儿的代码。这可能导致执行了比预期要多的指令或者进行了不必要的纹理采样。
  • 动态分支(包括uniform分支),可能会不利于驱动进行优化。具体驱动实现过于黑盒,而且随着驱动的迭代更新可能会不断改进。但是通常来说,如果我们通过分支来替换multi_compile,都会增加shader的执行开销。如果是常用shader,或者游戏的GPU已经跑的比较满了,则分支的副作用不可忽略,尤其是在低端机上。
  • 某些驱动(常见于低端机),可能会在驱动级别对分支做“优化”,如果分支指令较少,会强制展开。但是分支的另外一个隐性开销在于参数传递导致的带宽增加。即便分支指令很少,带宽的增加也可能会成为压死骆驼的最后一根稻草。
  • Adreno3xx、4xx部分驱动存在兼容性问题,使用half参数做判定条件执行if指令,永远无法判定为真。修改为float做判定条件即可。所以,兼容性测试也需要多加关注。

4.5.4 multi_compile的副作用

如果不使用if-else,那么另外一个选择就是multi_compile。遗憾的是,使用multi_compile同样会有明显的副作用。

  • 增加了keyword的数量,keyword本身是有限的。
  • 增加了变体的数量,不同的变体其实是不同的shader,这会导致SetPassCalls增加,影响运行时性能。
  • 变体增多会产生更多的内存占用。而且Shader实际的内存占用可能会比我们在UnityProfiler中看到的ShaderLab的内存占用更多。因为驱动会消耗两三倍的内存去管理ShaderProgram。相比增加大量变体,选择if-else,并使用const或者uniform作为其判定条件有的时候是更加理想的选择。

4.5.5 关于分支的建议

  • 尽量不要使用分支,如必须使用的话,优先选择常量的判定条件,其次选择uniform变量作为判定条件。
  • 最糟糕的情况是使用shader内部计算的值作为判定条件,尽可能避免。
  • 尽可能避免在常用shader,或者给低端机使用的shader中使用分支。
  • 最终确定要使用分支,请确保两条分支不存在大量重复代码。大量重复代码会导致shader占用寄存器文件明显增多,减少active warp数量,最终导致性能下降。
  • 如果分支指令较多,不要忘记添加branch关键字。
  • 选择分支还是multi_compile,一定要以实际游戏性能测试为准。尤其要留意其对低端机GPU占用率的影响。

4.6 Load/Store Action和Memoryless

4.6.1 Load/Store Action

  • 从SystemMemory拷贝数据到TileMemory是Load Action。
  • 从TileMemory拷贝数据到SystemMemory是Store Action。也称为Resolve。
  • OpenGLES中可以通过glInvalidateFramebuffer来规避上述Load和Store。
  • Metal中可以通过RenderPass的loadAction和storeAction的设置来控制Load/Store。
    • loadAction有三种,dontCare,load,clear。
    • storeAction有四种,dontCare,store,multisampleResolve,storeAndMultisampleResolve。
  • 比如后处理执行完毕之后深度就没用了,那么就可以设置depthTexture的RT的storeAction为dontCare。这样可以避免深度写回主存的带宽开销。
  • 在XCode中可以看到渲染的L/S bandwidth的开销。通过抓帧可以清楚的看到每个RenderPass的load/store action。这可以很方便的帮助我们优化渲染管线的性能。

4.6.2 Memoryless

  • 像Depth/Stencil buffer,只在Tile绘制中有用,不需要存到主存中。所以其storeAction可以声明为dontCare。这样可以节省带宽。
  • Metal下,这样不需要Resolve的RT,可以设置为Memoryless,这样可以降低显存开销。

4.6.3 Render Target切换

  • 上图可以看到,RenderTarget的切换是非常慢的。在我们游戏的渲染流程中,应该尽可能的避免频繁的RT切换。
  • 因为移动平台TBDR的特性,切换RT在移动平台上会有更大的开销。它会严重阻塞渲染流水线的执行。每次切换RT都需要等待前面的指令全部执行完毕,把数据写入主存。切换到新的RT后,还需要把数据从主存Load到TileMemory中。频繁的与主存交互不仅很慢,而且消耗大量带宽。所以类似后处理这样必须使用RT的,应当把多个Pass尽可能合并成一个Pass。
  • 当我们使用RenderTexture的时候,一定要慎重。一方面它消耗了过多的内存。另外方面RenderTexture的绘制更新不可避免的会有RT切换。如果过多使用,或者过于频繁的更新,会出现明显的性能问题,尤其是在低端机上。当确定要使用RenderTexture的时候,一定要严格控制其大小。
  • 一个我们常见的功能,将场景或者角色模型绘制到一个RenderTexture上,然后将这个RenderTexture绘制到UI上。这个过程其实就存在明显的性能隐患。另外,有一些UI框架会做优化,将静态不变的UI绘制到一个RenderTexture上来减少DrawCall。如果不能保证UI真的完全静止不动,在移动平台上这么做通常是负优化。原因同样在于内存消耗和RT切换开销。

4.6.4 避免CPU回读GPU数据

  • CPU回读GPU数据(比如glReadPixels)会严重阻碍CPU和GPU的并行。当CPU要读取FrameBuffer中的数据的时候,必须要保证GPU已经全部写入完毕。
  • 部分解决方法是,在下一帧的时候读取上一帧的数据。这样可以规避等待的开销,不过毕竟是两帧数据,所以结果可能会有偏差。

4.6.5 Pixel local storage

  • Mali和Adreno是提供了API来获取TileMemory中的数据。这样就可以高效的实现一些特殊效果,比如软粒子或者一些后处理效果。
  • iOS平台因为使用了Memoryless,framebuffer_fetch无法获取深度数据,但是可以通过一些其他手段,比如MRT(Multiple Render Targets),或者把深度写入到color的alpha通道来实现类似功能。

4.6.6 移动平台延迟渲染优化

  • 传统延迟渲染的GBuffer会带来大量的带宽开销,移动平台上同样可以利用OnChip Memory来实现基于移动平台的高性能延迟渲染。
  • 而原神貌似直接使用传统的延迟渲染方案,并没有针对移动端做性能优化,所以它只能在高配手机上才能跑得动。带宽的开销可见一斑。

4.7 MSAA对性能的影响

4.7.1 MSAA

  • 移动平台的MSAA可以在TileMemory上实现Multisampling,不会带来大量的访问主存的开销,也不会大幅增加显存占用。所以移动平台MSAA是比较高效的。通过指定FrameBuffer格式就可以开启MSAA,不需要通过后处理等方案来自己实现MSAA。
  • 但是这并不是说MSAA在移动平台就是免费的了。它依然是有一定开销的,所以也只能在高配手机开启MSAA。
    • 在Adreno上,GMEM大小是固定的(256k~1M),而Tile大小跟RenderTarget的格式有关。如果开了MSAA,Tile会对应缩小,这就导致产生更多的与主存的交互。开启HDR会有更大开销也在于此。
    • Mali和PowerVR由于TileMemory有限,打开HDR与MSAA需要更多空间来保存渲染结果,GPU只能够通过缩小Tile的尺寸来适应On-Chip Memory的固定大小。进行渲染的Tile数量会因此而增加。比如PowerVR原本Tile是32×32,如果开启MSAA可能就变为32×16或者16×16。下面的表格显示了Mali Bifrost GPU,bits/pixel和Tile大小的关系。
Family16×16 Tile16×8 Tile8×8 Tile
<= Bifrost Gen 1128 bpp256 bpp512 bpp
>= Bifrost Gen 2256 bpp512 bpp1024 bpp

4.7.2 Alpha to coverage

  • Alpha-to-coverage简单理解就是基于MSAA,使用AlphaTest来模拟AlphaBlend的效果。原本的AlphaTest不可避免的会有比较硬的边缘,通过Alpha-to-coverage,像素的透明度是由4个像素插值计算得来的,边缘就会柔和很多。
  • 使用Alpha-to-coverage的好处是,因为它的本质是AlphaTest也就是不透明物体,所以不会有渲染半透明物体那样容易出现深度错误的情况。
  • 使用Alpha-to-coverage也会影响EarlyZ的执行(无论是AlphaTest影响还是MSAA影响),在移动平台上性能不高,所以建议只在必要的地方使用。
  • 因为Alpha-to-coverage是基于MSAA的,所以不使用MSAA的时候,启用Alpha-to-coverage,其结果是无法预测的。不同的图形 API 和 GPU 对这种情况有不同的处理方式。

4.8 Shader的优化建议

  • MAD(乘加)是一条指令。将计算转换为 (a x b + c) 的形式,可以节省指令。
  • saturate, negation, abs是免费的。clamp, min, max不是。不要进行负优化。
  • sin, cos, log, sqrt, pow, atan, atan2 使用SFU进行计算,通常需要花费几个ALU甚至几十个ALU,尽可能避免。
  • CVT (类型转换,如half–&gtfloat,vec3–&gtvec4)并不一定是免费的,可能需要占用一个cycle。需要减少无意义的类型转换。
  • 先计算scalar,再计算vector,性能更好。通常编译器会做这个优化,但是编译器不一定能优化到极致。在保证代码清晰的前提下,尽可能编写高效代码是个良好习惯。
  • 优先使用半精度的浮点数(half),速度更快。 lowp和mediump是FP16,highp是FP32。一般顶点坐标需要FP32,其他如颜色都可以是FP16。
  • 不要单纯为了性能把标量合并为向量,尤其是以降低代码可读性为代价。这对现代GPU而言通常不会有优化效果,GPU执行的时候还是拆散为多个标量来执行。手工合并数据很容易因为代码混乱产生负优化,比如引入了隐式类型转换产生了更多的开销。
  • 是否可以使用分支,参考上面对于分支性能的分析。一个稳妥的建议是,要阅读编译后的代码,要在不同机型上在真实游戏环境下做测试。确保修改不是负优化。
  • 在PC平台,使用discard剔除像素或许有优化效果,可以避免ROP的开销。在移动平台则不要这么做,会影响HSR和EarlyZ,是负优化。
  • 减少寄存器的使用,避免Register Spilling(寄存器超过限制写入主存)。这里主要就是减少uniform变量、临时变量的数量(简单说就是尽可能精简shader代码)。查看编译后的代码可以比较清晰的看到这些。使用Mali offline compiler也可以定量化的获得寄存器数量和指令数据。

结语

  • 了解GPU硬件架构和运行机制对我们的性能优化工作有指导意义,可以帮助我们更快的分析出游戏的性能瓶颈。比如下图是SnapdragonProfiler中的Trace截图。如果用Mali的Streamline的话可以看到更加详细(复杂)的参数指标。如果对GPU不够了解的话,这些参数就毫无意义。
  • 本文覆盖的知识点很多,限于篇幅和本人的技术水平限制,很多东西无法讲的很透彻。在我看来,这篇文章更大的价值在于告诉读者有哪些知识需要去了解,了解这些知识有何意义,而不是把某个知识点讲的足够准确或者深入。

原文地址:https://it.sohu.com/a/553556920_100093134

作者: Josh Chen

技术引领潮流!