一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

視學演算法發表於2020-04-06

點選關注上方“視學演算法”,設為“置頂或星標”,第一時間送達技術乾貨。

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

本文來源:http://cenalulu.github.io/linux/about-denormalized-float-number

一個有趣的實驗

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

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

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

#include 
#include 
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 
#include 
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倍

世界觀被顛覆了有木有?

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

shell> g++ code1.c -o test1
shell> g++ code2.c -o test2
shell> time ./test1

real   0m1.490s
user   0m1.483s
sys    0m0.003s

shell> time ./test2

real   0m9.895s
user   0m9.871s
sys    0m0.009s

本著知其然還要知其所以然的態度,博主做了一個詳盡的分析和思路整理過程。也希望讀者能夠從0開始解釋這個詭異現象的原因。

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

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

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

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

三個主要成分是:

  • 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不會造成任何兩個浮點數的混淆。

具體填充後的結果見下圖 

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

什麼是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更快了些,世界觀又端正了不是麼:) 修改後的程式碼如下。

#include 
#include 
#include 
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;
}

- END -

如果看到這裡,說明你喜歡這篇文章,請轉發、點贊掃描下方二維碼或者微信搜尋「perfect_iscas」,新增好友後即可獲得10套程式設計師全棧課程+1000套PPT和簡歷模板向我私聊「進群」二字即可進入高質量交流群。

掃描二維碼進群↓

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

在看 一個有趣的實驗:用0.1f 替換 0,效能提升 7 倍!

相關文章