此文已由做者餘笑天受權網易雲社區發佈。
html
歡迎訪問網易雲社區,瞭解更多網易技術產品運營經驗。nginx
本文主要是基於我以前學習《深刻理解計算機系統》(如下簡稱CSAPP)這本書第五章優化程序性能內容的回顧以及總結。主要內容並無從大而全的方面去闡述如何優化程序,而是從一些細節着手來看待優化代碼質量這個大問題。因爲我以前接觸C/C++程序較多,所以示例代碼都是用C++編寫,可是我認爲不管是什麼語言,一些基本的優化原則是相通的。算法
1.程序優化原則django
在CSAPP做者看來性能好的程序要有如下幾種特色:數組
(1)合適的數據結構和算法,都說程序=算法+數據結構,所以這兩方面的優化是程序優化的基石。緩存
(2)儘可能的寫出編譯器能夠有效優化的代碼,現代編譯器都會對源代碼進行優化,以提升程序的性能。好比Linux下的GCC編譯器就能控制優化的等級,優化等級高,對應的程序性能好。若是你的程序編譯器並不能肯定是否能進行安全優化,那麼對於一些的成熟的編譯器而言,它並不會採用一些激進的優化方式,這部份內容在優化安全性會有具體介紹。安全
(3)對於處理運算量特別大的計算,能夠將一個任務拆分爲多個任務。甚至能夠考慮到在多核和對處理器上進行並行計算,這部份內容在CSAPP中的12章會有詳細敘述。bash
(4)在實現和維護代碼的簡單性和運行速度之間作出權衡,好比調用系統的排序算法能夠知足平常大部分的排序需求,可是進行特殊的優化可能要針對排序的數據進行分析而後對應修改排序算法,這個過程耗費的時間和最後的優化結果以及優化後可能帶來的可讀性、模塊性的下降須要做出權衡。數據結構
1.1優化的安全性數據結構和算法
對於C/C++程序,大多數的編譯器會指定優化級別,以GCC爲例子:gcc -o指令就能夠設置優化級別:
-o0:關閉全部優化
-o1:最基本的優化級別,編譯器試圖以較少的時間生成更快以及體積更小的代碼。
-o2:推薦的優化級別,o1的進階。
-o3:較危險的優化等級,這個等級會延長編譯時間,編譯後會產生更大的二進制文件,會帶來一些沒法預知的問題。
-os:優化代碼體積,一般適用於磁盤空間緊張或者CPU緩存較小的機器。
所謂優化的安全性,咱們不妨看如下一個栗子:
能夠看出看上去以上兩個函數實現的功能是一致的,都是將yp所指向的int值的兩倍加到xp所指向的值。可是f2的性能要比f1更好一些,由於f2有3次引用,f1有6次引用(2次讀xp,2次讀yp,2次寫xp)。咱們指望編譯器會幫咱們進行以上優化,可是成熟的編譯器不會這麼作的,這是由於該程序存在內存別名使用(memory aliasing)的問題。就是說xp,yp可能指向同一位置:
能夠看出當出現以上狀況時,兩個函數的行爲並不一致,這類程序的編寫就成爲了編譯器優化它的阻礙因素,對應到優化原則的第二條。
其次函數調用一樣會阻礙編譯器的優化,編譯器是不會對函數內容做出假設,所以針對函數調用,編譯器通常不會貿然進行優化,一樣能夠舉出一個栗子:
能夠看出f1調用了f()兩次,而f2()只調用了一次,函數的調用涉及到棧幀的操做這須要消耗一些系統資源,所以按理來講f2()的性能優於f1(),可是編譯器針對這種狀況一樣不會進行優化,考慮到如下代碼:
一樣能夠看出在這種狀況下,兩個函數行爲一樣會不一致。
2消除低效的循環
咱們編寫了一個循環累加的程序來測試在不一樣循環下,程序性能的開銷,首先定義了這樣一個數據結構:
typedef struct { long int len;
data_t *data;
}vec_rec, *vec_ptr;複製代碼
vec_rec表示爲data_t的數組,data_t表示爲自定義的數據類型,len爲該數組的長度。
原書中針對date_t進行了兩種定義分別是:整數以及浮點數,並對各自的類型進行加法和乘法的操做,分別統計各自的性能狀況,於此同時還定義了性能衡量標準CPE即每元素時鐘週期,舉個栗子:計算一個數組中全部元素之和,分別統計數組元素個數不一樣的狀況下該程序所用的時鐘週期,而後得出每加入一個元素平均多耗費的時鐘週期,這個值就是CPE。下面是該書的做者統計的CPE值,這部分因爲本人並無作實驗,所以只貼出做者的結果以供參考:
能夠看出目前的CPU對於浮點操做的優化使其性能接近甚至略好於對整數的操做,同時對於程序至少進行o1級別的優化一樣是有必要的。
下面貼出具體的循環調用代碼:
#include"stdlib.h"#include"time.h"#include#ifndef _CLOCK_T_DEFINED
#define _CLOCK_T_DEFINED
#endif
typedef long clock_t;
using namespace std;
typedef int data_t;
typedef struct
{ long int len;
data_t *data;
}vec_rec, *vec_ptr;vec_ptr new_vec(long len){
vec_ptr res = (vec_ptr)malloc(sizeof(vec_rec));
data_t *data = NULL; if (!res) return NULL;
res->len = len; if (len > 0)
{
data = (data_t *)calloc(len, sizeof(data_t)); if (!data)
{
free((void*)res); return NULL;
}
}
res->data = data; return res;
}long vec_length(vec_ptr v){ return v->len;
}int get_vec_element(vec_ptr v,long index, data_t *dest){ if (index < 0 || index >= v->len) return 0;
*dest = v->data[index]; return 1;
}void combine1(vec_ptr v, data_t *dest) { long i;
*dest = 0; for (i = 0; i < vec_length(v); ++i)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}複製代碼
該程序分別依次取數組元素的值而後加到dest所指的位置中去,這是通常的循環累加的寫法,能夠看到每次迭代求值都會對測試條件進行求值操做,另外一方面針對這種狀況,數組的長度並不會隨着循環而更改,所以咱們定義了combine2以下:
void combine2(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
*dest = 0; for (i = 0; i < len; ++i)
{
data_t val;
get_vec_element(v, i, &val);
*dest = *dest + val;
}
}複製代碼
爲了對比性能,我作了如下實驗:
int main()
{
vec_ptr vec = new_vec(100000000); int* tmp = new int[100000000];
vec->data = (int *)tmp; int res = 0;
clock_t start, finish; double totaltime;
start = clock();
combine1(vec, &res);
finish = clock();
totaltime = (double)(finish - start) / CLOCKS_PER_SEC; cout << "\n此程序的運行時間爲" << totaltime << "秒!" << endl;
start = clock();
combine2(vec, &res);
finish = clock();
totaltime = (double)(finish - start) / CLOCKS_PER_SEC; cout << "\n此程序的運行時間爲" << totaltime << "秒!" << endl;
system("pause");
}複製代碼
獲得以下結果:
3減小過程調用
一個函數的調用基本過程大體以下:
一、調用者函數把被調函數所須要的參數按照與被調函數的形參順序相反的順序壓入棧中
二、調用者函數使用call指令調用被調函數,並把call指令的下一條指令的地址當成返回地址壓入棧中
三、在被調函數中,被調函數會先保存調用者函數的棧底地址(push ebp),而後再保存調用者函數的棧頂地址
四、在被調函數中,從ebp的位置處開始存放被調函數中的局部變量和臨時變量,而且這些變量的地址按照定義時的順序依次減少
能夠看出在函數調用過程當中,須要作一些壓棧出棧操做,同時須要一些寄存器幫助保存和恢復環境,這些都將帶來系統開銷。所以減小一些函數調用將會提升程序性能。以上面的程序爲例,能夠看到combine函數在循環中調用了get_vec_element操做,這部分操做能夠移到循環內部而沒必要調用函數,具體作法以下:
增長get_vec_start函數獲取數組起始位置:
data_t *get_vec_start(vec_ptr v)
{ return v->data;
}複製代碼
修改combine函數:
void combine3(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
data_t *data = get_vec_start(v);
*dest = 0; for (i = 0; i < len; ++i)
{
*dest = *dest + data[i];
}
}複製代碼
修改後的程序性能對好比下:
4消除沒必要要的引用
combine3將計算後的值累加在dest指針後,一下貼出段代碼的彙編結果:
從這段代碼能夠看出dest指針放在寄存器rax中,每次迭代,data指針加1。每次迭代後。累積的數值從內存中讀出再寫入到內存中,這樣頻繁的讀寫內存將會影響程序的性能。
這類頻繁的內存讀寫是能夠避免的,能夠引入一個臨時變量存儲*dest的值,循環中只取變量的值,直至循環結束將結果寫到dest指針所指的位置中。代碼以下:
void combine4(vec_ptr v, data_t *dest){ long i; long len = vec_length(v);
data_t *data = get_vec_start(v); long acc = 0; //*dest = 0;
for (i = 0; i < len; ++i)
{
acc = acc + data[i];
}
*dest = acc;
}複製代碼
這段代碼的彙編結果以下:
能夠看出該部分彙編代碼用rax保存累計值沒有涉及到取內存的操做,所以在循環中的內存操做變成只有取data數組這一次。
如下貼出結果對比:
能夠看出combine4在以前的基礎上性能又稍有提升。
5循環展開
循環展開是一種程序變換,經過增長每次循環的計算量,減小循環次數從而改進程序性能。循環展開對程序性能的影響有兩點,其一是它減小了循環中的輔助計算量例如循環索引和條件分支(該書5.7節詳細介紹了條件分支對性能的影響)。第二它減小了關鍵路徑的操做數量。下面給出循環展開的一個版本:
void combine5(vec_ptr v, data_t *dest){ long i=0; long len = vec_length(v); long limit = len - 1;
data_t *data = get_vec_start(v);
data_t acc = 0; for (int i = 0; i < limit; i += 3)
{
acc = (acc + data[i]) + data[i + 1];
} if (i < len)
{
acc = acc + data[i];
}
*dest = acc;
}複製代碼
下面是循環展開後的程序性能:
該版本的循環展開將原有的循環次數減小了一半,延續這個思想,可將循環按任意因子k展開,下面是做者將改程序循環展開後屢次後性能表現狀況:
能夠看出對於該優化不會超過延遲界限值,查看循環展開操做的彙編代碼:
能夠看到該操做會致使兩條vmulsd操做,一條將data[i]加到acc上,第二條將data[i+1]加到acc上。每條vmulsd被翻譯成兩個操做:一個操做是從內存中加載一個數組元素,另外一個是把這個值乘以已有的累計值。能夠看到,循環的每次執行中,對寄存器%xmm0讀和寫兩次。從中能夠看到,迭代的次數減半了,可是每次迭代中仍是有兩個順序的乘法操做。這個關鍵路徑是循環沒有展開代碼的性能制約因素。具體彙編代碼過程圖示以下:
至此,完成了該程序的初步優化,關於循環展開部分,該書第五章後半段有進階的內容,有興趣的同窗能夠一塊兒學習交流。
更多網易技術、產品、運營經驗分享請點擊。
相關文章:
【推薦】 django項目在uwsgi+nginx上部署遇到的坑
【推薦】 Memcached Hash算法
【推薦】 3分鐘掌握一個有數小技能:回頭客分析