C 語言高效程式設計與程式碼優化

2016-12-23    分類:C/C++開發、程式設計開發、首頁精華0人評論發表於2016-12-23

本文由碼農網 – gunner原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

在本篇文章中,我收集了很多經驗和方法。應用這些經驗和方法,可以幫助我們從執行速度和記憶體使用等方面來優化C語言程式碼。

簡介

在最近的一個專案中,我們需要開發一個執行在移動裝置上但不保證影像高質量的輕量級JPEG庫。期間,我總結了一些讓程式執行更快的方法。在本篇文章中,我收集了一些經驗和方法。應用這些經驗和方法,可以幫助我們從執行速度和記憶體使用等方面來優化C語言程式碼。

儘管在C程式碼優化方面有很多的指南,但是關於編譯和你使用的程式設計機器方面的優化知識卻很少。

通常,為了讓你的程式執行的更快,程式的程式碼量可能需要增加。程式碼量的增加又可能會對程式的複雜度和可讀性帶來不利的影響。這對於在手機、PDA等對於記憶體使用有很多限制的小型裝置上編寫程式時是不被允許的。因此,在程式碼優化時,我們的座右銘應該是確保記憶體使用和執行速度兩方面都得到優化。

宣告

實際上,在我的專案中,我使用了很多優化ARM程式設計的方法(該專案是基於ARM平臺的),也使用了很多網際網路上面的方法。但並不是所有文章提到的方法都能起到很好的作用。所以,我對有用的和高效的方法進行了總結收集。同時,我還修改了其中的一些方法,使他們適用於所有的程式設計環境,而不是侷限於ARM環境。

哪裡需要使用這些方法?

沒有這一點,所有的討論都無從談起。程式優化最重要的就是找出待優化的地方,也就是找出程式的哪些部分或者哪些模組執行緩慢亦或消耗大量的記憶體。只有程式的各部分經過了優化,程式才能執行的更快。

程式中執行最多的部分,特別是那些被程式內部迴圈重複呼叫的方法最該被優化。

對於一個有經驗的碼農,發現程式中最需要被優化的部分往往很簡單。此外,還有很多工具可以幫助我們找出需要優化的部分。我使用過Visual C++內建的效能工具profiler來找出程式中消耗最多記憶體的地方。另一個我使用過的工具是英特爾的Vtune,它也能很好的檢測出程式中執行最慢的部分。根據我的經驗,內部或巢狀迴圈,呼叫第三方庫的方法通常是導致程式執行緩慢的最主要的起因。

整形數

如果我們確定整數非負,就應該使用unsigned int而不是int。有些處理器處理無符號unsigned 整形數的效率遠遠高於有符號signed整形數(這是一種很好的做法,也有利於程式碼具體型別的自解釋)。

因此,在一個緊密迴圈中,宣告一個int整形變數的最好方法是:

register unsigned int variable_name;

記住,整形in的運算速度高浮點型float,並且可以被處理器直接完成運算,而不需要藉助於FPU(浮點運算單元)或者浮點型運算庫。儘管這不保證編譯器一定會使用到暫存器儲存變數,也不能保證處理器處理能更高效處理unsigned整型,但這對於所有的編譯器是通用的。

例如在一個計算包中,如果需要結果精確到小數點後兩位,我們可以將其乘以100,然後儘可能晚的把它轉換為浮點型數字。

除法和取餘數

在標準處理器中,對於分子和分母,一個32位的除法需要使用20至140次迴圈操作。除法函式消耗的時間包括一個常量時間加上每一位除法消耗的時間。

Time (numerator / denominator) = C0 + C1* log2 (numerator / denominator)
     = C0 + C1 * (log2 (numerator) - log2 (denominator)).

對於ARM處理器,這個版本需要20+4.3N次迴圈。這是一個消耗很大的操作,應該儘可能的避免執行。有時,可以通過乘法表示式來替代除法。例如,假如我們知道b是正數並且b*c是個整數,那麼(a/b)>c可以改寫為a>(c*b)。如果確定運算元是無符號unsigned的,使用無符號unsigned除法更好一些,因為它比有符號signed除法效率高。

合併除法和取餘數

