本文從一個有趣而詭異的實驗開始。最先這個例子博主是從 Stackoverflow上的一個問題中看到的。爲了提升可讀性,博主這裏作了改寫,簡化成了如下兩段代碼:html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
#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;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
#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倍。 。世界觀被顛覆了有木有?博主是在本身的Macbook Pro上進行的測試,有興趣的讀者也能夠在本身的筆記本上試試。(只要是支持SSE2指令集的CPU都會有類似的結果)。ios
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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開始解釋這個詭異現象的緣由。shell
如今讓咱們複習大學計算機基礎課程。若是你熟練掌握了浮點數向二進制表達式轉換的方法,那麼你能夠跳過這節。
咱們先來看下浮點數二進制表達的三個組成部分。ide
三個主要成分是:工具
下面咱們來看個實際例子來解釋下轉換過程。
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不會形成任何兩個浮點數的混淆。
具體填充後的結果見下圖
spa
練習:若是想考驗本身是否充分理解這節內容的話,能夠隨便寫一個浮點數嘗試轉換。經過 浮點二進制轉換工具能夠驗證答案。.net
瞭解完浮點數的表達之後,不難看出浮點數的精度和指數範圍有很大關係。最低不能低過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也是有答案的,有興趣的讀者能夠自行查閱。
總上面的分析中咱們得出瞭如下結論:
因而咱們就能夠發現經過幾十上百次的循環後,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更快了些,世界觀又端正了不是麼:) 修改後的代碼以下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#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;
}
|