信号NaN的用处是什么?

59

我最近阅读了一些关于IEEE 754和x87架构的资料。我在编写一些数值计算代码时考虑使用NaN作为“缺失值”,并希望使用 signal NaN能够在不希望继续处理“缺失值”的情况下捕获浮点异常。相反的,我会使用quiet NaN来允许“缺失值”通过计算进行传播。然而,根据(非常有限的)已有文献所述,signal NaN 并没有按照我预想的那样工作。

这是我所知道的摘要(所有这些都使用x87和VC++):

  • _EM_INVALID(IEEE“invalid”异常)控制x87在遇到NaN时的行为
  • 如果屏蔽了_EM_INVALID(禁用异常),则不会生成异常,并且操作可以返回quiet NaN。涉及signal NaN的操作不会引发异常,但会被转换为quiet NaN。
  • 如果未屏蔽_EM_INVALID(启用异常),则无效操作(例如sqrt(-1))会导致抛出一个无效异常。
  • x87从不生成signal NaN.
  • 如果未屏蔽_EM_INVALID,任何对signal NaN的使用(甚至是初始化变量)都会导致抛出无效异常。

标准库提供了一种访问NaN值的方法:

std::numeric_limits<double>::signaling_NaN();

std::numeric_limits<double>::quiet_NaN();

问题在于,我认为信号NaN没有任何用处。如果掩盖了_EM_INVALID,则其行为与静默NaN完全相同。由于没有NaN可与其他任何NaN进行比较,因此没有逻辑区别。

如果未屏蔽_EM_INVALID(启用异常),则甚至无法使用信号NaN初始化变量: double dVal = std::numeric_limits & lt; double & gt; :: signaling_NaN();,因为这会引发异常(将信号NaN值加载到x87寄存器中以将其存储到内存地址中)。

您可能像我一样认为以下各项:

  1. 掩盖_EM_INVALID。
  2. 使用信号NaN初始化变量。
  3. 取消掩盖_EM_INVALID。

然而,步骤2会导致信号NaN转换为静默NaN,因此随后对其的使用不会导致抛出异常! 真是什么鬼?!

信号NaN是否有任何效用或目的? 我理解最初的意图之一是使用它来初始化内存,以便可以捕获未初始化的浮点值的使用。

有人能告诉我我是否漏掉了什么吗?


编辑:

为了进一步说明我希望做的事情,这里有一个例子:

考虑对数据向量(双精度浮点数)执行数学运算。对于某些操作,我希望允许向量包含“缺失值”(假装这对应于电子表格列,例如其中某些单元格没有值,但它们的存在很重要)。对于某些操作,我不希望向量包含“缺失值”。也许如果集合中存在“缺失值”,我想采取不同的行动 - 可能是执行不同的操作(因此这不是无效状态)。

这个原始代码看起来像这样:

const double MISSING_VALUE = 1.3579246e123;
using std::vector;

vector<double> missingAllowed(1000000, MISSING_VALUE);
vector<double> missingNotAllowed(1000000, MISSING_VALUE);

// ... populate missingAllowed and missingNotAllowed with (user) data...

for (vector<double>::iterator it = missingAllowed.begin(); it != missingAllowed.end(); ++it) {
    if (*it != MISSING_VALUE) *it = sqrt(*it); // sqrt() could be any operation
}

for (vector<double>::iterator it = missingNotAllowed.begin(); it != missingNotAllowed.end(); ++it) {
    if (*it != MISSING_VALUE) *it = sqrt(*it);
    else *it = 0;
}

请注意,必须在每次循环迭代中执行对“缺失值”的检查。虽然我明白在大多数情况下,sqrt函数(或任何其他数学操作)很可能会掩盖这个检查,但在某些情况下,该操作可能是微不足道的(可能只是一次加法),而检查则是昂贵的。更不用说“缺失值”将一个合法的输入值排除在运算之外,并且如果计算真正到达那个值(虽然可能性很小),可能会导致错误。此外,为了技术上的正确性,用户输入数据应该与该值进行比较,并采取适当的行动。我认为这种解决方案不够优雅,从性能角度来看也不够优化。这是关键性能代码,我们绝对没有并行数据结构或某种数据元素对象的奢侈。

使用NaN版本将如下所示:

using std::vector;

vector<double> missingAllowed(1000000, std::numeric_limits<double>::quiet_NaN());
vector<double> missingNotAllowed(1000000, std::numeric_limits<double>::signaling_NaN());

// ... populate missingAllowed and missingNotAllowed with (user) data...

for (vector<double>::iterator it = missingAllowed.begin(); it != missingAllowed.end(); ++it) {
    *it = sqrt(*it); // if *it == QNaN then sqrt(*it) == QNaN
}

for (vector<double>::iterator it = missingNotAllowed.begin(); it != missingNotAllowed.end(); ++it) {
    try {
        *it = sqrt(*it);
    } catch (FPInvalidException&) { // assuming _seh_translator set up
        *it = 0;
    }
}

