vatt'ghern jaskier's ballads
本文 2 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

8 減 7.5,答案只是 0.5,但 8087 得先把 7.5 右移對齊、做一次整數減法,再把結果左移四位、把指數減 4。加法最多多移一位,減法卻可能留下一長串前導零。

8087 怎麼用微碼做浮點加減

Ken Shirriff 在 righto.com 上拆解了 Intel 8087 浮點加減法的微碼。這篇文章不假設你看過微碼:你只需要知道浮點數是「符號、指數、有效位數」三塊,就能跟著走完 8087 做一次加法和一次減法的全部步驟,並看懂為什麼減法才是難的那一半。文中的微碼位址、暫存器大小與例子都來自這篇文章,凡是我自己算出來或推測的地方,會直接寫明。

8 減 7.5:一道小減法裡的四次移位

先看 Shirriff 舉的第二個例子。要算的是 8 − 7.5,兩個數都是再普通不過的值。8 寫成二進位是 1.000 × 2³,7.5 是 1.111 × 2²。兩者的指數差 1,有效位數卻幾乎填滿,差距只有最後一位。

原文對這個例子的描述很短,步驟只有三步。第二個運算數右移,讓指數對上、二進位小數點對齊;有效位數做整數減法;結果左移,直到最高位是 1,這個左移是四位,所以指數要減 4。我照這個流程補算了數值:8 的有效位數是 1.000,7.5 右移一位後變成 0.111 1,相減剩下 0.000 1,左移四位回到 1.000,指數從 3 減到 −1,結果是 2⁻¹,也就是 0.5。這個最終值是我自己算的,原文沒有寫出來。

這個例子跟另一個例子擺在一起,反差很大。第一個例子是 13.5 + 3.5:第二個運算數右移兩位對齊,整數相加後左邊多出一個進位位元,於是結果右移一位、指數加 1。同樣是對齊、運算、調整,加法的調整頂多一位,減法的調整是四位,而且方向相反。

對齊、運算、正規化:浮點加減法的三步

背景先交代:8087 在 1980 年推出,用來加速 IBM PC 之類系統的浮點運算。加法的加速幅度引自 Intel 的文件,相對於用 8086 以軟體模擬浮點,典型的情況是「by a factor of almost 100」,也就是接近 100 倍。這是 Intel 文件的數字,作者沒有自己量,註腳也註明速度提升依指令而異。

浮點數的形式是符號 × 有效位數 × 2 的指數次方,其中有效位數經過正規化,長得像 1.bbb……,最左邊一定是 1。兩個浮點數相加,不能直接把有效位數疊在一起,因為各自的指數不同,同一個位元位置代表的值就不同。Shirriff 的寫法是:「Before the numbers can be added (or subtracted), their binary points must be aligned」。對齊的做法是把指數較小的那個數的有效位數往右移,移幾位就是兩個指數的差,直到兩邊的二進位小數點落在同一個位置。

對齊之後,兩組有效位數就是兩個普通的二進位整數,可以用整數加法器直接加或減。最後一步是調整,因為運算的結果不一定還是「1.bbb……」的形狀。原文這樣說:「After the operation, the exponent of the result must be adjusted, normalizing the significand so it starts with a 1 bit.」把有效位數移回以 1 開頭,每移一位,指數就要跟著加或減一。

三步合起來就是對齊、運算、正規化。加法與減法的差別,幾乎全部發生在最後一步,以及最後一步之前要先處理的符號問題。

為什麼減法才是難的那一半

談到減法,原文先說:「One complication with subtraction is that the result can be negative.」結果可能是負的。減法會多出來的情況有三種:結果為負、結果為零、結果遠小於輸入。

為負的情況,修法是把結果取負,再翻轉符號。這件事要發生,前提是有效位數是用「無號整數」在算的:硬體只管算兩個非負整數之差,如果被減數比減數小,得到的是負數,就要先取負、再把整個浮點結果的符號翻過來。零的情況是兩數正好抵銷,結果必須回報成 +0 或 −0,這個符號並不固定,後面談零的符號時會給出規則。

