1. 程式人生 > 程式設計 >值得收藏的9個提高程式碼執行效率的小技巧(推薦)

值得收藏的9個提高程式碼執行效率的小技巧(推薦)

我們寫程式的目的就是使它在任何情況下都可以穩定工作。一個執行的很快但是結果錯誤的程式並沒有任何用處。在程式開發和優化的過程中,我們必須考慮程式碼使用的方式,以及影響它的關鍵因素。通常,我們必須在程式的簡潔性與它的執行速度之間做出權衡。今天我們就來聊一聊如何優化程式的效能。

1. 減小程式計算量

1.1 示例程式碼

for (i = 0; i < n; i++) {
  int ni = n*i;
  for (j = 0; j < n; j++)
    a[ni + j] = b[j];
}

1.2 分析程式碼

  程式碼如上所示,外迴圈每執行一次,我們要進行一次乘法計算。i = 0,ni = 0;i = 1,ni = n;i = 2,ni = 2n。因此,我們可以把乘法換成加法,以n為步長,這樣就減小了外迴圈的程式碼量。

1.3 改進程式碼

int ni = 0;
for (i = 0; i < n; i++) {
  for (j = 0; j < n; j++)
    a[ni + j] = b[j];
  ni += n;         //乘法改加法
}

計算機中加法指令要比乘法指令快得多。

2. 提取程式碼中的公共部分

2.1 示例程式碼

  想象一下,我們有一個影象,我們把影象表示為二維陣列,陣列元素代表畫素點。我們想要得到給定畫素的東、南、西、北四個鄰居的總和。並求他們的平均值或他們的和。程式碼如下所示。

up =    val[(i-1)*n + j  ];
down =  val[(i+1)*n + j  ];
left =  val[i*n     + j-1];
right = val[i*n     + j+1];
sum = up + down + left + right;

2.2 分析程式碼

  將以上程式碼編譯後得到彙編程式碼如下所示,注意下3,4,5行,有三個乘以n的乘法運算。我們把上面的up和down展開後會發現四格表示式中都有i*n + j。因此,可以提取出公共部分,再通過加減運算分別得出up、down等的值。

leaq   1(%rsi),%rax  # i+1
leaq   -1(%rsi),%r8  # i-1
imulq  %rcx,%rsi     # i*n
imulq  %rcx,%rax     # (i+1)*n
imulq  %rcx,%r8      # (i-1)*n
addq   %rdx,%rsi     # i*n+j
addq   %rdx,%rax     # (i+1)*n+j
addq   %rdx,%r8      # (i-1)*n+j

2.3 改進程式碼

long inj = i*n + j;
up =    val[inj - n];
down =  val[inj + n];
left =  val[inj - 1];
right = val[inj + 1];
sum = up + down + left + right;

  改進後的程式碼的彙編如下所示。編譯後只有一個乘法。減少了6個時鐘週期(一個乘法週期大約為3個時鐘週期)。

imulq	%rcx,%rsi  # i*n
addq	%rdx,%rsi  # i*n+j
movq	%rsi,%rax  # i*n+j
subq	%rcx,%rax  # i*n+j-n
leaq	(%rsi,%rcx),%rcx # i*n+j+n
...

  對於GCC編譯器來說,編譯器可以根據不同的優化等級,有不同的優化方式,會自動完成以上的優化操作。下面我們介紹下,那些必須是我們要手動優化的。

3. 消除迴圈中低效程式碼

3.1 示例程式碼

  程式看起來沒什麼問題,一個很平常的大小寫轉換的程式碼,但是為什麼隨著字串輸入長度的變長,程式碼的執行時間會呈指數式增長呢?

void lower1(char *s)
{
  size_t i;
  for (i = 0; i < strlen(s); i++)
    if (s[i] >= 'A' && s[i] <= 'Z')
      s[i] -= ('A' - 'a');
}

3.2 分析程式碼

  那麼我們就測試下程式碼,輸入一系列字串。

lower1程式碼效能測試

  當輸入字串長度低於100000時,程式執行時間差別不大。但是,隨著字串長度的增加,程式的執行時間呈指數時增長。

  我們把程式碼轉換成goto形式看下。

void lower1(char *s)
{
   size_t i = 0;
   if (i >= strlen(s))
     goto done;
 loop:
   if (s[i] >= 'A' && s[i] <= 'Z')
       s[i] -= ('A' - 'a');
   i++;
   if (i < strlen(s))
     goto loop;
 done:
}

  以上程式碼分為初始化(第3行),測試(第4行),更新(第9,10行)三部分。初始化只會執行一次。但是測試和更新每次都會執行。每進行一次迴圈,都會對strlen呼叫一次。

  下面我們看下strlen函式的原始碼是如何計算字串長度的。

size_t strlen(const char *s)
{
    size_t length = 0;
    while (*s != '\0') {
	s++; 
	length++;
    }
    return length;
}

