天天看點

定點數與浮點數

最近做HDR時,經常要用NV提供的16位紋理,它的說明書16位能達到24位的精度,就很奇怪?一直搞不懂浮點數的精度怎麼算的?

今天認真看了一下IEEE float point的标準,終于明白是什麼了

1. 什麼是浮點數

在計算機系統的發展過程中,曾經提出過多種方法表達實數。典型的比如相對于浮點數的定點數(Fixed Point Number)。在這種表達方式中,小數點固定的位于實數所有數字中間的某個位置。貨币的表達就可以使用這種方式,比如 99.00 或者 00.99 可以用于表達具有四位精度(Precision),小數點後有兩位的貨币值。由于小數點位置固定,是以可以直接用四位數值來表達相應的數值。SQL 中的 NUMBER 資料類型就是利用定點數來定義的。還有一種提議的表達方式為有理數表達方式,即用兩個整數的比值來表達實數。

定點數表達法的缺點在于其形式過于僵硬,固定的小數點位置決定了固定位數的整數部分和小數部分,不利于同時表達特别大的數或者特别小的數。最終,絕大多數現代的計算機系統采納了所謂的浮點數表達方式。這種表達方式利用科學計數法來表達實數,即用一個尾數(Mantissa ),一個基數(Base),一個指數(Exponent)以及一個表示正負的符号來表達實數。比如 123.45 用十進制科學計數法可以表達為 1.2345 × 102 ,其中 1.2345 為尾數,10 為基數,2 為指數。浮點數利用指數達到了浮動小數點的效果,進而可以靈活地表達更大範圍的實數。

提示: 尾數有時也稱為有效數字(Significand)。尾數實際上是有效數字的非正式說法。

同樣的數值可以有多種浮點數表達方式,比如上面例子中的 123.45 可以表達為 12.345 × 101,0.12345 × 103 或者 1.2345 × 102。因為這種多樣性,有必要對其加以規範化以達到統一表達的目标。規範的(Normalized)浮點數表達方式具有如下形式:

±d.dd...d × β e , (0 ≤ d i < β)

其中 d.dd...d 即尾數,β 為基數,e 為指數。尾數中數字的個數稱為精度,在本文中用 p 來表示。每個數字 d 介于 0 和基數之間,包括 0。小數點左側的數字不為 0。

基于規範表達的浮點數對應的具體值可由下面的表達式計算而得:

±(d 0 + d 1β-1 + ... + d p-1β-(p-1))β e , (0 ≤ d i < β)

對于十進制的浮點數,即基數 β 等于 10 的浮點數而言,上面的表達式非常容易了解,也很直白。計算機内部的數值表達是基于二進制的。從上面的表達式,我們可以知道,二進制數同樣可以有小數點,也同樣具有類似于十進制的表達方式。隻是此時 β 等于 2,而每個數字 d 隻能在 0 和 1 之間取值。比如二進制數 1001.101 相當于 1 × 2 3 + 0 × 22 + 0 × 21 + 1 × 20 + 1 × 2-1 + 0 × 2-2 + 1 × 2-3,對應于十進制的 9.625。其規範浮點數表達為 1.001101 × 23。

2. IEEE 浮點數

計算機中是用有限的連續位元組儲存浮點數的。儲存這些浮點數當然必須有特定的格式,Java 平台上的浮點數類型 float 和 double 采納了 IEEE 754 标準中所定義的單精度 32 位浮點數和雙精度 64 位浮點數的格式。

注意: Java 平台還支援該标準定義的兩種擴充格式,即 float-extended-exponent 和 double-extended-exponent 擴充格式。這裡将不作介紹,有興趣的讀者可以參考相應的參考資料。

在 IEEE 标準中,浮點數是将特定長度的連續位元組的所有二進制位分割為特定寬度的符号域,指數域和尾數域三個域,其中儲存的值分别用于表示給定二進制浮點數中的符号,指數和尾數。這樣,通過尾數和可以調節的指數(是以稱為"浮點")就可以表達給定的數值了。具體的格式參見下面的圖例:

在上面的圖例中,第一個域為符号域。其中 0 表示數值為正數,而 1 則表示負數。

