Contents

[译]Low-level Thinking in High-level Shading Languages

很多聪明人写的代码,本可以以更好的方式编写。

偶尔我会听到“这是未优化的”(this is unoptimized)或者这只是个“例子”(educational example)作为借口,但大多数时候这个借口都不成立。因为他们其实不知道如何做到正确。 此外,供应商的SDK示例中的代码也不总是正确的。当最优秀的牛人都做得不好时,这是一个行业的问题。

1
(x – 0.3) * 2.5 = x * 2.5 + (-0.75)

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412214416.png

很久之前,我们需要写汇编,从2003年之后的一切都是HLSL或者GLSL。这当然是一种进步,我们使用更高层次的抽象语言进行shading。这没毛病,但是,随着硬件与我们正在使用的抽象内容之间的差距的扩大,人们与硬件失去联系的风险就越来越大了。如果我们只看到HLSL代码,而从来没有不知道GPU运行的是什么,这将是个严重的问题。本文要传达的信息是,在使用高级着色语言时保持低级别(Low-Level)心态对于编写高性能着色器至关重要。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412215135.png

这表现的很清楚,为什么我们应该使用低级别的思考。我们只移动一些东西,加一些括号,就能实现一个更快的着色器。这是通过理解底层硬件并将HLSL构造映射到它从而来实现的。

本文使用的硬件是Radeon HD4870((用于生成最易读的反汇编代码)),但本文中的大部分都是通用的,除非另有说明否则适用于任何GPU。硬件之间具有诸多的不同。 即使您没有观察到特定GPU的性能提高,也有可能对其他的GPU有所提升。GPU有大量的ALU。如果我们将ALU的利用率从50%降低到25%,这却并不一定能够提高性能。因为同时其会受到其他因素的限制(贴图和内存带宽等)因此可能不会提高性能,但可以让你降低功耗,并且为新feature留出更多空间。最后如果你解决了TEX/BW,会给你带来相对于的收益。

“编译器将优化它”

  1. 编译器不懂你的思想。编译器只理解着色器中的操作的语义。他们不知道你在努力完成什么。许多可能的优化都“不安全”,因此必须由人来完成。
  2. 他们没有整张贴图数据
  3. 他们只有有限数据
  4. 他们不能打破常规

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412221619.png 这可能认为的最简单的代码示例,您可能认为它可以自动优化为使用MAD指令,而不是ADD+MUL,因为这两个常量都是立即数。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412221834.png

然而编译器并不是这么想的。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412222447.png 驱动程序仅限于传递的D3D字节码的语义。最终在GPU上执行的代码就是你写在着色器上的内容!大多数情况下你可以在PS3看到一样的结果。但是上面这种情况例外,可能是因为那里的常数为1.0f。其他的情况都不会变为MAD。

Xbox360着色器编译器很有趣。 它什么都不在乎。 即使你明显破坏了数字,你也会一直做这个优化。 即使它导致常量溢出或下溢为零,也无关紧要。

  1. 如果上述式子中1的位置是一个常数,GOGOGO。上车,直接优化
  2. 如果上述式子中1的位置是0,这样的话MUL不就好了吗? 太棒了!

所以当然会产生很多小误差。 如果在此转换中丢失了许多浮点数精度,则不会立即注意到为什么会发生这种情况。

我们在这里处理IEEE的浮点数。更改操作顺序并不安全。如果你运气好,你会得到同样的结果。 改变顺序更可能会提高精确度。但是,根据数值的不同,并不总是行得通。 在最坏的情况下,它可能会被上溢或下溢破坏,或者如果不进行优化,它可能会在正确运行的部分返回NaN。一般来说,编译器擅长于:移除死代码,消除未使用的资源,折叠常数,寄存器分配,代码调度;但通常不会:更改代码的含义、中断依赖关系、违反规则。

例如我们的x = 0.2f

  1. sqrt(0.1f * (0.2f - x)) ,returns 0
  2. sqrt(0.02f - 0.1f * x) ,returns NaN

