安静NaN和信号NaN有什么区别?

156

我了解浮点数,并知道 NaN 的出现与操作有关。但是我不太明白这些概念的确切含义。它们之间有什么区别?

在 C++ 编程中,哪种类型的 NaN 可能出现?作为程序员,我能否编写一个会导致 sNaN 的程序?

2个回答

114

当某个操作得到了一个 quiet NaN 时,直到程序检查结果并发现 NaN 时,没有任何异常指示。也就是说,如果浮点运算是通过软件实现的,那么即使不使用浮点单元 (FPU) 或库,计算也会继续进行。产生脉冲 NaN 将产生一个信号,通常以来自 FPU 的异常形式出现。是否抛出异常取决于 FPU 的状态。

C++11 添加了一些语言对浮点环境进行控制,并提供了创建和测试 NaNs 的标准化方法。然而,这些控制是否被实现并不太标准化,并且浮点异常通常无法像标准 C++ 异常一样捕获。

在 POSIX/Unix 系统中,通常使用 SIGFPE 的处理程序来捕获浮点异常。


56
此外,通常情况下,一个信号NaN(sNaN)的目的是用于调试。例如,浮点对象可能会被初始化为sNaN。然后,如果程序在使用该对象之前未将其赋值,那么当程序在算术运算中使用sNaN时,将会引发异常。程序不会无意间产生sNaN;没有任何正常的操作会产生sNaN。它们只是专门为了拥有一个信号NaN而创建的,而不是作为任何算术操作的结果。 - Eric Postpischil
30
相比之下,NaN更适用于正常的编程。当进行某些运算时没有数值结果时(例如,当计算负数的平方根而结果必须为实数时),就会产生NaN。它们的目的通常是允许算术运算在某种程度上正常进行。例如,您可能有一个包含大量数字的数组,其中一些表示无法正常处理的特殊情况。您可以调用复杂的函数来处理这个数组,并且它可以使用通常的算术运算忽略NaN。在处理结束后,您将把特殊情况分离出来以便进一步处理。 - Eric Postpischil
@wrdieter 谢谢,那么唯一的区别就是是否生成异常。 - JalalJaberi
@EricPostpischil谢谢您关注第二个问题。 - JalalJaberi
@JalalJaberi 是的,异常是主要的区别。 - wrdieter
唉...很遗憾这个问题必须在C++文档之外得到解答。谢谢! - zackery.fix

82

如何实验性地区分qNaN和sNaN?

首先,让我们学习如何识别sNaN或qNaN。

在本答案中,我将使用C++而不是C,因为它提供了方便的std::numeric_limits::quiet_NaNstd::numeric_limits::signaling_NaN,而这些在C中不太容易找到。

但是,我无法找到一个函数来分类NaN是否为sNaN或qNaN,因此让我们只打印出NaN的原始字节:

main.cpp

#include <cassert>
#include <cstring>
#include <cmath> // nanf, isnan
#include <iostream>
#include <limits> // std::numeric_limits

#pragma STDC FENV_ACCESS ON

void print_float(float f) {
    std::uint32_t i;
    std::memcpy(&i, &f, sizeof f);
    std::cout << std::hex << i << std::endl;
}

int main() {
    static_assert(std::numeric_limits<float>::has_quiet_NaN, "");
    static_assert(std::numeric_limits<float>::has_signaling_NaN, "");
    static_assert(std::numeric_limits<float>::has_infinity, "");

    // Generate them.
    float qnan = std::numeric_limits<float>::quiet_NaN();
    float snan = std::numeric_limits<float>::signaling_NaN();
    float inf = std::numeric_limits<float>::infinity();
    float nan0 = std::nanf("0");
    float nan1 = std::nanf("1");
    float nan2 = std::nanf("2");
    float div_0_0 = 0.0f / 0.0f;
    float sqrt_negative = std::sqrt(-1.0f);

    // Print their bytes.
    std::cout << "qnan "; print_float(qnan);
    std::cout << "snan "; print_float(snan);
    std::cout << " inf "; print_float(inf);
    std::cout << "-inf "; print_float(-inf);
    std::cout << "nan0 "; print_float(nan0);
    std::cout << "nan1 "; print_float(nan1);
    std::cout << "nan2 "; print_float(nan2);
    std::cout << " 0/0 "; print_float(div_0_0);
    std::cout << "sqrt "; print_float(sqrt_negative);

    // Assert if they are NaN or not.
    assert(std::isnan(qnan));
    assert(std::isnan(snan));
    assert(!std::isnan(inf));
    assert(!std::isnan(-inf));
    assert(std::isnan(nan0));
    assert(std::isnan(nan1));
    assert(std::isnan(nan2));
    assert(std::isnan(div_0_0));
    assert(std::isnan(sqrt_negative));
}

