k0kubun's blog

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

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 の呼び出しの間にどういう違いが発生するのかちゃんと調査していないのでわかっていない。

Treasure Data に入社しました

3月から Treasure Data で働いています。入社初日からタスクをアサインされ、RailsAPIの開発をやりました。

なぜ Treasure Data に転職したのか

前職もやりたいことができて優秀な同僚に囲まれ文句ない環境だったのですが、アルバイト入社から数えるともう3年半が経っていたし、入社前にイメージしていたような仕事も大体経験できていました。

そのままいても良かったのですが、ある程度の間隔で新しいことに挑戦しないと成長は止まってしまうと思っているので職場ごと変えることも考え始め、以下のような観点から Treasure Data に転職することに決めました。

  • エンジニアがユーザーになる仕事をしてみたい
    • 僕は開発者が使うツールを作るのが好きで、技術を売っている会社の方がそういうものを作る機会が増えそう
    • 正直あまりエンジニアリング以外に興味がないので、一般の人を対象にしたサービスを作っている会社より、エンジニアがユーザーな方が会社のビジネスに興味が持てる
  • 前職だとあまりやらなそうなことに挑戦する選択肢が増えそう *1
    • 分散システムやミドルウェアの開発、Javaを使ったバックエンドシステムの開発など
  • アメリカ (シリコンバレー) で働くチャンスを増やしたい*2
    • 上記を踏まえて今後のキャリアを考えると、日本にいるよりはアメリカにいた方が楽しい人生が送れそう*3なので、チャンスがあれば移住したい

その他、転職先に求めていたこと

市場調査のため転職ドラフトに登録していた時にいろんな企業様から声をかけていただいたきとてもありがたかったのですが、上記に加え下記のようなことを考慮し決めさせていただきました。

  • 自分が興味があったり得意な分野で尊敬できる人が多くいる
    • 今までは大体RubyRails、あとたまにインフラという感じで触っていて、そのどれでも自分よりすごい人がいる
  • 積極的にOSS活動や外部への発信をしている
    • 流石に全部オープンにするべきとは思ってないけど、何かすごい成果を出せたら社外に自慢する機会がないとモチベーションが維持しにくいと思っていて、そうしている人が多い会社の方がきっと楽しく仕事をしてるんだろうなと思う
  • 待遇が良い
    • 技術的な成長だけが人生じゃないし、総合的に見て幸せになるにはやっぱりお金も必要

気持ち

偉そうなことをいっぱい書いていますが、まだまだとても経験の浅い身ですので、早く成果を出して同僚の方に認めていただけるようがんばります。

*1:最初は僕がある程度得意であり人手も不足しているRailsアプリ(API)の開発をやります

*2:僕が働いているのは東京オフィスだけど、本社はMountain Viewにある

*3:技術で商売をしている会社は日本よりはシリコンバレーの方が多いよね、という話

Hamlを3倍速くした

Hamlコミッターになった

RubyKaigi 2015で「Hamlは遅いしメンテされてないので使わない方がいい」と言ったところ、じゃあ自分でメンテして速くしろということになりコミッターになった*1

当時から2年ごしなのは、当時のHamlのオーナーがあまりアクティブではなく最近a_matsudaさんがオーナーになったため。

HamlのTemple化・高速化を行った

Templeというのは、テンプレートエンジンをパイプライン的に構築するためのフレームワークで、テンプレートエンジン用の中間表現とその最適化エンジンを持つ。実装をTempleベースにすると、SlimHamlitに使われているような中間表現を使った最適化を適用しやすくなる。

コミット権をもらったので、RubyKaigi 2015でマージされないと言っていたパッチを自分でマージし、コード生成attributeのコンパイルをTemple化しながら高速化した*2。 それ以外にもいろいろ遅い原因を調べて改善した。

Hamlはどのくらい速くなったのか

Haml4 vs Haml5

つい昨日、それらの高速化を含むHaml 5.0.0.beta.2がリリースされた。

同じgem同士であるHaml4とHaml5を同時に比較するためのリポジトリを作っていて、これはslim-template/slimに入っているベンチマークからHTMLエスケープの回数が違う問題を修正したもの。

Ruby 2.4.0でHaml 4.0.7と現在のHamlのmasterを比較すると以下のようになる。 (Travisでの結果)

