长浮点数(long double)的陷阱

目录
前言

此文转载自Prelert的博客,作者为David。这篇文章目前只能在WebArchive找到了,因此我把此文转载并翻译到我的博客上。

我是在搜索为什么Rust没有对应C/C++中long double的数据类型的时候看到了这篇博文,Rust不提供对应的数据类型造成了一些互操作性的问题(参见这里这里)。与此相对的是,Zig和新发布的Carbon语言都支持f16f128数据类型(其中Zig还支持f80,Carbon还支持bfloat16)。不过这倒是不令人意外,因为Zig和Carbon都以与C/C++的极致兼容性为卖点。这篇博客也许能解释一部分Rust不支持更高精度浮点数的原因。

C++ 提供三种浮点数据类型:float, doublelong double。关于这些类型,C++11标准只提到了:

double类型需要提供至少与float同等的精度,而long double需要提供至少与double同等的精度。

float类型所有支持的数值是duoble的子集;double类型所有支持的数值是long duoble的子集。各浮点类型的数据表示方法取决于具体的实现。

但是,几乎所有C++编译器都带一个C编译器,而C99标准的F附件则有更详细的规定:

  • float 类型对应IEC 60559标准的单精度浮点数
  • double 类型对应IEC 60559标准的双精度浮点数
  • long double类型对应IEC 60559标准的扩展精度浮点数,或者非IEC 60559的扩展精度浮点数,或者IEC 60559的双精度浮点数。

只有一个彻头彻尾的M才会编写一个在C++和C部分使用不同浮点类型的C++编译器,因此实际上C++也要服从同样的规定。我所使用果的所有近20年内的C++编译器都使用IEC 60559(也就是IEEE 754)所规定的单精度和双精度浮点数,但是在实现最后一个类型——long double——上这些编译器会有不一致,这也导致了一些问题。

在我的整个软件开发生涯中,我遇到过的几次与long double类型相关的问题,可以被归为两类:

  1. 缺乏测试
  2. 可移植性

缺乏测试

在去年底我记录了一个可以归为第一类的问题。一个glibc在x86_64平台上的powl()函数实现中的bug有五年多未得到修复。我感觉如果这个bug是在用的更广泛的pow()函数中,那么会有更多人感到惊奇然后有人会更快修复它。因为这个函数的long double版本用的人较少,因此这个bug就烂在那了。

另一个long double缺乏测试的例子是我在加入Prelert之前碰到的,与IBM的xlC/C++编译器相关在AIX系统上的问题。调用这个编译器的时候使用的名字(硬链接)决定了它的行为方式,当使用名称 xlC128_r 调用编译器时,它使用128位的 long double 表示。然后有一段时间,即使是最简单的程序编译都会崩(core dump)。尽管bug报告里面提到了一个调用fork()的例子,但其实如果打开了-brtl开关,最简单的"hello world"程序也能崩!显然所有的测试都是在使用其他更常用的名字调用编译器时完成的(这些情况下long double不是128位)。

可移植性

而关于可移植性,一些需要注意的坑有:

  1. 微软的Visual C++使用IEEE 754双精度浮点数表示long double——跟double一样(C99标准所允许的第三种情况)。因此在你的代码中区分long doubledouble毫无意义如果你只用微软的VC++进行编译。但如果你的代码需要支持别的平台并且你仍然使用long double,那么你就给你的代码里面掺入了一个很关键的,与平台相关的行为区别,它会让你吃亏的。大部分其他x86编译器都把long double看作x87所使用的80位扩展精度浮点数。
  2. SPARC芯片上(我知道它已经快挂了),long double类型使用128位的表示,但是默认情况下,编译器会生成软件实现,而非硬件实现的(浮点数)操作。这个情况可以回溯到大部分SPARC芯片都不支持这样的操作,然后使用中断来实现的时候。在软件层面实现浮点操作比去响应这些中断要快。但是软实现的浮点操作比硬件加速的浮点操作慢好几个数量级——我们发现一些单元测试会慢20倍,而且这些并不是单纯的做long double运算的测试。这是一个牺牲性能来换取代码(在编译和正确性方面)可移植的例子。

参考其他可移植的语言很有指导意义。Java有与IEEE754的单/双精度浮点数对应的floatdouble类型(并且不像C++,Java标准对如何实现浮点数运算非常明确)。Java没有给程序员提供long double类型,大概是因为我上面提到的可移植性问题(尽管这个标准允许在中间计算过程中使用x87扩展精度的格式)。Python只有一个float类型,并且“通常用C语言的double实现”。因此,如果你的整体系统包含使用其他语言编写的组件,那你弃用long double可以避免数据交换中的问题。同样的道理也适用于在数据库中存储浮点数——例如PostgreSQL提供realdouble,对应IEEE 754的单/双精度浮点数。

最后,一个在x86 CPU上只用floatdouble的好处是,编译器可以利用上CPU的SSE单元,然后两次或四次(浮点)运算有机会并行完成。这时使用64位调用约定的把函数参数传到寄存器中,那之后就可以直接使用SSE寄存器了。反之,long double变量只能在x87浮点运算单元中使用,并且不会使用寄存器传参,从而让程序变慢了。

结论

有些人可能会说,使用long double可以提高结果的精度。这可能是对的,但是无论一个固定精度的浮点数有多少有效位数,它都会有有效位数损失的情况(如果使用的算法不好的话)。使用扩展精度而不是双精度可能会在某些情况下避免这些问题,但是长期来看唯一的解决方法是使用更适合计算机的算法,或者设法检测有效位数损失,并且把结果替换成合适的值。

在我看来,如果你想编写可移植的C++代码,使得它不仅可以在多个平台运行,并且在某些平台上没有很夸张的性能问题,你最好避开long duoble。这也是我们在Prelert做法——我们的C++代码不使用long duoble,并且在使用Boost的时候我们定义BOOST_MATH_NO_LONG_DOUBLE_MATH_FUNCTIONS这个宏,让Boost.Math也不使用。

使用 Hugo 构建
主题 StackedJimmy 设计,Jacob 修改