深入理解GPU(三)性能优化
前两章主要介绍了GPU的渲染管线和硬件架构,本文针对GPU的性能优化做一些简单的讨论。
DrawCall对于性能的影响
GPU是工作在内核空间的,应用层跟GPU打交道是通过图形API和GPU的驱动来完成的,驱动的调用会有一个用户空间到内核空间的转换。以DX为例,用户程序在CPU端提交一个DrawCall, 数据的流程是:APP > DX runtime > User mode driver > Dxgknl > Kernel mode driver > GPU,经过这一连串的调用,才能到达GPU,所有GPU之前的这些流程都是在GPU端执行的。所以DrawCall数量增加,增加的往往是CPU端的时间开销。这也就是为什么最新的现代图形API,如DX12,Vulkan等,都会将驱动层做薄的原因。而主机平台因为有特殊的驱动优化,因此CPU和GPU的交互性能开销是很低的,游戏性能也就更强。
DrawCall命令的开销并不是单纯的绘制命令本身,而在于DrawCall绑定的数据(Shader,Buffer,Texture)和渲染状态(Render State)设置的开销。Unity中,绑定VertexBuffer记作一个Batch,Unity以Batch的数量指代DrawCall的数量。CPU和GPU交互的方式,更擅长一次传输大量数据,而不是多次传输少量数据,如果绘制大量的小物体,则很长的时间都花费在CPU和GPU交互上,GPU本身的负载并不是很高,这也就是为什么DrawCall增加会影响CPU性能的原因。Unity中切换材质记作一个SetPassCalls,材质切换就会面临大量的属性同步,shader的编译和绑定,纹理的绑定,渲染状态的重新设置等,无论是引擎层面还是GPU交互层面都有巨大的开销,因此引擎中将渲染状态相同的物体进行连续绘制,目的就是减少这一部分的开销。
Shader中分支对于性能的影响
前面说到,GPU的核心是以warp为单位来执行任务的,每个warp执行的指令是相同的,当遇到分支的时候,GPU的方式是所有的分支都走一遍,然后通过掩码来丢弃部分分支的结果,这种情况往往是会带来性能开销的。
编译器会对shader的代码进行优化,所以分支主要分为以下几种:
- 常量分支:这种情况编译器会做优化,直接折叠分支未走到的代码,只保留分支代码,最终的机器码中没有分支跳转,比如define的宏定义的分支,这种情况几乎没有性能影响。
- uniform 分支: Uniform来做判定条件,多数情况可以保证不出现warp divergence,对性能也不会有太大影响。
- 动态分支:使用纹理采样,或者其他动态的值的分支,非常大概率会产生warp divergence,会严重影响性能,应当避免。
编译器会有一些默认的分支优化策略,也可以通过一些关键字来控制优化:
- branch : 该关键字指示编译器保留分支跳转,在运行时根据条件动态选择执行路径,适用于分支条件由运行时变量决定,且分支路径较长,代码量大的场景以及分支概率极端不平衡(90%线程走一个分支)的情况。非必要不使用。
- flatten : 该关键字指示编译器展平分支,同时执行所有分支的代码,然后根据条件选择最终的结果,本质是通过计算量来换取无分支跳转,避免线程束发散。适用于分支由运行时变量决定,但是分支路径较短的代码。分支概率接近50%时,此时展平会比全部执行更快。
- unroll : 该关键字指示编译器把循环展开,即把循环体重复N次,替代原来的循环控制逻辑,减少循环的控制开销。这里的N必须是编译器常量,且次数较少,通常<16,而且循环体的代码量较小,指令数可控。
- loop : 该关键字指示编译器不展开循环,保留原始的循环控制逻辑,适用于循环次数大或者循环次数时运行时变量的场景。
当shader中有大量的if-else分支,会增加寄存器的占用,就会导致Active Warp的数量减少,间接影响性能。而且shader的指令数增加会增加编译的shader文件大小,不利于缓存。
如果不使用if-else的分支,另外一种选择就是multi-compile,用于在单次编译过程中生成多个 Shader 变体(Variants),每个变体对应不同的宏定义组合,从而在运行时根据条件动态选择合适的变体执行。以 Unity 为例,Multi-Compile 通常通过 ShaderLab 指令(如#pragma multi_compile)配合 Cg/HLSL 中的宏条件编译(#ifdef/#endif)实现。但是multi-compile会增加keyword的数量,同时也增加了变体的数量,变体增多会增加内存的使用。有些情况下,使用if-else会比multi-compile更加合适。
在实际的shader实现过程中,尽量的不使用分支计算,如果必须使用,则优先选择常量判定条件,其次选择unifom变量判定条件,尽量不要使用动态计算的值作为判定条件,同时尽量避免在常用shader中使用分支。使用分支时尽量不要在分支中存在大量的重复代码,需要将重复代码抽离到分支之外。
Memory Load/Store对性能的影响
Load/Store Action
GPU中的Load/Store Action是指GPU与内存(包括显存,系统内存和缓存)之间进行数据的**读取(Load)和写入(Store)**操作过程。其中Load Action是将内存数据读取到寄存器,共享内存等片上内存的过程,如读取顶点位置,贴图采样等。Store Action是GPU将片上内存写入到内存的过程,如片元着色器写入像素颜色到FrameBuffer。
- Load Action 渲染开始时如何处理目标的FrameBuffer中已有的数据,常见选项:
- LOAD : 保留缓冲之前的数据。
- CLEAR : 将缓冲初始化为指定值,如设置为黑色,深度1.0
- DONT-CARE: 忽略已有的数据,可以直接覆盖,性能最优。
- Store Action : 渲染结束时,如果处理FrameBuffer中的结果:
- STORE : 将结果保存到内存,供后续渲染或者显示使用。
- DONT-CARE: 丢弃结果,无需保留,如中间渲染目标。
RenderTarget切换
渲染过程中RT的切换是非常慢的,游戏渲染过程应该避免频繁的切换RT。移动平台因为使用TBDR,切换RT的开销更大,因为切换RT会严重阻塞流水线的执行,每次切换RT需要首先等待前面的指令全部执行完毕,然后把数据都写入主存,切换RT之后,还需要把数据重新从主存load到TileMemory中。这种与主存的交互不仅速度慢,而且会消耗大量的带宽,严重影响性能。类似后处理这种必须使用RT的场景,应该尽可能的将多个Pass合成为一个Pass。
GPU数据回读
CPU回读GPU数据会严重阻碍CPU和GPU的并行,当CPU需要读取FrameBuffer的时候,必须要保证GPU的数据已经全部写入完毕。所以一种优化的方法是:在N+1帧的时候读取第N帧的数据,可以减少等待的开销,虽然会存在一些数据偏差。
Shader实现对性能的影响
- GPU中乘加(MAD)是一条指令,将计算转换为a * b + c的形式,可以节省指令数量。
- saturate,negation,abs这些函数是免费的,而clamp,min,max不是。
- sin,cos,log,sqrt,pow,atan,atan2都是使用SFU进行计算,耗时通常是ALU的几倍到几十倍,所以尽可能的避免使用。
- 类型转换(half->float, vec3->vec4)等并不一定是免费的,通常需要占用一个cycle,尽可能避免。
- 优先使用半精度浮点数Half,速度更快。因为half需要的带宽和计算量更少,指令延迟也更低,许多的GPU计算核心会有专门的16位计算单元。一般高精度要求,比如postion需要f32,其他的都可以使用half。
AlphaTest和AlphaBlend对性能的影响
AlphaTest和AlphaBlend都是逐片元的操作
- AlphaTest在片元着色器之后,深度测试之前的(因为如果深度测试过了,AlphaTest没过需要回滚深度)。片元着色器计算出像素的RGBA值,然后根据Alpha值和阈值判断是否保留该片段,未通过的片元直接被丢弃,不参与后续的深度测试和FrameBuffer的写入。
- AlphaBlend在深度测试之后,FrameBuffer写入之前,且通常在所有的测试都完成之后执行。片元在通过所有的测试之后(深度,模板),AlphaBlend将当前的片元的颜色与FrameBuffer中已有的颜色按照Alpha值进行混合,最后将混和的结果写入到FrameBuffer中。
桌面平台
移动平台的IMR架构下,AlphaBlend会有DRAM(读+写)的操作,使用过多会有明显的overdraw和带宽开销。AlphaTest如果discard了,就不会有FrameBuffer的写入操作,在移动平台上,会建议使用AlphaTest代替AlphaBlend。在实际使用过程中,AlphaTest会对EarlyZ的优化产生影响,所以具体对性能产生正向还是负向的影响还需要经过实际测试才能知道。
移动平台
移动平台的性能影响可以参考下面两篇文章的讨论:
以PowerVR的HSR为例,不透明片元再通过HSR监测之后就写入深度的,而AlphaTest片元再做HSR的时候是不能写入的,因为只有在片元着色器执行完毕之后才知道自己会不会被丢弃,因此在深度回写完毕之前,相同像素位置的片元都不能被处理,这就导致阻塞了管线的执行。AlphaTest导致EarlyZ失效也是因为这个原因。
单独的一个AlphaTest和AlphaBlend比较,AlphaBlend可能比较快,因为它不存在深度回读的过程,也就不会阻塞后续图元的绘制,不过这种影响只有在特定的情况下才会出现,更多的情况是,AlphaBlend无法写深度,不能做有效的剔除,导致overdraw很高,所以也会出现性能问题。
如果管线中加入了PreZ,那么AlphaTest的性能会更好。因为PreZ可以弥补AlphaTest导致的EarlyZ失效的问题,同时还可以解决AlphaTest造成的管线阻塞的问题,因为使用PreZ就无需等待AlphaTest的深度回读的问题,管线能够流畅的执行。所以渲染草地树叶等使用PreZ+AlphaTest+(AlphaToCoverage)是一个比较合理的选择。总结几点:
- 无论是AlphaTest还是AlphaBlend都不会影响其自身被不透明物体遮挡剔除。
- 对于树叶,草地等相互穿插严重的场景,AlphaBlend的性能很低,应该使用PreZ + AlphaTest。
- 按照Opaque->AlphaTest->Transparent的顺序进行渲染是合理的,打乱这个顺序可能造成明显的性能问题。
Opaque和AlphaTest属于不透明队列,Transparent属于半透明物体队列,AlphaTest物体不能频繁的和Opaque物体穿插绘制,半透明物体也不能提到不透明物体队列里,这些都会产生严重的性能问题。核心的原因是:不同类型物体(Opaque/AlphaTest/ 半透明)对渲染流水线的深度缓冲(Depth Buffer)和状态管理有完全不同的依赖,频繁穿插绘制会破坏 GPU 的底层优化机制(如 EarlyZ、深度缓存一致性),导致无效计算剧增、管线阻塞,最终引发严重性能问题。
PreZ pass是否有必要?
PreZ pass就是预先使用非常简单的shader(开启Zwrite,关闭color写入)画一遍场景,最终得到DepthBufer,然后再使用正常的Shader(关闭ZWrite,ZTest改为EQUAL)来绘制场景,这样只有最终显示再上面的像素会绘制出来,其他的像素会被EarlyZ剔除掉。
PreZ的好处就是降低了Overdraw,代价是多绘制了一次场景,Draw和顶点都翻倍了。由于正常的场景GPU瓶颈都在像素着色器,因此大多数情况下PreZ都是有优化的。一般对于PC而言,使用PreZpass是很好的降低Overdraw的手段。而移动平台刚好相反,移动GPU都有隐面剔除技术,不透明物体本身就不存在Overdraw,而且TBR架构对DrawCall和顶点数量敏感,因此移动平台不需要PreZ pass,而且如果游戏本身的性能瓶颈就在顶点着色器的话,PreZ会进一步的增加顶点压力。
具体是否需要PreZpass是需要经过实际的真机测试结果为准。