Rendering: /home/travis/build/k0kubun/haml_bench/templates/slim_bench.haml
Warming up --------------------------------------
          haml 4.0.7     1.595k i/100ms
   haml 5.0.0.beta.2     5.497k i/100ms
Calculating -------------------------------------
          haml 4.0.7     17.011k (± 9.9%) i/s -     84.535k in   5.031171s
   haml 5.0.0.beta.2     63.100k (± 1.8%) i/s -    318.826k in   5.054222s
Comparison:
   haml 5.0.0.beta.2:    63100.1 i/s
          haml 4.0.7:    17010.5 i/s - 3.71x  slower

というわけで、masterだとHaml4の3.71倍速くなっている*3。本当はmasterじゃなくて5.0.0.beta.2にしたいんだけど、あるバグ修正の際に誤って遅くしてしまったケースがあり、その対応を入れたリビジョンにしているので注意。5.0.0.beta.2だと3.28倍とかになる。

vs Slim, Faml, Hamlit, Erubi…

もともとslim-template/slimのベンチマークがテンプレート言語を超えて比較するベンチマークだったため、Slimなどとの比較もでき、以下のような結果になる*4。(Travisでの結果)

f:id:k0kubun:20170227151723p:plain:w540

まあまあ縮まったけどまだ遅いですねという感じ。やっていくぞ。

Erubiとは

これは完全に余談だけど、ErubiというERB実装をご存知だろうか。Railsで長らく使われていたErubisは、今年Erubiに置き替わった

Erubisはかなり前にメンテが止まっていた分frozen string literalが使われていなかったりして遅いんだけど、それがErubiでは解消され*5、ついうっかり僕もErubiのHTMLエスケープを速くしてしまったため、Hamlitとほぼ変わらないパフォーマンスが出るERB実装になっている。

どうやってHamlを速くしたのか

僕は自分のHaml実装を持っているので正直Haml使わないのに何故高速化をやっているかというと、何が原因でHamlが遅いのかに興味があったからである。というわけで、同じ興味を持っている人向けに、どうやって速くしたかを書いておく。高速化の手法自体はRubyKaigi 2015で話したので、以下では個別の話だけ書く。

以下はベンチマークの結果を見て効果があったと思われる順になっている。

1. attributeレンダリング用生成コードの最適化 haml/haml#904

このパッチが最も高速化に貢献しており、その分書くのも一番大変だった。簡単に言うと、以下のテンプレートは

.foo#bar{ class: 'baz', data: 'a' * 3 }

いままでは以下のようにコンパイルされていたが、

_hamlout.push_text(
  "<div#{
    _hamlout.attributes({"class"=>"foo", "id"=>"bar"}, nil,  class: 'baz', data: 'a' * 3 )
  }></div>\n",
  0,
  false,
)

以下のようにコンパイルされるようにした、ということである。

_hamlout.buffer << "<div class='baz foo'".freeze
_haml_attribute_compiler1 = 'a' * 3
case _haml_attribute_compiler1
when Hash
  _hamlout.buffer << _hamlout.attributes({ "data".freeze => _haml_attribute_compiler1 }, nil).to_s
when true
  _hamlout.buffer << " data".freeze
when false, nil
else
  _hamlout.buffer << " data='".freeze
  _hamlout.buffer << ::Haml::Helpers.html_escape(_haml_attribute_compiler1)
  _hamlout.buffer << "'".freeze
end
_hamlout.buffer << " id='bar'></div>\n".freeze

_hamlout.attributesって何、と思うかもしれないが、これはとにかく遅いメソッドであり、これを呼ばなくすると速くなる*6。コードの通り、動的な式('a' * 3の部分)の結果がHashじゃない限りはこの遅いメソッドが呼ばれず、また他の場所では可能な限り事前に連結された状態でバッファにconcatされるので、速い。

これをやるためには、ものすごく複雑なHaml attributeの仕様を正確に把握している必要があり大変だった。みんなもこの記事を5秒くらい見て欲しい。

このパッチで追加したHaml::AttributeCompilerは、互換性を一切崩さずに高速なコードを生成しようとしてものすごく色々なことを考えて書いてあるので語りたいことがたくさんあるんだけど、長くなるので何かのLTのネタとしてとっておく。

2. Haml::Bufferのオプション向けのオブジェクト生成を減らした haml/haml#897

