k0kubun's blog

railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。

2017年にやったこと

要約

今年はクックパッドからTreasure Dataに転職し、Rubyコミッターになり、結婚しました。

発表

今年は7回発表したのですが、9ヵ月くらいJITを触っていたので3回はJITの話になってしまいました。

Cookpad TechConf 2017: 快適な開発環境, CI, デプロイ等の話

前職でやっていた仕事の中でまだ発表していなかった工夫について話しました。2018年は、現職で前職とは少し要件の違うデプロイ環境を整備していくことになっているので、やっていきたいと思います。

TreasureData Tech Talk 201706: TD APIの話とERBの高速化の話

RubyコミッターとしてメンテするようになったERBをRuby 2.5で2倍速くした話とかをしました。

TokyuRuby会議11: Haml 5の高速化の話

Haml 5で内部の設計を結構大きく変え、3倍速くした話をしました。これに起因したバグはいまのところなく、とても上手くいったように思います。

ぎんざRuby会議01: Railsのパフォーマンス改善全般

計測の重要性について語りつつ、今までの発表とは異なる点として、最近バイトコードを読みながら最適化をするようになったのでそのあたりの知見を共有しました。

RubyKaigi 2017: LLVMベースのJITコンパイラ LLRB

今年Ruby向けに2つのJITコンパイラを実装したのですが、その1つ目の話です。LLVMJITを実装するのは割と素直な方針だと思っていたのですが、やってみると一番必要なのは既にCで書かれたコードをいかにインライン化するかというのが肝になるので、LLVMを生かしきれるステージはまだ先かなあという感じでした。

RubyConf 2017, Rails Developers Meetup 2017: JITコンパイラYARV-MJIT

2つ目のJITコンパイラ。upstreamへのマージ計画 https://bugs.ruby-lang.org/issues/14235 もある本命プロジェクトです。がんばっています。

JITコンパイラはいかにRailsを速くするか / Rails Developers Meetup 2017 // Speaker Deck

ホッテントリ

はてなブログを9記事、qiitaに20記事書きました。「要約」とか「発表」と重複あるけどブクマをいただいたのは以下の記事。

タイトル
1. Linux デスクトップ環境 2017 - k0kubun's blog
2. GraphQLは何に向いているか - k0kubun's blog
3. Railsアプリケーションのパフォーマンス改善手法 / #ginzarb // Speaker Deck
4. Hamlを3倍速くした - k0kubun's blog
5. Treasure Data に入社しました - k0kubun's blog
6. CRuby向けのLLVMベースのJITコンパイラを書いている話 - k0kubun's blog
7. VMに手を加えずRubyを高速化するJITコンパイラ「YARV-MJIT」の話 - k0kubun's blog
8. 快適なサービス開発を支える技術/Cookpad TechConf 2017 // Speaker Deck
9. Ruby コミッターになりました - k0kubun's blog
10. LLVM-Based JIT Compiler for CRuby // Speaker Deck
11. ISUCON7予選2日目「Railsへの執着はもはや煩悩」で予選通過した - k0kubun's blog
12. ISUCON7本戦「Railsへの執着はもはや煩悩」で4位だった - k0kubun's blog

OSS活動

今年リリースしたOSS

Star リポジトリ
★242 k0kubun/llrb
★28 k0kubun/yarv-mjit
★16 k0kubun/benchmark_driver

今年はほとんどJITを作っていたのであまり新しいものを作れませんでしたね。

benchmark_driverというのはRuby Grant 2017として採択されたプロジェクトで、ツールの開発は一旦キリがつき、来年ベンチマークセットやCIの整備をやっていくところです。

あと仕事でJavaを使ってbigdam-queueというミドルウェアを作ったので、それは来年公開しようと思っています。そこでJavaに少し慣れたので、既存のRailsアプリのワーカーをJavaで書き直したりもしていました。

Contribution

今年はHamlRubyのコミッターになったのでそのあたりに割とコミットしていました。

なんかHaml 5x3とか言ってRuby 3x3を煽っていたけど、いつのまにか自分がRuby 3x3をやっていく感じになっていました。

2018年は

ひとまず1月を目安に以下のPRがマージできるよう、残っているバグを全部取り除いていくのをがんばりたいと思います。その先で、 今ある最適化のアイデアをどんどん入れてRailsとかも速くしていけるといいですね。

github.com

広告を非表示にする

ISUCON7本戦「Railsへの執着はもはや煩悩」で4位だった

ISUCON7本戦に「railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。」チーム (@cnosuke, @rkmathi, @k0kubun) で参加し、4位でした。

本戦の概要

予選より参加者は少ないと思うので軽く解説しておくと、クッキークリッカーを模したトラフィックのほとんどがWebSocketのアプリで、1万桁とかのスコアを計算する都合ほとんどのチームのボトルネックが最後までBigintの演算になるような問題でした。

僕らは15:00くらいに1位になり、その後はスコアをそれほど改善できず終わってしまいました。

f:id:k0kubun:20171125222955p:plain

方針

最近Ruby向けのJITコンパイラを開発している ので、それを使ってバーンとやろうと思ってましたが、これは開始前にgfxさんにバラされたのでやめました。

というのは嘘ですが *1 、WebSocketと聞いた時点で並列度が必要になるんじゃないかなあと思ってた *2 のと、アプリのCPU演算がボトルネックになってた *3 ので普通に使って速い言語としてGoを使うのを最初から視野にいれていました。

一方で、チーム名からもわかるように我々はRubyに最もなじみがあり計測のノウハウがあるのと、また普通にRubyのコードはGoのコードに比べ文字数が少なく見通しが良かったので、問題を把握するまでの期間Rubyで参戦し、後にGoに切り替えました。

最終形の構成概要

僕は開始2時間くらい寝起きでぽよってたので @cnosuke や @rkmathi が最初に考えた内容ですが、大体以下の感じになりました。 問題の性質上、WebSocket以外を受けるノード以外の構成はどこも同じだったと思います。

  • appサーバ1 (ベンチマーク時この1台のみ指定)
    • WebSocket 以外を返す puma (Sinatra, Ruby)
      • 4つのサーバーに負荷をうまいこと分散していたようですが、僕はこの辺担当してないので他のメンバーの記事にご期待ください
      • WebSocketを担当するウェイトは他の3つが多くなるようにしていたようですが、どのサーバーもCPUは全部使い切れていたようです
    • WebSocket を返すGo
    • nginx (他はGoが直接受ける)
    • puma, Go用の同ホスト向けMySQL
  • appサーバ2
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL
  • appサーバ3
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL
  • appサーバ4
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL

やったこと

github.com

僕は業務で一番Rubyを触っている時間が長そうで、またGoもちょっと前はよく書いてたので、主にアプリのコードをいじる担当でした。逆にインフラが絡む部分は他の人に任せています。

