《編程珠璣,字字珠璣》910讀書筆記——代碼優化


寫在最前面的

再龐大復雜的代碼編譯器都能接受,編譯器會變得越來越聰明,讓我們原本的代碼更加高效。但是代碼執行的多變與不可預測性,如果編譯器大肆“優化”,偶爾或者大膽的說“在大多數情況下”,會得到“聰明反被聰明誤”的后果,所以編譯器非常小心謹慎,一遇到不可預測后果的優化,它就會立即折返,停止這一步的優化工作,因為它不知道程序員的本意是什么,“它怕得罪你”。
程序員要編寫容易優化的代碼,以幫助編譯器掃清障礙。關於代碼優化,筆者特別喜歡《深入理解計算機系統》一書中的第五章,有興趣的可以閱讀一下。

代碼優化小剖 

代碼優化的方法總結了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
    所以toupper中對i的每一次檢測,都調用了strlen,由此可見toupper的浪費由來。以前寫程序一直采用上面的toupper,以為夠簡潔,就像第三點中的sum偽代碼。  

  • 減少不必要的存儲器引用
    sum(* src,n,* dest)     //     src向量相加結果放入dest
         for i=[ 0,n)
            *dest += src + i
    代碼如此簡潔而有力,以前一直為這種風格而自豪。但它的缺點也是顯然的,循環內部過多的引用dest地址:每次循環都要把dest地址內的數據取出到寄存器接,寄存器相加得到結果后,又從寄存器寫入到dest地址,所以dest讀n次,寫n次。這有何必呢?讀寫浪費很浪費,下面的代碼會不會好點:  
    sum(* src,n,* dest)     //     src向量相加結果放入dest
        temp = *dest;
         for i=[ 0,n)
            temp += src + i
        *dest = temp

  • 循環展開 
    引用《深入理解》P348 "首先,它減少了不直接有助於程序結果的操作的數量,例如循環索引計算和條件分支。其次,它提供了一些方法(重結合變換),可以進一步變化代碼,減少整個計算中關鍵路徑上的操作數量。"關鍵路徑,書上對它的解釋是執行一組機器指令所需時鍾周期的一個下界。比如乘法和加法的周期,乘法就應該成為這組執行機器指令的關鍵路徑。  

    什么是循環開銷?比如:
    for i=( 0,n]
    每一循環的開始都要對i做判斷,以及對i自增,所以循環展開能降低這些開銷。二路循環展開的偽代碼:
    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個操作。 
    同樣,乘法也能得到效率的提高。注意循環寄存器有兩個,數據相關降低,兩個循環寄存器的運算是並行的。

 

關於第六種優化方法——重新結合變換 

書上還有說第六種優化——重新結合變換,如果大膽對浮點運算進行此類優化,性能有很大的提高。之所以沒有標號,是因為筆者也不太能說清楚,說說筆者的理解。

sum(* src,n,* dest)     //     src向量相加結果放入dest
    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的圖,后者大家可以自己手動畫畫。

image 

哈哈,書上說:未經訓練的人員,上面的兩條語句是一樣的,“搗亂笑而不語啊!”我的理解是雖然循環寄存器只有一個,但是temp = temp * (src+i * src+i+1)中(src+i * src+i+1)的計算不依賴於循環寄存器的值即temp的值,而temp = (temp * src+i) * src+i+1會對temp產生依賴,結合順序會對循環寄存器進行產生依賴,因此前者可以增加計算的並行性。 

神馬?什么是循環寄存器?對於某些循環來說,有些寄存器既作為源值又作為目的,一次循環的結果會在下一次循環中用到。循環寄存器之間的關聯越大,那么,這種關聯將是性能提升的瓶頸。 

簡單舉個例子:

sum(* src,n,* dest)     //     src向量相加結果放入dest
    temp = *dest;
     for i=[ 0,n)
        temp += src + i
    *dest = temp

那么temp所在寄存器就是所謂的“循環寄存器”,它使得每一次循環都有很大的關聯,所以,temp的加法操作(抑或是乘法操作)是關鍵路徑,這也就是為什么累積變量能夠提高程序的性能,它有兩個循環寄存器,降低了循環關聯。 

    《珠璣》第九章的代碼優化,印象比較深的:

  1.     整數取模 
  2.     函數內斂 
  3.     循環展開 

跟上面的內容差不多。 

關於哨兵

哨兵就是能幫助程序檢測數組邊界的東西,簡化了數組邊界的檢測,從而使代碼更加清晰簡便。記得一開始接觸哨兵是在順序表查找的時候。

search(* arr,n,data)
    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,才后移,這里會有兩個判斷。

insert_sort
     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]

另外,用單鏈表存儲一組順序數據,對於這種問題,一般的單鏈表插入,都要考慮頭插法和尾插法的情況,其他情況的代碼可以是一致的;如果能夠為單鏈表的最后添加哨兵,應該可以很大程度上簡化代碼。下面的代碼比一般的單鏈表插入簡潔而且應該更高效:

pre_insert:把maxval放置鏈表的最后
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次的檢測。
放置哨兵的做法:在數組的最后放置“已經找到的最大的元素”,然后逐個檢驗,

find_max(* arr)
    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 

搗亂小子 http://daoluanxiaozi.cnblogs.com/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM