現代處理器與代碼性能優化


現代處理器的一些特性html

現代處理器取得了了不得的功績之一,是他們採用複雜而奇異的處理器結構,其中,多條指令能夠並行的執行,同時又呈現出一種簡單的順序執行指令的表象。——《深刻理解計算機系統》程序員

在咱們直觀的認識中,處理器就是那個按着編譯好的代碼指令,不斷順序重複着取指、譯碼、執行操做的單調而可靠的機器。事實上,現代處理器對待代碼指令的處理方式,早已再也不是表面上看起來的那麼規矩,對不一樣形式的代碼,它將可能呈現不一樣的運行策略。算法

關於現代處理器的特性,本文只簡單介紹與後面代碼優化技巧有關的幾個,更多更豐富的特性介紹,建議參考資料[1],裏面有專業而詳細的描述。編程

 

1.1 超標量數組

能夠在每一個時鐘週期執行多個操做的處理器稱爲「超標量處理器」。現代處理器主要從兩個方面實現超標量處理:緩存

  1. 多個並行的功能單元。這些單元能同時執行相同或不一樣的指令,如TI的C64X+架構就配置了8個並行功能單元,分別負責乘加、邏輯、存取等操做。VLIW(Very Long Instruction Word)超長指令集被設計來給這多個功能單元進行指令分發;性能優化

  2. SSE(Streaming SIMD Extensions, 流SIMD指令擴展 ),SIMD即Single-In-struction,Multiple Data(單指令多數據)。經過擴展額外的矢量處理功能單元以及矢量寄存器等,能夠實現單個指令控制多路相同的計算,如一次作8個Byte的數據存取 ,又或是一次作8個16x16乘法。ARM處理器中的NEON協處理器就是對ARM架構的SIMD擴展。微信

 

1.2 高速緩存架構

高速緩存(cache)是一個小而快速的存儲設備,通常而言,CPU對高速緩存的訪問速度僅次於寄存器。緩存空間的大小不只與其價格高有關,更重要的是隨着存儲能量的擴大,存儲的訪問延遲將隨之增長,所以不少處理器設計了多級的緩存結構,越靠近CPU的層級其容量越小。ide

現代處理器包括獨立的I-cache(指令緩存)和D-cache(數據緩存),它們由專門的硬件邏輯來管理。簡單來講,緩存管理器在CPU第一次訪問某個低層級的存儲時(緩存缺失),會連帶把該存儲地址以後的多個指令/數據與上級緩存交互,這樣當CPU接下來想訪問下一個連續的指令/數據時,就只須要訪問緩存便可(緩存命中)。

有這樣幾個重要指標來衡量高速緩存的性能:缺失率、命中率、命中時間、缺失處罰。其中從缺失 處罰這個指標中,咱們來看看緩存對加快CPU的運算性能有多麼的重要。

缺失處罰是因爲緩存不命中所須要的額外時間開銷。對L1高速緩存來講,命中時間的數量級是幾個時鐘週期;L1缺失須要從L2獲得服務的處罰,一般是數10個週期;從L3獲得服務的處罰爲50個週期;從主存獲得服務的處罰爲200個週期!

 

1.3 分支預測、投機執行、條件傳送

分支代碼對於流水線處理而言是一個障礙,由於編譯器,包括硬件很是有可能沒法預知下一步到底將執行哪一個分支的指令,因而只好等待分支判斷結果出來後再繼續填充流水線,形成流水線中的「空泡」。

現代處理器採用了一種稱爲分支預測的技術,它會猜想是否會選擇分支,同時還預測分支的目標地址。以後,使用投機執行的技術,處理器會開始取出位於它預測的分支跳轉處的指令,並對指令譯碼,甚至在它肯定分支是否預測正確以前就開始執行這些操做。直到肯定了實際的分支路徑,若是預測正確,處理器就會「提交」投機執行的指令的結果。

