問題所在:無窮多個數,卻只有有限個位元
到這裡你已經知道,每個數值答案都是近似值,而其中一個重要原因,早在任何演算法執行之前就出現了:你一存下一個實數的那一刻,就已經四捨五入過了。在 1 和 2 之間有無窮多個實數,但一塊固定寬度的記憶體只能裝下有限多種不同的位元組合。例如一個 64 位元的字組,恰好有 2^64 種組合——這是個很大的數,但仍然是有限的。所以電腦無法保存「整條實數線」,它保存的是一張精心挑選的、有限的替身網格,並把你的數字「卡」到最近的網格點上。
一種顯而易見的設計是定點表示法:例如小數點前取 6 位、後取 4 位,得到一張等間距的網格,像 12.3456、12.3457 等等。這對記帳很好,但對科學很糟,因為你會想用同一個變數同時談論質子半徑(約 10^-15 公尺)和到某星系的距離(約 10^22 公尺)。一張細到能分辨質子的等間距網格,要延伸到星系會需要多到天文數字的位數。我們需要的是一張「間距會隨著數值大小而變大」的網格。
二進位版的科學記號
解法就是你早就會手算的那個:科學記號。你寫成 6.022 × 10^23,而不是寫一長串 24 位數字——一個尾數(6.022)乘上一個由指數(23)決定的十的次方。浮點數正是這個概念,只是改用二進位,因為硬體說的是位元的語言。一個實數 x 被存成 x ≈ (-1)^s × m × 2^e,其中 s 是單一個符號位元,m 是尾數(significand,又叫 mantissa),e 是指數。這個「小數點」是浮動的:把 e 加大,同樣的尾數就落在網格較粗的區段;把 e 減小,就落在較細的區段。
還有一個漂亮的小技巧可以再擠出一個位元。二進位的正規化數會寫成「1.某某某」——小數點前恰好一個非零數字,而在二進位裡唯一的非零數字就是 1。既然那個開頭的 1 永遠都在,我們就乾脆不存它。這個「隱藏位元」表示:存了 52 個位元的尾數欄位,實際上帶有 53 位元的精度。代價是,每當指數加一,可表示數字之間的間距就加倍:1000 附近的數字,間距大約是 1 附近數字間距的 1000 倍。但這正是重點——相對精度,也就是不論在哪裡,有效位數大致相同。
IEEE 754:大家共用的一套格式
如果每家晶片廠商都自己發明尾數寬度和捨入規則,同一支程式在不同機器上就會給出不同答案——這對浮點數運算和可重現性都是一場惡夢。自 1985 年起,IEEE 754 標準就固定了位元排版、捨入行為,以及那些特殊值,使得 1.0 + 1.0 在你的手機、筆電和超級電腦上都代表同一件事。日常運算由兩種大小主宰,而位元的「預算分配」就道盡了一切。
single (float32): 1 sign | 8 exp | 23 frac -> 24-bit precision, ~7 decimal digits double (float64): 1 sign | 11 exp | 52 frac -> 53-bit precision, ~16 decimal digits value = (-1)^s * (1.frac)_2 * 2^(e - bias) [normalized] bias = 127 (single) bias = 1023 (double)
雙精度(常直接叫「double」)給你大約 15 到 16 個有效十進位數字,而它幾乎是所有科學工作的預設值。單精度只給大約 7 位,但記憶體減半、在 GPU 上也快得多,這就是機器學習偏愛它的原因。把這個數字記在口袋裡:在一個 double 裡,~16 位數字就是你的全部預算。這筆預算不是可以隨便花的零用錢——後面的篇章會說明,一個條件很差的問題,可能在你的演算法還沒開始前就花掉了大半,所以請把這些位數當成珍寶。
從位元把一個數讀出來
我們來手動解碼一個小例子,好讓這個格式不再像魔法。以單精度表示數字 6.5。做法就是二進位科學記號,再把三個欄位封裝起來。
- 把 6.5 寫成二進位:6 是 110,0.5 是 0.1,所以 6.5 = 110.1(二進位)。
- 正規化成小數點前只有一個 1:110.1 = 1.101 × 2^2。尾數是 1.101,真實指數是 2。
- 丟掉隱藏的開頭 1。存下的 23 位小數欄位是 101,後面跟著二十個 0。
- 把指數加上偏移量:存下的指數 = 2 + 127 = 129,8 位元寫成 10000001。
- 符號位元是 0(正數)。把它們拼起來:0 | 10000001 | 10100000000000000000000。這 32 位元組合就是精確的 6.5——沒有捨入,因為 6.5 剛好落在網格點上。
把同樣的步驟套到像 0.1 這種數字上,第 1 步永遠不會終止:0.1 的二進位是 0.000110011001100...,無限循環下去,所以第 3 步必須在第 23(或第 52)位把它砍掉。最後存進去的是最近的網格點,它比 0.1 微微地大一點點。這個無法避免的「卡格」幅度——也就是相鄰網格點之間相對於數值的間隙——重要到有了自己的名字,機器 epsilon,並且獨佔下一篇。它是衡量浮點數一切「誠實度」的基本單位。
網格的邊緣:零、次正規數、無限大、NaN
有兩個指數值被保留作為「逃生口」,它們讓 IEEE 754 變得封閉而自洽,而不是到處都是破洞。當指數位元全為 0 時,那個隱藏的開頭 1 就被關掉;這些次正規數讓網格能平滑地一路淡入到零,而不是在最小正規數的正下方留下一個突兀的大缺口——這個特性叫「漸進下溢」。普通的 0.0 是其中最小的(而且還有一個 -0.0,它通常表現得跟 0.0 一樣)。
當指數位元全為 1 時,你得到的是特殊值。一個太大、塞不進網格的有限結果會變成 Inf(所以 1.0/0.0 會給出 +Inf 而不是當掉),而像 0.0/0.0 或 Inf - Inf 這種真正無定義的運算則給出 NaN,也就是「不是一個數」。NaN 是會傳染的——幾乎任何碰到它的運算都會回傳 NaN——而且它是唯一一個「不等於自己」的值,所以慣用寫法「x != x」可以用來測試它。這些情況,連同它們的近親溢位與下溢,會在第 5 篇完整處理;現在只要知道:這張網格有定義良好的邊界,而不是斷崖。
為什麼這對後面的一切都很重要
儲存一個數只是第一步;標準也規定了運算的行為。每個基本運算(+、-、×、÷、開根號)被定義為先算出精確的數學結果,然後捨入一次到最近的可表示數。這就是「捨入運算模型」,它乾淨得令人愉快——但也帶來一個驚人的後果:浮點數加法不滿足結合律。把一個極小的數加到一個極大的數上,那個小數可能會掉出網格底部而消失;換個順序來加,那些微小的貢獻就活了下來。所以 (a + b) + c 可能和 a + (b + c) 差到真實、可量測的程度。
這不是硬體的臭蟲,而是「有限網格」的代價,而好的數值方法都是繞著它來設計的。最戲劇化的例子是當你把兩個幾乎相等的數相減:它們相符的開頭位數互相抵消,只剩下充滿雜訊的尾端位元——這就是災難性抵消,它能在一次減法裡就抹掉你 ~16 位數字的大部分。而天真地把上百萬個值加總,會讓捨入誤差不斷堆積,這正是 Kahan 加總法巧妙修正的目標——它靠帶著一個累進的修正項來補回。這兩個想法都會在本級稍後登場;它們所做的一切,都是對你剛剛學會閱讀的這張網格的回應。