寫在最前面的
再龐大復雜的代碼編譯器都能接受,編譯器會變得越來越聰明,讓我們原本的代碼更加高效。但是代碼執行的多變與不可預測性,如果編譯器大肆“優化”,偶爾或者大膽的說“在大多數情況下”,會得到“聰明反被聰明誤”的后果,所以編譯器非常小心謹慎,一遇到不可預測后果的優化,它就會立即折返,停止這一步的優化工作,因為它不知道程序員的本意是什么,“它怕得罪你”。
程序員要編寫容易優化的代碼,以幫助編譯器掃清障礙。關於代碼優化,筆者特別喜歡《深入理解計算機系統》一書中的第五章,有興趣的可以閱讀一下。
代碼優化小剖
代碼優化的方法總結了5種。
- 將函數展開,即內斂函數,以優化函數的調用。 《從簡單的算法初探過程匯編》,過多的函數調用會導致極大的調用開銷。所以,簡單而且時常調用的函數建議內斂,例如swap,max,min。改用宏或者直接展開函數更好。
- 消除循環的低效率
這一點我感觸比較深。
toupper(* str)
for i=[ 0,strlen(str))
if(str[i]>= ' a ' && str[i]<= ' z ')
str[i] += ( ' A '- ' a ')
strlen的源碼大概如下:
strlen(* str)
len = 0
while(*s!= ' \0 ')
s++,len++
return len - 減少不必要的存儲器引用
sum(* src,n,* dest) // src向量相加結果放入dest
for i=[ 0,n)
*dest += src + i
sum(* src,n,* dest) // src向量相加結果放入dest
temp = *dest;
for i=[ 0,n)
temp += src + i
*dest = temp - 循環展開
什么是循環開銷?比如:
for i=( 0,n]
sum(* src,n,* dest) // src向量相加結果放入dest
temp = *dest
for i=[ 0,n- 2+ 1),i+= 2
temp += src+i + src+i+ 1
for i=[i,n),i+= 1
temp += src+i
但是浮點的乘法沒有得到效率的提高,是因為浮點乘法這個關鍵路徑是循環展開的限制因素,即使循環展開,也需要執行n次的乘法。而有疑問,為什么整數乘法就能得到提高呢,那是因為編譯器作了“重關聯變換”的優化,改變了乘法的結合順序(有興趣看看下面的第6點,我帶過了)。又有疑問了,為什么浮點不能作想整數乘法這樣的優化呢?因為浮點乘法加法是不可結合的,記住“編譯器怕得罪你”。
珠璣中第九章的順序搜索就是用了這種優化。 - 多個累積變量
這是提高並行操作的方法,同時達到了循環展開的效果。
sum(* src,n,* dest) // src向量相加結果放入dest
temp1 = *dest
temp2 = 0
for i=[ 0,n- 2+ 1),i+= 2
temp1 += src+i // 提高並行性,temp1和temp2可以並行計算而毫不牽連
temp2 += src+i+ 1
for i=[i,n),i+= 1
temp += src+i
*dest = temp1+temp2
// temp1和temp2的加法操作是兩條關鍵路徑,而兩條關鍵路徑各執行了n/2個操作。
關於第六種優化方法——重新結合變換
書上還有說第六種優化——重新結合變換,如果大膽對浮點運算進行此類優化,性能有很大的提高。之所以沒有標號,是因為筆者也不太能說清楚,說說筆者的理解。
temp = *dest
for i=[ 0,n- 2+ 1),i+= 2
temp = temp * (src+i * src+i+ 1) // 假設原來是temp = (temp * src+i) * src+i+1
for i=[i,n),i+= 1
temp += src+i
我的理解可以結合下圖,下圖是乘法的圖,同樣可以換成加法的圖:
注意是temp = temp * (src+i * src+i+1)的圖,而不是temp = (temp * src+i) * src+i+1的圖,后者大家可以自己手動畫畫。
哈哈,書上說:未經訓練的人員,上面的兩條語句是一樣的,“搗亂笑而不語啊!”我的理解是雖然循環寄存器只有一個,但是temp = temp * (src+i * src+i+1)中(src+i * src+i+1)的計算不依賴於循環寄存器的值即temp的值,而temp = (temp * src+i) * src+i+1會對temp產生依賴,結合順序會對循環寄存器進行產生依賴,因此前者可以增加計算的並行性。
神馬?什么是循環寄存器?對於某些循環來說,有些寄存器既作為源值又作為目的,一次循環的結果會在下一次循環中用到。循環寄存器之間的關聯越大,那么,這種關聯將是性能提升的瓶頸。
簡單舉個例子:
temp = *dest;
for i=[ 0,n)
temp += src + i
*dest = temp
那么temp所在寄存器就是所謂的“循環寄存器”,它使得每一次循環都有很大的關聯,所以,temp的加法操作(抑或是乘法操作)是關鍵路徑,這也就是為什么累積變量能夠提高程序的性能,它有兩個循環寄存器,降低了循環關聯。
《珠璣》第九章的代碼優化,印象比較深的:
- 整數取模
- 函數內斂
- 循環展開
跟上面的內容差不多。
關於哨兵
哨兵就是能幫助程序檢測數組邊界的東西,簡化了數組邊界的檢測,從而使代碼更加清晰簡便。記得一開始接觸哨兵是在順序表查找的時候。
arr[n] = data
for i=[ 0,n) arr[i]!=data // 肯定會遇到data
do nothing in for
if(i==n) return - 1
return i;
再來就是直接插入排序;如果不設置哨兵,不僅要檢測下標是否下溢,而且要檢測只有當滿足arr[j]>data,才后移,這里會有兩個判斷。
for i=[ 0,n)
if arr[i]>arr[i+ 1]
arr[ 0] = arr[i+ 1] // 哨兵
for j=[i+ 1) arr[j]>arr[ 0] // 相比常規版本,只做n次判斷。
arr[j] = a[j- 1],j--
arr[j+ 1] = arr[ 0]
另外,用單鏈表存儲一組順序數據,對於這種問題,一般的單鏈表插入,都要考慮頭插法和尾插法的情況,其他情況的代碼可以是一致的;如果能夠為單鏈表的最后添加哨兵,應該可以很大程度上簡化代碼。下面的代碼比一般的單鏈表插入簡潔而且應該更高效:
insert(* first,data)
p = first->next
while(p)
if data < q->data
end while
p = p->next;
p = new node(data,p)
《珠璣》第9章第8小題,“如何在程序中使用哨兵來找出數組中的最大元素”?
如果沒有“哨兵”,大致的想法就是用一個變量max來存儲數組的第一個值,然后從第二個開始“逐個”檢驗是否>max。加上循環中的下標檢驗,共有2n次的檢測。
放置哨兵的做法:在數組的最后放置“已經找到的最大的元素”,然后逐個檢驗,
i = 0
max = arr[i]
while(i!=n)
max = arr[i]
arr[n] = max
i++
while(arr[i]<max) // 因為有哨兵的存在,此循環必定可以終結。
i++
除非arr是嚴格遞增或者每個元素值相等,否則總的檢測次數會<2n。總之在考慮邊界檢測的時候,不妨考慮下用在邊界上放置哨兵來簡化清晰代碼。
總結
不知道這些優化,在以后的做題能不能奏效了。對於一些程序,筆者認為上面的優化是無關痛癢的,或許編程的技巧會更突顯重要性,當完成了大部分程序,而后考慮上面的優化也不為過;但養成“優化”的習慣不失為一個優秀程序員的品質。(以上是筆者的學習筆記,需要更深入了解上面的內容,閱讀原著或許收益更多。)關於珠璣的第十章,筆者實在無能為力 - =,只寫了第九章。
本文完 Sunday, April 15, 2012