バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

Ruby高速化の戦い@RubyKaigi2019

梅雨の季節と思いきや、清々しい天候の続くこの頃、皆様いかがお過ごしでしょうか? Tech開発部アーキテクチャグループで色々やっております塚本と申します。普段はスクラムマスターやったりしています。

もうひと月以上経ってますが、先日参加してきたRubyKaigiのセッションの内容を掘り下げて記事にしてみます。 Kaigiの概要は直也さんの記事をご参照ください。

tl;dr;

  • Rubyはよい
    • 個人的にはコミッターがよい(失礼)
  • 発表もよい
    • 記事にしたいセッションが絞りきれず執筆が大変なことに
    • Rubyコアの速度改善の話に絞りました
    • 他はまた別途

f:id:bst-tech:20190606081446j:plain
Rubyist world map

Rubyに対して

私のRuby歴は全部足して3年くらいで、関わってきたコミュニティの皆さんほど言語に対する思い入れもなく、Railsなんかツラい思い出が9割くらいなんですが、Rubyはいい言語だと思います。 端的にいうとMatzが「プログラミングを楽しむこと」を重視していて、それをRubyコミュニティの皆さんも体現している。 そこが素晴らしいと思います。 ところが私の知る限り、いつの世もプログラミング言語の宗教戦争は絶えず、Rubyもその舌渦の只中におります。これまでPythonやGolangを書いていたときのほうがツライ思い出は少ないせいで、「Rubyじゃなくてもいいんじゃないか?」と思うことも私自身よくありますが、それでもRubyはよい言語です。これだけは言っておきたいのですが、「よい言語かどうかを決めるのに他言語と比較する必要はない」ということです。

Rubyの速さ

特に以前、Railsアプリで大量のバッチ処理を扱っていたときに並列処理できず単体プロセスの処理速度にも問題を抱えていたこともあり、Rubyの速さに関しては気になっていました。 当然コミッタの方々も長年その問題に取り組んできていて、マルチコアへの対応などありますが、今回はJIT周りにフォーカス。

JITコンパイラによるRuby高速化の話

Performance Improvement of Ruby 2.7 JIT in Real World

rubykaigi.org

Ruby2.6からマージされている JITですが、「RailsではメソッドがJITコンパイルされるほど遅くなる」というIssueが昨年から提起されています bugs.ruby-lang.org

今、Ruby 2.7に向けてそこの改善に取り組んでいる戦いの様子を@k0kubunさんが発表

Rails遅くなる問題をもう少し噛み砕くと、Rails(に限らずWebフレームワーク全般)には無数のメソッドが装備されていて、これらを一気にコンパイルしようとしてマシンリソースを死ぬほど食ってしまう → そして通常のRailsへのリクエスト処理も遅くなる

ということで、問題切り分けのためコンパイルが全て終わった状態でRailsをベンチマークすると、それでもまだ微妙に遅くなっている

Discourseによる計測

* このあとの、Noahgibbsさんのスピーチでも出てきますが、DiscourseというRailsアプリで計測するのがお作法っぽい

コンパイルに死ぬほど時間かかる問題は置いておいても、なぜJITによって速くならんのか? そこを分析していくお話(本題やっとここから)

  • discourseはプロファイルするために安定状態にもっていくスタンバイに時間が食うため効率が悪い
  • そこで別のリポジトリ(Railsbench)を用意

github.com

  • Railsbenchで計測し直してみるとやっぱり遅い
  • 2.7になるとマシなのでJITの方向性は間違ってなさそう
  • よっしゃがんばっていろいろ試していこう

その取り組みが以下5本

  1. Profile-guided Optimization
  2. Optimization Prediction
  3. Deoptimized Recompilation
  4. Frame-omitted method Inlining
  5. Stack-based Object Allocation

それぞれ丁寧に説明してくれてましたが、ところどころ難解な箇所あったので、初心者でもわかるレベルに落とし込んでます。

1. Profile-guided Optimization

問題としては、JITedメソッドをたくさん呼ぶほど遅いこと

そこで Profile-guided Optimization 平たく言うと、GCCコンパイル時にプロファイル生成を挟むといい感じに最適化されるだろうという方法。 がしかし、Railsが遅い問題の解決にはならない(という気づきを得た)。

よって今回はこの方法は見送り。

2. Optimization Prediction

JITコンパイルしても効率が悪いようなメソッドを予測して、それらをコンパイルしないように除いたりできるだろう がしかし、これも効果としてはうまくいかない。。

3. Deoptimized Recompilation

  • JITキャンセルという挙動*1が起こるほどにオーバーヘッドになる
  • 全体の1/6はキャンセルされている(Railsbench計測)
  • 特にライブラリの中によくあるブラケットメソッド*2の実装がキャンセルが起きやすい
  • このブラケットメソッドを多重継承するとInvalidatedされる
  • Railsではこのような多重継承するコードが大量になるため多くのキャンセルが起きる

という問題でした。