这种差异是因为第二个表达式将非常小的负值传递给sqrt。 请记住,0.1f、0.2f和0.02f都不能用IEEE浮点数正确表示。这个偏差来自于有适当的四舍五入的常数。 编译器不可能预测未知输入的这些失败。因此我们需要让硬件知道我们要干什么。相信着色器编译器会很好地修复事情,真是太天真了。虽然D3D编译器允许自己在编译时忽略INF和NaN的可能性(这通常对于游戏开发是可取的),但这并不意味着驱动程序在运行时被允许这样做。如果D3D字节码说“乘以零”,那么这正是GPU最终要做的事情。虽然D3D编译器可以在编译期忽略INF和NaN的可能性(这通常有利于游戏开发),但驱动程序并不可以在运行时做同样的事情。 如果D3D字节码说“乘以0”,GPU将正确地执行它。

1
x* 0 = 0,但是NaN * 0=NaN

因此,我们关于硬件的通用事实是

  1. Multiply-add 是一条指令。Add-multiply是两条
  2. abs, negate ,saturate是free
  3. 标量操作比矢量操作使用更少的资源
  4. 仅涉及常量的着色器数学是疯狂的
  5. 不做事比做事快

MAD

任何一个线性的Remap都能表达为MAD的形式。当然MAD并不总是最直观的形式。

1
MAD=x*slop+offset_at_zero

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412232021.png 此处显示的示例代码是彩色的,以便您可以清楚地知道哪些是slope,哪些是offset。 左边的公式是直观的写法,右边的公式是优化的写法。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412232710.png

我们还有其他的变形。 这些都是基本数学,没有做什么特别的事情。 最后一个例子可能有点吃惊,左边是三指令(MUL-ADD-ADD),右边是两指令(MAD-MAD)。

Division

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412234056.png

由于大多数硬件使用倒数乘法来实现除法,因此应该重写除法表达式,利用MAD得到具有该乘法的加法形式。 不幸的是,这种优化机会往往被忽视。

如果你粗略地看一下,你会认为这只是一个中间值和范围的计算,就像前面的幻灯片一样,但这是不一样的。 如果代码是以MAD的形式编写的,你应该更清楚地知道。 然而,为了这个代码的荣誉,至少实际的计算是正确实现的。 然而,一个熟练的着色器程序员可能会认为,可以直观地将该表达式合并到单个MAD中。

MADness

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412233633.png 如果你粗略地看一下这个代码,你会觉得它只是一个中间值和范围的计算,但这并不是。 如果代码是以MAD的形式编写的,这将立即很明显。 当然它实现至少对其实际计算进行了正确的注释。 即便如此,一个经验丰富的图形开发者也应该直觉地感到,这个表达式可以归结为一个MAD。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412233716.png

如果你努力简化公式,你就会发现这是一个简单的MAD计算。 只要找到scale和offset,就会发现中间值和范围的计算是不匹配的。

Modifiers

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412234031.png

abs()可以用于输入而不是输出。 如果将abs()用于输出,则需要另一个指令来执行它。 如果要对绝对值返回的值进行其他处理,则abs()被扩展为下一个操作的输入修饰符。 但是,如果不对绝对值的计算做任何进一步的操作,编译器就会插入MOV指令。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412234019.png

负号跟上述相同。

saturate()输入输出都可以,而min() & max()则不能用于输入可以用于输出。因此我们应该多用saturate。不幸的是,HLSL编译器有时会做相反的事情。

saturate(dot(a, a)) → “Yay! dot(a, a) 永远是正的” → min(dot(a, a), 1.0f)

解决方案:

  1. 模糊编译器的能够确认的值的实际范围
  2. Use precise keyword

大多数情况下,HLSL编译器不知道变量中可能的值范围。 但是,已知saturate()和frac()的结果在[0,1]中,在某些情况下,由于数学原因(忽略NaN),它可以知道变量是非负值还是非正值。 也可以声明unorm float(范围[0,1])和snorm float(范围[-1,1])变量,以告知编译器期望的范围。 考虑到带有saturate()诡计,在许多情况下,这些提示实际上可能没有进行优化。

使用precise keyword

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210412235515.png

precise关键字有效的原因是它强制表达式具有IEEE Strictness。 saturate(x)定义为min(max(x,0.f),1.0f)。 如果x是NaN,则结果为0。 这符合IEEE-754-2008的使用,即当参数NaN被传递给min或max时,它返回另一个参数。 因此,由于max(NaN,0.0 f)=0.0 f,因此避免了与1.0f的min(),如左图所示。

