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 符號 | op | XOR | 運算 | 實際在算 |
|---|---|---|---|---|---|
| 0(正) | 0(正) | 0 | 0 | 加 | A + B |
| 0(正) | 0(正) | 1 | 1 | 減 | A - B |
| 0(正) | 1(負) | 0 | 1 | 減 | A + (-|B|) |
| 0(正) | 1(負) | 1 | 0 | 加 | A - (-|B|) |
| 1(負) | 0(正) | 0 | 1 | 減 | (-|A|) + B |
| 1(負) | 0(正) | 1 | 0 | 加 | (-|A|) - B |
| 1(負) | 1(負) | 0 | 0 | 加 | (-|A|) + (-|B|) |
| 1(負) | 1(負) | 1 | 1 | 減 | (-|A|) - (-|B|) |
八種組合裡剛好一半會走減法路徑。這也解釋了一件事:8087 的加法與減法指令共用同一段微碼,指令是加還是減,只是 XOR 公式的其中一個輸入。程式看到的是兩種指令,微碼看到的只有「無號加」與「無號減」兩種工作。
把三步和符號規則放在一起,最好的辦法是自己輸入兩個數走一遍。下面的小工具把前面三步實作出來。它不是 8087 的模擬器:有效位數只有 13 位元(真正的 8087 是 64 位元),也沒有模擬 guard、round、sticky 三個低位元,移出去的位元直接捨掉。它走的是原文描述的那條流程:算 XOR 決定無號運算,比較指數並交換,把較小的數右移對齊,加或減,最後正規化並決定符號。預設是第二個例子 8 − 7.5,按鈕可以切到第一個例子,也可以試試兩數相減剛好抵銷、或被減數比較小的情況。
輸入兩個數、選加或減,逐步看對齊、運算、正規化 · 13 位元示意版
每個位元欄位的格式是「進位位 最高位.小數位」
加法最多把結果右移一位就完成正規化,減法兩數相近時會留下一長串前導零,得左移同樣位數並把指數減掉,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 開始。下面這張圖,是依原文列出的位址範圍整理的執行路線。
這張圖最值得看的,是中間分叉之後兩條路的長度。左邊的減法路徑佔了 #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 個區塊
各區塊的位元數與功能取自文章
加減法在資料路徑上用到 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 個前導零,是兩個位元組加三個位元。把滑桿拖到 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 位元偵測器分兩段把前導零數完。