天天看點

c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識

來源:https://dwz.cn/ZQv439bl
           

# 一個有趣的實驗

本文從一個有趣而詭異的實驗開始,最早這個例子是 Stackoverflow上的一個問題:

https://stackoverflow.com/questions/9314534/why-does-changing-0-1f-to-0-slow-down-performance-by-10x

為了提高可讀性,對代碼稍作修改,簡化成了以下兩段代碼:

c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識
c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識
上面兩段代碼的唯一差别就是第一段代碼中y+=0.1f,而第二段代碼中是y+=0。由于y會先加後減同樣一個數值,照理說這兩段代碼的作用和效率應該是完全一樣的,當然也是沒有任何邏輯意義的。假設現在我告訴你:其中一段代碼的效率要比另一段慢7倍。想必讀者會認為一定是y+=0.1f的那段慢,畢竟它和y+=0相比看上去要多一些運算。但是,實驗結果,卻出乎意料, y+=0的那段代碼比y+=0.1f足足慢了7倍。世界觀被颠覆了有木有?部落客是在自己的MacbookPro上進行的測試,有興趣的讀者也可以在自己的筆記本上試試。(隻要是支援SSE2指令集的CPU都會有相似的結果)。世界觀被颠覆了有木有?
           

部落客是在自己的Macbook Pro上進行的測試,有興趣的讀者也可以在自己的筆記本上試試。(隻要是支援SSE2指令集的CPU都會有相似的結果)。

shell> g++ code1.c -o test1shell> g++ code2.c -o test2shell> time ./test1real 0m1.490suser 0m1.483ssys 0m0.003sshell> time ./test2real 0m9.895suser 0m9.871ssys 0m0.009s本着知其然還要知其是以然的态度,部落客做了一個詳盡的分析和思路整理過程。也希望讀者能夠從0開始解釋這個詭異現象的原因。
           

# 複習浮點數的二進制轉換

現在讓我們複習大學計算機基礎課程,如果你熟練掌握了浮點數向二進制表達式轉換的方法,那麼你可以跳過這節。

我們先來看下浮點數二進制表達的三個組成部分。

c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識

三個主要成分是:

  • Sign(1bit):表示浮點數是正數還是負數。0表示正數,1表示負數。
  • Exponent(8bits):指數部分。類似于科學技術法中的M*10^N中的N,隻不過這裡是以2為底數而不是10。需要注意的是,這部分中是以2^7-1即127,也即01111111代表2^0,轉換時需要根據127作偏移調整。
  • Mantissa(23bits):基數部分。浮點數具體數值的實際表示。

下面我們來看個實際例子來解釋下轉換過程。

Step 1 改寫整數部分 以數值5.2為例。先不考慮指數部分,我們先單純的将十進制數改寫成二進制。整數部分很簡單,5.即101.。

Step 2 改寫小數部分 小數部分我們相當于拆成是2^-1一直到2^-N的和。

例如:0.2 = 0.125+0.0625+0.007825+0.00390625即2^-3+2^-4+2^-7+2^-8….,也即.00110011001100110011

Step 3 規格化 現在我們已經有了這麼一串二進制101.00110011001100110011。然後我們要将它規格化,也叫Normalize。

其實原理很簡單就是保證小數點前隻有一個bit。于是我們就得到了以下表示:1.0100110011001100110011 * 2^2。

到此為止我們已經把改寫工作完成,接下來就是要把bit填充到三個組成部分中去了。

Step 4 填充 指數部分(Exponent):之前說過需要以127作為偏移量調整。是以2的2次方,指數部分偏移成2+127即129,表示成10000001填入。

整數部分(Mantissa):除了簡單的填入外,需要特别解釋的地方是1.010011中的整數部分1在填充時被舍去了。因為規格化後的數值整部部分總是為1。

那大家可能有疑問了,省略整數部分後豈不是1.010011和0.010011就混淆了麼?

其實并不會,如果你仔細看下後者:會發現他并不是一個規格化的二進制,可以改寫成1.0011 * 2^-2。