最費工的是結果遠小於輸入的情況。回頭看 8 − 7.5,兩個數大小相近,相減之後前面的位元全部抵銷,只剩最低位的一個 1。為了讓有效位數重新以 1 開頭,必須把整串前導零移掉,移掉幾位,指數就減掉幾位。移位量不是固定的,要看抵銷得多徹底,這代表硬體得先量出「前導零有幾個」。加法沒有這個問題:兩個以 1 開頭的數相加,最多多一位進位,所以不是不用調整,就是右移一位。

符號的處理有一個很乾淨的公式。把 tmpA 與 tmpB 兩個暫存器裡運算數的符號、再加上運算種類位元,三者做 XOR:「tmpAsign⊕tmpBsign⊕op yields 1 if the unsigned operation is subtraction」。這裡的 op 是指令本身的位元,作者的設定是減法為 1、加法為 0。公式的意思是:真正要在有效位數上做的「無號運算」,不只看指令是加還是減,還要看兩個運算數的符號。(−5) + 3 雖然是加法指令,實際上是用 5 減去 3 再決定符號;5 − (−3) 雖然是減法指令,實際上是兩個大小相加。下面的表格把八種組合列完。

tmpA 符號tmpB 符號opXOR運算實際在算
0(正)0(正)00加A + B
0(正)0(正)11減A - B
0(正)1(負)01減A + (-|B|)
0(正)1(負)10加A - (-|B|)
1(負)0(正)01減(-|A|) + B
1(負)0(正)10加(-|A|) - B
1(負)1(負)00加(-|A|) + (-|B|)
1(負)1(負)11減(-|A|) - (-|B|)
XOR 欄依上述公式計算,op 欄是指令的編碼(加為 0、減為 1),這是作者的假設。「實際在算」一欄是我自己的算術說明。標色的四列,就是需要走減法路徑的組合。

八種組合裡剛好一半會走減法路徑。這也解釋了一件事:8087 的加法與減法指令共用同一段微碼,指令是加還是減,只是 XOR 公式的其中一個輸入。程式看到的是兩種指令,微碼看到的只有「無號加」與「無號減」兩種工作。

把三步和符號規則放在一起,最好的辦法是自己輸入兩個數走一遍。下面的小工具把前面三步實作出來。它不是 8087 的模擬器:有效位數只有 13 位元(真正的 8087 是 64 位元),也沒有模擬 guard、round、sticky 三個低位元,移出去的位元直接捨掉。它走的是原文描述的那條流程:算 XOR 決定無號運算,比較指數並交換,把較小的數右移對齊,加或減,最後正規化並決定符號。預設是第二個例子 8 − 7.5,按鈕可以切到第一個例子,也可以試試兩數相減剛好抵銷、或被減數比較小的情況。

輸入兩個數、選加或減,逐步看對齊、運算、正規化 · 13 位元示意版


    

每個位元欄位的格式是「進位位 最高位.小數位」。前導的「0」是進位位,運算結果若把它變成 1,就代表需要右移一位。

每個位元欄位的格式是「進位位 最高位.小數位」

加法最多把結果右移一位就完成正規化,減法兩數相近時會留下一長串前導零,得左移同樣位數並把指數減掉,8 減 7.5 就要左移四位。

把預設的 8 − 7.5 看一遍:XOR 是 0 ^ 0 ^ 1 = 1,走減法;兩個指數差 1,所以 7.5 右移一位;相減之後只剩最低位,工具會告訴你前導零有幾個、左移幾位。再切到 13.5 + 3.5:XOR 是 0,走加法,右移兩位對齊,整數相加後進位位變成 1,右移一位,指數加 1。這兩次走完,就是兩個例子的完整過程。

把「−6 + 2.5」那個預設也按一下。指令是加法,XOR 卻是 1:6 比 2.5 大,所以大的數的符號保留,結果是 −3.5。換成「3 − 5」,被減數比較小,整數減法算出負值,工具會顯示取負與翻轉符號。這兩種情況在 8087 的微碼裡都有對應的處理,後面的位址表會指出在哪裡。

