導讀 | 如何在面對分支的時候選取正確的路?如果指令選取錯誤,整條管線需要先等待剩餘指令執行完畢,清空之後再重新從正確的位置開始。流水線的層次越深,造成的傷害越大。 |
效能最佳化,關鍵在於伺候好 CPU。身為一個追求效能極致的程式設計師,了解 CPU 的內部機制是一個不可迴避的議題。這是一個需要日積月累的持續的過程,但也不需要深入到數位電路的程度,就像一個設計CPU 的專家並不一定精通軟體設計一樣,你也不需要成為一個CPU 專家才能寫出高性能的軟體。
作為一小撮人類精英送給普羅大眾的珍貴禮物,能在市場上隨意購買到的 CPU 其實和買不到的核武器一樣代表了人類最尖端的科技水平。即便是一位 x86 CPU 專家也只能無一遺漏地講清楚他所專攻的那一部分內容。對我們來說,雖然不可能盡懂,但有三個部分的內容十分關鍵:管線、快取和指令集。這三個部分之中,「流水線」可以作為一條貫穿的線索。因此,承接上一篇文章中的範例,我們先來了解一下管線。
基本概念
PU 的主要工作是依據指令執行對資料的操作。這句話基本上解釋了什麼是流水線。我知道能點開這篇文章的人都不可能對「流水線」這個概念一無所知,我也不想一上來就鋪陳大段大段教科書式的文本,羅列各個概念的定義,這完全是在一心一意捨本逐末。技術的發展只是事物矛盾的一種運動形式,這次我們將嘗試從 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 的過剩產能:改一次只能處理一條指令為一次處理五條。
#從x86管線層面,談談如何進行效能最佳化我不知道各位怎麼看,反正我對這幅圖理解起來總是有困難。提供一個簡單的理解:將每條指令都想像為一個待加工的產品,在一條有 5 個加工工序的流水線上魚貫而入。這樣可以讓 CPU 的每一道工序始終保持工作量飽和,也就從根本上提升了指令的吞吐和程式的效能。
流水線引入的問題
如果簡單地將每一行程式碼抽象化為XOR指令,按下上圖 i486 管線的示意,第一個指令進入管線 Fetch 階段,然後進入 D1 階段,此時第二條指令進入 Fetch。在下一個機器週期,第一條指令進入 D2,第二條進入 D1,同時 Fetch 第三條指令。到目前為止一切正常,但下一個機器週期,當第一條指令進入Execute 階段的時候,第二條指令並不能繼續進入下一階段,因為它所需要的變數a的最終結果,必須在第一條指令執行完畢之後才能獲得。所以第二條指令會阻塞在管線之上,等第一條指令執行完畢才會繼續。而在第二條指令執行的過程中,第三條指令也會有類似的遭遇。當出現了管線阻塞的情況,指令的管線執行就會與單獨執行之間拉開距離,這稱為管線「氣泡」(bubble)。
時鐘週期:也叫震盪週期。是時脈頻率(主頻)的倒數,是最小的時間週期
機器週期:管線中的每個階段稱為一個基本操作,完成一個基本操作所需的時間為機器週期
指令周期:執行一條指令所需的時間,一般由多個機器週期組成
除了上面的情況,還有一個常見的原因導致氣泡的產生。執行每條指令所需消耗的時間(指令周期)是不同的。當一條簡單指令前面是一條耗時較長的複雜指令的時候,簡單指令必須等待複雜指令。另外,如果程式裡出現if這類分支呢?這些情況都會導致流水線不能滿載工作,從而導致性能的相對下降。
在面對問題的時候,人總是會傾向於引入一個更複雜的機制來解決問題,多層流水線就是一個例子。複雜可以反映出技術的改良,但「複雜」本身就是一個新的問題。這也許就是矛盾永遠不會消失,科技也不會停止進步的原因。但“為學日益,為道日損”,愈發複雜的機制總會在某個時機之下發生大破大立,但可能現在時機還沒有到來。面對「氣泡」問題,處理器又引入了一個更複雜的解決方案——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 個邏輯核心的由來。
亂序執行也不一定 100% 達到順序執行程式碼的效果。有些時候確實需要程式設計師引入記憶體屏障來確保執行的先後順序。
但複雜的事物總是會引入新的問題,這次矛盾轉移到了 Fetch 階段。如何在面對分支的時候選取正確的路?如果指令選取錯誤,整條管線需要先等待剩餘指令執行完畢,清空之後再重新從正確的位置開始。流水線的層次越深,造成的傷害越大。後續的文章,將會介紹一些在程式設計層面優化的方法。
作者介紹
張攀,雲杉網路工程師,專注於 x86 網路軟體的開發與效能最佳化,深度參與 ONF/OPNFV/ONOS 等組織及社區,曾任 ONF 測試工作小組副主席。
以上是優化x86流水線層面的性能方法的詳細內容。更多資訊請關注PHP中文網其他相關文章!