本博客已經遷移至:本文從一個有趣而又令人意外的實驗展開,介紹一些關于浮點數你應該知道的基礎知識
http://cenalulu.github.io/
為了更好的體驗,請通過此鏈接閱讀:http://cenalulu.github.io/linux/about-denormalized-float-number/
一個有趣的實驗文章歡迎轉載,但轉載時請保留本段文字,并置于文章的頂部作者:盧鈞軼(cenalulu)本文原文地址:
本文從一個有趣而詭異的實驗開始。最早這個例子博主是從 Stackoverflow上的一個問題中看到的。為了提高可讀性,博主這里做了改寫,簡化成了以下兩段代碼:
#include <iostream>#include <string>using namespace std;int main() { const float x=1.1; const float z=1.123; float y=x; for(int j=0;j<90000000;j++) { y*=x; y/=z; y+=0.1f; y-=0.1f; } return 0;}
#include <iostream>#include <string>using namespace std;int main() { const float x=1.1; const float z=1.123; float y=x; for(int j=0;j<90000000;j++) { y*=x; y/=z; y+=0; y-=0; } return 0;}
上面兩段代碼的唯一差別就是第一段代碼中y+=0.1f
,而第二段代碼中是y+=0
。由于y會先加后減同樣一個數值,照理說這兩段代碼的作用和效率應該是完全一樣的,當然也是沒有任何邏輯意義的。假設現在我告訴你:其中一段代碼的效率要比另一段慢7倍。想必讀者會認為一定是y+=0.1f
的那段慢,畢竟它和y+=0
相比看上去要多一些運算。但是,實驗結果,卻出乎意料, y+=0
的那段代碼比y+=0.1f
足足慢了7倍。{: style="color: red" } 。世界觀被顛覆了有木有?博主是在自己的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開始解釋這個詭異現象的原因。
復習浮點數的二進制轉換現在讓我們復習大學計算機基礎課程。如果你熟練掌握了浮點數向二進制表達式轉換的方法,那么你可以跳過這節。我們先來看下浮點數二進制表達的三個組成部分。
三個主要成分是:
M*10^N
中的N,只不過這里是以2為底數而不是10。需要注意的是,這部分中是以2^7-1
即127
,也即01111111
代表2^0
,轉換時需要根據127作偏移調整。下面我們來看個實際例子來解釋下轉換過程。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不會造成任何兩個浮點數的混淆。具體填充后的結果見下圖
練習:如果想考驗自己是否充分理解這節內容的話,可以隨便寫一個浮點數嘗試轉換。通過 浮點二進制轉換工具可以驗證答案。
什么是Denormalized Number了解完浮點數的表達以后,不難看出浮點數的精度和指數范圍有很大關系。最低不能低過2^-7-1
最高不能高過2^8-1
(其中剔除了指數部分全0喝全1的特殊情況)。那么當我們要表示一個例如:1.00001111*2^-7
這樣的超小數值的時候就無法用規格化數值表示,只能用0來代替。那么,這樣做有什么問題呢?最容易理解的一種副作用就是:當多次做低精度浮點數舍棄的時候,就會出現除數為0的exception,導致異常。
于是乎就出現了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也是有答案的,有興趣的讀者可以自行查閱。
回到實驗總上面的分析中我們得出了以下結論:
于是我們就可以發現通過幾十上百次的循環后,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
更快了些,世界觀又端正了不是么:) 修改后的代碼如下
#include <iostream>#include <string>#include <fenv.h>using namespace std;int main() { fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV); const float x=1.1; const float z=1.123; float y=x; for(int j=0;j<90000000;j++) { y*=x; y/=z; y+=0; y-=0; } return 0;}
Reference什么是非規格化浮點數Why does changing 0.1f to 0 slow down performance by 10x?IEEE floating pointFloating pointDenormal number
新聞熱點
疑難解答