【數據與安全】- 浮點數精度問題詳解

簡介

由于計算機存儲的規則所致,有些時候浮點數存入和讀取出來的值并不相等,同樣的數據用單精度(float)和用雙精度(double)存儲,獲取出來的值也會有差異。所以,當我們開發對精度要求比較高的業務場景下,如果不了解,很可能出現直接的經濟損失。針對這些問題,接下來用一篇文章來詳細講解這些問題和解決方案。

例子

public static void main(String[] args) {
    double d1 = 0.1;
    double d2 = 0.1f;
    float d3 = 0.1f;
    System.out.println("d1=" + d1);
    System.out.println("d2=" + d2);
    System.out.println("d3=" + d3);
}

輸出:

d1=0.1
d2=0.10000000149011612
d3=0.1
  • 第一個問題
    為什么d1d2打印的結果不同,d2打印的值為什么不是0.1,那d2后面149011612又是怎么來的呢?
  • 第二個問題
    都說浮點數存在精度問題,當讀取的時候會和存入時的值不同,那為何d3打印出來就是0.1呢?難道d3在計算機里存的本來就是0.1

如果想要解釋上面的問題,那么就需先了解浮點數在計算機中的存儲方式(遵循IEEE 754(IEEE Standard for Floating-Point Arithmetic)標準)。

十進制數轉換為二進制數

  • 整數轉二進制
    十進制整數轉換為二進制整數采用"除2取余,逆序排列"法。
  • 十進制小數轉換為二進制小數
    十進制小數轉換成二進制小數采用"乘2取整,順序排列"法。具體做法是:用2乘十進制小數,可以得到積,將積的整數部分取出,再用2乘余下的小數 部分,又得到一個積,再將積的整數部分取出,如此進行,直到積中的小數部分為零,或者達到所要求的精度為止。 然后把取出的整數部分按順序排列起來,先取的整數作為二進制小數的高位有效位,后取的整數作為低位有效位。
    aa.png

浮點數存儲格式

  • IEEE 754規定,對于32位的浮點數(單精度float),最高的1位是符號位s,接著的8位是指數E,剩下的23位為有效數字M。


    float.png
  • 對于64位的浮點數(雙精度double),最高的1位是符號位S,接著的11位是指數E,剩下的52位為有效數字M。


    double.png
IEEE 754對有效數字M和指數E的一些特別規定。
  • 1≤M<2,也就是說,M可以寫成1.xxxxxx的形式,其中xxxxxx表示小數部分。IEEE 754規定,在計算機內部保存M時,默認這個數的第一位總是1,因此可以被舍去,只保存后面的xxxxxx部分。比如保存1.01的時候,只保存01,等到讀取的時候,再把第一位的1加上去。這樣做的目的,是節省1位有效數字。以32位浮點數為例,留給M只有23位,將第一位的1舍去以后,等于可以保存24位有效數字。
至于指數E,情況就比較復雜。
  • 首先,E為一個無符號整數(unsigned int)。這意味著,如果E為8位,它的取值范圍為0 ~ 255;如果E為11位,它的取值范圍為0 ~ 2047。但是,我們知道,科學計數法中的E是可以出現負數的,所以IEEE 754規定,E的真實值必須再減去一個中間數,對于8位的E,這個中間數是127;對于11位的E,這個中間數是1023。

    比如,2^10的E是10,所以保存成32位浮點數時,必須保存成10+127=137,即10001001。
    
  • 然后,指數E還可以再分成三種情況:

    1. E不全為0或不全為1。這時,浮點數就采用上面的規則表示,即指數E的計算值減去127(或1023),得到真實值,再將有效數字M前加上第一位的1。

    2. E全為0。這時,浮點數的指數E等于1-127(或者1-1023),有效數字M不再加上第一位的1,而是還原為0.xxxxxx的小數。這樣做是為了表示±0,以及接近于0的很小的數字。

    3. E全為1。這時,如果有效數字M全為0,表示±無窮大(正負取決于符號位s);如果有效數字M不全為0,表示這個數不是一個數(NaN)。

浮點數舍入規則

而 IEEE 754 就是采用向最近偶數舍入(round to nearest even)的規則。

  • 向丟失精度最小的方向向上/向下舍入
  • 如果向上/向下舍入丟失精度一樣,者向偶數舍入

比如0.1的二進制是1.10011001100110011001100110011001100110011001100110011...
根據單精度有效位M長度時23:
1.10011001100110011001100110011001100110011001100110011...,顯然向上舍入丟失的精度更小,所以0.1單精度存儲為:10011001100110011001101而不是10011001100110011001100

舉例

