當(dāng)前位置:首頁 > 公眾號(hào)精選 > 架構(gòu)師社區(qū)
[導(dǎo)讀]問題背景 背景就簡(jiǎn)單點(diǎn)兒說,當(dāng)初一個(gè)項(xiàng)目 C# 編寫,涉及浮點(diǎn)運(yùn)算,來龍去脈省去,直接看如下代碼。(為什么有這個(gè)問題產(chǎn)生,是因?yàn)楫?dāng)初線上產(chǎn)生了很詭異的問題,和本地調(diào)試效果不一致。) float?p3x = 80838.0f; float?p2y = -2499.0f; double?v321 = p3x *


問題背景

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果


背景就簡(jiǎn)單點(diǎn)兒說,當(dāng)初一個(gè)項(xiàng)目 C# 編寫,涉及浮點(diǎn)運(yùn)算,來龍去脈省去,直接看如下代碼。(為什么有這個(gè)問題產(chǎn)生,是因?yàn)楫?dāng)初線上產(chǎn)生了很詭異的問題,和本地調(diào)試效果不一致。)

float p3x = 80838.0f;
float p2y = -2499.0f;
double v321 = p3x * p2y;
Console.WriteLine(v321);


很簡(jiǎn)單吧,馬上筆算下結(jié)果為 -202014162,沒問題,難道C#沒有產(chǎn)生這樣的結(jié)果?不可能吧,開啟 VisualStudio,copy代碼試試,果然結(jié)果是-202014162。就這樣完了么?顯然沒有!把編譯時(shí)的選項(xiàng)從AnyCPU改成x64試試~(服務(wù)器環(huán)境正是64位滴哦?。?結(jié)果居然變成了-202014160,對(duì)沒錯(cuò),就是-202014160。細(xì)想一下,因?yàn)楦↑c(diǎn)運(yùn)算的誤差,-202014160 這個(gè)結(jié)果是合理的。嗯,再試試C++。// 測(cè)試環(huán)境Intel(R) i7-3770 CPU, windows OS 64. Visual Studio 2012 默認(rèn)設(shè)置。


float p3x = 80838.0f;
float p2y = -2499.0f;
double v321 = p3x * p2y;
std::cout.precision(15);
std::cout << v321 << std::endl;


呃,好像x86、x64都是這個(gè)合理的結(jié)果 -202014160。奇了個(gè)怪了。其實(shí)上面這段C++代碼在不同的平臺(tái)下的結(jié)果如下:


  • Windows 32/64位下:-202014160

  • Linux 64位下(CentOS 6 gcc 4.4.7):-202014160

  • Linux 32位下(Ubuntu 12.04+ gcc 4.6.3)是:-202014162

補(bǔ)充說明:當(dāng)初這篇文章投稿到酷殼,著名程序員左耳朵耗子那邊,這部分結(jié)果數(shù)據(jù)來自耗子叔對(duì)文章做的部分調(diào)整。(因?yàn)楫?dāng)初行文沒抓住重點(diǎn),還引來了不少吐槽)

合理的運(yùn)算結(jié)果,應(yīng)該是-202014160,正確的運(yùn)算結(jié)果是-202014162,合理性是浮點(diǎn)精度不夠造成的(后文解釋了合理性)。若是用兩個(gè)double相乘可得正確且合理的運(yùn)算結(jié)果。// 就別糾結(jié)我用的“正確、合理”這兩個(gè)詞是否恰當(dāng)了。問題是為何C#下X64和X86結(jié)果不一致?


浮點(diǎn)運(yùn)算結(jié)果錯(cuò)誤但合理的解釋

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果


為何  80838.0f * -2499.0f = -202014160.0 是合理的?

32位浮點(diǎn)數(shù)在計(jì)算機(jī)中的表示方式為:1位符號(hào)位(s)-8位指數(shù)位(E)-23位有效數(shù)字(M),即:

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果

其中E是實(shí)際轉(zhuǎn)換成1.xxxxx*2^E的指數(shù),M是去掉 1 后的前面的xxxxx(節(jié)約1位)。



1.  80838.0 如何表達(dá)?

80838.0 = 1 0011 1011 1100 0110.0(二進(jìn)制) = 1.0011 1011 1100 0110 0*2^16

有效位M = 0011 1011 1100 0110 0000 000(一共 23 位)

指數(shù)位E = 16 + 127 = 143 = 10001111