在一些場景中,同時需要除法(x/y)和取餘數(x%y)操作。這種情況下,編譯器可以通過呼叫一次除法操作返回除法的結果和餘數。如果既需要除法的結果又需要餘數,我們可以將它們寫在一起,如下所示:

int func_div_and_mod (int a, int b) { 
        return (a / b) + (a % b);
    }

通過2的冪次進行除法和取餘數

如果除法中的除數是2的冪次,我們可以更好的優化除法。編譯器使用移位操作來執行除法。因此,我們需要儘可能的設定除數為2的冪次(例如64而不是66)。並且依然記住,無符號unsigned整數除法執行效率高於有符號signed整形出發。

typedef unsigned int uint;

    uint div32u (uint a) {
     return a / 32;
    }
    int div32s (int a){
     return a / 32;
    }

上面兩種除法都避免直接呼叫除法函式,並且無符號unsigned的除法使用更少的計算機指令。由於需要移位到0和負數,有符號signed的除法需要更多的時間執行。

取模的一種替代方法

我們使用取餘數操作符來提供算數取模。但有時可以結合使用if語句進行取模操作。考慮如下兩個例子:

uint modulo_func1 (uint count)
{
   return (++count % 60);
}

uint modulo_func2 (uint count)
{
   if (++count >= 60)
  count = 0;
  return (count);
}

優先使用if語句,而不是取餘數運算子,因為if語句的執行速度更快。這裡注意新版本函式只有在我們知道輸入的count結餘0至59時在能正確的工作。

使用陣列下標

如果你想給一個變數設定一個代表某種意思的字元值,你可能會這樣做:

switch ( queue ) {
case 0 :   letter = 'W';
   break;
case 1 :   letter = 'S';
   break;
case 2 :   letter = 'U';
   break;
}

或者這樣做:

if ( queue == 0 )
  letter = 'W';
else if ( queue == 1 )
  letter = 'S';
else
  letter = 'U';

一種更簡潔、更快的方法是使用陣列下標獲取字元陣列的值。如下:

static char *classes="WSU";

letter = classes[queue];

全域性變數

全域性變數絕不會位於暫存器中。使用指標或者函式呼叫,可以直接修改全域性變數的值。因此,編譯器不能將全域性變數的值快取在暫存器中,但這在使用全域性變數時便需要額外的(常常是不必要的)讀取和儲存。所以,在重要的迴圈中我們不建議使用全域性變數。

如果函式過多的使用全域性變數,比較好的做法是拷貝全域性變數的值到區域性變數,這樣它才可以存放在暫存器。這種方法僅僅適用於全域性變數不會被我們呼叫的任意函式使用。例子如下:

int f(void);
int g(void);
int errs;
void test1(void)
{
  errs += f();
  errs += g();
}

void test2(void)
{
  int localerrs = errs;
  localerrs += f();
  localerrs += g();
  errs = localerrs;
}

注意,test1必須在每次增加操作時載入並儲存全域性變數errs的值,而test2儲存localerrs於暫存器並且只需要一個計算機指令。

使用別名

考慮如下的例子:

void func1( int *data )
{
    int i;

    for(i=0; i<10; i++)
    {
          anyfunc( *data, i);
    }
}

儘管*data的值可能從未被改變,但編譯器並不知道anyfunc函式不會修改它,所以程式必須在每次使用它的時候從記憶體中讀取它。如果我們知道變數的值不會被改變,那麼就應該使用如下的編碼:

void func1( int *data )
{
    int i;
    int localdata;

    localdata = *data;
    for(i=0; i<10; i++)
    {
          anyfunc ( localdata, i);
    }
}

這為編譯器優化程式碼提供了條件。

變數的生命週期分割

由於處理器中暫存器是固定長度的,程式中數字型變數在暫存器中的儲存是有一定限制的。

有些編譯器支援“生命週期分割”(live-range splitting),也就是說在程式的不同部分,變數可以被分配到不同的暫存器或者記憶體中。變數的生命週期開始於對它進行的最後一次賦值,結束於下次賦值前的最後一次使用。在生命週期內,變數的值是有效的,也就是說變數是活著的。不同生命週期之間,變數的值是不被需要的,也就是說變數是死掉的。這樣,暫存器就可以被其餘變數使用,從而允許編譯器分配更多的變數使用暫存器。

需要使用暫存器分配的變數數目需要超過函式中不同變數生命週期的個數。如果不同變數生命週期的個數超過了暫存器的數目,那麼一些變數必須臨時儲存於記憶體。這個過程就稱之為分割。

編譯器首先分割最近使用的變數,用以降低分割帶來的消耗。禁止變數生命週期分割的方法如下:

  • 限定變數的使用數量:這個可以通過保持函式中的表示式簡單、小巧、不使用太多的變數實現。將較大的函式拆分為小而簡單的函式也會達到很好的效果。
  • 對經常使用到的變數採用暫存器儲存:這樣允許我們告訴編譯器該變數是需要經常使用的,所以需要優先儲存於暫存器中。然而,在某種情況下,這樣的變數依然可能會被分割出暫存器。

變數型別

C編譯器支援基本型別:char、short、int、long(包括有符號signed和無符號unsigned)、float和double。使用正確的變數型別至關重要,因為這可以減少程式碼和資料的大小並大幅增加程式的效能。

區域性變數

我們應該儘可能的不使用char和short型別的區域性變數。對於char和short型別,編譯器需要在每次賦值的時候將區域性變數減少到8或者16位。這對於有符號變數稱之為有符號擴充套件,對於無符號變數稱之為零擴充套件。這些擴充套件可以通過暫存器左移24或者16位,然後根據有無符號標誌右移相同的位數實現,這會消耗兩次計算機指令操作(無符號char型別的零擴充套件僅需要消耗一次計算機指令)。

可以通過使用int和unsigned int型別的區域性變數來避免這樣的移位操作。這對於先載入資料到區域性變數,然後處理區域性變數資料值這樣的操作非常重要。無論輸入輸出資料是8位或者16位,將它們考慮為32位是值得的。

考慮下面的三個函式:

int wordinc (int a)
{
   return a + 1;
}
short shortinc (short a)
{
    return a + 1;
}
char charinc (char a)
{
    return a + 1;
}

儘管結果均相同,但是第一個程式片段執行速度高於後兩者。

指標

我們應該儘可能的使用引用值的方式傳遞結構資料,也就是說使用指標,否則傳遞的資料會被拷貝到棧中,從而降低程式的效能。我曾見過一個程式採用傳值的方式傳遞非常大的結構資料,然後這可以通過一個簡單的指標更好的完成。

函式通過引數接受結構資料的指標,如果我們確定不改變資料的值,我們需要將指標指向的內容定義為常量。例如:

void print_data_of_a_structure ( const Thestruct  *data_pointer)
{
    ...printf contents of the structure...
}

這個示例告訴編譯器函式不會改變外部引數的值(使用const修飾),並且不用在每次訪問時都進行讀取。同時,確保編譯器限制任何對只讀結構的修改操作從而給予結構資料額外的保護。

指標鏈

指標鏈經常被用於訪問結構資料。例如,常用的程式碼如下:

typedef struct { int x, y, z; } Point3;
typedef struct { Point3 *pos, *direction; } Object;

void InitPos1(Object *p)
{
   p->pos->x = 0;
   p->pos->y = 0;
   p->pos->z = 0;
}

然而,這種的程式碼在每次操作時必須重複呼叫p->pos,因為編譯器不知道p->pos->x與p->pos是相同的。一種更好的方法是快取p->pos到一個區域性變數:

void InitPos2(Object *p)
{
   Point3 *pos = p->pos;
   pos->x = 0;
   pos->y = 0;
   pos->z = 0;
}

另一種方法是在Object結構中直接包含Point3型別的資料,這能完全消除對Point3使用指標操作。

條件執行

條件執行語句大多在if語句中使用,也在使用關係運算子(<,==,>等)或者布林值表示式(&&,!等)計算複雜表示式時使用。對於包含函式呼叫的程式碼片段,由於函式返回值會被銷燬,因此條件執行是無效的。

因此,保持if和else語句儘可能簡單是十分有益處的,因為這樣編譯器可以集中處理它們。關係表示式應該寫在一起。

下面的例子展示編譯器如何使用條件執行:

int g(int a, int b, int c, int d)
{
   if (a > 0 && b > 0 && c < 0 && d < 0)
   //  grouped conditions tied up together//
      return a + b + c + d;
   return -1;
}

由於條件被聚集到一起,編譯器能夠將他們集中處理。

