1. 程式人生 > >效能調優--永遠超乎想象 (原文最終修訂於 2006-08-28 晚上11:48:38)

效能調優--永遠超乎想象 (原文最終修訂於 2006-08-28 晚上11:48:38)

多年以前,我在開發一個C++的應用程式。我的同伴Jim Newkirk(當時的)過來告訴說,我們的一個公用函式執行得非常的緩慢。這個函式是用來轉換二進位制的樹結構資料為普通文字,並存儲到檔案中的。(這是在XML出現之前,但概念類似於XML)

我審視了這個函式一會兒,發現了一個線性查詢演算法,於是毫無疑問的將這個線性查詢演算法替換為二分查詢法(譯註:binary search),然後就把這個函式交回給了Jim。Jim幾小時後就回來了,問我是否有做過任何的改進,因為這個函式還是遲緩如初。

看來是我沒找到關鍵,於是就一遍又一遍的研究了這個函式,然後發現並改進了一些其它很明顯的演算法問題,可是效能依然沒有絲毫改進。函式依然緩慢,看到我對這個函式的無計可施,Jim也越來越沮喪。

最後,Jim終於找到了一個能夠去分析這個函式效能的辦法,就發現這個問題出自一個底層的叫strstream的C++庫(譯註1)。這個庫函式隨著文字資料的不斷增加,它不停的一次又一次的申請記憶體塊。這個函式單純根據即將讀入的文字資料大小來預先申請記憶體塊,速度也迅速的隨之以數量級的趨勢降低。

很久以前,曾有一次我要寫一個計算任意多邊形面積的演算法。我想出了一個不斷的把這個任意多邊形細分為三角形的主意。每次細分一個三角形,多邊形就會減少一個頂點,而它的面積就可以由此累加起來。由於不得不處理很多不規則的形狀,好久我才把這個功能寫好。一、兩天後,我就完成了這個厲害的演算法,它能計算任意的多邊形的面積。

幾天後,我的一個同事過來找我,說:“我新畫了多邊形的一條邊,可它花了45分鐘才計算出面積。所以,我要是重新繪製這條線斷或是調整它,面積都顯示不出。”45分鐘啊,很長的時間了,所以我就問她這個多邊形有多少個頂點,她告訴我有超過1,000個的。

看了看演算法,我認識到這個演算法的複雜度是O(N^3)的(譯註2),所以對於小多邊形來說很快,但對於大型的來說速度就慢得無法忍受了。我一遍又一遍的思考著這個問題,但卻找不到一個更好的演算法。(現今我們只要用google搜尋就好了,可那是現在而這是那時...)於是我們就把這個自動顯示面積的功能去掉,然後告訴客戶這太耗時了。

兩週之後,純屬偶然機會, 我正翻閱一本關於prolog程式語言的書(一個可愛又另類的程式語言,我建議你也學習學習它),然後就發現了一個計算多邊形面積的演算法。它優雅,簡單,而且是線性階(譯註2)

的,我是從來都沒寫出過這麼漂亮的演算法。我用了幾分鐘時間就實現了它,哇!即使拖動多邊形一個頂點繞著螢幕亂轉,面積竟然也可以及時更新。

 昨天晚上,我坐在一輛豪華轎車裡,從O'Hare駛向我在芝加哥北部郊區的家。I-294公路正在施工,而我們湊好趕上交通阻塞。於是我拿出我的Macbook Pro,然後開始即興的編寫Ruby程式。為了好玩,我開始編寫埃拉托色尼質數過濾演算法(譯註:Sieve of Eratosthenes)。我想讓程式一跑起來就能看到Ruby有多快,所以就在程式中增加了benchmark模組來度量速度。它相當快!能在兩秒鐘內算出所有在百萬以內的素數!對於一個解釋型語言來說還不錯。

我想知道這個演算法的複雜度O(x)是什麼樣的。坐在車裡不好算出來,於是我決定通過一些設定點取樣的方法來測出它。我從100,000開始到5,000,000,每間隔100,000執行一次這個演算法取樣,然後把這些取樣點繪在了一個圖上。竟然是線性階!

這個演算法怎能是線性階呢?它有一個巢狀的迴圈!難道不應該是複雜度類似於O(N^2),或者至少也應該是O(N log N)啊?這裡就是程式碼,你自己來看看:

 require 'benchmark'
def sievePerformance(n)
  r = Benchmark.realtime() do
    sieve = Array.new(n,true)
    sieve[0..1] = [false,false]
   
    2.upto(n) do |i|
      if sieve[i]
        (2*i).step(n,i) do |j|
          sieve[j] = false
        end
      end
    end
  end
  r