やった人 やったこと
7112 rkmathi Python初期スコア計測
5980 rkmathi Rubyに変更、初期スコア
4621 rkmathi Goも一応確認、初期スコア (その後Rubyに戻す)
- cnosuke サーバーやリポジトリのセットアップ
- k0kubun NewRelic をいれたが、99% WebSocketであるということだけがわかり、相性が微妙だったので今回は使用をやめた
- k0kubun 予選でも使用したStackProfミドルウェアを入れた。 calcStatusボトルネックであることがわかる。 今回もRubyを使っている間結構役に立ったが、StackProf周りは別のところで解説しようと思っているので今回は説明を省略
- k0kubun m_item テーブルを定数としてメモリに持つようにし、m_item のクエリを全てなくす。あまりスコアは変わらなかった
- k0kubun get_power, get_price のBigintの計算がボトルネックになっていたので、これをcountが0〜50の場合の結果をアプリ起動時に作るようにした。 そこそこスコアが上がった気がする
8968 cnosuke 採番して 2,3,4 にWebSocketのリクエストを分散
- rkmathi 2,3,4に個別のMySQL用意
- rkmathi 再起動耐性のための設定
- k0kubun 50個のキャッシュを500個にしてみたら全然起動しなくなった。この時点でも150までのカウントを使っていたが、150とかにしてもメモリの使用量がモリモリ増えて速攻で2GB使い切ってしまったので、Pumaのプロセスを減らすかとか考えていた(preload_app も試すべきだった)が、GILあるしなあとかまあそういうことを考えるのが面倒なので僕はここでRubyをやめることにした。
- rkmathi innodb_buffer_pool_sizeなど、MySQLの設定とか
- cnosuke WebSocket以外を返しているノードへのWebSocketへの負荷減らし
- rkmathi サーバーで動いているのをRubyからGoに切り替え
- k0kubun Rubyにやった変更(m_item, power/priceのキャッシュ)を全てGoに移植
- k0kubun power/price のキャッシュを 50 → 80に調整。 このパラメータをいじるだけでスコアが上がることを発見するが、起動時間が指数関数的に述びていくのでこのへんで断念したが、このあたりからcnosukeが起動時間を縮めるためこれのキャッシュをシリアライズするのを着手していた
- rkmathi 僕らが誰もGoのプロファイリング方法を知らないのでググり始める。rkmathiが速攻でpprofの使い方を理解し結果を共有してくれたのでとても助かった。始めて使ったけどtopとlistだけでかなり多くのことがわかる。あとなんかrkmathiがコールグラフsvgにしていたが、これも便利だった
- cnosuke WebSocketだけをさばくノードでnginxをなくしてGoで直接受けるように。 今回はあまり効果はなさそうだった
- k0kubun やたら 値が1000の big.Int インスタンスを生成してるっぽかったので毎回同じものを使うように (効果なし)
24227 k0kubun calcStatus内で 1000 * 13 のオーダーでやっているかけ算 + 比較を、13のループの外側で割り算 + ループ内比較 にすることでそこのかけ算のコストを 1/13 に。割とスコアが上がっていた
- k0kubun calcStatus内で 13回やっているかけ算を1つの割り算にできるものをもう1つ発見し、適用。 ここは1000回ループの外なのでインパクトは小さそう
- k0kubun 「1000 * 13回 のかけ算」 → 「1000回 の割り算」 に減らしたオーダーを、 「13回のかけ算」に減らした。これも効果があった記憶
- k0kubun この時点でpprofを見る限りでは big.Int の String()が90% くらい使っていたし、GoがCPUをほぼ使い切っていたので、僕は後半ずっとこれをどうにかできないかもがいていた。なんかfloat64に変換してLog10で桁数計算したり(これは変換した時点で精度が破滅)、なんかpprofのsvgの読み方をちょっと間違えてString()の後のItoaが重いと勘違いして、Exponentialインスタンスを作る時に整数への変換をスキップしたりしたが、無意味であった。これ以降僕はスコアを上げられていない
- rkmathi puma のワーカー数いじり
- rkmathi longtext -> varchar のalter
- cnosuke power/priceのキャッシュをYAMLシリアライズしてロードする奴を入れるが、バグのためrevert
27328 rkmathi 再起動テストとかプロファイリングとか。我々はプロファイラを入れるとスコアが上がるジンクスを持っており、pprofを有効にした時最高スコアを更新した
- cnosuke YAMLの奴をデバッグしていて、終了間際に原因がわかるが、ベンチマークを何度も回す余裕がなさそうなので、ここで変更を加えず何度かベンチを回して終了

やり残したこと

気付いていたのはaddingとかが過去になった奴をまとめておけそうなくらいですが、僕らの計測の限りではそこはボトルネックではなかったので着手しませんでした。ずっとボトルネックであった big.Int.String() や、 price/power の計算結果キャッシュ数を増やすのをがんばっていたが両方失敗してこのスコアに留まった形です。

1位のMSAは、僕らはボトルネックだと思っていなかったあたりのオンメモリ化や過去の結果のマージを全て終わらせて高いスコアを出したようなので、計測は難しいですねという感じです。

我々は気付かず懇親会で知ったのは、(具体的な内容はそちらのチームのブログに任せますが) ソン・モテメン・マサヨシ チームのgoroutineを使ったものやGCのチューニングで、同じGoのアプリをいじってるにも関わらず僕らとは全く違う方向性で速くしていたようなので、その辺はGoへの慣れの差が出たような気がします。

感想

最後のISUCON本戦参加は学生枠で惨敗という感じだったのが、3年経って同じメンバーで社会人枠で健闘でき、成長した実感が得られたのがとても良かったです。

時の運次第では優勝に手が届きそうな感覚が得られたので、来年は優勝したいと思います。参加された方と運営の皆さま、お疲れ様でした & ありがとうございました!

*1:単純にISUCONで使うならもうちょっと直したいところがあったが、本戦までに直していなかった

*2:これはそうでもない

*3:実際にはRubyのBignumはGoのbig.Intに比べ別に遅くないそうなのでこれも別に該当しませんでしたが、後述するようにメモリで断念しました

ISUCON7予選2日目「Railsへの執着はもはや煩悩」で予選通過した

ISUCON7予選に「railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。」チーム (@cnosuke, @rkmathi, @k0kubun) で参加し、217,457点で予選通過だったようです。 正確な値は覚えてませんが、Best Scoreは25万くらいでした。

f:id:k0kubun:20171022224227p:plain

最終形の構成概要

  • appサーバ1
    • puma 16スレッド: 画像のアップロード/表示、雑多なリクエスト対応
    • puma 2スレッド: GET /fetch だけ返す
  • appサーバ2
    • puma 16スレッド: 雑多なリクエスト対応 (画像はnginxがサーバ1に流す)
    • puma 2スレッド: GET /fetch だけ返す
  • DBサーバ

サーバ1, サーバ2をベンチマーク対象にしていました。この構成なのは GET /fetch がスコアにカウントされないため、それ以外にほとんどの時間を使えるようにするためでした。

やったこ

最終コード・他のメンバーのブログはこちら:

github.com