當分支預測邏輯預測錯誤時,條件分支可能會招致很大的「預測錯誤懲罰」。這時,處理器必須丟掉全部投機執行的結果,在正確的位置從新開始取指令的過程,在產生有用的結果以前,必須從新填充指令流水線。

另外一種處理分支的方法是使用「條件傳送指令」。編譯器能產生使用這些指令的代碼,依據條件知足與否選擇執行或忽略指令,而不是傳統的基於控制的條件轉移。翻譯成條件傳送的基本思想是計算出一個條件表達式或語句兩個方向上的值,而後用條件傳送選擇指望的值。條件傳送指令能夠被實現爲普通指令流水線化處理的一部分,沒有必要猜想條件是否知足,所以猜想錯誤也沒有處罰。

 

1.4 亂序執行

對於單個線程而言,若是隻是順序執行指令,有時後面的指令須要依賴前面指令的執行結果,所以可能引發功能單元或流水線等待,下降了處理效率。

亂序執行是指在邏輯上在後面的指令能夠先於前面的指令執行,這更提升了硬件的執行效率,達到更高的指令級並行度。處理器採用一種「寄存器重命名」的方式實現指令亂序執行的同時,保證不影響程序最終的結果。

 

代碼優化的必要性

現代處理器具備至關的計算能力,可是咱們可能須要按很是程序化的方式來編寫程序,以便將這些能力誘發出來。——《深刻理解計算機系統》

讓咱們暫時拋開代碼架構以及代碼可讀性,只談論對整個程序運行性能影響最大的那些核心代碼段。大部分程序員更多關心的是實現代碼功能的算法,而不多注意到使用對編譯器和處理器友好的代碼。例如數組排序,咱們會想究竟是用冒泡排序,仍是插入排序,亦或是堆排序……而後樂此不疲地比較哪一種算法能夠消耗最少的算力。在工程實踐中我發現,適當調整代碼實現的技巧,每每能比選擇算法自己帶來更大的效率提高,有時這種提高是成倍甚至幾十倍的!這麼說固然不是認爲算法選擇不重要,而是想說明代碼優化一樣很是重要。

編譯器一般集成有優化器,能自動地對用戶代碼作出合適的優化。儘管有些優化器已經極盡其所能了,但人爲的優化干預依然是必要的。

一方面,調整代碼結構有風險,爲避免因優化形成的代碼錯誤,編譯器老是作保守估計。

另外一方面,因爲一般的程序自己不具備並行性,嚴重地削弱了經過超標量執行實現的指令級並行性,即便最聰明的亂序超標量處理器,同時結合聰明的和富有競爭性的編譯器,依然會受到加載延遲、cache 缺失、分支和指令之間相關等的綜合影響,使得處理器在不多的週期內充滿( 全速運行)。

鑑於上面的緣由,用戶能夠大體從兩個方面着手優化本身的代碼:

  1. 編譯器友好化。理解優化編譯器的能力和侷限性,儘可能經過代碼自己和預編譯僞指令「告訴」編譯器用戶的真實意圖;

  2. 處理器友好化。調整代碼實現方式,儘量充分地利用處理器的硬件單元。

儘管一樣的優化策略在不一樣的處理器上不必定有一樣的效果,可是操做和優化的通用原則,對各類各樣的處理器都適用。

 

簡易卻有效的優化技巧

3.1 消除沒必要要的內存引用

代碼片斷1

void array_sum(short *a, short *sum, length)

{

     unsigned int i;

     for(i=0; i<length ; i++)

     {

          *sum = *sum  + a[i];

     }

}

對於上面這段代碼,每次迭代需進行兩次讀內存操做+1次寫內存操做+1次加法。然而除了最後一次迭代時,咱們須要把計算結果寫入sum所表明的存儲地址外,中間的計算過程實際上能夠臨時保存在寄存器中。所以對代碼作以下改動:

代碼片斷2

void array_sum(short *a, short *sum, length)

