我是如何讓 Ruby 專案提升 10 倍速度的

oschina發表於2013-09-03

  這篇文章主要介紹了我是如何把ruby gem contracts.ruby速度提升10倍的。

  contracts.ruby是我的一個專案,它用來為Ruby增加一些程式碼合約。它看起來像這樣:

Contract Num, Num => Num
def add(a, b)
  a + b
end

  現在,只要add被呼叫,其引數與返回值都將會被檢查。酷!

 20 秒

  本週末我校驗了這個庫,發現它的效能非常糟糕。

                                     user     system      total        real
testing add                      0.510000   0.000000   0.510000 (  0.509791)
testing contracts add           20.630000   0.040000  20.670000 ( 20.726758)

  這是在隨機輸入時,執行兩個函式1,000,000次以後的結果。

  所以給一個函式增加合約最終將引起極大的(40倍)降速。我開始探究其中的原因。

 8 秒

  我立刻就獲得了一個極大的進展。當一個合約傳遞的時候,我呼叫了一個名為success_callback的函式。這個函式是完全空的。這是它的完整定義:

def self.success_callback(data)
end  

  這是我歸結為“僅僅是案例”(未來再驗證!)的一類。原來,函式呼叫在Ruby中代價十分昂貴。僅僅刪除它就節約了8秒鐘!

                                     user     system      total        real
testing add                      0.520000   0.000000   0.520000 (  0.517302)
testing contracts add           12.120000   0.010000  12.130000 ( 12.140564)

  刪除許多其他附加的函式呼叫,我有了9.84-> 9.59-> 8.01秒的結果。這個庫已經超過原來兩倍速了!

  現在問題開始有點更為複雜了。

 5.93 秒

  有多種方法來定義一個合約:匿名(lambdas),類 (classes), 簡單舊資料(plain ol’ values), 等等。我有個很長的case語句,用來檢測它是什麼型別的合約。在此合約型別基礎之上,我可以做不同的事情。通過把它改為if語句,我節約了一些時間,但每次這個函式呼叫時,我仍然耗費了不必要的時間在穿越這個判定樹上面:

if contract.is_a?(Class)
  # check arg
elsif contract.is_a?(Hash)
  # check arg
...

  我將其修改為合約定義的時候,以及建立lambdas的時候,只需一次穿越樹:

if contract.is_a?(Class)
  lambda { |arg| # check arg }
elsif contract.is_a?(Hash)
  lambda { |arg| # check arg }
...

  之後我通過將引數傳遞給這個預計算的lambda來進行校驗,完全繞過了邏輯分支。這又節約了1.2秒。

                                     user     system      total        real
testing add                      0.510000   0.000000   0.510000 (  0.516848)
testing contracts add            6.780000   0.000000   6.780000 (  6.785446)

  預計算一些其它的if語句幾乎又節約1秒鐘:

                                     user     system      total        real
testing add                      0.510000   0.000000   0.510000 (  0.516527)
testing contracts add            5.930000   0.000000   5.930000 (  5.933225)

 5.09 秒

  斷開.zip的.times為我幾乎又節約了一秒鐘:

                                     user     system      total        real
testing add                      0.510000   0.000000   0.510000 (  0.507554)
testing contracts add            5.090000   0.010000   5.100000 (  5.099530)

  原來,

args.zip(contracts).each do |arg, contract|

  要比

args.each_with_index do |arg, i|

  更慢,而後者又比

 args.size.times do |i|

  更慢。

  .zip耗費了不必要的時間來拷貝與建立一個新的陣列。我想.each_with_index之所以更慢,是因為它受制於背後的.each,所以它涉及到兩個限制而不是一個。

 4.23 秒

  現在我們看一些細節的東西。contracts庫工作的方式是這樣的,對每個方法增加一個使用class_eval的新方法(class_eval比define_method快)。這個新方法中包含了一個到舊方法的引用。當新方法被呼叫時,它檢查引數,然後使用這些引數呼叫老方法,然後檢查返回值,最後返回返回值。所有這些呼叫contractclass:check_args和check_result兩個方法。我去除了這兩個方法的呼叫,在新方法中檢查是否正確。這樣我又節省了0.9秒:

                                     user     system      total        real
testing add                      0.530000   0.000000   0.530000 (  0.523503)
testing contracts add            4.230000   0.000000   4.230000 (  4.244071)

 2.94 秒

  之前我曾經解釋過,我是怎樣在合約型別基礎之上建立lambdas,之後再用它們來檢測引數。我換了一種方法,用生成程式碼來替代,當我用class_eval來建立新的方法時,它就會從eval獲得結果。一個糟糕的漏洞!但它避免了一大堆方法呼叫,並且為我又節省了1.25秒。

                                     user     system      total        real
testing add                      0.520000   0.000000   0.520000 (  0.519425)
testing contracts add            2.940000   0.000000   2.940000 (  2.942372)

 1.57秒

  最後,我改變了呼叫重寫方法的方式。我之前的方法是使用一個引用:

# simplification
old_method = method(name)

class_eval %{
    def #{name}(*args)
        old_method.bind(self).call(*args)
    end
}

  我把方法呼叫改成了 alias_method的方式:

alias_method :"original_#{name}", name
class_eval %{
    def #{name}(*args)
        self.send(:"original_#{name}", *args)
      end
}

  這帶給了我1.4秒的驚喜。我不知道為什麼 alias_method is這麼快...我猜測可能是因為跳過了方法呼叫和繫結

                                     user     system      total        real
testing add                      0.520000   0.000000   0.520000 (  0.518431)
testing contracts add            1.570000   0.000000   1.570000 (  1.568863)

 結果

  我們設計是從20秒到1.5秒!是否可能做得比這更好呢?我不這麼認為。我寫的這個測試指令碼表明,一個包裹的新增方法將比定期新增方法慢3倍,所以這些數字已經很好了。

  方法很簡單,更多的時間花在呼叫方法是隻慢3倍的原因。這是一個更現實的例子:一個函式讀檔案100000次:

                                     user     system      total        real
testing read                     1.200000   1.330000   2.530000 (  2.521314)
testing contracts read           1.530000   1.370000   2.900000 (  2.903721)

 慢了很小一點!我認為大多數函式只能看到稍慢一點,addfunction是個例外。

 我決定不使用alias_method,因為它汙染名稱空間而且那些別名函式會到處出現(文件,IDE的自動完成等)。

 一些額外的:

  1. Ruby中方法呼叫很慢,我喜歡將我的程式碼模組化的和重複使用,但也許是我開始內聯程式碼的時候了。
  2. 測試你的程式碼!刪掉一個簡單的未使用的方法花費我20秒到12秒。

 其他嘗試的東西

  方法選擇器

  Ruby2.0沒有引入的一個特性是方法選擇器,這執行你這樣寫

class Foo
  def bar:before
    # will always run before bar, when bar is called
  end

  def bar:after
    # will always run after bar, when bar is called
    # may or may not be able to access and/or change bar's return value
  end
end

  這使寫裝飾器更容易,而且可能更快。

  keywordold

  Ruby2.0沒有引入的另一個特性,這允許你引用一個重寫方法:

class Foo
  def bar
    'Hello'
  end
end 

class Foo
  def bar
    old + ' World'
  end
end

Foo.new.bar # => 'Hello World'

  使用redef重新定義方法

  這個Matz說過:

To eliminatealias_method_chain, we introducedModule#prepend. There’s no chance to add redundant feature in the language.

  所以如果redef是冗餘的特徵,也許prepend可以用來寫修飾器了?

  其他的實現

  到目前為止,所有這一切都已經在YARV上測試過。也許Rubinius會讓我做更加優化?

 參考

  原文地址:http://www.adit.io/posts/2013-03-04-How-I-Made-My-Ruby-Project-10x-Faster.html

相關文章