ISUCON7「Railsへの執着はもはや煩悩(ry」で予選通過した - 明日から本気だす

学生のころから何度も同じメンバーで出ているので、いつも通りチーム内で割と綺麗に並列に仕事ができました。

やったことと点数の推移の記録とかはやってないので、やったことだけを適当に列挙していきます*1。グラフからわかる通り多分序盤で意味のある改善が終了しており、終盤はあまりうまくいっていませんでした。

効果があった気がする奴を太字にしておく。

やった人 やったこ
cnosuke 公開鍵の準備とか
rkmathi リポジトリへの主要なコードの追加とか
k0kubun pythonruby変更、systemdの設定のリポジトリへの追加
k0kubun NewRelicのアカウント管理、インストール。この時点では GET /fetch が支配的だったのを確認
k0kubun sleepを消したり消さなかったりした後消す
k0kubun 適当にSELECTするカラムを消したり、messageテーブルにインデックスを貼ったり
k0kubun rack-lineprofを眺めるがほとんど参考にならなかった
cnosuke DBに入ってる画像をファイルにしてnginxから配信できるように変更 (ここで割と上位に来た)、多分Cache-Controlとかもこの辺でついてる
k0kubun GET /fetch を捌くpumaのプロセス (1スレッド)をまだ何もいない2つ目のサーバーに用意し、それ以外を元々いたpuma (16スレッド) に捌かせるようにした (これも結構上がり、後々もインパクトがあった)
rkmathi fetch用pumaのポートを別のサーバーからアクセスできるようにした
k0kubun (本来はruby実装のclose漏れによる)fdの枯渇をOSのfdを増やしたり nginxのworker_rlimit_nofile をいじったりしてどうにかしようとあがくがうまくいかない
cnosuke 2つ目のサーバーもfetchではないメインのpumaがレスポンスを返せるようにnginxを設定、画像を返すのを1つ目のサーバーに絞ったりとか
k0kubun close漏れに気付いて直す、多分workloadが上がってスコアが伸びる
rkmathi get_channel_list_info のループの余計な処理削り
k0kubun なんかsleepを0.2とかいれてみるがスコアが下がるのでなくす
rkmathi NewRelicで計測
cnosuke MySQLの設定がうまく反映されてない奴とかの対応
k0kubun GET /message のN+1つぶし、N=0で壊れるので直したり
k0kubun 自分のプロフィールの時に不要なクエリを減らす、そこのSELECT *のカラムも絞る。NewRelicのブレークダウンがなんかいつもと違って全く詳細になってなくてあまりインパクトのない変更を繰り返している
cnosuke MySQLがいるサーバーのメモリが使われるように設定を修正したり
rkmathi GET /history/:channel_id のN+1つぶし
rkmathi NewRelic見たり
k0kubun さっき自分がN+1で踏んだrkmathiのコードのバグとり
k0kubun GET /fetchのつぶしやすそうなN+1を1つつぶす
k0kubun fetchするpumaのスレッド数を適当にいじり2つにおちつく
k0kubun NewRelicのthread profilerを使うが、今回はびっくりするほど出力が見辛くはっきりいってほとんど役に立たなかった
rkmathi ランダムにする必要のないsaltを固定化
k0kubun Mysql2::Clientをリクエストごとに作るのをやめ、スレッドローカルに使いまわすようにする
cnosuke この辺でk0kubunがperf topを眺めてて、appサーバーがrubyよりlibzが支配的だったので、nginxのgzip_comp_levelをいじったりしている
k0kubun perfを見る限りではrubyボトルネックではなさそうだったが、ERB回り遅いのではと言われたので僕がERBを速くしたruby 2.5に変更(特にスコアに変化はない)
rkmathi GET /register が静的なのでnginxだけで返すように変更
cnosuke DBサーバーにもnginxをたて、そこからプロキシだけすることでglobalな帯域を有効に使えないか試したが、うまくいかなかった
k0kubun TD社内で書いた秘伝のstackprofミドルウェアを取り出し、何が遅いのか計測。rack-lineprofより今回はこっちの方が猛烈に見易かった。fetchでcounter cacheが効きそうなことに気付くが時間の都合でやらない
cnosuke なんかlibzまわりの関係でgzをいろいろやってる
cnosuke 画像を受けてるサーバーは1つだけだが、2つ目の方も最初からある画像は返せないか試していたが、うまくいかない
rkmathi 再起動テスト
rkmathi ログの出力を消したり
k0kubun rkmathiの変更で/registerがoctet-streamになってて動作確認がしにくかったので text/htmlにしてる
cnosuke bigintをintにしてた(のを↑のどこかでやってた)都合で壊れてるのがあって、initializeでauto incrementをリセットする変更をいれてる
k0kubun 余計なcreated_at, updated_atを作らないようにした

あとどこかでtextになってるカラムをvarcharにしたりとかしてました。

心残り

appサーバ1, 2の帯域両方をどうにかして画像のリクエスト処理に使えるようにしたかった(パスのどこかが奇数か偶数かでどちらかに固定で送るとか)ですが、いろいろ考えたけど僕はあまりいい対応が思いつかなかったです。最後 POST /message がボトルネックだったのも、MyISAMにしたりMEMORYにしたりしましたが効果はなかったですね。Redisにするみたいな大きい工事はできませんでした。

GET /fetchのレスポンスは更新があるまで止めておくのが良い(ベンチマーカーがそういう風にスコアをつけてる)、というのを感想部屋で聞いたけど、これも全く気付きませんでした。

あと、奥の手としてJITを考えてましたが全くRubyボトルネックが移せなかったのも残念ですね。

気持ち

上位のチームには相当離されていたので、どこに自分たちが見過していたブレークスルーがあったのか知るのが楽しみです。 本戦に出られるのは学生枠で出してもらっていた時ぶりですが、いい結果を残せるようがんばります。

*1:僕以外の人のタスクは僕が把握してるレベルしか書けないので多分かなり抜けててます

VMに手を加えずRubyを高速化するJITコンパイラ「YARV-MJIT」の話

先日のRubyKaigi 2017のLTではLLVMベースのCRuby向けJITコンパイラLLRBの話をしました。 5分はちょっとJITの話をするには短かかったですね。

LLRBをふまえた、Cのコード生成への軌道修正

さて、上記の資料にある通り、CRubyのJITにおいてはメインの高速化対象が既に存在するCのコードになるため、 開発の早い段階でパフォーマンスにインパクトを持てるとすればLLVM Passの順番を変えるくらいで、 LLVM IRを直接生成しても最適化上のメリットがほとんどないのでその部分はMJIT と同じくCのコードを生成するように変更したい、という話をした*1

で、LLRBはC拡張として作るべくちょっと不思議な努力をいろいろやっており、 それらの設計はやってみた結果(コアに直接変更を加えるのに比べ)デメリットの方が大きいと思ったので、 LLRBの失敗を全て生かしつつ、今回YARV-MJITという奴を新しくスクラッチした。

YARV-MJITとは?

github.com

MJITはJITを実装する前にレジスタベースのRTL命令にVMの命令を全て置き換えた上でそれをベースにコンパイルしているが、 YARV-MJITはVMの命令セットは全てのそのままに、スタックベースのYARV命令をコンパイルする点が異なっている。

逆に言うとそれ以外は全てMJITのパクり、というかそのままコードを持ってきたフォークになっており、 「Copyright (C) 2017 Vladimir Makarov」になってるコードの方が多い。

Optcarrot ベンチマーク

まだ Optcarrot しかまともにベンチマークできてないのでこちらを。

  • Intel 4.0GHz i7-4790K with 16GB memory under x86-64 Ubuntu 8コア で計測
  • 以下の実装を用いた:
    • v2 - Ruby MRI version 2.0.0
    • v2.5 - Ruby MRI 2.5.0-preview1, YARV-MJITの元のベースに近い
    • rtl - Vladimirの 最新の RTL MJIT (21bbbd3) -j なし
    • rtl-mjit - MJIT (-j) with GCC 5.4.0 with -O2
    • rtl-mjit-cl - MJIT (-j:l) using LLVM Clang 3.8.0 with -O2
    • yarv - 僕の YARV-MJIT の -j なしバージョン、v2.5と大体同じはずだが、JITが有効かのフラグのチェックのオーバーヘッドが入る
    • yarv-mjit - YARV-MJIT (-j) with GCC 5.4.0 with -O2
    • yarv-mjit-cl - YARV-MJIT (-j) with LLVM Clang 3.8.0 with -O2

以下のように、MJITほどのパフォーマンスはまだ出せていないが、大体Ruby 2.0の1.59倍くらいは速くなる。

v2 v2.5 rtl rtl-mjit rtl-mjit-cl yarv yarv-mjit yarv-mjit-cl
FPS 35.41 43.36 38.00 75.57 81.25 42.89 56.38 48.27
Speedup 1.0 1.22 1.07 2.13 2.29 1.21 1.59 1.36

なおRailsでのベンチと解説をRails Developers Meetup 2017でやる予定。 そっちも多分内部に触れるけど、よりコアよりの話とかJITの実装の苦労話とかマイクロベンチマークの紹介はRubyConf 2017でやると思う。

何故YARV-MJITを作っているのか?

JITのためにVMの命令をレジスタベースにするメリットが何なのか確かめたい

ko1さんを含めコミッターの人たちは大体RTL命令への変更に概ね皆賛成っぽいんだけど、 同じオブジェクト指向言語でスタックベースのバイトコードJITを持つJavaRubyより十分に速い以上、 JITで高速化をするためにVMの命令をレジスタベースにする必要があるのか、ということにずっと疑問を持ち続けている。

VMJITに馴染みがない人のために説明すると、 VMの命令セットをスタックベースからレジスタベースに変えるというのは大体以下のようなことと同じである:

あなたの会社の社員は全員はEmacsを使っているとする。巷では (anything.el や helm のような) ファジーファインダーでファイルを絞り込んで開くと編集効率が上がると言われているが、現在Emacsにはそのようなプラグインがまだないとする。

そこに突然、EmacsよりもVimの方が編集効率が高いと宣う熱狂的なVimmerが現れあなたは試験的に導入してみたが、 Emacsでできないことが突然発覚するリスクを背負う割にはそれほど編集効率が上がらなかった。 一方、そのVimmerはUnite.vimのようなプラグイン(Vimanything.el的な奴)を開発し、それを使ってみたら開発効率が爆増し、 全社的にEmacsをやめてVimを導入することになった。…というような話なのである。

冷静に考えてエディタは変えずanything.el的な部分だけを作って使った方が安全に決まっているし、 Emacs上でファジーファインダーを開発してもVimの時と同じように開発効率が上がるはず、というのが僕の主張である。

まあ冗談*2はさておき、スタックvsレジスタでのJITにおける本質的な違いが何なのか技術的な興味があって続けている。

JIT基盤の変更とVM命令の全リプレースが同時に入るリスクを軽減したい

上記の考えとは全く別に、僕が開発しているようなLLRBとかに比べて、MJITのクオリティは本当に高い。 なのでRuby 3にMJITが入るのは僕も応援したいのだけど、その一方、 JIT-jをつけるかどうかでオンオフが切り替えられるのに対し、 VMの命令セット全置き換えは切りようがないので、本番で動作しているアプリに投入するにはちょっとリスクが高すぎるように思っている。 特に弊社で動かしているような、一瞬でも止まるとお客様がかなり困るようなRailsアプリとかの場合。

一方現在のCRubyのコードベースでは、コマンドラインオプションで現在のYARV命令とRTL命令をスイッチする(かつRTLの時のみJITが使える)みたいな実装にするのは ちょっと厳しいんじゃないかとも思う。

そうすると、大きな2つの変更である「RTL」と「MJIT」に関して、オプショナルにできるMJIT側の基盤を先に入れてテストし、 後からRTL(とその命令のJITコンパイラ)を入れる方が安全にリリースできていいのでは、というのが僕の意見である。

MJITはよくできていて、JITコンパイラ側はVMの命令が呼び出す関数をインライン化していく作業がメインで、 特殊命令への変換やdeoptimize相当の処理の大部分はRTL命令側に実装されているので、実はJITコンパイラ部分だけ置き換えるのがそんなに大変じゃないと思っており、 マルチスレッドプログラミングが必要だったりポータビリティに難のあるオブジェクトのロードなどのMJITの基盤を先に導入できるメリットは結構あると思っている。

今後の展望

今年はもうなんだかんだ半年くらいJITを書き続けてるので、自分の満足の行くまで好きに続けようと思う。

Optcarrotで少なくとも60fpsは越えられるレベルになり、認識できるレベルのバグが修正され、JIT有効でもほとんどテストが通ったり、 実際のアプリケーションが問題なく動くことを確認できたら、 2.xの間の、3.0のRTL+MJITまでの繋ぎとしていかがですかというような提案ができたらいいなと思っている。

*1:というのは妄想で、僕のトークがスローなので5分では全くその話に到達できませんでした。

*2:この記事を書いている僕は最初Emacsを使っていたけど小指が痛くなったので今はVimに移行している、というオチ

GraphQLは何に向いているか

今年GitHubGraphQL APIを正式公開したあたりから、GraphQLが去年とかに比べちょっと流行り始めたように感じる。idobataがGraphQL APIを公開したり、Kibelaも公開APIをGraphQLで作ることを宣言している。

利用者側からすると使えるインターフェースの中から必要なものを調べて使うだけなのであまり考えることはないのだが、自分がAPIを提供する立場になると話は変わってくる。REST APIとGraphQL APIはどちらかがもう一方のスーパーセットという風にはなっておらず、どちらかを選択すると何かを捨てることになるので、要件に応じてどちらを選ぶのが総合的に幸せなのか考える必要がある。

以前趣味でGitHub連携のあるサービスを作っており、それを最近GraphQL API v4を使うように移行し、そこでついでにそのサービスのGraphQL APIを書いてみたりした結果GraphQLができること・できないことが少し見えてきたので、僕の現在の「GraphQLはREST APIに比べどういう用途に向いているか」についての考えをまとめておく。

REST APIと比較したGraphQL

この比較では、REST APIJSON Schemaと同時に使われうることも想定して書く。理由は、GraphQLが解決している問題の一部をREST APIで実現するためにJSON Schemaが使われることがしばしばあり、逆にGraphQLでは元々それに近い機能がありまず使わないと思うので、現実世界の問題を解く上ではそれらをセットにして比較した方がいいと考えたため。

なお、対比する上で本質的な部分となる「仕様上の問題」と、時間が解決しうる(が現実世界では当然考慮が必要な)「現在のエコシステムの問題」は分けて記述する。エコシステムに関しては筆者の都合でサーバー側はgraphql-rubyを念頭に置いている。

GraphQLにしても解決できない問題

仕様上の問題

  • GraphQLはありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない
    • 正直触る前は大体そういうイメージだった
    • 例えばページネーションなしに1種類のリソースを6000個取得しようとするとレスポンスに1分かかりリバースプロキシ(かunicorn, rack-timeout等)でタイムアウトになるREST APIが世の中には実在したのだけど、それをそのままGraphQLのクエリで再現したとして確実に同じ時間がかかる。つまりページネーションは確実に必要で、クライアント側でそのリソースに関してループを回す必要があり、そのリソースに関して何度かリクエストが必要になる。
    • 単に1つのリソースをページネーションしないといけない場合だけでなく、N個あるリソースにそれぞれM個リソースがネストしていてぞれそれにorderが必要な場合、これは確実にN+1回クエリが必要なわけだけど、そうやって裏側で非効率なクエリが走るようなクエリを投げてしまうと一回のリクエストに時間がかかりすぎてタイムアウトするリスクがあるので、ある程度は分けないといけない。実際僕もGitHubのGrpahQL APIを叩いているとリトライをしても結構タイムアウトを見た。
    • 後述するようにリクエスト数は減ることは多いが、必ず1回にできる銀の弾丸ではないという主張

現在のエコシステムの問題

  • 現時点ではAPIクライアントを自動生成できるライブラリは限られており、アプリ側にいちいち長いクエリを書く必要がある

GraphQLにすると困る問題

仕様上の問題

  • クエリをパースしないとキャッシュの可否を判定できないため、HTTPキャッシュが難しい
    • REST APIであれば、同じとみなせるGETリクエストをVarnish等でキャッシュすることが容易かつ効率的にできるが、GraphQLだとリクエストボディのJSONをパースし、その中に入っているqueryをパースし、そこにmutationがあるかどうかをチェックする必要がある。
    • ワークアラウンドとしてquery fieldが生えてるエンドポイントとmutation fieldが生えてるものを分ける等が考えられるが、エンドポイントを分ける(REST APIに近づける)ほど当然キャッシュのコントロールがしやすくなるわけで、HTTPキャッシュがないと困る用途には向かないと思う。
    • 追記: id:yamitzky さんのコメントで知りましたが、GET /graphql?query=...といった形でのリクエストも仕様上想定しているため、これは誤解のようです。graphql-rubyのgeneratorだと生えないので勘違いしていました。
  • HTTPのメソッドやステータスコードによる挙動の予測ができなくなる
    • queryとmutationしかないということは、HTTPメソッドのGETかPOSTしかない状態に等しく、mutationの中でそれがリソースの追加・更新・削除のうちどれなのかを表現する方法は別に仕様レベルでは標準化されておらず、実装した本人以外から見たら挙動が予測しにくくなる。
    • クエリの結果がエラーになっても大体200 OKが返ってくる。(OKとは)
      • 他にもGitHubの場合502 Gad Gateway: This may be the result of a timeout, or it could be a GitHub bugといったエラーが結構頻繁に返ってくるんだけど、timeoutの場合はリトライしたいしGitHub bugならリトライすべきではないのでこれはHTTPステータスコードで区別して表現してほしい。本当はどちらも(バグを含む何かが原因の)タイムアウトなのかなあ(そのうちサポートに確認する)。
      • errorオブジェクトのルールをちゃんと決めて実装すれば解決できるけど、このあたりに標準的な仕様やガイドラインが存在しない結果そういうレスポンスを生んでしまうのは問題だと思う。
  • 必要なfieldを必ず明示しなければいけないので、自動生成しない限りはAPIクライアントを書くのに必要なコードの文字数・行数は増えそう
    • 後述するようにIDEのGraphiQLのアシストがあるのでそこまで大変ではない

現在のエコシステムの問題

  • GraphQL Proを使わないとモニタリングが難しい
    • そこまで高くはない($900/year)ので仕事でやってるなら普通に金払えばいいんだけど、NewRelicとかで詳しくモニタリングしたかったらGraphQL::Proを使う必要がある
    • graphql-rubyとかにはinstrumentationの仕組みがあるので、まあ困ったら自分で実装することは可能
  • Railsで使ったらMVCのレールのうちVCから割と外れる
    • まずcontrollerとviewに入っていたはずのロジックが全てapp/graphql以下にくることになる。VとCが一緒になるだけだったら最悪いい(?)気もするけどqueryやmutationといったrootのフィールドにはいろんなリソースのロジックが一箇所に集まってくる上にroutes.rb相当の情報も来てしまう。
    • ネストしたリソースが(graphql-rubyが生成する)typesディレクトリにちゃんと分割されていればまあそこまで問題になることはないかもしれないが、resolveのブロック内のコードには割とプロジェクトによって自由度が発生して初見だと読みづらくなるような気がしている
  • N+1クエリの解決方法がいつもと違う感じになる
    • 普通はActiveRecordSQLのAST(Arel)を組み立て、そこにこのリソースをeager loadingするよという情報を埋め込むことでActiveRecord::Associations::PreloaderActiveRecord::Associations::JoinDependencyになんとかさせるんだけど、これは使わなくなる
    • かわりに、こういう感じでクエリのトラバース中に必要なIDを集めておいて、最後にgraphql-batchのミドルウェアを通してまとめてクエリすることになる。ネストしたリソースをクエリしてくるのに必要な情報を、親のリソースの一覧を参照することで取ってこれる限りはどうにかできるので、REST APIで問題にならないようなリクエスト(しかできないようにスキーマを制限した場合)ならGraphQLでも問題にならないような気がしている。しかし、例えばコントローラーから使うことを想定してN+1を解決するpreloaderを独自に書いていた場合は使えなくなる可能性があると思う。
    • graphql-batch相当のライブラリがない言語でやる場合、特にそれが静的型付け言語とかだとちょっと面倒かもしれない
  • graphql.jsを使う場合はFacebookBSD+Patentsライセンスに同意する必要がある

どちらでも大差のない好みの問題

仕様上の問題

  • REST APIのバージョン管理」 vs 「GraphQLの@deprecated
    • GraphQLのサイトにEvolve your API without versionsと書かれているが、個人的にはあまりこれが優れている点だと感じない。/v1/v2みたいなバージョンを更新していくかわりにフィールドに@deprecatedをつけていくと更新の粒度は細かくできるが、例えばStringだったfieldを同じ名前でObjectにしようとすると、REST APIなら新しいバージョンを1つ生やせば済むが、GraphQLの場合は同じ名前空間でやらないといけないので1度別のfieldを用意してそちらに移行し元のfieldを直すという2ステップ必要になる。どっちが楽かはどう変更していきたいかによる。
    • そもそも普通にアプリを書いているとRundeckみたいにAPIのバージョンをバシバシ上げる必要ってそんなに感じなくて、新たにfieldやエンドポイントを足すような後方互換性のある変更が多く、バージョンを上げるとしたらそれこそREST API→GraphQLくらい全体的に大きな変更がないとやらない気がしていて、もともとこれがそんなに問題ではない

現在のエコシステムの問題

  • JSON Schema」 vs 「GraphQLの型」
    • GraphQLは型があって便利! みたいな主張を見るが、別にその目的がAPIのparameterのvalidationやクライアント側がpropertyの型を知ることなら、別にREST APIでもJSON Schemaを書いてそれをJSONで返すエンドポイントでも生やしておけばいい。
      • APIを生やしたら必ずJSON Schemaもセットで書かないと(かつそれが実装にも使われていないと)気が済まない人にはGraphQLはマッチしそう
    • デフォルトで型があることによりツールチェインのアシストは最初から厚いと思うけど、それは完全にエコシステムの問題で、JSON Schemaに対して何かやったらいい
  • ドキュメントの自動生成のしやすさ
    • JSON Schema」 vs 「GraphQLの型」に付随する話。エンドポイントの名前やfieldの型以上の情報は人間が書く必要があり、これはどちらも変わらない。やりやすさはツールの実装依存
    • 「GraphQLだと型を書くことが強制される」こと自体は強みかもしれないが、型だけではドキュメントにならないし、REST APIでも各エンドポイントに対して必ずJSON Schemaを書かないといけなくなるようなテストでも用意すれば大体同じ状況になる。ドキュメント生成自体に関しては完全に利用する側の問題だと思っている。
  • ユーザーのパラメータ・レスポンスのプロパティにおける型安全性
    • GitHub APIのGraphQL化のモチベーションにWe wanted assurances of type-safety for user-supplied parametersがあげられているが、これも別にJSON Schema書いてそれをユーザー入力のバリデーションに使えばいい話で、別に必ずしもGraphQLの仕様が解決する話ではなく、単にRESTの時にサボっていただけと見ることができる

GraphQLの方がより良く解決している問題

仕様上の問題

  • いわゆるfields paramよりもインターフェースがより柔軟で記述力も高い
    • fields paramというのは?fields=id,name,..みたいに返すfieldを指定するパラメータのことを言っている。cookpad/garageにこういうのがあるのだが、miyagawaさん曰くこれはgraphQL になる前の Facebook graph API のやつをまねたらしいので、ちゃんとGraphQLになったものがより洗練されてるのは頷ける話
    • GraphQLのクエリは普通改行するが、スペース区切りでも書けるし、あまりネストしていないようなケースでそうした場合はリクエストする側のコードの見た目はどちらもあまり変わらない(見やすい)感じになる。
  • APIのリクエスト数やround trip timeを減らしやすくなる
    • 「ありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない」と書いたが、あるリソースに対して1対1の関係でネストしたリソースも同時に取ってくるのにはGraphQLはとても向いている。もちろんREST APIでもそういうエンドポイントは生やせるが、GraphQLの方が自然かつ重複なく実装できると思う。
  • 余計なfieldのリクエストが減りやすい
  • 各fieldをクライアントが使っていないことを明示できるので、fieldの利用状況を調べやすい
    • REST APIでも?fields=id,name,..みたいなパラメータを用意し、それを使うのを強制すれば解決できないことはない。が、実際毎回書くのは面倒なのでgarageだとfieldsを指定しなかったりするとデフォルトの奴を返せたりするし、人間は楽な方に流れがち。仕様レベルで強制されるGraphQLの方がこの点は良いはず。フレームワーク次第ではREST APIでもこれは解決できる。

現在のエコシステムの問題

  • クライアントキャッシュを実装するためのGlobally Unique IDsなどガイドラインが示されており、実際にRelayのようなそれを念頭に置き活用するフレームワークが存在する
    • この辺にRelayの仕組みが書いてあるが、RelayからReactを使うと、ユニークなIDをキーにnodeをクライアントサイドにキャッシュし、クエリをトラバースして本当に必要な場所だけクエリされるようにすることが可能らしい。REST APIでもクライアントキャッシュは実装しようがあるが、こちらの方がより細かい粒度で柔軟にキャッシュができると思う。
  • クエリのIDE的な機能を持つGraphiQLに型がちゃんと活用されている
    • そのため、毎回必要なfieldを指定しないといけない割には、クエリを書くのはそこまで苦痛ではない
    • 一方でGraphiQLはいろいろキーバインドが潰されてるので普通のテキストのエディットはとてもやりにくい

GraphQLではないと解決できない問題

  • 思いつかなかった

まとめ

GraphQLを使う場合の前提条件として、HTTPキャッシュを使わないケース*1である必要があり、また現時点だとGraphQL Proに$900/yearを払うかAPIの詳細なモニタリングを諦める必要がある。

その上で、サーバー側に型の記述を強制しクライアント側にfieldの記述を強制することにより、以下の例のように双方が幸せになると判断した場合は好みに応じて使えばいいと思う。REST APIかGraphQLのどちらかを使わないとすごく困るという状況は上記の前提以外はあまりなさそう。

GraphQLが向いてそうなケース

  • サーバーの実装者とクライアント実装者の距離が遠く、サーバー実装者の想定と異なるAPIの使い方が想定できる時に、余計なリソースを返したり不要にリクエスト数が増えるのを防止したり、消したいfieldが使われ続けるのを@deprecatedにより抑止したり、その利用現状を調査したりしたい場合
  • Reactでクライアントを構築しており、Relay等のフレームワークとGraphQL*2を用いてリソース単位のクライアントサイドキャッシュを実装することで、サーバーやデータベースへの負荷が最小限に抑えられることが期待できる場合

具体的にはGitHubが1つ目に該当してFacebookが2つ目に該当したんじゃないかなあと思っている。

*1:取得されるリソースがリクエストごとに分散しており、あまりHTTPキャッシュが役に立たない場合。あるいは(Relayなどでクライアント側にキャッシュすることも考慮して)そもそもそんなに多くのリクエストが来ないので不要な場合。

*2:全部同じ会社から出てるので相性はよくて当然

CRuby向けのLLVMベースのJITコンパイラを書いている話

LLRBというRuby向けのメソッドJITコンパイラを書いている

github.com

RubyKaigi 2015の最後のキーノート@evanphxが「LLVMでCRubyのコードをインライン化するメソッドJITを実装したら速いんじゃね」みたいな発表をしていたのを覚えているだろうか。

LLRBというのはまさにそれを実装しているプロジェクトであり、少なくとも現時点で「LLVMでCRubyのコードをインライン化するメソッドJIT」と言える状態まで実装でき、ものによっては効果が出る状態になったので公開した。

なんで書いてるの

言語を自分で実装するとその言語に関する理解が大分深まる、というのをHamlの実装とかCコンパイラとかで体験していて、僕が一番好きな言語はRubyなのでRubyでもそれをやっておきたい、というのがあった。また、Rubyは遅いと言われがちだが、どこに改善可能な点が眠っているのかにも興味がある。

また、ささださんがRuby3のゴールの一つにJust-in-Time compilationをあげていて、そのスライドで1つの選択肢として言われていた"Use LLVM"というパートを検証するものでもある。ただし、MatzがLLVMはDependency的に微妙と言っており、本体にこの方向性で入ることは多分なく、あくまで実験的なプロジェクトになる。

どのように動くか

READMEを書いてたら大分疲れたので、この記事はその日本語訳にしておく。

LLRBのビルドプロセスでは、CRubyの命令やそれに使われるいくつかの関数をLLVM bitcodeという形式にプリコンパイルしている。

 ________     _________     ______________
|        |   |         |   |              |
| CRuby  |   | CRuby   |   | CRuby        |
| C code |-->| LLVM IR |-->| LLVM bitcode |
|________|   |_________|   |______________|

LLRBのJITのためのプロファイラが開始されると、Rubyの実行中にJITコンパイルがスケジューリングされる。 JITコンパイルの際、LLRBはYARV ISeqをLLVM IRにコンパイルするが、その際にプリコンパイルしておいたLLVM bitcodeをリンクするので、LLVM Passによる関数のインライン化やその後の最適化が行なえる。

最適化を行なった後、LLVM IRはLLVMのMCJITエンジンによって機械語に変換され、それを呼び出すためのCの関数ポインタとしてLLRBに返される。コンパイル対象のISeqに対し、後に説明する方法によって変化が加えられ、この関数ポインタが実行されるようになり最適化が完了する。

 ______     ______________      __________      ___________      _________
|      |   |              |    |          |    |           |    |         |
| YARV |   | ISeq Control |    | LLVM IR  |    | Optimized |    | Machine |
| ISeq |-->| Flow Graph   |-*->| for ISeq |-*->| LLVM IR   |-*->| code    |
|______|   |______________| |  |__________| |  |___________| |  |_________|
                            |               |                |
                            | Link          | Optimize       | JIT compile
                      ______|_______     ___|____          __|____
                     |              |   |        |        |       |
                     | CRuby        |   | LLVM   |        | LLVM  |
                     | LLVM Bitcode |   | Passes |        | MCJIT |
                     |______________|   |________|        |_______|

実際にパフォーマンスが向上するか?

基本的なインライン化は実装済なので、その効果を見ていく。

以下のようなruby/benchmark/bm_loop_whileloop.rbのコードについて考える。

def while_loop
  i = 0
  while i<30_000_000
    i += 1
  end
end

このメソッドのYARV ISeq (LLRBのコンパイル対象) はこうなっている。

> puts RubyVM::InstructionSequence.of(method(:while_loop)).disasm
== disasm: #<ISeq:while_loop@(pry)>=====================================
== catch table
| catch type: break  st: 0015 ed: 0035 sp: 0000 cont: 0035
| catch type: next   st: 0015 ed: 0035 sp: 0000 cont: 0012
| catch type: redo   st: 0015 ed: 0035 sp: 0000 cont: 0015
|------------------------------------------------------------------------
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] i
0000 trace            8                                               (   1)
0002 trace            1                                               (   2)
0004 putobject_OP_INT2FIX_O_0_C_
0005 setlocal_OP__WC__0 3
0007 trace            1                                               (   3)
0009 jump             25
0011 putnil
0012 pop
0013 jump             25
0015 trace            1                                               (   4)
0017 getlocal_OP__WC__0 3
0019 putobject_OP_INT2FIX_O_1_C_
0020 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
0023 setlocal_OP__WC__0 3
0025 getlocal_OP__WC__0 3                                             (   3)
0027 putobject        30000000
0029 opt_lt           <callinfo!mid:<, argc:1, ARGS_SIMPLE>, <callcache>
0032 branchif         15
0034 putnil
0035 trace            16                                              (   6)
0037 leave                                                            (   4)
=> nil

