在我们当前常用的主流桌面级处理器以及移动处理器中都可能会涉及到浮点数的运算。而在计算机中浮点数的表达本身是要遵循IEEE754规范的。在此规范中明确定义了哪些浮点数是规格化的,哪些是非规格化的,哪些属于正负无穷大,哪些又属于我们今天要讨论的非数(NaN)。
在IEEE754标准中也提到了非数对于各个处理器实现而言可归为两类:一类是signaling NaN(简写为SNaN),即会触发异常浮点信号的非数,我们也称之为“发信号NaN”;还有一类则是quiet NaN(简写为QNaN),即不会触发浮点异常信号的非数,我们也称之为“静默NaN”。
在Intel开发者指南第1卷的4.2.2小节“Floating-Point Data Types”介绍了x86处理器中关于浮点数的表示方法,并且在该小节中的表4-3也列出了浮点数与非数的编码方式。
而ARMv8官方编程指南也是从A1.4.2小节起开始介绍从半精度浮点一直到定点数的表示方法。其中,A1.4.3小节则介绍单精度浮点的详细表示方法以及关于NaN的编码方法。
从上述文档来看,它们对SNaN与QNaN的编码方式是一样的,即无视符号位;指数部分为全1;尾数部分中,如果最高位为0,且尾数不为全0,则该浮点数为SNaN,而如果尾数最高位为1,则该浮点数为QNaN。
Intel开发者指南第1卷的4.8.3小节描述了实数与非数的的编码方式。其中,4.8.3.4到4.8.3.7小节详细描述了SNaN与QNaN在x86处理器中的行为以及如何应用。
ARMv8编程指南则是从A1.5.2小节起开始详细描述浮点数在ARMv8架构中的表示以及相关术语。其中,A1.5.5小节描述了NaN的处理以及关于QNaN与SNaN的操作执行情况。
概括起来看,无论是x86处理器还是ARM,如果一个浮点计算中其中有一个操作数为NaN,而另一个操作数不为NaN,那么目的操作数则会选取NaN的值。而如果两个源操作数均为NaN,那么x86处理器与ARM处理器的执行则会有所区别:
而对于上述过程中,如果目的操作数所取得的结果是一个SNaN,那么两个处理器都会把它转换为一个QNaN作为计算结果。而且两者在将SNaN转换为QNaN的行为也出奇地一致——直接将尾数部分的最高位置1,其他位保留为原来的状态不变。
前面我们提到过,QNaN对于处理器而言是会发送信号的。那么处理器是如何控制这个行为的呢?
下面我们通过一段代码来测试一下Windows系统下x86_64处理器对于NaN的处理行为。笔者这里的环境是Windows 11,开发工具为Visual Studio 2022,代码采用C++ 20标准。各位倘若使用Visual Studio 2019版本也完全没问题,只要选择C++ 20标准即可。
下面先给出main.cpp的代码:
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#include <utility>
#include <limits>
extern "C" void NanOpTest(unsigned dst[4], unsigned srcOp1[4], unsigned srcOp2[4], unsigned* pMXCSR);
int main(void)
{
printf("Has signaling NaN? %s\n", std::numeric_limits<float>::has_signaling_NaN ? "YES" : "NO");
union FloatType
{
float f;
unsigned u;
double d;
unsigned long long ull;
}
qnanf = { .f = std::numeric_limits<float>::quiet_NaN() },
qnand = { .d = std::numeric_limits<double>::quiet_NaN() },
snanf = { .f = std::numeric_limits<float>::signaling_NaN() },
snand = { .d = std::numeric_limits<double>::signaling_NaN() };
printf("qnanf = 0x%08X\n", qnanf.u);
printf("qnand = 0x%.16llX\n", qnand.ull);
printf("snanf = 0x%08X\n", snanf.u);
printf("snand = 0x%.16llX\n", snand.ull);
// Explicitly set it to a SNaN
snanf.u = 0x7fa0'0000U;
snand.ull = 0x7ff8'0000'0000'0000ULL;
constexpr FloatType normalInt = { .f = 0.5f };
struct alignas(64)
{
unsigned dst[4];
unsigned src1[4];
unsigned src2[4];
} opData = { .src1 = { qnanf.u | 1, snanf.u | 1, snanf.u | 1, normalInt.u | 1 },
.src2 = { snanf.u | 2, qnanf.u | 2, snanf.u | 2, qnanf.u | 2 }
};
unsigned mxcsrReg = 0;
NanOpTest(opData.dst, opData.src1, opData.src2, &mxcsrReg);
printf("Before operaton, MXCSR = 0x%04X\n", mxcsrReg);
printf("Op result: 0x%08X 0x%08X 0x%08X 0x%08X\n",
opData.dst[0], opData.dst[1], opData.dst[2], opData.dst[3]);
NanOpTest(opData.dst, opData.src1, opData.src2, &mxcsrReg);
printf("After operaton, MXCSR = 0x%04X\n", mxcsrReg);
}
对于上述代码有个细节需要讲一下。原本当前的C++标准是给出了对于给定运行环境下的SNaN的一个常量表示。但是这里MSVC所给出的一个单精度浮点的SNaN值是 0x7FC00001
,一看就知道不是一个真正的SNaN,因为尾数部分最高位(即22位)是1,而不是0。因此我在下面显式地写了一个常量:0x7fa0'0000U
,这也是在Linux GCC下获得的常量,是正确的对SNaN的一种表示。
下面给出NanOpTest
函数的实现,它是在test.asm汇编文件里:
.code
; void NanOpTest(unsigned dst[4], unsigned srcOp1[4], unsigned srcOp2[4], unsigned *pMXCSR)
NanOpTest proc public
vstmxcsr dword ptr [r9]
vmovdqa xmm1, xmmword ptr [rdx]
vmovdqa xmm2, xmmword ptr [r8]
vaddps xmm0, xmm1, xmm2
vmovdqa xmmword ptr [rcx], xmm0
ret
NanOpTest endp
end
最后,各位对工程设置一下,引入masm的汇编生成依赖项即可构建运行了。
无论是x86还是ARM,两者均支持这四种舍入模式:
此外,ARM64还额外支持Round to nearest with ties to Away(即我们在数学上所使用的四舍五入法)。
这里解释一下什么是舍入到最近偶数。这种舍入法基于我们数学上常用的四舍五入法,但在实际数据统计时,当一些数据的小数部分都是 .5 的时候,四舍五入法会使得数据样本的总和偏大。而现代典型的CPU所引入的最近偶数舍入法能避免这种情况发生,因为当我们遇到小数部分为 .5 的浮点数时,它会舍入到最近的一个偶数上。比如,1.5会舍入到2;2.5也是舍入到2。这么一来,当我们有一组包含 .5 的数据样本时,倘若要统计这些数据取整之后的总和,那么该总和值不会被过分放大。
下面列一张表概述了上面所提到的五种舍入法的结果:
舍入模式 | 0.49 | -0.49 | 1.5 | -1.5 | 2.5 | -2.5 |
---|---|---|---|---|---|---|
最近偶数 | 0 | 0 | 2 | -2 | 2 | -2 |
向正无穷大 | 1 | 0 | 2 | -1 | 3 | -2 |
向负无穷大 | 0 | -1 | 1 | -2 | 2 | -3 |
向零舍入 | 0 | 0 | 1 | -1 | 2 | -2 |
四舍五入 | 0 | 0 | 2 | -2 | 3 | -3 |
这里,所谓正无穷大方向、负无穷大方向,各位可以把这些浮点数想象为在一根一维的数轴(一般记为x轴)上,正无穷大方向就是沿着x轴的正方向,那么负无穷大方向就是x轴的负方向,很容易理解。