この問題は元々ある、キャンセルの最適化を無効化する機能を少しシンプルにすることで解消できる。 そしてこれは効果があった!

4. Frame-omitted method Inlining

Rubyはシンプルにメソッドコールが遅い(Rubyでは有名な話)*3

これはシンプルにメソッドのインライン化で解決できる

  1. JITが生成したコードの関数ポインタを呼び出す代わりに、まったく同様のCの関数定義を書いてインライン関数として呼び出す(めんどくさくない?)
  2. メソッド呼び出しでcallスタックに積まれていくやつを積まなくする(ことによって)メソッドがなかったかのような振る舞いをする

2番目のほうがJITとメソッドインライン化による正当な恩恵ですね

ただ、メソッドのインライン化をするためには、対象のメソッド呼び出しが副作用を持たないことを判定しないといけない。 副作用を持たないのであれば、コアのクラスのメソッドをRubyで書き直したり、JIT使うことにより効果がある

5. Stacked-based Object Allocation

  • オブジェクトを作れば作るほど遅くなる
  • Railsアプリはオブジェクトでいっぱい

JITはオブジェクトをStackに積むことができる

なので、ローカル変数で渡すのでなく、オブジェクトがループ内で何度も作られていたようなコードに効くようになる

arrを渡すだけの上の式と、[1,2,3]オブジェクトを毎回作成する下の式の比較
ベンチマーク結果

ただしこれもメソッドの外側に参照が飛ばないようになっている(escapeしない)条件がある ↑ ここさらっと書いてますが、まったくもって大変なことを解析している

結論

  • 愚直に改善を試行しつつ確実に前進しているのすごい(凄さを伝えられない)
  • Rubyプログラマに .freeze のようなマイクロ最適化を毎回させたくない(by @k0kubun氏)

こういう猛者がユーザのために日々がんばっているRubyは、いちユーザから見たプロダクトとしてもよい!

Railsの速度改善との6年間の格闘の歴史

Six Years of Ruby Performance: A History

ベンチツールを開発してきているNoahGibbsさんの発表

rubykaigi.org

6年間のグラフ

ここ数年はRails Ruby Benchというベンチマークツール*4(以下RRB)を使用してきた。

github.com

f:id:bst-tech:20190606100517p:plain
Ruby2.0〜2.6のスループットベンチ By RRB

バージョンを重ねるごとに着実に速くなっている

ところがRRBでは、複雑に構成されたアプリケーションのどこが一体遅いのか、はっきりとわからない。

  • RRBはそれ自体が大きすぎるという問題があり
  • どこに問題があるのかspecificにするならば小さいベンチマークツールも必要ではないか

そこでより小さく問題を検出するためにRSB f:id:bst-tech:20190606100909p:plain

github.com

RSBで計測していくと見えてくること

  • スタティック文字列を返すだけのシンプルなRailsアプリでも実はそんなに早くなってない
  • 早いのはPumaだった

こういうわけでRailsにはAPIモードがある。 通常装備のRailsにはオーバーヘッドが多すぎる

まずはフレームワークのオーバーヘッドというのが1つ目

次にConcurrencyについてみていくと

f:id:bst-tech:20190606101433p:plain
1process 4threads より 4process 1threadsのほうが速い

スレッドはよくないのか?

f:id:bst-tech:20190606101633p:plain
4processes 1thread より 4processes 8threadsのほうが遅い

ただし、プロセスをどんどん増やすと逆転し、マルチスレッドのほうが速くなる。 これは、ベンチマークツールのワークロードに依存するのが原因とのこと。

結論として、Rubyは並行処理をプロセスもスレッドも同じ方法でハンドリングしている。 このポイントに関しては、Guilds(仮)に期待していきましょう!

以上、Cocurrencyについての計測が2つ目

今回はこの二点に関するマイクロ計測の発表だけでしたが、

  • MJIT
  • GC profiling / Memory Usage

なども今後結果をお知らせしてくれるそうです。

まとめ

Rubyはこれまで地道に改良を続けてきていますが、さらにその裏で正しく言語処理速度を計測する仕組みをチューニングし続けるNoah Gibbs氏のような人もいて、Rubyを支える人たちの深みや厚みを感じる発表でした。

笹田さんのRubyのインタープリタをRubyで書く話

https://rubykaigi.org/2019/presentations/ko1.html#apr18

超面白かったんですが、ボリュームやばそうなので、今回はここまで。

このペースだとあと5記事くらい必要そうで全部終わる頃には来年のRubyKaigiになっていそうです。

余談

私自身、福岡には縁があって、1年ほど住んでたこともあるし訪れたことも10回以上ありますが、何度来てもここは最高です。

f:id:bst-tech:20190606082026j:plain
フグと思いきやアナゴの刺し身

こちらからは以上です。

*1:JITが最適化する条件を満たさないときにコンパイルキャンセルされる

*2: "def [] "のこと

*3:書きやすさとのトレードオフでもある

*4:discourseというreal worldでよく使われているフォーラムアプリの上で動くツール