這里以小數0.1以單精度和雙精度存儲位例進行講解。首先把0.1轉換成二進制的形式是(轉換網站

000110011001100110011001100110011001100110011001100110011...
  • 0.1以單精度存儲
    根據上面的存儲規則和有效數 M表示規則,需要把0.1表示的二進制向右移動4位,相當于乘以2^4,那么可以得出指數部分應該就是 127 - 4 = 123,二進制就是01111011 ,由于向右移動4位,那么上面二進制變成1.10011001100110011001100110011001100110011001100110011...,由于M的第一位可以不存儲,二單精度的有效位是23,那么截取小數點后23位存儲下來,其它舍去,在根據浮點數舍入規則最終存入的有效位二進制:10011001100110011001101,加上第一位符號位,那么在0.1在計算機存儲為:
        0        01111011      10011001100110011001101
    符號位(S)     指數位(E)            有效位(M)
    
  • 0.1以雙精度存儲(64位)
    0          01111111011 1001100110011001100110011001100110011001100110011010
    符號位(S)     指數位(E)            有效位(M)
    

二進制小數轉十進制

以上面0.1單精度存儲二進制1. 10011001100110011001101進行轉換:

2^0 + 2^-1 + 2^-4 + 2^-5 + 2^-8 + 2^-9 + 2^-12 + 2^-13 + 2^-16 + 2^-17 + 2^-20 + 2^-21 + 2^-23
上面加起來的值在乘以指數2^-4就得到0.1存儲在計算機的值了。
經過計算上面最終的值是:0.10000000149011612...

浮點數的有效位數

  • 單精度的尾數用23位存儲,加上預設的小數點前不做存儲的1這一位,那么可以表示的最大數:2^(23+1) = 16777216。因為 10^7 < 16777216 < 10^8,所以說單精度浮點數的有效位數是7位。

  • 雙精度的尾數用52位存儲,2^(52+1) = 9007199254740992,因為10^16 < 9007199254740992 < 10^17,所以雙精度的有效位數是16位。

解釋上面的問題

  • float d1 = 0.1f輸出為什么是0.1
    根據上面講解的點數的有效位數,單精度最大保留8位有效數。所以截去多余的就變成0.10000000即為0.1,這里看上去好像沒有出現精度問題。

  • double d1 = 0.1f輸出為什么是0.10000000149011612
    首先,'0.1f'按照單精度進行存儲,讀取出來的值賦值給雙精度d1,根據上面講解的點數的有效位數,雙精度最大保留17位有效數,截去多余部分:0.10000000149011612

  • double d1 = 0.1輸出為什么是0.1而不是0.10000000149011612
    這里0.1按照雙精度存儲,由于雙精度的尾數用52位存儲,精度更高,大家可以把52位二進制加起來算一下,把最后得到的數保留17位有效數,看是不是也是 0.1,答案,是肯定的。這里有個簡單的方式打印

public static void main(String[] args) {
    System.out.println("aa= " + new BigDecimal(0.1));
}

輸出

aa= 0.1000000000000000055511151231257827021181583404541015625

精度丟失

public static void main(String[] args) {
    float d4 = 111111.01111111f;
    System.out.println("d4=" + d4);
}

輸出

d4=111111.01

大家可以按照上面的思路轉換一下。可以見得,使用浮點數時,如果整數部分越大,小數精度丟失越嚴重。

精度丟失解決辦法

BigDecimal

Java的在使用除法(divide方法)時,應該手動指定精度和舍入的方式。如果不指定精度和舍入方式,在除不盡的時候會報異常。

public static void main(String[] args) {
  System.out.println("aa= " + new BigDecimal("1.0").divide(new   BigDecimal("3.0"),1170,BigDecimal.ROUND_HALF_UP));
}
Half

半精度,使用優勢:

  • float16和float相比恰里,總結下來就是兩個原因:內存占用更少,計算更快。

  • 內存占用更少:這個是顯然可見的,通用的模型 fp16 占用的內存只需原來的一半。memory-bandwidth 減半所帶來的好處:

    1. 模型占用的內存更小,訓練的時候可以用更大的batchsize。
    2. 模型訓練時,通信量(特別是多卡,或者多機多卡)大幅減少,大幅減少等待時間,加快數據的流通。
  • 計算更快:目前的不少GPU都有針對 fp16 的計算進行優化。論文指出:在近期的GPU中,半精度的計算吞吐量可以是單精度的 2-8 倍

半精度,缺點:

  • 數據溢出問題
  • 舍入誤差
BigInteger(不可變的任意精度有符號整數)

這個應該就是表示任意大小的整數類,里面用了一個數組來存儲。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容