可表示世界的邊界
到這裡你已經知道,機器只儲存一個有限的 浮點數 集合,依 IEEE 754 標準 排成符號、尾數 與指數。這份有限性帶來兩道誰都躲不掉的邊界:機器能裝下的最大數,以及能裝下的最小正數。一旦計算試圖跨過任一邊界,平常的算術就不再如你預期地運作。前幾篇看的是可表示範圍的內部——捨入、機器 epsilon、0.1 + 0.2 的意外。本篇則走到崖邊,往外探一探。
在 雙精度 中,最大的有限值約為 1.8e308,最小的正規化正值約為 2.2e-308。溢位(overflow)發生在結果超過可表示的最大量級時——標準不會繞回、也不會直接報錯,而是產生一個特殊值:正無窮或負無窮。下溢(underflow)發生在另一端,當結果的量級小於可表示的最小正數時——那裡的值會朝零塌陷,途中先穿過一圈我們稍後會遇到的微小漸進數。
Inf 與 NaN:讓算術繼續走下去的特殊值
IEEE 754 保留了幾個位元樣式,給那些並非普通數字的值,而這是一項刻意的設計,不是意外。Inf(帶符號的無窮)是溢位的產物,也是 1.0/0.0 的回傳值。它遵守合理的規則:Inf + 1 是 Inf,1/Inf 是 0,對每個有限的 x 都有 Inf > x。NaN——「不是一個數字」(Not a Number)——是沒有合理答案之運算的結果:0.0/0.0、Inf - Inf、sqrt(-1.0) 或 0.0*Inf。這些 特殊值 讓一段長計算能一路跑到底而不崩潰,於是你可以檢視輸出、追查哪裡出了錯。
NaN 有一個著名的怪癖:它與任何東西都不相等,連自己也不相等。x != x 這個測試恰好在 x 是 NaN 時為真,而這正是偵測 NaN 的標準、可移植做法。這種「不自反」是刻意的——它阻止 NaN 在比較中悄悄冒充成一個有效的數。另一面則是 NaN 具傳染性:任何碰到 NaN 的算術都會產生 NaN,所以管線早期的一個壞值,可能默默地把一整個結果陣列變成 NaN。當這發生時,正解絕不是把 NaN 壓下去,而是去找出第一個製造它的那個運算。
次正規數:通往零的軟著陸
就在下溢的邊界上,IEEE 754 做了一件聰明事,而不是直接跳到零。一個正規(normal)雙精度數的尾數永遠有一個前導的 1 位元,這固定了它的精度,但也固定了它能小到什麼程度。在那個最小正規值之下,標準允許 次正規數(subnormal,又稱非正規數 denormal):它捨去那個隱含的前導 1,讓尾數帶前導零,於是用一串等距的微小值,把最小正規數與零之間的縫隙填滿。這就是漸進下溢(gradual underflow)——不是懸崖,而是一道斜坡。
回報是一個你原本會失去的性質:若 x 與 y 是不同的浮點數,那麼 x - y 永遠不會恰好等於零。若沒有 次正規數,兩個很接近的不同數相減,可能得到一個小到無法表示的結果,於是被吸附成零——而接下來用那個差去做除法的程式,會突然碰上一個虛假的除以零。漸進下溢讓微小的差保持存活。代價有二:次正規數帶的有效位元較少,所以當你沉入其中時精度會平滑地退化;而且在某些硬體上,對次正規數的運算會慢得驚人,因為它們掉出了快速路徑。
避開邊界:先縮放再計算
好消息是,大多數溢位與下溢都可以靠重新縮放問題來避開:讓中間量保持在 1 附近,那是浮點最自在的地方。經典例子是歐幾里得長度 sqrt(a^2 + b^2),也就是向量的 2-範數——若天真地寫,輸入大時會溢位、輸入小時會下溢,即使答案本身沒問題。解法是先把最大的量級提出來,讓每個平方項至多為 1,於是這個和既不會溢位、也不會消失。
具體地說,令 m = max(|a|, |b|);若 m 為零則長度為零,否則令 s = min(|a|, |b|) / m,它永遠落在 [0, 1] 內,所以 s*s 無害。那麼長度就是 m * sqrt(1 + s*s),其中開根號只會看到一個介於 1 與 sqrt(2) 之間的數。大的量級被當成單一次乘法提到外面,於是 a^2 再也不會溢位、內部的和也再也不會下溢成零。結果是完全相同的數學,只是走了一條有限機器永遠能表示的路徑。
同樣的想法在科學計算裡到處重現。機率被改寫成對數之和來相乘(log-sum-exp 技巧會先減去最大值再取指數),這樣許多小數的乘積就不會下溢成零。雙精度 下的線性代數例程會在分解一個矩陣前先做平衡與縮放。誠實地說,這和前幾篇相連:縮放是一種穩定性技術,與穩定版二次公式、避免 相消 屬於同一族想法。你並沒有改變數學,只是把它重新排列,好讓有限的機器永遠不必去表示它表示不了的東西。
精確加總:從天真到 Kahan
最後我們來看日常數值工作中最實用的精度工具,它直接建立在你先前學到的事實上:浮點加法不滿足結合律,因此把一長串數字加起來,每做一次加法就累積一個捨入誤差。天真地加 n 項,最壞情況下總誤差會像 n 乘以 單位捨入 u 那樣成長——對一百萬項而言,這可能吃掉好幾位數。Kahan 加總法(補償式加總)只花一點點代價就修好這件事:它額外帶一個變數,記住每次加法丟掉的低位位元,再把它們回饋到下一步。
function kahan_sum(x[1..n]):
s = 0.0 # running sum
c = 0.0 # compensation: low-order bits lost so far
for i = 1 to n:
y = x[i] - c # add back what we lost last time
t = s + y # this rounding may drop low bits of y
c = (t - s) - y # recover EXACTLY those dropped bits
s = t
return s- y = x[i] - c:先把這一項減去上一步帶過來的誤差,這樣已累積的部分不會被丟失。
- t = s + y:做真正的加法。由於 s 可能遠大於 y,這次捨入會默默丟掉 y 最低的幾個位元。
- c = (t - s) - y:代數上是零,但在浮點裡不是——(t - s) 取回 y 中存活下來的部分,再減掉 y,剩下的恰好就是被丟掉的位元。
- s = t:把捨入後的和定下來,進入下一圈。補償值 c 現在把丟失的位元往前帶,準備下次重新加回總和。
Kahan 加總法把最壞情況誤差從約 n*u 壓到大約一個小常數乘以 u——幾乎與你加多少項無關。兩點誠實的告誡讓它落地。第一,激進的最佳化編譯器可能把 c = (t - s) - y 這一行「化簡」成零,悄悄摧毀整個方法;有時你需要特定的編譯旗標或 volatile 變數,來禁止那種「代數正確但結果錯誤」的改寫。第二,當真實答案是巨大項之間的微小差時,沒有任何加總方案能挽救 相消——那是輸入本身的病態條件,而一個 向後穩定的演算法(給出鄰近問題之精確答案者)也無法消除一個病態問題。加總的精度與問題的條件,是兩場不同的仗。
這一階留給你的東西
退一步看整個浮點階段,一條信條浮現:機器在一個有限數集上做精確、定義明確的算術,而你算出的每個數值答案都是近似的。你現在既懂它的內部(捨入、機器 epsilon、不滿足結合律的加法),也懂它的邊界(溢位成 Inf、經次正規數下溢、未定義時得 NaN)。有意思的問題從來不是「它精確嗎?」,而是「誤差多大、我能否替它定界?」——而你已握有具體工具來回答:縮放以躲開邊界、重排以躲開相消、補償以精確加總。
下一階把這一切從單一運算提升到整個問題。那裡你會遇到條件性(conditioning)——答案對微小輸入變化有多敏感——以及貫穿整個數值分析的乾淨等式:精度等於條件性乘以穩定性。你會看到為什麼一個 穩定的演算法 在病態問題上仍會失敗,以及為什麼一個接近 10^8 的條件數,會悄悄吃掉你約 16 位雙精度數字中的大約 8 位。你剛學到的所有關於捨入的事,將成為日後在大尺度上推理誤差的原料。