end

我的兒子Micah就坐在我旁邊,他看了看然後說:“這個迴圈最多隻應做到n平方根。”我慚愧的意識到這一定是導致線性階的原因。這個迴圈本應該在它剛到n平方根的時候就結束了的,可卻陷入了無用的線性迭代之中一直到n。

這個簡單的改變應該不僅僅能在取樣圖表上展現出原本的曲線形狀,演算法的效能也應能提高不少。如下:

require 'benchmark'
def sievePerformance(n)
  r = Benchmark.realtime() do
    sieve = Array.new(n,true)
    sieve[0..1] = [false,false]
   
    2.upto(Integer(Math.sqrt(n)) do |i|
      if sieve[i]
        (2*i).step(n,i) do |j|
          sieve[j] = false
        end
      end
    end
  end
  r

我把這兩幅取樣圖表拼接在了一起,如下:

真令人失望。首先,在圖上的sqrt(n)沒有展現出曲線來;其次,sqrt(n)的效能僅僅是原先的兩倍!一個函式的外迴圈上限在指數級別上變為的一半(即原來的平方根),可是速度的提升卻怎能只有2倍?

隨著我對這個演算法理解的加深,我認識到外層迴圈的迭代次數的增加,內層迴圈所耗用時間會因為兩個因素而減少。首先,步長增大了;其次,在篩選過程中出現了更多的'false'值,因此判斷語句會更少頻率的被執行。這兩個導致時間耗用降低的因素一定是導致演算法保持線性階的某種平衡因素。

我不是電腦科學家,而且對鑑別這個演算法到底是線性與否的數學問題我也不是非常感興趣。誰能猜出當外部迴圈的範圍縮小到原來上限值的平方根而效能卻只有2倍增長的原因?誰能猜到演算法本身竟然是線性階的?!

六年前,當大家剛開始沉迷於XP的時候,Kent Beck(譯註3)要在一組學生(大概30個左右)前示意一個演算法,我就為他寫了這個埃拉托色尼質數過濾演算法的Java例程。我驚訝的看到他從函式中把n的平方根刪掉,並替換成了n。他說“我不知道這是不是真的能讓演算法加快,不管如何,把上限設為n使得可讀性更好。”於是,他刪掉了這個特別的註釋,那是我在平方根周圍註釋來解釋為何不把上限設為n的聰明之處。

那時我眼珠子亂轉而且還在一旁偷偷傻笑。我確信,如果n很大的時候,上限是n平方根會讓演算法的效率在數量級上大於n的,我還深信n每擴大一百倍,它所耗費的時間只會隨之增加大概十倍。六年後(昨晚),我終於知道了程式的結果,而且知道了增幅是2倍線性階的,而對此Kent一直都是對的。

譯註:

1,strstream,標準C/C++的字串流類,派生自iostream。因效能問題,C++標準委員會做了修補,用stringstream替換之,因此也不建議再使用strstream。

2,複雜度(本文指時間複雜度),以演算法中基本運算的重複執行次數作為演算法時間複雜度的時間量度,並以符號O(x)來表示。通常,時間複雜度由小到大分為幾個等級,a)常量階 O(c),b)對數階 O(log2n),c)線性階 O(n),d)多項式階 O(nm)等

3,Kent Beck,是軟體開發方法學的泰斗、XP的創始人,長期致力於軟體工程的理論研究和實踐,並具有講授XP的豐富經驗。作為軟體業內最富創造,哇和最有口碑的領導人之一,KentBeck極力推崇模式、極限程式設計和測試驅動開發。他現在加盟於ThreeRivers研究所,是多部暢銷書如《Smalltalk Best PracticePatterns》、《解析極限程式設計——擁抱變化》和《規劃極限程式設計》(和Martin Fowler合著)的作者,並且是超級暢銷書《重構——改善既有程式碼的設計》(中國電力出版社出版中英文版)的特約撰稿人。

作者簡介:Robert C. MartinObject Mentor公司總裁,面向物件設計、模式、UML、敏捷方法學和極限程式設計領域內的資深顧問。他不僅是Jolt獲獎圖書《敏捷軟體開發:原則、模式與實踐》(中文版)(《敏捷軟體開發》(英文影印版))的作者,還是暢銷書Designing Object-Oriented C++ Applications Using the Booch Method的作者。MartinPattern Languages of Program Design 3More C++ Gems的主編,並與James Newkirk合著了XP in Practice。他是國際程式設計師大會上著名的發言人,並在C++ Report雜誌擔任過4年的編輯。