原文地址:Benchmarking floating-point precision in mobile GPUs - Part II
作者:tomolson
这是有关 GPU 中浮点质量的一系列博文中的第二篇,我的灵感源自 Stuart Russell 发表于 Youi Labs 网站的文章。在第1 部分中,我宣称许多程序员其实并不真正了解浮点数,也指出如果你准备将它用于比较棘手的东西,那么最好先准备好更加深入地了解其运作原理,甚至要超过你所希望达到的深度。我介绍了 Stuart 的测试,也说明它披露了 GPU 片段着色器中所用浮点精度的位数。这很有趣,但这个测试告诉我们的不止这些。在本文中,我将对此进行阐述。
测试与结果
Stuart 的测试项目使用特殊的片段着色器来计算屏幕中每一像素的灰度浓度值。作为提醒,如下为我的版本。
precision highp float;
uniform vec2 resolution;
void main( void){ float y = ( gl_FragCoord.y / resolution.y ) * 26.0; float x = 1.0 "" ( gl_FragCoord.x / resolution.x ); float b = fract( pow( 2.0, floor(y) ) + x ); if(fract(y) >= 0.9) b = 0.0; gl_FragColor = vec4(b, b, b, 1.0 );}
方框 1:Youi Labs GPU 精度着色器(稍有修改)
在前一篇博文中,我已详细说明了这一段代码,所以这里仅总结一下:着色器绘制由 26 个水平条纹组成的序列。每一条纹的灰度值理想状态下是从左侧 1.0(白色)到右侧 0.0(黑色)的线性斜坡。不过,灰度值受到了破坏,首先它被加上了 2B(其中 B是该像素所处条纹的索引),而后又被丢弃了相加结果中的整数部分。这使得每一后续条纹中的灰度值精度减少 1 位,导致斜坡变得越来越凹凸不平。最后,所有位都被丢弃,条纹也变成全黑色。
在 Stuart 的博文中,他公布了这一着色器在 6 款移动 GPU 和 1 款高端桌面显卡上所绘制图像的图片。图像的差异主要在两个方面。其一是非黑色条纹的数量;如我们上次发现的,该数字最终等于着色器引擎浮点有效数中小数位的数量。另一方面或许更引人注目:这些条纹在屏幕上生成的图案大有不同。这就是我今天要讨论的问题。
看看这些图像,它们似乎分成两大阵营:第一阵营由 Nvidia Tegra 3、Vivante GC4000 和 Qualcomm Adreno 225 组成,它们生成的条纹一直到屏幕左边缘都是白色,往右逐渐消失。其形状让我想起了虎鲸的背鳍,所以我把它叫做“逆戟鲸”图案(见图 1)。另一阵营由 NVIDIA 桌面 GPU 和两款 ARM MaliTM 设备组成,它们生成对称的图案,我把它叫做“蜂窝”形(见图 2)。(Imagination SGX544 的表现稍有不同,但似乎也位列“蜂窝”阵营。)这些形状告诉我们什么?一方优于另一方吗?
图 1:“逆戟鲸”图案(华为 Ascend D1 / Vivante GC4000)图 2:“蜂窝”图案 (Nexus 10 / Mali-T604)
在 Stuart 的博文中,他将许多条纹一直到屏幕左边缘都显示为白色视为良好的浮点质量。所以,他非常喜欢“逆戟鲸”GPU,对“蜂窝”阵营则不感冒。他尤其说道:
“左边缘的漂移表明计算中存在误差(本是白色的区域却为黑色),如果不加以考虑的话,将转换为令人不悦的视觉差错。”
他是正确的吗?要得出结果,我们必须探究在着色器运行时 GPU 的浮点单元内部会发生些什么;但在这之前,我们必须更进一步研究浮点的运作原理。
比你真正希望的还要细致,第 2 部分
在本系列的第 1 部分中,我快速介绍了通用单精度浮点格式,其带有 8 位指数和 24 位(包括隐藏位)有效数。最后我举了个例子,说明在将两个不同量度的数字相加时会发生什么,例如 8000000 加 11.3125。我们从这说起:
(-1)0 x 222 x1.11101000010010000000000 = 8000000.0
(-1)0 x 23 0x1.01101010000000000000000 = 11.3125
然后对齐二进制小数点,即向右偏移较小数字 19 位。在这之后,较小数字的二进制小数点左侧不再是平常的“1”位,所以我们说它被非规范化了。我们要相加的两个数字现在如下所示:
(-1)0 x 222 x1.11101000010010000000000
(-1)0 x 222 x0.00000000000000000010110(1010...0)
两者之和显而易见
(-1)0 x 222 x1.11101000010010000010110(1010...0) = 8000011.3125
请注意,红色位不再能够装入有效数中。问题是我们该怎么处理它们?最简单的做法是把它们丢掉;在数学中,这叫做向零取整 (RTZ) 或截尾。这相当于假装红色位全都是零,尽管它们不是。将一转换为零会带来误差;在本例中,向零取整的结果是
(-1)0x 222 x 1.11101000010010000010110 = 8000011.0
总误差是 0.3125。想一下,如果所有红色位最初都是 1,就会出现最糟糕的误差,此时我们给有效数带来的误差是
或者,大约是 2-23。
如果愿意稍微用心一点的话,我们可以做得更好。我们可以不丢弃红色位,而是将它们向上或向下取整到 24 位有效数更接近的值。这么做被证明并不难:如果第一个红色位是零,我们与上述一样截尾(向下取整)。如果是 1,并且至少还有一个红色位是 1,我们向上取整。在上例中,我们理想的相加结果是
(-1)0× 222 × 1.11101000010010000010110(1010...0) = 8000011.3125
向上取整为
(-1)0x 222 x 1.11101000010010000010111 = 8000011.5
总误差是 0.1875,比向零取整的结果稍微好一点。如果第一个红色位是 1,而其他红色位都不是,正好在两个可代表的值中间;此时我们该怎么做?可能有各种打破僵局的规则;首选规则(也是IEEE-754-2008 要求的默认规则)是朝着可以在有效数最低有效位中产生零的方向取整。这称为最近偶数取整 (RNE)。如果使用这一规则(或任何其他最近取整规则),最坏的误差是 2-24,而不是 2-23。这似乎没多少改善,但想想看:使用 RNE 而非 RTZ 可将最坏的误差砍掉一半。这很了不起;几乎像是免费获得了额外的一位精度。
总结时间
这与 Stuart Russell 的图像中的“逆戟鲸”和“蜂窝”又有什么关系呢?他的着色器(见上图方框 1)所做的大致与我们在上一小节的示例中所做的相同:它将一系列逐渐变大的整数加到 1.0 到 0.0 之间的一组灰度值上,导致精度误差逐渐变大。我们思考第 23 个条纹中发生了什么,在此我们要将灰度值与 222 相加。2 的幂表示为
(-1)0x 222 x 1.00000000000000000000000 = 4194304.0
我们在浮点数字系统中可以表示的下一个最大值是
(-1)0x 222 x 1.00000000000000000000001 = 4194304.5
再下一个最大值是
(-1)0x 222 × 1.00000000000000000000010 = 4194305.0
我们要与 222 相加的灰度值在零和 1 之间,所以浮点单元显然要将两者之和向三个值之一取整。做完加法后,着色器丢弃结果中的整数部分,所以剩余的仅仅是两个可能结果之一:0.0 或 0.5。
使用 RTZ 的 GPU 始终将正数值向下取整。所以,如果灰度值小于 0.5,结果将向下取整到 4194304.0,最终输出的灰度值为 0.0。如果灰度值大于 0.5,结果将(再次向下)取整到 4194304.5,最终输出的灰度值为 0.5。看看图 1 中最上方的可见条纹,这就是我们所发现的现象;条纹的右半部分(起始灰度值小于 0.5)变为黑色,右半部分(起始灰度值大于 0.5)变为 50% 灰色。“逆戟鲸”GPU 使用的是向零取整!
另一方面,使用 RNE 的 GPU 将结果取整到它可表示的最接近值。当灰度值小于 0.25 时,相加结果将向下取整到 4194304.0,因而生成黑色。当灰度值在 0.25 到 0.75 之间时,相加结果将取整到 4194304.5,生成 50% 灰色。灰度值超过 0.75 时,相加结果将向上取整到 4194305.0,逻辑上与白色对应;但是,在丢弃了结果中的整数部分时,最终再次生成黑色。这就是 Stuart 在其博文中提到的“从左边缘漂移”,也是我们在图 2 中看到的。“蜂窝”GPU 使用的是最近取整。
为了让视觉化这一点变得稍微简单一些,我们可以修改着色器,以便在相加结果取整到整数时保留所生成的 1.0 灰度值。方框 2 显示这一代码,图 3 则显示了在另一款“蜂窝”GPU(AMD 桌面产品 (Radeon HD3650))上的运行结果。与图 2 相比,条纹现在可以一直延伸到图像的左边缘,而且出现了一个额外的第 24 条纹,其与最近取整(似乎)给我们带来的“额外一位精度”相对应。
void main( void)
{ float y = ( gl_FragCoord.y / resolution.y ) * 26.0; float x = 1.0 — ( gl_FragCoord.x / resolution.x ); float p = pow( 2.0, floor(y) ); float b = ( p + x ) - p; if(fract(y) >= 0.9) b = 0.0; gl_FragColor = vec4(b, b, b, 1.0 );}
方框 2:精度着色器修改后可在范围 [0.0, 1.0] 中生成输出
看这些图片是有趣的,但在本例中,如果我们标绘出“逆戟鲸”和“蜂窝”GPU 上部几个条纹的输入和输出灰度值,则更容易发现区别。
图 4 显示所获得的结果。(你看到的与图 1 和图 3 中的数据相同,至少对于第 22-24 个条纹是如此 — 我们仅将它看作图形,而不是灰度值。)我们看到了什么?RNE 输出和输入的近似程度要优于 RTZ 输出;而且,其平均误差为零,而 RTZ 输出则有偏离(即平均误差不是零)。
还不确信吗?在图 5 中,我标绘出了 RTZ 和 RNE 曲线中的误差 — 即输出与输入之间差异的绝对值。如果稍作研究,在脑海中整合曲线下方的区域,你会高兴(但不惊讶!)地发现,在平均水平上,RNE 方法产生的误差恰好是 RTZ 方法的一半。
图 3:着色器修改后允许范围 (0.0,1.0) 中的灰度
谁的 GPU 拥有质量最高的浮点单位?
现在,我们终于可以解答这个问题:Stuart 图像中的图形就他所测试的 GPU 中的浮点质量告诉了我们些什么?在他的观点中,它们意味着 RTZ GPU(具体而言,Vivante GC4000 和 Qualcomm Adreno 225)生成质量最高的输出。但事实相反:执行 RNE 取整的 GPU(如 ARM 的 Mali-T604)生成的结果更为准确,误差更小。这也是为何最近偶数取整被指定为 IEEE-754-2008 中的默认取整方式。Stuart 可以喜欢“逆戟鲸”图形而不是“蜂窝”;但这只能基于个人喜好,而不是质量。
然后呢?
对于 Stuart 的着色器,我真正喜欢的是它将比较难懂的浮点行为细节
转换为引人注目的视觉图像。我们可否编写着色器为 IEEE-754 的其他死角做些类似的事?可以!下一次,我们将窥探一下令人畏惧的零洞 (Zero Hole),看看一个能够表明你的 GPU 是否具备填补该漏洞的能力的着色器。在这之前 — 想要告诉问我为何定向取整确实要比最近取整好吗?想要为最近奇数取整来场充满激情的辩护吗?请与我联系…