{

     unsigned int i;

     short sum_temp = 0;

     for(i=0; i<length ; i++)

     {

          sum_temp = sum_temp   + a[i];

     }

     *sum = sum_temp;

}

這樣便將每次迭代的內存操做從兩次讀和一次寫減小到了只需一次讀。

試想,如此明顯的優化難道編譯器不會自動完成嗎?若是咱們仔細分析代碼,會發現假若調用函數時a和sum指向了相同的地址,以上兩段代碼將可能會獲得不一樣的兩個結果。而在沒法確認是否會出現這種存儲混疊的狀況下,編譯器將採起保守的態度!

 

3.2 多個累積變量

從新考慮代碼片斷2,由於下一次sum_temp的計算依賴於上一次sum_temp的累加結果,每一個週期最多隻能計算一個元素的累加值 。假設處理器擁有兩個並行的加法單元,則總會有一個單元是閒置的。考慮到這一點,再把代碼改寫成以下的形式:

代碼片斷3

void array_sum(short *a, short *sum, length)

{

     unsigned int i;

     short sum_temp1 = 0;

     short sum_temp2 = 0;

     for(i=0; i<length-1 ; i+=2)

     {

          sum_temp1 = sum_temp1   + a[i];

          sum_temp2 = sum_temp2   + a[i+1];

     }

     for(; i<length; i++)

     {

          sum_temp1 = sum_temp1   + a[i];

     }

     *sum = sum_temp1 + sum_temp1;

}

用兩個臨時累積變量同時累加,使得在同一個週期內處理器的兩個加法單元能同時運行,提高了指令的並行度。

 

3.4 書寫適合條件傳送實現的代碼

代碼片斷4

for (i=0; i<CORDIC_level; i++)

{

            if (y_coord < 0) 

            {

                x_coord = x_coord - (y_coord >> i); 

                y_coord = y_coord + (x_coord >> i); 

                angle_accumulate = angle_accumulate - angleLUT[i];

            }

            else 

            {

                x_coord = x_coord + (y_coord >> i); 

                y_coord = y_coord - (x_coord >> i); 

                angle_accumulate = angle_accumulate + angleLUT[i];

            }

}

如上代碼段4所示,循環內包含了分支判斷語句,使得編譯器難以對循環體進行流水編排。另外,因爲分支預測只對有規律的模式可行,上述y_coord < 0 條件的判斷幾乎沒法預測,所以分支預測將會處理得很糟糕。

若是編譯器可以產生使用條件數據傳送而不是使用條件控制轉移的代碼,能夠極大提升程序的性能。有些表達條件行爲的方法可以直接地被翻譯爲條件傳送,避免了須要處理器進行分支預測的可能。

把代碼片斷4改成以下的風格,並經過檢查產生的彙編代碼,確認其確實生成了使用條件傳送的代碼:

代碼片斷5

int x_temp, y_temp;

for (i=0; i<CORDIC_level; i++)

{

     x_temp = x_coord >> i;

     y_temp = y_coord >> i;

     x_coord = (y_coord < 0)?  (x_coord - y_temp ) :  (x_coord + y_temp); 

     y_coord = (y_coord < 0)?  (y_coord + x_temp ) :  (y_coord - x_temp); 

     angle_accumulate = (y_coord < 0)? (angle_accumulate - angleLUT[i]) : (angle_accumulate + angleLUT[i]);

}

 

3.4 緩存友好型代碼

在本公衆號的另外一篇文章《計算機系統中與存儲有關的那些事》中,已經介紹了存儲訪問的時間局部性和空間局部性,並給出了編寫局部性好的代碼示例。

這裏再討論一個容易被忽視的問題,它出如今個人實際項目調試過程當中。有一個函數,不考慮存儲訪問的仿真結果顯示,該函數完整運行大概耗時100us,但實際運行卻發現該函數消耗了700us左右。由於仿真沒有考慮內存訪問的延遲,因此咱們允許實際運行結果會比仿真結果稍多一些,但700us相比於100us足足大了7倍,這就有點異常了。

