Ruby 2.5 の改善を自慢したい

技術部でフルタイム 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 

最初の putobjecttrace_putobject に変わったのが見て取れると思います。普通の putobjectTracePoint について何もしませんが、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倍高速化することができました。

何を実現したいのか

あるメソッドに渡されたブロックを、ほかのメソッドに渡したい、というシチュエーションが時々あると思います。

def block_yield
  yield
end

def block_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 が必要ないのか?」ということを知る必要があることです。

def sample1 &b
  block_yield(&b)
end

このプログラムは、bProc にする必要はありません。ブロックの情報のまま、他のメソッドに渡してやればいいからです。

def sample2 &b
  b
end

このプログラムは、bProc にする必要があります。呼び出し側が返値として Proc オブジェクトを期待する(かもしれない)からです。

def sample3 &b
  foo(b)
end

このプログラムも、bProc にする必要があります。foo を呼んだ先で Proc オブジェクトを期待する(かもしれない)からです。

こう見ると、block_yield(&b) のようにしか使っていなければ、b はブロック情報のままで良さそうです。では、次の例はどうでしょうか。

def sample4 &b
  get_b(binding)
end

一見すると、b は触っていないので、ブロック情報のままで良いような気がします。が、binding オブジェクトを用いると、そのバインディングを生成した箇所のローカル変数にアクセスすることができるので、get_b の定義を、

def get_b bind
  bind.local_variable_get(:b)
end

のようにすると、b の中身をアクセスすることができます。この場合、bsample4 の返値になるため、やはり Proc オブジェクトにしなければなりません。binding が出たら諦める、という方法もあるのですが、binding はメソッドなので、任意の名前にエイリアスをつけることができます。つまり、どんなメソッド呼び出しも、binding になる可能性があるのです。まぁ、ほぼそんなことは無いと思いますが。

どうやら、プログラムの字面を見て、「bProc オブジェクトにする必要は無い」と言い切るのは難しそうです(このようなことを調べることを、コンパイラの用語ではエスケープ解析ということがあります)。

そこで、実行時に bblock_yield(&b) のようなブロック渡し以外のアクセスがあったとき、初めて Proc オブジェクトを生成するようにしました。

この高速化手法自体は長い間検討していたのですが、もう少し一般的なエスケープ解析が必要じゃないかと思って、それは Ruby では難しそうだな、どうしようかな、と考えていて実現できていませんでした。ただ、改めて考えてみると、ブロックパラメータへのアクセスを実行時に監視すればできることに、ふと自転車を乗っているときに気づいたので、実装することができました。

def iter_yield
  yield
end

def iter_pass &b
  iter_yield(&b)
end

def iter_yield_bp &b
  yield
end

def iter_call &b
  b.call
end

N = 10_000_000 # 10M

require '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        real
yield        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        real
yield        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 は速くならないのか?

def block_call &b
  b.call
  # b.call と同じことをやるように見える yield なら速い。
end

このようなプログラムがあったとき、b がブロック情報のままでも yield 相当の処理に変換してしまえば、Proc オブジェクトを生成せずに済みそうな気がします。が、Proc#callyield には違いがあり、単純に yield に変換することはできません。

さて、何が違うかというと、$SAFE の設定、待避を行う、という機能です。yield では $SAFE について特に何もしませんが、Proc#call では、$SAFEProc オブジェクト生成時のものに設定し、呼び出しから戻すときに、呼び出し時の $SAFE に戻します。つまり、Proc 呼び出しの中で $SAFE を変更しても、呼び出しが終われば元通り、ということです。この違いがなければ、単純な yield に変換することは容易なのですが...。

ところで、$SAFE ってそもそもご存じですかね? 知らない方もいらっしゃるかと思いますが、これからも知らないでもあまり困らないのではないでしょうか。外部からの入力を用いて system メソッドなどで外部コマンドを呼ぶ、といった危険な機能を検知するかどうかを決めるための機能ですが、現在ではあまり利用するということは聞きません(危険なことができないようにするには、もっと OS レベルのセキュリティ機能を使うことを検討してください)。

そういうわけで、「あまり使って無さそうだから、$SAFE なくしませんか? 性能向上も阻害するし」、といったことを Ruby 開発者会議という毎月行っている Ruby コミッタの集まりで聞いてみたところ、まつもとゆきひろさんから、「$SAFE をなくすのはどうかと思うが、Proc オブジェクトで $SAFE の復帰・待避はしなくていいよ」という言質を取ったので([Feature #14250])、Ruby 2.6 では b.call のようにブロックパラメータを呼び出す速度が向上するのではないかと思います。だいたい、上記マイクロベンチマークの処理では、callyield と同じくらいの速度になるんじゃないかと思います。実際、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 での私の発表をご参照ください。

www.slideshare.net

実は、今年の多くの開発時間が、この改善につぎ込まれています。というのも、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を開催予定です。

寒いのでお体に気をつけて、良いお年をお迎えください。