最佳化--C程式設計師之終極標靶 (轉)

worldblog發表於2007-12-04
最佳化--C程式設計師之終極標靶 (轉)[@more@]

--C員之終極標靶

一個往往把他的生命中大部分時間用來等待輸出結果,為了減
少這個等待時間,使用者不得不採購更快的計算機,增加或更換整個
.開發者有責任儘量避免他的程式耗費昂貴的資源,為使用者挽回寶貴的時間
和金錢.--原作者
---------------------------------------------------------------------------
介紹:

最簡單的最佳化方法是藉助prof工具判斷程式的瓶頸在哪裡,你必須判斷出
程式的那些部分消耗了大量資源.

一旦你判斷出瓶頸(比如說上萬次的迴圈),你所做的第一件事就是重
新設計程式,減低迴圈次數.當然,現在絕大多數最佳化可以做到這一
點,(不過最好還是自己來--東樓),但是記住,當以下情況出現時,最佳化是
在浪費時間:
1)程式只寫了一部分
2)程式還沒有測試透過
3)看起來已經足夠快了

還要注意的就是判斷程式的用途,如果僅僅為了得到一份報告而寫的僅運
行一次的程式,使用者往往在午餐前執行程式,這時,程式只要在他們回來之
前執行完就可以了,如果程式其他的程式,而且其他程式都比較慢,那
麼優不最佳化效果都差不多,但是,如果是GUI圖形使用者介面程式(比如滑鼠光
標顯示程式),那麼一點點的延遲都會遭到使用者的投訴

完成最佳化後,帶上所有的最佳化命令編譯,然後用你實際使用的資料測試它,
如果做不到這一點,請小心選擇你的測試資料,程式設計師多半傾向於按照程式
的要求給輸入資料,但使用者可不這麼幹.

如果你已經完成了所有最佳化,但是程式仍然看起來不快,注意一下你的操作
,很多多工按時間片來劃分使用者資源,如果給你的資源太少,
那和你的員聯絡吧.

1.選擇一個更好的演算法:

應該熟悉演算法語言,知道各種演算法的優缺點,一般很多計算機資料文字上有
介紹,應該能夠看得懂演算法描述.

這裡是一些明顯可以通用的替換
  慢的演算法  替換成
  順序查詢  二分法查詢或亂序查詢
  插入排序或氣泡排序  排序,合併排序,根(radix)排序

還要選擇一種合適的資料結構(記著,你的程式所幹的唯一一件事就是在計
算機裡搬數,把一堆數從一個地方提出來,處理一下,甩到另一個地方,那麼
按什麼方式搬數有多重要你應該知道了吧--東樓),比如你在一堆隨機存放
的數中使用了大量的插入和刪除指令,那使用連結串列要快得多.如果你要做二
分法查詢,那提前排下序非常重要.

2.寫一些清晰,可讀性好並且簡單的程式碼

一個人容易看得懂的程式同樣也容易被編譯器讀懂.一個大而複雜的
往往會把編譯器腦袋都弄大,為了防止自己發瘋,編譯器往往放棄對這段代
碼的最佳化.但絕對不會向你報告,出於維護自己面子起見,東樓發現所有的編
譯器都只會向你報告它最佳化了多少,而決不會報告它幹不了的有多少,東樓
就親眼見到一個瓜編譯器因為一個表示式弄昏了頭,把整個模組的最佳化都放
棄了,回來居然還恬不知恥的報告最佳化非常順利,整個兒一個報喜不報憂.

適當的時候儘量減小每個的程式碼量(這時候對程式碼要摳一點,懂嗎?--東
樓),不過也別走極端,別為了最佳化把一個函式寫成10頁紙的一堆函式,那編
譯器倒高興了,可人發瘋了.

最佳化後,趕快找一臺快點的機器看看效果吧(滿足一下虛榮心,嬉嘻!)

3.透視你的程式