通過一番排查,最終發現問題出在一條變量初始化語句上。一個全局數組short a[1920*8],在函數開頭對它進行初始化處理:

memset(a, 0, sizeof(a));

然而就這一條語句就消耗了500多個us!過後對代碼功能進行分析,發現經過一些調整是能夠徹底避免對該變量進行初始化的。特別是像這樣大的數組,局部性再好其緩存缺失次數也將很大,並且會形成緩存被大片刷新。

一般一些好的編程習慣,可能會致使性能的惡化,好比數據塊的初始化,在代碼中常常能夠看到malloc後立刻memset,而後再對數據塊賦值,若是操做的內存塊很大,對性能影響很明顯。所以,變量初始化是一個好的編程習慣,但若是跟性能衝突儘量避免這樣的操做或者只對關鍵的數據進行初始化,避免大塊數據的操做。

 

程序性能剖析

4.1 確認性能瓶頸

在處理大程序時,要明確地知道應該優化什麼地方都是很難的。此時能夠藉助代碼剖析工具(code profiler),在程序執行時收集每一個函數的調用次數和所花費的時間等參數,經過打印的剖析報告就能得出函數的耗時分佈狀況。

Amdahl定律能夠用於分析程序中某部分性能的提高最終能給程序的總體性能帶來多大的影響。

Amdahl定律指出,設原程序執行時間爲Told,其某部分代碼所需執行時間佔該時間的比例爲a,而該部分性能提高的比例爲b,則整個程序的加速比爲:

Told/Tnew = 1/[(1-a) + a/b]

 

4.2 程序的最大性能

在對代碼進行優化後,經過仿真或者實際運行,能夠測試優化的效果。然而這終究只是一個相對的比較,若是能創建一種評估辦法,首先確立一個性能指數的邊界(就像參數估計中的克拉美羅界同樣),而後經過測試所寫代碼的該項性能指數,不就能得出代碼優化的絕對程度,以及預知還存在多大優化空間嗎?

《深刻理解計算機系統》這本書中,做者就給咱們提供了這樣的一套評估辦法。書中,做者以每元素的週期數(CPE)做爲統計指數,以延遲界限和吞吐量界限兩項來描述程序的最大性能。

CPE指數只是針對循環代碼而言的(幾乎能夠說代碼性能優化就是對循環的優化),它指處理數據的每一個元素所消耗的週期數。之因此使用每一個元素的週期數而不是每一個循環的週期數來度量,是由於循環次數可能隨循環展開的程度不一樣而變化,而咱們最終關心的是,對於給定的向量長度,程序運行的速度如何。

延遲界限描述的是,當一系列操做必須按照嚴格的順序執行時,處理每一個元素所歷經的關鍵路徑(最長路徑)的週期數。當數據相關問題限制了指令級並行的能力時,延遲界限可以限制程序性能。

吞吐量界限描述的是,處理器功能單元全力運行時的原始計算能力。好比處理器具備兩個能同時作乘法的單元,對於只有1次乘/元素的循環而言,此時的吞吐量界限就是0.5。吞吐量界限是程序性能的終極界限。

 

參考資料

【1】Modern Microprocessors:A 90-Minute Guide!

【2】BRYANT R E, O’HALLARON D R. Computer Systems: A Programmer’s Perspective[M]. 3 edition. Boston: Pearson, 2015.(譯名:深刻理解計算機系統)

【3】C\C++代碼優化的27個建議--伯樂在線.

【4】程序性能優化(1、2、三)--堅持的博客園.

 

 

·END·

 

歡迎來個人微信公衆號作客:信號君

專一於信號處理知識、高性能計算、現代處理器&計算機體系 

 

技術成長 | 讀書筆記 | 認知升級

幸會~

相關文章
相關標籤/搜索