微碼是什麼,8087 怎麼把這些步驟排成順序

到這裡為止談的都是「要做哪些事」,還沒談「由誰、以什麼順序做」。8087 的做法是把每一個浮點指令拆成一串更小的動作,這些動作存在晶片內的 ROM 裡,統稱微碼。這顆 ROM 共有 1648 條微指令,每條 16 位元的微指令只執行一個低階操作:「each 16-bit micro-instruction performs one low-level operation」。一條浮點加法指令對 8087 來說,是從 ROM 的某個位址開始、一路執行到指令結束為止的一串微指令。

微碼清單裡的微指令有幾種寫法。把某處的值搬到暫存器,是 來源 -> 目的。加法器運算要兩條微指令,第一條設定運算數與選項,後面一條把加總暫存器的內容搬到目的地。移位器同理,也是一條設定、一條搬出。再來是條件跳躍,例如 jmp #0269 if not unsigned sub,以及子程式呼叫與返回,像 call CREATE_DENORM。RNI 表示結束目前指令的微碼、去跑下一條機器指令。CREATE_DENORM、SPECIAL_TMPS 這些子程式名稱是微碼清單中的標籤。

加減法的微碼不是單獨一段。所有算術指令共用一個入口,位址是 #0001,把運算數載入 tmpA 與 tmpB、處理特殊值、依指令種類分派,算完存回結果、必要時彈出堆疊,最後以 RNI 結束。加減法專用的子程式從 #0232 開始。下面這張圖,是依原文列出的位址範圍整理的執行路線。

零檢查:0+0、0-0、異號零 #0232 至 #0235 比較指數,必要時交換運算數 #0236 至 #0245 對齊:CREATE_DENORM 右移 tmpB #0246 至 #0249 依 XOR 分流:無號加或無號減 #0250 無號減法:相減、為負則取負 檢查零與非正規值 #0251 至 #0268 無號加法:相加 #0270 查進位,#0271 右移 #0269 至 #0274 零與捨入位元;前導零正規化 先位元組、再位元 #0275 至 #0281;#0282 至 #0288 收尾:寫符號、ADJUST_PRECISION、算指數 #0289 至 #0298
方框裡的位址範圍與路徑來自文章的微碼說明。加法路徑在進位處理之後直接前往 #0289,減法路徑則多經過一段前導零正規化;圖中的跳躍目標是依範圍整理的,細節以原文列出的微碼為準。

這張圖最值得看的,是中間分叉之後兩條路的長度。左邊的減法路徑佔了 #0251 到 #0268 十八個位址,右邊的加法路徑只有 #0269 到 #0274 六個,而且減法還要多走 #0275 到 #0281 的零結果與捨入位元處理,以及 #0282 到 #0288 的前導零正規化。位址數不等於執行時間,但至少說明了微碼把複雜度放在哪裡:減法要處理負值、零、非正規值,加法只要處理進位。

前兩段跟三步流程的對應很直接。#0232 到 #0235 先擋掉零;#0236 到 #0245 比較兩個指數,把 tmpA 的符號複製到符號栓鎖,用加法器算出指數差,需要時交換兩個運算數,並在交換造成影響時翻轉符號栓鎖;#0246 到 #0249 呼叫 CREATE_DENORM,把 tmpB 右移到指數吻合,同時清掉 tmpB 的指數欄位,改用它記錄後續的調整量。到 #0250,微碼檢查前面算出的 XOR,跳向加法或減法。

減法路徑上,#0253 到 #0255 處理結果為負的情況,也就是取負;#0256 與 #0261 到 #0268 處理結果為零的各種情形;#0282 到 #0288 用前導零電路做正規化。加法路徑上,#0270 檢查加總暫存器的第 64 位,也就是最高位的進位,沒有進位就直接跳到 #0289;有進位,結果在 #0271 右移。兩條路都在 #0289 到 #0298 會合,寫入符號,除非是 FBLD 或 FBSTP 這兩條指令,否則呼叫 ADJUST_PRECISION 處理 32 位元與 64 位元結果,再算出最終指數並把結果存回。