LLRBのコンパイラは、これを以下のようなLLVM IRにコンパイルする。

define i64 @llrb_exec(i64, i64) {
label_0:
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 8, i64 52)
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  call void @llrb_insn_setlocal_level0(i64 %1, i64 3, i64 1)
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  br label %label_25

label_15:                                         ; preds = %label_25
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  %2 = call i64 @llrb_insn_getlocal_level0(i64 %1, i64 3)
  call void @llrb_set_pc(i64 %1, i64 94225474387824)
  %opt_plus = call i64 @llrb_insn_opt_plus(i64 %2, i64 3)
  call void @llrb_insn_setlocal_level0(i64 %1, i64 3, i64 %opt_plus)
  br label %label_25

label_25:                                         ; preds = %label_15, %label_0
  %3 = call i64 @llrb_insn_getlocal_level0(i64 %1, i64 3)
  call void @llrb_set_pc(i64 %1, i64 94225474387896)
  %opt_lt = call i64 @llrb_insn_opt_lt(i64 %3, i64 60000001)
  %RTEST_mask = and i64 %opt_lt, -9
  %RTEST = icmp ne i64 %RTEST_mask, 0
  br i1 %RTEST, label %label_15, label %label_34

label_34:                                         ; preds = %label_25
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 16, i64 8)
  call void @llrb_set_pc(i64 %1, i64 94225474387960)
  call void @llrb_push_result(i64 %1, i64 8)
  ret i64 %1
}