现在明确的检查已经被消除,性能应该得到改善。我认为如果我可以在不触碰FPU寄存器的情况下初始化向量,这一切都会起作用...

此外,我想象中任何值得尊重的sqrt实现都会检查NaN并立即返回NaN。


8
好问题。不幸的是,我所见过的使用信号NaN的唯一用途就是在周六晚上9:30给我的手机打电话。 - John Dibling
3个回答

13

据我所理解,信号NaN的目的是用于初始化数据结构,但是在C中运行时初始化会有风险,因为NaN可能会被加载到浮点寄存器中作为初始化的一部分,从而触发信号,因为编译器没有意识到需要使用整数寄存器来复制该浮点值。

我希望你能够初始化一个静态值为信号NaN,但即使如此,这也需要编译器进行一些特殊处理,以避免它被转换为安静NaN。您可以尝试使用一些强制类型转换的技巧,在初始化期间避免将其视为浮点值。

如果您在编写汇编语言,这将不成问题。但是在C和特别是C++中,我认为您必须破坏类型系统才能使用NaN初始化变量。我建议使用memcpy


1
是的,我认为这可能是一个合理的假设。我认为它需要成为语言的一部分,而不仅仅是库的一部分,才能按照预期工作。 - John Knoeller

3
使用特殊值(甚至是NULL)会使您的数据变得混乱,代码也会变得凌乱。无法区分QNaN结果和QNaN“特殊”值。

您最好维护一个平行的数据结构来跟踪有效性,或者将FP数据保存在不同(稀疏)的数据结构中,只保留有效数据。

这是相当通用的建议;在某些情况下,特殊值非常有用(例如内存或性能限制非常紧密),但随着上下文的增大,它们可能会带来更多困难,而不值得这样做。


这是一个好问题。我只是提供这个答案作为谦虚的建议,给那些认为“那将是一个很酷的技巧!”的经验不足的旅行者们。 :-) - Matt Curtis
1
明白了。显然,对于我没有看到的代码进行评论是困难的,而且我从痛苦的经历中知道,在大型丑陋的代码库上工作时,你被告知不要更改,但如果有机会,我会尝试将逻辑隔离到一个函数中以检查该值(即使是宏,如果您频繁检查它,则非内联函数调用会减慢调试构建)。如果没有其他选择,这会使一切变得更清晰,并且在最好的情况下,如果您找到更好的解决方案,它会给您重新组织的机会。 - Matt Curtis
好的,现在这样就清楚多了。在你的例子中,使用QNaN并避免测试将更加SIMD和缓存友好。正如我在原始答案中所说,紧密的内存/性能限制是不使用特殊值的一般规则的例外 :-) 感谢STingRaySC。很抱歉我没有为您的问题提供答案。 - Matt Curtis

3
以下是不同双精度NaN的位模式:
信号NaN由7FF0000000000001到7FF7FFFFFFFFFFFF或FFF0000000000001到FFF7FFFFFFFFFFFF之间的任何位模式表示。
安静NaN由7FF8000000000000到7FFFFFFFFFFFFFFF或FFF8000000000000到FFFFFFFFFFFFFFFF之间的任何位模式表示。
来源:https://www.doc.ic.ac.uk/~eedwards/compsys/float/nan.html 免责声明:正如其他人指出的,类型转换可能存在潜在危险并可能导致未定义的行为。使用memcpy被建议作为更安全的替代方法。
话虽如此,对于学术目的或者如果您知道它在所需硬件上是安全的:
理论上,似乎只需要一个常量uint64_t,其中位已设置为信号NaN的位。只要将其视为整数类型,信号NaN就与其他整数没有区别。然后,除了架构特殊情况问题外,也许你可以通过指针转换将其写入想要的位置。如果它按预期工作,甚至可能比memcpy更快。对于某些嵌入式系统来说,这可能会很有用。
示例:
const uint64_t sNan = 0xFFF7FFFFFFFFFFFF;
double[] myData;
...
uint64_t* copier = (uint64_t*) &myData[index];
*copier = sNan & ~myErrorFlags;

1
请注意,以这种方式重新解释转换会导致未定义的行为,因为指向的位置从未有过uint64_t。正确的方法是将字节表示复制到double位置。 - Kaznov
1
请你可以修复这段代码吗?它是错误的。1)它不会编译因为有大写字母。2)即使编译通过,它也会导致未定义的行为。3)它具有误导性,因为你所称呼的sNan实际上是负无穷大的模式(你可以检查自己的链接)。 - Arnaud
谢谢@Arnaud和Kaznov。我同意你们的批评。你们两个都得到了我的赞成票,我已经相应地更新了我的长期被遗忘的答案。 - Jan Heldal
感谢您更新您的答案。我取消了我的踩。 - Arnaud

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