C/C++ 性能優化背後的方法論:TMAM

開發過程當中咱們多少都會關注服務的性能,然而性能優化是相對比較困難,每每須要多輪優化、測試,屬於費時費力,有時候還未必有好的效果。可是若是有較好的性能優化方法指導、工具輔助分析能夠幫助咱們快速發現性能瓶頸所在,針對性地進行優化,能夠事半功倍。html

性能優化的難點在於找出關鍵的性能瓶頸點,若是不借助一些工具輔助定位這些瓶頸是很是困難的,例如:c++程序一般你們可能都會藉助perf /bcc這些工具來尋找存在性能瓶頸的地方。性能出現瓶頸的緣由不少好比 CPU、內存、磁盤、架構等。本文就僅僅是針對CPU調優進行調優,即如何榨乾CPU的性能,將CPU吞吐最大化。(實際上CPU出廠的時候就已經決定了它的性能,咱們須要作的就是讓CPU儘量作有用功),因此針對CPU利用率優化,實際上就是找出咱們寫的不夠好的代碼進行優化。java

1、示例

先敬上代碼:python

#include <stdlib.h>
 
 #define CACHE_LINE __attribute__((aligned(64)))
 
 struct S1
 {
   int r1;
   int r2;
   int r3;
   S1 ():r1 (1), r2 (2), r3 (3){}
 } CACHE_LINE;
 void add(const S1 smember[],int members,long &total) {
     int idx = members;
     do {
        total += smember[idx].r1;
        total += smember[idx].r2;
        total += smember[idx].r3;
     }while(--idx);
 }
 int main (int argc, char *argv[]) {
   const int SIZE = 204800;
   S1 *smember = (S1 *) malloc (sizeof (S1) * SIZE);
   long total = 0L;
   int loop = 10000;
   while (--loop) {  // 方便對比測試
       add(smember,SIZE,total);
   }
   return 0;
 }
注:代碼邏輯比較簡單就是作一個累加操做,僅僅是爲了演示。

編譯+運行:linux

g++ cache_line.cpp -o cache_line ; task_set -c 1 ./cache_line

下圖是示例cache\_line在CPU 1核心上運行,CPU利用率達到99.7%,此時CPU基本上是滿載的,那麼咱們如何知道這個cpu運行cache\_line 服務過程當中是否作的都是有用功,是否還有優化空間?c++

有的同窗可能說,能夠用perf 進行分析尋找熱點函數。確實是可使用perf,可是perf只能知道某個函數是熱點(或者是某些彙編指令),可是無法確認引發熱點的是CPU中的哪些操做存在瓶頸,好比取指令、解碼、.....算法

若是你還在爲判斷是CPU哪些操做致使服務性能瓶頸而不知所措,那麼這篇文章將會你給你授道解惑。本文主要經過介紹自頂向下分析方法(TMAM)方法論來快速、精準定位CPU性能瓶頸以及相關的優化建議,幫助你們提高服務性能。爲了讓你們更好的理解本文介紹的方法,須要準備些知識。編程

2、CPU 流水線介紹

(圖片來源:intel 官方文檔)後端

現代的計算機通常都是馮諾依曼計算機模型都有5個核心的組件:運算、存儲、控制、輸入、輸出。本文介紹的方法與CPU有關,CPU執行過程當中涉及到取指令、解碼、執行、回寫這幾個最基礎的階段。最先的CPU執行過程當中是一個指令按照以上步驟依次執行完以後,才能輪到第二條指令即指令串行執行,很顯然這種方式對CPU各個硬件單元利用率是很是低的,爲了提升CPU的性能,Intel引入了多級流水、亂序執行等技術提高性能。通常intel cpu是5級流水線,也就是同一個cycle 能夠處理5個不一樣操做,一些新型CPU中流水線多達15級,下圖展現了一個5級流水線的狀態,在7個CPU指令週期中指令1,2,3已經執行完成,而指令4,5也在執行中,這也是爲何CPU要進行指令解碼的目的:將指令操做不一樣資源的操做分解成不一樣的微指令(uops),好比ADD eax,[mem1] 就能夠解碼成兩條微指令,一條是從內存[mem1]加載數據到臨時寄存器,另一條就是執行運算,這樣就能夠在加載數據的時候運算單元能夠執行另一條指令的運算uops,多個不一樣的資源單元能夠並行工做。緩存

