Rubyのメソッド呼び出しの比較

ふと思い付きでRubyのメソッド呼び出しのやり方で速度がどう変わるのかベンチマークをとってみた。

Benchmark.ips do |x|
  x.report 'direct' do |times|
    i = 0
    while i < times; i += 1
      obj.to_s
    end
  end

  x.report '__send__' do |times|
    i = 0
    while i < times; i += 1
      obj.__send__(:to_s)
    end
  end

  # ...略...

  x.compare!
end

これまでbenchamrk-ipsの使い方をきちんと調べていなかったのに気付いた。ブロック引数のことを知らず、むだに自力でループをまわしていたよ。(もちろんベンチマークの対象として明示的なループを含める場合もある。)

ともあれ上の抜粋のような感じで、比較対象を以下とした。

  • 直接呼び出し
  • __send__
  • public_send
  • Method#call
  • Proc#call
  • evalで定義したメソッドの直接呼び出し
  • evalで定義した特異メソッドの直接呼び出し
  • define_methodで定義したメソッドの直接呼び出し
  • define_singleton_methodで定義したメソッドの直接呼び出し

前提として次のような場面で使われる書き方を想定している(つもり)。

def process!(type)
  # typeに応じた処理に振り分ける
end

Ruby 2.4.2での結果はこのようなものだった:

ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-darwin17]
Warming up --------------------------------------
              direct   420.953k i/100ms
            __send__   385.947k i/100ms
         public_send   366.926k i/100ms
         Method#call   391.803k i/100ms
           Proc#call   362.438k i/100ms
          Runner#run   404.075k i/100ms
         SRunner#run   401.759k i/100ms
       define_method   385.629k i/100ms
     define_s_method   384.551k i/100ms
Calculating -------------------------------------
              direct     39.934M (± 1.5%) i/s -    199.953M in   5.008397s
            __send__     23.080M (± 1.0%) i/s -    115.398M in   5.000515s
         public_send     18.459M (± 1.8%) i/s -     92.465M in   5.011171s
         Method#call     24.425M (± 0.8%) i/s -    122.243M in   5.005233s
           Proc#call     15.354M (± 0.6%) i/s -     76.837M in   5.004481s
          Runner#run     26.642M (± 0.9%) i/s -    133.345M in   5.005392s
         SRunner#run     27.387M (± 0.8%) i/s -    137.000M in   5.002728s
       define_method     23.140M (± 0.7%) i/s -    116.074M in   5.016350s
     define_s_method     22.547M (± 2.5%) i/s -    112.673M in   5.001530s

Comparison:
              direct: 39933553.7 i/s
         SRunner#run: 27386819.7 i/s - 1.46x  slower
          Runner#run: 26642207.3 i/s - 1.50x  slower
         Method#call: 24424736.8 i/s - 1.63x  slower
       define_method: 23140233.0 i/s - 1.73x  slower
            __send__: 23079618.5 i/s - 1.73x  slower
     define_s_method: 22547456.3 i/s - 1.77x  slower
         public_send: 18459211.6 i/s - 2.16x  slower
           Proc#call: 15354192.9 i/s - 2.60x  slower

Method#callがいいところにいるのが少し意外だった。

ちなみにRuby 2.3.5、2.2.8では以下の通り。2.2→2.4で並び順が違っている点、比較の幅が小さくなっている点、など興味深い。

ruby 2.3.5p376 (2017-09-14 revision 59905) [x86_64-darwin16]
[...]

Comparison:
              direct: 41255655.5 i/s
         SRunner#run: 26035506.5 i/s - 1.58x  slower
          Runner#run: 25723329.6 i/s - 1.60x  slower
            __send__: 23379461.9 i/s - 1.76x  slower
         Method#call: 21672615.9 i/s - 1.90x  slower
         public_send: 18619443.0 i/s - 2.22x  slower
           Proc#call: 15689293.4 i/s - 2.63x  slower
       define_method: 13979854.7 i/s - 2.95x  slower
     define_s_method: 13939945.4 i/s - 2.96x  slower
ruby 2.2.8p477 (2017-09-14 revision 59906) [x86_64-darwin16]
[...]