一個程式寫出來,憑直覺就應該感覺出哪些地方快,哪些地方慢,(就是,東樓
的程式就是全部憑直覺最佳化的(...反正吹牛不上稅,嘻嘻)),一般說來,最快
的運算就是分配一塊記憶體,給指標賦值,還有就是兩個整數的加法運算,別的
都有點慢,最慢的就要數開啟啦,開啟新的程式啦,讀寫一大塊記憶體啦,
尋找啦,排序啦等等,別看這幫蝦子指令都只要幾個微秒,可成百上千的殺將
過來,東樓可受不了.一定不能讓這幫蝦子進迴圈,幹了它.

這是經常犯的一個錯誤:

if (x != 0) x = 0;

程式的原意是當x等於0時,節約時間不執行賦值操作,可你別忘了,賦值語
句才是最快的,那還不如直接寫成下面的語句更來勁.

x = 0;

還有就是一些神勇的大蝦,非得等到編譯器把程式碼輸出成語言級然後
拿著計算器一行行加彙編指令的個數和週期數,才算最佳化完成了,不過可
別忘了,最後一次最佳化不是obj程式碼級的,而是由link程式完成的,這沒多
大用.

4.理解你的編譯程式選項

許多編譯程式有幾級最佳化選項,注意使用最最佳化的一項,特別注意gcc,最佳化
選項非常多,小心使用,別弄得適得其反.

通常情況下一旦選用最高階最佳化,編譯程式會近乎病態地追求程式碼最佳化,精
簡指令,(如DJGPP的-O3),但少數情況下,這會影響程式的正確性,這是你只
有改自己的程式啦.不過也有這種情況,就是一些引數會影響最佳化的程式,
但是不會影響普通程式,這時只有具體情況具體分析了.

5.內聯(內嵌)

gcc(使用-finline-functions引數),還有一些別的編譯器可以在最高階優
化中內聯一些小的函式.K&C編譯器則只有在庫函式是用匯編寫成的時候才
內聯,C++編譯器普遍支援行內函數.

不過把C函式寫成宏也能達到加速的作用,不過必須是在程式完全除錯之後,
因為絕大多數除錯程式不支援宏除錯.

宏內聯的例子:

舊程式碼:
int foo(a, b)
{
a = a - b;
b++;
a = a * b;
return a;
}

新程式碼:
#define foo(a, b) (((a)-(b)) * ((b)+1))

注意最外層括號是必須的,因為當宏在表示式中展開時,你不知道表示式裡
還有沒有比乘法級別更高的運算.

一些警告:

1.無限制地使用宏可以使程式碼爆炸,程式會很快消耗完你所有的資源,包
括實體記憶體,最後系統要麼崩潰,要麼把你的程式碼放到虛擬記憶體(上)
中去,那你再怎麼最佳化也沒用了
2.C的宏每次呼叫都要對引數賦值,如果引數很多很複雜,那光賦值就要消
耗大量的時間,效果還不如不用宏
3.因為宏允許包含很複雜的表示式,所以編譯程式會非常辛苦,為了使自
己不至於完全發瘋,一般編譯程式對宏能包含的字元數都有一個限制,注
意別犯規.
4.一旦用了宏,prof程式也跟著糊塗起來了,這是它說的話可信度可不高


6.迴圈展開

這是經典的速度最佳化,但許多編譯程式(如gcc -funroll-ls)能自動完成
這個事,所以現在你自己來最佳化這個顯得效果不明顯.(這裡說一句,雲風工
作室的雲風朋友曾來信和東樓專門探討過這個問題,他根據自己在DJGPP的
認定迴圈展開無效,東樓猜測可能就是因為gcc在編譯時自動進行了展
開,所以手工展開已經沒多大效果了.但這個方法總是對的).

舊程式碼:
for (i = 0; i < 100; i++)
{
do_stuff(i);
}

新程式碼:
for (i = 0; i < 100; )
{
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
}

