計算機組成原理-處理器

計算機組成原理-處理器

  本文根據徐文浩老師的計算機組成原理記錄:計算機組成原理

  CSDN base64 圖片顯示有問題, 想要個人筆記的可以私我

1 建立數據通路

1.1 週期

  1. Fetch(取得指令),也就是從 PC 寄存器裏找到對應的指令地址,根據指令地址從內存裏把具體的指令,加載到指令寄存器中,然後把 PC 寄存器自增,好在未來執行下一條指令
  2. Decode(指令譯碼),也就是根據指令寄存器裏面的指令,解析成要進行什麼樣的操作,是 R、I、J 中的哪一種指令,具體要操作哪些寄存器、數據或者內存地址
  3. Execute(執行指令),也就是實際運行對應的 R、I、J 這些特定的指令,進行算術邏輯操作、數據傳輸或者直接的地址跳轉
  4. 重複進行 1~3 的步驟。這樣的步驟,其實就是一個永不停歇的“Fetch - Decode - Execute”的循環,這個循環稱之爲指令週期(Instruction Cycle)

![指令週期][指令週期]

  指令是放在存儲器裏的,實際上,通過 PC 寄存器和指令寄存器取出指令的過程,是由控制器(Control Unit)操作的。指令的解碼過程,也是由控制器進行的。一旦到了執行指令階段,無論是進行算術操作、邏輯操作的 R 型指令,還是進行數據傳輸、條件分支的 I 型指令,都是由算術邏輯單元(ALU)操作的,也就是由運算器處理的。不過,如果是一個簡單的無條件地址跳轉,那麼我們可以直接在控制器裏面完成,不需要用到運算器。

![獲取指令][獲取指令]

  Machine Cycle,機器週期或者CPU 週期。CPU 內部的操作速度很快,但是訪問內存的速度卻要慢很多。每一條指令都需要從內存裏面加載而來,所以一般把從內存裏面讀取一條指令的最短時間,稱爲 CPU 週期。

![週期關係][週期關係]

1.2 數據通路

  一般來說,可以認爲,數據通路就是我們的處理器單元。它通常由兩類原件組成。

  第一類叫操作元件,也叫組合邏輯元件(Combinational Element),其實就是 ALU。它們的功能就是在特定的輸入下,根據下面的組合電路的邏輯,生成特定的輸出。

  第二類叫存儲元件,也有叫狀態元件(State Element)的。比如我們在計算過程中需要用到的寄存器,無論是通用寄存器還是狀態寄存器,其實都是存儲元件。

1.3 控制器

  控制器的邏輯很簡單。只是機械地重複“Fetch - Decode - Execute“循環中的前兩個步驟,然後把最後一個步驟,通過控制器產生的控制信號,交給 ALU 去處理。

  所有 CPU 支持的指令,都會在控制器裏面,被解析成不同的輸出信號。現在的 Intel CPU 支持 2000 個以上的指令。這意味着,控制器輸出的控制信號,至少有 2000 種不同的組合。控制器“翻譯”出來的,就是不同的控制信號。這些控制信號,告訴 ALU 去做不同的計算。

1.4 CPU 的硬件實現

  1. ALU: 一個沒有狀態的,根據輸入計算輸出結果的第一個電路
  2. 寄存器: 一個能夠進行狀態讀寫的電路元件, 能夠存儲到上一次的計算結果。這個計算結果並不一定要立刻拿到電路的下游去使用,但是可以在需要的時候拿出來用。常見的能夠進行狀態讀寫的電路,就有鎖存器(Latch)以及 D 觸發器(Data/Delay Flip-flop)的電路
  3. 一個"自動"的電路, 按照固定的週期,不停地實現 PC 寄存器自增,自動地去執行“Fetch - Decode - Execute“的步驟

  在最簡單的情況下,要讓每一條指令,從程序計數,到獲取指令、執行指令,都在一個時鐘週期內完成。如果 PC 寄存器自增地太快,程序就會出錯。因爲前一次的運算結果還沒有寫回到對應的寄存器裏面的時候,後面一條指令已經開始讀取裏面的數據來做下一次計算了。這個時候,指令使用同樣的寄存器,前一條指令的計算就會沒有效果,計算結果就錯了。在這種設計下,要在一個時鐘週期裏,確保執行完一條最複雜的 CPU 指令,也就是耗時最長的一條 CPU 指令。這樣的 CPU 設計,稱之爲單指令週期處理器(Single Cycle Processor)。

  1. 譯碼器: 一個"譯碼"的電路, 於指令進行 decode,還是對於拿到的內存地址去獲取對應的數據或者指令,我們都需要通過一個電路找到對應的數據

1.5 實現指令執行和算術邏輯計算的 CPU

