過去兩講,我爲你講解了流水線設計CPU所須要的基本概念。接下來,咱們一塊兒來看看,要想經過流水線設計來提高CPU的吞吐率,咱們須要冒哪些風險。緩存
任何一本講解CPU的流水線設計的教科書,都會提到流水線設計須要解決的三大冒險,分別是 結構冒險(Structural Harzard)、 數據冒險(Data Harzard)以及 控制冒險(Control Harzard)。架構
這三大冒險的名字頗有意思,它們都叫做 harzard(冒險)。喜歡玩遊戲的話,你應該知道一個著名的遊戲,生化危機,英文名就叫Bioharzard。的確,harzard還有一個意思就是「危機」。
那爲何在流水線設計裏,harzard沒有翻譯成「危機」,而是要叫「冒險」呢?性能
在CPU的流水線設計裏,當然咱們會遇到各類「危險」狀況,使得流水線裏的下一條指令不能正常運行。可是,咱們其實仍是經過「搶跑」的方式,「冒險」拿到了一個提高指令吞吐率的機會。
流水線架構的CPU,是咱們主動進行的冒險選擇。咱們指望可以經過冒險帶來更高的回報,因此,這不是無奈之下的應對之舉,天然也算不上什麼危機了。spa
事實上,對於各類冒險可能形成的問題,咱們其實都準備好了應對的方案。這一講裏,咱們先從結構冒險和數據冒險提及,一塊兒來看看這些冒險及其對應的應對方案。翻譯
咱們先來看一看結構冒險。結構冒險,本質上是一個硬件層面的資源競爭問題,也就是一個硬件電路層面的問題。CPU在同一個時鐘週期,同時在運行兩條計算機指令的不一樣階段。可是這兩個不一樣的階段,可能會用到一樣的硬件電路。最典型的例子就是內存的數據訪問。請你看看下面這張示意圖,其實就是第20講裏對應的5級流水線的示意圖。設計
能夠看到,在第1條指令執行到訪存(MEM)階段的時候,流水線裏的第4條指令,在執行取指令(Fetch)的操做。訪存和取指令,都要進行內存數據的讀取。咱們的內存,
只有一個地址譯碼器的做爲地址輸入,那就只能在一個時鐘週期裏面讀取一條數據,沒辦法同時執行第1條指令的讀取內存數據和第4條指令的讀取指令代碼3d
相似的資源衝突,其實你在平常使用計算機的時候也會遇到。最多見的就是薄膜鍵盤的「鎖鍵」問題。經常使用的最廉價的薄膜鍵盤,並非每個按鍵的背後都有一根獨立的線路,
而是多個鍵共用一個線路。若是咱們在同一時間,按下兩個共用一個線路的按鍵,這兩個按鍵的信號就沒辦法都傳輸出去。blog
這也是爲何,重度鍵盤用戶,都要買貴一點兒的機械鍵盤或者電容鍵盤。由於這些鍵盤的每一個按鍵都有獨立的傳輸線路,能夠作到「全鍵無衝」,這樣,不管你是要大量寫文章、寫程序,
仍是打遊戲,都不會遇到按下了鍵卻沒生效的狀況。遊戲
「全鍵無衝」這樣的資源衝突解決方案,其實本質就是 增長資源。一樣的方案,咱們同樣能夠用在CPU的結構冒險裏面。對於訪問內存數據和取指令的衝突,一個直觀的解決方案就是把咱們的內存分紅兩部分,讓它們各有各的地址譯碼器。這兩部分分別是 存放指令的程序內存和存放數據內存ip
這樣把內存拆成兩部分的解決方案,在計算機體系結構裏叫做哈佛架構(Harvard Architecture),來自哈佛大學設計Mark I型計算機時候的設計。對應的,咱們以前說的馮·諾依曼體系結構,
又叫做普林斯頓架構(Princeton Architecture)。從這些名字裏,咱們能夠看到,早年的計算機體系結構的設計,其實產生於美國各個高校之間的競爭中。
不過,咱們今天使用的CPU,仍然是馮·諾依曼體系結構的,並無把內存拆成程序內存和數據內存這兩部分。由於若是那樣拆的話,對程序指令和數據須要的內存空間,
咱們就沒有辦法根據實際的應用去動態分配了。雖然解決了資源衝突的問題,可是也失去了靈活性。
不過,借鑑了哈佛結構的思路,現代的CPU雖然沒有在內存層面進行對應的拆分,卻在CPU內部的高速緩存部分進行了區分,把高速緩存分紅了 指令緩存(Instruction Cache)和 數據緩存(Data Cache)兩部分。
內存的訪問速度遠比CPU的速度要慢,因此現代的CPU並不會直接讀取主內存。它會從主內存把指令和數據加載到高速緩存中,這樣後續的訪問都是訪問高速緩存。而指令緩存和數據緩存的拆分,使得咱們的CPU在進行數據訪問和取指令的時候,不會再發生資源衝突的問題了。
結構冒險是一個硬件層面的問題,咱們能夠靠增長硬件資源的方式來解決。然而還有不少冒險問題,是程序邏輯層面的事兒。其中,最多見的就是數據冒險。
數據冒險,其實就是同時在執行的多個指令之間,有數據依賴的狀況。這些數據依賴,咱們能夠分紅三大類,分別是 先寫後讀(Read After Write,RAW)、 先讀後寫(Write After Read,WAR)和 寫後再寫(Write After Write,WAW)。下面,咱們分別看一下這幾種狀況。
咱們先來一塊兒看看先寫後讀這種狀況。這裏有一段簡單的C語言代碼編譯出來的彙編指令。這段代碼簡單地定義兩個變量 a 和 b,而後計算 a = a + 2。再根據計算出來的結果,計算 b = a + 3。
代碼
int main() { int a = 1; int b = 2; a = a + 2; b = a + 3; }
彙編
int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp int a = 1; 4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 int b = 2; b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 a = a + 2; 12: 83 45 fc 02 add DWORD PTR [rbp-0x4],0x2 b = a + 3; 16: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 19: 83 c0 03 add eax,0x3 1c: 89 45 f8 mov DWORD PTR [rbp-0x8],eax } 1f: 5d pop rbp 20: c3 ret
你能夠看到,在內存地址爲12的機器碼,咱們把0x2添加到 rbp-0x4 對應的內存地址裏面。而後,在緊接着的內存地址爲16的機器碼,咱們又要從rbp-0x4這個內存地址裏面,把數據寫入到eax這個寄存器裏面。
因此,咱們須要保證,在內存地址爲16的指令讀取rbp-0x4裏面的值以前,內存地址12的指令寫入到rbp-0x4的操做必須完成。這就是先寫後讀所面臨的數據依賴。若是這個順序保證不了,咱們的程序就會出錯。
這個先寫後讀的依賴關係,咱們通常被稱之爲 數據依賴,也就是Data Dependency。
咱們還會面臨的另一種狀況,先讀後寫。咱們小小地修改一下代碼,先計算 a = b + a,而後再計算 b = a+ b。
代碼
int main() { int a = 1; int b = 2; a = b + a; b = a + b; }
彙編
int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp int a = 1; 4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 int b = 2; b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2 a = b + a; 12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 15: 01 45 fc add DWORD PTR [rbp-0x4],eax b = a + b; 18: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 1b: 01 45 f8 add DWORD PTR [rbp-0x8],eax } 1e: 5d pop rbp 1f: c3 ret
咱們一樣看看對應生成的彙編代碼。在內存地址爲15的彙編指令裏,咱們要把 eax 寄存器裏面的值讀出來,再加到 rbp-0x4 的內存地址裏。接着在內存地址爲18的彙編指令裏,
咱們要再寫入更新 eax 寄存器裏面。
若是咱們在內存地址18的eax的寫入先完成了,在內存地址爲15的代碼裏面取出 eax 才發生,咱們的程序計算就會出錯。這裏,咱們一樣要保障對於eax的先讀後寫的操做順序。
這個先讀後寫的依賴,通常被叫做 反依賴,也就是Anti-Dependency。
咱們再次小小地改寫上面的代碼。此次,咱們先設置變量 a = 1,而後再設置變量 a = 2。
代碼
int main() { int a = 1; a = 2; }
彙編
int main() { 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp int a = 1; 4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1 a = 2; b: c7 45 fc 02 00 00 00 mov DWORD PTR [rbp-0x4],0x2 }
在這個狀況下,你會看到,內存地址4所在的指令和內存地址b所在的指令,都是將對應的數據寫入到 rbp-0x4 的內存地址裏面。若是內存地址b的指令在內存地址4的指令以後寫入。
那麼這些指令完成以後,rbp-0x4 裏的數據就是錯誤的。這就會致使後續須要使用這個內存地址裏的數據指令,沒有辦法拿到正確的值。
因此,咱們也須要保障內存地址4的指令的寫入,在內存地址b的指令的寫入以前完成。
這個寫後再寫的依賴,通常被叫做 輸出依賴,也就是Output Dependency。
除了讀以後再進行讀,你會發現,對於同一個寄存器或者內存地址的操做,都有明確強制的順序要求。而這個順序操做的要求,也爲咱們使用流水線帶來了很大的挑戰。
由於流水線架構的核心,就是在前一個指令尚未結束的時候,後面的指令就要開始執行。
因此,咱們須要有解決這些數據冒險的辦法。其中最簡單的一個辦法,不過也是最笨的一個辦法,就是流水線停頓(Pipeline Stall),或者叫流水線冒泡(Pipeline Bubbling)。
流水線停頓的辦法很容易理解。若是咱們發現了後面執行的指令,會對前面執行的指令有數據層面的依賴關係,那最簡單的辦法就是「 再等等」。咱們在進行指令譯碼的時候,
會拿到對應指令所須要訪問的寄存器和內存地址。因此,在這個時候,咱們可以判斷出來,這個指令是否會觸發數據冒險。若是會觸發數據冒險,
咱們就能夠決定,讓整個流水線停頓一個或者多個週期。
我在前面說過,時鐘信號會不停地在0和1以前自動切換。其實,咱們並無辦法真的停頓下來。流水線的每個操做步驟必需要乾點兒事情。因此,在實踐過程當中,
咱們並非讓流水線停下來,而是在執行後面的操做步驟前面,插入一個NOP操做,也就是執行一個其實什麼都不幹的操做。
這個插入的指令,就好像一個水管(Pipeline)裏面,進了一個空的氣泡。在水流通過的時候,沒有傳送水到下一個步驟,而是給了一個什麼都沒有的空氣泡。這也是爲何,咱們的流水線停頓,
又被叫做流水線冒泡(Pipeline Bubble)的緣由。
講到這裏,相信你已經弄明白了什麼是結構冒險,什麼是數據冒險,以及數據冒險所要保障的三種依賴,也就是數據依賴、反依賴以及輸出依賴。
一方面,咱們能夠經過增長資源來解決結構冒險問題。咱們現代的CPU的體系結構,其實也是在馮·諾依曼體系結構下,借鑑哈佛結構的一個混合結構的解決方案。
咱們的內存雖然沒有按照功能拆分,可是在高速緩存層面進行了拆分,也就是拆分紅指令緩存和數據緩存這樣的方式,從硬件層面,使得同一個時鐘下對於相同資源的競爭再也不發生。
另外一方面,咱們也能夠經過「等待」,也就是插入無效的NOP操做的方式,來解決冒險問題。這就是所謂的流水線停頓。不過,流水線停頓這樣的解決方案,是以犧牲CPU性能爲代價的。
由於,實際上在最差的狀況下,咱們的流水線架構的CPU,又會退化成單指令週期的CPU了。
因此,下一講,咱們進一步看看,其餘更高級的解決數據冒險的方案,以及控制冒險的解決方案,也就是操做數前推、亂序執行和還有分支預測技術。