Built-in functions

  1. rcp(), rsqrt(), sqrt()直接对应一条硬件指令,其他等价的数学可能不是最优的
  2. exp2() log2() 有对应的硬件指令, exp() and log()没有
  3. pow(x,y)可以实现为exp2(log2(x)* y)
  4. sign(x) * y → (x >= 0)? y : -ysign()条件分配比GPU的早期更快。 使用sign()或step()的好理由很少。条件分配不仅速度快,而且可读性好。
  5. sin(), cos(), sincos() 有对应硬件指令
  6. asin(), acos(), atan(), atan2(), degrees(), radians()。 虽然有有效的用途,但是如果你计算了很多角度,那就是你数学不太好的信号。 比如内积,应该有更精炼、更快的解决方法。 当出现倒三角函数时,几乎可以保证你犯了错误。 度数,滚
  7. cosh(), sinh(), log10(),你是谁?

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413000524.png

  1. w值为1.0 f的情况很常见。 对于着色器编译器,应该在着色器中显式写入,而不是依赖于来自顶点提取的隐式1.0 f。 不幸的是,默认情况下,这不是MAD-MAD-MAD。 但是,您可以通过分解mul()并使用括号来实现它。 为了便于阅读,您还可以创建自己的mul()类函数。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413000637.png

  1. 由于硬件上的常数有读取端口限制。

Matrix math

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413000949.png

我们将屏幕空间中的纹理坐标和深度转换为世界空间中的坐标,以用于计算光源向量。 这些变换可以合并到同一个矩阵中。我们曾经通过合并转换,实际获得了2倍以上的性能提升。

Scalar math

现代硬件具有标量ALU,标量数学总是比矢量数学快.所有NVIDIA的DX10及更高版本的GPU都是基于标量的。 AMD的GCN体系结构(HD 7000系列)也是基于标量的。 早期的AMD DX10和DX11 GPU是VLIW。 DX9 GPU基于AMD和NVIDIA的矢量。 这包括PS3和Xbox360。

Mixed scalar/vector math

例如我们 normalize()、length()(、distance()等都调用dot()。 当这些函数混合在一起并且它们具有匹配部分时,编译器仅调用一次,但仅当它们完全匹配时才调用它们。 例如,如果代码中有一个名为length(a-b)的表达式,则distance(a,b)将重用子表达式,而不是distance(b,a)。

我们当然可以人为的去构造共有部分。

Hidden scalar math

1
normalize(vec) = vec * rsqrt(dot(vec, vec))

代替normalize(),您可以创建normfactor()来计算标量值的归一化系数。 其他标量系数可以在与向量相乘之前与此规格化系数相乘。

dot()返回标量,rsqrt()仍然标量.我们可以单独处理原始矢量和归一化因子。如果您支持PS3,请仔细检查它,因为它有一个内置的normalize(),它可能会由于各种因素而变得更快。

剩下的例如:

1
reflect(i, n) = i – 2.0f * dot(i, n) * n

对于lerp(a, b, c)来说,如果c和a或b都是标量,那么bc+a(1-c)是更少的运算。

1
50.0f * normalize(vec) = 50.0f * (vec * rsqrt(dot(vec, vec)))

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413002435.png

Hidden common sub-expressions

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413002507.png

这又是另一个例子。 内积计算是共享的,因为它具有匹配的子表达式。 但是,编译器没有很好地利用sqrt(x)和rsqrt(x)之间的数学关系。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413002607.png

  1. Expand expressions,明显的优化,即移除对sqrt()的调用,取而代之的是比较长度的平方。
  2. Unify expressions 让我们把公式合并起来。
  3. Extract sub-exp and flatten在集成表达式之后,您可以提取归一化系数并用clamp来代替if语句。
  4. Replace clamp with saturate 此外,clamp将替换为saturate(),因为不会出现负数。
  5. HLSL compiler workaround,最后,HLSL编译器会很脑残,所以在precise中采取措施。

Evaluation order

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413003544.png

不幸的是,这种优化机会很容易被忽略,但它是最有效和最广泛的优化之一。 所有硬件都有好处,尤其是最新的。 这在PC和下一代平台上是决定性的,但在基于向量的体系结构(如现代控制台)上也有很好的改进。 这基本上不影响可读性,只是代码的重新排列。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210413003446.png

这是用于VLIW和向量体系结构的。 这在基于标量的架构中没有好处,但也不会成为缺点。 不会有任何影响。 我们在这里做的最基本的事情就是将链分割成树,以实现更多的并行处理。 运算的次数完全不变,但所需的指令槽减少,执行速度更快。