&#www.cppcns.com8195; strlen函式計算字串長度的原理為:遍歷字串,直到遇到‘\0'才會停止。因此,strlen函式的時間複雜度為O(N)。lower1中,對於長度為N的字串來說,strlen 的呼叫次數為N,N-1,N-2 … 1。對於一個線性時間的函式呼叫N次,其時間複雜度接近於O(N2)。

3.3 改進程式碼

  對於迴圈中出現的這種冗餘呼叫,我們可以將其移動到迴圈外。將計算結果用於迴圈中。改進後的程式碼如下所示。

void lower2(char *s)
{
  size_t i;
  size_t len = strlen(s);
  for (i = 0; i < len; i++)
    if (s[i] >= 'A' && s[i] <= 'Z')
      s[i] -= ('A' - 'a');
}

  將兩個函式對比下,如下圖所示。lower2函式的執行時間得到明顯提升。

lower1和lower2程式碼效率

4. 消除不必要的記憶體引用

4.1 示例程式碼

  以下程式碼作用為,計算a陣列中每一行所有元素的和存在b[i]中。

void sum_rows1(double *a,double *b,long n) {
    long i,j;
    for (i = 0; i < n; i++) {
	b[i] = 0;
	for (j = 0; j < n; j++)
	    b[i] += a[i*n + j];
    }
}

4.2 分析程式碼

  彙編程式碼如下所示。

# sum_rows1 inner loop
.LaNKXMXas4:
        movsd   (%rsi,%rax,8),%xmm0	# 從記憶體中讀取某個值放到%xmm0
        addsd   (%rdi),%xmm0		    # %xmm0 加上某個值
        movsd   %xmm0,(%rsi,8)	# %xmm0 的值寫回記憶體,其實就是b[i]
        addq    $8,%rdi
        cmpq    %rcx,%rdi
        jne     .L4

  這意味著每次迴圈都需要從記憶體中讀取b[i],然後再把b[i]寫回記憶體 。 b[i] += b[i] + a[i*n + j]; 其實每次迴圈開始的時候,b[i]就是上一次的值。為什麼每次都要從記憶體中讀取出來再寫回呢?

4.3 改進程式碼

/* Sum rows is of n X n matrix a
   and store in vector b  */
void sum_rows2(double *a,j;
    for (i = 0; i < n; i++) {
	double val = 0;
	for (j = 0; j < n; j++)
	    val += a[i*n + j];
         b[i] = val;
    }
}

  彙編如下所示。

# sum_rows2 inner loop
.L10:
        addsd   (%rdi),%xmm0	# FP load + add
        addq    $8,%rdi
        cmpq    %rax,%rdi
        jne     .L10

  改進後的程式碼引入了臨時變數來儲存中間結果,只有在最後的值計算出來時,才將結果存放到陣列或全域性變數中。

5. 減小不必要的呼叫

5.1 示例程式碼

  為了方便舉例,我們定義一個包含陣列和陣列長度的結構體,主要是為了防止陣列訪問越界,data_t可以是int,long等型別。具體如下所示。

typedef struct{
	size_t len;
	data_t *data;  
} vec;

值得收藏的9個提高程式碼執行效率的小技巧(推薦)

  get_vec_element函式的作用是遍歷data陣列中元素並存儲在val中。

int get_vec_element (*vec v,size_t idxaNKXMXas,data_t *val)
{
	if (idx >= v->len)
		return 0;
	*val = v->data[idx];
	return 1;
}

  我們將以以下程式碼http://www.cppcns.com為例開始一步步優化程式。

void combine1(vec_ptr v,data_t *dest)
{
    long int i;
    *dest = NULL;
    for (i = 0; i < vec_length(v); i++) {
	data_t val;
	get_vec_element(v,i,&val);
	*dest = *dest * val;
    }
}

5.2 分析程式碼

  get_vec_element函式的作用是獲取下一個元素,在get_vec_element函式中,每次迴圈都要與v->len作比較,防止越界。進行邊界檢查是個好習慣,但是每次都進行就會造成效率降低。

5.3 改進程式碼

  我們可以把求向量長度的程式碼移到迴圈體外,同時抽象資料型別增加一個函式get_vec_start。這個函式返回陣列的起始地址。這樣在迴圈體中就沒有了函式呼叫,而是直接訪問陣列。

data_t *get_vec_start(vec_ptr v)
{
	return v->data;
}

void combine2 (vec_ptr v,data_t *dest)
{
	long i;
	long length  = vec_length(v);
    data_t *data = get_vec_start(v);
	*dest = NULL;
	for (i=0;i < length;i++)
	{
		*dest = *dest * data[i];
	}
}

6. 迴圈展開

6.1 示例程式碼

  我們在combine2的程式碼上進行改進。

6.2 分析程式碼

  迴圈展開是通過增加每次迭代計算的元素的數量減少迴圈的迭代次數

6.3 改進程式碼