布林表示式和範圍檢查

一個常用的布林表示式是用於判斷變數是否位於某個範圍內,例如,檢查一個圖形座標是否位於一個視窗內:

bool PointInRectangelArea (Point p, Rectangle *r)
{
   return (p.x >= r->xmin && p.x < r->xmax &&
                      p.y >= r->ymin && p.y < r->ymax);
}

這裡有一種更快的方法:x>min && x<max可以轉換為(unsigned)(x-min)<(max-min)。這對於min等於0時更為有益。優化後的程式碼如下:

bool PointInRectangelArea (Point p, Rectangle *r)
{
    return ((unsigned) (p.x - r->xmin) < r->xmax &&
   (unsigned) (p.y - r->ymin) < r->ymax);

}

布林表示式和零值比較

處理器的標誌位在比較指令操作後被設定。標誌位同樣可以被諸如MOV、ADD、AND、MUL等基本算術和裸機指令改寫。如果資料指令設定了標誌位,N和Z標誌位也將與結果與0比較一樣進行設定。N標誌表示結果是否是負值,Z標誌表示結果是否是0。

C語言中,處理器中的N和Z標誌位與下面的指令聯絡在一起:有符號關係運算x<0,x>=0,x==0,x!=0;無符號關係運算x==0,x!=0(或者x>0)。

C程式碼中每次關係運算子的呼叫,編譯器都會發出一個比較指令。如果操作符是上面提到的,編譯器便會優化掉比較指令。例如:

int aFunction(int x, int y)
{
   if (x + y < 0)
      return 1;
  else
     return 0;
}

儘可能的使用上面的判斷方式,這可以在關鍵迴圈中減少比較指令的呼叫,進而減少程式碼體積並提高程式碼效能。C語言沒有借位和溢位位的概念,因此,如果不借助彙編,不可能直接使用借位標誌C和溢位位標誌V。但編譯器支援借位(無符號溢位),例如:

int sum(int x, int y)
{
   int res;
   res = x + y;
   if ((unsigned) res < (unsigned) x) // carry set?  //
     res++;
   return res;
}

懶檢測開發

在if(a>10 && b=4)這樣的語句中,確保AND表示式的第一部分最可能較快的給出結果(或者最早、最快計算),這樣第二部分便有可能不需要執行。

用switch()函式替代if…else…

對於涉及if…else…else…這樣的多條件判斷,例如:

if( val == 1)
    dostuff1();
else if (val == 2)
    dostuff2();
else if (val == 3)
    dostuff3();

使用switch可能更快:

switch( val )
{
    case 1: dostuff1(); break;

    case 2: dostuff2(); break;

    case 3: dostuff3(); break;
}

在if()語句中,如果最後一條語句命中,之前的條件都需要被測試執行一次。Switch允許我們不做額外的測試。如果必須使用if…else…語句,將最可能執行的放在最前面。

二分中斷

使用二分方式中斷程式碼而不是讓程式碼堆成一列,不要像下面這樣做:

if(a==1) {
} else if(a==2) {
} else if(a==3) {
} else if(a==4) {
} else if(a==5) {
} else if(a==6) {
} else if(a==7) {
} else if(a==8)

{
}

使用下面的二分方式替代它,如下:

if(a<=4) {
    if(a==1)     {
    }  else if(a==2)  {
    }  else if(a==3)  {
    }  else if(a==4)   {

    }
}
else
{
    if(a==5)  {
    } else if(a==6)   {
    } else if(a==7)  {
    } else if(a==8)  {
    }
}

或者如下:

if(a<=4)
{
    if(a<=2)
    {
        if(a==1)
        {
            /* a is 1 */
        }
        else
        {
            /* a must be 2 */
        }
    }
    else
    {
        if(a==3)
        {
            /* a is 3 */
        }
        else
        {
            /* a must be 4 */
        }
    }
}
else
{
    if(a<=6)
    {
        if(a==5)
        {
            /* a is 5 */
        }
        else
        {
            /* a must be 6 */
        }
    }
    else
    {
        if(a==7)
        {
            /* a is 7 */
        }
        else
        {
            /* a must be 8 */
        }
    }
}

比較如下兩種case語句:

慢而低效的程式碼 快而高效的程式碼
c=getch();
switch(c){
    case 'A':
    {
        do something;
        break;
    }
    case 'H':
    {
        do something;
        break;
    }
    case 'Z':
    {
        do something;
        break;
    }
}
c=getch();
switch(c){
    case 0:
    {
        do something;
        break;
    }
    case 1:
    {
        do something;
        break;
    }
    case 2:
    {
        do something;
        break;
    }
}

switch語句vs查詢表

Switch的應用場景如下:

  • 呼叫一到多個函式
  • 設定變數值或者返回一個值
  • 執行一到多個程式碼片段

如果case標籤很多,在switch的前兩個使用場景中,使用查詢表可以更高效的完成。例如下面的兩種轉換字串的方式:

char * Condition_String1(int condition) {
  switch(condition) {
     case 0: return "EQ";
     case 1: return "NE";
     case 2: return "CS";
     case 3: return "CC";
     case 4: return "MI";
     case 5: return "PL";
     case 6: return "VS";
     case 7: return "VC";
     case 8: return "HI";
     case 9: return "LS";
     case 10: return "GE";
     case 11: return "LT";
     case 12: return "GT";
     case 13: return "LE";
     case 14: return "";
     default: return 0;
  }
}

char * Condition_String2(int condition) {
   if ((unsigned) condition >= 15) return 0;
      return
      "EQ\0NE\0CS\0CC\0MI\0PL\0VS\0VC\0HI\0LS\0GE\0LT\0GT\0LE\0\0" +
       3 * condition;
}

第一個程式需要240 bytes,而第二個僅僅需要72 bytes。

迴圈

迴圈是大多數程式中的常用的結構;程式執行的大部分時間發生在迴圈中,因此十分值得在迴圈執行時間上下一番功夫。

迴圈終止

如果不加註意,迴圈終止條件的編寫會導致額外的負擔。我們應該使用計數到零的迴圈和簡單的迴圈終止條件。簡單的終止條件消耗更少的時間。看下面計算n!的兩個程式。第一個實現使用遞增的迴圈,第二個實現使用遞減迴圈。

int fact1_func (int n)
{
    int i, fact = 1;
    for (i = 1; i <= n; i++)
      fact *= i;
    return (fact);
}

int fact2_func(int n)
{
    int i, fact = 1;
    for (i = n; i != 0; i--)
       fact *= i;
    return (fact);
}

第二個程式的fact2_func執行效率高於第一個。

更快的for()迴圈

這是一個簡單而高效的概念。通常,我們編寫for迴圈程式碼如下:

for( i=0;  i<10;  i++){ ... }

i從0迴圈到9。如果我們不介意迴圈計數的順序,我們可以這樣寫:

for( i=10; i--; ) { ... }

這樣快的原因是因為它能更快的處理i的值–測試條件是:i是非零的嗎?如果這樣,遞減i的值。對於上面的程式碼,處理器需要計算“計算i減去10,其值非負嗎?如果非負,i遞增並繼續”。簡單的迴圈卻有很大的不同。這樣,i從9遞減到0,這樣的迴圈執行速度更快。

這裡的語法有點奇怪,但確實合法的。迴圈中的第三條語句是可選的(無限迴圈可以寫為for(;;))。如下程式碼擁有同樣的效果:

for(i=10; i; i--){}

或者更進一步的:

for(i=10; i!=0; i--){}

這裡我們需要記住的是迴圈必須終止於0(因此,如果在50到80之間迴圈,這不會起作用),並且迴圈計數器是遞減的。使用遞增迴圈計數器的程式碼不享有這種優化。

合併迴圈

如果一個迴圈能解決問題堅決不用二個。但如果你需要在迴圈中做很多工作,這坑你並不適合處理器的指令快取。這種情況下,兩個分開的迴圈可能會比單個迴圈執行的更快。下面是一個例子:

//Original Code :

for(i=0; i<100; i++){
    stuff();
}

for(i=0; i<100; i++){
    morestuff();
}
//It would be better to do:

for(i=0; i<100; i++){
    stuff();
    morestuff();
}

函式迴圈

呼叫函式時總是會有一定的效能消耗。不僅程式指標需要改變,而且使用的變數需要壓棧並分配新變數。為提升程式的效能,在函式這點上有很多可以優化的。在保持程式程式碼可讀性的同時也需要程式碼的大小是可控的。