![CPU實現的抽象邏輯圖][CPU實現的抽象邏輯圖]

  1. 首先,需要一個自動計數器。這個自動計數器會隨着時鐘主頻不斷地自增,來作爲我們的 PC 寄存器
  2. 在這個自動計數器的後面,連上一個譯碼器。譯碼器還要同時連着通過大量的 D 觸發器組成的內存
  3. 自動計數器會隨着時鐘主頻不斷自增,從譯碼器當中,找到對應的計數器所表示的內存地址,然後讀取出裏面的 CPU 指令
  4. 讀取出來的 CPU 指令會通過我們的 CPU 時鐘的控制,寫入到一個由 D 觸發器組成的寄存器,也就是指令寄存器當中
  5. 在指令寄存器後面,可以再跟一個譯碼器。這個譯碼器不再是用來尋址的了,而是把拿到的指令,解析成 opcode 和對應的操作數
  6. 當拿到對應的 opcode 和操作數,對應的輸出線路就要連接 ALU,開始進行各種算術和邏輯運算。對應的計算結果,則會再寫回到 D 觸發器組成的寄存器或者內存當中

1.6 CPU 空閒狀態

  CPU在空閒狀態就會停止執行,具體來說就是切斷時鐘信號,CPU的主頻就會瞬間降低爲0,功耗也會瞬間降低爲0。由於這個空閒狀態是十分短暫的,所以在任務管理器裏面也只會看到CPU頻率下降,不會看到降低爲0。當CPU從空閒狀態中恢復時,就會接通時鐘信號,這樣CPU頻率就會上升。所以會在任務管理器裏面看到CPU的頻率起伏變化。

2 邏輯電路

  組合邏輯電路(Combinational Logic Circuit):只需要給定輸入,就能得到固定的輸出

  時序邏輯電路(Sequential Logic Circuit):

  1. 自動運行, 時序電路接通之後可以不停地開啓和關閉開關,進入一個自動運行的狀態
  2. 存儲: 通過時序電路實現的觸發器,能把計算結果存儲在特定的電路里面,而不是像組合邏輯電路那樣,一旦輸入有任何改變,對應的輸出也會改變
  3. 時序協調: 無論是程序實現的軟件指令,還是到硬件層面,各種指令的操作都有先後的順序要求。時序電路使得不同的事件按照時間順序發生

2.1 時鐘信號的硬件實現

![時鐘][時鐘]

  開關 A,一開始是斷開的,手工控制;

  合上開關 A,磁性線圈就會通電,產生磁性,開關 B 就會從合上變成斷開。一旦這個開關斷開了,電路就中斷了,磁性線圈就失去了磁性。於是,開關 B 又會彈回到合上的狀態。

  一個 D 型觸發器,只能控制 1 個比特的讀寫,同時拿出多個 D 型觸發器並列在一起,並且把用同一個 CLK 信號控制作爲所有 D 型觸發器的開關,這就變成了一個 N 位的 D 型觸發器,也就可以同時控制 N 位的讀寫。

  CPU 裏面的寄存器可以直接通過 D 型觸發器來構造。我們可以在 D 型觸發器的基礎上,加上更多的開關,來實現清 0 或者全部置爲 1 這樣的快捷操作。

3 現代處理器

3.1 舊時代的處理器: 單指令週期處理器

  不同指令的執行時間不同,但是需要讓所有指令都在一個時鐘週期內完成,那就只好把時鐘週期和執行時間最長的那個指令設成一樣。這就好比學校體育課 1000 米考試,要給這場考試預留的時間,肯定得和跑得最慢的那個同學一樣。因爲就算其他同學先跑完,也要等最慢的同學跑完間,才能進行下一項活動。

  快速執行完成的指令,需要等待滿一個時鐘週期,才能執行下一條指令。所以,在單指令週期處理器裏面,無論是執行一條用不到 ALU 的無條件跳轉指令,還是一條計算起來電路特別複雜的浮點數乘法運算,都等要等滿一個時鐘週期。在這個情況下,雖然 CPI 能夠保持在 1,但是時鐘頻率卻沒法太高。因爲太高的話,有些複雜指令沒有辦法在一個時鐘週期內運行完成。那麼在下一個時鐘週期到來,開始執行下一條指令的時候,前一條指令的執行結果可能還沒有寫入到寄存器裏面。那下一條指令讀取的數據就是不準確的,就會出現錯誤。

![單指令週期處理器][單指令週期處理器]

3.2 現代處理器

  將複雜的指令拆解成簡單的指令, 這樣的協作模式被稱爲指令流水線,每一個獨立的指令被稱爲流水線階段或者流水線級(Pipeline Stage)。而現代的處理器不需要確保最複雜的那條指令在時鐘週期裏面執行完成,而只要保障一個最複雜的流水線級的操作,在一個時鐘週期內完成就好了。

  如果某一個操作步驟的時間太長,就可以考慮把這個步驟,拆分成更多的步驟,讓所有步驟需要執行的時間儘量都差不多長。這樣,也就可以解決我們在單指令週期處理器中遇到的,性能瓶頸來自於最複雜的指令的問題。現代的 ARM 或者 Intel 的 CPU,流水線級數都已經到了 14 級。