(圖片來源:intel 官方文檔)性能優化

CPU內部還有不少種資源好比TLB、ALU、L1Cache、register、port、BTB等並且各個資源的執行速度各不相同,有的速度快、有的速度慢,彼此之間又存在依賴關係,所以在程序運行過程當中CPU不一樣的資源會出現各類各樣的約束,本文運用TMAM更加客觀的分析程序運行過程當中哪些內在CPU資源出現瓶頸。

3、自頂向下分析(TMAM)

TMAM 即 Top-down Micro-architecture Analysis Methodology自頂向下的微架構分析方法。這是Intel CPU 工程師概括總結用於優化CPU性能的方法論。TMAM 理論基礎就是將各種CPU各種微指令進行歸類從大的方面先確承認能出現的瓶頸,再進一步下鑽分析找到瓶頸點,該方法也符合咱們人類的思惟,從宏觀再到細節,過早的關注細節,每每須要花費更多的時間。這套方法論的優點在於:

  1. 即便沒有硬件相關的知識也可以基於CPU的特性優化程序
  2. 系統性的消除咱們對程序性能瓶頸的猜想:分支預測成功率低?CPU緩存命中率低?內存瓶頸?
  3. 快速的識別出在多核亂序CPU中瓶頸點

TMAM 評估各個指標過程當中採用兩種度量方式一種是cpu時鐘週期(cycle[6]),另一種是CPU pipeline slot[4]。該方法中假定每一個CPU 內核每一個週期pipeline都是4個slot即CPU流水線寬是4。下圖展現了各個時鐘週期四個slot的不一樣狀態,注意只有Clockticks 4 ,cycle 利用率纔是100%,其餘的都是cycle stall(停頓、氣泡)。

(圖片來源:intel 官方文檔)

3.1 基礎分類

(圖片來源於:intel 文檔)

TMAM將各類CPU資源進行分類,經過不一樣的分類來識別使用這些資源的過程當中存在瓶頸,先從大的方向確認大體的瓶頸所在,而後再進行深刻分析,找到對應的瓶頸點各個擊破。在TMAM中最頂層將CPU的資源操做分爲四大類,接下來介紹下這幾類的含義。

3.1.1 Retiring

Retiring表示運行有效的uOps 的pipeline slot,即這些uOps[3]最終會退出(注意一個微指令最終結果要麼被丟棄、要麼退出將結果回寫到register),它能夠用於評估程序對CPU的相對比較真實的有效率。理想狀況下,全部流水線slot都應該是"Retiring"。100% 的Retiring意味着每一個週期的 uOps Retiring數將達到最大化,極致的Retiring能夠增長每一個週期的指令吞吐數(IPC)。須要注意的是,Retiring這一分類的佔比高並不意味着沒有優化的空間。例如retiring中Microcode assists的類別其實是對性能有損耗的,咱們須要避免這類操做。

3.1.2 Bad Speculation

Bad Speculation表示錯誤預測致使浪費pipeline 資源,包括因爲提交最終不會retired的 uOps 以及部分slots是因爲從先前的錯誤預測中恢復而被阻塞的。因爲預測錯誤分支而浪費的工做被歸類爲"錯誤預測"類別。例如:if、switch、while、for等均可能會產生bad speculation。

3.1.3 Front-End-Boun

Front-End 職責:

  1. 取指令
  2. 將指令進行解碼成微指令
  3. 將指令分發給Back-End,每一個週期最多分發4條微指令

Front-End Bound表示處理其的Front-End 的一部分slots無法交付足夠的指令給Back-End。Front-End 做爲處理器的第一個部分其核心職責就是獲取Back-End 所需的指令。在Front-End 中由預測器預測下一個須要獲取的地址,而後從內存子系統中獲取對應的緩存行,在轉換成對應的指令,最後解碼成uOps(微指令)。Front-End Bound 意味着,會致使部分slot 即便Back-End 沒有阻塞也會被閒置。例如由於指令cache misses引發的阻塞是能夠歸類爲Front-End Bound。內存排序

3.1.4 Back-End-Bound