如果在迴圈中一個函式經常被呼叫,那麼就將迴圈納入到函式中,這樣可以減少重複的函式呼叫。程式碼如下:

for(i=0 ; i<100 ; i++)
{
    func(t,i);
}
-
-
-
void func(int w,d)
{
    lots of stuff.
}

應改為:

func(t);
-
-
-
void func(w)
{
    for(i=0 ; i<100 ; i++)
    {
        //lots of stuff.
    }
}

迴圈展開 

簡單的迴圈可以展開以獲取更好的效能,但需要付出程式碼體積增加的代價。迴圈展開後,迴圈計數應該越來越小從而執行更少的程式碼分支。如果迴圈迭代次數只有幾次,那麼可以完全展開迴圈,以便消除循壞帶來的負擔。

這會帶來很大的不同。迴圈展開可以帶非常可觀的節省效能,原因是程式碼不用每次迴圈需要檢查和增加i的值。例如:

for(i=0; i<3; i++){
    something(i);
}

//is less efficient than
something(0);
something(1);
something(2);

編譯器通常會像上面那樣展開簡單的,迭代次數固定的迴圈。但是像下面的程式碼:

for(i=0;i< limit;i++) { ... }

下面的程式碼(Example 1)明顯比使用迴圈的方式寫的更長,但卻更有效率。block-sie的值設定為8僅僅適用於測試的目的,只要我們重複執行“loop-contents”相同的次數,都會有很好的效果。在這個例子中,迴圈條件每8次迭代才會被檢查,而不是每次都進行檢查。由於不知道迭代的次數,一般不會被展開。因此,儘可能的展開迴圈可以讓我們獲得更好的執行速度。

//Example 1

#include<STDIO.H>

#define BLOCKSIZE (8)

void main(void)
{
int i = 0;
int limit = 33;  /* could be anything */
int blocklimit;

/* The limit may not be divisible by BLOCKSIZE,
 * go as near as we can first, then tidy up.
 */
blocklimit = (limit / BLOCKSIZE) * BLOCKSIZE;

/* unroll the loop in blocks of 8 */
while( i < blocklimit )
{
    printf("process(%d)\n", i);
    printf("process(%d)\n", i+1);
    printf("process(%d)\n", i+2);
    printf("process(%d)\n", i+3);
    printf("process(%d)\n", i+4);
    printf("process(%d)\n", i+5);
    printf("process(%d)\n", i+6);
    printf("process(%d)\n", i+7);

    /* update the counter */
    i += 8;

}

/*
 * There may be some left to do.
 * This could be done as a simple for() loop,
 * but a switch is faster (and more interesting)
 */

if( i < limit )
{
    /* Jump into the case at the place that will allow
     * us to finish off the appropriate number of items.
     */

    switch( limit - i )
    {
        case 7 : printf("process(%d)\n", i); i++;
        case 6 : printf("process(%d)\n", i); i++;
        case 5 : printf("process(%d)\n", i); i++;
        case 4 : printf("process(%d)\n", i); i++;
        case 3 : printf("process(%d)\n", i); i++;
        case 2 : printf("process(%d)\n", i); i++;
        case 1 : printf("process(%d)\n", i);
    }
}

}

統計非零位的數量 

通過不斷的左移,提取並統計最低位,示例程式1高效的檢查一個陣列中有幾個非零位。示例程式2被迴圈展開四次,然後通過將四次移位合併成一次來優化程式碼。經常展開迴圈,可以提供很多優化的機會。

//Example - 1

int countbit1(uint n)
{
  int bits = 0;
  while (n != 0)
  {
    if (n & 1) bits++;
    n >>= 1;
   }
  return bits;
}

//Example - 2

int countbit2(uint n)
{
   int bits = 0;
   while (n != 0)
   {
      if (n & 1) bits++;
      if (n & 2) bits++;
      if (n & 4) bits++;
      if (n & 8) bits++;
      n >>= 4;
   }
   return bits;
}

儘早的斷開迴圈

通常,迴圈並不需要全部都執行。例如,如果我們在從陣列中查詢一個特殊的值,一經找到,我們應該儘可能早的斷開迴圈。例如:如下迴圈從10000個整數中查詢是否存在-99。