要讓這些步驟有地方執行,晶片上要有對應的硬體。加減法用到的資料路徑區塊有:整數加法器、加總暫存器、移位器、前導零電路,以及指數轉換器。下面這個小圖可以逐一點開,看每個區塊在哪一步出力。

點選任一區塊,讀它在加減法裡負責什麼 · 6 個區塊

tmpA、tmpB 各 80 位元 移位器 68 位元,任意位移 整數加法器 69 位元 加總暫存器 加法器的輸出 前導零電路 在加總暫存器左側 指數轉換器 偵測溢位與下溢

各區塊的位元數與功能取自文章。圖中不畫連線,因為文章在這一段沒有逐條交代資料如何在區塊之間流動。

各區塊的位元數與功能取自文章

加減法在資料路徑上用到 69 位元整數加法器、68 位元移位器、加總暫存器、前導零電路和指數轉換器,tmpC 的低三位是 guard、round、sticky。

圖上的第一顆按鈕把 tmpC 也一併介紹了,因為它在圖上沒有獨立方塊,原文把它描述成沒有符號、指數與標籤、只有 68 位元有效位數的暫存器。guard、round、sticky 三個位元在加減法這一節只被點名,沒有展開用法,所以這裡也只停在「它們存在」這個事實。

運算數的格式也值得交代。內部的暫存器用 80 位元的「temporary real」格式:1 個符號位元、15 位元的指數、64 位元的有效位數,指數偏移值是 16383。八個堆疊暫存器各自帶一個標籤,值是零、有效、特殊或空。合理的推測是,微碼一開始先看標籤、先擋零,是因為零和特殊值不需要進入對齊與正規化,不過原文沒有明說這個理由。

前導零怎麼數,以及特殊值與零的符號

減法之後,結果左邊可能有任意多個零,像 8 − 7.5 那樣只剩最低位有值的話,前面幾乎全是零。正規化要知道前導零的個數,才能一次移到位。直覺的做法是做一個 64 位元的前導零偵測器,一次輸出 0 到 63。8087 沒有這樣做。

原文的說法是:「the leading zero shift is done in two steps」。先用 8 位元的偵測器看每個位元組是不是全零,把開頭連續全零的位元組整個移掉;再用一個 8 位元的偵測器,數剩下的最高位元組裡有 0 到 7 個前導零,移掉那幾位。這個做法是用「two 8-bit leading zero detectors instead of a 64-bit leading zero detector」,比 64 位元偵測器慢,但簡單得多。電路細節上,它把 0 到 7 個前導零編碼成 3 位元的值。作者對這段電路的解釋很坦白:「This is a hand-waving explanation, but one can enumerate the 256 cases and verify that they all produce the correct answer.」也就是 8 位元輸入的 256 種組合,可以一一列舉驗證。兩次移位的總量,會存進 tmpB 的指數欄位,供收尾計算最終指數。

下面的滑桿讓你決定結果開頭有幾個零,看兩次移位各出多少力。圖中的 64 位元內容是示意用的固定圖樣,不是 8087 的真實資料。

拖曳前導零個數,看兩次移位各移多少 · 0 到 63 個前導零

19
運算後的 64 位元有效位數 第一次:左移整個位元組 第二次:再左移 0 到 7 位元

深色格是 1,空格是 0,粗線分隔位元組。第二列開頭至多剩 7 個零,第三列開頭一定是 1,指數則要減掉兩次移位的總和。

預設的 19 個前導零,是兩個位元組加三個位元。把滑桿拖到 8 的倍數,例如 16 或 24,第二次移位會變成 0,只靠第一次就完成。拖到 7、15 這類數字,第一次不動或只動一個位元組,剩下的全部交給第二次。無論拖到哪裡,第三列的開頭都是 1,這就是正規化完成的標誌。

這個設計的取捨很具體:兩個小偵測器要串接走兩次,延遲比一個大偵測器長,原文承認了這個代價;換來的是電路簡單,8 位元輸入只有 256 種情況,列舉完就能確認它對。Intel 當年為什麼這樣取捨,原文沒有說,歸因於晶片面積或設計時間只是我的猜測。