编译和运行:

g++ -ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out

在我的x86_64机器上输出:

qnan 7fc00000
snan 7fa00000
 inf 7f800000
-inf ff800000
nan0 7fc00000
nan1 7fc00001
nan2 7fc00002
 0/0 ffc00000
sqrt ffc00000

我们也可以使用QEMU用户模式在aarch64上执行程序:
aarch64-linux-gnu-g++ -ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp
qemu-aarch64 -L /usr/aarch64-linux-gnu/ main.out

并且这会产生完全相同的输出,表明多个架构密切实现IEEE 754。

如果您不熟悉IEEE 754浮点数的结构,请查看:什么是次标准浮点数?

在二进制中,一些以上的值为:

     31
     |
     | 30    23 22                    0
     | |      | |                     |
-----+-+------+-+---------------------+
qnan 0 11111111 10000000000000000000000
snan 0 11111111 01000000000000000000000
 inf 0 11111111 00000000000000000000000
-inf 1 11111111 00000000000000000000000
-----+-+------+-+---------------------+
     | |      | |                     |
     | +------+ +---------------------+
     |    |               |
     |    v               v
     | exponent        fraction
     |
     v
     sign

从这个实验中我们可以观察到:

  • qNaN and sNaN seem to be differentiated only by bit 22: 1 means quiet, and 0 means signaling

  • infinities are also quite similar with exponent == 0xFF, but they have fraction == 0.

    For this reason, NaNs must set bit 21 to 1, otherwise it would not be possible to distinguish sNaN from positive infinity!

  • nanf() produces several different NaNs, so there must be multiple possible encodings:

    7fc00000
    7fc00001
    7fc00002
    

    Since nan0 is the same as std::numeric_limits<float>::quiet_NaN(), we deduce that they are all different quiet NaNs.

    The C11 N1570 standard draft confirms that nanf() generates quiet NaNs, because nanf forwards to strtod and 7.22.1.3 "The strtod, strtof, and strtold functions" says:

    A character sequence NAN or NAN(n-char-sequence opt ) is interpreted as a quiet NaN, if supported in the return type, else like a subject sequence part that does not have the expected form; the meaning of the n-char sequence is implementation-defined. 293)

另请参见:

qNaN和sNaN在手册中是什么样子的?

IEEE 754 2008建议(TODO强制或可选?):

  • 指数==0xFF且小数部分!=0的任何内容都是NaN
  • 最高小数位区分qNaN和sNaN

但它似乎没有说明哪个位更适合区分无穷大和NaN。

6.2.1“二进制格式中的NaN编码”说:

本子句进一步指定了NaN的编码方式,当它们是操作结果时,需要将其编码为位字符串。在编码时,所有NaN都有一个符号位和必要的位模式来识别编码为NaN并确定其种类(sNaN vs. qNaN)。剩余的位在尾数字段中,编码有效负载,这可能是诊断信息(见上文)。所有二进制NaN位字符串的偏置指数字段E的所有位都设置为1(见3.4)。安静NaN位字符串应使用尾数字段T的第一位(d1)为1进行编码。信令NaN位字符串应使用尾数字段的第一位为0进行编码。如果尾数字段的第一位为0,则必须有其他位的尾数字段非零以区分NaN和无穷大。在刚才描述的首选编码中,通过将d1设置为1并保持T的其余位不变,可以使信令NaN静音。对于二进制格式,有效负载编码在尾数字段的p-2个最低有效位中。

英特尔64和IA-32体系结构软件开发人员手册-卷1基本架构-253665-056US 2015年9月的4.8.3.4“NaNs”证实,x86遵循IEEE754标准,通过区分最高分数位的NaN和sNaN来区分它们:

IA-32架构定义了两类NaN:quiet NaN(QNaNs)和signaling NaN(SNaNs)。 QNaN是最高有效小数位设置为SNaN是最高有效小数位清除的NaN。

ARM架构参考手册-ARMv8,适用于ARMv8-A架构配置文件-DDI 0487C.a的A1.4.3“单精度浮点格式”也是如此:

fraction!=0:该值为NaN,可以是quiet NaN或signaling NaN。这两种类型的NaN由它们的最高分数位bit [22]区分:

  • bit[22] == 0: NaN是一种信号NaN。符号位可以取任何值,其余的分数位可以取任何非零值。
  • bit[22] == 1: NaN是一种安静NaN。符号位和剩余的分数位可以取任何值。

qNaNS和sNaNS是如何生成的?

qNaNS和sNaNS之间的一个主要区别是:

  • qNaN是通过常规内置(软件或硬件)算术运算与奇怪的值生成的
  • sNaN永远不会被内置操作生成,只能由程序员明确添加,例如使用std::numeric_limits::signaling_NaN