found = FALSE;
for(i=0;i<10000;i++)
{
    if( list[i] == -99 )
    {
        found = TRUE;
    }
}

if( found ) printf("Yes, there is a -99. Hooray!\n");

上面的程式碼可以正常工作,但是需要迴圈全部執行完畢,而不論是否我們已經查詢到。更好的方法是一旦找到我們查詢的數字就終止繼續查詢。

found = FALSE;
for(i=0; i<10000; i++)
{
    if( list[i] == -99 )
    {
        found = TRUE;
        break;
    }
}
if( found ) printf("Yes, there is a -99. Hooray!\n");

假如待查資料位於第23個位置上,程式便會執行23次,從而節省9977次迴圈。

函式設計

設計小而簡單的函式是個很好的習慣。這允許暫存器可以執行一些諸如暫存器變數申請的優化,是非常高效的。

函式呼叫的效能消耗

函式呼叫對於處理器的效能消耗是很小的,只佔有函式執行工作中效能消耗的一小部分。引數傳入函式變數暫存器中有一定的限制。這些引數必須是整型相容的(char,shorts,ints和floats都佔用一個字)或者小於四個字大小(包括佔用2個字的doubles和long longs)。如果引數限制個數為4,那麼第五個和之後的字就會儲存在棧上。這便在呼叫函式是需要從棧上載入引數從而增加儲存和讀取的消耗。

看下面的程式碼:

int f1(int a, int b, int c, int d) {
   return a + b + c + d;
}

int g1(void) {
   return f1(1, 2, 3, 4);
}

int f2(int a, int b, int c, int d, int e, int f) {
  return a + b + c + d + e + f;
}

ing g2(void) {
 return f2(1, 2, 3, 4, 5, 6);
}

函式g2中的第五個和第六個引數儲存於棧上並在函式f2中進行載入,會多消耗2個引數的儲存。

減少函式引數傳遞消耗

減少函式引數傳遞消耗的方法有:

  • 儘量保證函式使用少於四個引數。這樣就不會使用棧來儲存引數值。
  • 如果函式需要多於四個的引數,儘量確保使用後面引數的價值高於讓其儲存於棧所付出的代價。
  • 通過指標傳遞引數的引用而不是傳遞引數結構體本身。
  • 將引數放入一個結構體並通過指標傳入函式,這樣可以減少引數的數量並提高可讀性。
  • 儘量少用佔用兩個字大小的long型別引數。對於需要浮點型別的程式,double也因為佔用兩個字大小而應儘量少用。
  • 避免函式引數既存在於暫存器又存在於棧中(稱之為引數拆分)。現在的編譯器對這種情況處理的不夠高效:所有的暫存器變數也會放入到棧中。
  • 避免變參。變參函式將引數全部放入棧。

葉子函式

不呼叫任何函式的函式稱之為葉子函式。在以下應用中,近一半的函式呼叫是呼叫葉子函式。由於不需要執行暫存器變數的儲存和讀取,葉子函式在任何平臺都很高效。暫存器變數讀取的效能消耗,相比於使用四五個暫存器變數的葉子函式所做的工作帶來的系能消耗是非常小的。所以儘可能的將經常呼叫的函式寫成葉子函式。函式呼叫的次數可以通過一些工具檢查。下面是一些將一個函式編譯為葉子函式的方法:

  • 避免呼叫其他函式:包括那些轉而呼叫C庫的函式(比如除法或者浮點數操作函式)。
  • 對於簡短的函式使用__inline修飾()。

行內函數

行內函數禁用所有的編譯選項。使用__inline修飾函式導致函式在呼叫處直接替換為函式體。這樣程式碼呼叫函式更快,但增加程式碼的大小,特別在函式本身比較大而且經常呼叫的情況下。

__inline int square(int x) {
   return x * x;
}

#include <MATH.H>

double length(int x, int y){
    return sqrt(square(x) + square(y));
}

使用行內函數的好處如下:

  • 沒有函式呼叫負擔。函式呼叫處直接替換為函式體,因此沒有諸如讀取暫存器變數等效能消耗。
  • 更小的引數傳遞消耗。由於不需要拷貝變數,傳遞引數的消耗更小。如果引數是常量,編譯器可以提供更好的優化。

