流水與精確異常
我們為什么需要精確異常?
- 馮諾依曼結構ISA的語義
- 幫助軟件調試
- 使得異常恢復和進程重啟更容易
- 可以在軟件中加入trap
多周期與流水線
因為並不是所有指令的執行時間都是一樣長的,能不能在流水線中實現多周期的思想,即用多個不同的功能單元花費不同數量的時鍾周期。而在這些功能單元中指令的執行可以流水也可以不流水,這樣就必須保證指令序以及確定精確異常,因為在某個階段發現異常時,其之后的指令已經開始執行了,那么就需要將微體系結構的狀態還原到異常發生之前的那個狀態。
上圖展示了我們之前的思路,在傳統的單(指令執行)周期流水線中,當FMUL在EX階段時,ADD不能處於EX階段,進一步,ADD和FMUL的EX階段的時鍾周期必須一致,但是實際上ADD的EX階段並不需要那么長的時間,這樣就導致指令處理時間較長。那么如果進行多周期執行,當FMUL和ADD不存在相關時,ADD可以使用EX階段不同的功能單元,從而不需要等待FMUL指令EX階段的完成。
看起來不錯,但是如果第一個FMUL在WB階段出現異常了呢?這樣除了需要處理異常外,我們必須消除后續已執行的指令所帶來的對體系結構狀態的影響,否則指令的序就出現了問題,這個系統就不再是一個馮諾依曼結構的系統。
異常與中斷
必須指出的是,在不同的語境中,異常與中斷之間會發生混用。從以下幾個方面區分:
起因:
- 異常:系統內部產生,作用於運行的線程。
- 中斷:系統外部產生,例如外部的IO操作,作用於運行的線程。也可以理解為外部產生的異常。
處理的時機:
- 異常:一旦檢測出立即處理,否則很可能會導致線程運算錯誤。
- 中斷:方便的時候,並不需要馬上響應(除非中斷的優先級非常高,藍屏等)。
處理的上下文:
- 異常:進程參與處理。
- 中斷:系統參與處理。
異常的精確與非精確:
- 精確異常:這類異常可能不會導致進程終止,所以為了保證指令序,需要記錄並返回未發生異常之前的體系結構狀態。
- 非精確異常:這類異常會導致進程終止,所以並不需要返回到之前的體系結構狀態。
精確異常
當准備處理異常或中斷時,體系結構狀態必須是一致的,所以這就需要:
- 所有之前的指令必須回收(回收:指令執行完畢並更新體系結構狀態)。
- 之后的指令一律不得回收,即不可以更新體系結構狀態。
一點個人的理解是,精確異常屬於為了指令吞吐(流水)做出的改進所帶來的額外的問題,並不屬於進程本身產生的異常。所以應該盡可能地減少其影響。
流水中的精確異常
思路零
使每個操作花相同的時間。
這樣雖然保證了精確異常,但這也是我們想要避免的情況。
重排序緩沖
思路:亂序執行指令,產生體系結構狀態可見的結果之前進行重排序。
基本實現:主要借助於ROB(ReOrder Buffer)來實現。在指令譯碼階段在ROB中預留一個entry,在指令執行完成(產生結果但不更新體系結構狀態)時,根據指令entry將結果寫入ROB中相應的位置,當指令成為ROB中最舊的指令,已經產生結果且不出現異常時,將結果輸出到寄存器堆或內存,完成體系結構狀態的更新。可見ROB可以通過隊列數據結構實現。必須指出的是,具體到實現階段是十分復雜的。
下面是一個ROB entry的表項:
- V:有效位,表示結果是否准備好
- DestRegID:寫回寄存器ID
- DestRegVal:寫回寄存器的值
- StoreAddr:內存的修改地址
- StoreData:內存的修改值
- PC:指令寄存器,可以標識指令的新舊,也可以通過PC得到指令,進而得到指令類型等信息
- Valid bits:表示指令結果是否是有效的
- Exc:是否出現異常
應用了重排序之后的指令執行過程如下圖所示:
其中R表示寫入ROB中。
但是現在就出現了這樣一個問題,就是如何處理數據相關?因為指令產生的結果並沒有寫回寄存器,而是存在ROB中,那么如果這時另一條指令需要讀這個寫回寄存器的值時就會出現問題,因為從程序語義上來說,要讀的值在ROB中,當前寄存器堆中的值是一個無效值,因為從順序上來說他已經被覆蓋了,這就會出現錯誤。
那么如何解決這類問題?可以考慮數據轉發,而數據轉發涉及到很多的細節問題,因為需要訪問ROB來獲取尚未寫回的結果,兩種典型的方式一是同時訪問ROB和寄存器堆,這需要用內容尋址寄存器來實現ROB;二是間接尋址,這種方式需要在寄存器堆中存儲ROB entry的tag。這也就解釋了為什么讀后寫和寫后寫不是真正的相關,因為使用ROB相當於對寄存器進行了重命名,因為不同的值存在ROB中不同的entry里,相當於擴充了寄存器堆的容量。
那么關於寄存器重命名,上述辦法並不是唯一的方式,還有比如從體系結構寄存器(匯編代碼中的寄存器)ID轉換為物理寄存器(微體系結構中真實存在的硬件寄存器)ID等等。
ROB的tradeoff:
- 好處:簡單直接;ROB的重命名可以消除虛假相關。
- 壞處:需要訪問ROB來獲取尚未寫回的結果,增加了復雜性。
歷史緩沖(History Buffer)
思路:指令執行完后直接更新寄存器堆,但是當出現異常時撤銷那些更新。
基本實現:當指令譯碼時,預留一個HB條目,當指令執行完畢時,將目標地址中的舊值留在HB中,當指令是HB中最舊的一條且未發生異常時,丟棄該HB條目,當指令是HB最舊的一條並且出現異常,那么將HB中的舊值依次寫回體系結構狀態。
好處:寄存器堆中存在最新的值,而HB的訪問不在關鍵路徑上。
壞處:需要讀目的寄存器的舊值;在異常時需要回滾HB,增加了異常的處理時延。
未來寄存器堆(FF)+ROB
思路:維護兩個寄存器堆(投機的和體系結構的),指令執行完畢后立即更新投機寄存器堆;使用ROB維護一個體系結構的寄存器堆,投機的寄存器堆可以保證指令訪問最新的數據。
投機寄存器堆也叫前端寄存器堆,體系結構的寄存器堆也叫后端寄存器堆。
好處:不用從ROB中讀取值
壞處:使用了多個寄存器堆,出現異常時,需要從堆向堆中復制數據,這樣會帶來更大的異常處理時延。
檢查點
分支預測錯誤相當於一個“異常”,那么在恢復時並不需要分支指令成為最舊的指令。 因為分支預測錯誤相比於真正的異常來說是很常見的,所以我們希望分支預測錯誤的處理速度要盡可能快,思路是當分支取指時對前端寄存器狀態設立檢查點,保存現場,並且對比分支指令舊的指令產生的狀態保持更新。這樣可以使分支后正確的下一條指令能夠在分支預測錯誤被解決后立即執行。
在分支譯碼時,復制未來寄存器堆並關聯到分支,當指令產生寄存器值更新(FF)的時候,所有晚於該指令的未來寄存器堆的檢查點更新狀態;當分支預測錯誤被檢測出來,當錯誤被解決時,恢復被錯誤預測的分支的未來寄存器堆ckpt(checkpoint),清空所有晚於分支的指令,釋放所有晚於分支的檢查點。
上面的句子不像人話,那么我們畫圖拆解一下:
好處:響應快速。
壞處:保存ckpt帶來了大量的額外開銷。
存儲器的精確異常
我們之前所說的都是關於寄存器的,那么怎么處理存儲器的異常呢?這也是一個十分復雜的問題,這里我們只討論簡單的思路,如果想要撤銷store,我們可以在ROB中添加字段記錄store的地址,當store指令是最舊的指令時才寫回存儲器,但是這也帶來了轉發的困難,因為這時load指令也需要讀ROB。
那么我們如果把ROB中那些store指令的entry單獨拿出來,就組成了一個非常重要的部件,叫store buffer。我們將store指令按序放到store buffer中,當store指令譯碼時,在store buffer中分配一個條目,當store指令地址和數據都可用時,將地址和數據寫到store buffer的entry中,當store指令是最舊的指令時,更新存儲器地址(cache)和存儲的數據。
我們為什么需要store buffer?因為如果使用ROB且最舊的指令是store指令時,如果當前的store指令無法提交結果,或者提交時間很長(比如store的數據不在cache中,這時就需要訪存),這時候全部流水線就會停下來等,我們想盡可能減少這種停頓的情況,所以使用store buffer作為二級緩沖,一條store指令寫到store buffer中就算是完成,而如果出現store buffer無法提交的情況,流水線中不存在相關的指令還是可以繼續進行。
當然,上述想法都是較粗略的,涉及存儲器的精確異常一定還存在着很多更細致的問題,作者水平有限,拾人牙慧,權當作拋磚引玉。