Comparison:
              direct: 39551645.0 i/s
          Runner#run: 22201895.0 i/s - 1.78x  slower
         SRunner#run: 22007592.6 i/s - 1.80x  slower
         Method#call: 20553863.6 i/s - 1.92x  slower
            __send__: 19987987.2 i/s - 1.98x  slower
         public_send: 19081953.9 i/s - 2.07x  slower
           Proc#call: 13147615.6 i/s - 3.01x  slower
       define_method: 11609181.7 i/s - 3.41x  slower
     define_s_method: 11604994.9 i/s - 3.41x  slower

eval系が上位にくるのは当然ではあるものの、その準備には相応にコストがかかるはずである。そのあたりも含めたベンチマークをとってみよう、と思ったのだが、ここで対象としているような呼び出されるメソッドがかなり軽量なケースでは、かなりとんでもない回数のメソッド呼び出しがある状況でなければもとが取れないという結果になった。

まあ、それはそうか。実際のコードで検証しなければならないだろう。

ただ、PassengerやUnicornなどのように予めプロセスを待期させておくケースなど、運用上、初期化コストを無視できることもある。また、プロセスがそれなりに長時間生き続けるケースでも同様のことが言える。

参考までにRuby 2.4.2での結果を一部抜粋しておく:

Comparison:
         direct x100:   108983.2 i/s
    public_send x100:    83613.7 i/s - 1.30x  slower
    Method#call x100:    71422.0 i/s - 1.53x  slower
      Proc#call x100:    63886.4 i/s - 1.71x  slower
define_s_method x100:    38516.4 i/s - 2.83x  slower
  define_method x100:    33664.1 i/s - 3.24x  slower
    SRunner#run x100:    14121.4 i/s - 7.72x  slower
     Runner#run x100:    12891.5 i/s - 8.45x  slower
Comparison:
        direct x1000:    10918.1 i/s
   public_send x1000:     8458.4 i/s - 1.29x  slower
   Method#call x1000:     8377.2 i/s - 1.30x  slower
     Proc#call x1000:     7198.3 i/s - 1.52x  slower
define_s_method x1000:     6876.8 i/s - 1.59x  slower
 define_method x1000:     6660.9 i/s - 1.64x  slower
   SRunner#run x1000:     5311.9 i/s - 2.06x  slower
    Runner#run x1000:     5148.2 i/s - 2.12x  slower
Comparison:
       direct x10000:     1099.5 i/s
  Method#call x10000:      848.1 i/s - 1.30x  slower
  public_send x10000:      845.9 i/s - 1.30x  slower
define_s_method x10000:      747.6 i/s - 1.47x  slower
  SRunner#run x10000:      746.4 i/s - 1.47x  slower
define_method x10000:      741.0 i/s - 1.48x  slower
   Runner#run x10000:      737.3 i/s - 1.49x  slower
    Proc#call x10000:      728.2 i/s - 1.51x  slower
Comparison:
      direct x100000:      110.5 i/s
 Method#call x100000:       85.7 i/s - 1.29x  slower
 public_send x100000:       84.6 i/s - 1.31x  slower
 SRunner#run x100000:       78.6 i/s - 1.41x  slower
  Runner#run x100000:       78.3 i/s - 1.41x  slower
define_s_method x100000:       76.0 i/s - 1.45x  slower
define_method x100000:       75.9 i/s - 1.46x  slower
   Proc#call x100000:       73.4 i/s - 1.51x  slower
Comparison:
      direct x400000:       27.5 i/s
 Method#call x400000:       21.3 i/s - 1.29x  slower
 public_send x400000:       21.2 i/s - 1.29x  slower
 SRunner#run x400000:       19.9 i/s - 1.38x  slower
  Runner#run x400000:       19.7 i/s - 1.39x  slower
define_s_method x400000:       19.1 i/s - 1.44x  slower
define_method x400000:       19.0 i/s - 1.45x  slower
   Proc#call x400000:       18.2 i/s - 1.51x  slower

x10000というのは、1回の初期化と10,000回のメソッド呼び出しをしたことを表している。

ベンチマークコードはこんなの