行內函數的缺陷是如果呼叫的地方很多,程式碼的體積會變得很大。這主要取決於函式本身的大小和呼叫的次數。

僅對重要的函式使用inline是明智的。如果使用得當,行內函數甚至可以減少程式碼的體積:函式呼叫會產生一些計算機指令,但是使用內聯的優化版本可能產生更少的計算機指令。

使用查詢表

函式通常可以設計成查詢表,這樣可以顯著提升效能。查詢表的精確度比通常的計算低,但對於一般的程式並沒什麼差異。

許多訊號處理程式(例如,調變解調器解調軟體)使用很多非常消耗計算效能的sin和cos函式。對於實時系統,精確性不是特別重要,sin、cos查詢表可能更合適。當使用查詢表時,儘可能將相似的操作放入查詢表,這樣比使用多個查詢表更快,更能節省儲存空間。

浮點運算

儘管浮點運算對於所有的處理器都很耗時,但對於實現訊號處理軟體時我們仍然需要使用。在編寫浮點操作程式時,記住如下幾點:

  • 浮點除法很慢。浮點除法比加法或者乘法慢兩倍。通過使用常量將除法轉換為乘法(例如,x=x/3.0可以替換為x=x*(1.0/3.0))。常量的除法在編譯期間計算。
  • 使用float代替double。Float型別的變數消耗更好的記憶體和暫存器,並由於精度低而更加高效。如果精度夠用,儘可能使用float。
  • 避免使用先驗函式。先驗函式,例如sin、exp和log是通過一系列的乘法和加法實現的(使用了精度擴充套件)。這些操作比通常的乘法至少慢十倍。
  • 簡化浮點運算表示式。編譯器並不能將應用於整型操作的優化手段應用於浮點操作。例如,3*(x/3)可以優化為x,而浮點運算就會損失精度。因此,如果知道結果正確,進行必要手工浮點優化是有必要的。

然而,浮點運算的表現可能不能滿足特定軟體對效能的需求。這種情況下,最好的辦法或許是使用定點算數運算。當值的範圍足夠小,定點算數操作比浮點運算更精確、更快速。

其他技巧

通常,可以使用空間換時間。如果你能快取經常用的資料而不是重新計算,這便能更快的訪問。比如sine和cosine查詢表,或者偽隨機數。

  • 儘量不在迴圈中使用++和–。例如:while(n–){},這有時難於優化。
  • 減少全域性變數的使用。
  • 除非像宣告為全域性變數,使用static修飾變數為檔案內訪問。
  • 儘可能使用一個字大小的變數(int、long等),使用它們(而不是char,short,double,位域等)機器可能執行的更快。
  • 不使用遞迴。遞迴可能優雅而簡單,但需要太多的函式呼叫。
  • 不在迴圈中使用sqrt開平方函式,計算平方根非常消耗效能。
  • 一維陣列比多維陣列更快。
  • 編譯器可以在一個檔案中進行優化-避免將相關的函式拆分到不同的檔案中,如果將它們放在一起,編譯器可以更好的處理它們(例如可以使用inline)。
  • 單精度函式比雙精度更快。
  • 浮點乘法運算比浮點除法運算更快-使用val*0.5而不是val/2.0。
  • 加法操作比乘法快-使用val+val+val而不是val*3。
  • put()函式比printf()快,但不靈活。
  • 使用#define巨集取代常用的小函式。
  • 二進位制/未格式化的檔案訪問比格式化的檔案訪問更快,因為程式不需要在人為可讀的ASCII和機器可讀的二進位制之間轉化。如果你不需要閱讀檔案的內容,將它儲存為二進位制。
  • 如果你的庫支援mallopt()函式(用於控制malloc),儘量使用它。MAXFAST的設定,對於呼叫很多次malloc工作的函式由很大的效能提升。如果一個結構一秒鐘內需要多次建立並銷燬,試著設定mallopt選項。

最後,但是是最重要的是-將編譯器優化選項開啟!看上去很顯而易見,但卻經常在產品推出時被忘記。編譯器能夠在更底層上對程式碼進行優化,並針對目標處理器執行特定的優化處理。

譯文連結:http://www.codeceo.com/article/c-high-performance-coding.html
英文原文:Writing Efficient C and C Code Optimization
翻譯作者:碼農網 – gunner
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章