浮点数那些事儿

浮点数用来编码形如V = x \times 2^y的有理数,在涉及非常大的数字(|V|\gg0)、非常接近于 0 的数字(|V|\ll0)或者作为实数的近似值的运算中很有作用。

浮点数的不精确性

打开命令行中的 jshell 工具,在里面输入“0.3 + 0.6”,然后会得到如下结果:

jshell> 0.3 + 0.6
$1 = 0.8999999999999999

可以看到一个简单的加法,竟然没办法得出一个精确的结果,其原因就是计算机使用浮点数表示小数,而浮点数只能进行实数的近似值的运算,像 0.3,0.6,0.9 这些小数都是没办法精确表示的。

而这也是很好理解的事情,如果计算机使用 32 比特编码一个数字,理论上可以表示 2 的 32 次方个数(约 40 亿),而这显然没办法精确表示整个实数集。

那为什么计算机连 0.3,0.6 这些简单的小数也无法精确表示呢?
这和计算机采用的浮点表示法有关。接下来本文将介绍浮点表示法的具体编码方式,并探讨编程中如何使用浮点数以及如何处理因为浮点的不精确性可能导致的问题。


二进制小数

形如
b_m,b_{m-1},...,b_1,b_0.b_{-1},b_{-2},...b_{-(n-1)}b_{-n}
这样的表示法,其中每个二进制位b_i的取值为 0 或 1,这样的二进制小数表示的数 b 的定义如下:
b = \sum_{i=-n}^{m}2^i \times b_i
所以小数的二进制表示法仅能表示可以写成x \times 2^y形式的数,其它的数只能近似表示。

按以上编码方式,二进制小数的表示范围是极其有限的,这种固定小数点位置的表示方法(定点表示法)不能有效地表示特别大的数字。


IEEE 浮点表示

通过给定 x 和 y 的值来表示形如x \times 2^y的数,可以使用有限长度有效地表示特别大的数字。IEEE 浮点标准用V = (-1)^s \times M \times 2^E的形式来表示一个数:

  • 符号
    一个单独的符号位 s 直接编码符号 s, 决定这个数是负数(s=1)还是正数(s=0)
  • 尾数
    n 位小数字段frac = f_{n-1}...f_1f_0编码尾数 M
  • 阶码
    k 位阶字段exp = e_{k-1}...e_1e_0编码阶码 E

单精度浮点格式(C 语言 float 类型)中 s、exp、frac 三个字段分别为 1 位,k=8 位,n=23 位,得到一个 32 位的表示;双精度浮点格式(C 语言 double 类型)中 s、exp、frac三个字段分别为 1 位,k=11 位,n=52 位,得到一个 64 位表示。

根据 exp 的值,被编码的值可以分为三种不同的情况:

  • 规格化的值:阶码位既不全为 0,也不全为 1 的情况

阶码位被解释为以偏置形式表示的有符号数,也就是说阶码的值 E = e - Bias,其中 e 为无符号数,其位表示为e_{k-1}...e_1e_0,Bias 是一个等于2^{k-1}-1的偏置值(单精度127,双精度1023)。

尾数位被解释为描述小数值 f,其中 0 ≤ f <1,其二进制表示为0.f_{n-1}...f_1f_0。尾数定义为 M = 1 + f,这种方式也叫做隐含的以 1 开头的表示。

  • 非规格化的值:阶码位全为 0

阶码的值 E = 1 - Bias,尾数的值为 M = f。

非规格化提供了表示数值 0 的方法;另外还可以表示非常接近 0.0 的数,它们具有逐渐溢出的属性,可能的数值分布均匀地接近 0.0。

  • 特殊值:阶码位全为 1

当尾数位全为 0 时,表示无穷;尾数位非 0 时,表示“NaN”,即“不是一个数(Mot a Number)”。

关于浮点数的一些其它性质

  • 非规格化数 E 定义为 1-Bias 而不是 -Bias,可以补偿非规格化数的尾数没有隐含的开头的 1,使非规格化数和规格化数之间平滑转变。

  • 将浮点数的位表达式解释为无符号整数,对于正数而言其大小关系不变,这样设计是为了浮点数能够使用整数排序函数来进行排序。处理负数时大小关系相反。


舍入

浮点数无法精确的表示实数,对于值 x,通过一种方法,找到最接近的值 x',它可以用浮点形式表示出来,这就是舍入运算的任务。

