Ruby 3.3でYJITを今すぐ有効にすべき理由

Ruby 3.3がリリースされた。YJITには非常に多くの改善が含まれたリリースだったが、 NEWS解説記事リリースパーティーでは 2点しか触れられなかったので、この記事ではRuby 3.3でYJITがどう改善されたかについて解説する。

YJITは既に実用段階

YJITはRuby 3.1で導入されたが、Ruby 3.2の時点でexperimentalのマークが外れ、実用段階となった。 Ruby 3.2では、以下のような企業で性能改善が報告された。

弊社Shopifyで最もトラフィックが多いアプリでは、 Ruby 3.2の時点でのYJITによる高速化は10%程度だったが、 Ruby 3.3では17%高速化まで改善した。 YJITを本番で使っている全ての人にRuby 3.3へのバージョンアップをお勧めしたい。

我々はRuby 3.3.0のリリースを安定化させるべく、リリース前からRuby masterを本番のモノリスに全台デプロイしていた。 現在はリリース版Ruby 3.3.0が走っており、YJITも有効になっているが、Ruby masterを使っていた段階で数多くのバグを弊社が発見・修正したため、 Ruby 3.3.0は比較的安定したリリースになっているはずである。

YJIT本番運用のための手引き

ここまで読んで「YJIT使うぞ!」となって使ってみたが思うように性能が改善しなかった人のために、 本番運用のためのドキュメントへのリンクを貼っておく。

Ruby 3.3で本番運用に便利なツールが増えたため、バージョンごとに少し内容の異なるドキュメントを管理している。 Ruby 3.2時点での日本語の記事としては YJITの性能を最大限引き出す方法 がある。 Ruby 3.3で便利になった点は以下で解説する。

Ruby 3.3のYJIT改善点

前置きが長くなったが、ここからNEWSで触れられている改善点について解説していく。 運用方法に影響がある点から順に書いていく。

Code GCのデフォルト無効化

YJITが生成するコード量は --yjit-exec-mem-size でコントロールできる (デフォルト64MiB)。 生成コードのサイズが --yjit-exec-mem-size に達すると、YJITはデフォルトで以下のような動きをする。

  • Ruby 3.2: 全ての生成コードを破棄し、以降呼ばれたメソッドをコンパイルし直す。
  • Ruby 3.3: 新たにメソッドをコンパイルしなくなる。未コンパイルのメソッドはインタプリタ実行される。

Ruby 3.2のこの挙動をCode GCと呼んでいる。 数時間に一回Code GCが走る程度なら大した性能影響はないのだが、 --yjit-exec-mem-size が小さすぎると、頻繁にコンパイルし直すコストによってアプリがむしろ遅くなる場合がある。

こういった問題にヒットしにくくなるよう、Code GCはデフォルトで無効になった。 これの最大の利点は気軽に --yjit-exec-mem-size が下げられるようになった点で、 RubyVM::YJIT.runtime_stats[:code_region_size] を参考にしつつ、 --yjit-exec-mem-size=32 のような設定を使うのが現実的な選択肢になった。

もう一つの利点にCopy on Write フレンドリになる点がある。 弊社ではモダンなUnicornフォークであるPitchforkを使っているが、 これは既にリクエストを捌いているワーカーを定期的にフォークし直すことでプロセス間のメモリ共有を目指すリフォーキングという機能が備わっている。 リフォーク対象のサーバーが既にYJITのコンパイルを停止済みなら、 YJITが使うメモリは全ワーカー間で共有し続けられることになる。

RubyVM::YJIT.enable の追加

YJITはこれまでコマンドライン引数 --yjit や環境変数 RUBY_YJIT_ENABLE=1 で有効化するしかなかったが、 それらを使わずとも、Rubyコード内で RubyVM::YJIT.enable を呼び出すだけでYJITが有効化できるようになった。

開発中のRails 7.2ではこれを呼び出すイニシャライザがデフォルトで生成されるようになった。 つまりRailsではこれを使ってYJITがデフォルト化されたということになる。