第二個域為指數域,對應于我們之前介紹的二進制科學計數法中的指數部分。其中單精度數為 8 位,雙精度數為 11 位。以單精度數為例,8 位的指數為可以表達 0 到 255 之間的 255 個指數值。但是,指數可以為正數,也可以為負數。為了處理負指數的情況,實際的指數值按要求需要加上一個偏差(Bias)值作為儲存在指數域中的值,單精度數的偏內插補點為 127,而雙精度數的偏內插補點為 1023。比如,單精度的實際指數值 0 在指數域中将儲存為 127;而儲存在指數域中的 64 則表示實際的指數值 -63。 偏差的引入使得對于單精度數,實際可以表達的指數值的範圍就變成 -127 到 128 之間(包含兩端)。我們不久還将看到,實際的指數值 -127(儲存為 全 0)以及 +128(儲存為全 1)保留用作特殊值的處理。這樣,實際可以表達的有效指數範圍就在 -127 和 127 之間。在本文中,最小指數和最大指數分别用 emin 和 emax 來表達。

圖例中的第三個域為尾數域,其中單精度數為 23 位長,雙精度數為 52 位長。除了我們将要講到的某些特殊值外,IEEE 标準要求浮點數必須是規範的。這意味着尾數的小數點左側必須為 1,是以我們在儲存尾數的時候,可以省略小數點前面這個 1,進而騰出一個二進制位來儲存更多的尾數。這樣我們實際上用 23 位長的尾數域表達了 24 位的尾數。比如對于單精度數而言,二進制的 1001.101(對應于十進制的 9.625)可以表達為 1.001101 × 23,是以實際儲存在尾數域中的值為 00110100000000000000000,即去掉小數點左側的 1,并用 0 在右側補齊。

值得注意的是,對于單精度數,由于我們隻有 24 位的指數(其中一位隐藏),是以可以表達的最大指數為 224 - 1 = 16,777,215。特别的,16,777,216 是偶數,是以我們可以通過将它除以 2 并相應地調整指數來儲存這個數,這樣 16,777,216 同樣可以被精确的儲存。相反,數值 16,777,217 則無法被精确的儲存。由此,我們可以看到單精度的浮點數可以表達的十進制數值中,真正有效的數字不高于 8 位。事實上,對相對誤差的數值分析結果顯示有效的精度大約為 7.22 位。參考下面的示例:

        true value                stored value

        --------------------------------------

        16,777,215                1.6777215E7

        16,777,216                1.6777216E7

        16,777,217                1.6777216E7

        16,777,218                1.6777218E7

        16,777,219                1.677722E7

        16,777,220                1.677722E7

        16,777,221                1.677722E7

        16,777,222                1.6777222E7

        16,777,223                1.6777224E7

        16,777,224                1.6777224E7

        16,777,225                1.6777224E7

        --------------------------------------根據标準要求,無法精确儲存的值必須向最接近的可儲存的值進行舍入。這有點像我們熟悉的十進制的四舍五入,即不足一半則舍,一半以上(包括一半)則進。不過對于二進制浮點數而言,還多一條規矩,就是當需要舍入的值剛好是一半時,不是簡單地進,而是在前後兩個等距接近的可儲存的值中,取其中最後一位有效數字為零者。從上面的示例中可以看出,奇數都被舍入為偶數,且有舍有進。我們可以将這種舍入誤差了解為"半位"的誤差。是以,為了避免 7.22 對很多人造成的困惑,有些文章經常以 7.5 位來說明單精度浮點數的精度問題。

提示: 這裡采用的浮點數舍入規則有時被稱為舍入到偶數(Round to Even)。相比簡單地逢一半則進的舍入規則,舍入到偶數有助于從某些角度減小計算中産生的舍入誤差累積問題。是以為 IEEE 标準所采用。

3. 實數和浮點數之間的變換

現在我們已經明白了浮點數的 IEEE 表達方式。我們來做些實數和浮點數之間的變換練習以加深了解。在這些練習中,你還會發現一些圍繞浮點數運算的令人吃驚的事實。

首先我們來看看事情簡單的一面,從浮點數變換到實數。了解了浮點數的格式,做這個練習并不難。假定我們有一個 32 位的資料,用十六進制表示為 0xC0B40000,并且我們知道它實際上是一個單精度的浮點數。為了得到該浮點數實際表達的實數,我們首先将它變換為二進制形式:

  C    0    B    4    0    0    0    0

1100 0000 1011 0100 0000 0000 0000 0000接着按照浮點數的格式切分為相應的域:

1  10000001 01101000000000000000000符号域 1 意味着負數;指數域為 129 意味着實際的指數為 2 (減去偏內插補點 127);尾數域為 01101 意味着實際的二進制尾數為 1.01101 (加上隐含的小數點前面的 1)。是以,實際的實數為:

-1.01101 × 22

-(20 + 2-2 + 2-3 2-5) × 22

-5.625從實數向浮點數變換稍微麻煩一點。假定我們需要将實數 -9.625 表達為單精度的浮點數格式。方法是首先将它用二進制浮點數表達,然後變換為相應的浮點數格式。

首先,将小數點左側的整數部分變換為其二進制形式,9 的二進制性形式為 1001。處理小數部分的算法是将我們的小數部分乘以基數 2,記錄乘積結果的整數部分,接着将結果的小數部分繼續乘以 2,并不斷繼續該過程:

0.625 × 2 = 1.25        1

0.25  × 2 = 0.5         0

0.5   × 2 = 1           1

0當最後的結果為零時,結束這個過程。這時右側的一列數字就是我們所需的二進制小數部分,即 0.101。這樣,我們就得到了完整的二進制形式 1001.101。用規範浮點數表達為 1.001101 × 23。

因為是負數,是以符号域為 1。指數為 3,是以指數域為 3 + 127 = 130,即二進制的 10000010。尾數省略掉小數點左側的 1 之後為 001101,右側用零補齊。最終結果為:

1 10000010 00110100000000000000000最後可以将浮點數形式表示為十六進制的資料如下:

1100 0001 0001 1010 0000 0000 0000 0000

  C    1    1    A    0    0    0    0最終結果為 0xC11A0000。

很簡單?等等!你可能已經注意到了,在上面這個我們有意選擇的示例中,不斷的将産生的小數部分乘以 2 的過程掩蓋了一個事實。該過程結束的标志是小數部分乘以 2 的結果為 1,不難想象,很多小數根本不能經過有限次這樣的過程而得到結果(比如最簡單的 0.1)。我們已經知道浮點數尾數域的位數是有限的,為此,浮點數的處理辦法是持續該過程直到由此得到的尾數足以填滿尾數域,之後對多餘的位進行舍入。換句話說,除了我們之前講到的精度問題之外,十進制到二進制的變換也并不能保證總是精确的,而隻能是近似值。事實上,隻有很少一部分十進制小數具有精确的二進制浮點數表達。再加上浮點數運算過程中的誤差累積,結果是很多我們看來非常簡單的十進制運算在計算機上卻往往出人意料。這就是最常見的浮點運算的"不準确"問題。參見下面的 Java 示例:

System.out.print("34.6-34.0=" + (34.6f-34.0f));這段代碼的輸出結果如下:

34.6-34.0=0.5999985産生這個誤差的原因是 34.6 無法精确的表達為相應的浮點數,而隻能儲存為經過舍入的近似值。這個近似值與 34.0 之間的運算自然無法産生精确的結果。

4. 特殊值

通過前面的介紹,你應該已經了解的浮點數的基本知識,這些知識對于一個不接觸浮點數應用的人應該足夠了。不過,如果你興趣正濃,或者面對着一個棘手的浮點數應用,可以通過本節了解到關于浮點數的一些值得注意的特殊之處。

我們已經知道,指數域實際可以表達的指數值的範圍為 -127 到 128 之間(包含兩端)。其中,值 -127(儲存為 全 0)以及 +128(儲存為全 1)保留用作特殊值的處理。本節将詳細 IEEE 标準中所定義的這些特殊值。

浮點數中的特殊值主要用于特殊情況或者錯誤的處理。比如在程式對一個負數進行開平方時,一個特殊的傳回值将用于标記這種錯誤,該值為 NaN(Not a Number)。沒有這樣的特殊值,對于此類錯誤隻能粗暴地終止計算。除了 NaN 之外,IEEE 标準還定義了 ±0,±∞ 以及非規範化數(Denormalized Number)。

對于單精度浮點數,所有這些特殊值都由保留的特殊指數值 -127 和 128 來編碼。如果我們分别用 emin 和 emax 來表達其它正常指數值範圍的邊界,即 -126 和 127,則保留的特殊指數值可以分别表達為 emin - 1 和 emax + 1; 。基于這個表達方式,IEEE 标準的特殊值如下所示:

其中 f 表示尾數中的小數點右側的(Fraction)部分。第一行即我們之前介紹的普通的規範化浮點數。随後我們将分别對餘下的特殊值加以介紹。

4.1. NaN

NaN 用于處理計算中出現的錯誤情況,比如 0.0 除以 0.0 或者求負數的平方根。由上面的表中可以看出,對于單精度浮點數,NaN 表示為指數為 emax + 1 = 128(指數域全為 1),且尾數域不等于零的浮點數。IEEE 标準沒有要求具體的尾數域,是以 NaN 實際上不是一個,而是一族。不同的實作可以自由選擇尾數域的值來表達 NaN,比如 Java 中的常量 Float.NaN 的浮點數可能表達為 01111111110000000000000000000000,其中尾數域的第一位為 1,其餘均為 0(不計隐藏的一位),但這取決系統的硬體架構。Java 中甚至允許程式員自己構造具有特定位模式的 NaN 值(通過 Float.intBitsToFloat() 方法)。比如,程式員可以利用這種定制的 NaN 值中的特定位模式來表達某些診斷資訊。

定制的 NaN 值,可以通過 Float.isNaN() 方法判定其為 NaN,但是它和 Float.NaN 常量卻不相等。實際上,所有的 NaN 值都是無序的。數值比較操作符 <,<=,> 和 >= 在任一操作數為 NaN 時均傳回 false。等于操作符 == 在任一操作數為 NaN 時均傳回 false,即使是兩個具有相同位模式的 NaN 也一樣。而操作符 != 則當任一操作數為 NaN 時傳回 true。這個規則的一個有趣的結果是 x!=x 當 x 為 NaN 時竟然為真。

可以産生 NaN 的操作如下所示:

此外,任何有 NaN 作為操作數的操作也将産生 NaN。用特殊的 NaN 來表達上述運算錯誤的意義在于避免了因這些錯誤而導緻運算的不必要的終止。比如,如果一個被循環調用的浮點運算方法,可能由于輸入的參數問題而導緻發生這些錯誤,NaN 使得 即使某次循環發生了這樣的錯誤,也可以簡單地繼續執行循環以進行那些沒有錯誤的運算。你可能想到,既然 Java 有異常處理機制,也許可以通過捕獲并忽略異常達到相同的效果。但是,要知道,IEEE 标準不是僅僅為 Java 而制定的,各種語言處理異常的機制不盡相同,這将使得代碼的遷移變得更加困難。何況,不是所有語言都有類似的異常或者信号(Signal)處理機制。

注意: Java 中,不同于浮點數的處理,整數的 0 除以 0 将抛出 java.lang.ArithmeticException 異常。

4.2. 無窮

和 NaN 一樣,特殊值無窮(Infinity)的指數部分同樣為 emax + 1 = 128,不過無窮的尾數域必須為零。無窮用于表達計算中産生的上溢(Overflow)問題。比如兩個極大的數相乘時,盡管兩個操作數本身可以用儲存為浮點數,但其結果可能大到無法儲存為浮點數,而必須進行舍入。根據 IEEE 标準,此時不是将結果舍入為可以儲存的最大的浮點數(因為這個數可能離實際的結果相差太遠而毫無意義),而是将其舍入為無窮。對于負數結果也是如此,隻不過此時舍入為負無窮,也就是說符号域為 1 的無窮。有了 NaN 的經驗我們不難了解,特殊值無窮使得計算中發生的上溢錯誤不必以終止運算為結果。

無窮和除 NaN 以外的其它浮點數一樣是有序的,從小到大依次為負無窮,負的有窮非零值,正負零(随後介紹),正的有窮非零值以及正無窮。除 NaN 以外的任何非零值除以零,結果都将是無窮,而符号則由作為除數的零的符号決定。

回顧我們對 NaN 的介紹,當零除以零時得到的結果不是無窮而是 NaN 。原因不難了解,當除數和被除數都逼近于零時,其商可能為任何值,是以 IEEE 标準決定此時用 NaN 作為商比較合适。

4.3. 有符号的零

因為 IEEE 标準的浮點數格式中,小數點左側的 1 是隐藏的,而零顯然需要尾數必須是零。是以,零也就無法直接用這種格式表達而隻能特殊處理。

實際上,零儲存為尾數域為全為 0,指數域為 emin - 1 = -127,也就是說指數域也全為 0。考慮到符号域的作用,是以存在着兩個零,即 +0 和 -0。不同于正負無窮之間是有序的,IEEE 标準規定正負零是相等的。