上記のLLVM IRでcallされている関数は全てLLVM bitcodeにプリコンパイルされており、LLRBはJITコンパイル時にそれをリンクする。そのため以下のようなインライン化と最適化がLLVMによって行なわれる。

define i64 @llrb_exec(i64, i64) #0 {
  ...

land.lhs.true.i:                                  ; preds = %label_25
  %49 = load %struct.rb_vm_struct*, %struct.rb_vm_struct** @ruby_current_vm, align 8, !dbg !3471, !tbaa !3472
  %arrayidx.i = getelementptr inbounds %struct.rb_vm_struct, %struct.rb_vm_struct* %49, i64 0, i32 39, i64 7, !dbg !3471
  %50 = load i16, i16* %arrayidx.i, align 2, !dbg !3471, !tbaa !3473
  %and2.i = and i16 %50, 1, !dbg !3471
  %tobool6.i = icmp eq i16 %and2.i, 0, !dbg !3471
  br i1 %tobool6.i, label %if.then.i, label %if.else11.i, !dbg !3475, !prof !3380

if.then.i:                                        ; preds = %land.lhs.true.i
  call void @llvm.dbg.value(metadata i64 %48, i64 0, metadata !2680, metadata !3361) #7, !dbg !3477
  call void @llvm.dbg.value(metadata i64 60000001, i64 0, metadata !2683, metadata !3361) #7, !dbg !3478
  %cmp7.i = icmp slt i64 %48, 60000001, !dbg !3479
  %..i = select i1 %cmp7.i, i64 20, i64 0, !dbg !3481
  br label %llrb_insn_opt_lt.exit

