【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は使われていない
ということがわかりました。
通常引数と同じようにキーワード引数が扱われているか
メソッド呼び出しでどのような処理が行われているかは、rubyのVMである 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のしくみ」オススメです。(結局最後はこれ