正常數字的路徑到這裡走完,微碼的另一大塊在處理例外。8087 要處理無限大、零、NaN、非正規數(denormal)與 unnormal,共用碼裡連續有約十八個條件跳躍用來分派這些情況,作者形容這是充滿分支的「義大利麵」式程式碼,對特殊情況也只做了高層次的描述。其中 8 條路徑回傳 NaN,例如仿射模式下的 ∞ − ∞,以及 0 × ∞;另外 8 條回傳無限大,加減法的無限大符號取自那個無限大的運算數。

零的符號有一個容易出錯的規則。+0 加 +0 是 +0,−0 減 +0 是 −0;但兩個零互相抵銷時,例如 +0 與 −0 相加,文章寫的是:「rounding down yields ⁻0 while the other rounding modes yield ⁺0」。向下捨入得到 −0,其他捨入模式得到 +0。這和前面說的「減法抵銷成零」是同一回事:結果的符號要看當前的捨入模式,運算數的符號決定不了它。

結論部分,作者說 8087「led to the IEEE-754 floating-point standard used today」。我的解讀是,零、無限大與 NaN 的特殊規則因此延續到了標準裡。同時 8087 在特例上也「went a bit overboard」:projective infinity、unnormal 與 pseudo-zero 這幾項 8087 的功能被標準捨棄,作者給的理由是「weren't worth the effort」。

這份分析的邊界,與你可以拿去用的

讀這份分析時,有幾個邊界要記得。微碼是 Opcode Collective 這個團隊(原文提到 Smartest Blob 與 Gloriouscow)把 ROM 影像轉成微碼資料之後,由作者整理分析的,ROM 影像據說是以機器學習方式分類,所以我引用的位址與動作來自這次逆向,沒有廠商文件作後盾。op 的編碼是作者的假設。這篇文章專注在加減法,乘法、除法與其他指令的微碼我沒有讀到內容,這裡不做推論。

這篇文章有幾件事可以直接拿去用。先是一個十分鐘的練習。把上面的小工具設成 8 − 7.5,確認左移四位;再改成 8 − 7.9375,我自己算的結果是左移七位、指數從 3 減到 −4,先預測再用工具核對。接著換成 8 + 7.9375,會看到加法最多右移一位。這是我設計的練習,數字是我的算術,不是原文給的。

再來是閱讀順序。原文事實:8087 的微碼裡,減法路徑有專屬的前導零正規化區段(#0282 到 #0288),加法路徑在 #0270 查到進位時才於 #0271 右移,兩條路徑在 #0289 會合。我的推論:往後讀其他浮點硬體或軟體實作時,先找減法(以及異號相加)的正規化路徑,大概最快抓到實作的複雜度,原文沒有談其他實作,這只是把 8087 的結構當成找線索的起點。

最後是對照語意。原文事實:8087 的 projective infinity、unnormal 與 pseudo-zero 被捨棄了,原文也說 8087 催生了 IEEE-754。我的推論:拿 8087 的文件和現代浮點語意對讀時,這幾種型態會是兩邊的差異所在,值得先查。另外,「移位量取決於資料」這件事,8087 的取捨是兩個 8 位元偵測器換掉一個 64 位元偵測器,電路簡單、延遲變長;你自己設計類似的電路時可以拿來比較,不過原文只描述了 8087 的選擇,沒有說它在別處是否最佳。

回到最初那道題。8 − 7.5 要左移四位,因為相近的兩個浮點數相減,有效位數大量抵銷,抵銷掉幾位就得移回幾位。8087 把「量出抵銷了幾位」切成位元組與位元兩個階段,把「要走加法還是減法」壓成一個三輸入 XOR,再把兩條路徑分開寫在 ROM 裡。

Take-away:浮點加減法就是對齊、運算、正規化三步,加法最多把結果多移一位,減法卻可能抵銷出任意多個前導零;8087 用一個符號 XOR 決定走哪條路,再用兩個 8 位元偵測器分兩段把前導零數完。