我找不到关于这方面的明确IEEE 754或C11引用,但我也找不到任何生成sNaN的内置操作;-)

然而,英特尔手册在4.8.3.4“NaNs”中明确说明了这个原则:

SNaN通常用于陷阱或调用异常处理程序。它们必须由软件插入;也就是说,处理器从不生成SNaN作为浮点操作的结果。

这可以从我们的示例中看出,其中两者都是:

float div_0_0 = 0.0f / 0.0f;
float sqrt_negative = std::sqrt(-1.0f);

产生的位和std::numeric_limits<float>::quiet_NaN()完全一样。
这两个操作都编译成单个x86汇编指令,直接在硬件中生成qNaN(TODO确认与GDB)。
qNaN和sNaN有什么不同?
现在我们知道了qNaN和sNaN的外观,以及如何操纵它们,我们终于可以尝试让sNaN发挥作用,并炸毁一些程序!
所以,没有更多的拖延:

blow_up.cpp

#include <cassert>
#include <cfenv>
#include <cmath> // isnan
#include <iostream>
#include <limits> // std::numeric_limits
#include <unistd.h>

#pragma STDC FENV_ACCESS ON

int main() {
    float snan = std::numeric_limits<float>::signaling_NaN();
    float qnan = std::numeric_limits<float>::quiet_NaN();
    float f;

    // No exceptions.
    assert(std::fetestexcept(FE_ALL_EXCEPT) == 0);

    // Still no exceptions because qNaN.
    f = qnan + 1.0f;
    assert(std::isnan(f));
    if (std::fetestexcept(FE_ALL_EXCEPT) == FE_INVALID)
        std::cout << "FE_ALL_EXCEPT qnan + 1.0f" << std::endl;

    // Now we can get an exception because sNaN, but signals are disabled.
    f = snan + 1.0f;
    assert(std::isnan(f));
    if (std::fetestexcept(FE_ALL_EXCEPT) == FE_INVALID)
        std::cout << "FE_ALL_EXCEPT snan + 1.0f" << std::endl;
    feclearexcept(FE_ALL_EXCEPT);

    // And now we enable signals and blow up with SIGFPE! >:-)
    feenableexcept(FE_INVALID);
    f = qnan + 1.0f;
    std::cout << "feenableexcept qnan + 1.0f" << std::endl;
    f = snan + 1.0f;
    std::cout << "feenableexcept snan + 1.0f" << std::endl;
}

编译、运行并获取退出状态:

g++ -ggdb3 -O0 -Wall -Wextra -pthread -std=c++11 -pedantic-errors -o blow_up.out blow_up.cpp -lm -lrt
./blow_up.out
echo $?

输出:

FE_ALL_EXCEPT snan + 1.0f
feenableexcept qnan + 1.0f
Floating point exception (core dumped)
136

请注意,这种行为只会在GCC 8.2中的-O0时发生:使用-O3,GCC会预先计算并优化所有我们的sNaN操作!我不确定是否有符合标准的方法来防止这种情况发生。
因此,我们从这个例子中推断出:
- snan + 1.0会导致FE_INVALID,但qnan + 1.0则不会。 - 仅当使用feenableexept启用时,Linux才会生成信号。 这是glibc的扩展,在任何标准中都找不到任何方法来实现它。 当信号发生时,是因为CPU硬件本身引发了异常,Linux内核通过信号处理并告知应用程序。
结果是bash打印Floating point exception (core dumped),退出状态为136对应信号136 - 128 == 8,根据:
man 7 signal

SIGFPE 信号。

请注意,如果我们试图将整数除以0,我们将得到相同的信号:SIGFPE

int main() {
    int i = 1 / 0;
}

虽然对于整数:

  • 除以零会引发信号,因为整数中没有无限大的表示
  • 默认情况下会发生信号,无需使用feenableexcept

如何处理SIGFPE?

如果只创建一个正常返回的处理程序,它会导致无限循环,因为在处理程序返回后,除法会再次发生!可以通过GDB进行验证。

唯一的方法是使用setjmplongjmp跳转到其他地方,如所示:C handle signal SIGFPE and continue execution

sNaNs的一些实际应用是什么?

说实话,我还没有理解sNaN的超级有用的用例,这已经被问过了:Usefulness of signaling NaN?

sNaN感觉特别无用,因为我们可以使用feenableexcept检测生成qNaN的初始无效操作(0.0f/0.0f):似乎snan仅针对qnan不会引发错误的更多操作引发错误,例如(qnan + 1.0f)。

E.g.:

main.c

#define _GNU_SOURCE
#include <fenv.h>
#include <stdio.h>

