Skip to content

Latest commit

 

History

History
92 lines (57 loc) · 6.12 KB

what-every-programmer-should-know-about-floating-point.md

File metadata and controls

92 lines (57 loc) · 6.12 KB

很奇怪的现象

  1. 为什么 0.1 + 0.2 = 0.300000000000004 ?

原因是计算机使用的二进制浮点数无法精确代表0.1,0.2,0.3 这样的数字,只能近似到最接近它的数字,所以计算未开时前就引入了一些微小的近似误差。而类似0.5 可以被精确表示,因为 0.5=1/2= 2^-1。十进制也有类似问题,比如 1/3,也无法精确表示

  1. 为什么 0.1 + 0.4 = 0.5,貌似不存在上述问题? 因为0.1 和 0.4在用浮点数表示时的近似误差,正好抵消,而 0.5 又可以被精确表示。

IEEE-754 是第一版约定了各类不同型号、架构的计算机上如何表示浮点数,采用的是 Intel 的标准。它定义了浮点数的格式(包括正负0)与反常值(denormal number),特殊值(无穷 (Inf) 与非数值(NaN)),以及这些数值的“浮点运算符”;也指明了四种数值舍入规则和五种例外情况

FP32 格式的浮点数(single precision) 在 大部分 C/C++ 系统里就是 float,无论是 x86 CPU 还是 NV 的 GPU 都是支持的。而 FP16则不在 C/C++ 标准里,需要依赖特殊库,而 PyTorch 或 CUDA 里支持:torch.float16 or torch.half

bfloat16 是 Google 在自家 NPU 上支持的,后来 NV 在 A100 上也支持了。是 s7e8,

fp16 可以简记为 s10e5,即尾数精度为10位,指数为5位。实际存储时是 sign, e,s。

Range: ~5.96e−8 (6.10e−5) … 65504

所以计算机中的浮点数由于位数有限而且是二进制的,所以只能表示有限的小数部分而且分母是2。所以只能精确表示能被分解为2的指数幂的十进制小数。

  1. 为什么在计算机中不用十进制? 因为电子晶体管表示二进制最高效。

3.5 浮点数如何工作?

主要包含两部分:

  1. 尾数:包含数字的小数部分
  2. 指数:负的指数代表数字非常小(接近于0)

特点:

  1. 可以表示非常广的不同维度的数字(受指数的位数限制)
  2. 提供不同维度下相同的相对精度(受尾数的位数限制)
  3. 可以在不同维度间计算:乘以非常大的数和非常小的数都能保留结果上的精度

3.6 标准

有不少特点:

  1. 真实的bit顺序是: sign bit, exponent, 最终是 尾数位
  2. 指数没有符号位,而是需要减去 exponent bias: single 是 127,double 是 1023。这点再加上bit 顺序,让浮点数即使被当作整数,也可以被比较且准确排序。感觉这个没太大用,下文说了有正负零,这个应该是相等。
  3. 由于有符号位(整数也是,但是用补码表示,所以没有负0),所以有正负0: 除了符号位不同,其他位全为0。虽然 bit pattern 不同,但应该被当作0
  4. 同样也有正和负的无穷,指数是全1,而且尾数是全零(哦,就是 1^256?)。出现这种情况主要是指数超过了正值范围(上越界?),或者被0除(除零就是正负无穷了)
  5. 还有一个特殊的不是数字: not a number or (NaN): 指数部分全1,尾数不是全0. 代表众多未定义的行为:比如乘以0和无穷,任何与 NaN 相关的值。所以即使bit一模一样的 NaN,也不应该被当作相等

4. 误差

4.1 近似误差

当给定的要表示的数字长于浮点数的精度,剩余的位数被忽略--数字被近似。有三个原因:

  1. 大的除数(denominators): 任何制数下,当除数越大(不可约),就越需要更多数字来表示。例如 1/1000 无法被少于三位的十进制数字表示,它的倍数(不能被约掉)也不能被表示
  2. 循环小数(Periodical digits): 2/3, 1/7, 5/6 等。而在二进制下,即使是因数分解到5,虽然它在十进制里是有限的,但是5在2机制下,并不是有限的
  3. 无理数

4.2 近似的模式

近似方法的选取非常重要,因为不同方法会引起一些不同的问题。最常见的:

  1. 朝零近似(rounding towards zero):简单地丢弃掉额外的位数。最简单,但是引入大的误差,而且是偏向于0的(无论正数还是复数)
  2. 四舍五入(Rounding half away from zero): 需要截掉的最后一位如果大于等于进制数的一般,就让最后一位加一。虽然能减小误差,但是会引入一个远离0的误差(这个是为啥?不是一半一半么)
  3. Rounding half to even(cuda里的round to nearest even?)。类似四舍五入,但是当五时,只有当能产生一个偶数时才进位。这种方法能减小误差和偏见,所以在记账的时候经常用

4.3 比较浮点数

由于浮点数有近似的误差(表示和计算时),所以大部分情况下浮点数最终结果有微小的偏差。

4.4 不要使用绝对误差边界

所以方案是检查数字之间差值是否相似,而非检查是否完全一致。一般把用来比较的错误叫做 epsilon。 这个值得用相对度量,否则看起来相对小,但是可能对于比较的数字及场景而言非常大了。而当数字特别大,而 epsilon 比最小的近似误差还小(只能用0来标识了?),那所有的比较都会返回“false”

所以,必须确定相对的误差是否比 epsilon 还小

if ( fabs(a-b) < 0.0001) // wrong - don't do this

if( fabs((a-b)/b) < 0.00001 ) // still not right!

4.5 一些边界值

上述 (a-b)/b 场景下,有几个非常重要的特例,比较函数返回的肯定是 false:

  1. 0.0/0.0 是 NaN
  2. 当只有b是0,那么出发会产生"无穷大",引起一场,或即使a特别小的情况下,也比epsilon大
  3. a 和 b 都非常小,但是符号不同,即使它们是离0最近的小数,结果也是 false,因为结果是负数 ?

4.6 误差传播

当单精度浮点数非常小,即使简单的计算也可能包含增加结果误差的情况:

  1. 乘法和除法是“安全”的
  2. 加法、减法是危险的,因为当数字的维度不同,最小维度的数字的位数可能会丢失
  3. 这种数字的损失是不可避免的,可能是无害的(当丢失的精度对结果无影响),或灾难性的(损失被放大,非常强地扰乱了结果)
  4. 如果计算的越多(尤其在一些循环迭代的算法里),就需要考虑这种问题了