1. 程式人生 > >從x86流水線層面,談談如何進行效能優化

從x86流水線層面,談談如何進行效能優化

前言

效能優化,關鍵在於伺候好 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 的過剩產能:改一次只能處理一條指令為一次處理五條。

從網上以“CPU pipeline”為關鍵字搜尋總會找到類似下圖的圖片:

CPU pipeline

我不知道諸位怎麼看,反正我對著這幅圖理解起來總是有困難。提供一個簡單的理解:將每條指令都想象為一個待加工的產品,在一條有 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 測試工作組副主席。

文章來自微信公眾號:高效開發運維