為什麼整數會用光空間
這個梯級到目前為止的一切——ALU 做的加法與減法、移位相加乘法器、那慢吞吞的除法器——都作用在整數上。整數很美妙:每個值都精確,答案不是對就是溢位,中間沒有任何模糊地帶。但一個固定寬度的整數是一把刻度均勻、邊緣硬切的尺。用 32 位元,你能得到約四十億以內的整數、多一個都不行,而二分之一你根本無法表示。
科學需要在同一支程式裡同時處理一顆恆星的質量(約 2 times 10^30 公斤)與一個電子的質量(約 9 times 10^-31 公斤),沒有任何整數尺能同時容下兩者。解法是不再讓刻度均勻間隔。與其用固定刻度,不如把每個數寫成一種二進位科學記號——一個分數乘上 2 的某次方——於是可表示的值彼此的間距,在數變大時拉開、在數朝零縮小時擠攏。這正是浮點數所做的:二進位小數點浮動到值所需要的任何位置。
IEEE 754 格式:符號、指數、有效數
幾乎每台機器都遵循同一種佈局,也就是 IEEE 754 標準,這正是為什麼你筆電上存的數字,在手機或超級電腦上讀回時都一樣。一個 32 位的單精度浮點數把位元分成三個欄位:1 個符號位、8 個指數位、23 個分數位。其值為 符號 times 1.分數 times 2^(指數 減 127)。二進位小數點前那個領頭的 1 並不儲存——對正規數而言它永遠都在,所以把它設為隱含的,就白賺到一個位元的精度。那 23 個儲存的分數位加上那隱藏的 1,組成 24 位的有效數。
32-bit IEEE 754 single precision
[ s ][ exponent ][ fraction ]
1 8 23 = 32 bits
value = (-1)^s x 1.fraction(binary) x 2^(exponent - 127)
example: encode -6.5
6.5 = 110.1 (binary) = 1.101 x 2^2
s = 1 (negative)
exponent = 2 + 127 = 129 = 10000001
fraction = 101 0000... (the bits after the implicit 1.)
-> 1 10000001 10100000000000000000000指數採用 127 的偏移而非二補數,好讓浮點數比較時與整數同序,這對硬體是個方便的小技巧。標準保留兩個極端的指數樣式作特殊用途:全為零標記零與極小的次正規數,全為一則標記無限大(當分數為零時)與非數(當分數不為零時)。NaN 是 0/0 或負數開根號的誠實結果——一個說「這個問題沒有實數答案」的值,並接著毒化它之後碰到的每一次計算,於是錯誤永遠不會悄悄地長得像個正常數字。
浮點運算單元內部:一次加法如何運作
把兩個浮點數相加,可不是把它們的位元相加那麼簡單,因為兩個指數不同的數,二進位小數點落在不同的位置。你無法靠把有效數疊起來,就把 1.5 times 2^3 加到 1.0 times 2^0——你得先把小數點對齊,就像十進位裡把 12.0 與 0.34 相加,意味著要寫成讓兩個小數點上下對齊。浮點運算單元(FPU)是一條為了依序完成這種對齊、相加與善後而建的小型專用資料路徑。
- 對齊指數:把指數較小那個數的有效數右移,直到兩者共用較大的指數。要把 1.5 x 2^3 與 1.0 x 2^0 相加,就把第二個右移 3 位,使它變成 0.001 x 2^3。
- 用本質上與前幾篇相同的整數加法器把有效數相加——你已經見過的進位前瞻加法器負責這一步。
- 把總和正規化:將它移位,使二進位小數點前剛好有一個非零位(1.xxxx 的形式),並調整指數相配。像 10.1 x 2^3 這樣的總和會變成 1.01 x 2^4。
- 把結果捨入回有效數可用的位數,接著檢查溢位(指數過大,變成無限大)或下溢(過小,變成次正規數或零)。
那個對齊步驟,正是精度悄悄漏光的地方。當你把較小的數右移以配合指數時,它最低的那些位元就掉出有效數的尾端。若兩數大小相差懸殊——把 1.0 加到 2^30——小的那個每一位都被移進虛無,答案就只是那個沒變的大數。這不是錯誤;它是固定寬度有效數的代價,也是為什麼把一長串數字由大到小相加,會丟掉若由小到大相加便能保住的項。
捨入:保護位、捨入位與黏滯位
在相加與正規化之後,真正的結果常常比格式所能儲存的位數還多有效位,所以硬體必須捨入。最天真的想法——直接把多出的位元砍掉——是有偏差的,因為它總是朝零捨入,那些微小的誤差全都偏向同一邊,在數百萬次運算中累積。標準的預設更聰明:捨入至最近,平手取偶。選最接近的可表示值;若真值剛好落在兩者正中間,就選最後一位為 0 的那個。平手取偶沒有方向性偏差,於是誤差互相抵消而非堆疊。
但這裡有個微妙的硬體問題:要正確捨入,FPU 必須知道它即將丟棄的那些位元裡有什麼,然而它在對齊時早已把大部分丟掉了。優雅的解法是只在有效數之後多保留三個位元:保護位、捨入位與黏滯位。保護位與捨入位是頭兩個被丟棄的位元,原樣保留。黏滯位是它們以下每一個位元的邏輯 OR——它記住是否有任何非零的東西被移了出去,而這正是正確打破平手所需要的資訊。
捨入至最近取偶只是預設;捨入模式是可選的。標準還提供朝零、朝正無限大、朝負無限大捨入。那些有向模式驅動了區間運算:你把一個結果算兩次——一次向上捨入、一次向下捨入——以得到一個保證夾住真正數學答案的範圍。把捨入精準釘到位元層級,整個重點在於可重現性:在符合標準的硬體上,同一支程式在任何地方都給出位元完全相同的結果,若每顆晶片各憑喜好捨入,這便不可能。
浮點數的誠實意外
最著名的意外:0.1 + 0.2 不等於 0.3。原因不是硬體錯誤,而是表示的限制。就像 1/3 沒有精確的有限十進位表示(0.3333...),0.1 這個值也沒有精確的有限二進位分數——它在二進位裡無限循環。所以你一寫下 0.1,機器就已經存了最接近的可表示值,那略有偏差,而 0.1 與 0.2 各自的微小誤差,並不會剛好抵消成 0.3。算術精確到最後一位;只是輸入從來就不是你打進去的那個數。
浮點數還會打破你在代數裡信賴的規則。加法不具結合律:(a + b) + c 可能與 a + (b + c) 不同,因為每一步之後都會捨入,而不同的分組會在不同時刻捨入。災難性抵消更棘手——把兩個幾乎相等的數相減,會抹掉它們前面相符的位數,把底部的捨入雜訊提拔到答案的前導位上,於是一個看起來沒問題的結果,可能幾乎全是誤差。數值函式庫裡滿是經過巧妙重排、正是為了閃避這些陷阱而寫的公式。
這些都不會讓浮點數變得不可信——它讓浮點數成為一個有著明載限制的精準工具。IEEE 754 標準是誠實工程的一場勝利:它不假裝實數塞得進 32 或 64 位元,它精確規定它們如何被近似、捨入至最近值並精準到最後一位、以無限大與 NaN 來示警而非藏匿問題,並保證在每一台符合標準的機器上給出相同答案。理解浮點數,就像理解快取或管線,意味著不只知道它能用,更知道它究竟在哪裡、為何會彎折。