浮点运算结果不准确算不算是 bug?任何人都不想得到不准确的结果吧
最近开发时遇到一个问题就是关于“浮点运算结果不准确”的问题,很多开发语言中都有这个问题,进而衍生出了一些类库去专门解决浮点运算偏差。
我很好奇,开发人员应该没有人希望得到一个不稳定、不准确的结果,那么保留这个“浮点运算不准确”的特性有什么特殊意义吗?如果没有特殊意义,那么“浮点运算结果不准确”是不是 Bug 呢?如果是 Bug 的话为什么至今仍然有很多开发语言有这个问题呢?
请各位有经验的大佬说说原因?
最近开发时遇到一个问题就是关于“浮点运算结果不准确”的问题,很多开发语言中都有这个问题,进而衍生出了一些类库去专门解决浮点运算偏差。
我很好奇,开发人员应该没有人希望得到一个不稳定、不准确的结果,那么保留这个“浮点运算不准确”的特性有什么特殊意义吗?如果没有特殊意义,那么“浮点运算结果不准确”是不是 Bug 呢?如果是 Bug 的话为什么至今仍然有很多开发语言有这个问题呢?
请各位有经验的大佬说说原因?
大一的计算机组成原理里面就介绍过这个了吧
至于你说为什么开发语言不试图去解决这个问题,因为:(1) 需要保持行为的一致性,浮点数并没有那么简单,IEEE-754 标准以及各种扩展实现、浮点环境、异常、rounding mode 都是坑。除了 Excel 这种用途的,正常的通用编程语言不应该自作聪明地去掩盖这个问题;(2) 浮点运算是有硬件指令支持的,自己去模拟一个所谓「准确」的运算,在不严格要求精度的时候,性能问题怎么办?
上课好好听课
一个数轴,自然数都表示不全,指望着表示所有的实数?更何况还有虚数。
数学 /物理中有个概念:误差。在使用小数进行计算的时候,会要求误差控制在多少个小数点后面。高中物理都有,分别对比保留到保留小数点后面 M / N 位的误差。求小数点 N 位的时候,不都是在计算过程中使用 N+1,或者 N+位计算,甚至分数么?
使用小数计算,人为都做不到 100%精确,怎么到计算机这就是个 bug 了?
十进制本身也不能在有限精度内做循环小数运算。
比如三分之一,取 3 位小数是 0.333 ,乘上 3 以后是 0.999 ,一样会产生 0.001 的误差。
要精确计算循环小数需要用比例类型,存储 1 和 3 而不是 0.333 。
0.1000000000000000055511151231257827021181583404541015625
和
0.200000000000000011102230246251565404236316680908203125
他们相加以后就会变成:
0.3000000000000000444089209850062616169452667236328125
(这里由于精度损失所以加出了 444 而不是 166。)
而真正的 0.3 应该是:
0.299999999999999988897769753748434595763683319091796875
前者比后者大了整整:
0.000000000000000055511151231257827021181583404541015625
因此造成了计算误差。
看书十分钟,论坛吹水一整天
只要弄明白上面两个问题,就知道为啥需要浮点运算,以及为什么浮点运算会有精度问题。
可以想想 money 这样的场景,如果使用 float 是有误差的,对用户来说不可接受,所以都用 integer 和一个 10 为底的指数来表示,dec64 可以得到类似的效果。
在 dec64 下面,上面说的 0.1 0.2 等等 都是可以无误差表示的,只是 dec64 是有范围限制,差不多到 10^143,而 double 可以到 10^308。
IEEE 754 标准 已经做到硬件里面去了,它的计算效率就非常的高,像 dec64 这种只能用软件实现(有提供 asm 实现),效率有差别。
这和 primitive 的整数有有符号和无符号之分、范围限制、溢出问题之类某种程度上是同一个性质。同样我们可以问:primitive 整数有溢出是不是 bug ?
不同的在于,primitive 整数溢出可以是 bug——一些编程语言包含了边界检查的语义,在出现溢出时会抛出异常。但是浮点运算从定义上就是不准确的,因此不算做 bug。
之所以说它们有共同之处,是因为它们都是在计算机上实现计算过程时做出的妥协。
比如说,Java 有 Primitive Type 和非 Primitive Type 之分,Primitive Type 成为了 Java 的”一切皆对象“原则上的一个漏洞( web.archive.org/web/20081012113943/http://www.eecs.harvard.edu/~greg/cs256sp2005/lec15.txt )。可见 Primitive Type 造成的问题不仅仅是“运算不精确”一个。主的问题”为什么至今仍然有很多开发语言有这个问题呢“首先需要解决的是“既然 Primitive Type 如此麻烦,为什么还要保留 Primitive Type 呢?”并且不只 Java,几乎所有编程语言都具有 Primitive Type,这是为什么呢?
理论上,使用非常精简的规则,即可表达出所有计算需要的东西,包括数字( https://en.wikipedia.org/wiki/Church_encoding ),因此,Primitive Type 在理论上是不必要的。但是在实际应用的编程语言中,很少有大量应用 Church Encoding 的,相反,Primitive Type 被广泛使用。
因为“In theory, theory and practice are the same. In practice, they are not.”
如果把整个计算机看做一个系统的话,编程语言是开发者和计算机之间的接口,ISA 是软件和硬件之间的接口,这些接口都是越简洁越好。图灵机的基本规则也非常简单,如果只是实现一个计算机的话,几条指令完全够用了。
上世纪著名的 PDP 系列有很多是硬件不支持乘法 /除法操作的。Intel 在 8086 之前也不支持,而一直到 486 之前,浮点运算还要靠 Coprocessor。一直到现在的 RISCV,乘除法、浮点还是 Extension,核心指令集只有不到 50 条指令。这个规模并不比 UNIX 最早用的早期 PDP-11 要小多少。
同样的原则可以推出,GPU 所做的工作 CPU 也可以做,GPU 是完全没必要的东西。老黄赚的钱全是炒概念的智商税,AMD 也没必要养着 RTG。包括挖矿什么的也可以通通使用 CPU 搞定。
但是另一方面,RISC-V 也有各种扩展(很多还没做完),现在的 x86 算上各种扩展已经有了一千多条指令。ARM 的核心指令集规模和其他 RISC 类似,但是随便一个 SIMD 扩展甩出来就是几十上百条指令。而现在不仅 CPU 和 GPU 很火,还加入了乱七八糟的 FPGA、TPU 之类的东西,老黄还多此一举的把 RT core 做进了 GPU 里面。这似乎与我们所追求的简洁构成了某种矛盾,最重要的是,老黄又赚了我们一波智商税。
我在 V 站说明过“编程语言的设计可以影响给到编译器的程序信息的量,进而影响优化编译器的优化效果”( https://v2ex.com/t/632869#r_8401400 )以及“高级语言抽象好,低级语言上限高”( https://v2ex.com/t/594287#r_7803885 )的原理。同样的原则也适用在硬件上——硬件在执行计算时需要“我要执行什么计算”的信息,而 Church Encoding 之类的通用表示方法之所以没法用,就是因为它太通用了导致硬件得到的信息太少,执行效率太低——一个 C++ 程序可以被编译为机器码,但是给你一坨 C++ 编译出来的机器码(经过较多优化,无调试符号),不能反编译出原始的 C++ 程序,甚至就算再把原始 C++ 程序给你,把 C++ 代码和机器代码的位置对应起来在没有调试符号的情况下都是个难题,大量的高层程序信息在转换为具体的、底层的机器表示的过程中逐渐不可逆地丢失了。现代 CPU 会利用各种手段以利用更多的程序信息,达到更高的执行效率,但是当程序信息本身就不足时,硬件厂商也无能为力,所以现在硬件厂商宁愿教育开发者多写 “Modern Code” (虽然最后开发者还是更喜欢 Electron )来最大化硬件使用率,提高执行效率(这里的极端便是上个十年的 VLIW 架构——抛弃 CPU 部分的 hack 来简化硬件,寄希望于软件(包括编译器)能给出更多的信息)。另一方面,硬件厂商需要给出用来表示高效代码执行所需的接口,这就是各种乱七八糟的指令集扩展和非通用硬件。
硬件本身则通过 Chip Specialization 的方法,来最大化这些信息的利用。什么是 Specialization ?比如说我们知道整数 a * 8 等价于 a << 3,那么编译器如果有“a 是整数”和“表达式 a * 8”这样的信息,便可以把 a * 8 specialize 为 a << 3。Specialization 要求获得足够的信息,如果编译器不知道 a 的类型,或者遇到“a * b”这样的表达式( b 的值无法推导),就没有办法做 Specialization。
半导体中的 Chip Specialization 则是指对特定已知的计算,直接使用芯片硬件电路实现,而不是用通用的方法(先实现一个图灵完全的指令集,弄一个 CPU,再写软件实现算法)。这样做可以用更少的功耗,对特定计算实现更高的性能——因为算法直接在硬件实现,并且会用经过优化的方法实现。用软件实现和用优化的硬件实现的区别,就像用 Python 实现 FFT 算法性能不如直接调用 scipy 库一样——Python 直接实现的算法,在运行时除了你自己,计算机是不知道它在做 FFT 的,这个信息在源码之后就被丢失了。scipy 库则可以利用“我现在正在做的是 FFT”这项信息给出最优的实现,前提是你通过“调库”的方式,把这个信息告诉计算机。
GPU 是对图形运算的 Specialization,GPGPU 则是对 SIMT 模型的 Specialization,RT core 是对光线追踪算法的 Specialization,现在手机厂商争相加入的 AI 芯片,则是对 AI 算法的 Specialization,苹果为新 Mac Pro 推出了 Afterburner 加速卡,貌似是用 FPGA 做的,可以看做是对 ProRes 格式的 Specialization。
当然,越是做 Chip Specialization,就越会发现 Chip Specialization 的能力是有极限的,这就是现在半导体所讲的 The Accelerator Wall ( https://parallel.princeton.edu/papers/wall-hpca19.pdf )——芯片厂商在把常见算法都用硬件实现一遍之后就又没事可做了,现在看上去大家都在搞 Chip Specialization,只是因为之前都在搞通用处理器,没有来得及充分利用 Chip Specialization 的潜力而已,等到这波“红利”吃完了,还是会回到通过爬制程工艺,堆核扩大芯片规模来提升性能和能耗比的老路。
Chip Specialization 不仅体现在 AI、挖矿、光追等“高大上”领域,不同位数的整数运算、乘除法运算、浮点运算同样也属于 Chip Specialization,只不过这些早就普及了。也正是因为这些东西普及率高,工业上的通用编程语言才会设计 Primitive Type,作用正是允许程序员将优化需要的信息 encode 在程序中,从而方便编译器或硬件的 Specialization (一般做成 Primitive Type 的,在整个系统栈中的某个或多个位置都会有 Specialization,比如上面提到某些处理器没有提供硬件乘法指令,这时编译器会调用一个优化过的库函数来做乘法)。
需要注意的是,Primitive Type 和底层 Specialization 的对应关系,并不能动摇 Primitive Type 本身更像个 hack 的性质。Primitive Type 实际在程序语言中形成了某种边界模糊的 DSL,而将 Specialization 抽象为 DSL 的做法在最近越来越 explicit,比如 CUDA 则是程序员为 NVIDIA GPGPU 这一 Specialization 提供计算信息的工具,同样的现象出现在 AI 领域。
所以 C 语言标准里会针对各种 Primitive Type 做出“至少 32 位”之类奇怪的限制,因为这些 Primitive Type 直接对应硬件或软件的 Specialization 或某个可以用来做 Specialization 的标准。
无限范围 /精度的整数和实数在理论上是不能使用有限空间存储的,并且实现会比固定范围更复杂,而大多数情况下,其带来的好处无法 justify 其成本。最后形成的妥协便是:使用固定位数、有限精度的整数和浮点数来进行大多数的计算。在编程语言中做 Primitive Type,在编译器和库中针对这些类型做优化,在硬件中针对这些类型的运算做 Specialization。
“任何人都不想得到不准确的结果吧”同样的话可以这么说“任何人都不想内存空间受限制吧”“任何人都不想网速有个最大值吧”“任何人都不想一次航班要好几个小时吧”“任何人都不想钱能花完吧”。
浮点数只是系统给你提供的一个选择,当固定位数的整数 /浮点数无法满足你的需求时,你可以选择使用其他手段,就像在编程语言中定义新的函数、类型一样。比如使用符号计算,把你的公式本身(而不是公式运算出的值)存储起来,计算机来做化简,什么数都可以表示。如果主够厉害,够有钱,可以使用 Chip Specialization 的方式把这套系统用硬件实现,并做成编程语言的 Primitive Type (或一套 DSL )。就不会有这种问题了。
真正的 bug 出在主的认识里。“浮点数”从定义上就是有理数的一个子集而不是实数,也不是有理数。各种 Primitive Integer Type 一般也对应的是整数的一个子集而不是整数。主将“浮点数”默认为“小数”或“实数”导致出现了这样的疑问。但是有没有想过,如果“浮点数”等于“实数”的话,为什么要叫“浮点数”这个奇怪的名字而不是“实数”呢。
当然有些编程语言不负责任地定义了一个名字叫 “real” 类型,却用浮点数实现。real 这个名字上包含所有实数,但是只能包含有理数的一个子集。同理有些语言有名叫“int”或“integer”的类型,但是只能包含整数的一个子集。这种挂羊头卖狗肉的行为已经超越了 bug 的范畴,我个人是支持批判的。但是如果名为 “float”“single”“double”,用浮点数实现,只能表示部分有理数,这是预期行为,不是 bug。
#7 「很多人看了点教科书,以为自己懂了,其实压根没懂。」
我觉得你应该能理解层主要表达啥,就不多说了
数值分析(英语:numerical analysis ),是指在数学分析(区别于离散数学)问题中,对使用数值近似(相对于一般化的符号运算)算法的研究。
https://zh.m.wikipedia.org/wiki/%E6%95%B0%E5%80%BC%E5%88%86%E6%9E%90#%E8%AA%A4%E5%B7%AE%E7%9A%84%E7%94%A2%E7%94%9F%E5%8F%8A%E5%82%B3%E6%92%AD
那句话只怕要你自己拿回去哦。
https://gywbd.github.io/posts/2015/9/floating-point-number.html
2. 标题里说 “任何人都不想得到不准确的结果”,这里的任何人应该是指任何非专业人员,即普通用户。
3. 专业程序员会想得到不准确的结果吗,答案是:会!
– 专业程序员会根据具体的需求,在不需要非常精确的情况下,优先考虑运行效率。
4. 现在普遍使用的电脑( CPU ),其底层基本都是二进制计算的,因此在计算十进制的时候会有些“小问题”。注意,这是电脑的物理结构决定的,是电脑的本质特征之一。
5. 编程语言可以掩盖这个特征,但是,也可以选择暴露这个特征。编程语言作为一种专业领域的底层专业工具,一般来说,暴露这个特征是更好的选择。
6. 任何专业领域的底层专业工具都是这样的,都会有一些普通用户看起来奇怪、用起来容易出错的地方,这是专业工具的特点。
2、原贴的意义在于讨论“编程语言的问题”,而不是数集问题,更不是进制问题。
3、这个帖子下混战的太多了。然而都巧妙避开了主最关心的问题。A 指出,无理数无法正确表示。B 攻击 A,说这根本不是无理数的问题,而是二进制数没办法表示所有十进制小数的问题,如果你祖先只有 8 个手指那就可以表示了。C 站起来给 B 一巴掌,你放屁,你用八进制,去表示一个无理数 sqrt(2)给我看看? D 临空飞踹 C,怒吼道,本来是在讨论运算精度的问题,你非要精确表示一个数? E 听不惯了,揪着 D 的耳朵咬着牙皮笑肉不笑地说:你再给我说一遍,我开 2 的方怎么就不是运算了?? F,G,H,J……………………..
4、所有里,回答比较贴切的有
#9 @agagega
#46 @ipwx
#49 @lrxiao
#55 @aliipay
请大家直接点赞就行。当然了,我也没看仔细,也许有些同学说的也对。
但是有些同学呐,不要一上来就解决提出问的人,也不要连题目都看不懂就开始说这是“feature”。咱,不会的问题,就不要强答,不好吗?
5、我之前发过一篇帖子,题目是“如何解决手里有把锤子,看什么都是钉子的问题”,其实恰恰就是担心自己会出现这种生搬硬套自己熟悉的理论,去解决毫不相干的问题的这种行为。因为,真的不显得比人更聪明啊,反倒是十分勉强。
另外并不是所有的浮点数都不能精确表示,像 0.1、0.25 是可以精确表示的。不能表示的点就无法画在数轴上。
但是如果是一种 “编程语言” 的作者或委员会,则一般不会要求把编程语言设计成那样。
1. 是的,有意义,意义在于与硬件行为保持一致以便程序编写者能更好地利用上硬件,并优化代码逻辑。
2. 不是 bug,而是数学-工程实现 非完全一致带来的必然代价。有很多数学模型工程上都无法精确实现,只能近似。比如几乎所有的编程语言都是“图灵完全”的,但没有任何一种语言能写出图灵机,因为图灵机模型描述的无限长纸带是不可实现的。浮点数的问题也是“近似实现”带来的。
3. 为了追求数学上的精确,还有非常多的办法,也有专门的用数学语言编程的编程语言。但它们的目标是去解决数学问题,由于硬件限制,这种语言一般完全达不到工业语言的性能水平。所以,事实上有解决了浮点精度 /符号计算问题的语言,但它们不适合写工业代码;也有充分暴露硬件实现以便编码者优化逻辑的工业用编程语言,但它们的计算有精度限制,与数学运算不完全等价。
取舍问题