void combine3(vec_ptr v,data_t *dest)
{
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = NULL;
    
    /* 一次迴圈處理兩個元素 */
    for (i = 0; i < limit; i+=2) {
		acc = (acc * data[i]) * data[i+1];
    }
    /*     完成剩餘陣列元素的計算    */
    for (; i < length; i++) {
		acc = acc * data[i];
    }
    *dest = acc;
}

  在改進後的程式碼中,第一個迴圈每次處理陣列的兩個元素。也就是每次迭代,迴圈索引i加2,在一次迭代中,對陣列元素i和i+1使用合併運算。一般我們稱這種為21迴圈展開,這種變換能減小迴圈開銷的影響。

注意訪問不要越界,正確設定limit,n個元素,一般設定界限n-1

7. 累計變數,多路並行

7.1 示例程式碼

  我們在combine3的程式碼上進行改進。

7.2 分析程式碼

  對於一個可結合和可交換的合併運算來說,比如說整數加法或乘法,我們可以通過將一組合並運算分割成兩個或更多的部分,並在最後合併結果來提高效能。

特別注意:不要輕易對浮點數進行結合。浮點數的編碼格式和其他整型數等都不一樣。

7.3 改進程式碼

void combine4(vec_ptr v,data_t *dest)
{
	long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc0 = 0;
    data_t acc1 = 0;
    
    /* 迴圈展開,並維護兩個累計變數 */
    for (i = 0; i < limit; i+=2) {
		acc0 = acc0 * data[i];
		acc1 = acc1 * data[i+1];
    }
    /*     完成剩餘陣列元素的計算    */
    for (; i < length; i++) {
        acc0 = acc0 * data[i];
    }
    *dest = acc0 * acc1;
}

  上述程式碼用了兩次迴圈展開,以使每次迭代合併更多的元素,也使用了兩路並行,將索引值為偶數的元素累積在變數acc0中,而索引值為奇數的元素累積在變數acc1中。因此,我們將其稱為”22迴圈展開”。運用22迴圈展開。通過維護多個累積變數,這種方法利用了多個功能單元以及它們的流水線能力

8. 重新結合變換

8.1 示例程式碼

  我們在combine3的程式碼上進行改進。

8.2 分析程式碼

  到這裡其實程式碼的效能已經基本接近極限了,就算做再多的迴圈展開效能提升已經不明顯了。我們需要換個思路,注意下combine3程式碼中第12行的程式碼,我們可以改變下向量元素合併的順序(浮點數不適用)。重新結合前combine3程式碼的關鍵路徑如下圖所示。

image-20201224200707316

8.3 改進程式碼

void combine7(vec_ptr v,data_t *dest)
{
	long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    
    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
		acc = acc * (data[i] * data[i+1]);
    }
    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc * data[i];
    }
    *dest = acc;
}

  重新結合變換能夠減少計算中關鍵路徑上操程式設計客棧作的數量,這種方法增加了可以並行執行的運算元量了,更好地利用功能單元的流水線能力得到更好的效能。重新結合後關鍵路徑如下所示。

combine3重新結合後關鍵路徑

9 條件傳送風格的程式碼

9.1 示例程式碼

void minmax1(long a[],long b[],long n){
	long i;
	for(i = 0;i,n;i++){
        if(a[i]>b[i]){
            long t = a[i];
            a[i] = b[i];
            b[i] = t;
        }
   }
}

9.2 分析程式碼

  現代處理器的流水線效能使得處理器的工作遠遠超前於當前正在執行的指令。處理器中的分支預測在遇到比較指令時會進行預測下一步跳轉到哪裡。如果預測錯誤,就要重新回到分支跳轉的原地。分支預測錯誤會嚴重影響程式的執行效率。因此,我們應該編寫讓處理器預測準確率提高的程式碼,即使用條件傳送指令。我們用條件操作來計算值,然後用這些值來更新程式狀態,具體如改進後的程式碼所示。

9.3 改進程式碼

void minmax2(long a[],n;i++){
	long min = a[i] < b[i] ? a[i]:b[i];
	long max = a[i] < b[i] ? b[i]:a[i];
	a[i] = min;
	b[i] = max;
	}
}

  在原始碼的第4行中,需要對a[i]和b[i]進行比較,再進行下一步操作,這樣的後果是每次都要進行預測。改進後的程式碼實現這個函式是計算每個位置i的最大值和最小值,然後將這些值分別賦給a[i]和b[i],而不是進行分支預測。

10. 總結

  我們介紹了幾種提高程式碼效率的技巧,有些是編譯器可以自動優化的,有些是需要我們自己實現的。現總結如下。

消除連續的函式呼叫。在可能時,將計算移到迴圈外。考慮有選擇地妥協程式的模組性以獲得更大的效率。

消除不必要的記憶體引用。引入臨時變數來儲存中間結果。只有在最後的值計算出來時,才將結果存放到陣列或全域性變數中。

展開迴圈,降低開銷,並且使得進一步的優化成為可能。

通過使用例如多個累積變數和重新結合等技術,找到方法 提高指令級並行。

用功能性的風格重寫條件操作,使得編譯採用條件資料傳送。

到此這篇關於值得收藏的9個提高程式碼執行效率的小技巧(推薦)的文章就介紹到這了,更多相關提高程式碼執行效率內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!