Back-End 的職責:

  1. 接收Front-End 提交的微指令
  2. 必要時對Front-End 提交的微指令進行重排
  3. 從內存中獲取對應的指令操做數
  4. 執行微指令、提交結果到內存

Back-End Bound 表示部分pipeline slots 由於Back-End缺乏一些必要的資源致使沒有uOps交付給Back-End。

Back-End 處理器的核心部分是經過調度器亂序地將準備好的uOps分發給對應執行單元,一旦執行完成,uOps將會根據程序的順序返回對應的結果。例如:像cache-misses 引發的阻塞(停頓)或者由於除法運算器過載引發的停頓均可以歸爲此類。此類別能夠在進行細分爲兩大類:Memory-Bound 、Core Bound。

概括總結一下就是:

Front End Bound = Bound in Instruction Fetch -> Decode (Instruction Cache, ITLB)

Back End Bound = Bound in Execute -> Commit (Example = Execute, load latency)

Bad Speculation = When pipeline incorrectly predicts execution (Example branch mispredict memory ordering nuke)

Retiring = Pipeline is retiring uops

一個微指令狀態能夠按照下圖決策樹進行歸類:

(圖片來源:intel 官方文檔)

上圖中的葉子節點,程序運行必定時間以後各個類別都會有一個pipeline slot 的佔比,只有Retiring 的纔是咱們所指望的結果,那麼每一個類別佔比應該是多少纔是合理或者說性能相對來講是比較好,沒有必要再繼續優化?intel 在實驗室裏根據不一樣的程序類型提供了一個參考的標準:

(圖片來源:intel 用戶手冊)

只有Retiring 類別是越高越好,其餘三類都是佔比越低越好。若是某一個類別佔比比較突出,那麼它就是咱們進行優化時重點關注的對象。

目前有兩個主流的性能分析工具是基於該方法論進行分析的:Intel vtune(收費並且還老貴~),另一個是開源社區的pm-tools。

有了上面的一些知識以後咱們在來看下開始的示例的各分類狀況:

雖然各項指標都在前面的參照表的範圍以內,可是隻要retiring 沒有達到100%都仍是有可優化空間的。上圖中顯然瓶頸在Back-End。

3.3 如何針對不一樣類別進行優化?

使用Vtune或者pm-tools 工具時咱們應該關注的是除了retired以外的其餘三個大分類中佔比比較高,針對這些較爲突出的進行分析優化。另外使用工具分析工程中須要關注MUX Reliability (多元分析可靠性)這個指標,它越接近1表示當前結果可靠性越高,若是低於0.7 表示當前分析結果不可靠,那麼建議加長程序運行時間以便採集足夠的數據進行分析。下面咱們來針對三大分類進行分析優化。

3.3.1 Front-End Bound

(圖片來源:intel 官方文檔)

上圖中展現了Front-End的職責即取指令(可能會根據預測提早取指令)、解碼、分發給後端pipeline, 它的性能受限於兩個方面一個是latency、bandwidth。對於latency,通常就是取指令(好比L1 ICache、iTLB未命中或解釋型編程語言python\java等)、decoding (一些特殊指令或者排隊問題)致使延遲。當Front-End 受限了,pipeline利用率就會下降,下圖非綠色部分表示slot沒有被使用,ClockTicks 1 的slot利用率只有50%。對於BandWidth 將它劃分紅了MITE,DSB和LSD三個子類,感興趣的同窗能夠經過其餘途徑瞭解下這三個子分類。

(圖片來源:intel 官方文檔)

3.3.1.1 於Front-End的優化建議:

  • 代碼儘量減小代碼的footprint7:

C/C++能夠利用編譯器的優化選項來幫助優化,好比GCC -O* 都會對footprint進行優化或者經過指定-fomit-frame-pointer也能夠達到效果;

  • 充分利用CPU硬件特性:宏融合(macro-fusion)

宏融合特性能夠將2條指令合併成一條微指令,它能提高Front-End的吞吐。  示例:像咱們一般用到的循環:

因此建議循環條件中的類型採用無符號的數據類型可使用到宏融合特性提高Front-End 吞吐量。

  • 調整代碼佈局(co-locating-hot-code):

①充分利用編譯器的PGO 特性:-fprofile-generate -fprofile-use

