Peripateticism

Yuens' blog

View the Project on GitHub

IEEE 754 Floating NaN

本文内容来自对IEEE 754标准中的特殊值NAN(非数)的调研,同时在该过程中会一起参考OpenCL标准,看看IEEE 754和OpenCL标准的关系,最后在Numpy、TensorFlow、PyTorch中实验NaN这一特殊值的行为。本文目录如下:

  1. 浮点表示
    1. 有符号整数的二进制表示
  2. NaN
    1. 与NaN的操作
    2. NaN的二进制编码
    3. NaN传播与转换规则
  3. 应用行为:NaN计算
    1. PyTorch和TensorFlow
    2. Python
    3. Adreno OpenCL

计算机对浮点数的表示都是对真实实数的近似,计算机系统必须小心计算机算术真实世界算术的差距,程序员也务必小心近似值的含义。

floating_number_represent

计算机用于模拟近似真实世界的位模式,并没有内在的含义,它们代表的可能是有符号整数、无符号整数、浮点数、指令、字符串等,具体代表什么取决于对其操作的指令

1.浮点表示

标准的存在使几乎每台计算机都遵循,简化浮点程序接口且提高运算质量,也是软硬件的接口。

浮点相较于定点,在字面意思上来说,float表示binary point是浮动的,而且定点的计算机二进制表示是基于固定的位模式,Fixed,定点每个二进制表示都是特定值,一个萝卜一个坑,因而没有Inf和NaN。浮点符号位(S)、尾数位(F)和指数位(E)打来的表示位宽的实数区间大。

pi_fp32_binary_encode

