0.1f 改為 0 會使性能降低 10 倍,你信不信?

本文從一個有趣而詭異的實驗開始。最早這個例子博主是從 Stackoverflow上的一個問題中看到的。為了提高可讀性,博主這裡做了改寫,簡化成了以下兩段代碼:

<code>#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;
}/<string>/<iostream>/<string>/<iostream>/<code>

上面兩段代碼的唯一差別就是第一段代碼中y+=0.1f,而第二段代碼中是y+=0。由於y會先加後減同樣一個數值,照理說這兩段代碼的作用和效率應該是完全一樣的,當然也是沒有任何邏輯意義的。假設現在我告訴你:其中一段代碼的效率要比另一段慢7倍。想必讀者會認為一定是y+=0.1f的那段慢,畢竟它和y+=0相比看上去要多一些運算。但是,實驗結果,卻出乎意料, y+=0的那段代碼比y+=0.1f足足慢了7倍。 。世界觀被顛覆了有木有?博主是在自己的Macbook Pro上進行的測試,有興趣的讀者也可以在自己的筆記本上試試。(只要是支持SSE2指令集的CPU都會有相似的結果)。

<code>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/<code>

當然 原文中的投票最高的回答解釋的非常好,但博主第一次看的時候是一頭霧水,因為大部分基礎知識已經還給大學老師了。所以,本著知其然還要知其所以然的態度,博主做了一個詳盡的分析和思路整理過程。也希望讀者能夠從0開始解釋這個詭異現象的原因。

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


0.1f 改為 0 會使性能降低 10 倍,你信不信?

三個主要成分是:

  • 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 會使性能降低 10 倍,你信不信?

練習:如果想考驗自己是否充分理解這節內容的話,可以隨便寫一個浮點數嘗試轉換。通過 浮點二進制轉換工具可以驗證答案。

瞭解完浮點數的表達以後,不難看出浮點數的精度和指數範圍有很大關係。最低不能低過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更快了些,世界觀又端正了不是麼:) 修改後的代碼如下

<code>#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;
}/<fenv.h>/<string>/<iostream>/<code>

參考文章:

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


分享到:


相關文章: