x86高性能編程箋注(2)-流水線
性能優化,關鍵在于伺候好CPU。作為一個追求性能極致的程序員,了解CPU的內部機制是一個不可回避的話題。這是一個需要日積月累的持續的過程,但也并不需要深入到數字電路的程度,就像一個設計CPU的專家并不一定精通軟件設計一樣,你也并不需要成為一個CPU專家才能寫出高性能的軟件。
作為一小撮人類精英送給普羅大眾的珍貴禮物,能在市場上隨意購買到的CPU其實和買不到的核武器一樣代表了人類最尖端的科技水平。即便是一位x86 CPU專家也只能無一遺漏地講清楚他所專攻的那一部分內容。對于我們來說,雖然不可能盡懂,但有三個部分的內容十分關鍵:流水線、緩存和指令集。這三個部分之中,“流水線”可以作為一條貫穿的線索。因此,承接上一篇文章中的示例,我們先來了解一下流水線。
基本概念
CPU的主要工作是依據指令執行對數據的操作。這句話基本上解釋了什么是流水線。我知道能點開這篇文章的人都不可能對“流水線”這個概念一無所知,我也不想一上來就鋪陳大段大段教科書式的文本,羅列各個概念的定義,這完全是在一心一意地舍本逐末。技術的發展只是事物矛盾的一種運動形式,這次我們將嘗試從CPU的歷史沿革的角度切入對流水線各個組件的介紹。
從40年前Intel生產第一顆8086處理器直到今天,CPU的變化已經讓你覺得以前的處理器都只能叫做“單片機”。但即便真的是淘寶上幾毛錢一個的單片機,也有和今天的i7處理器相通的地方。8086處理器有14個今天仍在使用的寄存器:4個通用寄存器(General Purpose Register),4個段寄存器(Segment Register),4個索引寄存器(Index Register),1個標志位寄存器(EFLAGS Register)用于標示CPU狀態,以及最后一個,指令指針寄存器(Instruction Pointer Register),用來保存下一個需要執行的指令的地址。這個指令指針寄存器,就直接涉及到流水線的操作過程,它的持續存在,也表明了流水線基本原理的時間一致性。
從40年前到現在,所有CPU執行過的指令都遵循以下的流程:CPU首先依據指令指針取得(Fetch)將要執行的指令在代碼段的地址,接下來解碼(Decode)地址上的指令。解碼之后,會進入真正的執行(Execute)階段,之后會是“寫回”(Write Back)階段,將處理的最終結果寫回內存或寄存器中,并更新指令指針寄存器指向下一條指令。這基本上是一個完全符合人類邏輯的設計方案。
最初,也是最自然地,CPU會一個接一個地處理全部指令。每一個指令都按上面的過程執行完畢,然后執行下一個指令。那個時候的主要矛盾還是軟件日益增長的性能需求同落后的CPU處理速度之間的矛盾。在摩爾定律的正確指導下,CPU建設工作取得了歷史性成果,主要矛盾發生了轉移:CPU的執行速度慢慢快過了內存讀寫的速度。所以每次都去內存讀取指令越來越成為不能承受之重,因此在1982年,處理器中引入了指令緩存。
當CPU的速度越來越快,數據緩存作為矛盾雙方互相妥協的產物也引入到處理器之中。但這些都不是治本之法。矛盾的主要方面在于,CPU并沒有以飽和的狀態運轉。于是在1989年,i486處理器建設性地引入了五級流水線。其思路就是以拉動內需的方式消化CPU的過剩產能:改一次只能處理一條指令為一次處理五條。
從網上以“CPU pipeline”為關鍵字搜索總會找到類似下圖的圖片:
我不知道諸位怎么看,反正我對著這幅圖理解起來總是有困難。提供一個簡單的理解:將每條指令都想象為一個待加工的產品,在一條有5個加工工序的流水線上魚貫而入。這樣可以讓CPU的每一道工序始終保持工作量飽和,也就從根本上提升了指令的吞吐和程序的性能。
流水線引入的問題
考慮一個簡單的交換變量值的代碼:
a = a ^ b;
b = a ^ b;
a = b ^ a;
如果簡單地將每一行代碼抽象為一個XOR
指令,按上圖i486流水線的示意,第一條指令進入流水線Fetch階段,然后進入D1階段,此時第二條指令進入Fetch。在下一個機器周期,第一條指令進入D2,第二條進入D1,同時Fetch第三條指令。到此為止一切正常,但下一個機器周期,當第一條指令進入Execute階段的時候,第二條指令并不能繼續進入下一階段,因為它所需要的變量a
的最終結果,必須在第一條指令執行完畢之后才能獲得。所以第二條指令會阻塞在流水線之上,等第一條指令執行完畢才會繼續。而在第二條指令執行的過程中,第三條指令也會有類似的遭遇。當出現了流水線阻塞的情況,指令的流水線式執行就會與單獨執行之間拉開距離,這被稱為流水線“氣泡”(bubble)。
Side Notes:
時鐘周期:也叫震蕩周期。是時鐘頻率(主頻)的倒數,是最小的時間周期
機器周期:流水線中的每個階段稱為一個基本操作,完成一個基本操作所需要的時間為機器周期
指令周期:執行一條指令所需要的時間,一般由多個機器周期組成
除了上面的情況,還有一種常見的原因導致氣泡的產生。執行每條指令所需要消耗的時間(指令周期)是不同的。當一條簡單指令前面是一條耗時較長的復雜指令的時候,簡單指令不得不等待復雜指令。另外,如果程序里出現if
這類分支呢?這些情況都會導致流水線不能滿負荷工作,從而導致性能的相對下降。
在面對問題的時候,人總是會傾向于引入一個更復雜的機制來解決問題,多級流水線就是一個例子。復雜可以反映出技術的改良,但“復雜”本身就是一個新的問題。這也許就是矛盾永遠不會消失,技術也不會停止進步的原因。但“為學日益,為道日損”,愈發復雜的機制總會在某個時機之下發生大破大立,但可能現在時機還沒有到來:D面對“氣泡”問題,處理器又引入了一個更復雜的解決方案——1995年Intel發布Pentium Pro處理器時,加入了亂序執行核心(Out-of-order core, OOO core)。
亂序執行核心(OOO core)
其實亂序執行的思想很簡單:當下一條指令被阻塞的時候,從后面的指令里再找一條能執行的就好了嘛。但要完成這個工作卻相當復雜。首先要保證程序的最終結果與順序執行一致,同時要識別各類數據依賴。要達到理想的效果,除了并行執行之外,還需要對指令的粒度進一步細化,以達到以無厚入有間的效果,這樣就引入了“微操作”(micro-operations, μ-ops)的概念。在流水線的Decode階段,匯編指令又被進一步拆解,最終的產物就是一系列的微操作。
上圖就是引入亂序處理核心之后的指令μ-ops處理流程。不同顏色的模塊對應第一張圖中不同顏色的流水線處理階段。
Fetch階段沒有太多變化,在Decode階段,可以并行對四條指令解碼,解碼的最終產物就是上面提到的μ-ops。后面的Register Alias Table和Reorder Buffer可以當做是亂序執行核心的預處理階段。
對于并行執行的微操作,或者亂序執行的操作,很有可能會同時讀寫同一個寄存器。所以在處理器內部,原始的寄存器便被“別名”(aliased)為內部對軟件工程師不可見的寄存器,這樣原本在同一個寄存器上執行的操作便可以在臨時性的不同的寄存器上執行,無論讀寫,互不干擾(注意:這里要求兩個操作沒有數據依賴)。而對應的微操作的操作數也變為了臨時性的別名寄存器,相當于一種空間換時間的策略,并且同時對微指令進行了一次基于別名寄存器的轉譯。
之后微操作進入Reorder Buffer。至此,微指令已經準備就緒。它們會被放入Reservation Station(RS)并被并行執行。從圖中可以看到相當多的執行單元(Port X)。每一個執行單元都執行一個特定的任務,比如讀取(Load),寫入(Store),整數計算(ALU, SEE)等等。而每一條相關的微指令都可以在它所需要的數據準備好之后執行。這樣耗時較長的指令和有數據依賴關系的指令,雖然單從其自身的角度看,并沒有任何變化,但它們所帶來的阻塞的開銷,被后續指令的并行及亂序(提前)執行所分攤,化整為零,帶來整體吞吐的提升。
亂序執行核心的神奇之處就在于,它能夠最大限度地提升這套機制的效率,并且在外界看來,指令是在順序執行。這里面的詳細細節不在本文的討論范疇。但亂序執行核心是如此成功,以至于引入該機制的CPU即便是在大工作負載的情況下亂序執行核心仍會在大部分時間處于空閑的狀態,遠未飽和。因此,又引入了另外一個前端(Front-end,包括Fetch和Decode)給該核心輸送μ-ops,在系統看來,便可以抽象為兩個處理核心,這也就是超線程(Hyper-thread)N個物理核心,2N個邏輯核心的由來。
Side Note:亂序執行也并不一定100%達到順序執行代碼的效果。有些時候確實需要程序員引入內存屏障來確保執行的先后順序。
但復雜的事物總會引入新的問題,這次矛盾轉移到了Fetch階段。如何在面對分支的時候選取正確的路?如果指令選取錯誤,整條流水線需要首先等待剩余指令執行完畢,清空之后再重新從正確的位置開始。流水線的層次越深,造成的傷害越大。后續的文章,將會介紹一些在編程層面優化的方法。