如为了打包更多数据位,标准隐藏了规格化(规格化:用没有前导零的浮点记数法表示数;前导零:小数点左边的整数部分为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是个例外,其他数表示不变。

zero_binary_encode_represent

特别说明,符号位在最左边的最高有效位上,是为了便于比较排序,相比整数定点数的分类,浮点稍微复杂,标准的这种记数法本质是符号与幅值的形式,而非补码。

有符号整数的二进制表示

既然说到这两个关键词(补码、符号与幅值),那回顾下有符号数的表示。

符号与幅值的表示法即字面意思,作为早期提出用来表示区分定点数表示中正负数的一种方法,其缺点如符号位的位置在左还是右、计算需要额外引入符号的设置步骤、单独的符号会给0值的表达带来正负性。而且还有个问题是,当用一个较小数减去较大数时如21 - 42,会有借位行为,无符号的表示会让较小数前面的 0 中借位,导致前面的位变成一连串的 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

表中想表达的信息:

  1. 用特殊的符号来表示异常事件,如软件上可以设置结果设置为某种格式表达正负无穷、非数,代替除0中断;
  2. 指数被保留下来,用以标识特殊符号,即正负无穷,非数的表示上。
    • 从字面意思来说,非数不是一个数,这是标准为了表示无效操作而不得不引入的,如0 / 0或无穷减无穷。目的也是为了让计算无效的这一个结果得以保留,让程序员知道这里这个操作是无效的,可以在程序后续的过程中在做决断,或者在测试中使用发现一些特殊的情况。
    • 无穷,其存在目的是形成一个拓扑闭包。这句话对于非数学专业的同学很难理解,粗略的比喻是:想象一个装满小球的开放盒子,小球表示实数轴上的有限数(不要细究这个“有限”的意思),把盒子“封闭”意味着不让任何小球从盒子里掉出来,但盒子顶部是开放的,没有盖子,为此需要加一个盖子,这个盖子就代表了“无穷大”。 拓扑学中,“闭包”是一个包含了原集合的所有极限点的集合。在我们的比喻中,这些极限点就像是试图从盒子边缘逃出的小球,但由于加上了盖子(无穷大),它们都被包含在了闭包(封闭的盒子)之内。无穷大的概念确保了空间的“完整性”,使我们在这个空间内进行连续的数学运算而不会出现“漏洞”或“不连续”。
  3. 非规格化数:规格化我们知道是用没有前导零的,进一步说
    1. 在规格化的表示总假设有一个无需存储的尾数位最高位1,即在小数点前,从而节省空间提高精度;
    2. 指数部分不全位0或1,留给特殊值如零、无穷和非数。

    如二进制浮点数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, 这种非规格化带来的麻烦,是对浮点单元的硬件设计角度而言。因此,许多计算机在操作数出现非规格化数时会产生异常,较给软件来完成相应操作,虽然软件可以处理但是低效。

2.NaN

非数由754-1985引入,其二进制表示为:指数位全1,尾数位非全0,对于半精度亦是如此。

下图是FP16的NaN二进制表示,符号位、指数位和尾数位分别用蓝色、绿色和红色表示,这里重点关注其数值,FP32和FP64类似。

nan_fp16_binary_encoding_represent

2.1 与NaN的操作

说操作前,先说下标准中定义的NaN有两类:

下面关于静默和非静默分别举一些例子,与标准可能存在不同。

第一个例子,Intel架构对浮点行为有一个表格用来说明,下面加减乘除在SSE、AVX Scalar指令的行为,其中,Src1和Src2分别表示两个操作数:

intel_arch_floating_operation_with_nan

可以看到只要两个操作数有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标准带来的折损:

adreno_opencl_math_function_type 表 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类函数。

adreno_opencl_function_category 表 Performance of OpenCL math functions (IEEE 754 conformant) | Adreno OpenCL Guide

除了NaN的两个类型外,下面再介绍一些摘自754标准有关NaN的操作:

armv8_isa_overview 图 浮点Min/Max | ARMv8指令集概览

2.2 NaN的二进制编码

正如刚开始介绍的,对于编码是符号位不限制,且尾数位只要非0即可,这种灵活便携性,让NaN有不固定的二进制编码

nan_inf_binary_encoding

因为NaN的编码不唯一,自然引出“有效载荷”(Payload)这个概念,表示尾数位置除了最高的两位(通常区分qNaN和sNaN)外的其余位,如double的尾数位有52个,排除前面2位,有效载荷就是50。

再说符号位:

  1. 当输入或结果是NaN时,该标准不解释符号位的含义:
    1. 但在标准提到TotalOrder(x,y)这么个谓词函数,用于定义特定格式的全序关系,两个数比较必然是三个关系其中之一(小于、等于、大于),这对于浮点数处理尤其存在特殊值(Inf和NaN)时很有用处,totalOrder可以正确处理带有特殊值的情况,标准定义了存在NaN时该函数的返回关系, 具体参考标准章节5.10 Details of totalOrder predicate,本文不展开
    2. 对TotalOrder外的其他操作,标准不指定NaN结果的符号位
  2. 对位串的操作(copy、negate即取反、abs、copySign即两个操作数把一个操作数的符号给到另一个数上并返回),这些操作的符号位是确定的。

2.3 NaN传播与转换规则

还是摘自标准的内容,想不出一个例子直接看还是有些乏味晦涩:

3.应用行为:NaN计算

当运算中存在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

后续在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

Python对NaN的计算分为两类,这两类也不限于Python:

python_nan_compute

此外,Python提供至少三种逐元素的大小比较:

python_max_nan_1

  1. max/min: max(np.nan,1)返回1,max(1,np.nan)返回np.nan,行为不好描述,文档更没有提,见上图;
  2. np.maximum/np.minimum:np.maximum(1, np.nan)返回np.nan结果稳定,NaN会被保留下来,符合TF和Torch的行为;
  3. np.fmax/np.fmin: np.fmax(1, np.nan)返回另一个非nan的数即1,传递NaN这点看起来是遵循754对NaN的计算要求的,但不符合我们实际的需求(需要与主流深度学习框架保持一致)。

OpenCL

没在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)返回什么,我也没有在某个硬件上实验。但有一些猜想:

  1. 返回什么取决于x和y的参数顺序,因为文档里说是returns y if x < y,如果x和y里有一个NaN那么关系比较运算是False,返回y,那就看y是NaN还是x是NaN了;
  2. 返回NaN。我个人认为应该遵循C99的规范,因为一来是fmax这个接口用来遵循754就可以了,另外是x和y的比较运算因里面有一个NaN结果应该是False,那么返回的是NaN。而且在stackoverflow上有检索,在C/C++上的大小比较max和fmax,fmax遵循754标准返回另一个数,而max返回NaN。

但如果并非如此,就需要实现一个传递NaN的OpenCL max实现,可以通过OpenCL提供的函数如nan(…)和isnan(…)来实现,结合select(…)等方法可以带上mask。但需注意的是同一个接口在标量和矢量操作数时,是否返回一样的类型或值,如比较关系结果是True那么可能标量接口返回的是和向量接口类型不一样的值,这点需要注意。

参考