もう一つの利点は、YJITの起動を遅延させることで、 アプリ初期化後は使われないコードのコンパイルを避けメモリ消費量を削減できる点である。 Railsのイニシャライザでも効果はあるが、理想的にはUnicornのafter_forkやPumaのafter_worker_forkから呼び出すと良い。 これにより、起動するワーカーの半分だけYJITを有効化し、インタプリタと性能を比較する基盤として利用することもできる。

なお、--yjit-exec-mem-size などのチューニングオプションも指定するだけでも起動時にYJITが有効化されるため、 その場合も遅延起動するには --yjit-disable を明示する必要がある。

一部YJIT statsのデフォルト提供

RubyVM::YJIT.runtime_stats[:yjit_alloc_size] がデフォルトで提供されるようになった。 これはYJITがRustのヒープにアロケートしているメタデータのサイズで、:code_region_size と合わせると、 YJITが使っているメモリをバイト単位で監視できる。 YJITを運用する際は、この2つだけでも見ておくと --yjit-exec-mem-size のチューニングの参考になる。

また、RubyVM::YJIT.runtime_stats[:ratio_in_yjit]--yjit-stats 時にデフォルトのビルドでも提供されるようになった。 これはRuby VM上で実行される命令のうち何%がYJITで実行されたかを示すもので、 理想的には最大99%くらいが望ましいが、アプリによってはこれが平均90%とかでも18%高速化したりする。 速度を妥協して --yjit-exec-mem-size を下げる場合は、これがあまり下がり過ぎないように気をつけると良い*1

NEWSにはないが RubyVM::YJIT.runtime_stats[:compile_time_ms] も追加されており、デフォルトで使える。 これはYJITがコンパイルに使った累計時間を出すもので、例えばリクエスト前後で呼んで差を取ると、 そのリクエストでのYJITのコンパイルのオーバーヘッドを見ることができる。GC.stat(:time) に似ている。

YJITのメモリ使用量の削減

Ruby 3.2の時点でRustの省メモリ化の努力はあったが、 Ruby 3.3でもRcのかわりにBoxを使ったり、ひとつのu8に様々な意味のビットを詰めまくるといったチューニングが行なわれ、 YJITが使うメタデータのサイズは大幅に小さくなった。 Ruby 3.2とRuby 3.3で :code_region_size が同じ程度であれば、:yjit_alloc_size にあたる部分は大きく削減されるはずである。

それから、--yjit-cold-threshold という概念が追加され、あまり使われないメソッドのコンパイルをスキップするようになった。 また、--yjit-call-threshold がデフォルトで30なのが、メソッドやブロックが4万以上あると120に自動で引き上げられるようになった。 これにより、コンパイルしてもあまり性能に貢献しないメソッドがコンパイルされなくなり、メモリ使用量が節約される。

高速化

Ruby 3.2の時点では、YJITがコンパイルに対応していないパスがそこそこあり、 --yjit-exec-mem-size が十分でも :ratio_in_yjit が90%程度に留まることがあった。 記事が長くなってしまったので詳細は別の機会に語るが、 Ruby 3.3ではこれらの問題をほぼ解決し、ほとんどのアプリで :ratio_in_yjit が99%に達するようになった。 Ruby 3.2だと運悪くYJITの対応率が低かったアプリでも、Ruby 3.3なら速くなることが期待できる。

あとは、特別な最適化が実装されたCメソッドの数が増えており、 NEWSで言及している奴のことだが、 それらはインライン化もされる。 また、単に即値を返すRubyメソッドのインライン化も実装され、具体的にはRailsのblank?とかがmov命令一発になるのだが、 present?の方もRails 7.2では同じ最適化が期待できるようになった

まとめ

あなたとYJIT、今すぐ RUBY_YJIT_ENABLE=1

参考文献

*1:--yjit-stats のかわりに RubyVM::YJIT.enable(stats: true) でもstatsが有効化できるので、これを使ってPumaやUnicornのワーカーのうち1つだけstatsを有効にしておくと、比較的楽にratio_in_yjitが監視できて便利かもしれない。