可以看出,新程式碼裡比較指令由100次降低為10次,迴圈時間節約了90%.

不過注意:對於中間變數或結果被更改的迴圈,編譯程式往往拒絕展開,(怕
擔責任唄),這時候就需要你自己來做展開工作了.

還有一點請注意,在有內部指令cache的CPU上(如MMX),因為迴圈展開的
程式碼很大,往往cache,這時展開的程式碼會頻繁地在CPU 的cache和記憶體
之間調來調去,又因為cache速度很高,所以此時迴圈展開反而會變慢.還有
就是迴圈展開會影響向量運算最佳化.

7.迴圈巢狀

把相關迴圈放到一個迴圈裡,也會加快速度.

舊程式碼:
for (i = 0; i < MAX; i++) /* initialize 2d array to 0's */
  for (j = 0; j < MAX; j++)
  a[i][j] = 0.0;
  for (i = 0; i < MAX; i++) /* put 1's along the diagonal */
  a[i][i] = 1.0;

新程式碼:
for (i = 0; i < MAX; i++) /* initialize 2d array to 0's */
{
  for (j = 0; j < MAX; j++)
  a[i][j] = 0.0;
  a[i][i] = 1.0; /* put 1's along the diagonal */
}


8.迴圈轉置

有些機器對JNZ(為0轉移)有特別的指令處理,速度非常快,如果你的迴圈對方向
不敏感,可以由大向小迴圈

舊程式碼:
  for (i = 1; i <= MAX; i++)
  {
  ...
  }

新程式碼:
  i = MAX+1;
  while (--i)
  {
  ...
  }

不過千萬注意,如果指標操作使用了i值,這種方法可能引起指標超界的嚴重
錯誤(i = MAX+1;).當然你可以透過對i做加減運算來糾正,但是這樣加速的作用
就沒有了除非類似於以下情況
舊程式碼:
  char a[MAX+5];
  for (i = 1; i <= MAX; i++)
  {
  *(a+i+5)=0;
  }
新程式碼:
  i = MAX+1;
  while (--i)
  {
  *(a+i+4)=0;
  }

9.減小運算強度

採用運算量更小的表示式替換原來的表示式,下面是一個經典例子:

舊程式碼:
  x = w % 8;
  y = pow(x, 2.0);
  z = y * 33;
  for (i = 0; i < MAX; i++)
  {
  h = 14 * i;
  printf("%d", h);
  }

新程式碼:
  x = w & 7; /* 位操作比求餘運算快 */
  y = x * x; /* 乘法比平方運算快 */
  z = (y << 5) + y; /* 位移乘法比乘法快 */
  for (i = h = 0; i < MAX; i++)
  {
  h += 14; /* 加法比乘法快 */
  printf("%d", h);
  }


10.迴圈不變計算

對於一些不需要迴圈變數參加運算的計算任務可以把它們放到迴圈外面,現在許
多編譯器還是能自己幹這件事,不過對於中間使用了變數的算式它們就不敢動了,
所以很多情況下你還得自己幹.
那位大哥說了,不就是把沒必要的表示式拿出來嘛,這話我們可得商量商量,這裡的
計算任務可不是僅僅表示式那麼簡單,什麼呼叫函式啦,指標運算啦,陣列訪問啦,
總之,凡是你相讓計算機乾的事都算計算任務.

對於那些在迴圈中呼叫的函式,也不能讓它們輕鬆了,把它扒光了看看,凡是沒必
要執行多次的操作通通提出來,放到一個init函式里,迴圈前呼叫.另外儘量減少
餵食次數,沒必要的話儘量不給它傳參,需要迴圈變數的話讓它自己建立一個靜
態迴圈變數自己累加,速度會快一點.

還有就是結構體訪問,東樓的經驗,凡是在迴圈裡對一個結構體的兩個以上的元
素執行了訪問,就有必要建立中間變數了(結構這樣,那C++的呢?想想看),看
下面的例子:

