探秘X86架構CPU流水線
第一個改變是指令從內(nèi)存中取到處理器的指令緩存的過程?,F(xiàn)代處理器能夠檢測何時會產(chǎn)生一個大的分支跳轉(zhuǎn)(比如函數(shù)調(diào)用),然后提前將跳轉(zhuǎn)目的地的指令加載到指令緩存中。
譯碼級有一些略微的修改。不同于以往處理器僅僅譯碼指令指針指向的指令,奔騰 Pro 處理器每一個時鐘周期最多能譯碼 3 條指令?,F(xiàn)今的處理器(2008-2013 年)每個時鐘周期最多可以譯碼 4 條指令。譯碼過程產(chǎn)生很多小片的操作,被稱作微指令(micro-ops, ?-ops)。
下一級(或者好幾級)被稱為微指令翻譯,接著是寄存器重命名(register aliasing)。許多操作同時執(zhí)行,并且執(zhí)行的順序是亂序的,所以有可能出現(xiàn)一條指令讀一個寄存器的同時,另外一條指令正在對這個寄存器進行寫操作。在處理器內(nèi)部,這些原始的寄存器(如 AX,BX,CX,DX 等)被翻譯(或者重命名)成為內(nèi)部的寄存器,而這些寄存器對程序員是不可見的。寄存器和內(nèi)存地址需要被映射到一個臨時的地方用于指令執(zhí)行。當前每個始終周期可以翻譯 4 條微指令。
當微指令翻譯完成后,它們會進入一個重排序緩存(Reorder Buffer, ROB),ROB 可以存儲最多 128 條微指令。在支持超線程的處理器上,ROB 同樣可以重排來自兩個虛擬處理器的指令。兩個虛擬處理器在 ROB 中將微指令匯集到一個共享的亂序執(zhí)行部件中。
這些微指令已經(jīng)準備好可以執(zhí)行了。它們被放在保留站中(Reservation Station, RS)。RS 最多可以同時存儲 36 條微指令。
現(xiàn)在才開始亂序執(zhí)行部件神奇的部分。不同的微指令在不同的執(zhí)行單元中同時執(zhí)行,而且每個執(zhí)行單元都全速運行。只要當前微指令所需要的數(shù)據(jù)就緒,而且有空閑的執(zhí)行單元,微指令就可以立即執(zhí)行,有時甚至可以跳過前面還未就緒的微指令。通過這種方式,需要長時間運行的操作不會阻塞后面的操作,流水線阻塞帶來的損失被極大的減小了。
奔騰 Pro 的亂序執(zhí)行部件擁有 6 個執(zhí)行單元:兩個定點處理單元,一個浮點處理單元,一個取數(shù)單元,一個存地址單元,一個存數(shù)單元。這兩個定點處理單元有所不同,一個能夠處理復雜定點操作,一個能同時處理兩個簡單操作。在理想狀況下,奔騰 Pro 的亂序執(zhí)行部件可以在一個時鐘周期內(nèi)執(zhí)行 7 條微指令。
現(xiàn)今的亂序執(zhí)行部件仍然擁有 6 個執(zhí)行單元。其中取數(shù)單元,存地址單元,存數(shù)單元沒有變,另外 3 個多少發(fā)生了變化。這三個執(zhí)行單元都可以執(zhí)行基本算術運算,或者執(zhí)行更復雜的微指令。但每個執(zhí)行單元擅長執(zhí)行不同種類的微指令,使得它們能更高效的執(zhí)行運算。在理想狀況下,現(xiàn)今的亂序執(zhí)行部件可以在一個時鐘周期內(nèi)執(zhí)行 11 條微指令。
最終微指令會得到執(zhí)行,在經(jīng)過數(shù)個流水級之后,最終會退出流水線。這時,這條指令完成并且遞增指令指針。但從程序員的角度來說,指令僅僅是從一端進入 CPU,從另一端退出,就像老的 8086 一樣。
如果你仔細看過上面的內(nèi)容,你會注意到上面提到過很重要的一個問題:如果執(zhí)行指令的位置發(fā)生了跳轉(zhuǎn)會發(fā)生什么?例如,當指令運行到“if”或者是“switch”時,會發(fā)生什么呢?在較老的處理器中這意味著清空流水線,等待新的跳轉(zhuǎn)目的指令的取指執(zhí)行。
當 CPU 指令隊列中存儲了超過 100 條指令時,發(fā)生流水線阻塞帶來的性能損失是極其嚴重的。所有的指令都需要等待跳轉(zhuǎn)目的的指令取回并且重啟流水線。在這種情況下,亂序執(zhí)行部件需要將跳轉(zhuǎn)指令之后但是已經(jīng)執(zhí)行的微指令全部取消掉,返回到執(zhí)行前的狀態(tài)。當所有亂序執(zhí)行的微指令都退出亂序執(zhí)行部件之后,將它們丟棄掉,然后從新的地址開始執(zhí)行。這對于處理器來說是相當困難的,而且發(fā)生的頻率很高,因此對性能的影響很大。這時,引入了亂序執(zhí)行部件的另外一個重要功能。
答案就是猜測執(zhí)行。猜測執(zhí)行意味著當遇到一個分支指令后,亂序執(zhí)行部件會將所有分支的指令都執(zhí)行一遍。一旦分支指令的跳轉(zhuǎn)方向確定后,錯誤跳轉(zhuǎn)方向的指令都將被丟棄。通過同時執(zhí)行兩個跳轉(zhuǎn)方向的指令,避免了由于分支跳轉(zhuǎn)導致的阻塞。處理器設計者還發(fā)明了分支預測緩存,當面臨多個分支時進行預測,進一步提高了性能。雖然 CPU 阻塞仍然會發(fā)生,但是這個解決方案將 CPU 發(fā)生阻塞的概率降到了一個可以接受的范圍。
最后,擁有超線程的處理器將兩個虛擬的處理器暴露給共享的亂序執(zhí)行部件。它們共享一個重排序緩存和亂序執(zhí)行部件,讓操作系統(tǒng)認為它們是兩個獨立的處理器,看上去就像這樣:
超線程的處理器擁有兩個虛擬的處理器,從而可以給亂序執(zhí)行部件提供更多的數(shù)據(jù)。超線程對一般的應用程序都有性能提升,但是對一些計算密集型的應用,則會迅速使得亂序執(zhí)行部件飽和。在這種情況下,超線程反而會略微降低性能。但這種情況畢竟是少數(shù),超線程對于日常應用來講通常都能夠提供大約一倍的性能。
一個示例
這一切看上去有點令人感到困惑,那么我們舉一個例子來讓這一切變得清晰起來。
從應用程序的角度來看,我們?nèi)匀皇沁\行在指令流水線上,就想老的 8086 處理器那樣。處理器就是一個黑盒子。黑盒子會處理指令指針指向的指令,當處理完之后,會在內(nèi)存里找到處理的結(jié)果。
但是從指令本身的角度來講,這個過程可謂歷經(jīng)滄桑。我們下面介紹對于現(xiàn)今的處理器(大約在 2008-2013 年之間),一條指令在其內(nèi)部的過程。
首先,你是一條指令,你所屬的程序正在運行。
你一直在耐心的等待指令指針會指向自己,等待被 CPU 運行。當指令指針距離你還有 4KB 遠的時候(這大約是 1500 條指令),你被 CPU 從內(nèi)存取到指令緩存中。雖然從內(nèi)存加載進入指令緩存需要一段時間,但是現(xiàn)在距離你被執(zhí)行的時刻還很遠,你有足夠的時間。這個預取的過程屬于流水線的第一級。
當指令指針離你越來越近,距離你還有 24 條指令的時候,你和你旁邊的 5 個指令會被放到指令隊列里面。
這個處理器有 4 個譯碼器,可以容納一個復雜指令和最多三個簡單指令。你碰巧是一條復雜指令,通過譯碼,你被翻譯成 4 個微指令。
譯碼的過程可以劃分為多步。譯碼過程中的一步是檢查你需要的數(shù)據(jù)和猜測你可能會產(chǎn)生一個地址跳轉(zhuǎn)。譯碼器一旦檢測到需要的額外數(shù)據(jù),不需要讓你知道,這個數(shù)據(jù)就開始從內(nèi)存加載到數(shù)據(jù)緩存中了。
你的四個微指令到達寄存器重命名表。你告訴它你需要讀哪個內(nèi)存地址(比如說 fs:[eax+18h]),然后寄存器重命名表將這個地址轉(zhuǎn)換為臨時地址供微指令使用。地址轉(zhuǎn)化完成后,你的微指令將進入重排序緩存(Reorder Buffer, ROB)并記錄指令次序。接著第一時間進入保留站(Reservation Station, RS)。
保留站用于存儲已經(jīng)準備就緒可以執(zhí)行的指令。你的第三條微指令被立即選中并送往端口5,這個端口直接執(zhí)行運算。但是你并不知道為什么它會被首先選中,無論如何,它確實被執(zhí)行了。幾個時鐘周期之后你的第一條微指令前往端口2,該端口是讀單元(Load Address地址 execution unit)。剩余的微指令一直等待,同時各個端口正在收集不同的微指令。他們都在等待端口 2 將數(shù)據(jù)從緩存和內(nèi)存中加載進來并放在臨時存儲空間內(nèi)。
他們等了很久……
相當久的時間……
不過在他們等待第一條微指令返回數(shù)據(jù)的時候,又有其他的新指令又進來。好在處理器知道如何讓這些指令亂序執(zhí)行(即后到達保留站的微指令被優(yōu)先執(zhí)行)。
當?shù)谝粭l微指令返回了數(shù)據(jù),剩余的兩條微指令被立即送往執(zhí)行端口 0 和1.現(xiàn)在這 4 條微指令都已經(jīng)運行,最終它們會返回保留站。
這些微指令返回后交出他們的“票”并給出各自的臨時地址。通過這些地址,你作為一個完整的指令,將他們合并。最后 CPU 將結(jié)果交給你并使你退出
當你到達標有“退出”的門的時候,你會發(fā)現(xiàn)這里要排一個隊列。你進入后發(fā)現(xiàn)你剛好站在你前面進來指令的后面,即使執(zhí)行中的順序可能已經(jīng)不同,但你們退出的順序繼續(xù)保持一致。看來亂序執(zhí)行部件真正知道自己做了什么。
每條指令最終離開 CPU,每次一條指令,就和指令指針指向的順序一樣!
結(jié)論
希望這篇小文能夠給讀者展示一些處理器工作的奧秘,要知道,這并不是魔術。
讓我們回到最初的問題,現(xiàn)在我們應該可以給出一些較好的答案了。
處理器內(nèi)部是如何工作的呢?在這個復雜的過程中,指令首先被分解為更小的微指令命令,這些微指令以亂序的方式盡可能快的被執(zhí)行,然后按照原始的順序提交執(zhí)行結(jié)果。因此,從外部看來,所有的指令都是按照順序的方式執(zhí)行的。但是現(xiàn)在我們知道,處理器內(nèi)部是以亂序的方式處理指令的,有時甚至以猜測的方式來運行分支代碼。
運行一條指令究竟需要多長時間呢?對于沒有使用流水線技術的處理器來說,這是一個容易回答的問題,但對于現(xiàn)代的處理器來說,一條指令的執(zhí)行時間與它周圍指令的內(nèi)容以及臨近 cache 的大小和內(nèi)容都有關。一條指令通過處理器有一個最小的時間,但只能粗略的說這個時間是恒定的。一個好的程序員和編譯器可以讓很多條指令同時運行,從而使每條指令的分攤時間幾乎為零。這里說的幾乎為零的執(zhí)行時間并不是指一條指令的總的執(zhí)行時間很短,相反,通過整個亂序部件和等待內(nèi)存讀寫數(shù)據(jù)是需要花費很多時間的。
一個新的處理器擁有 12 級或者 18 級、甚至更深的 31 級流水線意味著什么呢?這意味著更多的指令可以被同時送進加工廠。一個非常深的流水線可以讓幾百條指令同時被處理。當一切順利時,一個亂序部件可以保持高速運轉(zhuǎn),從而獲得驚人的吞吐量。不幸的是,深的流水線同時意味著流水線停頓會從一個相對可以容忍的性能損失變成一個可怕的性能噩夢。因為幾百條指令都不得不停頓下來,等待流水線恢復運轉(zhuǎn)。
我怎么根據(jù)這些信息來優(yōu)化程序呢?幸運的是,CPU 可以在大部分常見情況下工作良好,并且編譯器已經(jīng)為亂序處理器優(yōu)化了近 20 年。當指令和數(shù)據(jù)按照順序(沒有煩人的跳轉(zhuǎn))執(zhí)行時,CPU 可以獲得最好的性能。因此,首先,使用簡單的代碼。簡單直接的代碼會幫助編譯器的優(yōu)化引擎識別并優(yōu)化代碼。盡量不使用跳轉(zhuǎn)指令,當你不得不跳轉(zhuǎn)時,盡量每次跳轉(zhuǎn)到同樣的方向。復雜的設計,例如動態(tài)跳轉(zhuǎn)表,雖然看起來很酷并且的確可以完成非常強大的功能,但不管是處理器還是編譯器,都無法進行很好的預測處理,因此復雜的代碼很可能導致流水線停頓和猜測錯誤,從而極大的損害處理器性能。其次,使用簡單的數(shù)據(jù)結(jié)構。保持數(shù)據(jù)順序、相鄰和連續(xù)可以阻止數(shù)據(jù)停頓。使用正確的數(shù)據(jù)結(jié)構和數(shù)據(jù)分布可以獲得很大的性能提升。只要保持代碼和數(shù)據(jù)結(jié)構盡量簡單,剩下的工作就可以放心地交給編譯器的優(yōu)化引擎來完成了。
評論