從 IEEE 754 標準來看為什麼浮點誤差是無法避免的

Larry Lu
Starbugs Weekly 星巴哥技術專欄
7 min readDec 25, 2019

You can find the English version at Why 0.1 + 0.2 ≠ 0.3: A Deep Dive into IEEE 754 and Floating-Point Arithmetic.

前言

很多人在初學寫程式時都會遇到所謂的浮點誤差,如果你到目前都還沒被浮點誤差雷過,那只能說你真的很幸運XD

以下圖 Python 的例子來說 0.1 + 0.2 並不等於 0.38.7 / 10 也不等於 0.87,而是 0.869999…,真的超怪 der 🤔

但這絕對不是什麼神 bug,也不是 Python 設計得不好,而是浮點數在做運算時必然的結果,所以即便是到了 Node.js 或其他語言也都是一樣

電腦如何儲存一個整數(Integer)

在講為什麼會有浮點誤差之前,先來談談電腦是怎麼用 0 跟 1 來表示一個 整數,大家應該都知道二進制這個東西:像 101 代表 2² + 2⁰ 也就是 5、1010 代表 2³ + 2¹ 也就是 10

如果是一個 unsigned 的 32 bit 整數,代表他有 32 個位置可以放 0 或 1,所以最小值就是 0000...0000 也就是 0,而最大值 1111...1111 代表 2³¹ + 2³⁰ + … + 2¹ + 2⁰ 也就是 4294967295

從排列組合的角度來想,因為每一個 bit 都可以是 0 或 1,整個變數值有 2³² 種可能性,所以可以 精確的 表達出 0 到 2³²-1 中任一個值,不會有任何誤差

浮點數(Floating Point)

雖然從 0 到 2³²-1 之間有很多很多個整數,但數量終究是 有限 的,就是 2³² 個那麼多而已;但浮點數就大大的不同了,大家可以這樣想:在 1 到 10 這個區間中只有十個整數,但卻有 無限多個 浮點數,譬如說 5.1、5.11、5.111 等等,再怎麼數都數不完

但因為在 32 bit 的空間中就只有 2³² 種可能性,為了把所有浮點數都塞在這個 32 bit 的空間裡面,許多 CPU 廠商發明了各種浮點數的表示方式,但若各家 CPU 的格式都不一樣也很麻煩,所以最後是以 IEEE 發佈的 IEEE 754 作為通用的浮點數運算標準,後來的 CPU 也都遵循這個標準進行設計

IEEE 754

IEEE 754 裡面定義了很多東西,其中包括單精度(32 bit)、雙精度(64 bit)跟特殊值(無窮大、NaN)的表示方式等等

正規化

以 8.5 這個符點數來說,如果要變成 IEEE 754 格式的話必須先做正規化:把 8.5 拆成 8 + 0.5 也就是 2³ + 1/2¹,接著寫成二進位變成 1000.1,最後再寫成 1.0001 x 2³,跟十進位的科學記號滿像的

單精度浮點數

在 IEEE 754 中 32 bit 浮點數被拆成三個部分,分別是 sign、exponent 跟 fraction,加起來總共是 32 個 bit

  • sign:最左側的 1 bit 代表正負號,正數的話 sign 就為 0,反之則是 1
  • exponent:中間的 8 bit 代表正規化後的次方數,採用的是 超127 格式,也就是 3 還要加上 127 = 130
  • fraction:最右側的 23 bit 放的是小數部分,以 1.0001 來說就是去掉 1. 之後的 0001

所以如果把 8.5 表示成 32 bit 格式的話就會是這樣:

這圖我畫超久的,請大家仔細看XD

什麼情況下會不準呢?

剛剛 8.5 的例子可以完全表示為 2³+ 1/2¹,是因為 8 跟 0.5 剛好都是 2 的次方數,所以完全不需要犧牲任何精準度

但如果是 8.9 的話因為沒辦法換成 2 的次方數相加,所以最後會被迫表示成 1.0001110011… x 2³,而且還會產生大概 0.0000003 的誤差,好奇結果的話可以到 IEEE-754 Floating Point Converter 網站上玩玩看

雙精度浮點數

上面講的單精度浮點數只用了 32 bit 來表示,為了讓誤差更小,IEEE 754 也定義了如何用 64 bit 來表示浮點數,跟 32 bit 比起來 fraction 部分大了超過兩倍,從 23 bit 變成 52 bit,所以精準度自然提高許多

以剛剛不太準的 8.9 為例,用 64 bit 表示的話雖然可以變得更準,但因為 8.9 無法完全寫成 2 的次方數相加,到了小數下 16 位還是出現誤差,不過跟原本的誤差 0.0000003 比起來已經小了很多

類似的情況還有像 Python 中的 1.00.999...999 是相等的、123122.999...999 也是相等的,因為他們之間的差距已經小到無法放在 fraction 裡面,所以就二進制的格式看來他們每一個 bit 都一樣

解決方法

既然無法避免浮點誤差,那就只好跟他共處了(打不過就加入?),這邊提供兩個比較常見的處理方法

設定最大允許誤差 ε (epsilon)

在某些語言裡面會提供所謂的 epsilon,用來讓你判斷是不是在浮點誤差的允許範圍內,以 Python 來說 epsilon 的值大概是 2.2e-16

所以你可以把 0.1 + 0.2 == 0.3 改寫成 0.1 + 0.2 — 0.3 <= epsilon,這樣就能避免浮點誤差在運算過程中作怪,也就可以正確比較出 0.1 加 0.2 是不是等於 0.3

當然如果系統沒提供的話你也可以自己定義一個 epsilon,設定在 2 的 -15 次方左右

完全使用十進位進行計算

之所以會有浮點誤差,是因為十進制轉二進制的過程中沒辦法把所有的小數部分都塞進 fraction,既然轉換可能會有誤差,那乾脆就不要轉了,直接用十進制來做計算!!

在 Python 裡面有一個 module 叫做 decimal,它可以幫你用十進位來進行計算,就像你自己用紙筆計算 0.1 + 0.2 絕對不會出錯、也不會有任何誤差(其他語言也有類似的模組)

自從我用了 Decimal 之後不只 bug 不見了,連考試也都考一百分了呢!

雖然用十進位進行計算可以完全躲掉浮點誤差,但因為 Decimal 的十進位計算是模擬出來的,在最底層的 CPU 電路中還是用二進位在進行計算,所以跑起來會比原生的浮點運算慢非常多,所以也不建議全部的浮點運算都用 Decimal 來做

總結

回歸到這篇文章的主題:「為什麼浮點誤差是無法避免的?」,相信大家都已經知道了

至於你說知道 IEEE 754 的浮點數格式有什麼用嗎?好像也沒什麼特別的用處XD,只是覺得能從浮點數的格式來探究誤差的成因很有趣而已,感覺離真相又近了一點點

而且說不定哪天會有人問我「為什麼浮點運算會產生誤差而整數不會」,那時我就可以有自信的講解給他聽,而不是跟他說「反正浮點運算就是會有誤差,背起來就對了」

後記

這是我第一次寫這種幾乎是純原理的文章,不管你是覺得很有趣,還是覺得太理論了不知道學這要幹嘛,都歡迎你在下方留言跟我說,或是透過拍手表達你的意見,這樣我也比較能知道你們喜歡什麼類型的文章,謝謝大家~

參考資料

--

--

Larry Lu
Starbugs Weekly 星巴哥技術專欄

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~