圧倒亭グランパのブログ

30年後の自分にもわかるように書くブログ

【ruby】キーワード引数のメソッド呼び出しは遅い!しかし2.2.0-preview2 以降で劇的に改善されていた話

Rubyのしくみ」を読んでいたら、「キーワード引数って遅いんじゃないか?」と思ったので調べてみました。

まずは結果から。

rubyの各バージョンで、キーワード引数がどのくらい遅いのかを調べてみました。

調査コードは以下です。

Makefile
VERSIONS= \
        2.0.0-p0 \
        2.1.0 \
        2.2.0-preview1 \
        2.2.0-preview2 \
        2.2.0 \
        2.3.0

run: $(VERSIONS)

$(VERSIONS):
    @rbenv global $@
    @rbenv rehash
    @echo ""
    @echo "==============================================================="
    @ruby -v
    @echo "---------------------------------------------------------------"
    @ruby keyword.rb
    @echo ""
keyword.rb
require 'benchmark'

def no_arg
  2 + 3
end

def normal_arg(a, b)
  a + b
end

def keyword_arg(a: 1, b: 1)
  a + b
end

n = 10000000
result = Benchmark.bm do |b|
  b.report("no_arg     ") { n.times { x = no_arg                  } }
  b.report("normal_arg ") { n.times { x = normal_arg(2, 3)        } }
  b.report("keyword_arg") { n.times { x = keyword_arg(a: 2, b: 3) } }
end

結果は以下。

$ make run

===============================================================
ruby 2.0.0p0 (2013-02-24 revision 39474) [x86_64-darwin14.5.0]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.640000   0.010000   0.650000 (  0.645543)
normal_arg   0.690000   0.000000   0.690000 (  0.691584)
keyword_arg  8.530000   0.080000   8.610000 (  8.618338)


===============================================================
ruby 2.1.0p0 (2013-12-25 revision 44422) [x86_64-darwin14.0]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.670000   0.000000   0.670000 (  0.669037)
normal_arg   0.720000   0.000000   0.720000 (  0.725388)
keyword_arg  9.940000   0.080000  10.020000 ( 10.024457)


===============================================================
ruby 2.2.0preview1 (2014-09-17 trunk 47616) [x86_64-darwin14]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.660000   0.000000   0.660000 (  0.664598)
normal_arg   0.680000   0.000000   0.680000 (  0.685753)
keyword_arg 10.270000   0.080000  10.350000 ( 10.347111)


===============================================================
ruby 2.2.0preview2 (2014-11-28 trunk 48628) [x86_64-darwin14]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.690000   0.000000   0.690000 (  0.692030)
normal_arg   0.790000   0.000000   0.790000 (  0.783927)
keyword_arg  1.060000   0.000000   1.060000 (  1.068646)


===============================================================
ruby 2.2.0p0 (2014-12-25 revision 49005) [x86_64-darwin14]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.670000   0.010000   0.680000 (  0.663958)
normal_arg   0.680000   0.000000   0.680000 (  0.686735)
keyword_arg  0.940000   0.000000   0.940000 (  0.937196)


===============================================================
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin14]
---------------------------------------------------------------
             user       system     total    real
no_arg       0.570000   0.000000   0.570000 (  0.578201)
normal_arg   0.600000   0.000000   0.600000 (  0.596595)
keyword_arg  0.980000   0.000000   0.980000 (  0.982302)

相変わらず「引数なし」「通常引数」に比べて遅いものの、

2.2.0-preview1 → 2.2.0-preview2 にかけて10倍近く速度が改善されています。

2.2.0-preview1以前をお使いの方。

改善の余地あるかもしれません。

 

何が起こっているのか

なんでこんなに速度改善されているのでしょうか。

実は以下のようになっているので、速度に差があるみたいです。

  • 2.2.0-preview1 以前
    • キーワード引数の変数と値のバインドに Hash を使っている
  • 2.2.0-preview2 以降
    • rubyのコアコードの改善により、通常引数のように最適化された処理で引数を扱っている

検証してみます。

 

本当にHashを使っているか

2.2.0-preview1 で試してみましょう。

これは「Rubyのしくみ」でも行っていた検証です。

hash.rb
# Hash#key? を書き換える
class Hash
  def key?(val)
    puts "Looking for key #{val}"
    false
  end
end

def keyword(a: 1, b: 1)
  a + b
end

puts keyword(a: 2, b: 3)
$ ruby -v
ruby 2.2.0preview1 (2014-09-17 trunk 47616) [x86_64-darwin14]
$ ruby hash.rb
Looking for key a
Looking for key b
2

Hash#key? を各引数で呼び出していることがわかります。

かつ、強制的にfalseを返し「引数が見つからない」と応答しているので、デフォルト引数が使われていることも分かります。

では 2.2.0-preview2 で試してみましょう。

$ rbenv global 2.2.0-preview2
$ ruby -v
ruby 2.2.0preview2 (2014-11-28 trunk 48628) [x86_64-darwin14]
$ ruby hash.rb
5

