原文地址:Benchmarking floating-point precision in mobile GPUs
原作者:Tom Olson
谈到 GPU 性能(这是 Mali 中心老生常谈的话题)时,我们通常会谈论速度。在之前的博文中,我们谈论了每秒可在屏幕中显示的像素数,每秒可以宣称绘制的三角形数(不要问),最近我们还谈论了每秒可以发布的浮点运算数。谈论速度富有乐趣,我们乐此不疲,但这并非人们感兴趣的唯一 GPU 性能指标;质量也很重要。毕竟,如果获得错误的结果,计算速度再快也没用。在本人接下来的几篇博文中,将(暂时)放下对速度的痴迷,谈谈对 GPU 浮点运算质量的基准测试。要谈论的内容有许多,篇幅肯定会很长。所以请保存下来,有时间再细细阅读吧。
不过,先容我啰嗦一句...
浮点算法使用起来比较棘手。许多程序员都不能真正地理解它,即便是一些非常优秀的也不例外。一些看似正确的代码可能得不到正确的结果,而且原因往往难以找到。甚至在为运行良好的 IEEE-754 兼容 CPU 编写代码时,也会存在该问题。当面对具有更多特性(其实是 GPU)的设备时,你想当然地就会认为看到的不正常现象是由于运算上出错而导致。但事实未必如此;可能只是因为你对正常结果的直观概念是错误的。
如果你准备利用浮点运算做些比较前卫的事情 – 当然质量基准测试就属于这一类别 – 最好习惯于去思考浮点单元中究竟会发生什么,也就是说要更为细致地理解浮点的运算原理,可能要超过你真正想要达到的水平。搞定它,黑客!
比你想的更多的细节
如果你已了解浮点的运算原理,可以跳过此部分;如果没有,这里有几篇关于 IEEE-754 和单精度浮点的维基百科文章,你真的应该读一下这些优秀文章。不过,对本文而言,你真正需要了解的只是下文:
基本的浮点数包含一个符号位 n,几个指数位,还有几个有效数位。(有些人不称有效数而将其称之为尾数;我有些喜欢它的发音“尾数,尾数”,但它如今已成为一种怀旧的说法。好吧。)如果使用典型的 FP32 浮点数,则将包含 8 位指数和 23 位有效数。指数(逻辑上)的范围为 -126 到 +127;此处我将逻辑值写为 E。有效数是二进制定点数,我写为 1.sss...,值就是 。最后,整个浮点数的值按如下方法算出:
my_value= (-1)n × 2E × 1.sssssssssssssssssssssss
由于有效数的位数是有限的,数字表示的精度就存在限制。
假设我们要将 16000000 和 11.3125 这两个数字相加:
16000000= (-1)0 × 223 × 1.11101000010010000000000
11.31250= (-1)0 × 23 0× 1.01101010000000000000000
首先要做的就是右移位(也叫非规范化)较小数字的有效数,使它们的指数相等。在本例中,我们得移位 20 位:
11.31250= (-1)0 × 223 × 0.00000000000000000001011(010100...00)
... 然后,将有效数相加获得结果:
16000011= (-1)0 × 223 × 1.11101000010010000001011
... 最后,根据需要重新进行规范化,但本例中不需要。
请注意,由于较小数的某些位(上文红色表示)移位至有效数末尾之外而被丢弃,因此我们的结果偏差了 0.3125;这就是在进行浮点运算时丢失精度的常见方式。要相加的两个数的指数差越大,丢失的位就越多。
GPU 中的浮点精度
现在,我们已准备好开始讨论 GPU 中的浮点。我谈论这一主题的灵感源自 Stuart Russell 发表在 Youi Labs 网站上的文章。他比较了六款移动 GPU 以及一款台式显卡,得到一些有趣的发现。我先回顾一下他的结果。本人早前说过浮点是棘手的,正确的行为可能会产生不直观的结果... 这得到了证实。
Stuart 利用精心设计的 OpenGL ES 2.0 片段(像素)着色器进行了对比。我的版本如下所示;它稍有不同,但这些修改不会影响结果。他的博文包含着色器在每款设备上生成内容的图片本人强烈建议花点时间看看这些图片。结果中存在显著的差异。它们全都是 OpenGL ES 2.0 兼容设备,但 OpenGL ES 对浮点运算的定义非常松散。这对于一般的图形应用程序来说不是问题,但测试着色器经过了精心设计,它对浮点范围内暗处发生的情况比较敏感。
//Youi Labs GPU precision shader (slightly modified)precisionhighp float;uniformvec2 resolution;voidmain( 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 );}
//Youi Labs GPU precision shader (slightly modified)
precisionhighp float;
uniformvec2 resolution;
voidmain( 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 );
}
看得出移动 GPU 中存在许多差异;告诉我一些以前不知道的。例如,什么类型的差异?事实证明,Stuart 的结果中有多个不同(基本上互不相关的)的地方。我从最简单的开始:所有图像都将屏幕画面划分多个水平条,但条纹的数量从最少 10 个到最多 23 个不等。原因?
为了回答这个问题,我们需要仔细看看测试着色器。
测试着色器的行为上述着色器在图像的每个像素上运行。内置的输入变量 gl_FragCoord 提供 x 和 y 像素坐标。函数的第一行(变量 y)将图像划分为 26 个水平条纹,其中 y 的整数部分告诉你当前像素位于哪一个条纹(0 到 25),小数部分告诉你它在条纹上方多远处。第二行(变量 x)计算浓度值,从图像最左侧的接近 1.0(白色)到最右侧的接近 0.0(黑色)呈线性方式变化。第 4 和第 5 行将每个条纹的上部 10% 像素变为黑色,让你容易看清条纹数量。
有趣的地方在第 3 行:
float b = fract(pow( 2.0, floor(y) ) + x );
内置的 pow() 函数返回整数:第一条纹 20,第二条 21,第三条 22,直至最后一条 225。该(整数)值与浓度 x,相加,两者之和的整数部分被 fract() 函数丢弃。
我们已知道在将两个不同大小的浮点数相加时会发生什么:较小数的低阶位会被丢弃。因此,当着色器丢弃整数部分时,留下的部分为原始的浓度 x,除了一些低阶位被丢弃之外;我们在第一条少了 0 位,第二条少了 1 位,以此类推。结果导致浓度在灰度的数量级上越来越小,光滑的斜坡变得越来越凹凸不平。当指数差等于有效数的位数时,x 的所有位都被丢弃,我们就看不到条纹了。由于 x 始终小于 1,其浮点指数最多为 -1;因此,如果你进行简单的三级运算(引入负数时?),你会确信图像中非黑色条纹的数量正好等于着色器引擎的浮点有效数小数部分的位数。很棒
尾数、尾数
所以,这些图像告诉我们的第一点是不同的 GPU 在有效数的位数上有所不同。似乎分成两大阵营:极简派,仅提供 OpenGL ES 2.0 要求的位数;以及奢侈派模型,提供接近于 FP32 的位数。我们来分开考虑。
比较中的两款 GPU 采取了极简派方式:ARM MaliTM-400 拥有 10 位有效数,NVIDIA 的 Tegra 3拥有 13 位,两者都大约是其他四款 GPU 提供位数的一半。区别很大 - 怎么回事呢?
原因在于 OpenGL ES 2.0(或者说 GLSL ES 1.0 着色语言)定义了三种不同的浮点数:highp、mediump 和 lowp。第一种 (highp)有至少 7 位指数和 16 位有效数,而第二种 (mediump)则拥有至少 5 位指数和 10 位有效数。(第三种 (lowp)实际上根本算不上浮点数;最小的实施是 10 位定点数,小数精度为 8 位。)务必要认识到这些是最小值;需要的话,可以完全自由地实施 64 位浮点的 lowp。
更为重要的是要认识到,在 OpenGL ES 2.0,片段着色器对 highp 精度的支持是可选的。Mali-400和 Tegra 3 不支持 highp,其他四种 GPU 则是支持的。为何不同?其他四款 GPU 是统一着色器架构;它们对顶点和片段着色使用相同的计算引擎。OpenGL ES 2.0 要求在顶点着色器中支持 highp;由于必须为顶点着色提供这一支持,同时提供给片段着色在这些结构上不会增加硅面积成本。Mali-400 和 Tegra 3 使用非统一着色器,意味着它们对顶点着色和片段着色使用不同的计算引擎。这允许它们为必须执行的任务优化各个引擎。支持 highp 在硅面积和功耗上成本昂贵,而且并不是标准要求,所以放弃它似乎是这些架构理所当然的选择。编写良好的 OpenGL ES 2.0 内容并不需要它,丢弃它可以实现非常、非常高效的核心。
对于如何为不支持 highp 的 GPU 编写代码还有许多要了解的地方;如果需要更加全面的讨论,请参阅 Sean Ellis针对该主题撰写的博文。
Puttin'on the Bitz
(对不起,我控制不住自己。)
现在,我们来看看奢侈派模型。在 Stuart的结果中,如果你放大图像并且仔细数的话,你会看到 Qualcomm 的 Adreno 225 有 21 条,ARM 的 Mali-T604 有 22 条Vivante 和 Imagination 核心有 23 条。这是否表示 GC2000 和 SGX544 的精度比 Mali 和 Adreno 高?
当 Stuart 的博文发表出来时,这个问题让我辗转反侧。最后,我注意到除了屏幕底部有标准的 Android 导航栏之外,Mali-T604 图像在屏幕顶部还有一个状态栏。Adreno 225 图像中的要厚一点,GC2000 和 SGX544 图像中则没有。嗯... 都来看看吧,我们的 Android 黑客。事实证明,如果你不够仔细,Android 状态栏可以混合到你所称的全屏应用的上方;或许,它们覆盖了其中一些条纹?好吧,我承认,这是我重新实施 Stuart 的着色器的真正原因。我必须搞清楚!
图 1 显示了在配备 Mali-T604 的 Nexus 10 上运行该着色器的结果,以及在使用 Qualcomm Adreno 225 的 Samsung Galaxy SIII(美国版)上运行的结果。(我们在实施中使用 GL 坐标,而不是 DX 坐标,因此我们的图像与 Stuart 的上下颠倒;如果这对你有干扰,那就倒立着看它们吧。)如果你不喜欢数条纹的话,这些图像表明,这两款 GPU 实际上在其有效数中有 23 个小数位,与 Imagination 和 Vivante 核心相同。也就是说:所有这些 GPU 提供完全相同的原始精度。
图 1:测试着色器在 Mali-T604(Nexus 10,左侧)和 Adreno 225(Samsung Galaxy SIII,右侧)上运行
不言而喻的事实我们解答了 Stuart 图像中条纹数告诉你的问题:它是片段着色器有效数中小数位的数量。Mali-400 有 10 位,正如你从使用 IEEE-754 半精度 (binary16) 作为其浮点类型的设备上所预期的。Adreno 225、GC4000、Mali-T604 和 SGX544 都提供 23 位,表示它们提供的位数与 IEEE-754 单精度 (binary32) 接近。Tegra 3 有效数拥有 13 个小数位,从我的了解来看是 NVIDIA 所独一无二的。
不过,如果你看看 Stuart 的图像,条纹数并不是你首先注意到的地方。首先跳入你眼帘的是这些条纹排列成形状各不相同的图案。其中一些(如上图 1 中的 Mali-T604)组成了对称的碗或蜂巢状;而 Adreno 225 等另一些则紧靠在图像左侧并且逐渐以曲线方式向右分散;Imagination SGX544 的图形则完全别具一格。怎么回事呢?答案很是有趣,但这篇博文已经够长了,今天到此为止吧。在下一篇中,我们将探索那些不同之处,看看它们会告诉我们有关这些 GPU 的什么信息。
在那之前 - 认为我上面所说的有错吗?认为我在胡说八道吗?那就给我留言吧...