3.3 現代處理器的性能瓶頸

  流水線可以增加吞吐率,爲什麼我們不把流水線級數做得更深呢?爲什麼不做成 20 級,乃至 40 級呢?一個最基本的原因,就是增加流水線深度,其實是有性能成本的。用來同步時鐘週期的,不再是指令級別的,而是流水線階段級別的。每一級流水線對應的輸出,都要放到流水線寄存器(Pipeline Register)裏面,然後在下一個時鐘週期,交給下一個流水線級去處理。所以,每增加一級的流水線,就要多一級寫入到流水線寄存器的操作。但是,不斷加深流水線,這些操作佔整個指令的執行時間的比例就會不斷增加。最後,性能瓶頸就會出現在這些 overhead 上。

![流水線的性能瓶頸][流水線的性能瓶頸]

  流水線技術帶來的性能提升,是一個理想情況; 在世紀的程序執行中,並不一定能夠做得到; 如果一個複雜指令被分爲 3 個流水線階段, 但是要執行 3 需要 2 的結果 ,執行 2 則需要 1 的結果, 這樣的依賴關係會導致指令完成時間和單指令週期 CPU 花費的時間一致

4 CPU 性能提升: 冒險和預測

  流水線設計需要解決的三大冒險,分別是結構冒險(Structural Hazard)、數據冒險(Data Hazard)以及控制冒險(Control Hazard)。

4.1 結構冒險(Structural Hazard)

  本質上是一個硬件層面的資源競爭問題,也就是一個硬件電路層面的問題。同一個時鐘週期,兩個不同指令訪問同一個資源類似的資源衝突。

![結構冒險][結構冒險]

  在第 1 條指令執行到訪存(MEM)階段的時候,流水線裏的第 4 條指令,在執行取指令(Fetch)的操作。訪存和取指令,都要進行內存數據的讀取。我們的內存,只有一個地址譯碼器的作爲地址輸入,那就只能在一個時鐘週期裏面讀取一條數據,沒辦法同時執行第 1 條指令的讀取內存數據和第 4 條指令的讀取指令代碼。

  資源衝突解決方案,其實本質就是增加資源。對於訪問內存數據和取指令的衝突,一個直觀的解決方案就是把我們的內存分成兩部分,讓它們各有各的地址譯碼器。這兩部分分別是存放指令的程序內存和存放數據的數據內存。

  把內存拆成兩部分的解決方案,在計算機體系結構裏叫作哈佛架構(Harvard Architecture),來自哈佛大學設計Mark I 型計算機時候的設計。

  馮·諾依曼體系結構,又叫作普林斯頓架構(Princeton Architecture)。

  但是, 今天使用的 CPU,仍然是馮·諾依曼體系結構的,並沒有把內存拆成程序內存和數據內存這兩部分。因爲如果那樣拆的話,對程序指令和數據需要的內存空間,我們就沒有辦法根據實際的應用去動態分配了。雖然解決了資源衝突的問題,但是也失去了靈活性。

  不過,借鑑了哈佛結構的思路,現代的 CPU 雖然沒有在內存層面進行對應的拆分,卻在 CPU 內部的高速緩存部分進行了區分,把高速緩存分成了指令緩存(Instruction Cache)和數據緩存(Data Cache)兩部分。

![CPU體系結構][CPU體系結構]

4.2 數據冒險(Data Hazard)

  數據冒險,其實就是同時在執行的多個指令之間,有數據依賴的情況。這些數據依賴,我們可以分成三大類,分別是先寫後讀(Read After Write,RAW)、先讀後寫(Write After Read,WAR)和寫後再寫(Write After Write,WAW)。

4.2.1 先寫後讀(Read After Write)

  先寫後讀的依賴關係,我們一般被稱之爲數據依賴,也就是 Data Dependency。

int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}

4.2.2 先讀後寫(Write After Read)

  先讀後寫的依賴,一般被叫作反依賴,也就是 Anti-Dependency。

int main() {
int a = 1;
int b = 2;
a = a + b;
b = a + b;
}

4.2.3 寫後再寫(Write After Write)

  寫後再寫的依賴,一般被叫作輸出依賴,也就是 Output Dependency。

int main() {
int a = 1;
a = 2;
}

4.2.4 數據冒險最笨的解決方法

  解決這些數據冒險。其中最簡單的一個辦法,不過也是最笨的一個辦法,就是流水線停頓(Pipeline Stall),或者叫流水線冒泡(Pipeline Bubbling)。時鐘信號會不停地在 0 和 1 之前自動切換。其實,並沒有辦法真的停頓下來。流水線的每一個操作步驟必須要乾點兒事情。所以,在實踐過程中,並不是讓流水線停下來,而是在執行後面的操作步驟前面,插入一個 NOP 操作,也就是執行一個其實什麼都不幹的操作。

![流水線停頓解決數據冒險][流水線停頓解決數據冒險]

4.2.5 操作數轉發

  一個數據冒險:

  1. 第一條指令,把 s1 和 s2 寄存器裏面的數據相加,存入到 t0 這個寄存器裏面。
  2. 第二條指令,把 s1 和 t0 寄存器裏面的數據相加,存入到 s2 這個寄存器裏面。

![停頓][停頓]

  其實第二條指令的執行,未必要等待第一條指令寫回完成,才能進行。如果第一條指令的執行結果,能夠直接傳輸給第二條指令的執行階段,作爲輸入,第二條指令,就不用再從寄存器裏面,把數據再單獨讀出來一次,纔來執行代碼。

  完全可以在第一條指令的執行階段完成之後,直接將結果數據傳輸給到下一條指令的 ALU。然後,下一條指令不需要再插入兩個 NOP 階段,就可以繼續正常走到執行階段。

