技術部でフルタイム Ruby コミッタをしている笹田です。最近ひさびさに Ruby のライブラリに pull request をしました(show valid break point lines #393)。
12/25 のクリスマスに、Ruby 2.5 が無事にリリースされました(Ruby 2.5.0 リリース)。関係各位の努力に感謝します。いろいろなバグ修正、いろいろな新機能、いろいろな性能改善があります(詳細は、上記リリースノート、もしくは Ruby のソースコードにある NEWS ファイルをご参照ください)ので、試して頂けると良いと思います。そういえば、私がクックパッドに入社して初めての Ruby リリースでした。
前回の techlife ブログ( Ruby の NODE を GC から卒業させた )で遠藤さんが
クックパッドからの主な貢献としては、「trace 命令の削除による高速化」や「分岐・メソッドカバレッジの測定のサポート」などがあります。
と書いていました。
リリースノートより「trace 命令の削除による高速化」について引用します。
命令列中のすべての
trace
命令を削除することで、5~10% の高速化を実現しました。trace
命令は TracePoint をサポートするために挿入されていましたが、ほとんどの場合、TracePoint は有効にされず、これらの命令は無用なオーバヘッドとなっていました。Ruby 2.5 では、trace 命令を用いる代わりに、動的書き換えを利用します。詳細は [Feature #14104]をご覧ください。
それから、同じくリリースノートに、ブロックパラメータに関する性能改善について書いてあります。
ブロックパラメータによるブロック渡し(例:
def foo(&b); bar(&b); end
)が、”Lazy Proc allocation” というテクニックを用いることで、Ruby 2.4 と比べて約3倍高速化しました。渡されたブロックを、さらに他のメソッドに渡したい場合、ブロックパラメータを利用する必要があります。しかし、ブロックパラメータは Proc オブジェクトの生成が必要であり、ブロック渡しのためにはこれが大きなオーバヘッドとなっていました。”Lazy Proc allocation” はこの問題を解決します。詳細は [Feature #14045]をご覧ください。
これらは私の仕事だったので、紹介文を書かせて頂きました。他と比べて長すぎますね。まぁ、迫力があっていいんじゃないでしょうか。具体的な数字があると、うれしいですしね。
本稿では、これらの機能をもう少し深掘りして、リリースノートやチケットでの議論では出てこない、普段、私がどんなことを考えながら開発しているのかをご紹介できればと思っています。また、これらの目立つ改善以外の、Ruby 2.5 のために私が行ってきた活動についてもご紹介します。
trace
命令の除去と命令の動的書き換え
まずは、リリースノートに書いてある「trace
命令の除去」についての話です。
何を実現したいのか
この新機能については、福岡Ruby会議02でのキーノート「Rubyにおけるトレース機構の刷新」でお話ししました。
www.slideshare.netというか、このキーノートに間に合わせるために開発予定を調整しました(EDD: Event Driven Development)。
Ruby には TracePoint
という機能があります(リファレンス。古くは set_trace_func
)。何かあると、例えば行を越えると何かフックを実行する、ということに使います。例えばこんな感じ。
trace = TracePoint.new(:line) do |tp| p tp end trace.enable do x = 1 y = 2 z = x + y end
は、TracePoint#enable
のブロック内で TracePoint
の :line
イベントごとに TracePoint#new
に渡したブロックを実行します。そのため、出力は次のようなものになります。
#<TracePoint:line@t.rb:6> #<TracePoint:line@t.rb:7> #<TracePoint:line@t.rb:8>
この機能を実現するために、VM が実行する命令列(バイトコード)中に、trace
命令というものを、フックを実行する可能性があるところに沢山挿入しています。一番多いのは :line
イベント用に、行が変わる度にこの trace
命令が挿入されています。つまり、5 行のメソッドには 5 つの trace
命令が入っています。
「TracePoint
って知ってますか?」と聞くと、多くの Rubyist は「知らない」と答えると思います。つまり、あんまり使われない機能なのですが、使われないと、trace
命令は単なるオーバヘッドにしかなりません。つまり、多くの場合、この命令は無駄なわけです。この無駄を排除するためのコンパイルオプション(Ruby のコンパイラはいくつかコンパイルオプションを受け取ります)を指定すれば、TracePoint
は動かなくなるけどちょっと速くなる、ということができたのですが、そもそもコンパイルオプションが指定できることを知っている人はごく少数ですよね。
なお、Ruby 2.4 以前を利用しなければならず、Ruby プログラムを 1% でも高速化したい、という方は、プログラムの最初に RubyVM::InstructionSequence.compile_option = {trace_instruction: false}
と書いておけば、trace
命令を利用しなくなります(が、TracePoint
が利用できなくなるため、例えば byebug といったデバッガが利用できなくなります)。
どうやって高速化するのか:trace
命令を排除
そこで、TracePoint
のために trace
命令を利用する、ということをやめました。代わりにどうするか。命令列の命令を書き換える、ということにしました。
実際の例を用いて説明します。
x=1 y=2 p x+y+3
このプログラムは、Ruby 2.4 では次のようにコンパイルされていました。
# Ruby 2.4 0000 trace 1 ( 2) 0002 putobject 1 0004 setlocal x, 0 0007 trace 1 ( 3) 0009 putobject 2 0011 setlocal y, 0 0014 trace 1 ( 4) 0016 putself 0017 getlocal x, 0 0020 getlocal y, 0 0023 send :+ 0027 putobject 3 0029 send :+ 0033 send :p 0037 leave
いくつか trace
命令が入っていることがわかります。これを、Ruby 2.5 では、
# Ruby 2.5 0000 putobject 1 ( 2)[Li] 0002 setlocal x, 0 0005 putobject 2 ( 3)[Li] 0007 setlocal y, 0 0010 putself ( 4)[Li] 0011 getlocal x, 0 0014 getlocal y, 0 0017 send :+ 0021 putobject 3 0023 send :+ 0027 send :p 0031 leave
このように trace
命令を排除した状態でコンパイルしておきます。trace
命令がないので、なんとなく速そう、という気分が伝わるんじゃないかと思います。伝わるといいな。
さて、TracePoint
を利用した時です。有効にした瞬間、Ruby プロセス中に存在するすべての命令列を探し出して、必要な形に変換します。今回の場合、次のように変換されます。
# Ruby 2.5 / Trace on! 0000 trace_putobject 1 ( 2)[Li] 0002 setlocal x, 0 0005 trace_putobject 2 ( 3)[Li] 0007 setlocal y, 0 0010 trace_putself ( 4)[Li] 0011 getlocal x, 0 0014 getlocal y, 0 0017 send :+ 0021 putobject 3 0023 send :+ 0027 send :p 0031 leave
最初の putobject
が trace_putobject
に変わったのが見て取れると思います。普通の putobject
は TracePoint
について何もしませんが、trace_putobject
は、まず TracePoint
についての処理を行ってから、従来の putobject
の処理を行います。
この手法は、「TracePoint
をオンにするタイミングで命令書き換えが起こるので、それが大きなオーバヘッドになる」という問題がありますが、そもそも TracePoint
は使われないので、問題ないと判断しました。
検討した点、苦労したところ
この辺から、リリースノートに書いていない話になります。
なぜ今まで trace
命令を使っていたのか?
見ての通り、難しい話はないので、もっと前からやっておけよ、と言われるかもしれませんが、YARV 開発から10年以上たって、やっと入りました。
以前は、命令の書き換えをしないほうが、別言語への変換(例えば、C 言語への変換)がやりやすいかな、と思っていたからなのですが、最近、「結局そういうことやってないしなぁ」と思ったり(すみません)、現在 JIT コンパイラの導入の話が進んでいますが、「書き換えるなら一度 JIT コンパイル済みコードをキャンセルすればいい」という踏ん切りがついたためです。なら、どうせなら派手に書き換えるようにしてしまえ、と思い、このようにしてみました。
書き換えるに当たり、trace_
prefix 命令ではなく、trace
命令を動的に挿入する、という選択肢もありました(これが、最も互換性に優れた方法です)。ただ、命令を増やすと命令アドレスを変更しなければならず、若干面倒です。そのため、各命令の位置は変更しない、という選択をしました(そのため、プロトタイプは一晩で実現できました)。今後、もっとアグレッシブに命令書き換えを行うためには、命令位置変更にも対応しないといけないと思っています。
trace
命令を沢山入れると、TracePoint
を有効にしない場合の速度劣化を気にしなければなりませんでしたが、これからはこのオーバヘッドが気にならなくなります。そのため、TracePoint
向けのイベントを追加できると思っています。例えば、定数やインスタンス変数をトレースしたり、メソッドを呼び出す caller 側をフックしたりすることができると思っています。
trace_
prefix 命令をいつ戻すのか
TracePoint
を有効にしている間は trace_
prefix 命令が必要です。ですが、無効にしたタイミングで、TracePoint
向けの処理が不要になります。そのため、初期実装では、TracePoint
がすべて不要になったタイミングで、同じようにすべての命令列を探し出して元に戻す処理を入れていました。これは、TracePoint
をパタパタと on/off 繰り返すようなプログラムはないだろうな、という予測に基づく設計でした。上記福岡Ruby会議02で紹介した時には、このような設計をしていました。off にするときのオーバヘッドを軽減するための工夫も盛り込んでいます。ただ、ある程度のオーバヘッドは、やはり必要になります(具体的には、ヒープ上からすべての命令列を探し出す部分)。
しかし、一部のライブラリ(具体的に問題としてあがってきたのは power-assert)では、TracePoint
の on/off が多く起こるため、問題になることがわかりました。そこで、結局一度 trace_
prefix 命令に変更すれば、その後 TracePoint
を無効にしても、そのままにしておくようにしました。TracePoint
向けのチェックがついてしまい、trace
命令があったときと同じように、若干遅くなるのですが、TracePoint
をちょっとだけ on にしてその後は利用しない、というシチュエーションは、あまりなさそうかな、と思い、最終的に「戻さない」とすることにしました。
非互換の対応
この変更にともない、バックトレースや TracePoint
などで得られる行番号がずれる、という問題がありました。多少、変わっても、人間が見る分には問題ないだろう、と思っていたのですが、人間以外が見る、具体的にはデバッガ等で特定の行番号(例えば、end
の位置の行番号)に依存した処理があったため、byebug という有名な Ruby 用デバッガで問題が起こる、ということがありました。
問題は修正できたのですが、この問題が発覚した(教えてもらった)のが 12/23 で、リリースの直前でした。久々にリリース直前にたくさんコーディングをして(例年は、リリース直前には怖くてコードをいじれません)、なんとか問題ないところまでもっていくことができました。本件でお世話になった関係各位に感謝いたします。
我々も、もっとちゃんと著名ライブラリはチェックしておかないとな、という反省をするとともに、RC1 リリースなどでちょっと試してもらえないかと読者の皆様にお願いするところです。
Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化
Lazy Proc allocation というテクニックを考えて、ブロックパラメータを用いたブロック渡しを約3倍高速化することができました。
何を実現したいのか
あるメソッドに渡されたブロックを、ほかのメソッドに渡したい、というシチュエーションが時々あると思います。
defblock_yieldyieldenddefblock_pass&b # do something block_yield(&b) end
block_pass
のようなメソッドを書くと思いますが、このときブロックローカル変数 b
でブロックを受け取り、その受け取ったブロックを block_yield(&b)
のように渡すことで、このような受け渡しを実現することができます(なお、ブロックを渡す他の(素直な)方法はありません)。
とりあえず、これで一件落着なのですが、ブロックローカル変数を使うと、yield
するだけに比べて遅くなってしまう、という問題が生じます。というのも、ブロックローカル変数は Proc
オブジェクトを受け取るのですが、この Proc
オブジェクトの生成が重いためです。なぜ遅いかを大雑把に言うと、関連するローカル変数領域などをメソッドフレームをたどって芋ずる的にヒープに確保しなければならないためです(この理由をより深く理解するには、Rubyのしくみ Ruby Under a Microscopeなどをご参照ください)。
渡されたブロックという情報を他のメソッドに渡すために、ブロックパラメータを経由してしまうため、Proc
という、より冗長なデータを受け取ってしまい、遅い、という問題です。これを解決したい。
ポイントは、ブロックの情報だけだったら軽い、というところです。
どうやって高速化をするか:Lazy Proc creation
block_yield(&b)
のようにブロックの情報を渡すだけなら、Proc
は必要ありません。なので、ブロックローカル変数が block_yield(&b)
のように、他のメソッドにブロックを渡すだけであれば、Proc
を作らなくてもよいようにすれば速くなりそうです。本当に Proc
が必要になるまで Proc
オブジェクトの生成を遅延する、だから Lazy Proc creation と名付けています。まぁ、ある意味自明な機能なのですが、それでも名前を付けると、なんかカッコいいですよね。なお、並列分散処理の分野で "Lazy task creation"という技法があります。あまり関係ないですけど、カッコいい手法なので興味があれば調べてみてください。
さて、ここで問題になるのは、「Proc
が必要ないのか?」ということを知る必要があることです。
defsample1&b block_yield(&b) end
このプログラムは、b
を Proc
にする必要はありません。ブロックの情報のまま、他のメソッドに渡してやればいいからです。
defsample2&b b end
このプログラムは、b
を Proc
にする必要があります。呼び出し側が返値として Proc
オブジェクトを期待する(かもしれない)からです。
defsample3&b foo(b) end
このプログラムも、b
を Proc
にする必要があります。foo
を呼んだ先で Proc
オブジェクトを期待する(かもしれない)からです。
こう見ると、block_yield(&b)
のようにしか使っていなければ、b
はブロック情報のままで良さそうです。では、次の例はどうでしょうか。
defsample4&b get_b(binding) end
一見すると、b
は触っていないので、ブロック情報のままで良いような気がします。が、binding
オブジェクトを用いると、そのバインディングを生成した箇所のローカル変数にアクセスすることができるので、get_b
の定義を、
defget_b bind bind.local_variable_get(:b) end
のようにすると、b
の中身をアクセスすることができます。この場合、b
が sample4
の返値になるため、やはり Proc
オブジェクトにしなければなりません。binding
が出たら諦める、という方法もあるのですが、binding
はメソッドなので、任意の名前にエイリアスをつけることができます。つまり、どんなメソッド呼び出しも、binding
になる可能性があるのです。まぁ、ほぼそんなことは無いと思いますが。
どうやら、プログラムの字面を見て、「b
を Proc
オブジェクトにする必要は無い」と言い切るのは難しそうです(このようなことを調べることを、コンパイラの用語ではエスケープ解析ということがあります)。
そこで、実行時に b
へ block_yield(&b)
のようなブロック渡し以外のアクセスがあったとき、初めて Proc
オブジェクトを生成するようにしました。
この高速化手法自体は長い間検討していたのですが、もう少し一般的なエスケープ解析が必要じゃないかと思って、それは Ruby では難しそうだな、どうしようかな、と考えていて実現できていませんでした。ただ、改めて考えてみると、ブロックパラメータへのアクセスを実行時に監視すればできることに、ふと自転車を乗っているときに気づいたので、実装することができました。
defiter_yieldyieldenddefiter_pass&b iter_yield(&b) enddefiter_yield_bp&b yieldenddefiter_call&b b.call endN = 10_000_000# 10Mrequire'benchmark'Benchmark.bmbm(10){|x| x.report("yield"){ N.times{ iter_yield{} } } x.report("yield_bp"){ N.times{ iter_yield_bp{} } } x.report("yield_pass"){ N.times{ iter_pass{} } } x.report("send_pass"){ N.times{ send(:iter_pass){} } } x.report("call"){ N.times{ iter_call{} } } } __END__ruby 2.5.0dev (2017-10-24 trunk 60392) [x86_64-linux] user system total realyield 0.634891 0.000000 0.634891 ( 0.634518)yield_bp 2.770929 0.000008 2.770937 ( 2.769743)yield_pass 3.047114 0.000000 3.047114 ( 3.046895)send_pass 3.322597 0.000002 3.322599 ( 3.323657)call 3.144668 0.000000 3.144668 ( 3.143812)modified user system total realyield 0.582620 0.000000 0.582620 ( 0.582526)yield_bp 0.731068 0.000000 0.731068 ( 0.730315)yield_pass 0.926866 0.000000 0.926866 ( 0.926902)send_pass 1.110110 0.000000 1.110110 ( 1.109579)call 2.891364 0.000000 2.891364 ( 2.890716)
ベンチマーク結果を見ると、ブロック渡しをしているケースでは、修正前と後で3倍程度性能向上していることがわかります。
なぜ block.call
は速くならないのか?
defblock_call&b b.call # b.call と同じことをやるように見える yield なら速い。end
このようなプログラムがあったとき、b
がブロック情報のままでも yield
相当の処理に変換してしまえば、Proc
オブジェクトを生成せずに済みそうな気がします。が、Proc#call
と yield
には違いがあり、単純に yield
に変換することはできません。
さて、何が違うかというと、$SAFE
の設定、待避を行う、という機能です。yield
では $SAFE
について特に何もしませんが、Proc#call
では、$SAFE
を Proc
オブジェクト生成時のものに設定し、呼び出しから戻すときに、呼び出し時の $SAFE
に戻します。つまり、Proc
呼び出しの中で $SAFE
を変更しても、呼び出しが終われば元通り、ということです。この違いがなければ、単純な yield
に変換することは容易なのですが...。
ところで、$SAFE
ってそもそもご存じですかね? 知らない方もいらっしゃるかと思いますが、これからも知らないでもあまり困らないのではないでしょうか。外部からの入力を用いて system
メソッドなどで外部コマンドを呼ぶ、といった危険な機能を検知するかどうかを決めるための機能ですが、現在ではあまり利用するということは聞きません(危険なことができないようにするには、もっと OS レベルのセキュリティ機能を使うことを検討してください)。
そういうわけで、「あまり使って無さそうだから、$SAFE
なくしませんか? 性能向上も阻害するし」、といったことを Ruby 開発者会議という毎月行っている Ruby コミッタの集まりで聞いてみたところ、まつもとゆきひろさんから、「$SAFE
をなくすのはどうかと思うが、Proc
オブジェクトで $SAFE
の復帰・待避はしなくていいよ」という言質を取ったので([Feature #14250])、Ruby 2.6 では b.call
のようにブロックパラメータを呼び出す速度が向上するのではないかと思います。だいたい、上記マイクロベンチマークの処理では、call
が yield
と同じくらいの速度になるんじゃないかと思います。実際、Ruby コミッタ(パッチモンスター)の中田さん実験ではそれくらいの速度が出ているようです。
その他の貢献の話
さて、実はここからが本番なのです。が、もう長々と書いてしまったので、短くまとめます。
上記二つの性能向上は、良い数字が出ているので目立つのですが、実はあんまり苦労していません。だいたい数日で実現できてしまっています(その後、安定させるために、もう少し時間がかかっているんですが)。では、それ以外は何をしていたのでしょうか。
クックパッドに入って、Ruby のテスト環境を新たに整備しました([ruby-core:81043] rapid CI service for MRI trunk)。いわゆる普通のテストを定期的に行う CI は rubyciというものがあるのですが、結果が出るまで時間がかかる、通知がないなど不満がありました。そこで、最短で2分で結果が出る環境を整備することにしました。計算機はクラウド環境では無く、実機を利用しています。私が主催した東京Ruby会議11の予備費を用いて購入したマシンと、ある企業様から Ruby Association へ寄贈頂いたマシン、それからクックパッドで確保できたマシンを利用しています。マシン調達・運用にお世話になった/なっている皆様に深く感謝いたします。
テストは、コミットフックを用いるのではなく、とにかく何度も何度もテストを繰り返す、という方針をとっており、時々しか出ないタイミング問題などをあぶり出すことも挑戦することができました(普通は、同じテストを2度以上実行しても、結果は変わらないと思いますが、Ruby のテストですと、そうでもないことがあります)。実際、いくつかの問題を発見しています(多くはテストの不備でした)。また、結果を Slack に流して(普通ですね)、問題のあるコミットがあれば、すぐに気づくことができるようにしました。複数環境で実行しているため、たとえばビルドエラーが起こると Slack に数十の通知が一斉に飛んでくるので、とても焦るので直さないと、という気分になります。
それから、Ruby でコルーチンを実現するための Fiber
周りの整理・改善を行いました(リファレンスマニュアル)。結果は Fiber
の切り替えが数% 速くなる、というなんとも地味な結果になりました。詳細は Ruby会議2017 での私の発表をご参照ください。
実は、今年の多くの開発時間が、この改善につぎ込まれています。というのも、Fiber という機能を私が作ったのが約10年前なのですが、その頃テキトーにしていたところを、全部見直して書き直す、という作業になったからです。「実行コンテキストの管理」という、バグも出やすいムズカシイ部分ですし、そもそも覚えていない。そういう点でも気合いと開発時間が必要でした。うまくいかなくて、何度も最初からやり直しました。
この修正は、一義的には Fiber のための改善なのですが、実は狙いは将来導入を検討している新しい並行・並列のための機能を入れるための基盤作りでした。将来のデザインのために、今のうちに改善を行ったということになります。今年この点を頑張ったおかげで、来年は挑戦しやすくなったなという感触を持っています。
また、今年最大の Ruby への貢献といえば、遠藤さんにクックパッドにジョインして頂き、フルタイム Ruby コミッタとして活躍して頂いたことじゃないかと思います。遠藤さんによる目覚ましい成果は、だいたい私の成果と言っても過言ではないかと思います(過言です)。
おわりに
本稿では、Ruby 2.5.0 に導入した性能向上の仕組みについて、詳しくご紹介しました。その際、どのようなことを考え、気をつけながら開発をしているかも書いてみました。ちょっとだけのつもりが、長くなってしまいました。Ruby 2.5.0 と Ruby 開発の魅力を少しでもお伝えできていれば幸いです。来年も Ruby 2.6 そして Ruby 3 の実現のためにがんばります。
もし、Ruby インタプリタの開発が面白そうだな、と思ったら、できる限りサポートしますのでお声かけください。今年8月に好評だったRuby Hack Challenge( Cookpad Ruby Hack Challenge 開催報告 )の2回目である Ruby Hack Challenge #2は1月末に英語メインで開催予定ですが(申し込み締め切りは過ぎてしまいました)、2月にも日本語メインで開催しようと思いますので、よろしければ参加をご検討ください。また、RHC もくもく会を、だいたい毎月どこかで開催していますので、そちらもチェック頂ければ幸いです。来月は 1/29 (月) に RHCもくもく会#4を開催予定です。
寒いのでお体に気をつけて、良いお年をお迎えください。