if.else11.i:                                      ; preds = %land.lhs.true.i, %label_25
  %call35.i = call i64 (i64, i64, i32, ...) @rb_funcall(i64 %48, i64 60, i32 1, i64 60000001) #7, !dbg !3483
  br label %llrb_insn_opt_lt.exit, !dbg !3486

llrb_insn_opt_lt.exit:                            ; preds = %if.then.i, %if.else11.i
  %retval.1.i = phi i64 [ %..i, %if.then.i ], [ %call35.i, %if.else11.i ]
  %RTEST_mask = and i64 %retval.1.i, -9
  %RTEST = icmp eq i64 %RTEST_mask, 0

  ...
}

いろいろインライン化されている上に僕の書いたCSSがクソなので読みづらいが、簡単に説明するとRubyVMの状態を取得し、<が再定義されているかどうかをチェックし、再定義されていなければicmp sltという命令が実行されるようになっている。インライン化されているので、llrb_insn_opt_ltという本来の関数の呼び出しのオーバーヘッドもなくなっている。 これはYARVのinsns.defの定義をほとんどそのまま持ってきてインライン化しているだけなので、実装コストが低いというメリットがある。

この最適化で、以下のようなベンチマークで、

ruby = Class.new
def ruby.script
  i = 0
  while i< 30_000_000
    i += 1
  end