IEEE 浮点格式定义了四种不同的舍入方式,默认方式是向偶数舍入,而其它三种可以用于计算上界和下界。

  • 向偶数舍入
    也称为向最接近的值舍入,是默认的方式,对于两个可能结果的中间数值,采用向上或向下的舍入方式,使结果的最低有效位数字为偶数。应用于二进制小数时,可以认为 0 是偶数, 1 是奇数。
    这样做的好处是可以避免向上舍入或向下舍入引入的的统计偏差,50% 的时间里它将向上舍入,50% 的时间里它将向下舍入。

  • 向零舍入
    正数向下舍入,负数向上舍入,得到\widehat x,使得|\widehat x| ≤ |x|

  • 向上舍入
    得到x^+,使得x ≤ x^+

  • 向下舍入
    得到x^-,使得x ≥ x^-


转换

C 语言中 int、float 和 double 之间进行类型转换时,遵循的原则如下:

  • int 转换成 float,数字不会溢出,但可能被舍入
  • int 或 float 转换成 double,能够保留精确的值
  • double 转换为 float,可能溢出或被舍入
  • float 或 double 转换成 int,值会向零舍入。同时可能溢出,C 标准没有对这种情况制定固定的标准。与 Intel 兼容的微处理器指定位模式[1000...](字长为 ω 时的 TMin_ω)为整数不确定值。一个从浮点数到整数的转换,如果不能为该浮点数找到一个合理的近似值,就会产生这样一个值。

浮点数的加法和精度损失

浮点数的加法实现原理就是先对齐、再计算。两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位变成一样的,然后只去计算有效位的加法就好了。例如 0.5+0.125 的浮点数运算的过程如下图所示。

浮点数加法

指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就丢失精度。两个相加数的指数位差的越大,位移的位数越大,可能丢失的精度也就越大。

32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。这也就意味着,只要两个数,差出 2^{24},也就是差不多 1600 万倍,那这两个数相加之后,结果完全不会变化。

运行下面的代码你会发现,一个值为 2000 万的 32 位浮点数和 1 相加,+1 这个过程因为精度损失,被“完全抛弃”了。

public class FloatPrecision {
  public static void main(String[] args) {
    float a = 20000000.0f;
    float b = 1.0f;
    float c = a + b;
    System.out.println("c is " + c);
    float d = c - a;
    System.out.println("d is " + d);
  }
}

输出结果:

is 2.0E7
d is 0.0

Kahan Summation 算法

一个常见的应用场景是,在一些“积少成多”的计算过程中,比如在机器学习中,我们经常要计算海量样本计算出来的梯度或者 loss,于是会出现几亿个浮点数的相加。每个浮点数可能都差不多大,但是随着累积值的越来越大,就会出现“大数吃小数”的情况。

看下面这个程序,用一个循环相加 2000 万个 1.0f,最终的结果会是 1600 万左右,而不是 2000 万。这是因为,加到 1600 万之后的加法因为精度丢失都没有了。

public class FloatPrecision {
  public static void main(String[] args) {
    float sum = 0.0f;
    for (int i = 0; i < 20000000; i++) {
      float x = 1.0f;
      sum += x;      
    }
    System.out.println("sum is " + sum);   
  }  
}

输出结果:

sum is 1.6777216E7

有一种叫作 Kahan Summation 的算法可以解决这个问题。算法的对应代码如下:

public class KahanSummation {
  public static void main(String[] args) {
    float sum = 0.0f;
    float c = 0.0f;
    for (int i = 0; i < 20000000; i++) {
      float x = 1.0f;
      float y = x - c;
      float t = sum + y;
      c = (t-sum)-y;
      sum = t;      
    }
    System.out.println("sum is " + sum);   
  }  
}

输出结果:

sum is 2.0E7

这个算法的原理就是在每次的计算过程中,都用一次减法,把当前加法计算中损失的精度记录下来,然后在后面的循环中,把这个精度损失放在要加的小数上,再做一次运算。这个方法常用来解决大量数据累加中,浮点数精度带来的“大数吃小数”问题


最后

本文主要总结了浮点数的表示方式、浮点的舍入算法、C 语言中浮点类型的转换原则等,并介绍了浮点加法精度损失可能导致的问题以及如何通过 Kahan Summation 算法解决“大数吃小数”问题。

本文主要参考自《深入理解计算机系统》第三章和极客时间《深入浅出计算机组成原理》第 15、16 讲,想要了解更多细节和原理,可以查阅上述资料。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,294评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,780评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,001评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,593评论 1 289
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,687评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,679评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,667评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,426评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,872评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,180评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,346评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,019评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,658评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,268评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,495评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,275评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,207评论 2 352