今度は Hash#key? を使っていません。

かつ、与えた引数がちゃんと使われています。

以上の検証より、

  • 2.2.0-preview1では、実際にHashが使われている
  • 2.2.0-preview2では、Hashは使われていない

ということがわかりました。

 

通常引数と同じようにキーワード引数が扱われているか

メソッド呼び出しでどのような処理が行われているかは、rubyVMである YARV (Yet Another Ruby VM) の命令を見ると少しわかります。

以下のコードで検証します。

yarv.rb
code = <<-EOS
def keyword(a: 1, b: 1)
  a + b
end
puts keyword(a: 2, b: 3)
EOS

puts RubyVM::InstructionSequence.compile(code).disasm

2.2.0-preview1で実行してみます。

$ rbenv global 2.2.0-preview1
$ ruby -v
ruby 2.2.0preview1 (2014-09-17 trunk 47616) [x86_64-darwin14]
$ ruby yarv.rb
-- snip --
== disasm: <RubyVM::InstructionSequence:keyword@<compiled>>=============
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, keyword: 2@2] s0)
[ 4] a          [ 3] b          [ 2] ?
0000 getlocal_OP__WC__0 2                                             (   1)
0002 dup
0003 putobject        :a
0005 opt_send_simple  <callinfo!mid:key?, argc:1, ARGS_SKIP>  # (1)
0007 branchunless     18
0009 dup
0010 putobject        :a
0012 opt_send_simple  <callinfo!mid:delete, argc:1, ARGS_SKIP>  # (2)
0014 setlocal_OP__WC__0 4
0016 jump             21
0018 putobject_OP_INT2FIX_O_1_C_
0019 setlocal_OP__WC__0 4
0021 dup
0022 putobject        :b
0024 opt_send_simple  <callinfo!mid:key?, argc:1, ARGS_SKIP>  # (1)
0026 branchunless     37
0028 dup
0029 putobject        :b
0031 opt_send_simple  <callinfo!mid:delete, argc:1, ARGS_SKIP>  # (2)
0033 setlocal_OP__WC__0 3
0035 jump             40
0037 putobject_OP_INT2FIX_O_1_C_
0038 setlocal_OP__WC__0 3
0040 pop
-- snip --

(1)(2)でHashのメソッドを呼び出しています。

Hash#key? だけでなく Hash#delete も呼んでいたんですね。

次に 2.2.0-preview2 です。

$ rbenv global 2.2.0-preview2
$ ruby -v
ruby 2.2.0preview2 (2014-11-28 trunk 48628) [x86_64-darwin14]
$ ruby yarv.rb
-- snip --
== disasm: <RubyVM::InstructionSequence:keyword@<compiled>>=============
local table (size: 4, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@0, kwrest: -1])
[ 4] a          [ 3] b          [ 2] ?
0000 trace            8                                               (   1)
0002 trace            1                                               (   2)
0004 getlocal_OP__WC__0 4
0006 getlocal_OP__WC__0 3
0008 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>
0010 trace            16                                              (   3)
0012 leave                                                            (   2)

こちらは Hash のメソッドが見当たりません。

では、「通常引数のメソッド呼び出し」と比べてみましょう。

yarv_normal.rb
code = <<-EOS
def normal(a, b)
  a + b
end
puts normal(2, 3)
EOS

puts RubyVM::InstructionSequence.compile(code).disasm

通常引数の場合のYARV命令列を見てみます。

$ rbenv global 2.2.0-preview2
$ ruby -v
ruby 2.2.0preview2 (2014-11-28 trunk 48628) [x86_64-darwin14]
$ ruby yarv_normal.rb
-- snip --
== disasm: <RubyVM::InstructionSequence:normal@<compiled>>==============
local table (size: 3, argc: 2 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 3] a<Arg>     [ 2] b<Arg>
0000 trace            8                                               (   1)
0002 trace            1                                               (   2)
0004 getlocal_OP__WC__0 3
0006 getlocal_OP__WC__0 2
0008 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>
0010 trace            16                                              (   3)
0012 leave                                                            (   2)

ところどころ数値は違いますが、先ほどの2.2.0-preview2のキーワード引数の場合と似ていますね。

つまり、2.2.0-preview2以降では、キーワード引数の扱いは通常引数と同じように最適化されているということになります。

 

まとめ

  • キーワード引数呼び出しは、引数なしや通常引数のメソッド呼び出しよりも遅い
  • キーワード引数呼び出しは、2.2.0-preview1以前は相当遅かった
  • キーワード引数呼び出しは、2.2.0-preview2以降、2.2.0-preview1以前よりも10倍程度速い

まだまだ YARVの中身は理解できていませんが、rubyの内部処理に触れたことでこのような疑問を持つことになりましたし、検証方法も学べるので良いですね。

Rubyのしくみ」オススメです。(結局最後はこれ