end

llrb = Class.new
def llrb.script
  i = 0
  while i< 30_000_000
    i += 1
  end
end

LLRB::JIT.compile(llrb, :script)

Benchmark.ips do |x|
  x.report('Ruby') { ruby.script }
  x.report('LLRB') { llrb.script }
  x.compare!
end

以下のようにパフォーマンスが改善することがわかる。

# x86_64 GNU/Linux, Intel(R) Core(TM) i7-4790K CPU @ 4.00GHz
Calculating -------------------------------------
                Ruby      2.449  (± 0.0%) i/s -     13.000  in   5.308125s
                LLRB      8.533  (± 0.0%) i/s -     43.000  in   5.040016s

Comparison:
                LLRB:        8.5 i/s
                Ruby:        2.4 i/s - 3.48x  slower

LLRBの設計はどうなっているか

C拡張として作成

どうやらYARVは最初C拡張として作られていたらしい。JITコンパイラをC拡張として作るのは大分無理があると思ってたけど、YARVがC拡張でできるならJITコンパイラもC拡張でできるのではないかと思ったのでそうしてみた。実際、CRubyのforkとして開発をするのに比べると、コアと疎結合になるだけでなく、bundlerとかbenchmark-ipsとかその辺のgemをカジュアルに使えるので開発はやりやすい。