Hamlレンダリングコードには必ず以下のコードが最初に入っていた。

_hamlout = @haml_buffer = Haml::Buffer.new(
  haml_buffer,
  {
    :autoclose=>["area", "base", "basefont", "br", "col", "command", "embed", "frame", "hr", "img", "input", "isindex", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"], 
    :preserve=>["textarea", "pre", "code"],
    :attr_wrapper=>"'",
    :ugly=>false,
    :format=>:html5,
    :encoding=>"UTF-8",
    :escape_html=>true,
    :escape_attrs=>true,
    :hyphenate_data_attrs=>true,
    :cdata=>false
  }
)

これが何を意味するかというと、レンダリングする度に毎回同じString 25個・Array 2個・Hash 1個を生成していることになる。毎回同じならこんなに渡す必要はないので、デフォルトとは違うオプションのみ渡されるようにした。そのため、普通に使っていれば、以下のようなコードが生成される。

_hamlout = @haml_buffer = Haml::Buffer.new(haml_buffer, {})

余計なオブジェクトが作られなくなるので速くなる。

3. attribute値のpreserveの無効化 haml/haml#903

以下のテンプレートは、

%p{ data: "foo\nbar" }

Haml4だと以下のようになるんだけど、

<p data='foo&#x000A;bar'></p>

Haml5では以下のようになるようにした。

<p data='foo
bar'></p>

これに関しては完全に仕様を変えているので全く褒められた改善ではないのだが、このpreserveと呼ばれる機能はとても遅い。遅いのは、ただでさえgsubが遅いのにそれ以外にもいろいろやっているからである。

SlimやFaml, Hamlitは後者の挙動で問題なく動いているし、入った時のコミットを見ても何で必要なのかよくわからん仕様なので、遅くなるデメリットの方が大きいと判断し削った。何か意見のある識者は声をかけてほしい。

4. 文字列リテラルのfreeze haml/haml#893

あまり解説する必要はなさそうだけど、静的な文字列をバッファに渡す時に必ず.freezeがついた状態で文字列を作るため、レンダリング時に文字列オブジェクトが生成しなくて済むというもの。

Temple化をすると静的な文字列は全て:staticという中間表現になるため、これに全部.freezeをつけていくのが簡単になる。

5. HTMLエスケープの高速化 haml/haml#902

Ruby 2.3でRuby本体のHTMLエスケープメソッドを高速化したので、gsubではなくそっちを活用するようにした。その際、古いHamlのものすごく複雑なエスケープの挙動が邪魔になるので、FamlやHamlitと同じごく普通の挙動にした。そこは後方互換性のない変更になってしまうが、流石にこれで困る人はいない気がしている。

github.com

HamlitよりHamlを使った方がよくなる?

ならない。Hamlitが速いのはHaml::Bufferが必要な機能を諦めているのとC拡張があるからで、少なくとも前者をやるのはHaml::Helpersを消せない都合かなり難しいし、後者はまだボトルネックではないのでやらなそう。

というわけで、Haml特有のヘルパーを使っている人などがHaml 5の想定ユーザーになるが、何らかの理由でFamlやHamlitへの移行を諦めた人には普通に嬉しいリリースなんじゃないだろうか。

僕も最終的にはみんなhaml.gemを使えばいい状態にはしたいが、そう簡単に後方互換性は切れるものじゃないと思うので、結構先になる気がする。

気持ち

FamlやHamlitではなくHamlを使っているアプリがある人はHaml 5.0.0.beta.2を試してみて欲しい。

*1:これは冗談で、a_matsudaさんからお誘いがあり、僕も興味があったのでやりますと言い、メンテをさせていただけることになった。

*2:余談だけど、HamlのTemple化をやりながらTempleを直していたところ、Templeの方のコミット権もいただいた。

*3:僕が変更を加える前のmasterは1.09倍くらい https://travis-ci.org/k0kubun/haml_bench/jobs/205696677

*4:Hamlは先ほどと同じリビジョンで、Erubiも現在のmasterである ad41891 にしてある

*5:なお遅かったのは素のErubisの話で、もともとActionViewはErubisを魔改造していたためRails上ではこの問題はなかった

*6:真面目に説明すると、改善後のコードがコンパイル時に済ませている文字列連結を全部レンダリング時にやっているのが_hamlout.attributesである

広告を非表示にする