舊程式碼:
  total =
  a->b->c[4]->aardvark +
  a->b->c[4]->baboon +
  a->b->c[4]->cheetah +
  a->b->c[4]->dog;

新程式碼:
  struct animals * temp = a->b->c[4];
  total =
  temp->aardvark +
  temp->baboon +
  temp->cheetah +
  temp->dog;

一些老的C語言編譯器不做聚合最佳化,而符合ANSI規範的新的編譯器可以自動完
成這個最佳化,看例子:

  float a, b, c, d, f, g;
  ...
  a = b / c * d;
  f = b * g / c;

這種寫法當然要得,但是沒有最佳化

  float a, b, c, d, f, g;
  ...
  a = b / c * d;
  f = b / c * g;

如果這麼寫的話,一個符合ANSI規範的新的編譯器可以只計算b/c一次,然後將結
果代入第二個式子,節約了一次除法運算.


11.公用程式碼塊

一些公用處理模組,為了滿足各種不同的呼叫需要,往往在內部採用了大量的
if-then-else結構,這樣很不好,判斷語句如果太複雜,會消耗大量的時間的,應
該儘量減少公用程式碼塊的使用.(任何情況下,空間最佳化和時間最佳化都是對立的--
東樓).
當然,如果僅僅是一個(3==x)之類的簡單判斷,適當使用一下,也還是允許的.記
住,最佳化永遠是追求一種平衡,而不是走極端.

12.採用遞迴

與LISP之類的語言不同,C語言一開始就病態地喜歡用重複程式碼迴圈,許多C程式
員(包括東樓)都是除非演算法要求,堅決不用遞迴.事實上,C編譯器們對最佳化遞迴
呼叫一點都不反感,相反,它們還很喜歡幹這件事.只有在遞迴函式需要傳遞大量
引數,可能造成瓶頸的時候,才應該使用迴圈程式碼,其他時候,還是用遞迴好些.

13.查表(遊戲程式設計師必修課)

一個聰明的遊戲大蝦,基本上不會在自己的主迴圈裡搞什麼運算工作,絕對是先
計算好了,再到迴圈裡查表.(東樓每一次寫遊戲,基本上都有一大堆表格).看下
面的例子:

舊程式碼:
  long factorial(int i)
  {
  if (i == 0)
  return 1;
  else
  return i * factorial(i - 1);
  }

新程式碼:
  static long factorial_table[] =
  {1, 1, 2, 6, 24, 120, 720 /* etc */};

  long factorial(int i)
  {
  return factorial_table[i];
  }

如果表很大,不好寫,就寫一個init函式,在迴圈外臨時生成表格.


14.變數

在最內層迴圈避免使用全域性變數和靜態變數,除非你能確定它在迴圈週期中不會
動態變化,大多數編譯器們最佳化變數僅有置成暫存器變數一招,而對於動態變數,
它們乾脆放棄對整個表示式的最佳化.

儘量避免把一個變數地址傳遞給另一個函式,雖然這個還很常用.C語言的編譯器
們總是先假定每一個函式的變數都是內部變數,這是由它的機制決定的,在這種
情況下,它們的最佳化完成得最好,但是,一旦一個變數有可能被別的函式改變,這
幫兄弟就再也不敢把變數放到暫存器裡了,嚴重影響速度.看例子:

a = b();
c(&d);

因為d的地址被c函式使用,有可能被改變,編譯器不敢把它長時間的放在暫存器
裡,一旦執行到c(&d),編譯器就把它丟回記憶體,如果在迴圈裡,會造成N次頻繁的
在記憶體和暫存器之間讀寫d的動作,眾所周知,CPU在系統匯流排上的讀寫速度可是
慢得可以.比如你的賽楊300,CPU主頻300,匯流排速度最多66M,為了一個匯流排讀,
CPU可能要等4-5個週期,得..得..得..想起來都打顫.


 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-987794/,如需轉載,請註明出處,否則將追究法律責任。

相關文章