Yuens' blog
本文内容来自对IEEE 754标准中的特殊值NAN(非数)的调研,同时在该过程中会一起参考OpenCL标准,看看IEEE 754和OpenCL标准的关系,最后在Numpy、TensorFlow、PyTorch中实验NaN这一特殊值的行为。本文目录如下:
- 浮点表示
- 有符号整数的二进制表示
- NaN
- 与NaN的操作
- NaN的二进制编码
- NaN传播与转换规则
- 应用行为:NaN计算
- PyTorch和TensorFlow
- Python
- Adreno OpenCL
计算机对浮点数的表示都是对真实实数的近似,计算机系统必须小心计算机算术和真实世界算术的差距,程序员也务必小心近似值的含义。
计算机用于模拟近似真实世界的位模式,并没有内在的含义,它们代表的可能是有符号整数、无符号整数、浮点数、指令、字符串等,具体代表什么取决于对其操作的指令。
标准的存在使几乎每台计算机都遵循,简化浮点程序接口且提高运算质量,也是软硬件的接口。
浮点相较于定点,在字面意思上来说,float表示binary point是浮动的,而且定点的计算机二进制表示是基于固定的位模式,Fixed,定点每个二进制表示都是特定值,一个萝卜一个坑,因而没有Inf和NaN。浮点符号位(S)、尾数位(F)和指数位(E)打来的表示位宽的实数区间大。
如为了打包更多数据位,标准隐藏了规格化(规格化:用没有前导零的浮点记数法表示数;前导零:小数点左边的整数部分为0)二进制数小数点前面的1。
尾数表示一个0~1之间的数,E指明了指数字段的值,若从左到右将尾数各位标记位s1,s2,s3,…,则数的值为:
(-1)^S × [1 + s1×2^(-1) + s2×2^(-2) + s3×2^(-3) + …]×2^E
因此,数据有效位:
为了精确,用术语有效位(significand)表示24位或53位的数,即隐含的1加上尾数。特别说明一下0这个数值如下图,0没有前导1,指数保留为0,所以硬件不会将1加到尾数前面。那自然这样的表示法,因符号位的0或1取值让其具有正负。0是个例外,其他数表示不变。
特别说明,符号位在最左边的最高有效位上,是为了便于比较排序,相比整数定点数的分类,浮点稍微复杂,标准的这种记数法本质是符号与幅值的形式,而非补码。
既然说到这两个关键词(补码、符号与幅值),那回顾下有符号数的表示。
符号与幅值的表示法即字面意思,作为早期提出用来表示区分定点数表示中正负数的一种方法,其缺点如符号位的位置在左还是右、计算需要额外引入符号的设置步骤、单独的符号会给0值的表达带来正负性。而且还有个问题是,当用一个较小数减去较大数时如21 - 42
,会有借位行为,无符号的表示会让较小数前面的 0 中借位,导致前面的位变成一连串的 1 。
在没有其他更好的方案下,有符号二进制表示最终解决方案取决于硬件易于实现的方法(二进制补码):
21 - 42
的计算过程,原本的减法视为加法,即正 21 与负 42 相加。先将两个数以二进制表示
0b0001 0101
0b0010 1010
,然后按照上面讲的各位取反得0b1101 0101
,再加1得0b1101 0110
0b1110 1011
,符号为是1肯定是负数,即-210b0000 0000
(即20-1,0)到0b0111 1111
,即(27-1,127),后面是按绝对值递减的负数,从绝对值最大的负数0b1000 0000
(即-27,-128)到0b1111 1111
(即-1)上面这一段都是讲的有符号定点,再回到浮点数的表示,下面表格给出IEEE 754在单精度和双精度浮点数上的编码表示。
表示对象 | 单精度 | 双精度 | ||
---|---|---|---|---|
指数 | 尾数 | 指数 | 尾数 | |
0 | 0 | 0 | 0 | 0 |
±非规格化数 | 0 | 非0 | 0 | 非0 |
±浮点数 | 1~254 | 任何值 | 1~2046 | 任何值 |
±无穷 | 255 | 0 | 2047 | 0 |
NaN(非数) | 255 | 非0 | 2047 | 非0 |
表中想表达的信息:
0 / 0
或无穷减无穷。目的也是为了让计算无效的这一个结果得以保留,让程序员知道这里这个操作是无效的,可以在程序后续的过程中在做决断,或者在测试中使用发现一些特殊的情况。如二进制浮点数3.1415927
,在本文开始的图示中有表示其尾数位为1.5707964×2^1
。而非规格化(denormalized)或称为亚规格化(sub normalized)是为了进一步最大限度获取精度位,也是标准允许的,减小 0 和最小规格化数的间隙,非规格化数和 0 有相同的指数,但尾数非 0 ,非规格化允许有效位变小直到位 0 ,称为逐级下溢(gradual underflow),如正的单精度规格化数是
1.0000 0000 0000 0000 0000 000two × 2^(-126)
,two是以2为基数,
而最小单精度非规格化数是:
0.0000 0000 0000 0000 0000 001two × 2^(-126)
,即1.0two×2-149,
这种非规格化带来的麻烦,是对浮点单元的硬件设计角度而言。因此,许多计算机在操作数出现非规格化数时会产生异常,较给软件来完成相应操作,虽然软件可以处理但是低效。
非数由754-1985引入,其二进制表示为:指数位全1,尾数位非全0,对于半精度亦是如此。
下图是FP16的NaN二进制表示,符号位、指数位和尾数位分别用蓝色、绿色和红色表示,这里重点关注其数值,FP32和FP64类似。
说操作前,先说下标准中定义的NaN有两类:
下面关于静默和非静默分别举一些例子,与标准可能存在不同。
第一个例子,Intel架构对浮点行为有一个表格用来说明,下面加减乘除在SSE、AVX Scalar指令的行为,其中,Src1和Src2分别表示两个操作数:
可以看到只要两个操作数有sNaN的结果都是Invalid,而只有qNaN时qNaN被保留。
第二个例子,在某些硬件平台上,对同一个功能的指令会有不同的版本:静默和信号,当指令遇到某些值时会发出异常,而静默版本的不会。
第三个例子,OpenCL中有符号常量来表示无穷和静默非数(能表示的肯定是静默的),在单精度浮点的精度下表示为:
常量名 | 描述 |
---|---|
INFINITY | A constant expression of type float representing positive or unsigned infinity. |
NAN | A constant expression of type float representing a quiet NaN. |
OpenCL规范在对754标准上的遵循,有专门的说明,参考:https://registry.khronos.org/OpenCL/specs/3.0-unified/html/OpenCL_C.html#opencl-numerical-compliance。
再补充一点,OpenCL对边界场景的行为分为两类:遵循C99规范以及特别的。对Adreno GPU来说,遵循规则意味着一种平衡,下表是Adreno GPU OpenCL文档,可以看到性能方面为了支持754标准带来的折损:
表 Math function options based on precision/performance | Adreno OpenCL Guide
讲到这里,不得不扯远一点,754标准与性能的关系。
Adreno GPU有一个用于加速基本数学运算的硬件模块,称为基本函数单元(EFU,Elementary function unit),基于ALU性能最优,然后是EFU计算的函数,其次是通过EFU和ALU运算结合起来计算的函数,性能最不好的自然是编译器使用复杂算法仿真出来的。下表是Adreno GPU OpenCL函数列表,性能最好的是A类函数。
表 Performance of OpenCL math functions (IEEE 754 conformant) | Adreno OpenCL Guide
除了NaN的两个类型外,下面再介绍一些摘自754标准有关NaN的操作:
图 浮点Min/Max | ARMv8指令集概览
正如刚开始介绍的,对于编码是符号位不限制,且尾数位只要非0即可,这种灵活便携性,让NaN有不固定的二进制编码:
因为NaN的编码不唯一,自然引出“有效载荷”(Payload)这个概念,表示尾数位置除了最高的两位(通常区分qNaN和sNaN)外的其余位,如double的尾数位有52个,排除前面2位,有效载荷就是50。
再说符号位:
TotalOrder(x,y)
这么个谓词函数,用于定义特定格式的全序关系,两个数比较必然是三个关系其中之一(小于、等于、大于),这对于浮点数处理尤其存在特殊值(Inf和NaN)时很有用处,totalOrder可以正确处理带有特殊值的情况,标准定义了存在NaN时该函数的返回关系, 具体参考标准章节5.10 Details of totalOrder predicate
,本文不展开还是摘自标准的内容,想不出一个例子直接看还是有些乏味晦涩:
当运算中存在NaN和非NaN的比较时,深度学习框架的行为是关注的重点。后面的内容都以激活函数ReLU为例,这个太特别了,因为其实现是通过elementwise maximum实现,关键是754标准对逐个元素比较大小的行为,当存在NaN的时候是这么定义的:
For an operation with quiet NaN inputs, other than maximum and minimum operations, if a floating-point result is to be delivered the result shall be a quiet NaN which should be one of the input NaNs.
即当有NaN和非NaN时,返回另一个非NaN的数。 这个规范为什么这么定义,我想,NaN是一个非数和另一个不是非数的数比较:
我个人的倾向是比较操作是无意义的,没有想到哪些具体的场景需要返回另一个非NaN的数。
下面来看下在深度学习框架、Python、Adreno OpenCL上对NaN的行为。
后续在PyTorch和TensorFlow上也分别做了实验,输入是NaN时,结果对NaN保留下来:
# PyTorch
a=torch.Tensor(range(-3,3))
a[-1]=np.nan
torch.nn.ReLU()(a) # 输出结果:[0,0,0,0,1,nan]
# TensorFlow
Tf.keras.layers.ReLU()([np.nan]) # 输出结果 [np.nan]
Python对NaN的计算分为两类,这两类也不限于Python:
此外,Python提供至少三种逐元素的大小比较:
没在Adreno等平台做实验只是调研了文档,OpenCL提供两个接口做两组数的elemtwise max:
OpenCL Built-in | 说明 |
---|---|
gentype fmax(gentype x, gentype y), gentypef fmax(gentypef x, float y), gentyped fmax(gentyped x, double y) | 当输入是NaN和非NaN时,返回另一非NaN的数 |
gentype max(gentype x, gentype y),For OpenCL C 1.1 or newer: gentype max(gentype x, sgentype y) | Returns y if x < y, otherwise it returns x. |
这里没有说max(x, NaN)返回什么,我也没有在某个硬件上实验。但有一些猜想:
但如果并非如此,就需要实现一个传递NaN的OpenCL max实现,可以通过OpenCL提供的函数如nan(…)和isnan(…)来实现,结合select(…)等方法可以带上mask。但需注意的是同一个接口在标量和矢量操作数时,是否返回一样的类型或值,如比较关系结果是True那么可能标量接口返回的是和向量接口类型不一样的值,这点需要注意。