②能夠經過\_\_attribute\_\_ ((hot)) \_\_attribute\_\_ ((code)) 來調整代碼在內存中的佈局,hot的代碼

在解碼階段有利於CPU進行預取。

其餘優化選項,能夠參考:GCC優化選項 GCC通用屬性選項

  • 分支預測優化

① 消除分支能夠減小預測的可能性能:好比小的循環能夠展開好比循環次數小於64次(可使用GCC選項 -funroll-loops)

② 儘可能用if 代替:? ,不建議使用a=b>0? x:y 由於這個是無法作分支預測的

③ 儘量減小組合條件,使用單一條件好比:if(a||b) {}else{} 這種代碼CPU無法作分支預測的

④對於多case的switch,儘量將最可能執行的case 放在最前面

⑤ 咱們能夠根據其靜態預測算法投其所好,調整代碼佈局,知足如下條件:

前置條件,使條件分支後的的第一個代碼塊是最有可能被執行的

bool  is_expect = true;
 if(is_expect) {
    // 被執行的機率高代碼儘量放在這裏
 } else {
    // 被執行的機率低代碼儘量放在這裏
 }
後置條件,使條件分支的具備向後目標的分支不太可能的目標
 
 do {
    // 這裏的代碼儘量減小運行
 } while(conditions);

3.3.2 Back-End Bound

這一類別的優化涉及到CPU Cache的使用優化,CPU cache[14]它的存在就是爲了彌補超高速的 CPU與DRAM之間的速度差距。CPU 中存在多級cache(register\L1\L2\L3) ,另外爲了加速virtual memory address 與 physical address 之間轉換引入了TLB。

若是沒有cache,每次都到DRAM中加載指令,那這個延遲是無法接受的。

(圖片來源:intel 官方文檔)

3.3.2.1 優化建議:

  • 調整算法減小數據存儲,減小先後指令數據的依賴提升指令運行的併發度
  • 根據cache line調整數據結構的大小
  • 避免L二、L3 cache僞共享

(1)合理使用緩存行對齊

CPU的緩存是彌足珍貴的,應該儘可能的提升其使用率,日常使用過程當中可能存在一些誤區致使CPU cache有效利用率比較低。下面來看一個不適合進行緩存行對齊的例子:

#include <stdlib.h>
 #define CACHE_LINE
 
 struct S1
 {
   int r1;
   int r2;
   int r3;
   S1 ():r1 (1), r2 (2), r3 (3){}
 } CACHE_LINE;
 
 int main (int argc, char *argv[])
{
  // 與前面一致
 }

下面這個是測試效果:

作了緩存行對齊:

#include <string.h>
  #include <stdio.h>
 
  #define CACHE_LINE __attribute__((aligned(64)))
 
  struct S1 {
    int r1;
    int r2;
    int r3;
    S1(): r1(1),r2(2),r3(3){}
  } CACHE_LINE;
 
  int main(int argc,char* argv[]) {
    // 與前面一致
  }

測試結果:

經過對比兩個retiring 就知道,這種場景下沒有作cache 對齊緩存利用率高,由於在單線程中採用了緩存行致使cpu cache 利用率低,在上面的例子中緩存行利用率才3*4/64 = 18%。緩存行對齊使用原則:

  • 多個線程存在同時寫一個對象、結構體的場景(即存在僞共享的場景)
  • 對象、結構體過大的時候
  • 將高頻訪問的對象屬性儘量的放在對象、結構體首部

(2)僞共享

前面主要是緩存行誤用的場景,這裏介紹下如何利用緩存行解決SMP 體系下的僞共享(false shared)。多個CPU同時對同一個緩存行的數據進行修改,致使CPU cache的數據不一致也就是緩存失效問題。爲何僞共享只發生在多線程的場景,而多進程的場景不會有問題?這是由於linux 虛擬內存的特性,各個進程的虛擬地址空間是相互隔離的,也就是說在數據不進行緩存行對齊的狀況下,CPU執行進程1時加載的一個緩存行的數據,只會屬於進程1,而不會存在一部分是進程一、另一部分是進程2。

(上圖中不一樣型號的L2 cache 組織形式可能不一樣,有的多是每一個core 獨佔例如skylake)