是以省略小數點前的一個bit不會造成任何兩個浮點數的混淆。

具體填充後的結果見下圖

c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識

# 什麼是Denormalized Number

了解完浮點數的表達以後,不難看出浮點數的精度和指數範圍有很大關系。最低不能低過2^-7-1最高不能高過2^8-1(其中剔除了指數部分全0和全1的特殊情況)

如果超出表達範圍那麼不得不舍棄末尾的那些小數,我們成為overflow和underflow,甚至有時舍棄都無法表示

例如當我們要表示一個:1.00001111*2^-7這樣的超小數值的時候就無法用規格化數值表示,如果不想點其他辦法的話,CPU内部就隻能把它當做0來處理。

那麼,這樣做有什麼問題呢?最顯然易見的一種副作用就是:當多次做低精度浮點數舍棄的後,就會出現除數為0的exception,導緻異常。

當然精度失準嚴重起來也可以要人命,以下這個事件摘自wikipedia:

On 25 February 1991, a loss of significance in a MIM-104 Patriot missile battery prevented it intercepting an incoming Scud missile in Dhahran, Saudi Arabia, contributing to the death of 28 soldiers from the U.S. Army’s 14th Quartermaster Detachment.[25] See also: Failure at Dhahran

于是乎就出現了Denormalized Number(後稱非規格化浮點)。他和規格浮點的差別在于,規格浮點約定小數點前一位預設是1。而非規格浮點約定小數點前一位可以為0,這樣小數精度就相當于多了最多2^22範圍。

但是,精度的提升是有代價的。由于CPU硬體隻支援,或者預設對一個32bit的二進制使用規格化解碼。是以需要支援32bit非規格數值的轉碼和計算的話,需要額外的編碼辨別,也就是需要額外的硬體或者軟體層面的支援。

以下是wiki上的兩端摘抄,說明了非規格化計算的效率非常低。一般來說,由軟體對非規格化浮點數進行處理将帶來極大的性能損失,而由硬體處理的情況會稍好一些,但在多數現代處理器上這樣的操作仍是緩慢的。

極端情況下,規格化浮點數操作可能比硬體支援的非規格化浮點數操作快100倍。

For example when using NVIDIA’s CUDA platform, on gaming cards, calculations with double precision take 3 to 24 times longer to complete than calculations using single precision.

如果要解釋為什麼有如此大的性能損耗,那就要需要涉及電路設計了,超出了部落客的知識範圍。當然萬能的wiki也是有答案的,有興趣的讀者可以自行查閱。

# 回到實驗

總上面的分析中我們得出了以下結論:

  • 浮點數表示範圍有限,精度受限于指數和底數部分的長度,超過精度的小數部分将會被舍棄(underflow)
  • 為了表示更高精度的浮點數,出現了非規格化浮點數,但是他的計算成本非常高。

于是我們就可以發現通過幾十上百次的循環後,y中存放的數值無限接近于零。CPU将他表示為精度更高的非規格化浮點。

而當y+0.1f時為了保留跟重要的底數部分,之後無限接近0(也即y之前存的數值)被舍棄,當y-0.1f後,y又退化為了規格化浮點數。并且之後的每次y*x和y/z時,CPU都執行的是規劃化浮點運算。

而當y+0,由于加上0值後的y仍然可以被表示為非規格化浮點,是以整個循環的四次運算中CPU都會使用非規格浮點計算,效率就大大降低了。

# 其他

當然,也有在程式内部也是有辦法控制非規範化浮點的使用的。在相關程式的上下文中加上fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);就可以迫使CPU放棄使用非規範化浮點計算,提高性能。

我們用這種辦法修改上面實驗中的代碼後,y+=0的效率就和y+=0.1f就一樣了。甚至還比y+=0.1f更快了些,世界觀又端正了不是麼:) 修改後的代碼如下。

c浮點數保留兩位小數_很多老鳥都不知道這些浮點數基礎知識