しかし実際のところ、基本的な演算子のメソッドが再定義されてるかどうかを持つ変数のシンボルが当然exportされてないことに気付き、そこでCRubyに変更を加えず開発することは諦めた。ので、k0kubun/rubyのllrbブランチにしかインストールできない。それでも、シンボルのexport以外には何もしないという縛りを設けてあるので、今後CRubyのバージョンが上がっても追従はしやすい気がする。

YARVに手を加えない保守的なアプローチ

YARVは大分長い間運用されている信頼性のあるVMなので、仮にJITコンパイラを導入したとしても移行期間中はベースになるYARVがそのまま使える状態の方が安心できると思っている。なので、LLRBはYARV ISeqからコンパイルする方式を取り、またYARVのコア自体には一切変更を加えないようになっている。

じゃあそれでどうやってJITを達成しているかというと、YARVにはopt_call_c_functionという命令があり、「ネイティブコンパイルしたメソッドを起動。」という説明が書いてある。これをJITに使わない理由はない。

というわけで、LLRBは前述した方法でCの関数ポインタを取得してそれをopt_call_c_functionオペランドとし、全てのメソッドを以下のようなISeqに変換する。

0000 opt_call_c_function
0002 leave

opt_call_c_functionYARVの内部例外を扱える *1ので、この命令を使えば大抵のことは実装できるし、少なくとも僕がテストを書いた範囲ではthrowとかbreakとかreturnが動いている。

しかし、ISeqを書き変える方法だと考慮しないといけないことがある。それは、YARVが内部例外を補足するcatch tableがプログラムカウンターの位置に依存して内部例外をハンドルすることである。 プログラムカウンターが0か2にいればすむようにcatch table自体を書き変えると分岐できなくなるケースがありそうだったので、LLRBはcatch tableには手を加えず、逆にネイティブコードの中でプログラムカウンターを従来通り動かす方を選択した。

なので、プログラムカウンターがそのような位置にあっても適切にleaveできるよう、余った位置にleave命令埋めをしている。これはshyouheiさんのDeoptimization Engineのnop埋めのパクリである。

正直それで問題が起きないのかはよくわかってないので他の人のツッコミ待ちだが、僕がテストを書いた範囲ではrescueとかensureが動いている。

サンプリングベースの軽量なプロファイラ

LLRBはプロファイラとしてサンプリングプロファイラを採用している。CPU時間で一定のインターバルおきにバックトレースのサンプリングを行ない、バックトレースの一番上に出現する頻度が高いものをコンパイル対象として選択する。

stackprofとかperfもサンプリングプロファイラであり、この手法自体は既に広く使われているため信頼性がある。

また、stackprofと同じくrb_postponed_job_register_one APIを使う + GC実行中かどうかを判定していて、おそらくコンパイルしても安全と思われる瞬間にコンパイルが行なわれる。

低いコンパイルコスト

上述したように、CRubyのC関数はビルドプロセスでLLVM bitcodeにプリコンパイルされているので、LLRBがJITコンパイルを実行する際ランタイムでCの関数のパースやコンパイルを行なうオーバーヘッドは発生しない。 また、RubyのASTからコンパイルするわけではなく、ある程度コンパイルされたYARV ISeqからコンパイルを開始しているので、おそらくこれが一番コンパイルコストが低い手法になると思われる。

TODOs

  • まだ全ての命令に対して最適化を実施できていないので、実際のアプリケーションで使うとパフォーマンスが劣化する。なので、まずはその対応から…
  • ISeqのインライン化に対応したい
  • まだコンパイルに対応してないYARV命令がいくつかある
    • expandarray, reverse, reput, defineclass, once, opt_call_c_function
  • コンパイル中に作るRubyのオブジェクトのうちGCを考慮できてない奴がある気がする

ビルド・使用方法

このへんを参照

気持ち

まだ大分未完成なんだけど、3か月くらい経ちモチベーションの維持が難しくなってきたので公開した。この辺で得られた知見をRubyKaigiとかで話せたらいいなと思っていたけど、まだ僕のCFPはacceptされていないので、まあ無理だったらRubyConfで会いましょう。

*1:YARV内部の例外についてはRuby under a microscopeとかに詳しく書いてあるのでここでは説明しない

Ruby コミッターになりました

m_sekiさんhsbtさんの推薦で、ERBのメンテナとしてRubyのコミット権をいただきました

以下が初コミットです。

github.com

普段テンプレート言語Hamlの高速化その更に高速な別実装Hamlitの実装をやっていてテンプレートエンジンの高速化に知見があり、ちょこちょこERBにも知見を還元したりしていたのですが、一昨日ふとERBの生成コードのiseqを眺めていた時に気付いてパッチを送った後、「入れるのめんどくさいし、ERBのコミッタやりますか」とお声がけいただいた形です。

というわけで、引き続き主に高速化の方面でERBのメンテナンスをやっていきますが、他にも以前僕がC拡張にしたHTMLエスケープとか、広い範囲でパフォーマンス改善をやれたらいいなと思います。

さっきのパッチに関連してto_sもメソッド呼び出しをバイパスできるようにしたら結構いろんなものが速くなるんじゃないかなと思ったんですが、ベンチを取ってみたところ効果が微妙だったので、コアの改善は難しいなあとか考えています。 *1

どうぞよろしくお願いいたします。

*1:前提として僕はメソッド探索をしなくなれば速くなると思っていて、メソッドキャッシュの影響はありそうなものの String#concat と String#to_s の呼び出しの間にどういう違いが発生するのかちゃんと調査していないのでわかっていない。