內(nèi)部表示 80838.0 = 0 [10001111] [0011 1011 1100 0110 0000 000] = 0100 0111 1001 1101 1110 0011 0000 0000 = 47 9d e3 00 //實(shí)際調(diào)試時(shí)看到的內(nèi)存值 可能是00 e3 9d 47是因?yàn)檎{(diào)試環(huán)境用了小端表示法法:低位字節(jié)排內(nèi)存低地址端,高位排內(nèi)存高地址

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果



2. -2499.0 如何表達(dá)?


-2499.0 = -100111000011.0 = -1.001110000110 * 2^11

有效位M = 0011 1000 0110 0000 0000 000

指數(shù)位E = 11+127=138= 10001010

符號(hào)位s = 1

內(nèi)部表示-2499.0 = 1 [10001010] [0011 1000 0110 0000 0000 000]

=1100 0101 0001 1100 0011 0000 0000 0000 =c5 1c 30 00

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果



3. 如何計(jì)算 80838.0 * -2499.0 = ?



指數(shù) e = 11+16 = 27

則指數(shù)位 E = e + 127 = 154 = 10011010

有效位相乘結(jié)果為 1.1000 0001 0100 1111 1011 1010 01 (可以自己動(dòng)手實(shí)際算下),實(shí)際中只能有23位,后面的被截?cái)嗉?000 0001 0100 1111 1011 1010 01,相乘結(jié)果內(nèi)部表示=1[10011010][1000 0001 0100 1111 1011 101= 1100 1101 0100 0000 1010 0111 1101 1101 = cd 40 a7 dd

結(jié)果 = -1.1000 0001 0100 1111 1011 101 *2^27

= -11000 0001 0100 1111 1011 1010000

= -202014160

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果


通過上面得知,32 位浮點(diǎn)數(shù),-202014160 就是合理的結(jié)果,完全能解釋清楚。但如果有效數(shù)字更長(zhǎng)的話, 上面的就不會(huì)被截?cái)唷?/span>



4. 正確的結(jié)果-202014162怎么得來?


有效位相乘結(jié)果為 1.1000 0001 0100 1111 1011 1010 01

即結(jié)果 = -1.1000 0001 0100 1111 1011 101001 *2^27

= -11000 0001 0100 1111 1011 101001 = -202014162


一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果



根因挖掘

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果


上面部分解釋了兩種結(jié)果的來源,但貌似沒從根本回到為什么?用C++同樣的代碼,X86,X64(DEBUG下,這個(gè)后面會(huì)說)下得到一致的結(jié)果-202014160,容易理解且也是合理的。原因何在?看下編譯后生成的代碼(截取關(guān)鍵部分)

//C# x86 下
......
float p3x = 80838.0f;
0000003b mov dword ptr [ebp-40h],479DE300h
float p2y = -2499.0f;
00000042  mov dword ptr [ebp-44h],0C51C3000h
double v321 = p3x * p2y;
00000049  fld dword ptr [ebp-40h]
0000004c fmul dword ptr [ebp-44h]
0000004f  fstp qword ptr [ebp-4Ch]
.......

//C# X64下
......
float p3x = 80838.0f;
00000045  movss xmm0,dword ptr [00000098h]
0000004d  movss dword ptr [rbp+3Ch],xmm0
float p2y = -2499.0f;
00000052  movss xmm0,dword ptr [000000A0h]
0000005a movss dword ptr [rbp+38h],xmm0
double v321 = p3x * p2y;
0000005f  movss xmm0,dword ptr [rbp+38h]
00000064  mulss xmm0,dword ptr [rbp+3Ch]
00000069  cvtss2sd xmm0,xmm0
0000006d  movsd mmword ptr [rbp+30h],xmm0
......


