性能調優--永遠超乎想象

 多年以前,我在開發一個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合著)的作者,並且是超級暢銷書《重構——改善既有代碼的設計》(中國電力出版社出版中英文版)的特約撰稿人。

 

 

原文地址:http://blog.csdn.net/rmartin/article/details/1132312

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章