![操作數轉發][操作數轉發]

  這樣的解決方案,就叫作操作數前推(Operand Forwarding),或者操作數旁路(Operand Bypassing)。其實,更合適的名字應該叫操作數轉發。這裏的 Forward,其實就是寫 Email 時的“轉發”(Forward)的意思。不過現有的經典教材的中文翻譯一般都叫“前推”。

  操作數前推的解決方案不但可以單獨使用,還可以和流水線冒泡一起使用。有的時候,雖然可以把操作數轉發到下一條指令,但是下一條指令仍然需要停頓一個時鐘週期。

  比如說,先去執行一條 LOAD 指令,再去執行 ADD 指令。LOAD 指令在訪存階段才能把數據讀取出來,所以下一條指令的執行階段,需要在訪存階段完成之後,才能進行。

4.2.6 CPU亂序執行, 空閒 NOP 利用

a = b + c
d = a * e
x = y * z

  計算裏面的 x ,卻要等待 a 和 d 都計算完成,實在沒啥必要。所以完全可以在 d 的計算等待 a 的計算的過程中,先把 x 的結果給算出來。

![亂序執行][亂序執行]

  1. 在取指令和指令譯碼的時候,亂序執行的 CPU 和其他使用流水線架構的 CPU 是一樣的。它會一級一級順序地進行取指令和指令譯碼的工作。
  2. 在指令譯碼完成之後,就不一樣了。CPU 不會直接進行指令執行,而是進行一次指令分發,把指令發到一個叫作保留站(Reservation Stations)的地方。
  3. 這些指令不會立刻執行,而要等待它們所依賴的數據,傳遞給它們之後纔會執行。
  4. 一旦指令依賴的數據來齊了,指令就可以交到後面的功能單元(Function Unit,FU),其實就是 ALU,去執行了。有很多功能單元可以並行運行,但是不同的功能單元能夠支持執行的指令並不相同。
  5. 指令執行的階段完成之後,並不能立刻把結果寫回到寄存器裏面去,而是把結果再存放到一個叫作重排序緩衝區(Re-Order Buffer,ROB)的地方。
  6. 在重排序緩衝區裏,我們的 CPU 會按照取指令的順序,對指令的計算結果重新排序。只有排在前面的指令都已經完成了,纔會提交指令,完成整個指令的運算結果。
  7. 實際的指令的計算結果數據,並不是直接寫到內存或者高速緩存裏,而是先寫入存儲緩衝區(Store Buffer 面,最終纔會寫入到高速緩存和內存裏。

4.3 控制冒險(Control Hazard)

  當循環或者條件分子執行的時候, CPU 會跳去執別的指令, 這種爲了確保能取到正確的指令,而不得不進行等待延遲的情況,就是控制冒險(Control Harzard)。

4.3.1 縮短分支延遲

  條件跳轉指令其實進行了兩種電路操作:

  1. 進行條件比較
  2. 把要跳轉的地址信息寫入到 PC 寄存器

  都是在指令譯碼(ID)的階段就能獲得的。而對應的條件碼比較的電路,只要是簡單的邏輯門電路就可以了,並不需要一個完整而複雜的 ALU。

  所以,可以將條件判斷、地址跳轉,都提前到指令譯碼階段進行,而不需要放在指令執行階段。對應的,也要在 CPU 裏面設計對應的旁路,在指令譯碼階段,就提供對應的判斷比較的電路。

4.3.2 靜態分支預測

  讓 CPU 來猜一猜,條件跳轉後執行的指令,應該是哪一條; 分支預測失敗了,就把後面已經取出指令已經執行的部分丟棄掉。這個丟棄的操作,在流水線裏面,叫作 Zap 或者 Flush。

  假裝分支不發生; 繼續執行後面的指令;

4.3.3 動態分支預測

  一級分支預測(One Level Branch Prediction),或者叫1 比特飽和計數(1-bit saturating counter)。這個方法,其實就是用一個比特,去記錄當前分支的比較情況,直接用當前分支的比較情況,來預測下一次分支時候的比較情況。這個方法還是有些“草率”,需要更多的資料來判斷下次分支的指令, 於是引入一個狀態機(State Machine)來做這個事情。這個時候就需要 2 個比特來記錄對應的狀態。這樣這整個策略,就可以叫作2 比特飽和計數,或者叫雙模態預測器(Bimodal Predictor)。

  寫代碼的時候養成良好習慣,按事件概率高低在分支中升序或降序安排,爭取讓狀態機少判斷

5 CPU 性能提升: 吞吐率

  程序的 CPU 執行時間 = 指令數 × CPI × Clock Cycle Time

   CPI 的倒數,又叫作 IPC(Instruction Per Clock),也就是一個時鐘週期裏面能夠執行的指令數,代表了 CPU 的吞吐率。最佳情況下,IPC 也只能到 1。因爲無論做了哪些流水線層面的優化,即使做到了指令執行層面的亂序執行,CPU 仍然只能在一個時鐘週期裏面,取一條指令。

  現在用的 CPU 芯片中, 雖然浮點數計算已經變成 CPU 裏的一部分,但並不是所有計算功能都在一個 ALU 裏面,真實的情況是,CPU 會有多個 ALU。所以在指令執行層面可以並行, 如此, 爲了提升性能, 可以通過增加物理硬件的方式將取指令和指令譯碼部分同樣增加即可。這樣可以一次性從內存裏面取出多條指令,然後分發給多個並行的指令譯碼器,進行譯碼,然後對應交給不同的功能單元去處理。在一個時鐘週期裏,能夠完成的指令就不只一條了。

  這種 CPU 設計,叫作多發射(Mulitple Issue)和超標量(Superscalar)。

  無論是亂序執行,還是超標量技術,在實際的硬件層面,其實實施起來都挺麻煩的。這是因爲,在亂序執行和超標量的體系裏面,CPU 要解決依賴衝突(冒險)的問題。CPU 需要在指令執行之前,去判斷指令之間是否有依賴關係。如果有對應的依賴關係,指令就不能分發到執行階段。這些對於依賴關係的檢測,都會使得 CPU 電路變得更加複雜。

  於是,計算機科學家和工程師們就又有了一個大膽的想法。能不能不把分析和解決依賴關係的事情,放在硬件裏面,而是放到軟件裏面來幹呢?

  可以通過改進編譯器來優化指令數這個指標。有一個非常大膽的 CPU 設計想法,叫作超長指令字設計(Very Long Instruction Word,VLIW)。這個設計,不僅想讓編譯器來優化指令數,還想直接通過編譯器,來優化 CPI。圍繞着這個設計的,是 Intel 一個著名的“史詩級”失敗,也就是著名的 IA-64 架構的安騰(Itanium)處理器。只不過,這一次,責任不全在 Intel,還要拉上可以稱之爲硅谷起源的另一家公司,也就是惠普。之所以稱爲“史詩”級失敗,這個說法來源於惠普最早給這個架構取的名字,顯式併發指令運算(Explicitly Parallel Instruction Computer),這個名字的縮寫EPIC,正好是“史詩”的意思。

  在亂序執行和超標量的 CPU 架構裏,指令的前後依賴關係,是由 CPU 內部的硬件電路來檢測的。而到了超長指令字的架構裏面,這個工作交給了編譯器這個軟件。編譯器在這個過程中,其實也能夠知道前後數據的依賴。於是, 讓編譯器把沒有依賴關係的代碼位置進行交換。然後,再把多條連續的指令打包成一個指令包。安騰的 CPU 就是把 3 條指令變成一個指令包。

  CPU 在運行的時候,不再是取一條指令,而是取出一個指令包。然後,譯碼解析整個指令包,解析出 3 條指令直接並行運行。可以看到,使用超長指令字架構的 CPU,同樣是採用流水線架構的。也就是說,一組(Group)指令,仍然要經歷多個時鐘週期。同樣的,下一組指令並不是等上一組指令執行完成之後再執行,而是在上一組指令的指令譯碼階段,就開始取指令了。

  值得注意的一點是,流水線停頓這件事情在超長指令字裏面,很多時候也是由編譯器來做的。除了停下整個處理器流水線,超長指令字的 CPU 不能在某個時鐘週期停頓一下,等待前面依賴的操作執行完成。編譯器需要在適當的位置插入 NOP 操作,直接在編譯出來的機器碼裏面,就把流水線停頓這個事情在軟件層面就安排妥當。

安騰失敗的原因有很多,其中有一個重要的原因就是“向前兼容”。

  一方面,安騰處理器的指令集和 x86 是不同的。這就意味着,原來 x86 上的所有程序是沒有辦法在安騰上運行的,而需要通過編譯器重新編譯纔行。

  另一方面,安騰處理器的 VLIW 架構決定了,如果安騰需要提升並行度,就需要增加一個指令包裏包含的指令數量,比方說從 3 個變成 6 個。一旦這麼做了,雖然同樣是 VLIW 架構,同樣指令集的安騰 CPU,程序也需要重新編譯。因爲原來編譯器判斷的依賴關係是在 3 個指令以及由 3 個指令組成的指令包之間,現在要變成 6 個指令和 6 個指令組成的指令包。編譯器需要重新編譯,交換指令順序以及 NOP 操作,才能滿足條件。甚至,需要重新來寫編譯器,才能讓程序在新的 CPU 上跑起來。

  於是,安騰就變成了一個既不容易向前兼容,又不容易向後兼容的 CPU。那麼,它的失敗也就不足爲奇了。

6 CPU 性能提升: 超線程

  與超標量不同, 超線程的 CPU,其實是把一個物理層面 CPU 核心,“僞裝”成兩個邏輯層面的 CPU 核心。這個 CPU,會在硬件層面增加很多電路,使其可以在一個 CPU 核心內部,維護兩個不同線程的指令的狀態信息。

![超線程][超線程]

  比如,在一個物理 CPU 核心內部,會有雙份的 PC 寄存器、指令寄存器乃至條件碼寄存器。這樣,這個 CPU 核心就可以維護兩條並行的指令的狀態。在外面看起來,似乎有兩個邏輯層面的 CPU 在同時運行。所以,超線程技術一般也被叫作同時多線程(Simultaneous Multi-Threading,簡稱 SMT)技術。

  不過,在 CPU 的其他功能組件上,Intel 並沒有提供雙份。無論是指令譯碼器還是 ALU,一個 CPU 核心仍然只有一份。因爲超線程並不是真的去同時運行兩個指令,那就真的變成物理多核了。超線程的目的,是在一個線程 A 的指令,在流水線裏停頓的時候,讓另外一個線程去執行指令。因爲這個時候,CPU 的譯碼器和 ALU 就空出來了,那麼另外一個線程 B,就可以拿來幹自己需要的事情。這個線程 B 可沒有對於線程 A 裏面指令的關聯和依賴。這樣,CPU 通過很小的代價,就能實現“同時”運行多個線程的效果。通常只要在 CPU 核心的添加 10% 左右的邏輯功能,增加可以忽略不計的晶體管數量,就能做到這一點。

  不過,由於並沒有增加真的功能單元。所以超線程只在特定的應用場景下效果比較好。一般是在那些各個線程“等待”時間比較長的應用場景下。比如,需要應對很多請求的數據庫應用,就很適合使用超線程。各個指令都要等待訪問內存數據,但是並不需要做太多計算。於是,CPU 計算並沒有跑滿,但是往往當前的指令要停頓在流水線上,等待內存裏面的數據返回。這個時候,讓 CPU 裏的各個功能單元,去處理另外一個數據庫連接的查詢請求就是一個很好的應用案例。

7 CPU 性能提升: SIMD

  基於 SIMD 的向量計算指令,也正是在 Intel 發佈 Pentium 處理器的時候,被引入的指令集。當時的指令集叫作MMX,也就是 Matrix Math eXtensions 的縮寫,中文名字就是矩陣數學擴展。

  • 單指令單數據(Single Instruction Single Data)&& 多指令多數據流(Multiple Instruction Multiple Data)
  • 單指令多數據流(Single Instruction Multiple Data)
$ python
>>> import numpy as np
>>> import timeit
>>> a = list(range(1000))
>>> b = np.array(range(1000))
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
32.82800309999993
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
0.9787889999997788
>>>

  兩個功能相同的代碼性能有着巨大的差異,足足差出了 30 多倍。所有用 Python 講解數據科學的教程裏,往往在一開始就告訴我們不要使用循環,而要把所有的計算都向量化(Vectorize)。直接用 C 語言實現一下 1000 個元素的數組裏面的每個數加 1, 就會發現, 即使是 C 語言編譯出來的代碼,還是遠遠低於 NumPy。原因就是,NumPy 直接用到了 SIMD 指令,能夠並行進行向量的操作。

  前面使用循環來一步一步計算的算法呢,一般被稱爲SISD,也就是單指令單數據(Single Instruction Single Data)的處理方式。如果使用的是一個多核 CPU ,那麼它同時處理多個指令的方式可以叫作MIMD,也就是多指令多數據(Multiple Instruction Multiple Data)。

![SIMD][SIMD]

  SIMD 在獲取數據和執行指令的時候,都做到了並行。在從內存裏面讀取數據的時候,SIMD 是一次性讀取多個數據。數組裏面的每一項都是一個 integer,也就是需要 4 Bytes 的內存空間。Intel 在引入 SSE 指令集的時候,在 CPU 裏面添上了 8 個 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是說,一個寄存器一次性可以加載 4 個整數。比起循環分別讀取 4 次對應的數據,時間就省下來了。

  數據讀取到了之後,在指令的執行層面,SIMD 也是可以並行進行的。4 個整數各自加 1,互相之前完全沒有依賴,也就沒有冒險問題需要處理。只要 CPU 裏有足夠多的功能單元,能夠同時進行這些計算,這個加法就是 4 路同時並行的,自然也省下了時間。

8 異常和中斷

  異常其實是一個硬件和軟件組合到一起的處理過程。異常的前半生,也就是異常的發生和捕捉,是在硬件層面完成的。但是異常的後半生,異常的處理,其實是由軟件來完成的。計算機會爲每一種可能會發生的異常,分配一個異常代碼(Exception Number)。有些教科書會把異常代碼叫作中斷向量(Interrupt Vector)。這些異常代碼裏,I/O 發出的信號的異常代碼,是由操作系統來分配的,也就是由軟件來設定的。而像加法溢出這樣的異常代碼,則是由 CPU 預先分配好的,也就是由硬件來分配的。這又是另一個軟件和硬件共同組合來處理異常的過程。

異常

8.1 異常的分類: 中斷、陷阱、故障和中止

  中斷(Interrupt)。程序在執行到一半的時候,被打斷了。這個打斷執行的信號,一般來自於 CPU 外部的 I/O 設備。

  陷阱(Trap),其實是程序員“故意“主動觸發的異常。就好像你在程序裏面打了一個斷點,這個斷點就是設下的一個"陷阱"。當程序的指令執行到這個位置的時候,就掉到了這個陷阱當中。然後,對應的異常處理程序就會來處理這個"陷阱"當中的獵物。應用程序通過系統調用去讀取文件、創建進程,其實也是通過觸發一次陷阱來進行的。這是因爲,用戶態的應用程序沒有權限來做這些事情,需要把對應的流程轉交給有權限的異常處理程序來進行。

  故障(Fault)。它和陷阱的區別在於,陷阱是開發程序的時候刻意觸發的異常,而故障通常不是。故障在異常程序處理完成之後,仍然回來處理當前的指令,而不是去執行程序中的下一條指令。因爲當前的指令因爲故障的原因並沒有成功執行完成。

  中止(Abort)。這是故障的一種特殊情況。當 CPU 遇到了故障,但是恢復不過來的時候,程序就不得不中止了。

編號 類型 原因 示例 觸發時機 處理後操作
1 中斷 I/O設備信號 用戶鍵盤輸入 異步 下一條指令
2 陷阱 程序刻意觸發 程序進行系統調用 同步 下一條指令
3 故障 程序執行出錯 程序加載的缺頁錯誤 同步 當前指令
4 中止 故障無法恢復 ECC內存校驗失敗 同步 退出程序

8.2 異常的處理: 上下文切換

  在實際的異常處理程序執行之前,CPU 需要去做一次“保存現場”的操作。這個保存現場的操作,和函數調用的過程非常相似。

  切換到異常處理程序的時候,其實就好像是去調用一個異常處理函數。指令的控制權被切換到了另外一個"函數"裏面,所以自然要把當前正在執行的指令去壓棧。這樣才能在異常處理程序執行完成之後,重新回到當前的指令繼續往下執行。不過,切換到異常處理程序,比起函數調用,還是要更復雜一些。原因有下面幾點:

  1. 因爲異常情況往往發生在程序正常執行的預期之外,比如中斷、故障發生的時候。所以,除了本來程序壓棧要做的事情之外,還需要把 CPU 內當前運行程序用到的所有寄存器,都放到棧裏面。最典型的就是條件碼寄存器裏面的內容
  2. 像陷阱這樣的異常,涉及程序指令在用戶態和內核態之間的切換。對應壓棧的時候,對應的數據是壓到內核棧裏,而不是程序棧裏
  3. 像故障這樣的異常,在異常處理程序執行完成之後。從棧裏返回出來,繼續執行的不是順序的下一條指令,而是故障發生的當前指令。因爲當前指令因爲故障沒有正常執行成功,必須重新去執行一次

  linux內核中有軟中斷和硬中斷的說法。比如網卡收包時,硬中斷對應的概念是中斷,即網卡利用信號“告知”CPU有包到來,CPU執行中斷向量對應的處理程序,即收到的包拷貝到計算機的內存,然後“通知”軟中斷有任務需要處理,中斷處理程序返回;軟中斷是一個內核級別的進程(線程),沒有對應到本次課程的概念,用於處理硬中斷餘下的工作,比如網卡收的包需要向上送給協議棧處理

  軟中斷是由軟件來觸發,它屬於同步的中斷。一般用來完成一些特定任務:int 3 調試斷點,以及之前 Linux 的 int 80h 系統調用

  硬件中斷是硬件組件觸發的,可能是CPU內部異常,也可能是io外設的。外設的中斷屬於異步,它可能會在CPU指令執行期間觸發。

  軟中斷對應陷阱, 硬中斷對應中斷, 有時候也包含故障, 也有的把故障單獨歸類爲異常

9 CPU 指令集: CISC vs RISC

  CPU 的指令集裏的機器碼是固定長度還是可變長度,也就是複雜指令集(Complex Instruction Set Computing,簡稱 CISC)和精簡指令集(Reduced Instruction Set Computing,簡稱 RISC)

編號 CISC RISC
1 以硬件爲中心的指令集設計 以軟件爲中心的指令集設計
2 通過硬件實現各類程序指令 通過編譯器實現簡單指令的組合, 完成複雜功能
3 更高效的使用內存和寄存器 需要更大的內存和寄存器, 並更頻繁的使用
4 可變的指令長度, 支持更復雜的指令長度 簡單, 定長的指令
5 大量指令數 少量指令數

  MIPS 機器碼的長度都是固定的 32 位

  Intel x86 的機器碼的長度是可變的

  在計算機歷史的早期,所有的 CPU 其實都是 CISC

  雖然馮·諾依曼高屋建瓴地提出了存儲程序型計算機的基礎架構,但是實際的計算機設計和製造還是嚴格受硬件層面的限制。當時的計算機很慢,存儲空間也很小。爲了讓計算機能夠做盡量多的工作,每一個字節乃至每一個比特都特別重要。所以,CPU 指令集的設計,需要仔細考慮硬件限制。爲了性能考慮,很多功能都直接通過硬件電路來完成。爲了少用內存,指令的長度也是可變的。就像算法和數據結構裏的赫夫曼編碼(Huffman coding)一樣,常用的指令要短一些,不常用的指令可以長一些。那個時候的計算機,想要用盡可能少的內存空間,存儲儘量多的指令。

  隨着計算機性能越來越好, 存儲空間也越來越大。UC Berkeley 的大衛·帕特森(David Patterson)教授發現,實際在 CPU 運行的程序裏,80% 的時間都是在使用 20% 的簡單指令。

  在硬件層面,要想支持更多的複雜指令,CPU 裏面的電路就要更復雜,設計起來也就更困難。更復雜的電路,在散熱和功耗層面,也會帶來更大的挑戰。在軟件層面,支持更多的複雜指令,編譯器的優化就變得更困難。於是,在 RISC 架構裏面,CPU 把指令“精簡”到 20% 的簡單指令。而原先的複雜指令,則通過用簡單指令組合起來來實現,讓軟件來實現硬件的功能。這樣,CPU 的整個硬件設計就會變得更簡單了,在硬件層面提升性能也會變得更容易了。

9.1 微指令架構(Micro-Instructions/Micro-Ops)

  核心問題是要始終向前兼容 x86 的指令集,那麼能不能不修改指令集,但是讓 CISC 風格的指令集,用 RISC 的形式在 CPU 裏面運行呢?在微指令架構的 CPU 裏面,編譯器編譯出來的機器碼和彙編代碼並沒有發生什麼變化。但在指令譯碼的階段,指令譯碼器“翻譯”出來的,不再是某一條 CPU 指令。譯碼器會把一條機器碼,“翻譯”成好幾條“微指令”。這些 RISC 風格的微指令,會被放到一個微指令緩衝區裏面,然後再從緩衝區裏面,分發給到後面的超標量,並且是亂序執行的流水線架構裏面。不過這個流水線架構裏面接受的,就不是複雜的指令,而是精簡的指令了。在這個架構裏,指令譯碼器相當於變成了設計模式裏的一個“適配器”(Adaptor)。這個適配器,填平了 CISC 和 RISC 之間的指令差異。

微指令架構

  凡事有好處就有壞處。這樣一個能夠把 CISC 的指令譯碼成 RISC 指令的指令譯碼器,比原來的指令譯碼器要複雜。這也就意味着更復雜的電路和更長的譯碼時間:本來以爲可以通過 RISC 提升的性能,結果又有一部分浪費在了指令譯碼上。之所以大家認爲 RISC 優於 CISC,來自於一個數字統計,那就是在實際的程序運行過程中,有 80% 運行的代碼用着 20% 的常用指令。這意味着,CPU 裏執行的代碼有很強的局部性。而對於有着很強局部性的問題,常見的一個解決方案就是使用緩存。

  所以,Intel 就在 CPU 裏面加了一層 L0 Cache。這個 Cache 保存的就是指令譯碼器把 CISC 的指令“翻譯”成 RISC 的微指令的結果。於是,在大部分情況下,CPU 都可以從 Cache 裏面拿到譯碼結果,而不需要讓譯碼器去進行實際的譯碼操作。這樣不僅優化了性能,因爲譯碼器的晶體管開關動作變少了,還減少了功耗。

  因爲“微指令”架構的存在,從 Pentium Pro 開始,Intel 處理器已經不是一個純粹的 CISC 處理器了。它同樣融合了大量 RISC 類型的處理器設計。不過,由於 Intel 本身在 CPU 層面做的大量優化,比如亂序執行、分支預測等相關工作,x86 的 CPU 始終在功耗上還是要遠遠超過 RISC 架構的 ARM,所以最終在智能手機崛起替代 PC 的時代,落在了 ARM 後面。

  到了 21 世紀的今天,CISC 和 RISC 架構的分界已經沒有那麼明顯了。Intel 和 AMD 的 CPU 也都是採用譯碼成 RISC 風格的微指令來運行。而 ARM 的芯片,一條指令同樣需要多個時鐘週期,有亂序執行和多發射。甚至有這樣的評價,“ARM 和 RISC 的關係,只有在名字上”。

  ARM 真正能夠戰勝 Intel,主要是因爲下面這兩點原因:

  1. 功耗優先的設計。一個 4 核的 Intel i7 的 CPU,設計的時候功率就是 130W。而一塊 ARM A8 的單個核心的 CPU,設計功率只有 2W。兩者之間差出了 100 倍。在移動設備上,功耗是一個遠比性能更重要的指標,畢竟我們不能隨時在身上帶個發電機。ARM 的 CPU,主頻更低,晶體管更少,高速緩存更小,亂序執行的能力更弱。所有這些,都是爲了功耗所做的妥協
  2. 低價。ARM 並沒有自己壟斷 CPU 的生產和製造,只是進行 CPU 設計,然後把對應的知識產權授權出去,讓其他的廠商來生產 ARM 架構的 CPU。它甚至還允許這些廠商可以基於 ARM 的架構和指令集,設計屬於自己的 CPU。像蘋果、三星、華爲,它們都是拿到了基於 ARM 體系架構設計和製造 CPU 的授權。ARM 自己只是收取對應的專利授權費用。多個廠商之間的競爭,使得 ARM 的芯片在市場上價格很便宜。所以,儘管 ARM 的芯片的出貨量遠大於 Intel,但是收入和利潤卻比不上 Intel

  

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章