C++ x86 / x64下都生成了類似的代碼(這也就是為何 C++ x86/x64與C#x64結(jié)果一致)即都用了先用浮點(diǎn)乘起來(mulss),然后轉(zhuǎn)成double(cvtss2sd)。從上面的匯編代碼可以看出 C# X86生成代碼用的指令fld/fmul/fstp等。其中fld/fmul/fstp等指令是由FPU(float point unit)浮點(diǎn)運(yùn)算處理器做的,F(xiàn)PU在進(jìn)行浮點(diǎn)運(yùn)算時(shí),用了80位的寄存器做相關(guān)浮點(diǎn)運(yùn)算,然后再根據(jù)是float/double截取成32位或64位。非FPU的情況是用了SSE中128位寄存器(float實(shí)際只用了其中的32位,計(jì)算時(shí)也是以32位計(jì)算的),這就是導(dǎo)致上述問題產(chǎn)生的最終原因。


浮點(diǎn)運(yùn)算標(biāo)準(zhǔn)IEEE-754 推薦標(biāo)準(zhǔn)實(shí)現(xiàn)者提供浮點(diǎn)可擴(kuò)展精度格式(Extended precision),Intel x86處理器有FPU(float point unit)浮點(diǎn)運(yùn)算處理器支持這種擴(kuò)展。C#的浮點(diǎn)是支持該標(biāo)準(zhǔn)的,其中其官方文檔也提到了浮點(diǎn)運(yùn)算可能會(huì)產(chǎn)生比返回類型更高精度的值(正如上面的返回值精度就超過了float的精度),并說明如果硬件支持可擴(kuò)展浮點(diǎn)精度的話,那么所有的浮點(diǎn)運(yùn)算都將用此精度進(jìn)行以提高效率,舉個(gè)例子x*y/z, x*y的值可能都在double的能力范圍之外了,但真實(shí)情況可能除以z后又能把結(jié)果拉回到double范圍內(nèi),這樣的話,用了FPU的結(jié)果就會(huì)得到一個(gè)準(zhǔn)確的double值,而非FPU的就是無窮大之類的了。


即產(chǎn)生如上的結(jié)果原因是,兩個(gè)浮點(diǎn)數(shù)相乘在非FPU的情況下,用了32位計(jì)算產(chǎn)生的結(jié)果導(dǎo)致結(jié)果存在誤差,而FPU是用了80位進(jìn)行計(jì)算的,所以得到的結(jié)果是精度很高的,體現(xiàn)在本文的案例上就是個(gè)位數(shù)上的2。所以大家在寫代碼的時(shí)候得保證實(shí)際運(yùn)行環(huán)境/測(cè)試環(huán)境/開發(fā)環(huán)境的一致性(包括OS架構(gòu)啊、編譯選項(xiàng)等)啊,不然莫名其妙的問題會(huì)產(chǎn)生(本文就是開發(fā)環(huán)境與運(yùn)行環(huán)境不一致導(dǎo)致的問題,糾結(jié)了好久才發(fā)現(xiàn)是這個(gè)原因);遇到涉及浮點(diǎn)運(yùn)算的時(shí)候別忘了有可能是這個(gè)原因產(chǎn)生的;另外,float/double混用的情況得特別注意。


總結(jié)一下,本文通過分析之前遇到的一個(gè)疑難雜癥帶著大家一塊回顧或者學(xué)習(xí)了一下計(jì)算機(jī)內(nèi)部浮點(diǎn)數(shù)的表達(dá),解決了疑問。時(shí)候可能需要跟進(jìn)到硬件底層,當(dāng)然隨著術(shù)的發(fā)展,可能理所當(dāng)然的東西在新硬件的情況下也會(huì)有所不同(例如文中提到的 FPU 也有更高端的技術(shù)來替換了,本人對(duì)于硬件這塊了解不多,感興趣可以查閱更多材料,閱讀原文有更多參考資料)。

特別推薦一個(gè)分享架構(gòu)+算法的優(yōu)質(zhì)內(nèi)容,還沒關(guān)注的小伙伴,可以長(zhǎng)按關(guān)注一下:

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果

長(zhǎng)按訂閱更多精彩▼

一個(gè)由跨平臺(tái)產(chǎn)生的浮點(diǎn)數(shù)bug | 有你意想不到的結(jié)果

如有收獲,點(diǎn)個(gè)在看,誠(chéng)摯感謝

免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問題,請(qǐng)聯(lián)系我們,謝謝!

本站聲明: 本文章由作者或相關(guān)機(jī)構(gòu)授權(quán)發(fā)布,目的在于傳遞更多信息,并不代表本站贊同其觀點(diǎn),本站亦不保證或承諾內(nèi)容真實(shí)性等。需要轉(zhuǎn)載請(qǐng)聯(lián)系該專欄作者,如若文章內(nèi)容侵犯您的權(quán)益,請(qǐng)及時(shí)聯(lián)系本站刪除。
關(guān)閉
關(guān)閉