今天要介绍的是GDC2013上的一篇关于shader代码优化的技术分享,这里是原文链接,作者是Avalanche工作室的Emil Persson。
这个分享的起源在于Persson经常在各类顶级分享上看到一些写的十分不得体的shader,虽然这些shader的作者会用一些类似“未经优化”以及“测试demo”之类的借口来进行遮掩,但是据Persson观察,其实大部分的原因在于这些shader的作者不知道怎么优化。好了,闲话少说,跳过前面一些虚头巴脑的东西,我们直接进入实战。
背景早期游戏开发的时候,shader都是通过汇编编写的,输出的shader代码可以实现跟硬件的良好吻合,因此具有极高的运行效率,比如这里给的一个例子是将(x-0.3)0.25改写成了x0.25+(-0.75)从而实现了Mul+Add两条指令到MAD一条指令的优化。
演进随着开发进程与开发工具的演进,原有的汇编开发方式被扫入了历史的尘埃中,之后就是HLSL/GLSL等高级语言接管一切的时代。
高级shader语言的引入极大的加速了开发效率,但是带来的问题就是输出的shader再也无法实现与硬件的灵肉合一,因而在运行效率上就存在着一定的浪费。
示例从上面的例子我们可以看到,不同的写法对于最终shader执行的指令数有着极大的影响。
作用上面给出了shader指令优化的一些收益,虽然有些收益是肉眼看不到的,但是却是实实在在存在的,比如虽然单帧消耗没有降低,但是可能减少了发热之类,而这些收益的存在会使得产品的表现更加的突出。
编译器的弊端现在的一个误解就是,开发者无需画蛇添足,编译器会自动完成优化,但实际上编译器所能完成的优化是非常有限的,下面举一些例子来做下辅证:
示例上面例子中左侧的汇编是fxc输出的,可以看到依然是两条指令,而右边的则是驱动输出的,并没有做到任何的优化。实际上,驱动会受限于D3D提供的bytecode的语法,因此很多的优化是无法达成的。除了D3D之外,其他平台也有类似的表现。
顾虑那么为什么硬件不做这样的优化呢?自然不是因为做不了,而是因为一些表现问题,举个例子:
sqrt(0.1(0.2-x)),如果x=0.2,那么优化前的结果为0,而优化后就变成了sqrt(0.02-0.1x),在这种情况下由于浮点精度的原因时可能会导致输出结果为NaN的(为什么前一种方法不会?这是因为按照IEEE浮点表示方法,越靠近0点,精度越高)。
因此,总结来说,因为硬件无法预测输入,所以在一些优化上是存在局限的。进一步说,要想得到最佳的优化,只能靠开发者。
一些规则虽然D3D编译器会自动忽略NaN/INF等输入情况,从而据此完成一些优化(这种表现跟开发者预期是一致的),但是驱动在运行时却不会做这种假设。
硬件相关这里给出一些硬件所共有的常识(可能会有少数例外,比如ATI会提前进行Add从而抵消MAD带来的优化,当然不管怎么说,按照下面的优化写法编写shader都是有益的):
- MAD是单指令,而Mul+Add则是双指令
- abs/negate/saturate是无消耗的(除非强制触发了一条MOV指令,即赋值?)
- 尽可能的使用标量操作而非矢量操作
- 如果运算的成员只发生在常量之间,建议直接用运算好的结果替代,跳过这些浪费的指令
- 如果没有必要,尽量减少shader的计算,不做计算永远比做计算要简单高效。
大部分的ramp(rangeRemap)功能都可以用一个MAD来完成,当然,其中因为输入数据的原因,可能会有一些变体。
更多MAD上面给出了更多的MAD使用示例。
除法通常来说,a/b可以转换为a * rcp(b),因为硬件对于rcp指令做了优化,因此其执行效率会高于直接的除法计算,虽然大部分硬件都会将除法转换成rcp操作,但是也存在例外,因此最佳的使用方式就是显示使用rcp替代除法操作。
一个案例这里给了一段看起来十分优雅的代码,变量的命名规则可以很容易的让后续开发者理解整个函数的意思,但是实际上这段代码的基本意思可以用一个MAD来表示,从而通过一个更为简洁且执行更为高效的方式来编写:
简化版本 abs如上图所示,abs/neg等指令最好施加在输入变量上,而非输出结果上,否则可能会需要一条额外的指令处理相关功能(当然,上面给的例子其实比较特殊,如果abs的结果并不是直接返回,而是用作另一条指令的输入,那么就不会多出后面的MOV指令,在这种情况下,这两种写法是等价的,但是如果像上面这种情况一样直接返回的话,编译器就会强制在后面加上一条MOV指令,从而导致一定浪费)。
Neg这里是negates指令的情况,表现跟abs完全一样。
saturatesaturate跟前面两种不同,最好是施加在输出数据上,否则如果变量不是从前面的其他计算结果中导出,而是直接从外部作为函数参数传入的话,就会多出一条MOV指令的浪费。
saturate替换min/max这里说到min/max是有计算消耗的,而saturate则是免费的,因此即使min/max已经够用,也尽量使用saturate来代替(当然,一些计算表现会出现差异的情况例外)。可惜的是有些编译器的优化恰恰是相反的,比如上面列举的HLSL编译器,出现这种情况的主要原因在于编译器不清楚变量的范围,因此在实际编写shader的时候尽量使用更为精确的修饰符来表示变量范围(比如unorm float通常表示[0, 1]而snorm float则表示[-1, 1])。
workaround针对HLSL编译器的负面优化,这里给出一种比较取巧的解决方案,即在输出结果前面添加precise修饰符(这是少有的几个使用precise反而提升运行效率的例子),而这个修饰符则会强制令此表达式严格遵循IEEE的规则。
由于saturate(x)的定义是min(max(x, 0.0f), 1.0f),如果x是NaN,那么结果就应该是0(根据IEEE-754-2008,当输入x为NaN时,使用min/max的返回值就是函数的另一个输入形参),而在这种情况下其运行结果就跟前面编译器添加了min指令的运行结果不一致了,因此就可以起到阻止编译器添加min指令的作用。
内置函数在shader语言中有几条内置的指令在所有平台上都有对应的硬件指令:rcp/rsqrt。而部分指令比如sqrt则只有部分平台上有对应的硬件实现(比如DX10+),对于这种情况,其他没有对应的硬件指令的平台会将之转换为rcp(rsqrt(x)),而实际上我们知道直接使用xrsqrt(x)执行效率会更高,因此在编写相应功能的时候,对于DX10+等平台可以直接使用sqrt,其他则建议写成xrsqrt(x)。
内置指令条件赋值语句在GPU上执行速度快,且可读性好,因此尽量使用sign而非step,此外在使用的过程中要注意输入为0时的输出(落在前面一个输出上)。
三角函数通常都有对应的硬件实现版本,因此并不是不能用(当然消耗依然很高),不过通常只在我们无法将三角函数展开换算成其他的实现方法的前提下使用,如果有更优解,那就尽量不要使用。
而反三角函数基本上没有硬件实现版本,因此是坚决不能用的,如果用了?那说明你可能没有找到这个问题的正确解。
内置函数在矩阵运算中,大部分时候向量的最后一维是1.0,在这种情况下最好显示指定为1.0,而不要想着编译器会自动进行优化。此外,即使写成了1.0,编译器在默认情况下可能也不会进行优化,最好还是用括号包起来省掉最前面的那个乘法指令。
内置函数上面给出优化前后的汇编指令表现,虽然最终的指令数并没有减少,但是优化后的版本释放了一些计算通道的消耗,而这些通道会用于其他的计算,相当于还是做到了性能优化。
矩阵乘法通过对运算进行顺序调整,将一部分通用代码放到CPU中执行,可以极大提高GPU的执行效率。
标量运算当代的GPU(比如NVIDIA DX10+)都是基于标量运算架构的,包含了大量的标量运算组件,因此在标量运算上通常要比矢量运算要快一些。而早期的GPU架构(比如Very Long Instruction Word (VLIW) )即使没有这类组件,尽可能的使用标量运算依然是有意义的,比如可以释放vector的部分通道用作其他运算等,而且从标量向矢量的转移与扩展非常的方便,可能基本上都没什么感知,因此在如果可以的情况下,尽量使用标量运算。
标量运算与矢量运算分开这里说到具体的优化策略,有如下两种思路:
- 从更低层次来思考,对算法进行重新规划:
1.1 将标量与矢量运算分开计算
1.2 对一些子运算的结果进行重用,比如dot/normalize/reflect/length/distance等运算最终都会用到一个dot计算,如果可以的话可以考虑进行重用,比如distance(a, b)与length(a - b)实际上是相同的,编译器在这种情况下可能会将两者等同看待,但是如果调换ab顺序,distance(b, a)虽然结果与length(a-b)是相同的,但是编译器就无法识别了。 - 将同一个计算公式中的标量与矢量分离出来,比如用括号等,不过这里需要注意计算顺序,别因此导致计算结果不一致。
如上图所示,这里以normalize为例(PS3平台上normalize是有硬件实现的,因此展开计算可能就不太合适),一些标准的矢量运算,实际上内部也包含着若干的标量运算,因此在一些使用情景中,这些标量运算是可以被借出去与其他运算相结合以提升运行效率的。
使用举例如上图所示,通过将乘法塞到标量运算中,可以减少矢量运算的数量,提升性能。
计算结果共享对于一些具有相同计算逻辑的功能(函数)来说,其中一些计算结果是可以共享的,可惜的是编译器并不能检测到所有的可以共享的情况,如上图所示,normalize跟length都会调用一个dot计算,而这个结果是可以共享的,但是由于编译器并没有抓住sqrt与rsqrt之间的倒数关系,使得此计算共享被打断,计算产生了浪费。
优化这里的优化可以参考上面这个路径完成。
指令对比这里的一个比较大的改动是unify这一步,在这一步上,if判断中的计算与后续的计算就完全重合,因此可以直接被编译器感知并优化。
最终优化结果最终优化完成后的指令数相对于最开始的指令数有了非常明显的进步,而且这种做法实际上还可以做进一步扩展,将这里的saturate改成clamp到任意范围,而这个做法只增加了一个标量乘法,但是放到原实现中,却需要增加三个乘法。
计算顺序shader实现中一个非常有用的优化策略是调整运算顺序,虽然这种做法看起来十分的不起眼,但是对于所有的GPU都能起到非常有用的效果,这里的顺序调整通常会基于如下两点
- 除了括号以及运算符优先级之外,指令的执行通常都是按照从左往右顺序完成的
- 通过使用括号以及将标量运算放到左侧等优先执行的顺序位置有助于提升运算的性能
上图中给出的就是按照这种策略优化后的效果对比。
评估顺序VLIW以及向量架构GPU对于执行顺序有着较强的约束,因此在优化上会显得束手束脚,在这种时候,使用括号以及调整顺序等手段可以得到非常明显的性能提升。
一个实际应用这里给出原作者在Clustered Deferred Shading工作上的应用效果,在这里列举了high-level与low-level两种层面的优化消耗与结果,可以看到在low-level上优化的性价比更高(感觉所有项目都应该搞一遍)。
其他优化除了前面列举的一些指令级别的优化之外,这里还给出了如下的一些优化建议:
- 善用branch/flatten/loop/unroll:
1.1 branch指的是将条件执行语句当成dynamic来看待,当条件满足时执行,不满足时跳过;在跳过逻辑下,会导致GPU的执行流程被打断,因此是存在一定的代价的,只有当条件后的执行指令数相当复杂时才建议开启
1.2 flatten是与branch相对的,指的是将条件执行语句各个分支的执行逻辑都运行一遍,最后根据条件选用对应分支的输出结果,当条件后面跟的执行指令比较简单时推荐使用这种方式
1.3 loop是针对循环指令for/while等的,相当于循环指令版的branch
1.4 unroll相当于循环指令版的flatten - 将一些不需要放在shader中执行的指令移除出去
- 将一些线性指令移动到VS中完成
- 尽可能的精简输出,降低带宽消耗
这里给出了一些在工作中持续进阶的一些建议,到这里为止整篇文章就介绍完了,希望大家都能有所收获。
网友评论