僞共享之因此對性能影響很大,是由於他會致使本來能夠並行執行的操做,變成了併發執行。這是高性能服務不能接受的,因此咱們須要對齊進行優化,方法就是CPU緩存行對齊(cache line align)解決僞共享,原本就是一個以空間換取時間的方案。好比上面的代碼片斷:

#define CACHE_LINE __attribute__((aligned(64)))
 
  struct S1 {
    int r1;
    int r2;
    int r3;
    S1(): r1(1),r2(2),r3(3){}
  } CACHE_LINE;

因此對於緩存行的使用須要根據本身的實際代碼區別對待,而不是人云亦云。

3.3.3 Bad Speculation分支預測

(圖片來源:intel 官方文檔)

當Back-End 刪除了微指令,就出現Bad Speculation,這意味着Front-End 對這些指令所做的取指令、解碼都是無用功,因此爲何說開發過程當中應該儘量的避免出現分支或者應該提高分支預測準確度可以提高服務的性能。雖然CPU 有BTB記錄歷史預測狀況,可是這部分cache 是很是稀缺,它能緩存的數據很是有限。

分支預測在Font-End中用於加速CPU獲取指定的過程,而不是等到須要讀取指令的時候才從主存中讀取指令。Front-End能夠利用分支預測提早將須要預測指令加載到L2 Cache中,這樣CPU 取指令的時候延遲就極大減少了,因此這種提早加載指令時存在誤判的狀況的,因此咱們應該避免這種狀況的發生,c++經常使用的方法就是:

  • 在使用if的地方儘量使用gcc的內置分支預測特性(其餘狀況能夠參考Front-End章節)
#define likely(x) __builtin_expect(!!(x), 1) //gcc內置函數, 幫助編譯器分支優化
 #define unlikely(x) __builtin_expect(!!(x), 0)
 
 if(likely(condition)) {
   // 這裏的代碼執行的機率比較高
 }
 if(unlikely(condition)) {
  // 這裏的代碼執行的機率比較高
 }
 
 // 儘可能避免遠調用
  • 避免間接跳轉或者調用

在c++中好比switch、函數指針或者虛函數在生成彙編語言的時候均可能存在多個跳轉目標,這個也是會影響分支預測的結果,雖然BTB可改善這些可是畢竟BTB的資源是頗有限的。(intel P3的BTB 512 entry ,一些較新的CPU無法找到相關的數據)

4、寫在最後

這裏咱們再看下最開始的例子,採用上面提到的優化方法優化完以後的評測效果以下:

g++ cache\_line.cpp -o cache\_line -fomit-frame-pointer; task\_set -c 1 ./cache\_line

耗時從原來的15s 下降到如今9.8s,性能提高34%:retiring 從66.9% 提高到78.2% ;Back-End bound 從31.4%下降到21.1%

5、CPU知識充電站

[1] CPI(cycle per instruction) 平均每條指令的平均時鐘週期個數

[2] IPC (instruction per cycle) 每一個CPU週期的指令吞吐數

[3] uOps 現代處理器每一個時鐘週期至少能夠譯碼 4 條指令。譯碼過程產生不少小片的操做,被稱做微指令(micro-ops, uOps)

[4] pipeline slot pipeline slot 表示用於處理uOps 所須要的硬件資源,TMAM中假定每一個 CPU core在每一個時鐘週期中都有多個可用的流水線插槽。流水線的數量稱爲流水線寬度。

[5] MIPS(MillionInstructions Per Second)  即每秒執行百萬條指令數 MIPS= 1/(CPI×時鐘週期)= 主頻/CPI

[6]cycle 時鐘週期:cycle=1/主頻

[7] memory footprint 程序運行過程當中所須要的內存大小.包括代碼段、數據段、堆、調用棧還包括用於存儲一些隱藏的數據好比符號表、調試的數據結構、打開的文件、映射到進程空間的共享庫等。

[8] MITE Micro-instruction Translation Engine

[9]DSB Decode stream Buffer 即decoded uop cache

[10]LSD Loop Stream Detector

[11] 各個CPU維度分析

[12] TMAM理論介紹

[13] CPU Cache

[14] 微架構

做者:vivo- Li Qingxing
相關文章
相關標籤/搜索