零有正負之分,的确非常容易讓人困惑。這一點是基于數值分析的多種考慮,經利弊權衡後形成的結果。有符号的零可以避免運算中,特别是涉及無窮的運算中,符号資訊的丢失。舉例而言,如果零無符号,則等式 1/(1/x) = x 當x = ±∞ 時不再成立。原因是如果零無符号,1 和正負無窮的比值為同一個零,然後 1 與 0 的比值為正無窮,符号沒有了。解決這個問題,除非無窮也沒有符号。但是無窮的符号表達了上溢發生在數軸的哪一側,這個資訊顯然是不能不要的。零有符号也造成了其它問題,比如當 x=y 時,等式1/x = 1/y 在 x 和 y 分别為 +0 和 -0 時,兩端分别為正無窮和負無窮而不再成立。當然,解決這個問題的另一個思路是和無窮一樣,規定零也是有序的。但是,如果零是有序的,則即使 if (x==0) 這樣簡單的判斷也由于 x 可能是 ±0 而變得不确定了。兩害取其輕者,零還是無序的好。

4.4. 非規範化數

我們來考察浮點數的一個特殊情況。選擇兩個絕對值極小的浮點數,以單精度的二進制浮點數為例,比如 1.001 × 2-125 和 1.0001 × 2-125 這兩個數(分别對應于十進制的 2.6448623 × 10-38 和 2.4979255 × 10-38)。顯然,他們都是普通的浮點數(指數為 -125,大于允許的最小值 -126;尾數更沒問題),按照 IEEE 754 可以分别儲存為 00000001000100000000000000000000(0x1100000)和 00000001000010000000000000000000(0x1080000)。

現在我們看看這兩個浮點數的內插補點。不難得出,該內插補點為 0.0001 × 2-125,表達為規範浮點數則為 1.0 × 2-129。問題在于其指數大于允許的最小指數值,是以無法儲存為規範浮點數。最終,隻能近似為零(Flush to Zero)。這中特殊情況意味着下面本來十分可靠的代碼也可能出現問題:

if (x != y) {

        z = 1 / (x -y);

}正如我們精心選擇的兩個浮點數展現的問題一樣,即使 x 不等于 y,x 和 y 的內插補點仍然可能絕對值過小,而近似為零,導緻除以 0 的情況發生。

為了解決此類問題,IEEE 标準中引入了非規範(Denormalized)浮點數。規定當浮點數的指數為允許的最小指數值,即 emin 時,尾數不必是規範化的。比如上面例子中的內插補點可以表達為非規範的浮點數 0.001 × 2-126,其中指數 -126 等于 emin。注意,這裡規定的是"不必",這也就意味着"可以"。當浮點數實際的指數為 emin,且指數域也為 emin 時,該浮點數仍是規範的,也就是說,儲存時隐含着一個隐藏的尾數位。為了儲存非規範浮點數,IEEE 标準采用了類似處理特殊值零時所采用的辦法,即用特殊的指數域值 emin - 1 加以标記,當然,此時的尾數域不能為零。這樣,例子中的內插補點可以儲存為 00000000000100000000000000000000(0x100000),沒有隐含的尾數位。

有了非規範浮點數,去掉了隐含的尾數位的制約,可以儲存絕對值更小的浮點數。而且,也由于不再受到隐含尾數域的制約,上述關于極小內插補點的問題也不存在了,因為所有可以儲存的浮點數之間的內插補點同樣可以儲存。

A 16-bit floating-point number has a 1-bit sign (S), a 5-bit

    exponent (E), and a 10-bit mantissa (M).  The value of a 16-bit

    floating-point number is determined by the following:

        (-1)^S * 0.0,                        if E == 0 and M == 0,

        (-1)^S * 2^-14 * (M / 2^10),         if E == 0 and M != 0,

        (-1)^S * 2^(E-15) * (1 + M/2^10),    if 0 < E < 31,

        (-1)^S * INF,                        if E == 31 and M == 0, or

        NaN,                                 if E == 31 and M != 0,

    where

        S = floor((N mod 65536) / 32768),

        E = floor((N mod 32768) / 1024), and

        M = N mod 1024.

是以當E=0時,按非規範浮點數處理得到的結果就2^-24精度的數字