四則運(yùn)算:從IEEE 754標(biāo)準(zhǔn)到硬件實(shí)現(xiàn)的工程化解析)
1. 從“算得對”到“算得好”浮點(diǎn)運(yùn)算的工程挑戰(zhàn)在計(jì)算機(jī)的世界里我們總希望它能像人一樣“聰明”地處理數(shù)字。但計(jì)算機(jī)的“聰明”是建立在極其精確和嚴(yán)格的規(guī)則之上的。當(dāng)我們處理整數(shù)時(shí)比如計(jì)算123 456結(jié)果579是確定無疑的。然而一旦進(jìn)入科學(xué)計(jì)算、圖形渲染、人工智能訓(xùn)練等領(lǐng)域我們面對的數(shù)字動(dòng)輒是3.1415926、2.71828或者6.022e23這樣的實(shí)數(shù)。這時(shí)整數(shù)運(yùn)算的“直來直去”就行不通了我們需要一種能表示極大范圍、極高精度實(shí)數(shù)的方案這就是浮點(diǎn)數(shù)。浮點(diǎn)四則運(yùn)算聽起來像是把小學(xué)算術(shù)搬到了計(jì)算機(jī)里但實(shí)際要復(fù)雜得多。它不僅僅是“加減乘除”四個(gè)孤立的操作而是一套完整的、環(huán)環(huán)相扣的工程化流程。核心矛盾在于我們既要利用有限的硬件資源固定的字長比如32位或64位去表示一個(gè)理論上無限稠密的實(shí)數(shù)集合又要保證運(yùn)算的速度和結(jié)果的可靠性。這就引出了一系列關(guān)鍵問題兩個(gè)浮點(diǎn)數(shù)如何對齊小數(shù)點(diǎn)對階尾數(shù)運(yùn)算溢出或精度不足時(shí)怎么辦規(guī)格化運(yùn)算結(jié)果需要四舍五入但計(jì)算機(jī)里怎么“舍”怎么“入”舍入處理這些問題處理不好輕則導(dǎo)致計(jì)算結(jié)果存在微小誤差重則引發(fā)程序邏輯錯(cuò)誤甚至系統(tǒng)崩潰。因此理解浮點(diǎn)運(yùn)算是理解現(xiàn)代計(jì)算機(jī)如何處理現(xiàn)實(shí)世界復(fù)雜計(jì)算任務(wù)的關(guān)鍵一步也是寫出健壯、高效數(shù)值計(jì)算程序的基石。2. 浮點(diǎn)四則運(yùn)算的核心流程拆解浮點(diǎn)數(shù)的表示通常遵循IEEE 754標(biāo)準(zhǔn)分為符號位S、階碼E和尾數(shù)M三部分。四則運(yùn)算雖然目標(biāo)不同但其核心流程共享一套相似的“預(yù)處理-計(jì)算-后處理”框架。理解這個(gè)框架比死記硬背四個(gè)獨(dú)立公式要重要得多。2.1 運(yùn)算流程的通用骨架無論是加減還是乘除一次完整的浮點(diǎn)運(yùn)算都可以抽象為以下幾個(gè)階段操作數(shù)檢查這是第一步也是安全閥。需要檢查操作數(shù)是否為特殊的非規(guī)格化數(shù)、無窮大Inf或非數(shù)NaN。例如任何數(shù)與NaN運(yùn)算結(jié)果通常都是NaN無窮大與有限數(shù)的運(yùn)算也有特定規(guī)則。硬件中的浮點(diǎn)運(yùn)算單元FPU會首先處理這些特殊情況避免進(jìn)入復(fù)雜的常規(guī)計(jì)算流程。對階/對齊對于加減法或階碼計(jì)算對于乘除法這是加減法與乘除法的分水嶺。加減法由于尾數(shù)直接相加減需要小數(shù)點(diǎn)對齊所以必須將兩個(gè)操作數(shù)的階碼調(diào)整至相同。方法是找出階碼較小的數(shù)將其尾數(shù)右移相當(dāng)于縮小數(shù)值同時(shí)增大其階碼直到兩數(shù)階碼相等。右移出的低位可能會丟失這就引入了舍入誤差。乘除法尾數(shù)直接相乘或相除無需對齊小數(shù)點(diǎn)。新的階碼由兩個(gè)原階碼通過加乘法或減除法得到同時(shí)需要減去一個(gè)偏置常數(shù)Bias。尾數(shù)運(yùn)算在對齊的階碼加減法或計(jì)算出的新階碼乘除法基礎(chǔ)上對尾數(shù)進(jìn)行實(shí)際的定點(diǎn)整數(shù)運(yùn)算加法、減法、乘法或除法。結(jié)果規(guī)格化尾數(shù)運(yùn)算的結(jié)果很可能不符合浮點(diǎn)數(shù)的規(guī)格化要求對于二進(jìn)制原碼規(guī)格化要求尾數(shù)最高位為1。因此需要將結(jié)果尾數(shù)左移或右移并相應(yīng)地調(diào)整階碼使其滿足規(guī)格化形式。這個(gè)過程可能需要進(jìn)行多次。舍入處理規(guī)格化后的尾數(shù)位數(shù)可能超過硬件所能存儲的位數(shù)。必須按照設(shè)定的舍入模式如向最近偶數(shù)舍入、向零舍入等將多出的位處理掉。舍入可能引發(fā)再次規(guī)格化。溢出/下溢判斷最后檢查結(jié)果的階碼是否超出了表示范圍。若階碼過大超過最大值稱為“上溢”結(jié)果可能變?yōu)闊o窮大若階碼過小低于最小值稱為“下溢”結(jié)果可能變?yōu)?或非規(guī)格化數(shù)。這個(gè)流程就像一條精密的流水線每一步的產(chǎn)出都是下一步的輸入任何環(huán)節(jié)的微小偏差都可能在后續(xù)被放大。2.2 加減運(yùn)算關(guān)鍵在于“對齊”浮點(diǎn)加減法是四則運(yùn)算中最能體現(xiàn)工程復(fù)雜性的。我們以A B為例假設(shè)兩數(shù)均為規(guī)格化數(shù)。第一步0操作數(shù)檢查。略過假設(shè)均為正常數(shù)。第二步對階。這是核心。假設(shè)A 1.101 * 2^4,B 1.001 * 2^2。顯然兩者指數(shù)不同不能直接相加尾數(shù)。對階原則是“小階向大階看齊”。這里B的階碼2小于A的階碼4差值ΔE 2。因此需要將B的尾數(shù)右移2位B 0.01001 * 2^4。注意右移后原來尾數(shù)低位的01被移出它們被稱為“保護(hù)位”和“舍入位”在后續(xù)舍入時(shí)會用到。對階后兩數(shù)階碼統(tǒng)一為4。第三步尾數(shù)相加?,F(xiàn)在可以對尾數(shù)進(jìn)行定點(diǎn)加法1.101 0.01001 1.11101。結(jié)果尾數(shù)為1.11101。第四步規(guī)格化。檢查結(jié)果尾數(shù)1.11101其最高位已經(jīng)是1符合規(guī)格化要求無需左規(guī)。但有時(shí)加法會導(dǎo)致尾數(shù)絕對值大于等于2即最高位產(chǎn)生進(jìn)位例如1.111 1.001 11.000這時(shí)就需要進(jìn)行“右規(guī)”將尾數(shù)右移一位變成1.1000同時(shí)階碼加1。第五步舍入。我們的結(jié)果尾數(shù)1.11101有5位小數(shù)假設(shè)我們的浮點(diǎn)數(shù)格式只允許存儲3位小數(shù)即尾數(shù)有效位為4位包含隱含的1。那么我們需要對多出的01進(jìn)行舍入。采用最常用的“向最近偶數(shù)舍入”Round to nearest, ties to even模式看被舍去的部分是否大于最低有效位LSB的一半或者等于一半且LSB為奇數(shù)。這里01小于0.1LSB的一半所以直接舍去結(jié)果為1.111。第六步溢出判斷。檢查階碼4是否在正常范圍內(nèi)假設(shè)正常則得到最終結(jié)果1.111 * 2^4。注意對階時(shí)“小階向大階看齊”的原因是如果讓大階向小階看齊大數(shù)的尾數(shù)需要左移這會導(dǎo)致其高位有效數(shù)字被移出造成巨大的精度損失甚至錯(cuò)誤。而讓小階數(shù)的尾數(shù)右移損失的只是低位精度影響相對較小。2.3 乘除運(yùn)算關(guān)鍵在于“階碼計(jì)算與規(guī)格化”浮點(diǎn)乘除法在流程上比加減法稍顯簡潔因?yàn)樗^了對階這一步但帶來了新的挑戰(zhàn)。浮點(diǎn)乘法A * B階碼相加新階碼E E_A E_B - Bias。減去Bias是因?yàn)樵贗EEE 754中階碼是以移碼Excess-N形式存儲的直接相加會包含兩次偏置。尾數(shù)相乘將兩個(gè)尾數(shù)通常是1.M的形式作為定點(diǎn)小數(shù)相乘。這是一個(gè)位數(shù)翻倍的操作例如兩個(gè)24位尾數(shù)包含隱含位相乘會得到一個(gè)48位的結(jié)果。規(guī)格化乘積的尾數(shù)可能不在[1, 2)區(qū)間。如果大于等于2則需右規(guī)如果小于1由于尾數(shù)都是大于等于1的相乘后小于1的情況極少除非有非規(guī)格化數(shù)參與則需左規(guī)。舍入由于尾數(shù)相乘后位數(shù)變多舍入處理是必然的且舍入可能引發(fā)第二次規(guī)格化例如舍入進(jìn)位導(dǎo)致尾數(shù)等于2。確定符號符號位由兩個(gè)操作數(shù)的符號位異或得到。浮點(diǎn)除法A / B階碼相減新階碼E E_A - E_B Bias。這里加回Bias以修正移碼表示。尾數(shù)相除將被除數(shù)的尾數(shù)除以除數(shù)的尾數(shù)。這是比乘法更復(fù)雜的操作通常通過迭代算法如牛頓-拉弗森方法或?qū)S玫某ㄆ饔布?shí)現(xiàn)。規(guī)格化商的尾數(shù)也需要規(guī)格化到[1, 2)區(qū)間。舍入與后續(xù)處理與乘法類似。實(shí)操心得在編寫高性能數(shù)值代碼時(shí)要警惕乘除法的成本?,F(xiàn)代CPU中浮點(diǎn)乘法的延遲通常比加法高除法的延遲更是遠(yuǎn)高于乘法。一個(gè)常見的優(yōu)化是在可能的情況下用乘以倒數(shù)來代替除法但需要注意精度問題。例如a / b在循環(huán)外計(jì)算inv_b 1.0 / b循環(huán)內(nèi)計(jì)算a * inv_b如果循環(huán)次數(shù)很多這可能帶來性能提升。3. 規(guī)格化保證精度的核心操作規(guī)格化不是可選項(xiàng)而是浮點(diǎn)運(yùn)算的強(qiáng)制性步驟。它的根本目的是為了在給定的位數(shù)下獲得最高的表示精度。一個(gè)未規(guī)格化的浮點(diǎn)數(shù)比如0.001101 * 2^6其有效數(shù)字前有多個(gè)前導(dǎo)零浪費(fèi)了寶貴的存儲位。規(guī)格化后變?yōu)?.101 * 2^3所有有效數(shù)字都集中到了小數(shù)點(diǎn)后精度得以最大化。3.1 左規(guī)與右規(guī)根據(jù)運(yùn)算結(jié)果的不同規(guī)格化分為左規(guī)和右規(guī)左規(guī)當(dāng)尾數(shù)運(yùn)算結(jié)果的形式為0.xxx...或1.0xxx...對于補(bǔ)碼符號位與最高數(shù)值位相同時(shí)需要規(guī)格化。方法是尾數(shù)不斷左移每左移一位階碼減1直到尾數(shù)最高位變?yōu)橛行е翟a下為1補(bǔ)碼下符號位與最高數(shù)值位不同。左規(guī)可能進(jìn)行多次。例如加法結(jié)果00.00101補(bǔ)碼雙符號位需要左移兩位變成00.10100階碼相應(yīng)減2。右規(guī)當(dāng)尾數(shù)運(yùn)算結(jié)果溢出時(shí)即雙符號位為01.xxx...或10.xxx...補(bǔ)碼需要進(jìn)行右規(guī)。尾數(shù)右移一位階碼加1。右規(guī)通常一次即可完成。例如乘法結(jié)果尾數(shù)為10.1101右規(guī)后為1.01101最高位1進(jìn)到符號位這里需要仔細(xì)理解在補(bǔ)碼表示且采用雙符號位檢測溢出時(shí)10.xxx表示負(fù)溢出右規(guī)一位變?yōu)?1.0110階碼加1。一個(gè)關(guān)鍵技巧使用雙符號位。在硬件實(shí)現(xiàn)中為了可靠地檢測尾數(shù)加減是否溢出常采用變形補(bǔ)碼即使用兩個(gè)二進(jìn)制位表示符號位。這樣“00”表示正數(shù)“11”表示負(fù)數(shù)。當(dāng)運(yùn)算結(jié)果的兩個(gè)符號位不同01或10時(shí)就表示發(fā)生了溢出從而觸發(fā)右規(guī)操作。這是硬件設(shè)計(jì)中的一個(gè)經(jīng)典且有效的技巧。3.2 規(guī)格化帶來的連鎖反應(yīng)規(guī)格化操作特別是左規(guī)會帶來一個(gè)副作用它可能將尾數(shù)低位的“0”移到有效位上同時(shí)從右側(cè)移入新的“0”。這本身沒有問題。問題在于如果左規(guī)的位數(shù)過多可能會把之前尾數(shù)運(yùn)算中隱藏的、用于提高舍入精度的“保護(hù)位”也移到有效區(qū)域之外從而影響最終舍入的準(zhǔn)確性。因此在高端浮點(diǎn)運(yùn)算單元設(shè)計(jì)中會在尾數(shù)運(yùn)算時(shí)保留額外的保護(hù)位、舍入位和粘位確保在經(jīng)歷規(guī)格化移位后仍有足夠的信息進(jìn)行正確的舍入判斷。4. 舍入不可避免的誤差管理與藝術(shù)舍入是浮點(diǎn)運(yùn)算中誤差的主要來源之一。因?yàn)闊o限精度的實(shí)數(shù)結(jié)果必須被“塞進(jìn)”有限位的浮點(diǎn)數(shù)格式中。IEEE 754標(biāo)準(zhǔn)定義了多種舍入模式讓程序員可以根據(jù)應(yīng)用需求進(jìn)行選擇。4.1 四種主要的舍入模式向最近偶數(shù)舍入Round to nearest, ties to even這是默認(rèn)的也是應(yīng)用最廣的模式。規(guī)則是找到最接近的兩個(gè)可表示值取距離更近的那個(gè)。如果距離相等即恰好位于中間則取“偶數(shù)”結(jié)果即最低有效位為0的那個(gè)。這種模式在統(tǒng)計(jì)上能最小化累積誤差是最優(yōu)選擇。示例假設(shè)保留3位小數(shù)。1.00101舍去部分01 0.001一半故舍去得1.001。1.00110舍去部分10 0.001故進(jìn)位得1.010。1.00111舍去部分11進(jìn)位得1.010。1.01010中間情況舍去部分恰好是100...兩個(gè)最近數(shù)是1.010和1.011取最低位為0的偶數(shù)1.010。向零舍入Round toward zero直接截?cái)喽嘤嗟奈徊蛔鋈魏握{(diào)整。這是最簡單、速度最快的模式但會引入系統(tǒng)性偏差結(jié)果絕對值總是不大于精確值。向正無窮舍入Round toward ∞結(jié)果總是朝正無窮方向調(diào)整。在需要保證結(jié)果不小于真實(shí)值的場合如計(jì)算資源下限很有用。向負(fù)無窮舍入Round toward -∞結(jié)果總是朝負(fù)無窮方向調(diào)整。用途與向正無窮舍入類似。4.2 保護(hù)位、舍入位與粘位為了更精確地進(jìn)行“向最近舍入”硬件在內(nèi)部運(yùn)算時(shí)會保留比標(biāo)準(zhǔn)格式更多的位數(shù)。通常包括保護(hù)位Guard Bit, G緊跟在最低有效位LSB后的第一位。舍入位Round Bit, R保護(hù)位后的第二位。粘位Sticky Bit, S從舍入位之后的所有位進(jìn)行“或”運(yùn)算得到的一位。只要這些位中有任何一個(gè)為1粘位就為1。工作原理在規(guī)格化之后、最終舍入之前硬件會檢查G、R、S位。對于“向最近偶數(shù)舍入”看G位。如果G0直接舍去。如果G1且R或S中至少有一個(gè)為1則進(jìn)位。如果G1且RS0即恰好是中間值則看LSB使其變?yōu)榕紨?shù)。這個(gè)機(jī)制確保了即使在經(jīng)過規(guī)格化移位后仍然能對最初被移出的低位信息做出正確的舍入判斷極大地提高了舍入精度。常見問題為什么我寫的浮點(diǎn)數(shù)循環(huán)累加結(jié)果和數(shù)學(xué)期望總有微小偏差 這正是舍入誤差累積的典型表現(xiàn)。例如用0.1累加10次并不等于1.0。因?yàn)?.1在二進(jìn)制中是無限循環(huán)小數(shù)0.0001100110011...存入浮點(diǎn)數(shù)時(shí)已經(jīng)被舍入。每次加法都可能產(chǎn)生新的舍入誤差。成千上萬次操作后誤差就可能被放大到肉眼可見的程度。解決方案是1) 理解并接受這是浮點(diǎn)數(shù)的本質(zhì)2) 在比較浮點(diǎn)數(shù)相等時(shí)使用誤差范圍如fabs(a-b) 1e-9而不是3) 對于數(shù)值敏感的算法考慮使用更高精度的double64位而不是float32位或者使用定點(diǎn)數(shù)、有理數(shù)庫等。5. 溢出與下溢邊界情況的處理浮點(diǎn)數(shù)的表示范圍是有限的運(yùn)算結(jié)果可能超出這個(gè)范圍。5.1 上溢Overflow當(dāng)結(jié)果的階碼超過最大可表示值如單精度浮點(diǎn)數(shù)的階碼大于127時(shí)發(fā)生上溢。IEEE 754規(guī)定此時(shí)根據(jù)符號位和舍入模式結(jié)果被設(shè)置為正無窮大Inf或負(fù)無窮大-Inf。無窮大可以參與后續(xù)運(yùn)算如Inf * 2 Inf,5 / Inf 0但像Inf - Inf或0 * Inf這樣的不確定操作會產(chǎn)生NaNNot a Number。5.2 下溢Underflow當(dāng)結(jié)果的階碼小于最小可表示值如單精度浮點(diǎn)數(shù)的階碼小于-126時(shí)發(fā)生下溢。這意味著結(jié)果的真值非常接近于零。處理方式有兩種突然下溢直接將結(jié)果置為0帶符號。這種方式簡單但從非零值突然跳到0在數(shù)學(xué)上不連續(xù)。漸進(jìn)下溢IEEE 754采用允許階碼取比最小值更小的值但此時(shí)尾數(shù)不再要求是規(guī)格化的即最高位可以是0這種數(shù)稱為非規(guī)格化數(shù)。非規(guī)格化數(shù)的精度隨著數(shù)值接近0而逐漸降低最終平滑地過渡到0。這提供了“軟著陸”避免了突然下溢帶來的問題但表示范圍和精度都有損失。排查技巧在調(diào)試數(shù)值程序時(shí)如果結(jié)果突然變成Inf,-Inf或NaN首先要排查的就是溢出和下溢。可以按以下步驟打印中間變量在關(guān)鍵計(jì)算步驟后輸出變量的值看是哪個(gè)操作導(dǎo)致了溢出。檢查輸入范圍確認(rèn)輸入數(shù)據(jù)是否在合理預(yù)期內(nèi)。一個(gè)巨大的數(shù)乘以另一個(gè)巨大的數(shù)極易上溢。使用數(shù)學(xué)函數(shù)替代例如計(jì)算exp(x)當(dāng)x很大時(shí)會上溢??梢钥紤]使用log1p,expm1等更穩(wěn)定的函數(shù)變體或者在計(jì)算前對數(shù)據(jù)進(jìn)行縮放例如在邏輯回歸中對線性部分進(jìn)行歸一化。啟用浮點(diǎn)異常在一些編程環(huán)境如C/C使用fenv.h中可以啟用浮點(diǎn)異常捕獲當(dāng)發(fā)生溢出、除零等操作時(shí)觸發(fā)信號或異常便于定位。6. 硬件實(shí)現(xiàn)與優(yōu)化窺探了解算法流程后我們看看硬件是如何高效實(shí)現(xiàn)這一切的?,F(xiàn)代CPU中的浮點(diǎn)運(yùn)算單元FPU是一個(gè)高度流水線化、并行的復(fù)雜電路。6.1 關(guān)鍵硬件組件階碼比較器與移位器負(fù)責(zé)加減法中的對階操作快速比較兩個(gè)階碼大小并控制桶形移位器對小階操作數(shù)的尾數(shù)進(jìn)行右移。尾數(shù)加法器/乘法器/除法器這是核心計(jì)算部件。乘法器通?;谌A萊士樹或布斯算法等快速乘法結(jié)構(gòu)。除法器則更復(fù)雜可能采用SRT等迭代算法。前導(dǎo)零/一預(yù)測與計(jì)數(shù)電路在規(guī)格化步驟中需要快速確定尾數(shù)有多少個(gè)前導(dǎo)零對于正數(shù)左規(guī)或前導(dǎo)一對于負(fù)數(shù)補(bǔ)碼左規(guī)以便一次性完成多位左移而不是一位一位地移。這是一個(gè)關(guān)鍵的加速電路。舍入邏輯單元根據(jù)G、R、S位和當(dāng)前舍入模式?jīng)Q定是否向尾數(shù)最低位進(jìn)位。溢出/下溢檢測電路監(jiān)控階碼的最終值判斷是否超出范圍。6.2 融合乘加運(yùn)算這是現(xiàn)代浮點(diǎn)運(yùn)算一個(gè)極其重要的優(yōu)化Fused Multiply-Add, FMA。它在一個(gè)不可分割的原子操作中計(jì)算(A * B) C。與先乘后加相比FMA有兩大優(yōu)勢精度更高它只進(jìn)行一次舍入在最終結(jié)果而分開計(jì)算會進(jìn)行兩次舍入乘法一次加法一次從而減少了舍入誤差。速度更快/功耗更低它用一個(gè)專門的硬件單元一次完成比兩個(gè)獨(dú)立操作更快且減少了中間結(jié)果的讀寫。FMA指令對許多數(shù)值算法如矩陣運(yùn)算、多項(xiàng)式求值、點(diǎn)積計(jì)算是巨大的福音。在編寫性能關(guān)鍵代碼時(shí)應(yīng)留意編譯器是否自動(dòng)生成了FMA指令或者是否有對應(yīng)的內(nèi)聯(lián)函數(shù)如C/C中的fma()函數(shù)可供使用。理解浮點(diǎn)四則運(yùn)算從抽象的算法流程到具體的硬件實(shí)現(xiàn)再到編程實(shí)踐中的誤差控制和優(yōu)化技巧是一個(gè)層層遞進(jìn)的過程。它不僅僅是計(jì)算機(jī)組成原理課本上的一章更是每一個(gè)需要與數(shù)值計(jì)算打交道的程序員必須內(nèi)化的基礎(chǔ)知識。下次當(dāng)你寫下一條簡單的浮點(diǎn)數(shù)加法語句時(shí)或許能體會到其背后這一系列精密而優(yōu)雅的操作。