cuzic (a.k.a Tomoya Kawanishi) です。
RubyKaigi2018 3日目の内容の速報記事です。
- Parallel and Thread-Safe Ruby at High-Speed with TruffleRuby
- The Method JIT Compiler for Ruby 2.6
- LuaJIT as a mruby backend
- How happy they became with H2O/mruby, and the future of HTTP
- TRICK 2018 FINAL Results
Parallel and Thread-Safe Ruby at High-Speed with TruffleRuby
https://speakerdeck.com/eregon/parallel-and-thread-safe-ruby-at-high-speed-with-truffleruby
性能比較
- OptCarrot
- OptCarrot のデモ
- 28 fps -> 42 fps
- MJIT を使うと 50 fps を実現できる
- JVM 上の Ruby だと 160 fps にもなる。
- ベンチマーク
- OptCarrot の Warmup
- TruffleRuby はベンチマークでも圧倒的に良い結果
- CRuby と比較して高速化
- マイクロベンチマークでは CRuby に比較し 32倍以上高速化
- TruffleRuby は ERB のレンダリングで MRI より 9.4倍速い
- eval, binding, Proc , lambda, blocks
- TruffleRuby は ERB のレンダリングで MRI より 9.4倍速い
- Rails ベンチマークはまだ
- たくさんの C 言語のパッチが必要。研究・実験が継続中
TruffleRuby はどのようにして高性能を実現したか
- Partial Evaluation と Graal JIT Compiler
- コアな primitive は Java で実装されている(Integer#+) とか
- ほかは Ruby で実装されている
- Truffle AST の PatialEvaluation
- AST を順にたどって JVM メソッドのインライン化と定数の畳み込みを実施
- 下記の実装の最適化の説明
def foo [1, 2].map {|e| e * 3 } end
- Inlining ( Partial Evaluation and Graal Compilation )
- inline ma() and block()
- RubyArray を使わず Java のネイティブの配列(int )で置き換える
- int であることが分かっているので、型伝播できる。
- for ループがたった2回のループだったので、ループする代わりにベタに書くことができる
- 定数の畳み込みにより、そコンパイル時に計算し、実行時には計算を省略できる
- 上記の Ruby コードは 1行の非常にシンプルな Java コードに変換できる
Object foo(){ return new RubyArray(new int[2] { 3, 6 }); }
- MJIT
- 呼び出し回数がおおいとき、MJIT は Rubyバイトコードから Cコードを生成する
- C の世界から Ruby のメソッドを呼ぶとき、MJIT には知識が不足している
- MJIT はインラインしていいかどうか判断できない
- 一緒に最適化できない
- Ruby Performance Summary
- JIT コンパイラはコアライブラリにアクセスできる必要がある
- JIT コンパイラは Rubyの構造を理解する必要がある(例 Ruby オブジェクトのメモリ配置)
Parallel and Thread safe Ruby
Parallel and Thread-Safe Ruby
- 動的言語は parallelism に対するサポートが不十分。
- Global Lock:
- シングルプロセスで動作し、Global Lock があり、メモリを浪費し、相互通信が遅い。
- Unsafe:
- concurrent Array#<< raises exceptions
- JRuby, Rubinius
- Share Nothing
- JavaScript, Erlang, Guilds
Guilds
- Stronger memory model
- Shared multable data へのアクセスは逐次的になる
- Ruby Thread を使った既存ライブラリを再実装が必要になる。Thread とモデルが異なる
- 共有メモリモデルと、actor モデルは相互補完的。問題によって使分けるべき。
concurrently な配列への値の追加
- CRuby では Global Lock があるため正しい値となるが、
- JRuby ではまちがった値を返すか、非同期変更に関するエラーが生じるという例
- Rubinius でもエラーになる
ワークアラウンドの問題点
- Mutex#synchronize か Concurrent::Array.new を使うのがワークアラウンド
- しばしば Mutex#synchronize し忘れる
- 同期が不要なとき、大きなオーバーヘッドとなる
- Thread-safe な Array と Hash は Thread-unsafe な実装とは非互換
- Bundler と Rubygems ではそれに関するバグ報告がある
Collection を thread-safe にしてさらに、シングルスレッドのオーバーヘッドをなくす方法
- 動的言語では実行時にフィールドの追加や削除が可能
- オブジェクトのメモリサイズが動的に増やせられる
- 同時に書き込みすると失われる場合がある
- アイデア: 同期が必要なオブジェクトやコレクションに関してだけ同期する
- 単一スレッドからしかアクセスされないオブジェクトには同期の必要はない
- 複数スレッドからアクセスがある場合は同期が必要になる
Write Barrier: Tracking the set of shared objects
- 共有オブジェクトに書き込みがあった場合は、推移的に値を共有している
- 共有コレクションは、要素を追加するときに write barrier を使用する
- Array Storage Strategies
- 狭い型から順に coalescing、/ migrating する
- 例1: empty -> int -> long -> Object
- 例2: empty -> double -> Object
- Shared Array の目標
- すべての配列操作をサポートし、thread-safe であること
- 配列の異なる箇所への頻繁な並列アクセスが可能であること
- いろんなロック手法で、配列の reads/write の性能を比較
- NASA の Parallel Benchmark
- Java や Fortran と比べても TruffleRuby は善戦
TruffleRuby は既存の Thread を使った Ruby コードを並列実行できるようにできる
結論
- TruffleRuby が示したように Ruby の性能は劇的に改善可能
- parallelism と thread-safety をオーバーヘッドなしで実現できる
- 複数のコアを利用し、thread-safety も確保した上で Rubyコードを並列実行できる
Grow and Shrink - Dynamically Extending the Ruby VM Stack
- 自己紹介
- マーティンの研究室でしてきたこと
- おもに国際化関係
- Ruby の VM 関係をやっている
- Ruby が大好きでもっといいのになるといいな、とふっている
- 今回の動機
- Multithread Ruby
- Go とか Elixir のような言語がどんどん増えている
- Ruby もそういうのに近い、並行処理に関する努力がある
- Ruby をそういう方面にも強くするということでやっている
たくさん Thread を使うとメモリ使用量が問題になる
- 現在の実装では 1MB の固定サイズになっている
- そうではなくて、小さく確保して、あとから拡張することを研究している
Ruby VM スタック
- 前提: MRI 、YARV の場合
- 2つのスタックを使っている
- 1つの領域の中に2つのスタックがある。
- 上から下に伸びるのと、下から上に伸びる
- Call Stack
- 上から下に伸びる
- Call Stack
- eval/class/method/block
- 実例
- ここは全体の実装になっている
- メソッドを呼び出して、C で呼び出した関数を呼んで、show_stack を見てみる
- Ruby の VM が落ちたときの情報にスタックの情報が見える
- 落ちたときでなくてもスタックの情報が見えるように Object#show_stack というのを提案した
- Internal Stack
- 大きさが一定じゃない
- しかも重なっている(overlapping)
- 命令の実行のために使う
- 細かく見ると
- 計算の演算子を載せて、演算の結果を載せる。Stack Pointer はスタックの上にある
- 特殊な値があって、環境の引数とかブロック変数がある
- ブロックから外側の引数・変数にアクセスしたい場合には、何個外の何番目の引数でアクセスするかの情報がある
- こういうところでは実装では大変になっている部分
- show_stack を作って内部スタックを表示できるようにした。
- この計算でこういうふうにスタックに載せて、最初に計算することができる
- こういうふうに計算の途中でもその状態を見せることができる
- この研究はささださんと話して、ささださんが 2016年に提案した
- 一生懸命こいけくんががんばったけど、完成できなかった
- 本を信じすぎた。実際はもっと複雑だった
- すぎやまくんが引き継いで、完成までいった。
スタック拡張の実装について
- スタックの拡張はスタックオーバーフローが検知されたときにスタックのサイズが2倍になる
- ただし、無限再帰対応のため、スタックのサイズには上限がある
スタック拡張
- 仮想マシンのデフォルトは 1MB
- このサイズは環境変数を用いて初期化時に変更可能 - 新たに初期サイズを設定するための環境変数を用意した
- スタックの拡張を必要に応じて呼び出すためにスタック拡張への呼び出しをするようマクロを書き換え
- 先ほどのマクロを使用しているところから、スタックの拡張するところが8ヵ所あることが分かった
- 最大サイズよりも小さければ、スタックを拡張する
- スタックの拡張はスタックが移動するため、スタック内へのポインタが問題になる
- 拡張時にポインタを書き換えることで対応できる
- ただし、どこからか分からないものもあるし、
- C で使うものもある
- コピーしておくことで対応する
- 元は直接のポインタだったのを、スタックの最初の場所からの位置(オフセット)に変更
- 内部スタックのポインタをオフセットに変更する実装の例
- メソッド呼び出しの際に、メソッドの引数として渡されるスタック内のポインタがある
- 呼び出されるメソッドでは引数のスタックが移動することが想定されていない
- 別に確保した領域にコピーしておく。
- 開発手法
- 何かを修正する → セグメンテーションフォルトを待つ
- セグメンテーションフォルトは遅すぎる。
- うまくいかない
- 採用した方法
- 古いスタックへのアクセスを禁止
- 頻繁にスタックを強制的に移動
- mprotect関数というlinuxシステムコールでメモリへのアクセスを制御する
- すぐにセグメンテーションフォルトを発生させることができる
- スタックの拡張は Ruby プログラムでは困難
- 実際に拡張が発生するのは少ないがありとあらゆるタイミングでの拡張で問題ないことを確認することが必要
- スタックが拡張する可能性があるすべての場所でスタックを移動するように修正した。
- 古いスタックへのアクセスの禁止と組合わせることで、いつスタックが拡張しても問題ない実装ができる
- 完成したバージョンはすべて成功した
- 失敗したテストとしては、時間制限があるテストがあったが、制限時間を緩和することで解決した。
- メモリリーステストにはスタックの free が漏れていたことが原因で、問題がない実装ができていたことが確認できた
- 何かを修正する → セグメンテーションフォルトを待つ
- 仮想マシンのデフォルトは 1MB
性能改善効果とその後の改善
- MRI の実装でベンチマークした
- 変更による実行速度の影響を確認した
- スタックの拡張が行われない速度で確認した
- もっとも結果が良いものを採用した。
- 最大で66% 、平均で18%の速度の低下だった。
- コールスタックへの参照を間接的な参照に変更した結果、遅くなっていた
- コールスタックを移動させずに済む手法が有効だと考えた
- chaining という手法を試行
- control frame の連結リストとして実装
- 内部スタックを上方向に実装
- スタックオーバーフロー、スタックの拡張は内部スタックに関してのみ発生するようにする
- 全体の平均での実行速度の低下は、18% から平均 7% にまでベンチマークの低下が改善した。 - 内部スタックが移動することから、メソッドに渡される引数を別領域にコピーする対応も必要 - control frame を動的に確保すると、実行速度の低下が確認できた - 性能が低下する原因を調査するため、実行速度の影響を詳細に確認した
- メモリ使用量の削減を確認するため、sleeping threadの場合は再帰を行う thread で評価を実施
- 初期サイズを変更することでの実行速度への影響を調査
- chain 法に関する結果
- スタックのサイズが毎回2倍に拡張させるため、スタックの拡張処理そのものの影響は小さく、スタックへのアクセス方法の変更が影響していると考えられる
- デフォルトである 1MB から 1KB に設定した。
- 評価では sleep するだけの Thread を使った
- 生成する thread を 1000 から 10_000 個まで実験
- もとの実装では 25.7KB で、stretching では 14.8KB 、chaining では 15.0KB となったことが分かった
- スレッド数が多くなるとともに、改善が大きくなる
- 初期スタックサイズから 128B から 1MB まで2倍ずつ変化させた
- 初期スタックサイズが小さくなるほど、メモリ使用量が変化するということが分かる
- chain法では stretch 法と比べてメモリ使用量が少ない
- 実際の物理メモリの使用量は計算上のメモリ使用は1GB だが実際は 250MB であることが分かった
- より現実的なユースケースでの評価すため2つのモデルを使用
- スタックの深さを線形としたモデル
- スタックのサイズを半減するたびにスタックの深さを倍にしたべき乗モデル
- 浅いスタックが頻繁に使われ、深いスタックはめったに使われないという現実のユースケースを考慮したモデル
- 元の実装に比べて 30% 改善することが分かった
- 全体でのメモリ使用量が削減できた。
- 実用的な応用でよくみられるようなスタックの自動拡張がメモリ使用量の削減に有効であることが分かった
注意点
- 多段に抽象化層がある
- RubyVM
- Allocator
- Operating System
- Hardware ( cache, translation lookaside buffer ) - 下の影響はどうしても避けられない。
- OS の部分によって、助けられているとも考えられる
- 多段に抽象化層がある
Go, mruby, Lua, Perl, Java, Python で動的拡張が行っている
- なぜか、Ruy では動的拡張をやっていなかった
- 結論
- 動的拡張の安定実装ができた
- マルチスレッドプログラムでのメモリ消費を削減できた
Future Work
- もっと遅くならないようにしたい
- もっと多くの環境でテストしたい
QA
- Q: スピード改善の見込みは何があるか?
A: control frame を1つずつやっているが、そこは改善できる。内部フレームでのポインタの変換のところも改善の余地がある。
Q: 上からの方は chaining したりとかで、ということだったが、下からの方はなぜ chaining していないのか?
A: スタックフレームも拡張すると、スタックフレームの移動を前提とした実装が必要でやらなかった。外からのポインタスタックフレームは少な目、コントロールフレームは多いのでそうした。
Q: call stack というのは双方向連結リストなのか?
- Q: 前のフレームから後のフレームへの参照はないはずなので、単方向でできるはず。
The Method JIT Compiler for Ruby 2.6
- k0kubun
- アジェンダ
- 現在のステータス
- JIT が Rais でどういう動くのか。
- JITのステータス
- 同期コンパイルしていると、ちゃんと動く
- 並列実行すると race condition があって必ずバグがある
- MJIT
- JITをするとやり方が特殊
- バイトコードを C にコンパイルして、 dlopen を使ってメモリ上にロードしたりする
- JIT コンパイラを行っている
- バイトコードを C にコンパイルするところが大切
- 右側のようにそれっぽく erb で書くとうまくできる
- すごくメンテナンスがラク
- portability
- visual C++ とかでは動かない
- mingw では動かなかったけど、最適化するとなぜか動くようになった。
- Clang でも動くようになった
- performance
- 5.7x 倍くらい速くなった
- 去年よりもだいぶ速くなった
- preview2 としてリリースされている
- Optcarrot でベンチマークすると 2倍くらい
- Rails では遅くなるというバグがある
- Ruby を production で使うユースケースとして、重要
- なぜかファミコンは速くなるのに Rails は遅くなるのか
- 仮説
- longjmp するときは遅くなる
- 仮説を立てながら JIT しているので、仮説が外れた場合は遅くなる
- profiling するのでオーバーヘッドがあるから遅くなる
- JIT コンパイルにオーバーヘッドがある?(別スレッドだけど)
- longjmp 。大域脱出
- VM の中だと、return するだけでできるけど、JIT だとそれができない
- profiling は遅くなるか確かめた
- おそらく違うと予想していたが、やはり違っていた
- ほとんど影響ない
- JIT で呼び出したメソッドがキャンセルが起きているのではないか説
- Ruby のメソッドって、最適化されている
- 適当に定義されたクラスら遅くなる
- opt_xxx というのが遅くなりやすい
- メソッドキャッシュが効く間はすごく速い
- キャッシュが有効でなくなった場合は JIT のキャンセルを行う
- 引数に対して別のクラスがきたときに遅くなる
- Optcarrot にはないが、Rails だとそういうことはある
- Hash# がけっこうたくさん呼び出されている
- 実際しらべてみるとやはりそうだった
- JIT コンパイルのオーバーヘッド
- ふつうに CPU のリソースが必要
- メインのスレッドから転送が必要
- mjit_stop というのを使って、JIT のコンパイルを止めたあとにどういう違いがあるか計測した
- 8ms のうち 5ms はこの理由になっている
- ベンチマークをとるときはこの影響があるが、Rails ではそんな影響ない
- JIT されたコードを呼び出すとオーバーヘッドがある
- nil を返すメソッドを作って JIT すると遅くなる
- これは厳しい。いやぁ。厳しい。
- profiling すると、コールスタックのフレームからのメモリのロードにボトルネックがありそうに思った
- メソッドの種類を増やすと nil を返すだけのメソッドたちなんだけど、遅くなる。
- L1 のキャッシュが効いて、次に L2 のキャッシュが影響している
- Rails でいろんなメソッドが呼び出されていると、遅くなる
- Rails は遅くなるに決まっている
- insn per cycle を調査する
- いくつもの命令を同時に実行できなくなっている
- CPU のクロック数をむちゃくちゃムダに使っている
- 84億くらい使っている
- icache.ifdata_stall 。キャッシュにヒットしなくて呼び出しにストールしている
- 実際 L1 から L2 に移っていることが分かる
- Ruby は SO ファイルを dlopen してロードしている
- メモリを 2MB 消費する
- けっこうヤバい状態にある
- 1ページ消費している。メモリのロードを上げるには局所性をあげた
- 実際に1個のファイルにすると局所性が上がって性能が良くなる
- 実験すると実際良くなった
- すべてコンパイル済のメソッドを1個のファイルにする工夫をすれば、改善できる
- すべてのメソッドがあればインライン化が簡単になるメリットがある
- idq_dsb_cycles の改善も予定している
- ベンチマークしているときは JIT の途中だと遅くなる
- ちょっと開発が必要
- 仮説
- DIVE Into Native Code
- スタックから1と2をプッシュした
- クラス名を計算したあと
- メソッド名を呼び出したとき、
- レシーバと引数が両方 FixNum かを調査する
- Float と Array と String に分岐しているコード
- 再定義された場合の処理を除外する
- メソッド呼び出しというのは C-c のチェックがある
- C-c したらすぐに死なないといけないという法則がある
- メソッド呼び出しから抜ける処理
- バイトコードの命令をとってきて実行するオーバーヘッドがない
- プログラムカウンタに関するオーバーヘッドがない
- スタックにつっこんで、どこかに動かすというオーバーヘッドがない
- 一番重要なのは Fixnum とかのチェックがなくなって、3 を返すというのができている
- GCC にお任せして、できている
- ちょっと複雑な計算でも GCC がやってくれる
- JIT にすると 8倍速くなる
- i をゼロにして 1000_000 までループして、というのを最適化する例の説明
- とか < の演算がレジスタで行われている
- と < の再定義が共有されていて、速くなっている
Method inlining
- C コンパイラに仕事をたくさんしてもらうためにはインライン化というのが必要
- じゃぁ、インライン化というのは何かというと
- インライン化で何が必要か。
- JIT コンパイラは定義をしらないといけない
- JIT コンパイラはメソッドを呼び出す方のコードを JIT コンパイラが知っている必要がある
- C の関数の内側にあると大変
- メソッドをインライン化したあとに破棄できないといけない
- メソッドキャッシュがないところでインライン化しようとすると大変
- Ruby method, Ruby block, C method
- Ruby から Ruby を呼び出すのはカンタン
- バイトコードを対象にいろいろ処理をやっている。これはコントロールしやすい
- 呼び出し側も呼び出される側もコントロールできる
- Ruby block が yield しているのを呼び出すのは比較的カンタン
- C を Ruby から呼ぶのもできる
- C から呼び出される場合はすべて難しい
- Ruby -> C -> Ruby inlining Problem
- もし、Ruby が C よりも速かったらどう思うか。
- Ruby の方がもしも速かったらどうなのか?
- C のコードを Rubyに変えると高速化することができる
- Ruby で定義するとふつうは遅くなる
- JIT がある場合、Ruby で書くとめっちゃ速くなる
- C language is Dead
LuaJIT as a mruby backend
Takashi Watanabe
- 動機
- LuaJIT について具体的に話を聞くことが少なく調べてみたかった
- RubyKaigi でスピーカーしたかった
- 3つ CFP を出していて、これだけがアクセプトされた
- mruby での継続的インテグレーションの話
- mruby はキーワード引数を渡せるようにした話
- 今回の発表がアクセプトされて、触ることができた
- Elm という純粋関数型言語も勉強している
- バージョン
- mruby 1.4.1 から
- LuaJIT 2.0から
- Ruby 2.4から
- LuaJIT
- Mike Pallさんが作ったもので、世界で何番目かに速い動的型付言語の JITエンジン
- VM がオリジナルの Lua よりもずっと速い
- Lua
- ブラジル生まれ
- 言語機能は JavaScript にとても近い
- 整数型というのは言語機能ではない
- オブジェクト指向もできるけど、プロトタイプ指向
- メタテーブルというハッシュテーブル的なもの
- アプリケーション組込み前提
- Ruby との違い
- Array やリストはなく、Table が兼用する
- Array のようなテーブルではゼロからではなく 1から始まる
- 文字列は変更不可
- メソッド呼び出しに : を使う
- mruby とどう似ているか
- Lua はとても mruby に影響を与えている
- Ruby の内部構造を Lua に似せて、mruby ができた
- mruby と内部の命令セットは似ている
- その用途は同じで、アプリケーションに組み込むということが主眼になっている
- CRuby ばっかり速くなってズルいと思うので mruby を速くしたい
- JIT コンパイラに関する基本
- VM で実行するという環境が必要
- VM をプロファイラにして必要なコードをどんどんネイティブのコードにコンパイルしていくという動き
- 立ち上げが速いとかカンタンに生成できるとか、メリットがあるので、
- 遅いところだけ JIT でコンパイルすることで速くしたい
- 実行可能なメモリ領域を確保して、さっき確保したメモリに書き出して、次読むときにはネイティブコードに書き換えるということを考えている
- warmup というフェーズが必要になってくる
- 最初は VM で実行が始まってしまう
- 求める最高性能っていうのが出てきて、
- JIT コンパイラの処理系の最高性能を測る時は warmup が終わったあとに計測が始まる
- Method JIT コンパイラと Tracing JIT コンパイラがあって、
- LuaJIT は Tracing JIT コンパイラを採用している
- メソッド単位よりも細かい単位でできるのが Tracing JIT コンパイラ
- デモ
- ここで大事なのは PROT_EXEC というフラグをメモリに確保するときに渡していること
- 実行できない領域に通常割り当てられるのが、このフラグにより JIT コンパイラが可能になる
- Ruby でもできないのか、というと可能なのだが、mmap システムコールに対応することができなくはなく
- mmap がちゃんとうごくものがあれば可能
- JIT コンパイルにはメリットデメリットがある
- オーバーヘッドがある
- 組込み用途だとそれほどやさしくはない
- C とか C++ を使うと遅くなるということがある。
- 対策がないわけでなく
- JIT コンパイラと FFI を組合せると非常に速くできる
- Python で FFI をより高速化する DragonFFI という取り組みがある
- 関数ポインタが返ってくるのだが、それをうまく JIT コンパイラと組合わせることで速くできる
- LLVM は JIT コンパイラとして向いていない
- JavaScriptCore は V8 とかと違って、LLVM に組み込んでいるのだが、独自実装に切り替えた
- HHVM も試してみたが、若干の性能向上はあったが、労力に見合わないということで、こころみとして終わった
- JIT コンパイラとしてどんなコードが JIT コンパイルされるかが重要
- 何%かのコードしか実際には JITコンパイルされない
- ループというのは何回も実行される
- このコードをコンパイルして欲しいという指定もできる
- LuaJIT がなぜ速いか
- 設計がいい
- 結局、JITコンパイルする場合でも、VM で実行する割合が高い
- コンパイラが非常に小さい
- メモリ構造が大変よくできている
- NaN Boxing というデータ構造がある
- 浮動小数点型で表せる数字の単位は非常に広い
- その中にポインタとかを組み込むことができると効率化できる
- tagged pointer という技術が Ruby で使われている
- VALUE 型のこと
- ふだん使っているポインタで指す必要のない領域というのがあって、それを使っている
- ポインタというのはアラインメンツというのがよくされている。
- 下位数ビットが必ず使われていないというのが分かっている
- そこをタグとして使っている
- 全部を使い切っていない領域に別の領域に使うことでもできる
- さきほどの NaN boxing は全部の数が浮動小数点でいけないという場合 有効
- LuaJIT のコートレベル
- ソースコード - ふだん良く見るようなソースコード。lj_parse.c で処理
- バイトコード
- CRuby の場合は、ここ数年で活用されている
- SSA IR 。static single assignment 中間形式
- コンパイラが取り扱いやすい。最適化しやすい。
- 多くのコンパイラ実装では使っている
- static single assignment というのは単一代入
- 次に代入するときは別名をつけて、新しく名前を付けた方を採用するという方式
- そのあとはネイティブコードということで、CPU が直接実行できるような形式
- x86/ARMなどがサポートされている
- LuaJIT 内での最適化
- バイトコード、SSA の形式で最適化が行える。
- Wiki のページを見ると、大変長い最適化が行われている。
- バイトコード最適化だけはしっかりドキュメントされている
- SSA-IR の形式だとソースコードをみないと、よく分からない
- 定数しか存在しないような式をバイトコードに落とすときに計算しないようにするという最適化が行われている
- バイトコードのあとは、JIT VM でコンパイルしなければならないというふうに最適化されている
- src/lj_opt_*.c で実装されている
- allocation sinking という最適化だけはしっかりと最適化がある
- SSA IR からネイティブコード生成を行う
- LuaJIT みたいに複数のネイティブコード生成に対応するには工夫が必要で、そのために各JITコンパイラにはネイティブコードを生成する機構がある
- LuaJIT では Dynamic Assembler という仕組みが用意されている
- マクロのような便利な機能を追加したアセンブリ言語
- ほかの言語処理系でも使われている
- Perl の MoreVM でも使われている
- C のコードと混ぜて生成できる
- LuaJIT で特徴的なのは 64bit 環境でも必ずポインタの長さは 32 bit という特徴がある
- MAP_32bit というフラグを渡していて、mmap するときに 32bit に収まるような割り当ててもらっている
- LuaJIT では 32bitのポインタを必ず使うようにしている
- NaN ボクシングをするときには 53bit までしかポインタが使えないので、その対策
LuaJIT VM
- DynASM で生成されている
- おもしろい最適化として、make amalg という、1つのファイルにくっつけることで Cコンパイラに最適化してもらうという技術
- LuaJIT で使う C を include するだけで inline 化などをしてくれる
- LuaJIT ではテストが難しく、作者がテスト方法を用意してくれているが、実行方法すら分からない
- LuaJIT 速いかな、と思って、LuaJIT のフォークとか独自実装もあるよう
LuaJIT
- LuaJIT はスポンサーベースで行っているので、基本的にお金が入らないと開発が進まない
- Lua の未来についてたいへん悲観的
- LuaJIT は Lua5.1対応だが、Lua最新は Lua5.3
とりあえず Ruby を LuaJIT の上で作るというのをやってみた
- Opal みたいに数値型が Float と Integer は同じになってしまう
- mruby を実装しようと思った
- ソースコードを AST にしてからバイトコードに落としてから、この先の JIT を作ってみようというもの
- LuaJIT の上で実装する方法
- mruby の型を LuaJIT の型にすく
- コード生成器を置き換え
- DynASM で置き換える
- メソッド解決は Ruby でも複雑。それを Lua の上で再実装しないといけないということが分かった
- mruby の API を Lua に接続できるかいうのを試してみた。
- コンパイルを通すことですら難しい。
- ファイルを消しすぎて、どうしようもなくなったりして、大変だった
- 内部構造とかも全然違う。
- うまいこと落としこむことも難しい
- Lua の API というのは使いにくかった。
- LuaJIT を触りだしたところで苦戦
- どうしたらやりたいことが LuaJIT で実現できるかというのは分かった
- ほかほかいろいろやったがうまく動かなかった。
How happy they became with H2O/mruby, and the future of HTTP
- 実はこのタイトルは YAPC::Kansai というイベントで H2O/mruby でどう幸せになれるのかというトークをした。
- アンサートーク的になっている
- 実際に前回発表した内容を試させてくれる環境を探していた
- 大学の友人で nginx で困っている
- 使ってみたいな、ということでやり始めた
- Room Clip
- 日本最大のインテリア専門SNS
- CEO と CTO が大学の友達
- 写真サイズであるあるなのが、イメージのリサイズ、フィルタ、マスク
- 任意のサイズに縮小して配信する必要がある
- unsharp mask がかかっている
- デザイナの人からすると全然違うといわれる
- 計算量高いけど、やらないといけない
- nginx と smalllight で実現していた
- キャッシュを非常に活用した構成になっていた
- ローカルにある変換後画像のキャッシュを見に行く
- S3 にある変換後画像を見に行く
- それもなければオリジナル画像のキャッシュを見に行く
- それもなければ、変換したり、各種キャッシュに書き込んだりしていた
- 最初みたときびっくりした。
- 解読に2週間くらいかかった
- 自分で書いておきながら読めない状況だった
- バグの温床になっていた
- セキュリティ的な問題もあった
- 任意の画像に拡大縮小といっても制限が必要
- 一瞬で DoS がおきる不具合があった
- 100 pixel が欲しい場合は、180を選択するのが適切。
- nginx での configuration では and を書けない制限がある
- nginx の設定ファイルはプログラム言語ではない
- nginx の設定ファイルはデバッグとテストに関して制約がある。つらい。
- スケジュール
- 5月くらいに顔合わせ
- 6月にほぼ実装完了
- 7月には fastly に入社
- 最終的にリリースしたのは 11月
- だいたい1ヵ月くらいで検証からデプロイまで完了した
アーキテクチャの変更はしなかった
- ほとんど変えていないけれど、smalllight をなくして、webサーバと画像変換のサーバを分けた
- converter と RMagick を使って動いている
H2O の設定
- 基本 mruby でハンドルするようにした
- h2o の静的ファイルでさばかせて、あとは mruby でさばかせる。
- 実際にはローカル変数だったんだけど、必要な引数を渡している
Rack::Reprocessable
- internal redirect ができる
- S3 へのフォールバックとかをカスケードで実現
- Rack fashion で階層的にアプリケーションが構築できている
- SourceSizeSelector
- Converter
- unicorn で動いている架増変換サーバにリクエストしているだけ
- 中で何が起きているか、とか。けっこう調べるのは大変。
- printf が当然必要
- H2O/mruby でも当然できる
- WEBサーバのテストをどうやればいいのか?
- 開発環境とかで、curl とかでやっているのか?
- プログラミング的には unit test を書けばいい
- unit test というのは、各自で stub とか mock を用意してもらう必要がある
本番へのデプロイ
- H2O でロードバランサさせることにした
- Nginx + smalllight との結果と比較した
- レイテンシの測定結果、ヒストグラム
- nginx はたまに遅いのだが、
- H2O の場合はそんなことはない
- goreplay でまったく同じリクエストを与えている
- sourcesizeselector のおかげでムダな元画像を持ってこなくなるし、結果としてキャッシュも聞きやすくなったし、計算量自体も下がった
- たまにワーカスレッドがたまるということがあった。
- 80% percentile のレイテンシだと nginx の方が速い
- 95% percentile だと H2O と mruby の方が速い
- 全体としてのスループットは改善したといえる
- 保守運用性も改善した
- S3 の転送量の点でもコスト削減できた
- ただ、一方でドキュメントが必要
- 副産物
- mruby-rack という実装
- チョコチョコ syntax が違うので、それを修正したりしていた。
- Rack のテストを全部動かして、動かせるようにするのが大変だった
- mruby-rack-httpcache の実装もある
H2O のほかの最近の改善
- TreasureData
- narittan
- H2oO::Channel
- combine first 2 responses from upstream
- redis の subscribe ができたりする
- Redis の pub/sub も使える
- リクエスト
- Early Data Header 425 というステータスコードを増やした
- TreasureData
103 Early Hints
- アプリケーションサーバから H2サーバに対してどうやって通知するか
- 疲れており、メモしてなかった
Design pattern for embedding mruby into middleware
- 自己紹介
- Matsumotory
- WEB にどんどん mruby を使っていっている
- その設計の仕方みたいなのを紹介したい
- ミドルウェアに mruby を組み込む話
- システム管理の仕事をしてきた
- いわゆる運用をやってきた。
- インフラエンジニア
- お客さんのコンテンツをいじることはできない
- いかに最適化するかというのをずっとやってきた
- そこで使われる汎用的なのは C で書かれているのが多い
- postfix とか sendmail とか
- bind とか
- 周辺ツールとかいろいろ作っていた
- Perl で書いていて、web contents をいじれない以上、もう少し融通を利かせていきたい
- ミドルウェアの特性に合わせて C で書いてきた
- ドキュメントがないので、コードを読みながら nginx とか apache のモジュールを作ってきた
- mruby が来た
- 乗らなきゃこのビッグウェーブ
- mruby は C で書かれたミドルウェアに対して、インタプリタそのものを組み込んで一部の機能を mruby で書こうという考え方
- 設定をコードで書けたらベンリと思った
- ミドルウェアの一部の機能を mruby で書ける
- ミドルウェアのふるまいそのものを mruby で書ける
- C の製品の拡張・設定を書ける
- 設定に特化したいことをやりたいと思った
- nginx mruby であったり、 imap の拡張とか
- h2o の mruby を組み込むところの最初の PR を作ってみた
- もうほぼ自分のコードは残っていない
- 初期実装だけ
- 関連ツールも mruby CLI というのも作ってみた
- そうこうしているうちに mgem-list でも一番とれた
- 情報学の博士号もとれた
なぜ、ミドルウェアに mruby を組み込んだか
- もう少しミドルウェアのふるまいを ruby で定義したかった
- あんまり自由すぎても困る
- メソッドとか限定されているのも都合が良かった
- ミドルウェアのセッションとかパラメータとかでふるまいを簡単に変えられるようになった
- 効率が良かった
- Service Mesh の実現にも一部使われている
組み込めるのは分かったけれども、どのように組み込むかというのを考えたときに、プログラミング言語そのものを組み込むのは大変
- メモリ管理をしっかりすること
- パフォーマンスを出すこと
- 使いやすいかというのも大きな柱として注力するべきところ
- マルチスレッド、マルチプロセスはあたりまえ
- それをどう組み込むのかは試行錯誤したところ。
マルチスレッドの話
- やっぱりミドルウェアなので、接続数を多くさばけるかということ
- ネイティブスレッドでどれだけ並列性を実現できるかが気になる
- mrb_state の中で、thread を起こすと動かない
- bytecode の中で thread を動かすのも動かない
- thread 単位で mrb_stateを動かすというのが基本的な考え方
- threadを作ってちゃんと排他処理をするとうまく動く
- ロックとかが大変。
- 基本的に thread 単位で mrb_state を作るのが単純でやりやすい
mrb_state を作る mrb_open という関数があるけど、処理も重たい処理
- thread を作るよりも余裕で重たい
- その中で mrb_state を作るのはとても重たい
- 新たにプロセスを作っているくらいそれなりに重たい
- 状況とか考える必要がある
- できれば、ミドルウェアという観点では、起動してワーカープロセスとかリクエストとかを処理するのが基本
- サービスを起動したあとは、あまり mrb_state をいじりたくない
- 以降のデザインパターンでもいろんなミドルウェアに組み込んだり、自分でミドルウェアを作ったりしてきた
- 初期化とか、メモリ管理とか、性能とか使いやすさの観点でデザインパターンを紹介する
init/exit
- init: independent mrb_state
- マスタプロセスがワーカプロセスを複数起動するのが基本モデル
- ワーカプロセスの処理に初期化時に DB 接続をしたいとかあったとして、
- 実行時に mrb_state を作って、コンパイルして走らせる、とか
- スクリプト単位で mrb_state を作って、コンパイルして実行するパターン
- 初期化の段階で freeze して実行する
- メリット
- mruby のスクリプト独立して実行できる
- 毎回メモリも開放されるので、メモリも節約できる
- デメリット
- リクエストのタイミングでオブジェクトを渡すといことができない
- 安全ではあるけど、安全すぎてユーザビリティは低い。使いにくい。
- init: independent mrb_state
init: share mrb_state
- mrb_state の共有
- worker init の実行が終わったら解放される
- これも mrb_state のコストは下がっている
- メリット
- メモリを節約できる。省エネ。きれいにできる
- init の中でほかのスクリプトのオブジェクトを共有できる
- デメリット
- リクエスト時にオブジェクトを渡すことができない
init: share mrb_state with request phase
- mrb_state を作って、ワーカをバイトコードまでコンパイルしたらこのままにしておく。
- リクエストで実行する
- exit のときに mrb_state の掃除をする
- こういう組込みの設計にすると、mrb_state をリクエストのたびに作る必要はない
- オブジェクトをリクエストフェーズに持っていくことができる
- メリット
- スクリプトごとに mrb_state を作るコストを削減できる
- init フェーズに他のスクリプトのオブジェクトを共有できる
- リクエストフェーズでオブジェクトを渡すことができる
- デメリット
- メモリ管理は大変
each request : independent mrb_state
- マルチプロセス
- mruby スクリプトは完全に独立して、実行
- メリット
- mrb_state をリクエストのたびに確保が必要になる
- バイトコードを作って、それから処理することになる
- 安全、シンプル。メモリ管理が簡単。
- スクリプトの変更がすぐに反映される。便利
- mrb_state をリクエストのたびに確保が必要になる
- デメリット
- パフォーマンスが悪い。遅すぎる
- メリット
- マルチプロセス
- mruby スクリプトは完全に独立して、実行
each request : share mrb_state
- オブジェクトを共有できる
- 起動が重たいミドルウェアがあったときに、スクリプトを変えるとすぐに反映できる
- 毎回コンパイルするのは mrb_state ほどではないけど、遅い
- 毎回コンパイルする処理があると、そこが必ずボトルネックになる
- メモリ管理がめちゃくちゃ大変
- かなりやったけど、何かしらリークするというのがある
- 最終、初期化の段階で、mrb_state 発行して、バイトコードも準備しておいて、
- レスポンスを返したら、そのコンテキストだけを開放したりする。
- オブジェクトを管理している areana の中の解放を行う
- バイトコードのコンパイルなどは実行中はしない、という考え方
- high performance で動作するという利点がある
- スクリプトを変えても反映されず、ずっと同じままになる
ベンチマーク
- 都度、コンパイルしていると 46_000 req/s
- 単に index.html 52_000 req/s
- キャッシュを使うと 65_000 req/s
- mrb_state を毎回確保すると、15_000 も出ない
さっきプロセス単位でやっていたのと同じことをスレッド単位でやればいい
- スレッド単位でいっぱいやっているという感じ
まとめ
- each request pattern
- multi process model- mod_mruby, ngx_mruby, postfix-mruby, dovecot-mruby, trusterd HTTP/2 server
- multi threading model
- share mrb_state and bytecode
- H2O, nghttpd
- independent mrb_state
- pmilter
- share mrb_state and bytecode
- each request pattern
non-blocking middleware
- できる限りシングルCPU で遠くのリクエストを同時処理できるようにする
- ブロックするファイルIO とかネットワークIO とか
- そういうのをノンブロッキングモードで実行して、かつその状態を監視しつつ、通知もらっている間は
- マルチプロセス、マルチスレッドで、プロセス1つでノンブロッキングな処理をやっている
- epoll とか使ったイベントループとかは、監視しているのが一般的
- 単純に mruby を組み込んでしまうと、mruby を実行しているタイミングは必ずプロックされる
- せっかくミドルウェアがノンブロックの処理に対応しているのに、その部分だけ実行されるとそこがオーバーヘッドになるという問題がある
- mruby の処理が長くなればなるほど、最初と最後の差が広がっていく
- mruby の処理が短ければ工夫は不要だが、長い場合は大変。
- sleep 3 する場合のデモ
- ブロッキングの処理は元のミドルウェアに処理を戻すということを考える
- そうすると全部 3秒で返ってくるということが実現できる
- そうする設計パターンというのを紹介する
- ノンブロックモードでメソッドが実行されたら、一時停止させる
- ミドルウェアのイベントループに戻してやる
- ブロッキングの処理が完了したら、resume するようなコールバックをさせてやる
- Fiber を使って、必要なときだけ Ruby の世界に渡す様に設計する
- Fiber を毎回作成しなおすのではなく、最初の状態にリセットできるような API があるとベンリ
結論
- ミドルウェアを mruby に組み込む際のデザインパターンについて発表
TRICK 2018 FINAL Results
- へんな Ruby プログラムで競うコンテスト
- 今回は第3回で最後のコンテスト
- Most warned
- static warning が 10個
- daynamic warning が 10個
- どうやっても出せない warning がいくつかあった
最小の irb 実装
Most Attractive
- クリスマスツリーの 3D レンダリングをターミナルで実現
Best double meaning
- unicode space を有効活用
- スペースを有効活用することで見栄えと動作を分離
Most (un)readable
- 日本語で記述された Quine
Best Compiler
- Brainf*ck から Ruby へのコンパイラ
- コマンドライン引数あれば、コンパイラとして動作する
Most composable
- ライフゲームのセルとして動くプログラム
Most reversible
- 逆順ソートの逆順ソース
Mostthree-dimentional
- 回転体の 3Dモデルを作るDSL
Best One Liner
- テストフレームワーク
Best png viewer
- ascii アートで表示する ping viewer
Best Spiral
- らせん状に出力する Quine
- 空気を読まずに1位をとりにいった
Most reserved
- 予約語だけで構成されたエラーにならないプログラム
- 1行に6個の予約語