int main(int argc, char **argv) {
    (void)argv;
    float f0 = 0.0;

    if (argc == 1) {
        feenableexcept(FE_INVALID);
    }
    float f1 = 0.0 / f0;
    printf("f1 %f\n", f1);

    feenableexcept(FE_INVALID);
    float f2 = f1 + 1.0;
    printf("f2 %f\n", f2);
}

编译:

gcc -ggdb3 -O0 -std=c99 -Wall -Wextra -pedantic -o main.out main.c -lm

接下来:

./main.out

给出:

Floating point exception (core dumped)

并且:

./main.out  1

提供:

f1 -nan
f2 -nan

参见: 如何在C++中跟踪NaN

什么是信号标志(signal flags),它们如何操作?

所有内容都是由CPU硬件实现的。

标志位存储在某个寄存器中,一个比特位表示是否应该引发异常/信号。

这些寄存器从大多数架构的用户空间 可以访问

glibc 2.29代码的这一部分实际上非常容易理解!

例如,fetestexcept 在 x86_64 上实现于 sysdeps/x86_64/fpu/ftestexcept.c:

#include <fenv.h>

int
fetestexcept (int excepts)
{
  int temp;
  unsigned int mxscr;

  /* Get current exceptions.  */
  __asm__ ("fnstsw %0\n"
       "stmxcsr %1" : "=m" (*&temp), "=m" (*&mxscr));

  return (temp | mxscr) & excepts & FE_ALL_EXCEPT;
}
libm_hidden_def (fetestexcept)

因此,我们立即看到指令使用stmxcsr,它代表“存储MXCSR寄存器状态”。

feenableexcept是在sysdeps/x86_64/fpu/feenablxcpt.c中实现的:

#include <fenv.h>

int
feenableexcept (int excepts)
{
  unsigned short int new_exc, old_exc;
  unsigned int new;

  excepts &= FE_ALL_EXCEPT;

  /* Get the current control word of the x87 FPU.  */
  __asm__ ("fstcw %0" : "=m" (*&new_exc));

  old_exc = (~new_exc) & FE_ALL_EXCEPT;

  new_exc &= ~excepts;
  __asm__ ("fldcw %0" : : "m" (*&new_exc));

  /* And now the same for the SSE MXCSR register.  */
  __asm__ ("stmxcsr %0" : "=m" (*&new));

  /* The SSE exception masks are shifted by 7 bits.  */
  new &= ~(excepts << 7);
  __asm__ ("ldmxcsr %0" : : "m" (*&new));

  return old_exc;
}

C标准对于qNaN和sNaN有何规定?

C11 N1570标准草案明确表示,在F.2.1“无穷大、有符号零和NaN”中,标准不区分它们:

1 本规范未定义信令NaN的行为。通常使用术语NaN来表示静默NaN。NAN和INFINITY宏以及<math.h>中的nan函数提供了IEC 60559 NaN和无穷大的指示。

在Ubuntu 18.10,GCC 8.2中进行测试。GitHub上游:


1
https://en.wikipedia.org/wiki/IEEE_754#Interchange_formats指出,IEEE-754仅*建议*用0表示信号NaN是一个很好的实现选择,以允许消除NaN而不会让它成为无穷大(significand = 0)。 显然这并没有标准化,尽管这是x86所做的。 (以及决定qNaN vs. sNaN的significand的MSB确实是标准化的)https://en.wikipedia.org/wiki/Single-precision_floating-point_format 表示x86和ARM是相同的,但PA-RISC则做出了相反的选择。 - Peter Cordes
@PeterCordes 是的,我不确定IEEE 754 20at中的“should” == “must”或“is preferred”,即“信令NaN位串应使用尾数字段的第一位为0进行编码”。 - Ciro Santilli OurBigBook.com
回复:但它似乎没有指定应使用哪个位来区分无穷大和NaN。 你写得好像你期望标准推荐设置某些特定的位来区分sNaN和无穷大。我不知道为什么你会期望有这样的位;任何非零的选择都可以。只需选择一些稍后可以识别sNaN来源的东西。我不知道,听起来就像是奇怪的措辞,当我读到它时我的第一印象是你在说那个网页没有描述编码中区分inf和NaN(一个全零有效数字)的方法。 - Peter Cordes
2
在2008年之前,IEEE 754规定了信号/静默位(第22位),但没有指定哪个值表示什么。大多数处理器都收敛于1 = 静默,因此这成为了2008版标准的一部分。它使用“应该”而不是“必须”,以避免使做出相同选择的旧实现不符合标准。通常,“应该”在标准中意味着“必须,除非您有非常有说服力(最好有记录)的理由不遵守”。 - John Cowan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接